Compare commits

..
3 Commits
Author SHA1 Message Date
James Wampler 7df1fffdca Tweaks to Import
CI / deploy (push) Has been skipped
CI / build-and-push (push) Failing after 37s
2026-08-20 14:21:44 -07:00
James Wampler 1423977ed4 Modernize web client: dark phase-driven theme, sidebar shell, global agent panel
Replaces the warm-paper/serif look with a dark palette where the novel's
lifecycle phase drives the accent color app-wide. Sidebar nav replaces the
old header/tab-bar. Dashboard leads with quick actions (new chapter, new
character, continue writing) instead of just showing history. Agent chat
is now a context-aware slide-out panel reachable from any page in a novel,
replacing the buried /agent tab.
2026-08-20 14:21:44 -07:00
James Wampler 661f2917ea Add zip upload and mapped-path picker for outline import
Sandbox source paths under a configured Imports:RootPath, browse it
from the web dialog, upload a zip that extracts into staging, and
import a single markdown file (agent infers chapter vs character).
2026-08-20 14:21:44 -07:00
42 changed files with 1750 additions and 323 deletions
+3
View File
@@ -12,6 +12,9 @@
*.env
.env.deploy
# Local outline drop-box for the import feature (Imports:RootPath)
/imports/
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
+1
View File
@@ -103,5 +103,6 @@ Several real bugs here — SQLite refusing ORDER BY DateTimeOffset, agent's mode
- Anthropic model id lives in `appsettings.json` under `Agent:Model`. Don't hardcode.
- API key comes from `ANTHROPIC_API_KEY` or `Agent:ApiKey` — never commit one. App must stay fully usable without key; only agent endpoints require it.
- EF migrations: `dotnet ef migrations add <Name> -p src/Novelly.Api -o Data/Migrations`. API migrates on boot.
- Outline import root lives in `appsettings.json` under `Imports:RootPath` (`Imports__RootPath` env var). When set, it's the only folder the browse/upload import endpoints and the source picker can reach; unset, those endpoints are disabled and the dialog falls back to a typed path with no sandbox. Created at boot if missing.
- `git push` runs `scripts/ci/prepush.sh` through Husky: build, test, then web build. Run `npm install`
once at repo root to install hook.
+1
View File
@@ -15,6 +15,7 @@ services:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
Agent__Model: claude-sonnet-5
Agent__Effort: high
Imports__RootPath: /data/imports
volumes:
- /mnt/storage/apps/novelly/data:/data
networks:
@@ -0,0 +1,48 @@
# Frontend Modernization — Output
Implemented per `docs/plans/web/frontend-modernization_plan.md`, all 5 chunks.
## Chunk 1 — Design tokens + primitives
- `src/index.css`: full token rewrite. Dark-first palette (`--canvas`, `--surface`, `--surface-sunken`, `--ink`, `--ink-muted`, `--line`), violet `--accent` default, light-mode override via `prefers-color-scheme` + `data-theme`.
- Five-stage color ramp `--stage-1..5` (violet → blue → coral → teal → gold), shared by `NovelPhase` and `DraftStatus` via `src/api/stage.ts` (`novelPhaseColor`, `draftStatusColor`) — both are 5-step progressions, one hue system backs both.
- Fonts self-hosted via `@fontsource-variable/*` (no CDN dep): Fraunces (display), Inter (UI), Source Serif 4 (prose/markdown), JetBrains Mono (utility). Imported in `src/main.tsx`.
- `src/components/ui.tsx` primitives (`.card`, `.btn`, `.input`, `StatusBadge`, etc.) rebuilt on the new tokens.
## Chunk 2 — Sidebar shell
- `src/pages/NovelLayout.tsx` rebuilt: left sidebar (wordmark, novel title, phase pill, icon nav) replaces the old header + horizontal tab bar. Kills the old back-link-next-to-title layout — top bar is now just a breadcrumb.
- New `src/components/icons.tsx` — small hand-written inline SVG icon set (no icon library dependency).
- **Signature element**: novel's `phase` sets `--accent`/`--accent-soft` for the whole layout, scoped via inline style on the layout root. Nav active state, buttons, focus rings, phase pill all recolor together when phase changes.
- Bug fixed during build: breadcrumb section-matching used a suffix `startsWith` check that broke on exact segment matches (`chapters` vs `chapters/`) — replaced with explicit segment split/compare.
## Chunk 3 — Dashboard rebuild
- `src/pages/DashboardPage.tsx`: quick-actions row now leads the page — **New chapter** (creates + jumps into the editor), **New character** (reuses the add-character modal, now exported from `CharactersPage.tsx`), and **Continue writing** (jumps to the most-recently-updated chapter, or **View chapters** if nothing's drafted).
- Brainstorming phase gets its own pair above the notes field: **Add a character** / **Move to outlining**.
- Renamed `OutliningDashboard``WorkDashboard` (it covers Outlining/Writing/Editing/Complete, not just Outlining — old name was misleading).
- History content (activity graph, recent chapters/characters, tag cloud) unchanged, just repositioned under the new hero row.
## Chunk 4 — Global agent panel
- `/agent` route and `pages/AgentPage.tsx` retired.
- New `src/components/AgentPanel.tsx`: fixed slide-out drawer mounted in `NovelLayout` (shell level), reachable from every page in a novel via the sidebar "Agent" toggle or `g a`. Non-modal — background stays interactive.
- **Context-aware**: panel shows "Talking about {X}" — resolves to the specific chapter/character title on detail pages, falls back to section name elsewhere. Each outgoing message gets a `Context: {label}` line prepended (server has no route awareness, so this is how the agent learns what page you're on); stripped back out and shown as a small "re: …" tag on render rather than raw text in the transcript.
- Compacted the old two-pane (sidebar list + chat) layout into a single column with a conversation-switcher dropdown — panel width doesn't fit a full list rail.
- No backend changes — works within the existing `SendAgentMessageRequest` shape.
## Chunk 5 — Polish
- Global `:focus-visible` ring via `box-shadow` (not `outline`, to avoid clobbering `TagColorPicker`'s outline-based selection indicator or `.input`'s own focus ring). Ring color follows the phase accent.
- Global `prefers-reduced-motion: reduce` override (`!important` on `animation-duration`/`transition-duration`/`scroll-behavior`) — neutralizes the agent panel's slide transition too, since author `!important` beats a normal-priority inline style in the cascade.
- Fixed two leftover hardcoded `#9a4a2f` (old terracotta accent) defaults in `TagColorPicker.tsx` and `TagsPage.tsx` → new violet `#7c5cff`.
- Audited remaining pages (Settings, Locations, Tags, Characters, Chapters) — all inherit cleanly from the chunk-1 primitives already, no stale styling found.
## Verification
Every chunk built clean (`npm run build`) and was clicked through live in Chrome against a local API + SQLite instance — login/signup, novel creation, phase switching (confirmed accent recolor live: violet → blue → coral), chapter/character creation flows, agent panel open/close/context-swap across navigation, keyboard focus ring.
## Deferred / not done
- Sidebar collapse toggle — mentioned in the original plan's layout description ("persistent, icon+label, collapsible") but never implemented; flagged as deferred in chunk 2 and again in chunk 5. Would need its own pass (collapsed-width icon rail, persisted preference).
- No backend/API changes anywhere in this arc — all five chunks were frontend-only.
@@ -0,0 +1,41 @@
# Frontend Modernization Plan
## Design direction
Drop warm-paper/serif "manuscript" look — reads dated, low-contrast, single dull accent. New identity: **phase-driven color**. Novelly already models a novel's lifecycle as phases (`Brainstorming → Outlining → Drafting → Revising → Final`, see `novelPhases`, `StatusBadge` tones). Make that real data drive the whole app's mood instead of hiding in a badge — the active novel's phase sets an accent hue across nav, buttons, focus rings, charts. Writer sees at a glance "I'm in draft mode" vs "polishing." Distinctive, grounded in the product's own model, not decoration.
### Tokens
Color (base neutrals, dark-first):
- `--ink: #14121a` / `--ink-muted: #8b859a`
- `--surface: #1b1825` (panel/card) / `--surface-sunken: #100e17`
- `--canvas: #0c0a12` (app background)
- `--line: #2c2838`
- Light mode mirrors with `--canvas:#f7f6fb`, `--surface:#ffffff`, `--ink:#14121a`
Phase accents (used for `--accent` + `--accent-soft`, swapped by `novel.phase`):
- Brainstorming — `#7c5cff` violet
- Outlining — `#2f8fe0` blue
- Drafting — `#ff7a45` coral
- Revising — `#14b88a` teal
- Final — `#d9a404` gold
Type:
- Display (headlines, dashboard hero, page titles): **Fraunces** — variable serif w/ real character, used large/sparingly
- UI (nav, buttons, body chrome): **Inter**
- Prose editing (chapter/beat text, agent transcript): keep a serif for long-form reading — **Source Serif 4** replaces Iowan/Palatino (renders consistently, not Mac-only)
- Utility/data (counts, timestamps, mono bits): **JetBrains Mono**
Layout: left sidebar nav (persistent, icon+label, collapsible), agent as a right-docked slide-out panel triggered from anywhere (sidebar icon, always visible), main content full-bleed under a slim top bar (breadcrumb + phase pill + user menu — no more "← Novels" link floating left of the title).
Signature element: the phase-accent system itself — nav active states, primary buttons, focus rings, and the dashboard's activity graph all recolor together when phase changes. Nothing else in the app competes for boldness; everything else stays a disciplined dark neutral.
## Chunks (each independently buildable/committable)
1. **Design tokens + primitives** — rewrite `index.css` theme (colors, fonts incl. `@font-face`/Google Fonts imports, spacing), update `ui.tsx` primitives (`btn`, `card`, `input`, `StatusBadge`) to new tokens. No layout changes yet — existing pages just reskin. Fastest way to see the new palette/type everywhere at once.
2. **App shell: sidebar nav** — replace `NovelLayout`'s header+tab-bar with left sidebar (novel switcher, section nav, phase pill), slim top bar. Fixes the back-link-left-of-title complaint structurally. Agent gets a nav icon but no panel yet (still routes to `/agent` page).
3. **Dashboard rebuild** — make it the true home: recent activity + work history (already there) alongside prominent "start new work" actions (new chapter, new character, continue last chapter) above the fold. This is the biggest content/layout change, isolated to one page.
4. **Global agent panel** — extract `AgentPage`'s chat UI into a slide-out panel mounted at the app shell level (outside `<Outlet>`), triggered from the sidebar on any route, passes current route/entity as context. Retire the standalone `/agent` route once panel covers it.
5. **Polish pass** — motion (panel slide, nav active-state transitions, dashboard load-in), empty states, focus-visible/reduced-motion audit, remaining pages (Characters/Chapters/Tags/Locations/Settings) get spacing/type touch-ups to match new primitives from chunk 1.
Suggest reviewing after each chunk before starting the next — chunk 2 and 4 both touch navigation/shell so seeing 12 landed first will make it obvious if the sidebar direction is right before the agent panel builds on top of it.
@@ -94,8 +94,11 @@ public static class NovellyServiceRegistration
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
services.Configure<ImportOptions>(configuration.GetSection(ImportOptions.SectionName));
services.AddSingleton(Channel.CreateUnbounded<Guid>());
services.AddScoped<ImportService>();
services.AddScoped<ImportBrowseService>();
services.AddScoped<ImportZipExtractor>();
services.AddScoped<ImportAgentToolset>();
services.AddScoped<ImportAgentService>();
services.AddHostedService<ImportJobRunner>();
+65 -1
View File
@@ -23,7 +23,9 @@ public class ImportAgentService(
toolset.Initialize(sourceRoot, existingNovelId);
var startingLedger = toolset.ReadLedgerOrNull();
var systemPrompt = BuildSystemPrompt(sourceRoot);
var systemPrompt = ImportPaths.IsSingleFileSource(sourceRoot)
? BuildSingleFileSystemPrompt(sourceRoot)
: BuildSystemPrompt(sourceRoot);
var transcript = new List<AgentChatMessage>
{
@@ -105,6 +107,9 @@ public class ImportAgentService(
private static string BuildSystemPrompt(string sourceRoot) => SystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot);
private static string BuildSingleFileSystemPrompt(string sourceRoot) =>
SingleFileSystemPromptTemplate.Replace("{{SOURCE_ROOT}}", sourceRoot);
private const string SystemPromptTemplate = """
You import a novel outline that already exists as markdown files on disk into this
app's novel data. You are running unattended nobody will read your replies or
@@ -199,4 +204,63 @@ public class ImportAgentService(
- If a tool call fails, stop that item and move on rather than retrying blindly
the ledger stays at the last successful write either way.
""";
private const string SingleFileSystemPromptTemplate = """
You import a single outline file that already exists as markdown on disk into this
app's novel data. You are running unattended nobody will read your replies or
answer questions mid-run, so make the judgment calls yourself and record anything
genuinely ambiguous rather than stalling on it.
Your tools give you exactly two things: read-only access to the file under the
import source folder, and application tools that create the novel's chapters,
characters, beats and arcs. You cannot write or edit anything on disk except the
resume ledger, and you cannot read anything outside the source folder.
## Source file
The source root `{{SOURCE_ROOT}}` holds exactly one markdown file. Call
list_source_files to find its name, then read_source_file to read it. Decide what
kind of document it is before doing anything else:
- If it reads like a chapter outline (`# Chapter NN`, one or more summary
paragraphs, a beat table `| Beat | Character | What | Why |`) treat it as a single
chapter.
- If it reads like a character dossier (`# Name`, an italic tagline,
`## Appearance`, `## Background`, `## Motivation`) treat it as a single character.
`**Thread:**` (chapter files only) may name one character, several, or a character
plus a qualifier only auto-create an undossiered name from it when it names
exactly one clear proper name.
## The ledger
Before writing anything, call read_ledger. If it returns `{{}}`, this is a fresh
run. Call write_ledger with the full, updated ledger after every successful write.
## Passes
1. **Novel** skip if "novel" is in completedPasses or a novel id was already
supplied. Otherwise create one from whatever title/author information the file
gives, or a sensible placeholder title drawn from the file name if none is
present. Record novelId, mark "novel" done.
2. **The document** skip if already recorded. If it is a chapter: auto-create a
character stub (name only) for any single, unqualified name in the Thread or a
beat's Character column that isn't in the ledger yet, then create_chapter with
title, number (1 unless the file states otherwise), summary, and tags, then
create_beat for each table row with resolved character_ids. If it is a
character: create_character with occupation from the tagline and
appearance/backstory/want from Appearance/Background/Motivation; if it has a
`## Events` section, also update_character(importance: "Main") and add_arc_stage
for each bullet. Mark "characters", "chapters", and "arcs" all done once you've
handled the one document this run only ever has one item to place.
## Constraints
- Never invent plot content or character detail, and never guess which of several
candidate names an ambiguous reference means.
- Never write to disk except via write_ledger.
- Never call a create tool for something the ledger already records.
- If a tool call fails, stop and record what you have the ledger stays at the
last successful write either way.
""";
}
@@ -0,0 +1,81 @@
using Microsoft.Extensions.Options;
namespace Novelly.Api.Imports;
public class ImportBrowseService(IOptions<ImportOptions> options, ILogger<ImportBrowseService> logger)
{
private readonly ImportOptions _options = options.Value;
public string? RootPath => _options.RootPath;
public ImportBrowseResponse List(string? relativePath)
{
var root = RequireRoot();
logger.LogInformation("Browsing import root at {RelativePath}", relativePath ?? "");
var target = string.IsNullOrWhiteSpace(relativePath) ? root : ImportPaths.ResolveWithin(root, relativePath);
if (!Directory.Exists(target))
throw new ArgumentException($"'{relativePath}' does not exist or is not a directory.", nameof(relativePath));
var normalizedRelative = Path.GetRelativePath(root, target).Replace(Path.DirectorySeparatorChar, '/');
if (normalizedRelative == ".")
{
normalizedRelative = "";
}
var parent = normalizedRelative == "" ? null : Path.GetRelativePath(root, Path.GetFullPath(Path.Combine(target, ".."))).Replace(Path.DirectorySeparatorChar, '/');
if (parent == ".")
{
parent = "";
}
var entries = Directory.EnumerateFileSystemEntries(target)
.Select(BuildEntry)
.Where(e => e is not null)
.Select(e => e!)
.OrderByDescending(e => e.IsDirectory)
.ThenBy(e => e.Name, StringComparer.Ordinal)
.ToArray();
return new ImportBrowseResponse(normalizedRelative, parent, entries);
ImportBrowseEntry? BuildEntry(string path)
{
var name = Path.GetFileName(path);
if (name.StartsWith('.'))
{
return null;
}
var isDirectory = Directory.Exists(path);
if (!isDirectory && !name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var entryRelative = Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/');
var markdownCount = isDirectory ? ImportPaths.CountChapterFiles(path) : 0;
var looksImportable = isDirectory
? File.Exists(Path.Combine(path, "outline.md")) || markdownCount > 0
: true;
return new ImportBrowseEntry(name, entryRelative, path, isDirectory, markdownCount, looksImportable);
}
}
private string RequireRoot()
{
if (string.IsNullOrWhiteSpace(_options.RootPath))
throw new InvalidOperationException("No import root is configured (Imports:RootPath).");
var full = Path.GetFullPath(_options.RootPath);
Directory.CreateDirectory(full);
return full;
}
}
public record ImportBrowseEntry(string Name, string RelativePath, string SourceRoot, bool IsDirectory, int MarkdownFileCount, bool LooksImportable);
public record ImportBrowseResponse(string RelativePath, string? ParentRelativePath, IReadOnlyList<ImportBrowseEntry> Entries);
@@ -57,6 +57,8 @@ public class StartImportRequestValidator : IModelValidator<StartImportRequest>
}
}
public record ImportUploadResponse(string SourceRoot, string RelativePath, int MarkdownFileCount);
public static class ImportMapping
{
public static ImportJobResponse ToResponse(this ImportJob job) => new(
@@ -28,6 +28,24 @@ public static class ImportEndpoints
(await service.GetStatusAsync(id, ct))?.ToResponse().ToApiResult())
.WithSummary("Poll an import job's progress.");
imports.MapGet("/browse", (string? path, ImportBrowseService browse) =>
Results.Ok(browse.List(path)))
.WithSummary("List entries under the configured import root, for the source picker.");
imports.MapPost("/upload", (IFormFile file, ImportService service) =>
{
if (!file.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Only .zip files can be uploaded.", nameof(file));
if (file.Length == 0)
throw new ArgumentException("The uploaded file is empty.", nameof(file));
using var stream = file.OpenReadStream();
return Results.Ok(service.UploadZip(stream, file.FileName));
})
.DisableAntiforgery()
.WithSummary("Upload a zip of an outline folder and stage it under the configured import root.");
return app;
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Novelly.Api.Imports;
public class ImportOptions
{
public const string SectionName = "Imports";
public string? RootPath { get; set; }
}
+36 -3
View File
@@ -13,6 +13,7 @@ public record ImportLedger(
internal static class ImportPaths
{
private const string LedgerFileName = ".novelly-import.json";
public const string StagingFolderName = ".novelly-staging";
private static readonly JsonSerializerOptions LedgerOptions = new()
{
@@ -20,7 +21,7 @@ internal static class ImportPaths
WriteIndented = true
};
public static string ResolveRoot(string sourceRoot)
public static string ResolveRoot(string sourceRoot, string? importRoot = null)
{
if (string.IsNullOrWhiteSpace(sourceRoot))
throw new ArgumentException("'Source Root' must not be empty.", nameof(sourceRoot));
@@ -38,25 +39,57 @@ internal static class ImportPaths
if (!Directory.Exists(full))
throw new ArgumentException($"'{full}' does not exist or is not a directory.", nameof(sourceRoot));
EnsureWithinImportRoot(importRoot, full, sourceRoot);
return full;
}
public static void EnsureWithinImportRoot(string? importRoot, string candidate, string originalInput)
{
if (importRoot is not null && !IsWithin(importRoot, candidate))
throw new ArgumentException($"'{originalInput}' is outside the configured import root.", nameof(originalInput));
}
public static bool IsSingleFileSource(string root)
{
if (Directory.EnumerateDirectories(root).Any())
{
return false;
}
return Directory.EnumerateFiles(root, "*.md", SearchOption.TopDirectoryOnly).Count() == 1;
}
public static string ResolveWithin(string root, string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
throw new ArgumentException("Path must not be empty.");
var combined = Path.GetFullPath(Path.Combine(root, relativePath));
var relativeToRoot = Path.GetRelativePath(root, combined);
if (relativeToRoot.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relativeToRoot))
if (!IsWithin(root, combined))
throw new ArgumentException($"'{relativePath}' escapes the import source folder.");
return combined;
}
private static bool IsWithin(string root, string candidate)
{
var relativeToRoot = Path.GetRelativePath(root, candidate);
return relativeToRoot == "." || !relativeToRoot.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relativeToRoot);
}
public static string LedgerPath(string root) => Path.Combine(root, LedgerFileName);
public static string StagingRoot(string importRoot) => Path.Combine(importRoot, StagingFolderName);
public static string SanitizeForFolderName(string value)
{
var sanitized = new string(value.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray());
sanitized = sanitized.Trim('-', '_');
return string.IsNullOrEmpty(sanitized) ? "import" : sanitized[..Math.Min(sanitized.Length, 60)];
}
public static ImportLedger? ReadLedger(string root)
{
var path = LedgerPath(root);
+61 -2
View File
@@ -1,5 +1,6 @@
using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
@@ -13,10 +14,34 @@ public class ImportService(
NovelService novels,
Channel<Guid> queue,
INovelUserContext userContext,
IOptions<ImportOptions> importOptions,
ImportZipExtractor zipExtractor,
ILogger<ImportService> logger,
IModelValidator<InspectImportRequest> inspectValidator,
IModelValidator<StartImportRequest> startValidator)
{
private readonly string? _importRoot = importOptions.Value.RootPath is { } root ? Path.GetFullPath(root) : null;
public ImportUploadResponse UploadZip(Stream zipStream, string fileName)
{
if (_importRoot is null)
throw new InvalidOperationException("No import root is configured (Imports:RootPath).");
logger.LogInformation("Uploading import zip {FileName}", fileName);
Directory.CreateDirectory(_importRoot);
var stagingDir = Path.Combine(
ImportPaths.StagingRoot(_importRoot),
$"zip-{ImportPaths.SanitizeForFolderName(Path.GetFileNameWithoutExtension(fileName))}-{Guid.NewGuid():N}");
zipExtractor.Extract(zipStream, stagingDir);
var markdownCount = Directory.EnumerateFiles(stagingDir, "*.md", SearchOption.AllDirectories).Count();
var relativePath = Path.GetRelativePath(_importRoot, stagingDir).Replace(Path.DirectorySeparatorChar, '/');
return new ImportUploadResponse(stagingDir, relativePath, markdownCount);
}
public Task<ImportInspectionResponse> InspectAsync(InspectImportRequest request, CancellationToken ct = default)
{
Guard.Null(request, nameof(request));
@@ -24,7 +49,7 @@ public class ImportService(
logger.LogInformation("Inspecting import source {SourceRoot}", request.SourceRoot);
var root = ImportPaths.ResolveRoot(request.SourceRoot);
var root = ResolveSourceRoot(request.SourceRoot);
var ledger = ImportPaths.ReadLedger(root);
var total = ImportPaths.CountChapterFiles(root);
@@ -48,7 +73,7 @@ public class ImportService(
logger.LogInformation(
"Starting import for {SourceRoot}, forceRestart {ForceRestart}", request.SourceRoot, request.ForceRestart);
var root = ImportPaths.ResolveRoot(request.SourceRoot);
var root = ResolveSourceRoot(request.SourceRoot);
if (request.ForceRestart)
{
@@ -88,6 +113,40 @@ public class ImportService(
return job;
}
private string ResolveSourceRoot(string sourceRoot)
{
string full;
try
{
full = Path.GetFullPath(sourceRoot);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
throw new ArgumentException($"'{sourceRoot}' is not a valid path.", nameof(sourceRoot), ex);
}
if (!File.Exists(full))
{
return ImportPaths.ResolveRoot(sourceRoot, _importRoot);
}
if (!full.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"'{sourceRoot}' is not a markdown file or a directory.", nameof(sourceRoot));
ImportPaths.EnsureWithinImportRoot(_importRoot, full, sourceRoot);
var stagingParent = _importRoot is not null
? ImportPaths.StagingRoot(_importRoot)
: Path.Combine(Path.GetTempPath(), "novelly-import-staging");
var stagingDir = Path.Combine(
stagingParent, $"file-{ImportPaths.SanitizeForFolderName(Path.GetFileNameWithoutExtension(full))}");
Directory.CreateDirectory(stagingDir);
File.Copy(full, Path.Combine(stagingDir, Path.GetFileName(full)), overwrite: true);
return stagingDir;
}
public async Task<ImportJob?> GetStatusAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
@@ -0,0 +1,84 @@
using System.IO.Compression;
namespace Novelly.Api.Imports;
public class ImportZipExtractor(ILogger<ImportZipExtractor> logger)
{
private const int MaxEntryCount = 2000;
private const long MaxEntryUncompressedBytes = 10 * 1024 * 1024;
private const long MaxTotalUncompressedBytes = 100 * 1024 * 1024;
private static readonly string[] AllowedFileNames = [".novelly-import.json"];
public void Extract(Stream zipStream, string stagingDirectory)
{
Directory.CreateDirectory(stagingDirectory);
try
{
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read);
var entries = archive.Entries
.Where(e => !string.IsNullOrEmpty(e.Name))
.Where(e => !e.FullName.StartsWith("__MACOSX/", StringComparison.OrdinalIgnoreCase))
.Where(e => AllowedFileNames.Contains(e.Name) || !e.Name.StartsWith('.'))
.ToArray();
if (entries.Length == 0)
throw new ArgumentException("The zip file is empty.");
if (entries.Length > MaxEntryCount)
throw new ArgumentException($"The zip file has too many entries (max {MaxEntryCount}).");
var stripPrefix = FindCommonTopLevelDirectory(entries);
var totalBytes = 0L;
foreach (var entry in entries)
{
var relativePath = stripPrefix is null
? entry.FullName
: entry.FullName[(stripPrefix.Length + 1)..];
if (relativePath.Length == 0) continue;
if (!relativePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase) && !AllowedFileNames.Contains(entry.Name))
throw new ArgumentException($"'{entry.FullName}' is not a markdown file. Only .md files (and .novelly-import.json) are allowed.");
if (entry.Length > MaxEntryUncompressedBytes)
throw new ArgumentException($"'{entry.FullName}' is too large (max {MaxEntryUncompressedBytes / (1024 * 1024)} MB per file).");
totalBytes += entry.Length;
if (totalBytes > MaxTotalUncompressedBytes)
throw new ArgumentException($"The zip file is too large uncompressed (max {MaxTotalUncompressedBytes / (1024 * 1024)} MB).");
var destination = ImportPaths.ResolveWithin(stagingDirectory, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
using var entryStream = entry.Open();
using var fileStream = File.Create(destination);
entryStream.CopyTo(fileStream);
}
logger.LogInformation("Extracted import zip with {EntryCount} entries into staging folder", entries.Length);
}
catch
{
if (Directory.Exists(stagingDirectory))
Directory.Delete(stagingDirectory, recursive: true);
throw;
}
}
private static string? FindCommonTopLevelDirectory(IReadOnlyCollection<ZipArchiveEntry> entries)
{
var topLevelSegments = entries
.Select(e => e.FullName.Split('/', '\\')[0])
.Distinct()
.ToArray();
return topLevelSegments.Length == 1 && entries.All(e => e.FullName.Contains('/') || e.FullName.Contains('\\'))
? topLevelSegments[0]
: null;
}
}
+6
View File
@@ -68,6 +68,12 @@ using (var scope = app.Services.CreateScope())
await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger);
await ActivityBackfill.RunAsync(db, app.Logger);
var importRoot = builder.Configuration.GetSection(ImportOptions.SectionName)[nameof(ImportOptions.RootPath)];
if (!string.IsNullOrWhiteSpace(importRoot))
{
Directory.CreateDirectory(importRoot);
}
}
app.UseSerilogRequestLogging();
@@ -15,5 +15,8 @@
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal"
}
}
},
"Imports": {
"RootPath": "../../imports"
}
}
+3
View File
@@ -35,5 +35,8 @@
},
"UiSettings": {
"ShowPronouns": false
},
"Imports": {
"RootPath": null
}
}
+1
View File
@@ -7,6 +7,7 @@ server {
}
location /api/ {
client_max_body_size 50m;
proxy_pass http://api:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+36
View File
@@ -8,6 +8,10 @@
"name": "novelly-web",
"version": "0.0.0",
"dependencies": {
"@fontsource-variable/fraunces": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/source-serif-4": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@tanstack/react-query": "^5.101.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -27,6 +31,38 @@
"vite": "^8.2.0"
}
},
"node_modules/@fontsource-variable/fraunces": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/fraunces/-/fraunces-5.3.0.tgz",
"integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/source-serif-4": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/source-serif-4/-/source-serif-4-5.3.0.tgz",
"integrity": "sha512-9vch9WqxjaaA+1o9Ur8pOgIGbCYLjRReOUel23A6lOpD1syptgjtkORevvNmldJ5kGXQL29onQUqI5Ltz0s3bQ==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
"integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+4
View File
@@ -10,6 +10,10 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/fraunces": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/source-serif-4": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@tanstack/react-query": "^5.101.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
-2
View File
@@ -8,7 +8,6 @@ import TagsPage from './pages/TagsPage'
import LocationsPage from './pages/LocationsPage'
import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage'
import AgentPage from './pages/AgentPage'
import SettingsPage from './pages/SettingsPage'
import LoginPage from './pages/LoginPage'
import { AuthProvider, useAuth } from './auth/AuthContext'
@@ -44,7 +43,6 @@ export default function App() {
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<NovelsPage />} />
+3 -1
View File
@@ -11,11 +11,12 @@ export class ApiError extends Error {
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const isFormData = init?.body instanceof FormData
const response = await fetch(`${BASE}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...init?.headers,
},
})
@@ -37,6 +38,7 @@ export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
postForm: <T>(path: string, body: FormData) => request<T>(path, { method: 'POST', body }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
+22
View File
@@ -12,9 +12,11 @@ import type {
ConversationSummary,
Beat,
Genre,
ImportBrowse,
ImportInspection,
ImportJob,
ImportJobStatus,
ImportUpload,
OpenQuestion,
LocationReferences,
LocationSummary,
@@ -47,6 +49,7 @@ export const keys = {
conversations: (novelId: string) => ['novels', novelId, 'conversations'] as const,
conversation: (id: string) => ['conversations', id] as const,
importJob: (id: string) => ['imports', id] as const,
importBrowse: (path: string) => ['imports', 'browse', path] as const,
novelActivity: (novelId: string) => ['novels', novelId, 'activity'] as const,
myActivity: ['activity'] as const,
}
@@ -635,6 +638,25 @@ export function useStartImport() {
})
}
export function useImportBrowse(path: string, enabled = true) {
return useQuery({
queryKey: keys.importBrowse(path),
queryFn: () => api.get<ImportBrowse>(`/api/imports/browse?path=${encodeURIComponent(path)}`),
enabled,
retry: false,
})
}
export function useUploadImportZip() {
return useMutation({
mutationFn: (file: File) => {
const form = new FormData()
form.append('file', file)
return api.postForm<ImportUpload>('/api/imports/upload', form)
},
})
}
const terminalImportStatuses: ImportJobStatus[] = ['Completed', 'Failed', 'Paused']
export function useImportJob(jobId: string | undefined) {
+27
View File
@@ -0,0 +1,27 @@
import type { DraftStatus, NovelPhase } from './types'
const stageVar = (index: 1 | 2 | 3 | 4 | 5) => `var(--stage-${index})`
const novelPhaseStage: Record<NovelPhase, 1 | 2 | 3 | 4 | 5> = {
Brainstorming: 1,
Outlining: 2,
Writing: 3,
Editing: 4,
Complete: 5,
}
const draftStatusStage: Record<DraftStatus, 1 | 2 | 3 | 4 | 5> = {
Planned: 1,
Outlined: 2,
Drafted: 3,
Revised: 4,
Final: 5,
}
export function novelPhaseColor(phase: NovelPhase): string {
return stageVar(novelPhaseStage[phase])
}
export function draftStatusColor(status: DraftStatus): string {
return stageVar(draftStatusStage[status])
}
+21
View File
@@ -329,6 +329,27 @@ export interface ImportInspection {
completedPasses: string[]
}
export interface ImportBrowseEntry {
name: string
relativePath: string
sourceRoot: string
isDirectory: boolean
markdownFileCount: number
looksImportable: boolean
}
export interface ImportBrowse {
relativePath: string
parentRelativePath: string | null
entries: ImportBrowseEntry[]
}
export interface ImportUpload {
sourceRoot: string
relativePath: string
markdownFileCount: number
}
export interface ActivityDay {
date: string
words: number
@@ -0,0 +1,196 @@
import { useEffect, useRef, useState } from 'react'
import { useConversation, useConversations, useSendAgentMessage } from '../api/hooks'
import type { AgentMessage } from '../api/types'
import { ErrorNote, Spinner } from './ui'
import { IconPlus } from './icons'
import { useHotkey } from '../keyboard/HotkeysContext'
const starters = [
'Read the brief and tell me what the outline is missing.',
"Draft a three-act skeleton from the logline, then stop so I can react.",
'Look at my protagonist: is the want genuinely in tension with the need?',
]
const CONTEXT_PATTERN = /^Context: (.+)\n\n([\s\S]*)$/
export interface AgentContext {
label: string
}
export function AgentPanel({
novelId,
open,
onClose,
context,
}: {
novelId: string
open: boolean
onClose: () => void
context: AgentContext | null
}) {
const { data: conversations } = useConversations(novelId)
const [conversationId, setConversationId] = useState<string | undefined>()
const { data: conversation } = useConversation(conversationId)
const send = useSendAgentMessage(novelId)
const [draft, setDraft] = useState('')
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open) endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [conversation?.messages.length, send.isPending, open])
const submit = (message: string) => {
const trimmed = message.trim()
if (!trimmed || send.isPending) return
setDraft('')
const withContext = context ? `Context: ${context.label}\n\n${trimmed}` : trimmed
send.mutate(
{ message: withContext, conversationId },
{ onSuccess: (turn) => setConversationId(turn.conversationId) },
)
}
useHotkey('mod+Enter', 'Send message', () => submit(draft), {
group: 'Agent',
allowInInputs: true,
enabled: open && draft.trim().length > 0 && !send.isPending,
})
useHotkey('Escape', 'Close agent', onClose, { group: 'Agent', enabled: open, allowInInputs: true })
return (
<div
id="agent-panel"
className="fixed top-0 right-0 z-30 flex h-screen w-full flex-col sm:w-[26rem]"
style={{
background: 'var(--surface)',
borderLeft: '1px solid var(--line)',
boxShadow: open ? '-12px 0 32px -12px rgba(0,0,0,0.45)' : 'none',
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 220ms ease',
}}
aria-hidden={!open}
>
<header className="flex items-center gap-2 px-4 py-3" style={{ borderBottom: '1px solid var(--line)' }}>
<h2 className="text-sm font-semibold" style={{ fontFamily: 'var(--font-display)' }}>
Agent
</h2>
{conversations && conversations.length > 0 && (
<select
className="input ml-2 w-auto flex-1 py-1 text-xs"
value={conversationId ?? ''}
onChange={(e) => setConversationId(e.target.value || undefined)}
aria-label="Conversation"
>
<option value="">New conversation</option>
{conversations.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
)}
<button
className="btn ml-auto px-2 py-1"
title="New conversation"
onClick={() => setConversationId(undefined)}
>
<IconPlus width={14} height={14} />
</button>
<button className="btn px-2 py-1" onClick={onClose} aria-label="Close agent panel">
</button>
</header>
{context && (
<div className="px-4 pt-3 text-xs muted">
Talking about <span style={{ color: 'var(--accent)' }}>{context.label}</span>
</div>
)}
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
{!conversation && (
<div className="card p-4">
<h3 className="font-semibold">Your writing partner</h3>
<p className="mt-1 text-sm muted">
It can read and edit the brief, the outline, character dossiers and chapter prose
the same data you see elsewhere in the app.
</p>
<div className="mt-3 grid gap-2">
{starters.map((starter) => (
<button key={starter} className="btn justify-start text-left text-sm" onClick={() => submit(starter)}>
{starter}
</button>
))}
</div>
</div>
)}
{conversation?.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{send.isPending && <Spinner label="Thinking" />}
{send.error && <ErrorNote error={send.error} />}
<div ref={endRef} />
</div>
<form
className="flex gap-2 px-4 py-3"
style={{ borderTop: '1px solid var(--line)' }}
onSubmit={(e) => {
e.preventDefault()
submit(draft)
}}
>
<textarea
className="input flex-1 resize-none"
rows={2}
value={draft}
placeholder="Ask about structure, a character's arc, or what the next beat should do…"
onChange={(e) => setDraft(e.target.value)}
/>
<button className="btn btn-primary self-end" disabled={!draft.trim() || send.isPending}>
Send
</button>
</form>
</div>
)
}
function MessageBubble({ message }: { message: AgentMessage }) {
const isUser = message.role === 'User'
const match = isUser ? message.content.match(CONTEXT_PATTERN) : null
const contextLabel = match?.[1]
const content = match ? match[2] : message.content
return (
<div className={isUser ? 'flex justify-end' : ''}>
<div
className={`card max-w-[90%] px-3.5 py-2.5 ${isUser ? '' : 'w-full'}`}
style={isUser ? { background: 'var(--accent-soft)', borderColor: 'transparent' } : undefined}
>
{contextLabel && <div className="mb-1 text-[0.6875rem] muted">re: {contextLabel}</div>}
<div className="prose-serif whitespace-pre-wrap text-[0.9375rem]">{content}</div>
{message.toolCalls.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-xs muted">
{message.toolCalls.length} change
{message.toolCalls.length === 1 ? '' : 's'} made
</summary>
<ul className="mt-2 grid gap-2">
{message.toolCalls.map((call, index) => (
<li key={index} className="rounded-md p-2 text-xs" style={{ background: 'var(--surface-sunken)' }}>
<div className="font-mono font-semibold">{call.name}</div>
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-words opacity-70">
{call.input}
</pre>
</li>
))}
</ul>
</details>
)}
</div>
</div>
)
}
@@ -1,8 +1,11 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useImportJob, useInspectImport, useStartImport } from '../api/hooks'
import { useImportJob, useInspectImport, useStartImport, useUploadImportZip } from '../api/hooks'
import type { ImportInspection, ImportJob } from '../api/types'
import { ErrorNote, Modal, Spinner } from './ui'
import { ImportSourcePicker } from './ImportSourcePicker'
type SourceMode = 'browse' | 'upload' | 'path'
export function ImportDialog({
onClose,
@@ -11,13 +14,16 @@ export function ImportDialog({
onClose: () => void
onImported?: (novelId: string) => void
}) {
const [sourceMode, setSourceMode] = useState<SourceMode>('browse')
const [sourceRoot, setSourceRoot] = useState('')
const [inspection, setInspection] = useState<ImportInspection | null>(null)
const [jobId, setJobId] = useState<string>()
const [confirmingRestart, setConfirmingRestart] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const inspect = useInspectImport()
const start = useStartImport()
const upload = useUploadImportZip()
const job = useImportJob(jobId)
const qc = useQueryClient()
@@ -33,6 +39,15 @@ export function ImportDialog({
inspect.mutate(sourceRoot.trim(), { onSuccess: setInspection })
}
const chooseSource = (root: string) => {
setSourceRoot(root)
inspect.mutate(root, { onSuccess: setInspection })
}
const uploadZip = (file: File) => {
upload.mutate(file, { onSuccess: (result) => chooseSource(result.sourceRoot) })
}
const beginImport = (forceRestart = false) => {
start.mutate({ sourceRoot: sourceRoot.trim(), forceRestart }, { onSuccess: (created) => setJobId(created.id) })
}
@@ -55,11 +70,59 @@ export function ImportDialog({
)
}
const busy = inspect.isPending || start.isPending || upload.isPending
return (
<Modal title="Import outline" onClose={onClose}>
<form onSubmit={check} className="grid gap-3">
{!inspection && (
<div id="import-source-mode" className="flex gap-1">
{(['browse', 'upload', 'path'] as const).map((mode) => (
<button
key={mode}
type="button"
className="btn"
style={sourceMode === mode ? { color: 'var(--accent)' } : undefined}
disabled={busy}
onClick={() => setSourceMode(mode)}
>
{mode === 'browse' ? 'Browse' : mode === 'upload' ? 'Upload zip' : 'Enter path'}
</button>
))}
</div>
)}
{!inspection && sourceMode === 'browse' && (
<ImportSourcePicker onSelect={chooseSource} disabled={busy} />
)}
{!inspection && sourceMode === 'upload' && (
<div className="grid gap-2">
<input
id="import-zip-input"
ref={fileInputRef}
type="file"
accept=".zip"
className="input"
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) uploadZip(file)
}}
/>
<p className="text-sm muted">
A zip of the folder holding outline.md, its chapter files and character
dossiers.
</p>
{upload.isPending && <Spinner label="Uploading" />}
{upload.error && <ErrorNote error={upload.error} />}
</div>
)}
{!inspection && sourceMode === 'path' && (
<>
<label className="block">
<span className="label">Source folder</span>
<span className="label">Source folder or file</span>
<input
className="input"
autoFocus
@@ -69,13 +132,17 @@ export function ImportDialog({
setInspection(null)
}}
placeholder="/home/you/Documents/Novels/my-outline"
disabled={inspect.isPending || start.isPending}
disabled={busy}
/>
</label>
<p className="text-sm muted">
Absolute path to the folder holding outline.md, its chapter files and character
dossiers.
Absolute path to the folder holding outline.md and its chapter/character
files, or to a single markdown file.
</p>
</>
)}
{inspection && <p className="text-sm muted">{sourceRoot}</p>}
{inspect.error && <ErrorNote error={inspect.error} />}
{start.error && <ErrorNote error={start.error} />}
@@ -96,7 +163,7 @@ export function ImportDialog({
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
{!inspection && (
{!inspection && sourceMode === 'path' && (
<button
type="submit"
className="btn btn-primary"
@@ -0,0 +1,91 @@
import { useState } from 'react'
import { useImportBrowse } from '../api/hooks'
import { ApiError } from '../api/client'
import { Spinner } from './ui'
export function ImportSourcePicker({
onSelect,
disabled,
}: {
onSelect: (sourceRoot: string) => void
disabled?: boolean
}) {
const [path, setPath] = useState('')
const browse = useImportBrowse(path)
if (browse.isError && browse.error instanceof ApiError && browse.error.status === 400) {
return (
<p id="import-picker-unavailable" className="text-sm muted">
No import folder is configured on this server.
</p>
)
}
if (browse.isLoading) {
return <Spinner label="Loading" />
}
if (!browse.data) {
return null
}
const segments = browse.data.relativePath ? browse.data.relativePath.split('/') : []
return (
<div id="import-source-picker" className="grid gap-2">
<nav className="flex flex-wrap items-center gap-1 text-sm">
<button type="button" className="btn" disabled={disabled} onClick={() => setPath('')}>
Import folder
</button>
{segments.map((segment, i) => (
<span key={i} className="flex items-center gap-1">
<span className="muted">/</span>
<button
type="button"
className="btn"
disabled={disabled}
onClick={() => setPath(segments.slice(0, i + 1).join('/'))}
>
{segment}
</button>
</span>
))}
</nav>
<ul
id="import-source-picker-entries"
className="grid gap-1 rounded-md p-1"
style={{ background: 'var(--surface-sunken)', maxHeight: '16rem', overflowY: 'auto' }}
>
{browse.data.entries.length === 0 && <li className="px-2 py-1 text-sm muted">Empty folder.</li>}
{browse.data.entries.map((entry) => (
<li key={entry.relativePath} className="flex items-center justify-between gap-2 px-2 py-1">
<button
type="button"
className="flex-1 text-left text-sm"
disabled={disabled}
onClick={() => (entry.isDirectory ? setPath(entry.relativePath) : onSelect(entry.sourceRoot))}
>
{entry.isDirectory ? '📁' : '📄'} {entry.name}
{entry.isDirectory && entry.looksImportable && (
<span className="ml-2 text-xs" style={{ color: 'var(--accent)' }}>
outline found
</span>
)}
</button>
{entry.isDirectory && (
<button
type="button"
className="btn"
disabled={disabled || !entry.looksImportable}
onClick={() => onSelect(entry.sourceRoot)}
>
Select
</button>
)}
</li>
))}
</ul>
</div>
)
}
@@ -76,7 +76,7 @@ export function TagColorPicker({
id="tag-color-custom-input"
className="sr-only"
type="color"
value={isCustom ? value : '#9a4a2f'}
value={isCustom ? value : '#7c5cff'}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
/>
+109
View File
@@ -0,0 +1,109 @@
import type { SVGProps } from 'react'
function Icon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
width={18}
height={18}
aria-hidden
{...props}
/>
)
}
export function IconDashboard(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<rect x="3.5" y="3.5" width="7" height="9" rx="1.5" />
<rect x="13.5" y="3.5" width="7" height="5" rx="1.5" />
<rect x="13.5" y="11.5" width="7" height="9" rx="1.5" />
<rect x="3.5" y="15.5" width="7" height="5" rx="1.5" />
</Icon>
)
}
export function IconChapters(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M4 4.5c2-1 5-1 8 0 3-1 6-1 8 0v14c-2-1-5-1-8 0-3-1-6-1-8 0z" />
<path d="M12 4.5v14" />
</Icon>
)
}
export function IconCharacters(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<circle cx="9" cy="8" r="3.25" />
<path d="M3.5 20c.7-3.4 2.8-5.5 5.5-5.5s4.8 2.1 5.5 5.5" />
<path d="M15.5 5.2c1.4.4 2.4 1.6 2.4 3s-1 2.6-2.4 3" />
<path d="M18 14.6c2 .5 3.4 2.2 4 4.9" />
</Icon>
)
}
export function IconTags(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M11.5 3.5h5.8a1 1 0 0 1 .7.3l3 3a1 1 0 0 1 .3.7v5.8a1 1 0 0 1-.3.7l-8.3 8.3a1 1 0 0 1-1.4 0l-8-8a1 1 0 0 1 0-1.4l8.3-8.3a1 1 0 0 1 .7-.3z" />
<circle cx="16.5" cy="7.5" r="1.25" />
</Icon>
)
}
export function IconLocations(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z" />
<circle cx="12" cy="9.5" r="2.5" />
</Icon>
)
}
export function IconAgent(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M12 3.5l1.4 3.4 3.4 1.4-3.4 1.4L12 13.1l-1.4-3.4-3.4-1.4 3.4-1.4z" />
<path d="M18.5 14.5l.8 1.9 1.9.8-1.9.8-.8 1.9-.8-1.9-1.9-.8 1.9-.8z" />
</Icon>
)
}
export function IconSettings(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<circle cx="12" cy="12" r="3" />
<path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M17.7 6.3l-1.6 1.6M7.9 16.1l-1.6 1.6M17.7 17.7l-1.6-1.6M7.9 7.9 6.3 6.3" />
</Icon>
)
}
export function IconChevronDown(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M6 9l6 6 6-6" />
</Icon>
)
}
export function IconPlus(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M12 5v14M5 12h14" />
</Icon>
)
}
export function IconArrowRight(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M4.5 12h15M13.5 6l6 6-6 6" />
</Icon>
)
}
+2 -9
View File
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import { draftStatusColor } from '../api/stage'
import type { DraftStatus } from '../api/types'
export function Spinner({ label = 'Loading' }: { label?: string }) {
@@ -35,16 +36,8 @@ export function EmptyState({ title, hint }: { title: string; hint?: ReactNode })
)
}
const statusTone: Record<DraftStatus, string> = {
Planned: '#8a8178',
Outlined: '#5b7fa8',
Drafted: '#a8813f',
Revised: '#63914f',
Final: '#4a8f7b',
}
export function StatusBadge({ status }: { status: DraftStatus }) {
const tone = statusTone[status]
const tone = draftStatusColor(status)
return (
<span
className="inline-block rounded-full px-2 py-0.5 text-[0.6875rem] font-semibold tracking-wide uppercase"
+74 -51
View File
@@ -1,58 +1,56 @@
@import 'tailwindcss';
@theme {
--font-sans: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif;
--font-ui: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-display: 'Fraunces Variable', Georgia, serif;
--font-ui: 'Inter Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-serif: 'Source Serif 4 Variable', Georgia, serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}
:root {
--paper: #faf7f0;
--canvas: #0c0a12;
--surface: #1b1825;
--surface-sunken: #100e17;
--ink: #f1eef8;
--ink-muted: #8b859a;
--line: #2c2838;
--accent: #7c5cff;
--accent-soft: #241d3d;
--accent-ink: #ffffff;
--stage-1: #7c5cff;
--stage-2: #2f8fe0;
--stage-3: #ff7a45;
--stage-4: #14b88a;
--stage-5: #d9a504;
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
--canvas: #f7f6fb;
--surface: #ffffff;
--surface-sunken: #f2ede2;
--ink: #241f1a;
--ink-muted: #6b6157;
--line: #e0d8c8;
--accent: #9a4a2f;
--accent-soft: #f6e9e2;
--surface-sunken: #eeecf6;
--ink: #14121a;
--ink-muted: #6b6577;
--line: #e2dfec;
--accent-soft: #efe9ff;
--accent-ink: #ffffff;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #16151a;
--surface: #1e1d24;
--surface-sunken: #131217;
--ink: #ece7de;
--ink-muted: #9a9288;
--line: #322f39;
--accent: #e08b62;
--accent-soft: #2c2229;
color-scheme: dark;
}
}
:root[data-theme='dark'] {
--paper: #16151a;
--surface: #1e1d24;
--surface-sunken: #131217;
--ink: #ece7de;
--ink-muted: #9a9288;
--line: #322f39;
--accent: #e08b62;
--accent-soft: #2c2229;
color-scheme: dark;
}
:root[data-theme='light'] {
--paper: #faf7f0;
--canvas: #f7f6fb;
--surface: #ffffff;
--surface-sunken: #f2ede2;
--ink: #241f1a;
--ink-muted: #6b6157;
--line: #e0d8c8;
--accent: #9a4a2f;
--accent-soft: #f6e9e2;
--surface-sunken: #eeecf6;
--ink: #14121a;
--ink-muted: #6b6577;
--line: #e2dfec;
--accent-soft: #efe9ff;
--accent-ink: #ffffff;
color-scheme: light;
}
@@ -63,21 +61,44 @@ body,
}
body {
background: var(--paper);
background: var(--canvas);
color: var(--ink);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3 {
font-family: var(--font-display);
}
:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--canvas), 0 0 0 4px var(--accent, #7c5cff);
border-radius: 0.25rem;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@layer components {
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 0.5rem;
border-radius: 0.75rem;
}
.btn {
@apply inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition;
@apply inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
@@ -94,11 +115,11 @@ body {
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
color: var(--accent-ink);
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
filter: brightness(1.1);
background: var(--accent);
}
@@ -112,7 +133,7 @@ body {
}
.input {
@apply w-full rounded-md px-2.5 py-1.5 text-sm outline-none transition;
@apply w-full rounded-lg px-2.5 py-1.5 text-sm outline-none transition;
background: var(--surface);
border: 1px solid var(--line);
color: var(--ink);
@@ -121,7 +142,7 @@ body {
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
}
.label {
@@ -134,17 +155,18 @@ body {
}
.prose-serif {
font-family: var(--font-sans);
font-family: var(--font-serif);
@apply text-[1.0625rem] leading-relaxed;
}
.markdown-preview {
font-family: var(--font-sans);
font-family: var(--font-serif);
@apply text-[1.0625rem] leading-relaxed;
}
.markdown-preview :is(h1, h2, h3, h4) {
@apply mt-5 mb-2 font-semibold first:mt-0;
font-family: var(--font-display);
}
.markdown-preview h1 {
@@ -183,6 +205,7 @@ body {
.markdown-preview code {
@apply rounded px-1 py-0.5 text-sm;
font-family: var(--font-mono);
background: var(--surface-sunken);
}
+6
View File
@@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import '@fontsource-variable/fraunces/wght.css'
import '@fontsource-variable/fraunces/wght-italic.css'
import '@fontsource-variable/inter/wght.css'
import '@fontsource-variable/source-serif-4/wght.css'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/600.css'
import './index.css'
const queryClient = new QueryClient({
-161
View File
@@ -1,161 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useConversation, useConversations, useSendAgentMessage } from '../api/hooks'
import type { AgentMessage } from '../api/types'
import { ErrorNote, Spinner } from '../components/ui'
import { useHotkey } from '../keyboard/HotkeysContext'
const starters = [
'Read the brief and tell me what the outline is missing.',
"Draft a three-act skeleton from the logline, then stop so I can react.",
'Look at my protagonist: is the want genuinely in tension with the need?',
]
export default function AgentPage() {
const { novelId = '' } = useParams()
const { data: conversations } = useConversations(novelId)
const [conversationId, setConversationId] = useState<string | undefined>()
const { data: conversation } = useConversation(conversationId)
const send = useSendAgentMessage(novelId)
const [draft, setDraft] = useState('')
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [conversation?.messages.length, send.isPending])
const submit = (message: string) => {
const trimmed = message.trim()
if (!trimmed || send.isPending) return
setDraft('')
send.mutate(
{ message: trimmed, conversationId },
{ onSuccess: (turn) => setConversationId(turn.conversationId) },
)
}
useHotkey('n', 'New conversation', () => setConversationId(undefined), { group: 'Agent' })
useHotkey('mod+Enter', 'Send message', () => submit(draft), {
group: 'Agent',
allowInInputs: true,
enabled: draft.trim().length > 0 && !send.isPending,
})
return (
<div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
<aside className="grid content-start gap-2">
<button
className="btn btn-primary w-full justify-center"
onClick={() => setConversationId(undefined)}
>
New conversation
</button>
{conversations?.map((item) => (
<button
key={item.id}
onClick={() => setConversationId(item.id)}
className="card px-3 py-2 text-left text-sm transition hover:shadow-sm"
style={
item.id === conversationId
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
: undefined
}
>
<div className="truncate">{item.title}</div>
<div className="text-xs muted">
{item.messageCount} message{item.messageCount === 1 ? '' : 's'}
</div>
</button>
))}
</aside>
<section className="flex min-h-[70vh] flex-col">
<div className="flex-1 space-y-4 overflow-y-auto pb-4">
{!conversation && (
<div className="card p-6">
<h2 className="text-lg font-semibold">Your writing partner</h2>
<p className="mt-1 text-sm muted">
It can read and edit the brief, the outline, character dossiers and chapter
prose the same data you see in the other tabs.
</p>
<div className="mt-4 grid gap-2">
{starters.map((starter) => (
<button
key={starter}
className="btn justify-start text-left"
onClick={() => submit(starter)}
>
{starter}
</button>
))}
</div>
</div>
)}
{conversation?.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{send.isPending && <Spinner label="Thinking" />}
{send.error && <ErrorNote error={send.error} />}
<div ref={endRef} />
</div>
<form
className="flex gap-2 pt-3"
style={{ borderTop: '1px solid var(--line)' }}
onSubmit={(e) => {
e.preventDefault()
submit(draft)
}}
>
<textarea
className="input flex-1 resize-none"
rows={3}
value={draft}
placeholder="Ask about structure, a character's arc, or what the next beat should do…"
onChange={(e) => setDraft(e.target.value)}
/>
<button className="btn btn-primary self-end" disabled={!draft.trim() || send.isPending}>
Send
</button>
</form>
<p className="mt-1 text-xs muted">/Ctrl + Enter to send.</p>
</section>
</div>
)
}
function MessageBubble({ message }: { message: AgentMessage }) {
const isUser = message.role === 'User'
return (
<div className={isUser ? 'flex justify-end' : ''}>
<div
className={`card max-w-[46rem] px-4 py-3 ${isUser ? '' : 'w-full'}`}
style={isUser ? { background: 'var(--accent-soft)', borderColor: 'transparent' } : undefined}
>
<div className="prose-serif whitespace-pre-wrap">{message.content}</div>
{message.toolCalls.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-xs muted">
{message.toolCalls.length} change
{message.toolCalls.length === 1 ? '' : 's'} made
</summary>
<ul className="mt-2 grid gap-2">
{message.toolCalls.map((call, index) => (
<li key={index} className="rounded-md p-2 text-xs" style={{ background: 'var(--surface-sunken)' }}>
<div className="font-mono font-semibold">{call.name}</div>
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-words opacity-70">
{call.input}
</pre>
</li>
))}
</ul>
</details>
)}
</div>
</div>
)
}
@@ -257,7 +257,6 @@ export default function ChapterPage() {
value={chapter.summary}
multiline
rows={5}
serif
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
onCommit={(summary) => patch({ summary })}
onContextMenu={(e) => handleContextMenu(e, () => {})}
@@ -622,7 +621,6 @@ function BeatTable({
value={beat.whatHappened}
multiline
rows={3}
serif
placeholder="The event itself."
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
onContextMenu={(e) =>
@@ -638,7 +636,6 @@ function BeatTable({
value={beat.whatsNext}
multiline
rows={3}
serif
placeholder="What it sets in motion."
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
onContextMenu={(e) =>
+1 -1
View File
@@ -324,7 +324,7 @@ function CharacterTable({ characters, novelId }: { characters: Character[]; nove
)
}
function AddCharacterModal({
export function AddCharacterModal({
novelId,
onClose,
onCreated,
+142 -5
View File
@@ -1,9 +1,12 @@
import { Link, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useNovel, useNovelActivity, useTags, useUpdateNovel } from '../api/hooks'
import type { Novel, TagSummary } from '../api/types'
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useCreateChapter, useNovel, useNovelActivity, useTags, useUpdateNovel } from '../api/hooks'
import type { ChapterSummary, Novel, TagSummary } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
import { ContributionGraph } from '../components/ContributionGraph'
import { IconArrowRight, IconCharacters, IconChapters as IconChapter, IconPlus } from '../components/icons'
import { AddCharacterModal } from './CharactersPage'
const RECENT_COUNT = 5
const RECENT_CHAPTERS_COUNT = 10
@@ -18,15 +21,37 @@ export default function DashboardPage() {
return novel.phase === 'Brainstorming' ? (
<BrainstormingDashboard novel={novel} />
) : (
<OutliningDashboard novelId={novelId} />
<WorkDashboard novelId={novelId} />
)
}
function BrainstormingDashboard({ novel }: { novel: Novel }) {
const update = useUpdateNovel(novel.id)
const { can } = useAuth()
const canCreate = can('CreateContent', novel)
const [addingCharacter, setAddingCharacter] = useState(false)
const navigate = useNavigate()
return (
<div className="grid gap-6">
{canCreate && (
<section className="grid gap-3 sm:grid-cols-2">
<ActionCard
icon={IconPlus}
label="Add a character"
hint="Most outline questions resolve once you know who wants what."
onClick={() => setAddingCharacter(true)}
primary
/>
<ActionCard
icon={IconArrowRight}
label="Move to outlining"
hint="Once the shape is there, start turning notes into chapters."
onClick={() => update.mutate({ phase: 'Outlining' })}
/>
</section>
)}
<div className="card p-5">
<h2 className="mb-1 text-sm font-semibold tracking-wide uppercase muted">Notes</h2>
<p className="mb-3 text-sm muted">
@@ -43,14 +68,26 @@ function BrainstormingDashboard({ novel }: { novel: Novel }) {
readOnly={!can('Write', novel)}
/>
</div>
{addingCharacter && (
<AddCharacterModal
novelId={novel.id}
onClose={() => setAddingCharacter(false)}
onCreated={(id) => navigate(`/novels/${novel.id}/characters/${id}`)}
/>
)}
</div>
)
}
function OutliningDashboard({ novelId }: { novelId: string }) {
function WorkDashboard({ novelId }: { novelId: string }) {
const { data: novel } = useNovel(novelId)
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(novelId)
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId)
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId)
const { data: activity, isPending: activityPending, error: activityError } = useNovelActivity(novelId)
const { can } = useAuth()
const canCreate = can('CreateContent', novel)
const recentCharacters = [...(characters ?? [])].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
@@ -61,6 +98,8 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
return (
<div className="grid gap-6">
{canCreate && <QuickActions novelId={novelId} lastChapter={recentChapters[0]} />}
<section id="dashboard-activity-graph" className="card p-5">
<h2 className="mb-4 text-lg font-semibold">Activity</h2>
@@ -174,6 +213,104 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
)
}
function QuickActions({ novelId, lastChapter }: { novelId: string; lastChapter?: ChapterSummary }) {
const navigate = useNavigate()
const createChapter = useCreateChapter(novelId)
const [addingCharacter, setAddingCharacter] = useState(false)
return (
<section className="grid gap-3 sm:grid-cols-3">
<ActionCard
icon={IconPlus}
label="New chapter"
hint="Start with a title — the outline can come later."
onClick={() =>
createChapter.mutate(
{ title: 'Untitled chapter' },
{ onSuccess: (chapter) => navigate(`chapters/${chapter.id}`) },
)
}
busy={createChapter.isPending}
primary
/>
<ActionCard icon={IconCharacters} label="New character" hint="Add someone new to the cast." onClick={() => setAddingCharacter(true)} />
{lastChapter ? (
<ActionCard to={`chapters/${lastChapter.id}`} icon={IconChapter} label="Continue writing" hint={lastChapter.title} />
) : (
<ActionCard to="chapters" icon={IconChapter} label="View chapters" hint="Nothing drafted yet." />
)}
{createChapter.error && (
<div className="sm:col-span-3">
<ErrorNote error={createChapter.error} />
</div>
)}
{addingCharacter && (
<AddCharacterModal
novelId={novelId}
onClose={() => setAddingCharacter(false)}
onCreated={(id) => navigate(`characters/${id}`)}
/>
)}
</section>
)
}
function ActionCard({
icon: ActionIcon,
label,
hint,
onClick,
primary,
busy,
to,
}: {
icon: typeof IconPlus
label: string
hint: string
onClick?: () => void
primary?: boolean
busy?: boolean
to?: string
}) {
const className =
'card flex items-start gap-3 px-4 py-3.5 text-left transition hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60'
const style = primary ? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' } : undefined
const iconStyle = {
background: primary ? 'var(--accent)' : 'var(--surface-sunken)',
color: primary ? 'var(--accent-ink)' : 'var(--ink-muted)',
}
const content = (
<>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg" style={iconStyle}>
<ActionIcon />
</span>
<span className="min-w-0">
<span className="block font-semibold" style={primary ? { color: 'var(--accent)' } : undefined}>
{label}
</span>
<span className="block truncate text-sm muted">{hint}</span>
</span>
</>
)
if (to) {
return (
<Link to={to} className={className} style={style}>
{content}
</Link>
)
}
return (
<button type="button" className={className} style={style} onClick={onClick} disabled={busy}>
{content}
</button>
)
}
function TagCloud({ novelId, tags }: { novelId: string; tags: TagSummary[] }) {
const maxCount = Math.max(...tags.map((t) => t.totalCount), 1)
+149 -39
View File
@@ -1,29 +1,43 @@
import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom'
import { useLogout, useNovel, useUpdateNovel } from '../api/hooks'
import { useState } from 'react'
import { Outlet, useParams, Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
import { useChapter, useCharacters, useLogout, useNovel, useUpdateNovel } from '../api/hooks'
import { novelPhaseColor } from '../api/stage'
import { novelPhases } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ErrorNote, Spinner } from '../components/ui'
import {
IconAgent,
IconChapters,
IconCharacters,
IconDashboard,
IconLocations,
IconSettings,
IconTags,
} from '../components/icons'
import { AgentPanel, type AgentContext } from '../components/AgentPanel'
import { HelpButton } from '../keyboard/HelpButton'
import { useHotkey } from '../keyboard/HotkeysContext'
import type { ComponentType, CSSProperties, SVGProps } from 'react'
const sections: { to: string; label: string; end?: boolean }[] = [
{ to: '', label: 'Dashboard', end: true },
{ to: 'chapters', label: 'Chapters' },
{ to: 'characters', label: 'Characters' },
{ to: 'tags', label: 'Tags' },
{ to: 'locations', label: 'Locations' },
{ to: 'agent', label: 'Agent' },
{ to: 'settings', label: 'Settings' },
const sections: { to: string; label: string; end?: boolean; icon: ComponentType<SVGProps<SVGSVGElement>> }[] = [
{ to: '', label: 'Dashboard', end: true, icon: IconDashboard },
{ to: 'chapters', label: 'Chapters', icon: IconChapters },
{ to: 'characters', label: 'Characters', icon: IconCharacters },
{ to: 'tags', label: 'Tags', icon: IconTags },
{ to: 'locations', label: 'Locations', icon: IconLocations },
{ to: 'settings', label: 'Settings', icon: IconSettings },
]
export default function NovelLayout() {
const { novelId = '' } = useParams()
const navigate = useNavigate()
const location = useLocation()
const { data: novel, isPending, error } = useNovel(novelId)
const update = useUpdateNovel(novelId)
const { user, can } = useAuth()
const canWrite = can('Write', novel)
const logout = useLogout()
const [agentOpen, setAgentOpen] = useState(false)
const goTo = (path: string) => navigate(path ? `/novels/${novelId}/${path}` : `/novels/${novelId}`)
@@ -32,74 +46,170 @@ export default function NovelLayout() {
useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' })
useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' })
useHotkey('g l', 'Go to locations', () => goTo('locations'), { group: 'Navigate' })
useHotkey('g a', 'Go to agent', () => goTo('agent'), { group: 'Navigate' })
useHotkey('g a', 'Toggle agent', () => setAgentOpen((o) => !o), { group: 'Navigate' })
useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' })
const segments = (location.pathname.split(`/novels/${novelId}/`)[1] ?? '').split('/')
const currentSegment = segments[0] ?? ''
const entityId = segments[1]
const currentSection = sections.find(({ to, end }) => (end ? currentSegment === '' : currentSegment === to))
const { data: chapterForContext } = useChapter(currentSegment === 'chapters' ? entityId : undefined)
const { data: charactersForContext } = useCharacters(novelId)
let agentContext: AgentContext | null = null
if (currentSegment === 'chapters' && chapterForContext) {
agentContext = { label: `Chapter — ${chapterForContext.title}` }
} else if (currentSegment === 'characters' && entityId) {
const character = charactersForContext?.find((c) => c.id === entityId)
if (character) agentContext = { label: `Character — ${character.name}` }
}
if (!agentContext && currentSection) {
agentContext = { label: currentSection.label }
}
const accentStyle = novel
? ({
'--accent': novelPhaseColor(novel.phase),
'--accent-soft': `color-mix(in srgb, ${novelPhaseColor(novel.phase)} 16%, transparent)`,
} as CSSProperties)
: undefined
return (
<div className="min-h-full">
<header className="sticky top-0 z-10 border-b" style={{ borderColor: 'var(--line)', background: 'var(--surface)' }}>
<div className="mx-auto flex max-w-[100rem] items-center gap-4 px-6 py-3">
<Link to="/" className="text-sm muted hover:underline">
Novels
<div className="flex min-h-full" style={accentStyle}>
<aside
id="novel-sidebar"
className="sticky top-0 flex h-screen w-60 shrink-0 flex-col"
style={{ background: 'var(--surface)', borderRight: '1px solid var(--line)' }}
>
<Link to="/" className="flex items-center gap-2 px-5 py-5" style={{ fontFamily: 'var(--font-display)' }}>
<span
className="grid h-6 w-6 shrink-0 place-items-center rounded-md text-xs font-bold"
style={{ background: 'var(--accent)', color: 'var(--accent-ink)' }}
>
N
</span>
<span className="text-lg font-semibold">Novelly</span>
</Link>
<Link to={`/novels/${novelId}`} className="truncate text-base font-semibold hover:underline">
<div className="px-5 pb-5">
<Link
to={`/novels/${novelId}`}
className="block truncate text-base leading-snug font-semibold hover:opacity-85"
style={{ fontFamily: 'var(--font-display)' }}
>
{novel?.title ?? '…'}
</Link>
<div className="ml-auto flex items-center gap-3">
{novel && (
<div className="relative mt-2 inline-block">
<select
className="input w-auto"
id="novel-phase-select"
className="appearance-none rounded-full py-1 pr-6 pl-2.5 text-xs font-semibold tracking-wide"
value={novel.phase}
disabled={!canWrite}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof novelPhases)[number] })}
aria-label="Novel phase"
style={{
background: 'color-mix(in srgb, var(--accent) 16%, transparent)',
color: 'var(--accent)',
border: '1px solid color-mix(in srgb, var(--accent) 35%, transparent)',
}}
>
{novelPhases.map((phase) => (
<option key={phase} value={phase}>
<option key={phase} value={phase} style={{ background: 'var(--surface)', color: 'var(--ink)' }}>
{phase}
</option>
))}
</select>
)}
<HelpButton />
{user && (
<>
<span className="truncate text-sm muted" title={user.email}>
{user.displayName} · {user.globalRole}
</span>
<button
className="btn"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</>
</div>
)}
</div>
</div>
<nav className="mx-auto flex max-w-[100rem] gap-1 px-6 pb-2 text-sm">
{sections.map(({ to, label, end }) => (
<nav className="space-y-0.5 px-3">
{sections.map(({ to, label, end, icon: SectionIcon }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`rounded-md px-3 py-1.5 font-medium transition ${isActive ? '' : 'muted hover:opacity-100'}`
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition ${
isActive ? '' : 'muted hover:bg-[var(--surface-sunken)] hover:opacity-100'
}`
}
style={({ isActive }) =>
isActive ? { background: 'var(--accent-soft)', color: 'var(--accent)' } : undefined
}
>
<SectionIcon />
{label}
</NavLink>
))}
</nav>
<div className="flex-1 px-3 pt-2">
<button
id="agent-toggle"
type="button"
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition"
style={
agentOpen
? { background: 'var(--accent-soft)', color: 'var(--accent)' }
: { color: 'var(--accent)' }
}
onClick={() => setAgentOpen((o) => !o)}
>
<IconAgent />
Agent
<span className="ml-auto text-xs muted">g a</span>
</button>
</div>
<div className="px-3 pt-2 pb-4" style={{ borderTop: '1px solid var(--line)' }}>
{user && (
<div className="flex items-center gap-2 px-2 pt-3 pb-2">
<span
className="grid h-7 w-7 shrink-0 place-items-center rounded-full text-xs font-semibold"
style={{ background: 'var(--accent-soft)', color: 'var(--accent)' }}
>
{user.displayName.slice(0, 1).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{user.displayName}</div>
<div className="truncate text-xs muted">{user.globalRole}</div>
</div>
</div>
)}
<div className="flex items-center gap-2 px-2">
<HelpButton />
<button
className="btn flex-1 justify-center"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</div>
</div>
</aside>
<div className="min-w-0 flex-1">
<header
className="sticky top-0 z-10 flex items-center gap-2 px-8 py-4 text-sm"
style={{ background: 'var(--canvas)' }}
>
<Link to="/" className="muted hover:opacity-100 hover:underline">
Novels
</Link>
<span className="muted">/</span>
<span className="font-medium">{currentSection?.label ?? '…'}</span>
</header>
<main className="mx-auto max-w-[100rem] px-6 py-8">
<main className="mx-auto max-w-[100rem] px-8 pb-10">
{error && <ErrorNote error={error} />}
{isPending ? <Spinner label="Loading novel" /> : <Outlet context={{ novelId }} />}
</main>
</div>
<AgentPanel novelId={novelId} open={agentOpen} onClose={() => setAgentOpen(false)} context={agentContext} />
</div>
)
}
+1 -1
View File
@@ -121,7 +121,7 @@ function TagReferencePanel({
/>
</label>
<TagColorPicker
value={data.tag.color ?? '#9a4a2f'}
value={data.tag.color ?? '#7c5cff'}
readOnly={!canWrite}
onChange={(color) => update.mutate({ id: tagId, color })}
/>
@@ -0,0 +1,100 @@
using Microsoft.Extensions.Options;
using Novelly.Api.Imports;
namespace Novelly.Api.Tests;
[TestFixture]
public class ImportBrowseServiceTests
{
private string _root = null!;
private ImportBrowseService _browse = null!;
[SetUp]
public void SetUp()
{
_root = Directory.CreateTempSubdirectory("novelly-browse-test-").FullName;
_browse = new ImportBrowseService(
Options.Create(new ImportOptions { RootPath = _root }),
new CapturingLogger<ImportBrowseService>());
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Test]
public void Listing_the_root_returns_directories_before_files_ordered_by_name()
{
File.WriteAllText(Path.Combine(_root, "notes.md"), "# Notes");
Directory.CreateDirectory(Path.Combine(_root, "zeta"));
Directory.CreateDirectory(Path.Combine(_root, "alpha"));
var listing = _browse.List(null);
Assert.That(listing.Entries.Select(e => e.Name), Is.EqualTo(new[] { "alpha", "zeta", "notes.md" }));
}
[Test]
public void A_folder_with_an_outline_file_is_flagged_importable()
{
var folder = Path.Combine(_root, "my-novel");
Directory.CreateDirectory(folder);
File.WriteAllText(Path.Combine(folder, "outline.md"), "# My Novel");
var listing = _browse.List(null);
Assert.That(listing.Entries.Single(e => e.Name == "my-novel").LooksImportable, Is.True);
}
[Test]
public void An_empty_folder_is_not_flagged_importable()
{
Directory.CreateDirectory(Path.Combine(_root, "empty"));
var listing = _browse.List(null);
Assert.That(listing.Entries.Single(e => e.Name == "empty").LooksImportable, Is.False);
}
[Test]
public void The_staging_folder_is_hidden_from_listings()
{
Directory.CreateDirectory(Path.Combine(_root, ".novelly-staging"));
Directory.CreateDirectory(Path.Combine(_root, "visible"));
var listing = _browse.List(null);
Assert.That(listing.Entries.Select(e => e.Name), Is.EqualTo(new[] { "visible" }));
}
[Test]
public void Escaping_the_import_root_is_rejected()
{
Assert.That(() => _browse.List(".."), Throws.TypeOf<ArgumentException>());
}
[Test]
public void Browsing_without_a_configured_root_fails()
{
var unconfigured = new ImportBrowseService(
Options.Create(new ImportOptions()),
new CapturingLogger<ImportBrowseService>());
Assert.That(() => unconfigured.List(null), Throws.TypeOf<InvalidOperationException>());
}
[Test]
public void Listing_a_subfolder_reports_its_parent()
{
Directory.CreateDirectory(Path.Combine(_root, "child"));
var listing = _browse.List("child");
Assert.That(listing.ParentRelativePath, Is.EqualTo(""));
}
}
@@ -1,4 +1,5 @@
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using Novelly.Api.Imports;
using Novelly.Api.Novels;
@@ -20,6 +21,8 @@ public class ImportServiceTests : ServiceTestFixture
Novels,
_queue,
UserContext,
Options.Create(new ImportOptions()),
new ImportZipExtractor(new CapturingLogger<ImportZipExtractor>()),
new CapturingLogger<ImportService>(),
new InspectImportRequestValidator(),
new StartImportRequestValidator());
@@ -32,6 +35,12 @@ public class ImportServiceTests : ServiceTestFixture
{
Directory.Delete(_root, recursive: true);
}
var staging = Path.Combine(Path.GetTempPath(), "novelly-import-staging");
if (Directory.Exists(staging))
{
Directory.Delete(staging, recursive: true);
}
}
[Test]
@@ -131,6 +140,71 @@ public class ImportServiceTests : ServiceTestFixture
});
}
[Test]
public async Task Starting_an_import_outside_a_configured_import_root_is_rejected()
{
var configuredRoot = Directory.CreateTempSubdirectory("novelly-import-root-").FullName;
try
{
var restricted = new ImportService(
Db.Context,
Novels,
_queue,
UserContext,
Options.Create(new ImportOptions { RootPath = configuredRoot }),
new ImportZipExtractor(new CapturingLogger<ImportZipExtractor>()),
new CapturingLogger<ImportService>(),
new InspectImportRequestValidator(),
new StartImportRequestValidator());
Assert.That(
async () => await restricted.StartOrResumeAsync(new StartImportRequest(_root)),
Throws.TypeOf<ArgumentException>());
}
finally
{
Directory.Delete(configuredRoot, recursive: true);
}
}
[Test]
public async Task Starting_an_import_of_a_single_markdown_file_stages_it_into_a_folder()
{
var file = Path.Combine(_root, "01-chapter.md");
File.WriteAllText(file, "# Chapter 1");
var job = await _imports.StartOrResumeAsync(new StartImportRequest(file));
Assert.Multiple(() =>
{
Assert.That(Directory.Exists(job.SourceRoot), Is.True);
Assert.That(File.Exists(Path.Combine(job.SourceRoot, "01-chapter.md")), Is.True);
});
}
[Test]
public async Task Starting_an_import_of_the_same_single_file_twice_dedupes_to_the_same_staged_job()
{
var file = Path.Combine(_root, "01-chapter.md");
File.WriteAllText(file, "# Chapter 1");
var first = await _imports.StartOrResumeAsync(new StartImportRequest(file));
var second = await _imports.StartOrResumeAsync(new StartImportRequest(file));
Assert.That(second.Id, Is.EqualTo(first.Id));
}
[Test]
public async Task Starting_an_import_of_a_non_markdown_file_is_rejected()
{
var file = Path.Combine(_root, "notes.txt");
File.WriteAllText(file, "not markdown");
Assert.That(
async () => await _imports.StartOrResumeAsync(new StartImportRequest(file)),
Throws.TypeOf<ArgumentException>());
}
private void WriteChapterFiles(int count)
{
var outlines = Path.Combine(_root, "outlines");
@@ -0,0 +1,116 @@
using System.IO.Compression;
using Novelly.Api.Imports;
namespace Novelly.Api.Tests;
[TestFixture]
public class ImportZipExtractorTests
{
private string _stagingParent = null!;
private ImportZipExtractor _extractor = null!;
[SetUp]
public void SetUp()
{
_stagingParent = Directory.CreateTempSubdirectory("novelly-zip-test-").FullName;
_extractor = new ImportZipExtractor(new CapturingLogger<ImportZipExtractor>());
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_stagingParent))
{
Directory.Delete(_stagingParent, recursive: true);
}
}
[Test]
public void A_zip_of_the_expected_folder_layout_extracts_intact()
{
using var zip = BuildZip(("outline.md", "# My Novel"), ("outlines/01-chapter.md", "# Chapter 1"));
var staging = StagingDir();
_extractor.Extract(zip, staging);
Assert.Multiple(() =>
{
Assert.That(File.Exists(Path.Combine(staging, "outline.md")), Is.True);
Assert.That(File.Exists(Path.Combine(staging, "outlines", "01-chapter.md")), Is.True);
});
}
[Test]
public void A_single_wrapper_directory_is_flattened_away()
{
using var zip = BuildZip(("my-novel/outline.md", "# My Novel"), ("my-novel/outlines/01-chapter.md", "# Chapter 1"));
var staging = StagingDir();
_extractor.Extract(zip, staging);
Assert.Multiple(() =>
{
Assert.That(File.Exists(Path.Combine(staging, "outline.md")), Is.True);
Assert.That(Directory.Exists(Path.Combine(staging, "my-novel")), Is.False);
});
}
[Test]
public void A_zip_slip_entry_is_rejected_and_staging_is_cleaned_up()
{
using var zip = BuildZip(("outline.md", "# ok"), ("../evil.md", "pwned"));
var staging = StagingDir();
Assert.That(() => _extractor.Extract(zip, staging), Throws.TypeOf<ArgumentException>());
Assert.That(Directory.Exists(staging), Is.False);
}
[Test]
public void A_disallowed_file_extension_is_rejected()
{
using var zip = BuildZip(("outline.md", "# ok"), ("script.sh", "echo hi"));
var staging = StagingDir();
Assert.That(() => _extractor.Extract(zip, staging), Throws.TypeOf<ArgumentException>());
Assert.That(Directory.Exists(staging), Is.False);
}
[Test]
public void The_ledger_file_is_allowed_through()
{
using var zip = BuildZip(("outline.md", "# ok"), (".novelly-import.json", "{}"));
var staging = StagingDir();
_extractor.Extract(zip, staging);
Assert.That(File.Exists(Path.Combine(staging, ".novelly-import.json")), Is.True);
}
[Test]
public void An_empty_zip_is_rejected()
{
using var zip = BuildZip();
var staging = StagingDir();
Assert.That(() => _extractor.Extract(zip, staging), Throws.TypeOf<ArgumentException>());
}
private string StagingDir() => Path.Combine(_stagingParent, $"staging-{Guid.NewGuid():N}");
private static MemoryStream BuildZip(params (string Path, string Content)[] entries)
{
var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var (path, content) in entries)
{
var entry = archive.CreateEntry(path);
using var writer = new StreamWriter(entry.Open());
writer.Write(content);
}
}
stream.Position = 0;
return stream;
}
}