Surface project ownership/role on the wire and gate the web UI by it
ProjectResponse now carries OwnerId and a server-resolved MyRole so Editors/Reviewers see read-only fields and no delete/grant-management affordances instead of only finding out via a 403 after the fact. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PuBH9QSv66DPXSSBERmPs6
This commit is contained in:
@@ -99,7 +99,7 @@ public class NovelAgentToolset(
|
||||
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
|
||||
+ "Call this first in a conversation to ground yourself in what the book is.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(), "Project", projectId));
|
||||
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(null), "Project", projectId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_project_brief",
|
||||
@@ -121,7 +121,7 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "logline"),
|
||||
JsonInput.String(input, "synopsis"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(), "Project", projectId));
|
||||
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Project", projectId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_characters",
|
||||
|
||||
@@ -202,7 +202,7 @@ public class ImportAgentToolset(
|
||||
Notes: JsonInput.String(input, "notes")), ct);
|
||||
|
||||
ProjectId = created.Id;
|
||||
return created.ToResponse();
|
||||
return created.ToResponse(null);
|
||||
});
|
||||
|
||||
yield return new ImportAgentTool(
|
||||
@@ -225,7 +225,7 @@ public class ImportAgentToolset(
|
||||
|
||||
return updated is null
|
||||
? new ImportToolNotFound("Project", projectId)
|
||||
: updated.ToResponse();
|
||||
: updated.ToResponse(null);
|
||||
});
|
||||
|
||||
yield return new ImportAgentTool(
|
||||
|
||||
@@ -25,6 +25,8 @@ public record ProjectResponse(
|
||||
string? Notes,
|
||||
int? TargetWordCount,
|
||||
ProjectPhase Phase,
|
||||
Guid? OwnerId,
|
||||
string? MyRole,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
@@ -103,7 +105,7 @@ file static class ProjectValidation
|
||||
|
||||
public static class ProjectMapping
|
||||
{
|
||||
public static ProjectResponse ToResponse(this Project p) => new(
|
||||
public static ProjectResponse ToResponse(this Project p, string? myRole) => new(
|
||||
p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
|
||||
p.TargetWordCount, p.Phase, p.CreatedAt, p.UpdatedAt);
|
||||
p.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Projects;
|
||||
|
||||
@@ -15,20 +16,36 @@ public static class ProjectEndpoints
|
||||
Results.Ok(await service.ListAsync(ct)))
|
||||
.WithSummary("List all novel projects.");
|
||||
|
||||
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
|
||||
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
|
||||
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
|
||||
{
|
||||
var project = await service.GetAsync(id, ct);
|
||||
if (project is null)
|
||||
return Results.NotFound();
|
||||
|
||||
var myRole = await access.GetMyRoleAsync(project, ct);
|
||||
return Results.Ok(project.ToResponse(myRole));
|
||||
})
|
||||
.WithSummary("Read a project's brief.");
|
||||
|
||||
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) =>
|
||||
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
|
||||
{
|
||||
var created = (await service.CreateAsync(request, ct)).ToResponse();
|
||||
var project = await service.CreateAsync(request, ct);
|
||||
var myRole = await access.GetMyRoleAsync(project, ct);
|
||||
var created = project.ToResponse(myRole);
|
||||
return Results.Created($"/api/projects/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Create a novel project.");
|
||||
|
||||
group.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) =>
|
||||
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
|
||||
Guid id, UpdateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
|
||||
{
|
||||
var project = await service.UpdateAsync(id, request, ct);
|
||||
if (project is null)
|
||||
return Results.NotFound();
|
||||
|
||||
var myRole = await access.GetMyRoleAsync(project, ct);
|
||||
return Results.Ok(project.ToResponse(myRole));
|
||||
})
|
||||
.WithSummary("Update a project's brief.");
|
||||
|
||||
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
|
||||
|
||||
@@ -51,6 +51,23 @@ public class ProjectAccessService(INovelDbContext db, INovelUserContext userCont
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetMyRoleAsync(Project project, CancellationToken ct = default)
|
||||
{
|
||||
if (userContext.GlobalRole == GlobalRole.Admin)
|
||||
return "Admin";
|
||||
|
||||
if (project.OwnerId is not null && project.OwnerId == userContext.UserId)
|
||||
return "Owner";
|
||||
|
||||
if (userContext.UserId is null)
|
||||
return null;
|
||||
|
||||
var member = await db.ProjectMembers.AsNoTracking()
|
||||
.FirstOrDefaultAsync(m => m.ProjectId == project.Id && m.UserId == userContext.UserId, ct);
|
||||
|
||||
return member?.ProjectRole.ToString();
|
||||
}
|
||||
|
||||
public IQueryable<Project> VisibleProjects()
|
||||
{
|
||||
if (userContext.GlobalRole == GlobalRole.Admin)
|
||||
|
||||
@@ -40,6 +40,8 @@ export type ProjectRole = 'Writer' | 'Editor' | 'Reviewer'
|
||||
|
||||
export const projectRoles: ProjectRole[] = ['Writer', 'Editor', 'Reviewer']
|
||||
|
||||
export type ProjectMyRole = 'Admin' | 'Owner' | 'Writer' | 'Editor' | 'Reviewer'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
@@ -84,6 +86,8 @@ export interface Project {
|
||||
notes: string | null
|
||||
targetWordCount: number | null
|
||||
phase: ProjectPhase
|
||||
ownerId: string | null
|
||||
myRole: ProjectMyRole | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
||||
import { useMe } from '../api/hooks'
|
||||
import type { User } from '../api/types'
|
||||
import type { Project, ProjectMyRole, User } from '../api/types'
|
||||
|
||||
export type AuthPermission = 'CreateNovel'
|
||||
export type AuthPermission = 'CreateNovel' | 'Write' | 'CreateContent' | 'DeleteContent' | 'ManageAccess'
|
||||
|
||||
const projectPermissionsByRole: Record<ProjectMyRole, AuthPermission[]> = {
|
||||
Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
|
||||
Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
|
||||
Writer: ['Write', 'CreateContent', 'DeleteContent'],
|
||||
Editor: ['Write'],
|
||||
Reviewer: [],
|
||||
}
|
||||
|
||||
interface AuthValue {
|
||||
user: User | null
|
||||
isPending: boolean
|
||||
can: (permission: AuthPermission) => boolean
|
||||
can: (permission: AuthPermission, project?: Pick<Project, 'myRole'> | null) => boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthValue>({ user: null, isPending: true, can: () => false })
|
||||
@@ -20,8 +28,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
() => ({
|
||||
user,
|
||||
isPending,
|
||||
can: (permission) =>
|
||||
permission === 'CreateNovel' && (user?.globalRole === 'Admin' || user?.globalRole === 'Writer'),
|
||||
can: (permission, project) => {
|
||||
if (permission === 'CreateNovel') return user?.globalRole === 'Admin' || user?.globalRole === 'Writer'
|
||||
const myRole = project?.myRole
|
||||
return myRole ? projectPermissionsByRole[myRole].includes(permission) : false
|
||||
},
|
||||
}),
|
||||
[user, isPending],
|
||||
)
|
||||
|
||||
@@ -13,9 +13,15 @@ import { AutoField, ErrorNote } from './ui'
|
||||
export function CharacterArc({
|
||||
projectId,
|
||||
character,
|
||||
canWrite,
|
||||
canCreate,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
character: Character
|
||||
canWrite: boolean
|
||||
canCreate: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const { data: chapters } = useChapters(projectId)
|
||||
const create = useCreateArcStage(projectId)
|
||||
@@ -65,22 +71,26 @@ export function CharacterArc({
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < stages.length - 1}
|
||||
onMove={(delta) => move(index, delta)}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<form onSubmit={submit} className="mt-3 flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Add a stage — three to five words, e.g. “she stops covering for him”"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary shrink-0" disabled={!title.trim() || create.isPending}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
{canCreate && (
|
||||
<form onSubmit={submit} className="mt-3 flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Add a stage — three to five words, e.g. “she stops covering for him”"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary shrink-0" disabled={!title.trim() || create.isPending}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{create.error && (
|
||||
<div className="mt-2">
|
||||
@@ -98,6 +108,8 @@ function ArcStageRow({
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onMove,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
stage: ArcStage
|
||||
@@ -105,6 +117,8 @@ function ArcStageRow({
|
||||
canMoveUp: boolean
|
||||
canMoveDown: boolean
|
||||
onMove: (delta: number) => void
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const update = useUpdateArcStage(projectId)
|
||||
const remove = useDeleteArcStage(projectId)
|
||||
@@ -121,6 +135,7 @@ function ArcStageRow({
|
||||
<AutoField
|
||||
value={stage.title}
|
||||
onCommit={(title) => title.trim() && update.mutate({ id: stage.id, title })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
value={stage.description}
|
||||
@@ -128,12 +143,14 @@ function ArcStageRow({
|
||||
rows={2}
|
||||
placeholder="What shifts here, and what it costs them."
|
||||
onCommit={(description) => update.mutate({ id: stage.id, description })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="input max-w-[16rem] py-1 text-xs"
|
||||
value={stage.chapterId ?? ''}
|
||||
disabled={!canWrite}
|
||||
onChange={(e) => update.mutate({ id: stage.id, chapterId: e.target.value })}
|
||||
>
|
||||
<option value="">Not pinned to a chapter</option>
|
||||
@@ -156,34 +173,38 @@ function ArcStageRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<button
|
||||
className="btn px-2 py-0.5 text-xs"
|
||||
disabled={!canMoveUp}
|
||||
onClick={() => onMove(-1)}
|
||||
aria-label="Move stage earlier"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
className="btn px-2 py-0.5 text-xs"
|
||||
disabled={!canMoveDown}
|
||||
onClick={() => onMove(1)}
|
||||
aria-label="Move stage later"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
className="btn px-2 py-0.5 text-xs"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete “${stage.title}” from the arc?`)) remove.mutate(stage.id)
|
||||
}}
|
||||
aria-label="Delete stage"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<button
|
||||
className="btn px-2 py-0.5 text-xs"
|
||||
disabled={!canMoveUp}
|
||||
onClick={() => onMove(-1)}
|
||||
aria-label="Move stage earlier"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
className="btn px-2 py-0.5 text-xs"
|
||||
disabled={!canMoveDown}
|
||||
onClick={() => onMove(1)}
|
||||
aria-label="Move stage later"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
className="btn px-2 py-0.5 text-xs"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete “${stage.title}” from the arc?`)) remove.mutate(stage.id)
|
||||
}}
|
||||
aria-label="Delete stage"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -6,11 +6,13 @@ export function MarkdownEditor({
|
||||
onCommit,
|
||||
placeholder,
|
||||
rows = 24,
|
||||
readOnly,
|
||||
}: {
|
||||
value: string | null
|
||||
onCommit: (next: string) => void
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value ?? '')
|
||||
const [mode, setMode] = useState<'write' | 'preview'>('write')
|
||||
@@ -61,6 +63,7 @@ export function MarkdownEditor({
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<div className="markdown-preview card min-h-[20rem] p-4">
|
||||
|
||||
@@ -12,9 +12,15 @@ import { ErrorNote, Spinner } from './ui'
|
||||
export function OpenQuestions({
|
||||
projectId,
|
||||
scope,
|
||||
canCreate,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
scope: { chapterId?: string; characterId?: string }
|
||||
canCreate: boolean
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const [showResolved, setShowResolved] = useState(false)
|
||||
const [asking, setAsking] = useState(false)
|
||||
@@ -61,9 +67,11 @@ export function OpenQuestions({
|
||||
/>
|
||||
Show resolved
|
||||
</label>
|
||||
<button className="btn" onClick={() => setAsking((open) => !open)}>
|
||||
{asking ? 'Cancel' : 'Ask'}
|
||||
</button>
|
||||
{canCreate && (
|
||||
<button className="btn" onClick={() => setAsking((open) => !open)}>
|
||||
{asking ? 'Cancel' : 'Ask'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +107,14 @@ export function OpenQuestions({
|
||||
) : questions?.length ? (
|
||||
<ul className="grid gap-2">
|
||||
{questions.map((q) => (
|
||||
<QuestionRow key={q.id} projectId={projectId} question={q} scope={scope} />
|
||||
<QuestionRow
|
||||
key={q.id}
|
||||
projectId={projectId}
|
||||
question={q}
|
||||
scope={scope}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
@@ -116,10 +131,14 @@ function QuestionRow({
|
||||
projectId,
|
||||
question,
|
||||
scope,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
question: OpenQuestion
|
||||
scope: { chapterId?: string; characterId?: string }
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const resolve = useResolveQuestion(projectId)
|
||||
const reopen = useReopenQuestion(projectId)
|
||||
@@ -177,29 +196,33 @@ function QuestionRow({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{question.isResolved ? (
|
||||
<button className="btn px-2 py-1 text-xs" onClick={() => reopen.mutate(question.id)}>
|
||||
Reopen
|
||||
</button>
|
||||
) : (
|
||||
{canWrite && (
|
||||
question.isResolved ? (
|
||||
<button className="btn px-2 py-1 text-xs" onClick={() => reopen.mutate(question.id)}>
|
||||
Reopen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn px-2 py-1 text-xs"
|
||||
onClick={() => setResolving((open) => !open)}
|
||||
>
|
||||
{resolving ? 'Cancel' : 'Resolve'}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
className="btn px-2 py-1 text-xs"
|
||||
onClick={() => setResolving((open) => !open)}
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm('Delete this question? Resolving keeps the decision; deleting does not.')) {
|
||||
remove.mutate(question.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{resolving ? 'Cancel' : 'Resolve'}
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn px-2 py-1 text-xs"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm('Delete this question? Resolving keeps the decision; deleting does not.')) {
|
||||
remove.mutate(question.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ export function AutoField({
|
||||
serif,
|
||||
suggestions,
|
||||
onContextMenu,
|
||||
readOnly,
|
||||
}: {
|
||||
label?: string
|
||||
value: string | null | undefined
|
||||
@@ -75,6 +76,7 @@ export function AutoField({
|
||||
serif?: boolean
|
||||
suggestions?: readonly string[]
|
||||
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value ?? '')
|
||||
const committed = useRef(value ?? '')
|
||||
@@ -109,6 +111,7 @@ export function AutoField({
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onContextMenu={onContextMenu}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -120,6 +123,7 @@ export function AutoField({
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{suggestions?.length ? (
|
||||
<datalist id={suggestionsId}>
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
useCreateBeat,
|
||||
useDeleteBeat,
|
||||
useDeleteChapter,
|
||||
useProject,
|
||||
useReorderBeats,
|
||||
useTags,
|
||||
useUpdateBeat,
|
||||
useUpdateChapter,
|
||||
} from '../api/hooks'
|
||||
import { draftStatuses, type Beat, type Chapter } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { TagChip, TagEditor } from '../components/TagEditor'
|
||||
@@ -28,6 +30,7 @@ export default function ChapterPage() {
|
||||
const { projectId = '', 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 update = useUpdateChapter(projectId)
|
||||
@@ -36,8 +39,12 @@ export default function ChapterPage() {
|
||||
const [tab, setTab] = useState<ChapterTab>('outline')
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId)
|
||||
const { can } = useAuth()
|
||||
const canWrite = can('Write', project)
|
||||
const canCreate = can('CreateContent', project)
|
||||
const canDelete = can('DeleteContent', project)
|
||||
|
||||
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
|
||||
useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
|
||||
|
||||
if (isPending) return <Spinner label="Loading chapter" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
@@ -65,6 +72,7 @@ export default function ChapterPage() {
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={chapter.number}
|
||||
readOnly={!canWrite}
|
||||
onBlur={(e) => {
|
||||
const number = Number(e.target.value)
|
||||
if (number > 0 && number !== chapter.number) patch({ number })
|
||||
@@ -75,12 +83,13 @@ export default function ChapterPage() {
|
||||
label="Title"
|
||||
value={chapter.title}
|
||||
onCommit={(title) => title.trim() && patch({ title })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
value={chapter.status}
|
||||
options={draftStatuses}
|
||||
onChange={(status) => patch({ status })}
|
||||
onChange={(status) => canWrite && patch({ status })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -89,6 +98,7 @@ export default function ChapterPage() {
|
||||
label="Setting"
|
||||
value={chapter.setting}
|
||||
onCommit={(setting) => patch({ setting })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -97,7 +107,7 @@ export default function ChapterPage() {
|
||||
label="Tags"
|
||||
tags={chapter.tags}
|
||||
suggestions={suggestions}
|
||||
onChange={(tags) => patch({ tags })}
|
||||
onChange={(tags) => canWrite && patch({ tags })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -105,9 +115,11 @@ export default function ChapterPage() {
|
||||
<div className="text-sm muted">
|
||||
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
|
||||
</div>
|
||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete chapter
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete chapter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -160,6 +172,7 @@ export default function ChapterPage() {
|
||||
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
|
||||
onCommit={(summary) => patch({ summary })}
|
||||
onContextMenu={(e) => handleContextMenu(e, () => {})}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -169,15 +182,19 @@ export default function ChapterPage() {
|
||||
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
|
||||
suggestions={suggestions}
|
||||
onCharacterContextMenu={handleContextMenu}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => createBeat.mutate({ title: 'New beat' })}
|
||||
disabled={createBeat.isPending}
|
||||
>
|
||||
Add beat
|
||||
</button>
|
||||
{canCreate && (
|
||||
<button
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => createBeat.mutate({ title: 'New beat' })}
|
||||
disabled={createBeat.isPending}
|
||||
>
|
||||
Add beat
|
||||
</button>
|
||||
)}
|
||||
{createBeat.error && (
|
||||
<div className="mt-2">
|
||||
<ErrorNote error={createBeat.error} />
|
||||
@@ -191,6 +208,7 @@ export default function ChapterPage() {
|
||||
value={chapter.prose}
|
||||
placeholder="Start writing the chapter."
|
||||
onCommit={(prose) => patch({ prose })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
@@ -207,10 +225,17 @@ export default function ChapterPage() {
|
||||
rows={5}
|
||||
placeholder="Notes on this chapter."
|
||||
onCommit={(notes) => patch({ notes })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<OpenQuestions projectId={projectId} scope={{ chapterId: chapter.id }} />
|
||||
<OpenQuestions
|
||||
projectId={projectId}
|
||||
scope={{ chapterId: chapter.id }}
|
||||
canCreate={canCreate}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
|
||||
{menuElement}
|
||||
</div>
|
||||
@@ -223,6 +248,8 @@ function BeatTable({
|
||||
characters,
|
||||
suggestions,
|
||||
onCharacterContextMenu,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: {
|
||||
chapter: Chapter
|
||||
projectId: string
|
||||
@@ -232,6 +259,8 @@ function BeatTable({
|
||||
e: MouseEvent<HTMLTextAreaElement>,
|
||||
onCreated: (characterId: string) => void,
|
||||
) => void
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const update = useUpdateBeat(chapter.id, projectId)
|
||||
const remove = useDeleteBeat(chapter.id)
|
||||
@@ -266,6 +295,7 @@ function BeatTable({
|
||||
}
|
||||
|
||||
const move = (index: number, delta: number) => {
|
||||
if (!canWrite) return
|
||||
const ids = chapter.beats.map((b) => b.id)
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= ids.length) return
|
||||
@@ -278,7 +308,7 @@ function BeatTable({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selectedIds.length > 0 && (
|
||||
{selectedIds.length > 0 && canWrite && (
|
||||
<div className="card mb-3 flex flex-wrap items-center gap-3 p-3">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedIds.length} beat{selectedIds.length === 1 ? '' : 's'} selected
|
||||
@@ -443,14 +473,16 @@ function BeatTable({
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
className="text-xs muted leading-none transition hover:opacity-100"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => setDeletingBeat(beat)}
|
||||
aria-label={`Delete beat ${beat.title}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
className="text-xs muted leading-none transition hover:opacity-100"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => setDeletingBeat(beat)}
|
||||
aria-label={`Delete beat ${beat.title}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -458,18 +490,26 @@ function BeatTable({
|
||||
<tr
|
||||
key={beat.id}
|
||||
id={`beat-${beat.id}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Edit beat ${beat.title}`}
|
||||
className="cursor-pointer transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--accent)]"
|
||||
tabIndex={canWrite ? 0 : undefined}
|
||||
role={canWrite ? 'button' : undefined}
|
||||
aria-label={canWrite ? `Edit beat ${beat.title}` : undefined}
|
||||
className={
|
||||
canWrite
|
||||
? 'cursor-pointer transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--accent)]'
|
||||
: ''
|
||||
}
|
||||
style={{ borderBottom: '1px solid var(--line)' }}
|
||||
onClick={() => setEditingId(beat.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setEditingId(beat.id)
|
||||
}
|
||||
}}
|
||||
onClick={canWrite ? () => setEditingId(beat.id) : undefined}
|
||||
onKeyDown={
|
||||
canWrite
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setEditingId(beat.id)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td className="px-2 py-2 align-top">
|
||||
<input
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
useCharacters,
|
||||
useCreateCharacter,
|
||||
useDeleteCharacter,
|
||||
useProject,
|
||||
useTags,
|
||||
useUpdateCharacter,
|
||||
} from '../api/hooks'
|
||||
import { characterImportances, characterRoles, type Character } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { TagEditor } from '../components/TagEditor'
|
||||
@@ -19,10 +21,15 @@ import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
export default function CharactersPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: characters, isPending, error } = useCharacters(projectId)
|
||||
const { data: project } = useProject(projectId)
|
||||
const { can } = useAuth()
|
||||
const canCreate = can('CreateContent', project)
|
||||
const canWrite = can('Write', project)
|
||||
const canDelete = can('DeleteContent', project)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
useHotkey('n', 'Add character', () => setAdding(true), { group: 'Characters' })
|
||||
useHotkey('n', 'Add character', () => canCreate && setAdding(true), { group: 'Characters' })
|
||||
|
||||
if (isPending) return <Spinner label="Loading characters" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
@@ -32,9 +39,11 @@ export default function CharactersPage() {
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[16rem_1fr]">
|
||||
<aside className="grid content-start gap-2">
|
||||
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
|
||||
Add character
|
||||
</button>
|
||||
{canCreate && (
|
||||
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
|
||||
Add character
|
||||
</button>
|
||||
)}
|
||||
{(['Main', 'Supporting'] as const).map((importance) => {
|
||||
const group = characters?.filter((c) => c.importance === importance) ?? []
|
||||
if (group.length === 0) return null
|
||||
@@ -69,7 +78,14 @@ export default function CharactersPage() {
|
||||
hint="Add the protagonist first — most outline questions resolve once you know what they want."
|
||||
/>
|
||||
) : (
|
||||
<CharacterSheet key={selected.id} projectId={projectId} character={selected} />
|
||||
<CharacterSheet
|
||||
key={selected.id}
|
||||
projectId={projectId}
|
||||
character={selected}
|
||||
canWrite={canWrite}
|
||||
canCreate={canCreate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -84,7 +100,19 @@ export default function CharactersPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function CharacterSheet({ projectId, character }: { projectId: string; character: Character }) {
|
||||
function CharacterSheet({
|
||||
projectId,
|
||||
character,
|
||||
canWrite,
|
||||
canCreate,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
character: Character
|
||||
canWrite: boolean
|
||||
canCreate: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const { data: allTags } = useTags(projectId)
|
||||
const update = useUpdateCharacter(projectId)
|
||||
const remove = useDeleteCharacter(projectId)
|
||||
@@ -101,36 +129,41 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
label="Name"
|
||||
value={character.name}
|
||||
onCommit={(name) => name.trim() && patch({ name })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<Select
|
||||
label="Role"
|
||||
value={character.role}
|
||||
options={characterRoles}
|
||||
onChange={(role) => patch({ role })}
|
||||
onChange={(role) => canWrite && patch({ role })}
|
||||
/>
|
||||
<Select
|
||||
label="Importance"
|
||||
value={character.importance}
|
||||
options={characterImportances}
|
||||
onChange={(importance) => patch({ importance })}
|
||||
onChange={(importance) => canWrite && patch({ importance })}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<AutoField label="Age" value={character.age} onCommit={(age) => patch({ age })} />
|
||||
<AutoField label="Age" value={character.age} onCommit={(age) => patch({ age })} readOnly={!canWrite} />
|
||||
<AutoField
|
||||
label="Pronouns"
|
||||
value={character.pronouns}
|
||||
onCommit={(pronouns) => patch({ pronouns })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Occupation"
|
||||
value={character.occupation}
|
||||
onCommit={(occupation) => patch({ occupation })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -139,7 +172,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
label="Tags"
|
||||
tags={character.tags}
|
||||
suggestions={allTags?.map((t) => t.name) ?? []}
|
||||
onChange={(tags) => patch({ tags })}
|
||||
onChange={(tags) => canWrite && patch({ tags })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -151,6 +184,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
rows={3}
|
||||
placeholder="What they are consciously chasing."
|
||||
onCommit={(want) => patch({ want })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Needs"
|
||||
@@ -159,24 +193,28 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
rows={3}
|
||||
placeholder="What the story will make them face instead."
|
||||
onCommit={(need) => patch({ need })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Internal conflict"
|
||||
value={character.internalConflict}
|
||||
multiline
|
||||
onCommit={(internalConflict) => patch({ internalConflict })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="External conflict"
|
||||
value={character.externalConflict}
|
||||
multiline
|
||||
onCommit={(externalConflict) => patch({ externalConflict })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Arc"
|
||||
value={character.arcSummary}
|
||||
multiline
|
||||
onCommit={(arcSummary) => patch({ arcSummary })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Voice"
|
||||
@@ -184,18 +222,21 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
multiline
|
||||
placeholder="Register, rhythm, the words they reach for."
|
||||
onCommit={(voice) => patch({ voice })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Appearance"
|
||||
value={character.appearance}
|
||||
multiline
|
||||
onCommit={(appearance) => patch({ appearance })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Personality"
|
||||
value={character.personality}
|
||||
multiline
|
||||
onCommit={(personality) => patch({ personality })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -207,12 +248,14 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
rows={5}
|
||||
serif
|
||||
onCommit={(backstory) => patch({ backstory })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Notes"
|
||||
value={character.notes}
|
||||
multiline
|
||||
onCommit={(notes) => patch({ notes })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -239,7 +282,13 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
</div>
|
||||
|
||||
{(character.importance === 'Main' || character.arcStages.length > 0) && (
|
||||
<CharacterArc projectId={projectId} character={character} />
|
||||
<CharacterArc
|
||||
projectId={projectId}
|
||||
character={character}
|
||||
canWrite={canWrite}
|
||||
canCreate={canCreate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CharacterBeats
|
||||
@@ -248,7 +297,13 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
characterName={character.name}
|
||||
/>
|
||||
|
||||
<OpenQuestions projectId={projectId} scope={{ characterId: character.id }} />
|
||||
<OpenQuestions
|
||||
projectId={projectId}
|
||||
scope={{ characterId: character.id }}
|
||||
canCreate={canCreate}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
|
||||
{confirmingDelete && (
|
||||
<ConfirmModal
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useChapters, useCharacters, useProject, useUpdateProject } from '../api/hooks'
|
||||
import type { Project } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
||||
|
||||
const RECENT_COUNT = 5
|
||||
@@ -21,6 +22,7 @@ export default function DashboardPage() {
|
||||
|
||||
function BrainstormingDashboard({ project }: { project: Project }) {
|
||||
const update = useUpdateProject(project.id)
|
||||
const { can } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
@@ -36,6 +38,7 @@ function BrainstormingDashboard({ project }: { project: Project }) {
|
||||
serif
|
||||
placeholder="Start anywhere."
|
||||
onCommit={(notes) => update.mutate({ notes })}
|
||||
readOnly={!can('Write', project)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,8 @@ export default function ProjectLayout() {
|
||||
const navigate = useNavigate()
|
||||
const { data: project, isPending, error } = useProject(projectId)
|
||||
const update = useUpdateProject(projectId)
|
||||
const { user } = useAuth()
|
||||
const { user, can } = useAuth()
|
||||
const canWrite = can('Write', project)
|
||||
const logout = useLogout()
|
||||
|
||||
const goTo = (path: string) => navigate(path ? `/projects/${projectId}/${path}` : `/projects/${projectId}`)
|
||||
@@ -46,6 +47,7 @@ export default function ProjectLayout() {
|
||||
<select
|
||||
className="input w-auto"
|
||||
value={project.phase}
|
||||
disabled={!canWrite}
|
||||
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
|
||||
aria-label="Novel phase"
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../api/hooks'
|
||||
import { ApiError } from '../api/client'
|
||||
import { projectRoles, type ProjectMember, type ProjectRole } 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'
|
||||
@@ -28,9 +29,14 @@ export default function SettingsPage() {
|
||||
const remove = useDeleteProject()
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const { can } = useAuth()
|
||||
|
||||
if (isPending || !project) return <Spinner label="Loading brief" />
|
||||
|
||||
const canWrite = can('Write', project)
|
||||
const canDelete = can('DeleteContent', project)
|
||||
const canManageAccess = can('ManageAccess', project)
|
||||
|
||||
const drafted = chapters?.reduce((sum, c) => sum + c.wordCount, 0) ?? 0
|
||||
const target = project.targetWordCount ?? 0
|
||||
const percent = target > 0 ? Math.min(100, Math.round((drafted / target) * 100)) : null
|
||||
@@ -44,12 +50,14 @@ export default function SettingsPage() {
|
||||
label="Title"
|
||||
value={project.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}
|
||||
onCommit={(author) => update.mutate({ author })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Genre"
|
||||
@@ -57,6 +65,7 @@ export default function SettingsPage() {
|
||||
placeholder="Pick one, or name your own."
|
||||
suggestions={genres?.map((g) => g.name)}
|
||||
onCommit={(genre) => update.mutate({ genre })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
</div>
|
||||
<AutoField
|
||||
@@ -66,6 +75,7 @@ export default function SettingsPage() {
|
||||
rows={2}
|
||||
placeholder="Who wants what, and what stands in the way."
|
||||
onCommit={(logline) => update.mutate({ logline })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Synopsis"
|
||||
@@ -75,6 +85,7 @@ export default function SettingsPage() {
|
||||
serif
|
||||
placeholder="The whole story in a few paragraphs, ending included."
|
||||
onCommit={(synopsis) => update.mutate({ synopsis })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
label="Notes"
|
||||
@@ -83,6 +94,7 @@ export default function SettingsPage() {
|
||||
rows={4}
|
||||
placeholder="Theme, tone, comparable titles, research threads."
|
||||
onCommit={(notes) => update.mutate({ notes })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<label className="block max-w-48">
|
||||
<span className="label">Target word count</span>
|
||||
@@ -92,6 +104,7 @@ export default function SettingsPage() {
|
||||
min={0}
|
||||
step={1000}
|
||||
defaultValue={project.targetWordCount ?? ''}
|
||||
readOnly={!canWrite}
|
||||
onBlur={(e) => {
|
||||
const value = Number(e.target.value)
|
||||
if (Number.isFinite(value) && value !== project.targetWordCount) {
|
||||
@@ -150,18 +163,20 @@ export default function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<h2 className="mb-2 text-sm font-semibold tracking-wide uppercase muted">Danger zone</h2>
|
||||
<p className="mb-3 text-sm muted">
|
||||
Deleting a novel removes its outline, characters, chapters and conversations.
|
||||
</p>
|
||||
<button className="btn btn-danger w-full" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete this novel
|
||||
</button>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<div className="card p-5">
|
||||
<h2 className="mb-2 text-sm font-semibold tracking-wide uppercase muted">Danger zone</h2>
|
||||
<p className="mb-3 text-sm muted">
|
||||
Deleting a novel removes its outline, characters, chapters and conversations.
|
||||
</p>
|
||||
<button className="btn btn-danger w-full" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete this novel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<ProjectPeople projectId={projectId} />
|
||||
{canManageAccess && <ProjectPeople projectId={projectId} />}
|
||||
|
||||
{importing && (
|
||||
<ImportDialog
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useDeleteTag, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
|
||||
import { useDeleteTag, useProject, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { TagChip } from '../components/TagEditor'
|
||||
@@ -8,6 +9,10 @@ import { TagChip } from '../components/TagEditor'
|
||||
export default function TagsPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const { data: tags, isPending, error } = useTags(projectId)
|
||||
const { data: project } = useProject(projectId)
|
||||
const { can } = useAuth()
|
||||
const canWrite = can('Write', project)
|
||||
const canDelete = can('DeleteContent', project)
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||
|
||||
if (isPending) return <Spinner label="Loading tags" />
|
||||
@@ -55,14 +60,30 @@ export default function TagsPage() {
|
||||
hint="Tags cross-reference the book: attach one to a character, a chapter and a beat, then trace it from here."
|
||||
/>
|
||||
) : (
|
||||
<TagReferencePanel key={selected.id} projectId={projectId} tagId={selected.id} />
|
||||
<TagReferencePanel
|
||||
key={selected.id}
|
||||
projectId={projectId}
|
||||
tagId={selected.id}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: string }) {
|
||||
function TagReferencePanel({
|
||||
projectId,
|
||||
tagId,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: {
|
||||
projectId: string
|
||||
tagId: string
|
||||
canWrite: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const { data, isPending, error } = useTagReferences(tagId)
|
||||
const update = useUpdateTag(projectId)
|
||||
const remove = useDeleteTag()
|
||||
@@ -83,6 +104,7 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str
|
||||
<input
|
||||
className="input w-64"
|
||||
defaultValue={data.tag.name}
|
||||
readOnly={!canWrite}
|
||||
onBlur={(e) => {
|
||||
const name = e.target.value.trim()
|
||||
if (name && name !== data.tag.name) update.mutate({ id: tagId, name })
|
||||
@@ -95,12 +117,15 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str
|
||||
className="input h-9 w-20 p-1"
|
||||
type="color"
|
||||
defaultValue={data.tag.color ?? '#9a4a2f'}
|
||||
onBlur={(e) => update.mutate({ id: tagId, color: e.target.value })}
|
||||
readOnly={!canWrite}
|
||||
onBlur={(e) => canWrite && update.mutate({ id: tagId, color: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete tag
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete tag
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{update.error && <ErrorNote error={update.error} />}
|
||||
|
||||
@@ -132,4 +132,51 @@ public class ProjectAccessTests : ServiceTestFixture
|
||||
var ownerNavigation = Db.Context.Model.FindEntityType(typeof(Project))!.FindNavigation(nameof(Project.Owner))!;
|
||||
Assert.That(ownerNavigation.ForeignKey.DeleteBehavior, Is.EqualTo(DeleteBehavior.Restrict));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_creator_of_a_novel_sees_their_role_as_owner()
|
||||
{
|
||||
var writerId = AsNewUser(GlobalRole.Writer);
|
||||
var project = await Projects.CreateAsync(new CreateProjectRequest("Owned by writer"));
|
||||
|
||||
UserContext.UserId = writerId;
|
||||
var role = await Access.GetMyRoleAsync(project);
|
||||
|
||||
Assert.That(role, Is.EqualTo("Owner"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task An_admin_sees_their_role_as_admin_even_on_a_novel_they_do_not_own()
|
||||
{
|
||||
AsNewUser(GlobalRole.Writer);
|
||||
var project = await Projects.CreateAsync(new CreateProjectRequest("Owned by someone else"));
|
||||
|
||||
AsAdmin();
|
||||
var role = await Access.GetMyRoleAsync(project);
|
||||
|
||||
Assert.That(role, Is.EqualTo("Admin"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_user_granted_editor_sees_their_role_as_editor()
|
||||
{
|
||||
var project = await Projects.CreateAsync(new CreateProjectRequest("Granted Novel"));
|
||||
var editorId = AsNewUser(GlobalRole.Reviewer);
|
||||
GrantProjectRole(project.Id, editorId, ProjectRole.Editor);
|
||||
|
||||
var role = await Access.GetMyRoleAsync(project);
|
||||
|
||||
Assert.That(role, Is.EqualTo("Editor"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_user_with_no_access_sees_a_null_role()
|
||||
{
|
||||
var project = await Projects.CreateAsync(new CreateProjectRequest("Someone Else's Novel"));
|
||||
AsNewUser(GlobalRole.Writer);
|
||||
|
||||
var role = await Access.GetMyRoleAsync(project);
|
||||
|
||||
Assert.That(role, Is.Null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,19 @@ public class ProjectDataTests : ServiceTestFixture
|
||||
Assert.That(afterUnrelatedUpdate.Phase, Is.EqualTo(ProjectPhase.Outlining));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_project_response_carries_the_owner_id_and_the_caller_s_role()
|
||||
{
|
||||
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||
var response = project.ToResponse(await Access.GetMyRoleAsync(project));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(response.OwnerId, Is.EqualTo(UserContext.UserId));
|
||||
Assert.That(response.MyRole, Is.EqualTo("Admin"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user