Per organized-ideas.md §8: the storage layer (DiagramRepository etc.) was already renamed in an earlier phase; this finishes it everywhere else. - domain/types.ts: Project -> Diagram. domain/project.ts -> domain/diagram.ts (createEmptyProject -> createEmptyDiagram, default name "Untitled Diagram"). - domain/compatibility.ts, domain/bom.ts: Project param/type -> Diagram. - data/exportImport.ts: ProjectImportError -> DiagramImportError, projectToJson/downloadProjectFile/readProjectFile/normalizeProject -> their Diagram equivalents. - state/projectStore.ts -> state/diagramStore.ts: useProjectStore -> useDiagramStore, the `project` field -> `diagram`, newProject/renameProject/ importProject/applyRestoredProject -> *Diagram, restoredProjectUpdatedAt -> restoredDiagramUpdatedAt. - Every component updated to match, compiler-guided (tsc -b enumerated each remaining call site after the core rename, the same approach used for the earlier catalog-parameter refactor). - README updated for the terminology, and to match the repository class names (which had already been renamed but the README hadn't caught up). No backend/schema changes: the JSON shape stored in diagrams.data never changed, only TypeScript-side identifiers, so existing diagrams are unaffected. One SQL comment fixed for accuracy (no migration needed). Verified: tsc -b and oxlint clean; grepped src/ for any remaining Project/project reference (none) after the sweep.
129 lines
5.4 KiB
TypeScript
129 lines
5.4 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import type { CollaboratorPermission } from '../../data/DiagramCollaboratorRepository'
|
|
import { SelfCollaboratorError, UnknownUsernameError } from '../../data/DiagramCollaboratorRepository'
|
|
import { useDiagramCollaboratorStore } from '../../state/diagramCollaboratorStore'
|
|
import { useDiagramStore } from '../../state/diagramStore'
|
|
import Modal from '../common/Modal'
|
|
|
|
export default function DiagramSharingModal({ onClose }: { onClose: () => void }) {
|
|
const diagram = useDiagramStore((s) => s.diagram)
|
|
const access = useDiagramStore((s) => s.access)
|
|
const isOwner = access?.myPermission === 'owner'
|
|
const diagramId = diagram.id
|
|
|
|
const collaborators = useDiagramCollaboratorStore((s) => s.collaborators)
|
|
const isLoaded = useDiagramCollaboratorStore((s) => s.isLoaded)
|
|
const load = useDiagramCollaboratorStore((s) => s.load)
|
|
const add = useDiagramCollaboratorStore((s) => s.add)
|
|
const updatePermission = useDiagramCollaboratorStore((s) => s.updatePermission)
|
|
const remove = useDiagramCollaboratorStore((s) => s.remove)
|
|
|
|
const [username, setUsername] = useState('')
|
|
const [permission, setPermission] = useState<CollaboratorPermission>('view')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
useEffect(() => {
|
|
load(diagramId)
|
|
}, [diagramId, load])
|
|
|
|
const handleAdd = async () => {
|
|
const trimmed = username.trim()
|
|
if (!trimmed) return
|
|
setError(null)
|
|
setBusy(true)
|
|
try {
|
|
await add(diagramId, trimmed, permission)
|
|
setUsername('')
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof UnknownUsernameError || err instanceof SelfCollaboratorError
|
|
? err.message
|
|
: 'Could not add that person. Try again.',
|
|
)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleRemove = (userId: string, name: string) => {
|
|
const confirmed = window.confirm(`Remove "${name}" from this diagram? They'll immediately lose access.`)
|
|
if (confirmed) remove(diagramId, userId)
|
|
}
|
|
|
|
return (
|
|
<Modal title={`Share "${diagram.name}"`} onClose={onClose} width="max-w-md">
|
|
<div className="space-y-3">
|
|
{isOwner && (
|
|
<div className="space-y-1.5 rounded border border-slate-200 bg-slate-50 p-2">
|
|
<div className="flex gap-1.5">
|
|
<input
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
|
placeholder="Username"
|
|
className="w-0 min-w-0 flex-1 rounded border border-slate-300 px-1.5 py-1 text-xs"
|
|
/>
|
|
<select
|
|
value={permission}
|
|
onChange={(e) => setPermission(e.target.value as CollaboratorPermission)}
|
|
className="rounded border border-slate-300 px-1 py-1 text-xs"
|
|
>
|
|
<option value="view">Can view</option>
|
|
<option value="edit">Can edit</option>
|
|
</select>
|
|
<button
|
|
onClick={handleAdd}
|
|
disabled={busy || !username.trim()}
|
|
className="shrink-0 rounded bg-indigo-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
{error && <p className="text-[11px] text-red-600">{error}</p>}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
{!isLoaded && <p className="py-2 text-center text-xs italic text-slate-400">Loading…</p>}
|
|
{isLoaded && collaborators.length === 0 && (
|
|
<p className="py-2 text-center text-xs italic text-slate-400">
|
|
Not shared with anyone yet{isOwner ? ' — add someone above.' : '.'}
|
|
</p>
|
|
)}
|
|
{collaborators.map((c) => (
|
|
<div key={c.userId} className="flex items-center justify-between gap-2 rounded border border-slate-200 bg-white px-2 py-1.5 text-xs">
|
|
<span className="font-medium text-slate-700">{c.username}</span>
|
|
{isOwner ? (
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
<select
|
|
value={c.permission}
|
|
onChange={(e) => updatePermission(diagramId, c.userId, e.target.value as CollaboratorPermission)}
|
|
className="rounded border border-slate-300 px-1 py-1 text-[11px]"
|
|
>
|
|
<option value="view">Can view</option>
|
|
<option value="edit">Can edit</option>
|
|
</select>
|
|
<button
|
|
onClick={() => handleRemove(c.userId, c.username)}
|
|
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-red-50 hover:text-red-600"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<span className="shrink-0 text-[11px] text-slate-400">{c.permission === 'edit' ? 'Can edit' : 'Can view'}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex justify-end border-t border-slate-100 pt-3">
|
|
<button onClick={onClose} className="rounded px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|