Add novel dashboard with project phases; fix beat/chapter overflow
Dashboard replaces the tabbed overview: Outline + Characters cards (recent-first), gated by a new Project.Phase (Brainstorming -> Outlining -> Writing -> Editing -> Complete). Brainstorming shows just a notes editor; existing projects backfill to Outlining. Old brief editor moves to a Settings route. Beat table now renders as plain readable text, turning into editable fields only for the clicked row. Fixed horizontal overflow on the chapters list and beat table caused by unconstrained CSS grid/table tracks sizing to their widest unwrapped child. Widened the project layout's max width.
This commit is contained in:
@@ -1,24 +1,26 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
import ProjectLayout from './pages/ProjectLayout'
|
||||
import OverviewPage from './pages/OverviewPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import CharactersPage from './pages/CharactersPage'
|
||||
import TagsPage from './pages/TagsPage'
|
||||
import ChaptersPage from './pages/ChaptersPage'
|
||||
import ChapterPage from './pages/ChapterPage'
|
||||
import AgentPage from './pages/AgentPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<ProjectsPage />} />
|
||||
<Route path="/projects/:projectId" element={<ProjectLayout />}>
|
||||
<Route index element={<OverviewPage />} />
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="characters" element={<CharactersPage />} />
|
||||
<Route path="chapters" element={<ChaptersPage />} />
|
||||
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
||||
<Route path="tags" element={<TagsPage />} />
|
||||
<Route path="agent" element={<AgentPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<ProjectsPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -30,6 +30,11 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
|
||||
|
||||
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
|
||||
|
||||
/** Where a novel is in its lifecycle, from first notes to a finished manuscript. */
|
||||
export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
|
||||
|
||||
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
|
||||
|
||||
export interface ProjectSummary {
|
||||
id: string
|
||||
title: string
|
||||
@@ -37,6 +42,7 @@ export interface ProjectSummary {
|
||||
genre: string | null
|
||||
logline: string | null
|
||||
targetWordCount: number | null
|
||||
phase: ProjectPhase
|
||||
characterCount: number
|
||||
chapterCount: number
|
||||
wordCount: number
|
||||
@@ -52,6 +58,7 @@ export interface Project {
|
||||
synopsis: string | null
|
||||
notes: string | null
|
||||
targetWordCount: number | null
|
||||
phase: ProjectPhase
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -194,6 +201,7 @@ export interface ChapterSummary {
|
||||
sceneCount: number
|
||||
wordCount: number
|
||||
tags: Tag[]
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'sceneCount' | 'wordCount'> {
|
||||
|
||||
@@ -111,6 +111,7 @@ body {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '../api/hooks'
|
||||
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
|
||||
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
|
||||
import { TagEditor } from '../components/TagEditor'
|
||||
import { TagChip, TagEditor } from '../components/TagEditor'
|
||||
import { OpenQuestions } from '../components/OpenQuestions'
|
||||
|
||||
export default function ChapterPage() {
|
||||
@@ -224,6 +224,7 @@ function BeatTable({
|
||||
const update = useUpdateBeat(chapter.id, projectId)
|
||||
const remove = useDeleteBeat(chapter.id)
|
||||
const reorder = useReorderBeats(chapter.id)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
if (chapter.beats.length === 0) {
|
||||
return (
|
||||
@@ -247,129 +248,182 @@ function BeatTable({
|
||||
|
||||
return (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full min-w-[64rem] border-collapse text-sm">
|
||||
<table className="w-full table-fixed border-collapse text-sm">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
<th className="w-16 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
|
||||
<th className="w-56 px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
|
||||
<th className="w-36 px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
||||
<th className="w-10 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
|
||||
<th className="w-[14%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
|
||||
<th className="w-[10%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
||||
Character
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
||||
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
||||
What happened
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left text-xs font-semibold uppercase muted">What's next</th>
|
||||
<th className="w-32 px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</th>
|
||||
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">What's next</th>
|
||||
<th className="w-[12%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</th>
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{chapter.beats.map((beat, index) => (
|
||||
// Anchored so the character page's beat list can link straight to this row.
|
||||
<tr key={beat.id} id={`beat-${beat.id}`} style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-4 text-xs muted">{index + 1}</span>
|
||||
<div className="flex flex-col">
|
||||
{chapter.beats.map((beat, index) =>
|
||||
editingId === beat.id ? (
|
||||
<tr
|
||||
key={beat.id}
|
||||
id={`beat-${beat.id}`}
|
||||
style={{ borderBottom: '1px solid var(--line)', background: 'var(--accent-soft)' }}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setEditingId(null)
|
||||
}}
|
||||
>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-4 text-xs muted">{index + 1}</span>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
className="text-xs leading-none muted disabled:opacity-25"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0 || reorder.isPending}
|
||||
aria-label="Move beat up"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
className="text-xs leading-none muted disabled:opacity-25"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === chapter.beats.length - 1 || reorder.isPending}
|
||||
aria-label="Move beat down"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<AutoField
|
||||
value={beat.title}
|
||||
placeholder="Three to five words"
|
||||
onCommit={(title) => title.trim() && patch(beat.id, { title })}
|
||||
/>
|
||||
<div className="mt-1.5">
|
||||
<TagEditor
|
||||
tags={beat.tags}
|
||||
suggestions={suggestions}
|
||||
onChange={(tags) => patch(beat.id, { tags })}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<select
|
||||
className="input"
|
||||
value={beat.characterName ?? '—'}
|
||||
onChange={(e) => {
|
||||
const match = characters.find((c) => c.name === e.target.value)
|
||||
patch(beat.id, { characterId: match?.id ?? null })
|
||||
}}
|
||||
>
|
||||
{['—', ...characters.map((c) => c.name)].map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<AutoField
|
||||
value={beat.whatHappened}
|
||||
multiline
|
||||
rows={3}
|
||||
serif
|
||||
placeholder="The event itself."
|
||||
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<AutoField
|
||||
value={beat.whatsNext}
|
||||
multiline
|
||||
rows={3}
|
||||
serif
|
||||
placeholder="What it sets in motion."
|
||||
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<select
|
||||
className="input"
|
||||
value={beat.sceneTitle ?? '—'}
|
||||
onChange={(e) => {
|
||||
const match = chapter.scenes.find((s) => s.title === e.target.value)
|
||||
patch(beat.id, { sceneId: match?.id ?? null })
|
||||
}}
|
||||
>
|
||||
{['—', ...chapter.scenes.map((s) => s.title)].map((title) => (
|
||||
<option key={title}>{title}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
className="text-xs leading-none muted disabled:opacity-25"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0 || reorder.isPending}
|
||||
aria-label="Move beat up"
|
||||
className="text-xs leading-none"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => setEditingId(null)}
|
||||
aria-label="Done editing beat"
|
||||
>
|
||||
▲
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
className="text-xs leading-none muted disabled:opacity-25"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === chapter.beats.length - 1 || reorder.isPending}
|
||||
aria-label="Move beat down"
|
||||
className="text-xs muted leading-none transition hover:opacity-100"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)}
|
||||
aria-label={`Delete beat ${beat.title}`}
|
||||
>
|
||||
▼
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr
|
||||
key={beat.id}
|
||||
id={`beat-${beat.id}`}
|
||||
className="cursor-pointer transition hover:brightness-110"
|
||||
style={{ borderBottom: '1px solid var(--line)' }}
|
||||
onClick={() => setEditingId(beat.id)}
|
||||
>
|
||||
<td className="px-2 py-2 align-top text-xs muted">{index + 1}</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<AutoField
|
||||
value={beat.title}
|
||||
placeholder="Three to five words"
|
||||
onCommit={(title) => title.trim() && patch(beat.id, { title })}
|
||||
/>
|
||||
<div className="mt-1.5">
|
||||
<TagEditor
|
||||
tags={beat.tags}
|
||||
suggestions={suggestions}
|
||||
onChange={(tags) => patch(beat.id, { tags })}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<div className="font-medium">{beat.title}</div>
|
||||
{beat.tags.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{beat.tags.map((tag) => (
|
||||
<TagChip key={tag.id} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<select
|
||||
className="input"
|
||||
value={beat.characterName ?? '—'}
|
||||
onChange={(e) => {
|
||||
const match = characters.find((c) => c.name === e.target.value)
|
||||
patch(beat.id, { characterId: match?.id ?? null })
|
||||
}}
|
||||
>
|
||||
{['—', ...characters.map((c) => c.name)].map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top">{beat.characterName ?? <span className="muted">—</span>}</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<AutoField
|
||||
value={beat.whatHappened}
|
||||
multiline
|
||||
rows={3}
|
||||
serif
|
||||
placeholder="The event itself."
|
||||
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top whitespace-pre-wrap break-words">
|
||||
{beat.whatHappened ?? <span className="muted">—</span>}
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<AutoField
|
||||
value={beat.whatsNext}
|
||||
multiline
|
||||
rows={3}
|
||||
serif
|
||||
placeholder="What it sets in motion."
|
||||
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top whitespace-pre-wrap break-words">
|
||||
{beat.whatsNext ?? <span className="muted">—</span>}
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<select
|
||||
className="input"
|
||||
value={beat.sceneTitle ?? '—'}
|
||||
onChange={(e) => {
|
||||
const match = chapter.scenes.find((s) => s.title === e.target.value)
|
||||
patch(beat.id, { sceneId: match?.id ?? null })
|
||||
}}
|
||||
>
|
||||
{['—', ...chapter.scenes.map((s) => s.title)].map((title) => (
|
||||
<option key={title}>{title}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-2 align-top">{beat.sceneTitle ?? <span className="muted">—</span>}</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<button
|
||||
className="text-xs muted transition hover:opacity-100"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)}
|
||||
aria-label={`Delete beat ${beat.title}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<td className="px-2 py-2 align-top" />
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function ChaptersPage() {
|
||||
hint="A chapter is a summary paragraph plus a table of beats. Add one and start outlining."
|
||||
/>
|
||||
) : (
|
||||
<ul className="grid gap-2">
|
||||
<ul className="grid grid-cols-1 gap-2">
|
||||
{chapters?.map((chapter) => (
|
||||
<li key={chapter.id}>
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useChapters, useCharacters, useProject, useUpdateProject } from '../api/hooks'
|
||||
import type { Project } from '../api/types'
|
||||
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
||||
|
||||
const RECENT_COUNT = 5
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: project, isPending, error } = useProject(projectId)
|
||||
|
||||
if (error) return <ErrorNote error={error} />
|
||||
if (isPending || !project) return <Spinner label="Loading novel" />
|
||||
|
||||
return project.phase === 'Brainstorming' ? (
|
||||
<BrainstormingDashboard project={project} />
|
||||
) : (
|
||||
<OutliningDashboard projectId={projectId} />
|
||||
)
|
||||
}
|
||||
|
||||
/** The only thing the writer needs before there's a shape to the book: a place to dump notes. */
|
||||
function BrainstormingDashboard({ project }: { project: Project }) {
|
||||
const update = useUpdateProject(project.id)
|
||||
|
||||
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={project.notes}
|
||||
multiline
|
||||
rows={20}
|
||||
serif
|
||||
placeholder="Start anywhere."
|
||||
onCommit={(notes) => update.mutate({ notes })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Chapter outlines and character development — where most of the outlining phase happens. */
|
||||
function OutliningDashboard({ projectId }: { projectId: string }) {
|
||||
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(projectId)
|
||||
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(projectId)
|
||||
|
||||
const recentCharacters = [...(characters ?? [])].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
)
|
||||
const recentChapters = [...(chapters ?? [])].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<nav className="flex flex-wrap gap-4 text-sm">
|
||||
<Link to="tags" className="muted hover:underline">
|
||||
Tags
|
||||
</Link>
|
||||
<Link to="agent" className="muted hover:underline">
|
||||
Agent
|
||||
</Link>
|
||||
<Link to="settings" className="muted hover:underline">
|
||||
Settings
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<section className="card p-5 lg:col-span-2">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">Outline</h2>
|
||||
<Link to="chapters" className="text-sm muted hover:underline">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{chaptersError && <ErrorNote error={chaptersError} />}
|
||||
{chaptersPending ? (
|
||||
<Spinner label="Loading chapters" />
|
||||
) : recentChapters.length === 0 ? (
|
||||
<EmptyState title="No chapters yet" hint="Add the first chapter to start outlining." />
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-2">
|
||||
{recentChapters.slice(0, RECENT_COUNT).map((chapter) => (
|
||||
<li key={chapter.id}>
|
||||
<Link
|
||||
to={`chapters/${chapter.id}`}
|
||||
className="card flex items-center gap-3 px-4 py-2.5 transition hover:shadow-sm"
|
||||
>
|
||||
<span className="w-6 shrink-0 text-right text-sm font-semibold muted">
|
||||
{chapter.number}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{chapter.title}</div>
|
||||
</div>
|
||||
<StatusBadge status={chapter.status} />
|
||||
<span className="shrink-0 text-xs muted">
|
||||
{new Date(chapter.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">Characters</h2>
|
||||
<Link to="characters" className="text-sm muted hover:underline">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{charactersError && <ErrorNote error={charactersError} />}
|
||||
{charactersPending ? (
|
||||
<Spinner label="Loading characters" />
|
||||
) : recentCharacters.length === 0 ? (
|
||||
<EmptyState title="No characters yet" hint="Add the protagonist first." />
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-2">
|
||||
{recentCharacters.slice(0, RECENT_COUNT).map((character) => (
|
||||
<li key={character.id}>
|
||||
<Link
|
||||
to="characters"
|
||||
className="card flex items-center justify-between gap-3 px-4 py-2.5 transition hover:shadow-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{character.name}</div>
|
||||
<div className="text-xs muted">
|
||||
{character.role} · {character.importance}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs muted">
|
||||
{new Date(character.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +1,41 @@
|
||||
import { NavLink, Outlet, useParams, Link } from 'react-router-dom'
|
||||
import { useProject } from '../api/hooks'
|
||||
import { Outlet, useParams, Link } from 'react-router-dom'
|
||||
import { useProject, useUpdateProject } from '../api/hooks'
|
||||
import { projectPhases } from '../api/types'
|
||||
import { ErrorNote, Spinner } from '../components/ui'
|
||||
|
||||
const tabs = [
|
||||
{ to: '', label: 'Overview', end: true },
|
||||
{ to: 'characters', label: 'Characters' },
|
||||
{ to: 'chapters', label: 'Chapters' },
|
||||
{ to: 'tags', label: 'Tags' },
|
||||
{ to: 'agent', label: 'Agent' },
|
||||
]
|
||||
|
||||
export default function ProjectLayout() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: project, isPending, error } = useProject(projectId)
|
||||
const update = useUpdateProject(projectId)
|
||||
|
||||
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-6xl items-center gap-4 px-6 py-3">
|
||||
<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>
|
||||
<h1 className="truncate text-base font-semibold">{project?.title ?? '…'}</h1>
|
||||
</div>
|
||||
<nav className="mx-auto flex max-w-6xl gap-1 px-4">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.label}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) =>
|
||||
`border-b-2 px-3 py-2 text-sm font-medium transition ${
|
||||
isActive ? 'border-current' : 'border-transparent'
|
||||
}`
|
||||
}
|
||||
style={({ isActive }) => ({ color: isActive ? 'var(--accent)' : 'var(--ink-muted)' })}
|
||||
<Link to={`/projects/${projectId}`} className="truncate text-base font-semibold hover:underline">
|
||||
{project?.title ?? '…'}
|
||||
</Link>
|
||||
{project && (
|
||||
<select
|
||||
className="input ml-auto w-auto"
|
||||
value={project.phase}
|
||||
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
|
||||
aria-label="Novel phase"
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
{projectPhases.map((phase) => (
|
||||
<option key={phase} value={phase}>
|
||||
{phase}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
||||
<main className="mx-auto max-w-[100rem] px-6 py-8">
|
||||
{error && <ErrorNote error={error} />}
|
||||
{isPending ? <Spinner label="Loading project" /> : <Outlet context={{ projectId }} />}
|
||||
</main>
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { useChapters, useCharacters, useDeleteProject, useProject, useUpdateProj
|
||||
import { ImportDialog } from '../components/ImportDialog'
|
||||
import { AutoField, ErrorNote, Spinner } from '../components/ui'
|
||||
|
||||
export default function OverviewPage() {
|
||||
export default function SettingsPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: project, isPending } = useProject(projectId)
|
||||
@@ -138,7 +138,7 @@ export default function OverviewPage() {
|
||||
className="btn w-full"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete “${project.title}” and everything in it? This cannot be undone.`)) {
|
||||
if (confirm(`Delete "${project.title}" and everything in it? This cannot be undone.`)) {
|
||||
remove.mutate(projectId, { onSuccess: () => navigate('/') })
|
||||
}
|
||||
}}
|
||||
Reference in New Issue
Block a user