Compare commits
3
Commits
e3d410da0b
...
7df1fffdca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df1fffdca | ||
|
|
1423977ed4 | ||
|
|
661f2917ea |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 1–2 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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
public class ImportOptions
|
||||
{
|
||||
public const string SectionName = "Imports";
|
||||
|
||||
public string? RootPath { get; set; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,5 +35,8 @@
|
||||
},
|
||||
"UiSettings": {
|
||||
"ShowPronouns": false
|
||||
},
|
||||
"Imports": {
|
||||
"RootPath": null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+36
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,27 +70,79 @@ 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">
|
||||
<label className="block">
|
||||
<span className="label">Source folder</span>
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
value={sourceRoot}
|
||||
onChange={(e) => {
|
||||
setSourceRoot(e.target.value)
|
||||
setInspection(null)
|
||||
}}
|
||||
placeholder="/home/you/Documents/Novels/my-outline"
|
||||
disabled={inspect.isPending || start.isPending}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-sm muted">
|
||||
Absolute path to the folder holding outline.md, its chapter files and character
|
||||
dossiers.
|
||||
</p>
|
||||
{!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 or file</span>
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
value={sourceRoot}
|
||||
onChange={(e) => {
|
||||
setSourceRoot(e.target.value)
|
||||
setInspection(null)
|
||||
}}
|
||||
placeholder="/home/you/Documents/Novels/my-outline"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-sm muted">
|
||||
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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
--surface: #ffffff;
|
||||
--surface-sunken: #f2ede2;
|
||||
--ink: #241f1a;
|
||||
--ink-muted: #6b6157;
|
||||
--line: #e0d8c8;
|
||||
--accent: #9a4a2f;
|
||||
--accent-soft: #f6e9e2;
|
||||
color-scheme: light;
|
||||
}
|
||||
--canvas: #0c0a12;
|
||||
--surface: #1b1825;
|
||||
--surface-sunken: #100e17;
|
||||
--ink: #f1eef8;
|
||||
--ink-muted: #8b859a;
|
||||
--line: #2c2838;
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
--accent: #7c5cff;
|
||||
--accent-soft: #241d3d;
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
--stage-1: #7c5cff;
|
||||
--stage-2: #2f8fe0;
|
||||
--stage-3: #ff7a45;
|
||||
--stage-4: #14b88a;
|
||||
--stage-5: #d9a504;
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme='dark']) {
|
||||
--canvas: #f7f6fb;
|
||||
--surface: #ffffff;
|
||||
--surface-sunken: #eeecf6;
|
||||
--ink: #14121a;
|
||||
--ink-muted: #6b6577;
|
||||
--line: #e2dfec;
|
||||
--accent-soft: #efe9ff;
|
||||
--accent-ink: #ffffff;
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -324,7 +324,7 @@ function CharacterTable({ characters, novelId }: { characters: Character[]; nove
|
||||
)
|
||||
}
|
||||
|
||||
function AddCharacterModal({
|
||||
export function AddCharacterModal({
|
||||
novelId,
|
||||
onClose,
|
||||
onCreated,
|
||||
|
||||
@@ -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,39 +21,73 @@ 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="card p-5">
|
||||
<h2 className="mb-1 text-sm font-semibold tracking-wide uppercase muted">Notes</h2>
|
||||
<p className="mb-3 text-sm muted">
|
||||
Premise, voice, scraps of scene, whatever's rattling around. Move to Outlining once
|
||||
there's a shape to work from.
|
||||
</p>
|
||||
<AutoField
|
||||
value={novel.notes}
|
||||
multiline
|
||||
rows={20}
|
||||
serif
|
||||
placeholder="Start anywhere."
|
||||
onCommit={(notes) => update.mutate({ notes })}
|
||||
readOnly={!can('Write', novel)}
|
||||
/>
|
||||
<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">
|
||||
Premise, voice, scraps of scene, whatever's rattling around. Move to Outlining once
|
||||
there's a shape to work from.
|
||||
</p>
|
||||
<AutoField
|
||||
value={novel.notes}
|
||||
multiline
|
||||
rows={20}
|
||||
serif
|
||||
placeholder="Start anywhere."
|
||||
onCommit={(notes) => update.mutate({ notes })}
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
</Link>
|
||||
<Link to={`/novels/${novelId}`} className="truncate text-base font-semibold hover:underline">
|
||||
<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>
|
||||
|
||||
<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 && (
|
||||
{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>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-[100rem] px-6 py-8">
|
||||
{error && <ErrorNote error={error} />}
|
||||
{isPending ? <Spinner label="Loading novel" /> : <Outlet context={{ novelId }} />}
|
||||
</main>
|
||||
<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-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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user