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:
@@ -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
@@ -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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,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('')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+15
-15
@@ -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>
|
||||
)
|
||||
+21
-21
@@ -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('')
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user