Rename Project concept to Novel across the stack

Renames the domain concept from Project to Novel throughout the backend
(entities, DTOs, services, endpoints, ProjectAccessService/Permission,
ProjectId foreign keys), MCP server (tool names and routes), and the
React/Vite frontend (types, hooks, routes, components). Adds a new EF
Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to
preserve existing data instead of dropping/recreating tables. Updates
CLAUDE.md's structure section to reference Novels/ instead of Projects/.
This commit is contained in:
James Wampler
2026-08-17 23:03:09 -07:00
parent 0ab4f568b5
commit 4313c8f206
95 changed files with 3192 additions and 1660 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
import ProjectsPage from './pages/ProjectsPage'
import ProjectLayout from './pages/ProjectLayout'
import NovelsPage from './pages/NovelsPage'
import NovelLayout from './pages/NovelLayout'
import DashboardPage from './pages/DashboardPage'
import CharactersPage from './pages/CharactersPage'
import CharacterDetailPage from './pages/CharacterDetailPage'
@@ -34,8 +34,8 @@ export default function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route path="/" element={<NovelsPage />} />
<Route path="/novels/:novelId" element={<NovelLayout />}>
<Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} />
<Route path="characters/:characterId" element={<CharacterDetailPage />} />
@@ -45,7 +45,7 @@ export default function App() {
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
<Route path="*" element={<NovelsPage />} />
</Route>
</Routes>
</HelpOverlayProvider>
+112 -112
View File
@@ -15,10 +15,10 @@ import type {
ImportJob,
ImportJobStatus,
OpenQuestion,
Project,
ProjectMember,
ProjectRole,
ProjectSummary,
Novel,
NovelMember,
NovelRole,
NovelSummary,
TagReferences,
TagSummary,
User,
@@ -26,18 +26,18 @@ import type {
export const keys = {
me: ['me'] as const,
members: (projectId: string) => ['projects', projectId, 'members'] as const,
projects: ['projects'] as const,
members: (novelId: string) => ['novels', novelId, 'members'] as const,
novels: ['novels'] as const,
genres: ['genres'] as const,
project: (id: string) => ['projects', id] as const,
characters: (projectId: string) => ['projects', projectId, 'characters'] as const,
tags: (projectId: string) => ['projects', projectId, 'tags'] as const,
novel: (id: string) => ['novels', id] as const,
characters: (novelId: string) => ['novels', novelId, 'characters'] as const,
tags: (novelId: string) => ['novels', novelId, 'tags'] as const,
tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const,
characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const,
chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const,
questions: (projectId: string) => ['projects', projectId, 'questions'] as const,
chapters: (novelId: string) => ['novels', novelId, 'chapters'] as const,
questions: (novelId: string) => ['novels', novelId, 'questions'] as const,
chapter: (id: string) => ['chapters', id] as const,
conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const,
conversations: (novelId: string) => ['novels', novelId, 'conversations'] as const,
conversation: (id: string) => ['conversations', id] as const,
importJob: (id: string) => ['imports', id] as const,
}
@@ -82,103 +82,103 @@ export function useLogout() {
})
}
export const useProjectMembers = (projectId: string) =>
export const useNovelMembers = (novelId: string) =>
useQuery({
queryKey: keys.members(projectId),
queryFn: () => api.get<ProjectMember[]>(`/api/projects/${projectId}/members`),
queryKey: keys.members(novelId),
queryFn: () => api.get<NovelMember[]>(`/api/novels/${novelId}/members`),
retry: false,
})
export function useGrantAccess(projectId: string) {
export function useGrantAccess(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { email: string; projectRole: ProjectRole }) =>
api.post<ProjectMember>(`/api/projects/${projectId}/members`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }),
mutationFn: (body: { email: string; novelRole: NovelRole }) =>
api.post<NovelMember>(`/api/novels/${novelId}/members`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(novelId) }),
})
}
export function useRevokeAccess(projectId: string) {
export function useRevokeAccess(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (userId: string) => api.delete(`/api/projects/${projectId}/members/${userId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }),
mutationFn: (userId: string) => api.delete(`/api/novels/${novelId}/members/${userId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(novelId) }),
})
}
export const useProjects = () =>
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') })
export const useNovels = () =>
useQuery({ queryKey: keys.novels, queryFn: () => api.get<NovelSummary[]>('/api/novels') })
export const useProject = (id: string) =>
useQuery({ queryKey: keys.project(id), queryFn: () => api.get<Project>(`/api/projects/${id}`) })
export const useNovel = (id: string) =>
useQuery({ queryKey: keys.novel(id), queryFn: () => api.get<Novel>(`/api/novels/${id}`) })
export function useCreateProject() {
export function useCreateNovel() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { title: string; author?: string; genre?: string; logline?: string }) =>
api.post<Project>('/api/projects', body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }),
api.post<Novel>('/api/novels', body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
})
}
export function useUpdateProject(id: string) {
export function useUpdateNovel(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Partial<Project>) => api.patch<Project>(`/api/projects/${id}`, body),
mutationFn: (body: Partial<Novel>) => api.patch<Novel>(`/api/novels/${id}`, body),
onSuccess: (updated) => {
qc.setQueryData(keys.project(id), updated)
qc.invalidateQueries({ queryKey: keys.projects })
qc.setQueryData(keys.novel(id), updated)
qc.invalidateQueries({ queryKey: keys.novels })
},
})
}
export function useDeleteProject() {
export function useDeleteNovel() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/projects/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.projects }),
mutationFn: (id: string) => api.delete(`/api/novels/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.novels }),
})
}
export const useCharacters = (projectId: string) =>
export const useCharacters = (novelId: string) =>
useQuery({
queryKey: keys.characters(projectId),
queryFn: () => api.get<Character[]>(`/api/projects/${projectId}/characters`),
queryKey: keys.characters(novelId),
queryFn: () => api.get<Character[]>(`/api/novels/${novelId}/characters`),
})
export function useCreateCharacter(projectId: string) {
export function useCreateCharacter(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Partial<Character> & { name: string }) =>
api.post<Character>(`/api/projects/${projectId}/characters`, body),
api.post<Character>(`/api/novels/${novelId}/characters`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
},
})
}
export function useUpdateCharacter(projectId: string) {
export function useUpdateCharacter(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Character, 'tags'>> & { id: string; tags?: string[] }) =>
api.patch<Character>(`/api/characters/${id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
},
})
}
export function useDeleteCharacter(projectId: string) {
export function useDeleteCharacter(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/characters/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
export function useLinkCharacterIdentity(projectId: string) {
export function useLinkCharacterIdentity(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
@@ -192,19 +192,19 @@ export function useLinkCharacterIdentity(projectId: string) {
revealedInChapterId?: string | null
note?: string | null
}) => api.put<Character>(`/api/characters/${id}/identity`, { sameCharacterAsId, revealedInChapterId, note }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
export function useUnlinkCharacterIdentity(projectId: string) {
export function useUnlinkCharacterIdentity(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/characters/${id}/identity`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
export function useAddRelationship(projectId: string) {
export function useAddRelationship(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
@@ -226,15 +226,15 @@ export function useAddRelationship(projectId: string) {
reciprocalRelationshipType,
description,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
export function useRemoveRelationship(projectId: string) {
export function useRemoveRelationship(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (relationshipId: string) => api.delete(`/api/characters/relationships/${relationshipId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
@@ -245,55 +245,55 @@ export const useCharacterBeats = (characterId: string | undefined) =>
enabled: Boolean(characterId),
})
export function useCreateArcStage(projectId: string) {
export function useCreateArcStage(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ characterId, ...body }: { characterId: string; title: string; result?: string; chapterId?: string }) =>
api.post<ArcStage>(`/api/characters/${characterId}/arc`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
export function useUpdateArcStage(projectId: string) {
export function useUpdateArcStage(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: { id: string; title?: string; result?: string; chapterId?: string }) =>
api.patch<ArcStage>(`/api/arc-stages/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
export function useSetArcStageBeats(projectId: string, characterId: string | undefined) {
export function useSetArcStageBeats(novelId: string, characterId: string | undefined) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, beatIds }: { id: string; beatIds: string[] }) =>
api.post<ArcStage>(`/api/arc-stages/${id}/beats`, { beatIds }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.characterBeats(characterId ?? '') })
},
})
}
export function useDeleteArcStage(projectId: string) {
export function useDeleteArcStage(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/arc-stages/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
export function useReorderArcStages(projectId: string) {
export function useReorderArcStages(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ characterId, stageIds }: { characterId: string; stageIds: string[] }) =>
api.post<ArcStage[]>(`/api/characters/${characterId}/arc/reorder`, { stageIds }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(novelId) }),
})
}
export const useOpenQuestions = (
projectId: string,
novelId: string,
filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {},
) => {
const params = new URLSearchParams()
@@ -303,66 +303,66 @@ export const useOpenQuestions = (
const query = params.toString()
return useQuery({
queryKey: [...keys.questions(projectId), query] as const,
queryKey: [...keys.questions(novelId), query] as const,
queryFn: () =>
api.get<OpenQuestion[]>(`/api/projects/${projectId}/questions${query ? `?${query}` : ''}`),
api.get<OpenQuestion[]>(`/api/novels/${novelId}/questions${query ? `?${query}` : ''}`),
})
}
export function useRaiseQuestion(projectId: string) {
export function useRaiseQuestion(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { question: string; detail?: string; chapterId?: string; characterId?: string }) =>
api.post<OpenQuestion>(`/api/projects/${projectId}/questions`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }),
api.post<OpenQuestion>(`/api/novels/${novelId}/questions`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
})
}
export function useUpdateQuestion(projectId: string) {
export function useUpdateQuestion(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: { id: string; question?: string; detail?: string }) =>
api.patch<OpenQuestion>(`/api/questions/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
})
}
export function useResolveQuestion(projectId: string) {
export function useResolveQuestion(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, resolution, appendToNotes }: { id: string; resolution: string; appendToNotes: boolean }) =>
api.post<OpenQuestion>(`/api/questions/${id}/resolve`, { resolution, appendToNotes }),
onSuccess: (question) => {
qc.invalidateQueries({ queryKey: keys.questions(projectId) })
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.questions(novelId) })
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
if (question.chapterId) qc.invalidateQueries({ queryKey: keys.chapter(question.chapterId) })
},
})
}
export function useReopenQuestion(projectId: string) {
export function useReopenQuestion(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.post<OpenQuestion>(`/api/questions/${id}/reopen`, {}),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
})
}
export function useDeleteQuestion(projectId: string) {
export function useDeleteQuestion(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/questions/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(novelId) }),
})
}
export const useGenres = () =>
useQuery({ queryKey: keys.genres, queryFn: () => api.get<Genre[]>('/api/genres') })
export const useTags = (projectId: string) =>
export const useTags = (novelId: string) =>
useQuery({
queryKey: keys.tags(projectId),
queryFn: () => api.get<TagSummary[]>(`/api/projects/${projectId}/tags`),
queryKey: keys.tags(novelId),
queryFn: () => api.get<TagSummary[]>(`/api/novels/${novelId}/tags`),
})
export const useTagReferences = (tagId: string | undefined) =>
@@ -372,13 +372,13 @@ export const useTagReferences = (tagId: string | undefined) =>
enabled: Boolean(tagId),
})
export function useUpdateTag(projectId: string) {
export function useUpdateTag(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) =>
api.patch<TagSummary>(`/api/tags/${id}`, body),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.tagRefs(id) })
},
})
@@ -392,7 +392,7 @@ export function useDeleteTag() {
})
}
export function useCreateBeat(chapterId: string, projectId: string) {
export function useCreateBeat(chapterId: string, novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (
@@ -400,12 +400,12 @@ export function useCreateBeat(chapterId: string, projectId: string) {
) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
},
})
}
export function useUpdateBeat(chapterId: string, projectId: string) {
export function useUpdateBeat(chapterId: string, novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
@@ -415,7 +415,7 @@ export function useUpdateBeat(chapterId: string, projectId: string) {
api.patch<Beat>(`/api/beats/${id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
},
})
}
@@ -458,10 +458,10 @@ export function useMoveBeats(chapterId: string) {
})
}
export const useChapters = (projectId: string) =>
export const useChapters = (novelId: string) =>
useQuery({
queryKey: keys.chapters(projectId),
queryFn: () => api.get<ChapterSummary[]>(`/api/projects/${projectId}/chapters`),
queryKey: keys.chapters(novelId),
queryFn: () => api.get<ChapterSummary[]>(`/api/novels/${novelId}/chapters`),
})
export const useChapter = (id: string | undefined) =>
@@ -471,40 +471,40 @@ export const useChapter = (id: string | undefined) =>
enabled: Boolean(id),
})
export function useCreateChapter(projectId: string) {
export function useCreateChapter(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Partial<Chapter> & { title: string }) =>
api.post<Chapter>(`/api/projects/${projectId}/chapters`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }),
api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
})
}
export function useUpdateChapter(projectId: string) {
export function useUpdateChapter(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Chapter, 'tags'>> & { id: string; tags?: string[] }) =>
api.patch<Chapter>(`/api/chapters/${id}`, body),
onSuccess: (updated) => {
qc.setQueryData(keys.chapter(updated.id), updated)
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
},
})
}
export function useDeleteChapter(projectId: string) {
export function useDeleteChapter(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/chapters/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(projectId) }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
})
}
export const useConversations = (projectId: string) =>
export const useConversations = (novelId: string) =>
useQuery({
queryKey: keys.conversations(projectId),
queryFn: () => api.get<ConversationSummary[]>(`/api/projects/${projectId}/agent/conversations`),
queryKey: keys.conversations(novelId),
queryFn: () => api.get<ConversationSummary[]>(`/api/novels/${novelId}/agent/conversations`),
})
export const useConversation = (id: string | undefined) =>
@@ -514,19 +514,19 @@ export const useConversation = (id: string | undefined) =>
enabled: Boolean(id),
})
export function useSendAgentMessage(projectId: string) {
export function useSendAgentMessage(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { message: string; conversationId?: string }) =>
api.post<AgentTurn>(`/api/projects/${projectId}/agent/messages`, body),
api.post<AgentTurn>(`/api/novels/${novelId}/agent/messages`, body),
onSuccess: (turn) => {
qc.invalidateQueries({ queryKey: keys.conversations(projectId) })
qc.invalidateQueries({ queryKey: keys.conversations(novelId) })
qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) })
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
qc.invalidateQueries({ queryKey: keys.questions(projectId) })
qc.invalidateQueries({ queryKey: keys.project(projectId) })
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.questions(novelId) })
qc.invalidateQueries({ queryKey: keys.novel(novelId) })
},
})
}
+18 -18
View File
@@ -28,19 +28,19 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
export type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
export const novelPhases: NovelPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
export type GlobalRole = 'Admin' | 'Writer' | 'Editor' | 'Reviewer'
export const globalRoles: GlobalRole[] = ['Admin', 'Writer', 'Editor', 'Reviewer']
export type ProjectRole = 'Writer' | 'Editor' | 'Reviewer'
export type NovelRole = 'Writer' | 'Editor' | 'Reviewer'
export const projectRoles: ProjectRole[] = ['Writer', 'Editor', 'Reviewer']
export const novelRoles: NovelRole[] = ['Writer', 'Editor', 'Reviewer']
export type ProjectMyRole = 'Admin' | 'Owner' | 'Writer' | 'Editor' | 'Reviewer'
export type NovelMyRole = 'Admin' | 'Owner' | 'Writer' | 'Editor' | 'Reviewer'
export interface User {
id: string
@@ -49,11 +49,11 @@ export interface User {
globalRole: GlobalRole
}
export interface ProjectMember {
export interface NovelMember {
userId: string
email: string
displayName: string
projectRole: ProjectRole
novelRole: NovelRole
grantedAt: string
}
@@ -62,21 +62,21 @@ export interface Genre {
name: string
}
export interface ProjectSummary {
export interface NovelSummary {
id: string
title: string
author: string | null
genre: string | null
logline: string | null
targetWordCount: number | null
phase: ProjectPhase
phase: NovelPhase
characterCount: number
chapterCount: number
wordCount: number
updatedAt: string
}
export interface Project {
export interface Novel {
id: string
title: string
author: string | null
@@ -85,9 +85,9 @@ export interface Project {
synopsis: string | null
notes: string | null
targetWordCount: number | null
phase: ProjectPhase
phase: NovelPhase
ownerId: string | null
myRole: ProjectMyRole | null
myRole: NovelMyRole | null
createdAt: string
updatedAt: string
}
@@ -173,7 +173,7 @@ export interface CharacterBeat {
export interface Character {
id: string
projectId: string
novelId: string
name: string
role: CharacterRole
importance: CharacterImportance
@@ -210,7 +210,7 @@ export interface CharacterIdentity {
export interface ChapterSummary {
id: string
projectId: string
novelId: string
number: number
title: string
summary: string | null
@@ -233,7 +233,7 @@ export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'wordCount'>
export interface OpenQuestion {
id: string
projectId: string
novelId: string
question: string
detail: string | null
chapterId: string | null
@@ -264,7 +264,7 @@ export interface AgentMessage {
export interface ConversationSummary {
id: string
projectId: string
novelId: string
title: string
messageCount: number
updatedAt: string
@@ -284,7 +284,7 @@ export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | '
export interface ImportJob {
id: string
sourceRoot: string
projectId: string | null
novelId: string | null
status: ImportJobStatus
statusMessage: string | null
chaptersCompleted: number
@@ -297,7 +297,7 @@ export type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete'
export interface ImportInspection {
readiness: ImportReadiness
projectId: string | null
novelId: string | null
chaptersCompleted: number
chaptersTotal: number
completedPasses: string[]
+6 -6
View File
@@ -1,10 +1,10 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { useMe } from '../api/hooks'
import type { Project, ProjectMyRole, User } from '../api/types'
import type { Novel, NovelMyRole, User } from '../api/types'
export type AuthPermission = 'CreateNovel' | 'Write' | 'CreateContent' | 'DeleteContent' | 'ManageAccess'
const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = {
const novelPermissionsByRole: Record<NovelMyRole, AuthPermission[]> = {
Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
Writer: ['Write', 'CreateContent', 'DeleteContent'],
@@ -15,7 +15,7 @@ const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = {
interface AuthValue {
user: User | null
isPending: boolean
can: (permission: AuthPermission, project?: Pick<Project, 'myRole'> | null) => boolean
can: (permission: AuthPermission, novel?: Pick<Novel, 'myRole'> | null) => boolean
}
const AuthContext = createContext<AuthValue>({ user: null, isPending: true, can: () => false })
@@ -28,10 +28,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => ({
user,
isPending,
can: (permission, project) => {
can: (permission, novel) => {
if (permission === 'CreateNovel') return user?.globalRole === 'Admin' || user?.globalRole === 'Writer'
const myRole = project?.myRole
return myRole ? projectPermissionsByRole[myRole].includes(permission) : false
const myRole = novel?.myRole
return myRole ? novelPermissionsByRole[myRole].includes(permission) : false
},
}),
[user, isPending],
+13 -13
View File
@@ -13,22 +13,22 @@ import type { ArcStage, Character } from '../api/types'
import { AutoField, ErrorNote } from './ui'
export function CharacterArc({
projectId,
novelId,
character,
canWrite,
canCreate,
canDelete,
}: {
projectId: string
novelId: string
character: Character
canWrite: boolean
canCreate: boolean
canDelete: boolean
}) {
const { data: chapters } = useChapters(projectId)
const { data: chapters } = useChapters(novelId)
const { data: beats } = useCharacterBeats(character.id)
const create = useCreateArcStage(projectId)
const reorder = useReorderArcStages(projectId)
const create = useCreateArcStage(novelId)
const reorder = useReorderArcStages(novelId)
const [title, setTitle] = useState('')
@@ -70,7 +70,7 @@ export function CharacterArc({
{stages.map((stage, index) => (
<ArcStageRow
key={stage.id}
projectId={projectId}
novelId={novelId}
stage={stage}
chapters={chapters ?? []}
unassignedBeats={unassignedBeats}
@@ -115,7 +115,7 @@ export function CharacterArc({
}
function ArcStageRow({
projectId,
novelId,
stage,
chapters,
unassignedBeats,
@@ -125,7 +125,7 @@ function ArcStageRow({
canWrite,
canDelete,
}: {
projectId: string
novelId: string
stage: ArcStage
chapters: { id: string; number: number; title: string }[]
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
@@ -135,9 +135,9 @@ function ArcStageRow({
canWrite: boolean
canDelete: boolean
}) {
const update = useUpdateArcStage(projectId)
const remove = useDeleteArcStage(projectId)
const setBeats = useSetArcStageBeats(projectId, stage.characterId)
const update = useUpdateArcStage(novelId)
const remove = useDeleteArcStage(novelId)
const setBeats = useSetArcStageBeats(novelId, stage.characterId)
const addBeat = (beatId: string) => {
if (!beatId) return
@@ -182,7 +182,7 @@ function ArcStageRow({
<Link
className="shrink-0 tabular-nums underline"
style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${beat.chapterId}#beat-${beat.id}`}
to={`/novels/${novelId}/chapters/${beat.chapterId}#beat-${beat.id}`}
>
{beat.chapterNumber}.{beat.sortOrder}
</Link>
@@ -235,7 +235,7 @@ function ArcStageRow({
<Link
className="text-xs underline"
style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${stage.chapterId}`}
to={`/novels/${novelId}/chapters/${stage.chapterId}`}
>
Open outline
</Link>
@@ -4,12 +4,12 @@ import type { ArcStage } from '../api/types'
import { ErrorNote, Spinner } from './ui'
export function CharacterBeats({
projectId,
novelId,
characterId,
characterName,
arcStages,
}: {
projectId: string
novelId: string
characterId: string
characterName: string
arcStages: ArcStage[]
@@ -49,7 +49,7 @@ export function CharacterBeats({
<Link
className="underline"
style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${beat.chapterId}#beat-${beat.id}`}
to={`/novels/${novelId}/chapters/${beat.chapterId}#beat-${beat.id}`}
>
{beat.chapterNumber}.{beat.sortOrder}
</Link>
@@ -8,9 +8,9 @@ type MenuState = {
onCreated: (characterId: string) => void
}
export function useCharacterContextMenu(projectId: string) {
export function useCharacterContextMenu(novelId: string) {
const [menu, setMenu] = useState<MenuState | null>(null)
const createCharacter = useCreateCharacter(projectId)
const createCharacter = useCreateCharacter(novelId)
const handleContextMenu = (
e: MouseEvent<HTMLTextAreaElement>,
@@ -5,11 +5,11 @@ import type { BeatCharacter } from '../api/types'
export function CharacterChip({
character,
projectId,
novelId,
onRemove,
}: {
character: BeatCharacter
projectId?: string
novelId?: string
onRemove?: () => void
}) {
return (
@@ -17,9 +17,9 @@ export function CharacterChip({
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}
>
{projectId ? (
{novelId ? (
<Link
to={`/projects/${projectId}/characters/${character.id}`}
to={`/novels/${novelId}/characters/${character.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
@@ -43,19 +43,19 @@ export function CharacterChip({
}
export function CharacterMultiSelect({
projectId,
novelId,
selected,
options,
onChange,
}: {
projectId: string
novelId: string
selected: BeatCharacter[]
options: { id: string; name: string }[]
onChange: (ids: string[]) => void
}) {
const [draft, setDraft] = useState('')
const listId = 'character-multiselect-options'
const createCharacter = useCreateCharacter(projectId)
const createCharacter = useCreateCharacter(novelId)
const add = () => {
const name = draft.trim()
@@ -83,7 +83,7 @@ export function CharacterMultiSelect({
return (
<div className="flex flex-wrap items-center gap-1.5">
{selected.map((character) => (
<CharacterChip key={character.id} character={character} projectId={projectId} onRemove={() => remove(character.id)} />
<CharacterChip key={character.id} character={character} novelId={novelId} onRemove={() => remove(character.id)} />
))}
<input
className="input w-28 flex-1 px-2 py-0.5 text-xs"
@@ -9,7 +9,7 @@ export function ImportDialog({
onImported,
}: {
onClose: () => void
onImported?: (projectId: string) => void
onImported?: (novelId: string) => void
}) {
const [sourceRoot, setSourceRoot] = useState('')
const [inspection, setInspection] = useState<ImportInspection | null>(null)
@@ -24,8 +24,8 @@ export function ImportDialog({
useEffect(() => {
if (job.data?.status !== 'Completed') return
qc.invalidateQueries()
if (job.data.projectId) onImported?.(job.data.projectId)
}, [job.data?.status, job.data?.projectId, qc, onImported])
if (job.data.novelId) onImported?.(job.data.novelId)
}, [job.data?.status, job.data?.novelId, qc, onImported])
const check = (e: FormEvent) => {
e.preventDefault()
@@ -173,7 +173,7 @@ function ImportReadinessSummary({
) : (
<div className="mt-2">
<p className="mb-2" style={{ color: 'var(--accent)' }}>
This permanently deletes the project this import created its chapters,
This permanently deletes the novel this import created its chapters,
characters, everything then starts over. This cannot be undone.
</p>
<div className="flex gap-2">
@@ -10,13 +10,13 @@ import type { OpenQuestion } from '../api/types'
import { ErrorNote, Spinner } from './ui'
export function OpenQuestions({
projectId,
novelId,
scope,
canCreate,
canWrite,
canDelete,
}: {
projectId: string
novelId: string
scope: { chapterId?: string; characterId?: string }
canCreate: boolean
canWrite: boolean
@@ -25,11 +25,11 @@ export function OpenQuestions({
const [showResolved, setShowResolved] = useState(false)
const [asking, setAsking] = useState(false)
const { data: questions, isPending, error } = useOpenQuestions(projectId, {
const { data: questions, isPending, error } = useOpenQuestions(novelId, {
...scope,
includeResolved: showResolved,
})
const raise = useRaiseQuestion(projectId)
const raise = useRaiseQuestion(novelId)
const [question, setQuestion] = useState('')
const [detail, setDetail] = useState('')
@@ -109,7 +109,7 @@ export function OpenQuestions({
{questions.map((q) => (
<QuestionRow
key={q.id}
projectId={projectId}
novelId={novelId}
question={q}
scope={scope}
canWrite={canWrite}
@@ -128,21 +128,21 @@ export function OpenQuestions({
}
function QuestionRow({
projectId,
novelId,
question,
scope,
canWrite,
canDelete,
}: {
projectId: string
novelId: string
question: OpenQuestion
scope: { chapterId?: string; characterId?: string }
canWrite: boolean
canDelete: boolean
}) {
const resolve = useResolveQuestion(projectId)
const reopen = useReopenQuestion(projectId)
const remove = useDeleteQuestion(projectId)
const resolve = useResolveQuestion(novelId)
const reopen = useReopenQuestion(novelId)
const remove = useDeleteQuestion(novelId)
const [resolving, setResolving] = useState(false)
const [resolution, setResolution] = useState('')
+3 -3
View File
@@ -12,11 +12,11 @@ const starters = [
]
export default function AgentPage() {
const { projectId = '' } = useParams()
const { data: conversations } = useConversations(projectId)
const { novelId = '' } = useParams()
const { data: conversations } = useConversations(novelId)
const [conversationId, setConversationId] = useState<string | undefined>()
const { data: conversation } = useConversation(conversationId)
const send = useSendAgentMessage(projectId)
const send = useSendAgentMessage(novelId)
const [draft, setDraft] = useState('')
const endRef = useRef<HTMLDivElement>(null)
+27 -27
View File
@@ -10,7 +10,7 @@ import {
useDeleteBeat,
useDeleteChapter,
useMoveBeats,
useProject,
useNovel,
useReorderBeats,
useTags,
useUpdateBeat,
@@ -30,24 +30,24 @@ import { useHotkey } from '../keyboard/HotkeysContext'
type ChapterTab = 'outline' | 'prose'
export default function ChapterPage() {
const { projectId = '', chapterId = '' } = useParams()
const { novelId = '', chapterId = '' } = useParams()
const navigate = useNavigate()
const { data: chapter, isPending, error } = useChapter(chapterId)
const { data: project } = useProject(projectId)
const { data: characters } = useCharacters(projectId)
const { data: allTags } = useTags(projectId)
const { data: chapters } = useChapters(projectId)
const createChapter = useCreateChapter(projectId)
const update = useUpdateChapter(projectId)
const remove = useDeleteChapter(projectId)
const createBeat = useCreateBeat(chapterId, projectId)
const { data: novel } = useNovel(novelId)
const { data: characters } = useCharacters(novelId)
const { data: allTags } = useTags(novelId)
const { data: chapters } = useChapters(novelId)
const createChapter = useCreateChapter(novelId)
const update = useUpdateChapter(novelId)
const remove = useDeleteChapter(novelId)
const createBeat = useCreateBeat(chapterId, novelId)
const [tab, setTab] = useState<ChapterTab>('outline')
const [confirmingDelete, setConfirmingDelete] = useState(false)
const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId)
const { handleContextMenu, menuElement } = useCharacterContextMenu(novelId)
const { can } = useAuth()
const canWrite = can('Write', project)
const canCreate = can('CreateContent', project)
const canDelete = can('DeleteContent', project)
const canWrite = can('Write', novel)
const canCreate = can('CreateContent', novel)
const canDelete = can('DeleteContent', novel)
useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
@@ -59,13 +59,13 @@ export default function ChapterPage() {
useHotkey(
'[',
'Previous chapter',
() => prevChapter && navigate(`/projects/${projectId}/chapters/${prevChapter.id}`),
() => prevChapter && navigate(`/novels/${novelId}/chapters/${prevChapter.id}`),
{ group: 'Chapter', enabled: Boolean(prevChapter) },
)
useHotkey(
']',
'Next chapter',
() => nextChapter && navigate(`/projects/${projectId}/chapters/${nextChapter.id}`),
() => nextChapter && navigate(`/novels/${novelId}/chapters/${nextChapter.id}`),
{ group: 'Chapter', enabled: Boolean(nextChapter) },
)
@@ -84,13 +84,13 @@ export default function ChapterPage() {
return (
<div>
<div className="mb-4 flex items-center justify-between gap-4">
<Link to={`/projects/${projectId}/chapters`} className="text-sm muted hover:underline">
<Link to={`/novels/${novelId}/chapters`} className="text-sm muted hover:underline">
All chapters
</Link>
<div className="flex items-center gap-3 text-sm">
{prevChapter ? (
<Link
to={`/projects/${projectId}/chapters/${prevChapter.id}`}
to={`/novels/${novelId}/chapters/${prevChapter.id}`}
className="muted hover:underline"
title={`Chapter ${prevChapter.number}: ${prevChapter.title}`}
>
@@ -103,7 +103,7 @@ export default function ChapterPage() {
)}
{nextChapter ? (
<Link
to={`/projects/${projectId}/chapters/${nextChapter.id}`}
to={`/novels/${novelId}/chapters/${nextChapter.id}`}
className="muted hover:underline"
title={`Chapter ${nextChapter.number}: ${nextChapter.title}`}
>
@@ -208,7 +208,7 @@ export default function ChapterPage() {
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`}
onConfirm={() =>
remove.mutate(chapter.id, {
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
onSuccess: () => navigate(`/novels/${novelId}/chapters`),
})
}
onClose={() => setConfirmingDelete(false)}
@@ -236,7 +236,7 @@ export default function ChapterPage() {
<BeatTable
chapter={chapter}
projectId={projectId}
novelId={novelId}
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
otherChapters={chapters?.filter((c) => c.id !== chapter.id) ?? []}
createChapter={createChapter}
@@ -278,7 +278,7 @@ export default function ChapterPage() {
</div>
<OpenQuestions
projectId={projectId}
novelId={novelId}
scope={{ chapterId: chapter.id }}
canCreate={canCreate}
canWrite={canWrite}
@@ -307,7 +307,7 @@ const MOVE_TO_NEW_CHAPTER = '__new__'
function BeatTable({
chapter,
projectId,
novelId,
characters,
otherChapters,
createChapter,
@@ -317,7 +317,7 @@ function BeatTable({
canDelete,
}: {
chapter: Chapter
projectId: string
novelId: string
characters: { id: string; name: string }[]
otherChapters: ChapterSummary[]
createChapter: ReturnType<typeof useCreateChapter>
@@ -329,7 +329,7 @@ function BeatTable({
canWrite: boolean
canDelete: boolean
}) {
const update = useUpdateBeat(chapter.id, projectId)
const update = useUpdateBeat(chapter.id, novelId)
const remove = useDeleteBeat(chapter.id)
const reorder = useReorderBeats(chapter.id)
const assignCharacter = useAssignCharacterToBeats(chapter.id)
@@ -579,7 +579,7 @@ function BeatTable({
<td className="px-2 py-2 align-top">
<CharacterMultiSelect
projectId={projectId}
novelId={novelId}
selected={beat.characters}
options={characters}
onChange={(characterIds) => patch(beat.id, { characterIds })}
@@ -726,7 +726,7 @@ function BeatTable({
{beat.characters.length > 0 ? (
<div className="flex flex-wrap gap-1">
{beat.characters.map((character) => (
<CharacterChip key={character.id} character={character} projectId={projectId} />
<CharacterChip key={character.id} character={character} novelId={novelId} />
))}
</div>
) : (
+7 -7
View File
@@ -1,17 +1,17 @@
import { Link, useParams } from 'react-router-dom'
import { useChapters, useCreateChapter, useProject } from '../api/hooks'
import { useChapters, useCreateChapter, useNovel } from '../api/hooks'
import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
import { TagChip } from '../components/TagEditor'
import { useAuth } from '../auth/AuthContext'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function ChaptersPage() {
const { projectId = '' } = useParams()
const { data: chapters, isPending, error } = useChapters(projectId)
const { data: project } = useProject(projectId)
const { novelId = '' } = useParams()
const { data: chapters, isPending, error } = useChapters(novelId)
const { data: novel } = useNovel(novelId)
const { can } = useAuth()
const canCreate = can('CreateContent', project)
const create = useCreateChapter(projectId)
const canCreate = can('CreateContent', novel)
const create = useCreateChapter(novelId)
useHotkey('n', 'Add chapter', () => canCreate && create.mutate({ title: 'Untitled chapter' }), { group: 'Chapters' })
@@ -45,7 +45,7 @@ export default function ChaptersPage() {
{chapters?.map((chapter) => (
<li key={chapter.id}>
<Link
to={`/projects/${projectId}/chapters/${chapter.id}`}
to={`/novels/${novelId}/chapters/${chapter.id}`}
className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md"
>
<span className="w-8 shrink-0 text-right text-sm font-semibold muted">
@@ -6,7 +6,7 @@ import {
useCharacters,
useDeleteCharacter,
useLinkCharacterIdentity,
useProject,
useNovel,
useRemoveRelationship,
useTags,
useUnlinkCharacterIdentity,
@@ -23,13 +23,13 @@ import { CharacterBeats } from '../components/CharacterBeats'
import { OpenQuestions } from '../components/OpenQuestions'
export default function CharacterDetailPage() {
const { projectId = '', characterId = '' } = useParams()
const { data: characters, isPending, error } = useCharacters(projectId)
const { data: project } = useProject(projectId)
const { novelId = '', characterId = '' } = useParams()
const { data: characters, isPending, error } = useCharacters(novelId)
const { data: novel } = useNovel(novelId)
const { can } = useAuth()
const canWrite = can('Write', project)
const canCreate = can('CreateContent', project)
const canDelete = can('DeleteContent', project)
const canWrite = can('Write', novel)
const canCreate = can('CreateContent', novel)
const canDelete = can('DeleteContent', novel)
if (isPending) return <Spinner label="Loading character" />
if (error) return <ErrorNote error={error} />
@@ -39,7 +39,7 @@ export default function CharacterDetailPage() {
if (!character) {
return (
<div className="grid gap-4">
<Link to={`/projects/${projectId}/characters`} className="text-sm muted hover:underline">
<Link to={`/novels/${novelId}/characters`} className="text-sm muted hover:underline">
All characters
</Link>
<EmptyState title="Character not found" hint="It may have been deleted." />
@@ -49,13 +49,13 @@ export default function CharacterDetailPage() {
return (
<div className="grid gap-4">
<Link to={`/projects/${projectId}/characters`} className="text-sm muted hover:underline">
<Link to={`/novels/${novelId}/characters`} className="text-sm muted hover:underline">
All characters
</Link>
<CharacterSheet
key={character.id}
projectId={projectId}
novelId={novelId}
character={character}
canWrite={canWrite}
canCreate={canCreate}
@@ -66,28 +66,28 @@ export default function CharacterDetailPage() {
}
function CharacterSheet({
projectId,
novelId,
character,
canWrite,
canCreate,
canDelete,
}: {
projectId: string
novelId: string
character: Character
canWrite: boolean
canCreate: boolean
canDelete: boolean
}) {
const navigate = useNavigate()
const { data: allTags } = useTags(projectId)
const { data: allCharacters } = useCharacters(projectId)
const { data: chapters } = useChapters(projectId)
const update = useUpdateCharacter(projectId)
const remove = useDeleteCharacter(projectId)
const linkIdentity = useLinkCharacterIdentity(projectId)
const unlinkIdentity = useUnlinkCharacterIdentity(projectId)
const addRelationship = useAddRelationship(projectId)
const removeRelationship = useRemoveRelationship(projectId)
const { data: allTags } = useTags(novelId)
const { data: allCharacters } = useCharacters(novelId)
const { data: chapters } = useChapters(novelId)
const update = useUpdateCharacter(novelId)
const remove = useDeleteCharacter(novelId)
const linkIdentity = useLinkCharacterIdentity(novelId)
const unlinkIdentity = useUnlinkCharacterIdentity(novelId)
const addRelationship = useAddRelationship(novelId)
const removeRelationship = useRemoveRelationship(novelId)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
update.mutate({ id: character.id, ...body })
@@ -281,7 +281,7 @@ function CharacterSheet({
{(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc
projectId={projectId}
novelId={novelId}
character={character}
canWrite={canWrite}
canCreate={canCreate}
@@ -290,14 +290,14 @@ function CharacterSheet({
)}
<CharacterBeats
projectId={projectId}
novelId={novelId}
characterId={character.id}
characterName={character.name}
arcStages={character.arcStages}
/>
<OpenQuestions
projectId={projectId}
novelId={novelId}
scope={{ characterId: character.id }}
canCreate={canCreate}
canWrite={canWrite}
@@ -309,7 +309,7 @@ function CharacterSheet({
title="Delete character"
message={`Delete ${character.name}? This cannot be undone.`}
onConfirm={() =>
remove.mutate(character.id, { onSuccess: () => navigate(`/projects/${projectId}/characters`) })
remove.mutate(character.id, { onSuccess: () => navigate(`/novels/${novelId}/characters`) })
}
onClose={() => setConfirmingDelete(false)}
/>
+16 -16
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useCharacters, useCreateCharacter, useProject, useTags } from '../api/hooks'
import { useCharacters, useCreateCharacter, useNovel, useTags } from '../api/hooks'
import { characterImportances, characterRoles, type Character, type CharacterImportance, type CharacterRole } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
@@ -10,13 +10,13 @@ import { useHotkey } from '../keyboard/HotkeysContext'
type SortKey = 'name' | 'updatedAt'
export default function CharactersPage() {
const { projectId = '' } = useParams()
const { novelId = '' } = useParams()
const navigate = useNavigate()
const { data: characters, isPending, error } = useCharacters(projectId)
const { data: project } = useProject(projectId)
const { data: allTags } = useTags(projectId)
const { data: characters, isPending, error } = useCharacters(novelId)
const { data: novel } = useNovel(novelId)
const { data: allTags } = useTags(novelId)
const { can } = useAuth()
const canCreate = can('CreateContent', project)
const canCreate = can('CreateContent', novel)
const [adding, setAdding] = useState(false)
const [search, setSearch] = useState('')
@@ -76,9 +76,9 @@ export default function CharactersPage() {
/>
{adding && (
<AddCharacterModal
projectId={projectId}
novelId={novelId}
onClose={() => setAdding(false)}
onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)}
onCreated={(id) => navigate(`/novels/${novelId}/characters/${id}`)}
/>
)}
</div>
@@ -115,13 +115,13 @@ export default function CharactersPage() {
onSortDir={setSortDir}
/>
<CharacterTable characters={sorted} projectId={projectId} />
<CharacterTable characters={sorted} novelId={novelId} />
{adding && (
<AddCharacterModal
projectId={projectId}
novelId={novelId}
onClose={() => setAdding(false)}
onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)}
onCreated={(id) => navigate(`/novels/${novelId}/characters/${id}`)}
/>
)}
</div>
@@ -271,7 +271,7 @@ function CharacterFilterBar({
)
}
function CharacterTable({ characters, projectId }: { characters: Character[]; projectId: string }) {
function CharacterTable({ characters, novelId }: { characters: Character[]; novelId: string }) {
if (characters.length === 0) {
return (
<div className="card p-5 text-sm muted" id="character-table-empty">
@@ -297,7 +297,7 @@ function CharacterTable({ characters, projectId }: { characters: Character[]; pr
<tr key={character.id} className="align-top" style={{ borderTop: '1px solid var(--line)' }}>
<td className="p-0">
<Link
to={`/projects/${projectId}/characters/${character.id}`}
to={`/novels/${novelId}/characters/${character.id}`}
className="block px-3 py-2 transition hover:bg-[var(--surface-sunken)]"
>
<div className="font-medium">{character.name}</div>
@@ -325,15 +325,15 @@ function CharacterTable({ characters, projectId }: { characters: Character[]; pr
}
function AddCharacterModal({
projectId,
novelId,
onClose,
onCreated,
}: {
projectId: string
novelId: string
onClose: () => void
onCreated: (id: string) => void
}) {
const create = useCreateCharacter(projectId)
const create = useCreateCharacter(novelId)
const [name, setName] = useState('')
const [role, setRole] = useState<Character['role']>('Supporting')
const [importance, setImportance] = useState<Character['importance']>('Supporting')
+19 -19
View File
@@ -1,6 +1,6 @@
import { Link, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useProject, useTags, useUpdateProject } from '../api/hooks'
import type { Project, TagSummary } from '../api/types'
import { useChapters, useCharacters, useNovel, useTags, useUpdateNovel } from '../api/hooks'
import type { Novel, TagSummary } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
@@ -8,21 +8,21 @@ const RECENT_COUNT = 5
const RECENT_CHAPTERS_COUNT = 10
export default function DashboardPage() {
const { projectId = '' } = useParams()
const { data: project, isPending, error } = useProject(projectId)
const { novelId = '' } = useParams()
const { data: novel, isPending, error } = useNovel(novelId)
if (error) return <ErrorNote error={error} />
if (isPending || !project) return <Spinner label="Loading novel" />
if (isPending || !novel) return <Spinner label="Loading novel" />
return project.phase === 'Brainstorming' ? (
<BrainstormingDashboard project={project} />
return novel.phase === 'Brainstorming' ? (
<BrainstormingDashboard novel={novel} />
) : (
<OutliningDashboard projectId={projectId} />
<OutliningDashboard novelId={novelId} />
)
}
function BrainstormingDashboard({ project }: { project: Project }) {
const update = useUpdateProject(project.id)
function BrainstormingDashboard({ novel }: { novel: Novel }) {
const update = useUpdateNovel(novel.id)
const { can } = useAuth()
return (
@@ -33,22 +33,22 @@ function BrainstormingDashboard({ project }: { project: Project }) {
there's a shape to work from.
</p>
<AutoField
value={project.notes}
value={novel.notes}
multiline
rows={20}
serif
placeholder="Start anywhere."
onCommit={(notes) => update.mutate({ notes })}
readOnly={!can('Write', project)}
readOnly={!can('Write', novel)}
/>
</div>
)
}
function OutliningDashboard({ projectId }: { projectId: string }) {
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(projectId)
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(projectId)
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(projectId)
function OutliningDashboard({ novelId }: { novelId: string }) {
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 recentCharacters = [...(characters ?? [])].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
@@ -150,7 +150,7 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
) : !tags || tags.length === 0 ? (
<EmptyState title="No tags yet" hint="Tag a character, chapter or beat and it shows up here." />
) : (
<TagCloud projectId={projectId} tags={tags} />
<TagCloud novelId={novelId} tags={tags} />
)}
</section>
</div>
@@ -159,7 +159,7 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
)
}
function TagCloud({ projectId, tags }: { projectId: string; tags: TagSummary[] }) {
function TagCloud({ novelId, tags }: { novelId: string; tags: TagSummary[] }) {
const maxCount = Math.max(...tags.map((t) => t.totalCount), 1)
const sizeFor = (count: number) => {
@@ -174,7 +174,7 @@ function TagCloud({ projectId, tags }: { projectId: string; tags: TagSummary[] }
.map((tag) => (
<Link
key={tag.id}
to={`/projects/${projectId}/tags?tag=${tag.id}`}
to={`/novels/${novelId}/tags?tag=${tag.id}`}
className="leading-none font-medium transition hover:underline"
style={{
fontSize: `${sizeFor(tag.totalCount)}rem`,
@@ -1,6 +1,6 @@
import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom'
import { useLogout, useProject, useUpdateProject } from '../api/hooks'
import { projectPhases } from '../api/types'
import { useLogout, useNovel, useUpdateNovel } from '../api/hooks'
import { novelPhases } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ErrorNote, Spinner } from '../components/ui'
import { HelpButton } from '../keyboard/HelpButton'
@@ -15,16 +15,16 @@ const sections: { to: string; label: string; end?: boolean }[] = [
{ to: 'settings', label: 'Settings' },
]
export default function ProjectLayout() {
const { projectId = '' } = useParams()
export default function NovelLayout() {
const { novelId = '' } = useParams()
const navigate = useNavigate()
const { data: project, isPending, error } = useProject(projectId)
const update = useUpdateProject(projectId)
const { data: novel, isPending, error } = useNovel(novelId)
const update = useUpdateNovel(novelId)
const { user, can } = useAuth()
const canWrite = can('Write', project)
const canWrite = can('Write', novel)
const logout = useLogout()
const goTo = (path: string) => navigate(path ? `/projects/${projectId}/${path}` : `/projects/${projectId}`)
const goTo = (path: string) => navigate(path ? `/novels/${novelId}/${path}` : `/novels/${novelId}`)
useHotkey('g d', 'Go to dashboard', () => goTo(''), { group: 'Navigate' })
useHotkey('g o', 'Go to outline', () => goTo('chapters'), { group: 'Navigate' })
@@ -40,19 +40,19 @@ export default function ProjectLayout() {
<Link to="/" className="text-sm muted hover:underline">
Novels
</Link>
<Link to={`/projects/${projectId}`} className="truncate text-base font-semibold hover:underline">
{project?.title ?? '…'}
<Link to={`/novels/${novelId}`} className="truncate text-base font-semibold hover:underline">
{novel?.title ?? '…'}
</Link>
<div className="ml-auto flex items-center gap-3">
{project && (
{novel && (
<select
className="input w-auto"
value={project.phase}
value={novel.phase}
disabled={!canWrite}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof novelPhases)[number] })}
aria-label="Novel phase"
>
{projectPhases.map((phase) => (
{novelPhases.map((phase) => (
<option key={phase} value={phase}>
{phase}
</option>
@@ -96,7 +96,7 @@ export default function ProjectLayout() {
<main className="mx-auto max-w-[100rem] px-6 py-8">
{error && <ErrorNote error={error} />}
{isPending ? <Spinner label="Loading project" /> : <Outlet context={{ projectId }} />}
{isPending ? <Spinner label="Loading novel" /> : <Outlet context={{ novelId }} />}
</main>
</div>
)
@@ -1,14 +1,14 @@
import { useId, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useCreateProject, useGenres, useLogout, useProjects } from '../api/hooks'
import { useCreateNovel, useGenres, useLogout, useNovels } from '../api/hooks'
import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog'
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
import { HelpButton } from '../keyboard/HelpButton'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function ProjectsPage() {
const { data: projects, isPending, error } = useProjects()
export default function NovelsPage() {
const { data: novels, isPending, error } = useNovels()
const { user, can } = useAuth()
const logout = useLogout()
const [creating, setCreating] = useState(false)
@@ -62,9 +62,9 @@ export default function ProjectsPage() {
</p>
{error && <ErrorNote error={error} />}
{isPending && <Spinner label="Loading projects" />}
{isPending && <Spinner label="Loading novels" />}
{projects?.length === 0 && (
{novels?.length === 0 && (
<EmptyState
title="Nothing here yet"
hint="Start with a title and a one-sentence logline. Everything else can come later."
@@ -72,27 +72,27 @@ export default function ProjectsPage() {
)}
<div className="grid gap-3">
{projects?.map((project) => (
{novels?.map((novel) => (
<Link
key={project.id}
to={`/projects/${project.id}`}
key={novel.id}
to={`/novels/${novel.id}`}
className="card block px-5 py-4 transition hover:shadow-md"
>
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-lg font-semibold">{project.title}</h2>
<h2 className="text-lg font-semibold">{novel.title}</h2>
<span className="text-xs muted">
{project.genre ?? 'Uncategorised'}
{project.author && ` · ${project.author}`}
{novel.genre ?? 'Uncategorised'}
{novel.author && ` · ${novel.author}`}
</span>
</div>
{project.logline && <p className="mt-1 text-sm muted">{project.logline}</p>}
{novel.logline && <p className="mt-1 text-sm muted">{novel.logline}</p>}
<div className="mt-3 flex gap-4 text-xs muted">
<span>{project.characterCount} characters</span>
<span>{project.chapterCount} chapters</span>
<span>{novel.characterCount} characters</span>
<span>{novel.chapterCount} chapters</span>
<span>
{project.wordCount.toLocaleString()}
{project.targetWordCount
? ` / ${project.targetWordCount.toLocaleString()} words`
{novel.wordCount.toLocaleString()}
{novel.targetWordCount
? ` / ${novel.targetWordCount.toLocaleString()} words`
: ' words'}
</span>
</div>
@@ -100,11 +100,11 @@ export default function ProjectsPage() {
))}
</div>
{creating && <CreateProjectModal onClose={() => setCreating(false)} />}
{creating && <CreateNovelModal onClose={() => setCreating(false)} />}
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
onImported={(projectId) => navigate(`/projects/${projectId}`)}
onImported={(novelId) => navigate(`/novels/${novelId}`)}
/>
)}
</main>
@@ -112,8 +112,8 @@ export default function ProjectsPage() {
)
}
function CreateProjectModal({ onClose }: { onClose: () => void }) {
const create = useCreateProject()
function CreateNovelModal({ onClose }: { onClose: () => void }) {
const create = useCreateNovel()
const { data: genres } = useGenres()
const genreListId = useId()
const [title, setTitle] = useState('')
+39 -39
View File
@@ -3,42 +3,42 @@ import { useNavigate, useParams } from 'react-router-dom'
import {
useChapters,
useCharacters,
useDeleteProject,
useDeleteNovel,
useGenres,
useGrantAccess,
useProject,
useProjectMembers,
useNovel,
useNovelMembers,
useRevokeAccess,
useUpdateProject,
useUpdateNovel,
} from '../api/hooks'
import { ApiError } from '../api/client'
import { projectRoles, type ProjectMember, type ProjectRole } from '../api/types'
import { novelRoles, type NovelMember, type NovelRole } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
export default function SettingsPage() {
const { projectId = '' } = useParams()
const { novelId = '' } = useParams()
const navigate = useNavigate()
const { data: project, isPending } = useProject(projectId)
const { data: characters } = useCharacters(projectId)
const { data: chapters } = useChapters(projectId)
const { data: novel, isPending } = useNovel(novelId)
const { data: characters } = useCharacters(novelId)
const { data: chapters } = useChapters(novelId)
const { data: genres } = useGenres()
const update = useUpdateProject(projectId)
const remove = useDeleteProject()
const update = useUpdateNovel(novelId)
const remove = useDeleteNovel()
const [importing, setImporting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const { can } = useAuth()
if (isPending || !project) return <Spinner label="Loading brief" />
if (isPending || !novel) return <Spinner label="Loading brief" />
const canWrite = can('Write', project)
const canDelete = can('DeleteContent', project)
const canManageAccess = can('ManageAccess', project)
const canWrite = can('Write', novel)
const canDelete = can('DeleteContent', novel)
const canManageAccess = can('ManageAccess', novel)
const drafted = chapters?.reduce((sum, c) => sum + c.wordCount, 0) ?? 0
const target = project.targetWordCount ?? 0
const target = novel.targetWordCount ?? 0
const percent = target > 0 ? Math.min(100, Math.round((drafted / target) * 100)) : null
return (
@@ -48,20 +48,20 @@ export default function SettingsPage() {
<div className="grid gap-4">
<AutoField
label="Title"
value={project.title}
value={novel.title}
onCommit={(title) => title.trim() && update.mutate({ title })}
readOnly={!canWrite}
/>
<div className="grid gap-4 sm:grid-cols-2">
<AutoField
label="Author"
value={project.author}
value={novel.author}
onCommit={(author) => update.mutate({ author })}
readOnly={!canWrite}
/>
<AutoField
label="Genre"
value={project.genre}
value={novel.genre}
placeholder="Pick one, or name your own."
suggestions={genres?.map((g) => g.name)}
onCommit={(genre) => update.mutate({ genre })}
@@ -70,7 +70,7 @@ export default function SettingsPage() {
</div>
<AutoField
label="Logline"
value={project.logline}
value={novel.logline}
multiline
rows={2}
placeholder="Who wants what, and what stands in the way."
@@ -79,7 +79,7 @@ export default function SettingsPage() {
/>
<AutoField
label="Synopsis"
value={project.synopsis}
value={novel.synopsis}
multiline
rows={8}
serif
@@ -89,7 +89,7 @@ export default function SettingsPage() {
/>
<AutoField
label="Notes"
value={project.notes}
value={novel.notes}
multiline
rows={4}
placeholder="Theme, tone, comparable titles, research threads."
@@ -103,11 +103,11 @@ export default function SettingsPage() {
type="number"
min={0}
step={1000}
defaultValue={project.targetWordCount ?? ''}
defaultValue={novel.targetWordCount ?? ''}
readOnly={!canWrite}
onBlur={(e) => {
const value = Number(e.target.value)
if (Number.isFinite(value) && value !== project.targetWordCount) {
if (Number.isFinite(value) && value !== novel.targetWordCount) {
update.mutate({ targetWordCount: value || null })
}
}}
@@ -176,20 +176,20 @@ export default function SettingsPage() {
)}
</aside>
{canManageAccess && <ProjectPeople projectId={projectId} />}
{canManageAccess && <NovelPeople novelId={novelId} />}
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
onImported={(newProjectId) => navigate(`/projects/${newProjectId}`)}
onImported={(newNovelId) => navigate(`/novels/${newNovelId}`)}
/>
)}
{confirmingDelete && (
<ConfirmModal
title="Delete novel"
message={`Delete "${project.title}" and everything in it? This cannot be undone.`}
onConfirm={() => remove.mutate(projectId, { onSuccess: () => navigate('/') })}
message={`Delete "${novel.title}" and everything in it? This cannot be undone.`}
onConfirm={() => remove.mutate(novelId, { onSuccess: () => navigate('/') })}
onClose={() => setConfirmingDelete(false)}
/>
)}
@@ -197,13 +197,13 @@ export default function SettingsPage() {
)
}
function ProjectPeople({ projectId }: { projectId: string }) {
const { data: members, isPending, error } = useProjectMembers(projectId)
const grant = useGrantAccess(projectId)
const revoke = useRevokeAccess(projectId)
function NovelPeople({ novelId }: { novelId: string }) {
const { data: members, isPending, error } = useNovelMembers(novelId)
const grant = useGrantAccess(novelId)
const revoke = useRevokeAccess(novelId)
const [email, setEmail] = useState('')
const [projectRole, setProjectRole] = useState<ProjectRole>('Reviewer')
const [revoking, setRevoking] = useState<ProjectMember | null>(null)
const [novelRole, setNovelRole] = useState<NovelRole>('Reviewer')
const [revoking, setRevoking] = useState<NovelMember | null>(null)
if (isPending) return null
if (error instanceof ApiError && (error.status === 403 || error.status === 401)) return null
@@ -211,7 +211,7 @@ function ProjectPeople({ projectId }: { projectId: string }) {
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
grant.mutate({ email: email.trim(), projectRole }, { onSuccess: () => setEmail('') })
grant.mutate({ email: email.trim(), novelRole }, { onSuccess: () => setEmail('') })
}
return (
@@ -240,9 +240,9 @@ function ProjectPeople({ projectId }: { projectId: string }) {
</div>
<div className="flex shrink-0 items-center gap-2">
<Select
value={member.projectRole}
options={projectRoles}
onChange={(next) => grant.mutate({ email: member.email, projectRole: next })}
value={member.novelRole}
options={novelRoles}
onChange={(next) => grant.mutate({ email: member.email, novelRole: next })}
/>
<button className="btn btn-danger" onClick={() => setRevoking(member)}>
Remove
@@ -264,7 +264,7 @@ function ProjectPeople({ projectId }: { projectId: string }) {
placeholder="someone@example.com"
/>
</label>
<Select value={projectRole} options={projectRoles} onChange={setProjectRole} />
<Select value={novelRole} options={novelRoles} onChange={setNovelRole} />
<button type="submit" className="btn btn-primary" disabled={!email.trim() || grant.isPending}>
{grant.isPending ? 'Granting' : 'Grant'}
</button>
+13 -13
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteTag, useProject, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
@@ -8,12 +8,12 @@ import { TagChip } from '../components/TagEditor'
import { TagColorPicker } from '../components/TagColorPicker'
export default function TagsPage() {
const { projectId = '' } = useParams()
const { data: tags, isPending, error } = useTags(projectId)
const { data: project } = useProject(projectId)
const { novelId = '' } = useParams()
const { data: tags, isPending, error } = useTags(novelId)
const { data: novel } = useNovel(novelId)
const { can } = useAuth()
const canWrite = can('Write', project)
const canDelete = can('DeleteContent', project)
const canWrite = can('Write', novel)
const canDelete = can('DeleteContent', novel)
const [searchParams, setSearchParams] = useSearchParams()
const selectedId = searchParams.get('tag') ?? undefined
@@ -70,7 +70,7 @@ export default function TagsPage() {
) : (
<TagReferencePanel
key={selected.id}
projectId={projectId}
novelId={novelId}
tagId={selected.id}
canWrite={canWrite}
canDelete={canDelete}
@@ -82,18 +82,18 @@ export default function TagsPage() {
}
function TagReferencePanel({
projectId,
novelId,
tagId,
canWrite,
canDelete,
}: {
projectId: string
novelId: string
tagId: string
canWrite: boolean
canDelete: boolean
}) {
const { data, isPending, error } = useTagReferences(tagId)
const update = useUpdateTag(projectId)
const update = useUpdateTag(novelId)
const remove = useDeleteTag()
const [confirmingDelete, setConfirmingDelete] = useState(false)
@@ -155,7 +155,7 @@ function TagReferencePanel({
<ul className="grid gap-1 text-sm">
{data.characters.map((c) => (
<li key={c.id}>
<Link to={`/projects/${projectId}/characters`} className="hover:underline">
<Link to={`/novels/${novelId}/characters`} className="hover:underline">
{c.name}
</Link>
<span className="muted"> {c.role}</span>
@@ -172,7 +172,7 @@ function TagReferencePanel({
{data.chapters.map((c) => (
<li key={c.id}>
<Link
to={`/projects/${projectId}/chapters/${c.id}`}
to={`/novels/${novelId}/chapters/${c.id}`}
className="font-medium hover:underline"
>
{c.number}. {c.title}
@@ -191,7 +191,7 @@ function TagReferencePanel({
{data.beats.map((b) => (
<li key={b.id}>
<Link
to={`/projects/${projectId}/chapters/${b.chapterId}`}
to={`/novels/${novelId}/chapters/${b.chapterId}`}
className="font-medium hover:underline"
>
{b.title}