Modernize web client: dark phase-driven theme, sidebar shell, global agent panel
Replaces the warm-paper/serif look with a dark palette where the novel's lifecycle phase drives the accent color app-wide. Sidebar nav replaces the old header/tab-bar. Dashboard leads with quick actions (new chapter, new character, continue writing) instead of just showing history. Agent chat is now a context-aware slide-out panel reachable from any page in a novel, replacing the buried /agent tab.
This commit is contained in:
@@ -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.
|
||||
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 />} />
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
--accent: #7c5cff;
|
||||
--accent-soft: #241d3d;
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
--stage-1: #7c5cff;
|
||||
--stage-2: #2f8fe0;
|
||||
--stage-3: #ff7a45;
|
||||
--stage-4: #14b88a;
|
||||
--stage-5: #d9a504;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--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='dark'] {
|
||||
--paper: #16151a;
|
||||
--surface: #1e1d24;
|
||||
--surface-sunken: #131217;
|
||||
--ink: #ece7de;
|
||||
--ink-muted: #9a9288;
|
||||
--line: #322f39;
|
||||
--accent: #e08b62;
|
||||
--accent-soft: #2c2229;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--paper: #faf7f0;
|
||||
--canvas: #f7f6fb;
|
||||
--surface: #ffffff;
|
||||
--surface-sunken: #f2ede2;
|
||||
--ink: #241f1a;
|
||||
--ink-muted: #6b6157;
|
||||
--line: #e0d8c8;
|
||||
--accent: #9a4a2f;
|
||||
--accent-soft: #f6e9e2;
|
||||
--surface-sunken: #eeecf6;
|
||||
--ink: #14121a;
|
||||
--ink-muted: #6b6577;
|
||||
--line: #e2dfec;
|
||||
--accent-soft: #efe9ff;
|
||||
--accent-ink: #ffffff;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@@ -63,21 +61,44 @@ body,
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--paper);
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-ui);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--canvas), 0 0 0 4px var(--accent, #7c5cff);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition;
|
||||
@apply inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
@@ -94,11 +115,11 @@ body {
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
filter: brightness(1.1);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
@@ -112,7 +133,7 @@ body {
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-md px-2.5 py-1.5 text-sm outline-none transition;
|
||||
@apply w-full rounded-lg px-2.5 py-1.5 text-sm outline-none transition;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
@@ -121,7 +142,7 @@ body {
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -134,17 +155,18 @@ body {
|
||||
}
|
||||
|
||||
.prose-serif {
|
||||
font-family: var(--font-sans);
|
||||
font-family: var(--font-serif);
|
||||
@apply text-[1.0625rem] leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown-preview {
|
||||
font-family: var(--font-sans);
|
||||
font-family: var(--font-serif);
|
||||
@apply text-[1.0625rem] leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown-preview :is(h1, h2, h3, h4) {
|
||||
@apply mt-5 mb-2 font-semibold first:mt-0;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.markdown-preview h1 {
|
||||
@@ -183,6 +205,7 @@ body {
|
||||
|
||||
.markdown-preview code {
|
||||
@apply rounded px-1 py-0.5 text-sm;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,15 +21,37 @@ export default function DashboardPage() {
|
||||
return novel.phase === 'Brainstorming' ? (
|
||||
<BrainstormingDashboard novel={novel} />
|
||||
) : (
|
||||
<OutliningDashboard novelId={novelId} />
|
||||
<WorkDashboard novelId={novelId} />
|
||||
)
|
||||
}
|
||||
|
||||
function BrainstormingDashboard({ novel }: { novel: Novel }) {
|
||||
const update = useUpdateNovel(novel.id)
|
||||
const { can } = useAuth()
|
||||
const canCreate = can('CreateContent', novel)
|
||||
const [addingCharacter, setAddingCharacter] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{canCreate && (
|
||||
<section className="grid gap-3 sm:grid-cols-2">
|
||||
<ActionCard
|
||||
icon={IconPlus}
|
||||
label="Add a character"
|
||||
hint="Most outline questions resolve once you know who wants what."
|
||||
onClick={() => setAddingCharacter(true)}
|
||||
primary
|
||||
/>
|
||||
<ActionCard
|
||||
icon={IconArrowRight}
|
||||
label="Move to outlining"
|
||||
hint="Once the shape is there, start turning notes into chapters."
|
||||
onClick={() => update.mutate({ phase: 'Outlining' })}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="card p-5">
|
||||
<h2 className="mb-1 text-sm font-semibold tracking-wide uppercase muted">Notes</h2>
|
||||
<p className="mb-3 text-sm muted">
|
||||
@@ -43,14 +68,26 @@ function BrainstormingDashboard({ novel }: { novel: Novel }) {
|
||||
readOnly={!can('Write', novel)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{addingCharacter && (
|
||||
<AddCharacterModal
|
||||
novelId={novel.id}
|
||||
onClose={() => setAddingCharacter(false)}
|
||||
onCreated={(id) => navigate(`/novels/${novel.id}/characters/${id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OutliningDashboard({ novelId }: { novelId: string }) {
|
||||
function WorkDashboard({ novelId }: { novelId: string }) {
|
||||
const { data: novel } = useNovel(novelId)
|
||||
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(novelId)
|
||||
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId)
|
||||
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId)
|
||||
const { data: activity, isPending: activityPending, error: activityError } = useNovelActivity(novelId)
|
||||
const { can } = useAuth()
|
||||
const canCreate = can('CreateContent', novel)
|
||||
|
||||
const recentCharacters = [...(characters ?? [])].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
@@ -61,6 +98,8 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
{canCreate && <QuickActions novelId={novelId} lastChapter={recentChapters[0]} />}
|
||||
|
||||
<section id="dashboard-activity-graph" className="card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Activity</h2>
|
||||
|
||||
@@ -174,6 +213,104 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function QuickActions({ novelId, lastChapter }: { novelId: string; lastChapter?: ChapterSummary }) {
|
||||
const navigate = useNavigate()
|
||||
const createChapter = useCreateChapter(novelId)
|
||||
const [addingCharacter, setAddingCharacter] = useState(false)
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 sm:grid-cols-3">
|
||||
<ActionCard
|
||||
icon={IconPlus}
|
||||
label="New chapter"
|
||||
hint="Start with a title — the outline can come later."
|
||||
onClick={() =>
|
||||
createChapter.mutate(
|
||||
{ title: 'Untitled chapter' },
|
||||
{ onSuccess: (chapter) => navigate(`chapters/${chapter.id}`) },
|
||||
)
|
||||
}
|
||||
busy={createChapter.isPending}
|
||||
primary
|
||||
/>
|
||||
<ActionCard icon={IconCharacters} label="New character" hint="Add someone new to the cast." onClick={() => setAddingCharacter(true)} />
|
||||
{lastChapter ? (
|
||||
<ActionCard to={`chapters/${lastChapter.id}`} icon={IconChapter} label="Continue writing" hint={lastChapter.title} />
|
||||
) : (
|
||||
<ActionCard to="chapters" icon={IconChapter} label="View chapters" hint="Nothing drafted yet." />
|
||||
)}
|
||||
|
||||
{createChapter.error && (
|
||||
<div className="sm:col-span-3">
|
||||
<ErrorNote error={createChapter.error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addingCharacter && (
|
||||
<AddCharacterModal
|
||||
novelId={novelId}
|
||||
onClose={() => setAddingCharacter(false)}
|
||||
onCreated={(id) => navigate(`characters/${id}`)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionCard({
|
||||
icon: ActionIcon,
|
||||
label,
|
||||
hint,
|
||||
onClick,
|
||||
primary,
|
||||
busy,
|
||||
to,
|
||||
}: {
|
||||
icon: typeof IconPlus
|
||||
label: string
|
||||
hint: string
|
||||
onClick?: () => void
|
||||
primary?: boolean
|
||||
busy?: boolean
|
||||
to?: string
|
||||
}) {
|
||||
const className =
|
||||
'card flex items-start gap-3 px-4 py-3.5 text-left transition hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60'
|
||||
const style = primary ? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' } : undefined
|
||||
const iconStyle = {
|
||||
background: primary ? 'var(--accent)' : 'var(--surface-sunken)',
|
||||
color: primary ? 'var(--accent-ink)' : 'var(--ink-muted)',
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg" style={iconStyle}>
|
||||
<ActionIcon />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-semibold" style={primary ? { color: 'var(--accent)' } : undefined}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="block truncate text-sm muted">{hint}</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link to={to} className={className} style={style}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className={className} style={style} onClick={onClick} disabled={busy}>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TagCloud({ novelId, tags }: { novelId: string; tags: TagSummary[] }) {
|
||||
const maxCount = Math.max(...tags.map((t) => t.totalCount), 1)
|
||||
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useLogout, useNovel, useUpdateNovel } from '../api/hooks'
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useParams, Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useChapter, useCharacters, useLogout, useNovel, useUpdateNovel } from '../api/hooks'
|
||||
import { novelPhaseColor } from '../api/stage'
|
||||
import { novelPhases } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { ErrorNote, Spinner } from '../components/ui'
|
||||
import {
|
||||
IconAgent,
|
||||
IconChapters,
|
||||
IconCharacters,
|
||||
IconDashboard,
|
||||
IconLocations,
|
||||
IconSettings,
|
||||
IconTags,
|
||||
} from '../components/icons'
|
||||
import { AgentPanel, type AgentContext } from '../components/AgentPanel'
|
||||
import { HelpButton } from '../keyboard/HelpButton'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
import type { ComponentType, CSSProperties, SVGProps } from 'react'
|
||||
|
||||
const sections: { to: string; label: string; end?: boolean }[] = [
|
||||
{ to: '', label: 'Dashboard', end: true },
|
||||
{ to: 'chapters', label: 'Chapters' },
|
||||
{ to: 'characters', label: 'Characters' },
|
||||
{ to: 'tags', label: 'Tags' },
|
||||
{ to: 'locations', label: 'Locations' },
|
||||
{ to: 'agent', label: 'Agent' },
|
||||
{ to: 'settings', label: 'Settings' },
|
||||
const sections: { to: string; label: string; end?: boolean; icon: ComponentType<SVGProps<SVGSVGElement>> }[] = [
|
||||
{ to: '', label: 'Dashboard', end: true, icon: IconDashboard },
|
||||
{ to: 'chapters', label: 'Chapters', icon: IconChapters },
|
||||
{ to: 'characters', label: 'Characters', icon: IconCharacters },
|
||||
{ to: 'tags', label: 'Tags', icon: IconTags },
|
||||
{ to: 'locations', label: 'Locations', icon: IconLocations },
|
||||
{ to: 'settings', label: 'Settings', icon: IconSettings },
|
||||
]
|
||||
|
||||
export default function NovelLayout() {
|
||||
const { novelId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { data: novel, isPending, error } = useNovel(novelId)
|
||||
const update = useUpdateNovel(novelId)
|
||||
const { user, can } = useAuth()
|
||||
const canWrite = can('Write', novel)
|
||||
const logout = useLogout()
|
||||
const [agentOpen, setAgentOpen] = useState(false)
|
||||
|
||||
const goTo = (path: string) => navigate(path ? `/novels/${novelId}/${path}` : `/novels/${novelId}`)
|
||||
|
||||
@@ -32,74 +46,170 @@ export default function NovelLayout() {
|
||||
useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' })
|
||||
useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' })
|
||||
useHotkey('g l', 'Go to locations', () => goTo('locations'), { group: 'Navigate' })
|
||||
useHotkey('g a', 'Go to agent', () => goTo('agent'), { group: 'Navigate' })
|
||||
useHotkey('g a', 'Toggle agent', () => setAgentOpen((o) => !o), { group: 'Navigate' })
|
||||
useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' })
|
||||
|
||||
const segments = (location.pathname.split(`/novels/${novelId}/`)[1] ?? '').split('/')
|
||||
const currentSegment = segments[0] ?? ''
|
||||
const entityId = segments[1]
|
||||
const currentSection = sections.find(({ to, end }) => (end ? currentSegment === '' : currentSegment === to))
|
||||
|
||||
const { data: chapterForContext } = useChapter(currentSegment === 'chapters' ? entityId : undefined)
|
||||
const { data: charactersForContext } = useCharacters(novelId)
|
||||
|
||||
let agentContext: AgentContext | null = null
|
||||
if (currentSegment === 'chapters' && chapterForContext) {
|
||||
agentContext = { label: `Chapter — ${chapterForContext.title}` }
|
||||
} else if (currentSegment === 'characters' && entityId) {
|
||||
const character = charactersForContext?.find((c) => c.id === entityId)
|
||||
if (character) agentContext = { label: `Character — ${character.name}` }
|
||||
}
|
||||
if (!agentContext && currentSection) {
|
||||
agentContext = { label: currentSection.label }
|
||||
}
|
||||
|
||||
const accentStyle = novel
|
||||
? ({
|
||||
'--accent': novelPhaseColor(novel.phase),
|
||||
'--accent-soft': `color-mix(in srgb, ${novelPhaseColor(novel.phase)} 16%, transparent)`,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<header className="sticky top-0 z-10 border-b" style={{ borderColor: 'var(--line)', background: 'var(--surface)' }}>
|
||||
<div className="mx-auto flex max-w-[100rem] items-center gap-4 px-6 py-3">
|
||||
<Link to="/" className="text-sm muted hover:underline">
|
||||
← Novels
|
||||
<div className="flex min-h-full" style={accentStyle}>
|
||||
<aside
|
||||
id="novel-sidebar"
|
||||
className="sticky top-0 flex h-screen w-60 shrink-0 flex-col"
|
||||
style={{ background: 'var(--surface)', borderRight: '1px solid var(--line)' }}
|
||||
>
|
||||
<Link to="/" className="flex items-center gap-2 px-5 py-5" style={{ fontFamily: 'var(--font-display)' }}>
|
||||
<span
|
||||
className="grid h-6 w-6 shrink-0 place-items-center rounded-md text-xs font-bold"
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-ink)' }}
|
||||
>
|
||||
N
|
||||
</span>
|
||||
<span className="text-lg font-semibold">Novelly</span>
|
||||
</Link>
|
||||
<Link to={`/novels/${novelId}`} className="truncate text-base font-semibold hover:underline">
|
||||
|
||||
<div className="px-5 pb-5">
|
||||
<Link
|
||||
to={`/novels/${novelId}`}
|
||||
className="block truncate text-base leading-snug font-semibold hover:opacity-85"
|
||||
style={{ fontFamily: 'var(--font-display)' }}
|
||||
>
|
||||
{novel?.title ?? '…'}
|
||||
</Link>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{novel && (
|
||||
<div className="relative mt-2 inline-block">
|
||||
<select
|
||||
className="input w-auto"
|
||||
id="novel-phase-select"
|
||||
className="appearance-none rounded-full py-1 pr-6 pl-2.5 text-xs font-semibold tracking-wide"
|
||||
value={novel.phase}
|
||||
disabled={!canWrite}
|
||||
onChange={(e) => update.mutate({ phase: e.target.value as (typeof novelPhases)[number] })}
|
||||
aria-label="Novel phase"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--accent) 16%, transparent)',
|
||||
color: 'var(--accent)',
|
||||
border: '1px solid color-mix(in srgb, var(--accent) 35%, transparent)',
|
||||
}}
|
||||
>
|
||||
{novelPhases.map((phase) => (
|
||||
<option key={phase} value={phase}>
|
||||
<option key={phase} value={phase} style={{ background: 'var(--surface)', color: 'var(--ink)' }}>
|
||||
{phase}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<HelpButton />
|
||||
{user && (
|
||||
<>
|
||||
<span className="truncate text-sm muted" title={user.email}>
|
||||
{user.displayName} · {user.globalRole}
|
||||
</span>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mx-auto flex max-w-[100rem] gap-1 px-6 pb-2 text-sm">
|
||||
{sections.map(({ to, label, end }) => (
|
||||
|
||||
<nav className="space-y-0.5 px-3">
|
||||
{sections.map(({ to, label, end, icon: SectionIcon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-1.5 font-medium transition ${isActive ? '' : 'muted hover:opacity-100'}`
|
||||
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition ${
|
||||
isActive ? '' : 'muted hover:bg-[var(--surface-sunken)] hover:opacity-100'
|
||||
}`
|
||||
}
|
||||
style={({ isActive }) =>
|
||||
isActive ? { background: 'var(--accent-soft)', color: 'var(--accent)' } : undefined
|
||||
}
|
||||
>
|
||||
<SectionIcon />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 px-3 pt-2">
|
||||
<button
|
||||
id="agent-toggle"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition"
|
||||
style={
|
||||
agentOpen
|
||||
? { background: 'var(--accent-soft)', color: 'var(--accent)' }
|
||||
: { color: 'var(--accent)' }
|
||||
}
|
||||
onClick={() => setAgentOpen((o) => !o)}
|
||||
>
|
||||
<IconAgent />
|
||||
Agent
|
||||
<span className="ml-auto text-xs muted">g a</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-2 pb-4" style={{ borderTop: '1px solid var(--line)' }}>
|
||||
{user && (
|
||||
<div className="flex items-center gap-2 px-2 pt-3 pb-2">
|
||||
<span
|
||||
className="grid h-7 w-7 shrink-0 place-items-center rounded-full text-xs font-semibold"
|
||||
style={{ background: 'var(--accent-soft)', color: 'var(--accent)' }}
|
||||
>
|
||||
{user.displayName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{user.displayName}</div>
|
||||
<div className="truncate text-xs muted">{user.globalRole}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<HelpButton />
|
||||
<button
|
||||
className="btn flex-1 justify-center"
|
||||
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<header
|
||||
className="sticky top-0 z-10 flex items-center gap-2 px-8 py-4 text-sm"
|
||||
style={{ background: 'var(--canvas)' }}
|
||||
>
|
||||
<Link to="/" className="muted hover:opacity-100 hover:underline">
|
||||
Novels
|
||||
</Link>
|
||||
<span className="muted">/</span>
|
||||
<span className="font-medium">{currentSection?.label ?? '…'}</span>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-[100rem] px-6 py-8">
|
||||
<main className="mx-auto max-w-[100rem] px-8 pb-10">
|
||||
{error && <ErrorNote error={error} />}
|
||||
{isPending ? <Spinner label="Loading novel" /> : <Outlet context={{ novelId }} />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<AgentPanel novelId={novelId} open={agentOpen} onClose={() => setAgentOpen(false)} context={agentContext} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user