- Added CommentsPanel component for managing comments on documents. - Integrated comment creation, deletion, and resolution functionalities. - Implemented activity tracking for document interactions. - Enhanced SketchPage with editing and saving capabilities. - Introduced template management API endpoints for creating, sharing, and deleting templates. - Added runtime validation report for frontend and backend. - Created GettingStartedPanel for user onboarding with template options. - Implemented Playwright E2E tests for accessibility and export functionality.
230 lines
12 KiB
TypeScript
230 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { Plus, Trash2, X } from 'lucide-react'
|
|
import { shedTemplates, siloTemplates } from '../../data/createData'
|
|
import {
|
|
createDocument,
|
|
getDefaultShedContent,
|
|
getDefaultTitle,
|
|
listDocuments,
|
|
type DocumentRecord,
|
|
} from '../../lib/documents'
|
|
import { getStoredUser } from '../../lib/api'
|
|
import { templatesApi, type ApiTemplate } from '../../lib/api'
|
|
|
|
interface TemplatesFlyoutProps {
|
|
sidebarCollapsed: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
export default function TemplatesFlyout({ sidebarCollapsed, onClose }: TemplatesFlyoutProps) {
|
|
const navigate = useNavigate()
|
|
const author = getStoredUser()?.full_name ?? 'You'
|
|
const [customTemplates, setCustomTemplates] = useState<ApiTemplate[]>([])
|
|
const [category, setCategory] = useState('All')
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [name, setName] = useState('')
|
|
const [description, setDescription] = useState('')
|
|
const [templateCategory, setTemplateCategory] = useState('General')
|
|
const [emoji, setEmoji] = useState('📄')
|
|
const [bodyHtml, setBodyHtml] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [placement, setPlacement] = useState('top')
|
|
const [placements, setPlacements] = useState<DocumentRecord[]>([])
|
|
const [shareTemplateId, setShareTemplateId] = useState<string | null>(null)
|
|
const [shareIdentifier, setShareIdentifier] = useState('')
|
|
|
|
useEffect(() => {
|
|
void templatesApi.list().then(setCustomTemplates).catch(() => setError('Could not load your templates.'))
|
|
void listDocuments().then((documents) => setPlacements(documents.filter((document) => document.type === 'stead' || document.type === 'silo')))
|
|
}, [])
|
|
|
|
const categories = useMemo(
|
|
() => ['All', ...Array.from(new Set(customTemplates.map((template) => template.category)))],
|
|
[customTemplates],
|
|
)
|
|
const visibleTemplates = category === 'All'
|
|
? customTemplates
|
|
: customTemplates.filter((template) => template.category === category)
|
|
|
|
async function createShed(templateId: (typeof shedTemplates)[number]['id']) {
|
|
if (templateId === 'templates') return
|
|
const doc = await createDocument(
|
|
'shed',
|
|
getDefaultTitle('shed', templateId),
|
|
getDefaultShedContent(templateId),
|
|
templateId,
|
|
author,
|
|
)
|
|
onClose()
|
|
navigate(`/workspace/shed/${doc.id}`)
|
|
}
|
|
|
|
async function createSilo(html: string, label: string) {
|
|
const doc = await createDocument(
|
|
'silo',
|
|
`${label} — ${new Date().toLocaleDateString()}`,
|
|
{ body: '', bodyHtml: html, status: 'draft' },
|
|
undefined,
|
|
author,
|
|
)
|
|
onClose()
|
|
navigate(`/workspace/silo/${doc.id}`)
|
|
}
|
|
|
|
async function createCustomTemplate(template: ApiTemplate) {
|
|
const doc = await createDocument(
|
|
'silo',
|
|
template.name,
|
|
{ ...template.content, status: 'draft' },
|
|
undefined,
|
|
author,
|
|
placement.startsWith('space:') ? placement.slice(6) : undefined,
|
|
placement.startsWith('page:') ? placement.slice(5) : undefined,
|
|
)
|
|
onClose()
|
|
navigate(`/workspace/silo/${doc.id}`)
|
|
}
|
|
|
|
async function shareTemplate(templateId: string) {
|
|
if (!shareIdentifier.trim()) return
|
|
try {
|
|
const updated = await templatesApi.share(templateId, shareIdentifier.trim())
|
|
setCustomTemplates((current) => current.map((template) => template.id === updated.id ? updated : template))
|
|
setShareTemplateId(null)
|
|
setShareIdentifier('')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Could not share template.')
|
|
}
|
|
}
|
|
|
|
async function saveTemplate(event: React.FormEvent) {
|
|
event.preventDefault()
|
|
if (!name.trim()) return
|
|
try {
|
|
const template = await templatesApi.create({
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
category: templateCategory.trim() || 'General',
|
|
emoji: emoji || '📄',
|
|
content: { body: '', bodyHtml, status: 'draft' },
|
|
})
|
|
setCustomTemplates((current) => [template, ...current])
|
|
setName('')
|
|
setDescription('')
|
|
setBodyHtml('')
|
|
setShowCreate(false)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Could not save template.')
|
|
}
|
|
}
|
|
|
|
async function removeTemplate(id: string) {
|
|
try {
|
|
await templatesApi.delete(id)
|
|
setCustomTemplates((current) => current.filter((template) => template.id !== id))
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Could not delete template.')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} />
|
|
<aside
|
|
className={`fixed top-12 z-50 flex h-[calc(100vh-3rem)] w-80 flex-col border-r border-silos-cream-dark bg-white shadow-xl sm:w-96 ${
|
|
sidebarCollapsed ? 'left-14' : 'left-60'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
|
|
<h2 className="text-lg font-semibold text-silos-text">Templates</h2>
|
|
<div className="flex items-center gap-1">
|
|
<button type="button" onClick={() => setShowCreate((current) => !current)} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Create template"><Plus className="h-4 w-4" /></button>
|
|
<button type="button" onClick={onClose} className="rounded-lg p-1.5 hover:bg-silos-cream" aria-label="Close templates"><X className="h-4 w-4 text-silos-muted" /></button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-3">
|
|
{showCreate && (
|
|
<form onSubmit={(event) => void saveTemplate(event)} className="mb-4 rounded-xl border border-silos-cream-dark bg-silos-cream/40 p-3">
|
|
<p className="mb-2 text-sm font-semibold text-silos-text">New page template</p>
|
|
<div className="flex gap-2">
|
|
<input value={emoji} onChange={(event) => setEmoji(event.target.value)} className="w-12 rounded-lg border border-silos-cream-dark bg-white px-2 py-2 text-center" aria-label="Template emoji" />
|
|
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Template name" required className="min-w-0 flex-1 rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" />
|
|
</div>
|
|
<input value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Description" className="mt-2 w-full rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" />
|
|
<input value={templateCategory} onChange={(event) => setTemplateCategory(event.target.value)} placeholder="Category, e.g. Product" className="mt-2 w-full rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" />
|
|
<textarea value={bodyHtml} onChange={(event) => setBodyHtml(event.target.value)} placeholder="Starter content (HTML is supported)" rows={3} className="mt-2 w-full resize-none rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" />
|
|
<button type="submit" className="mt-2 w-full rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white">Save template</button>
|
|
</form>
|
|
)}
|
|
{customTemplates.length > 0 && (
|
|
<>
|
|
<p className="px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Your templates</p>
|
|
<div className="mb-3 flex gap-1 overflow-x-auto pb-1">
|
|
{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`whitespace-nowrap rounded-full px-2.5 py-1 text-xs ${category === item ? 'bg-silos-brown text-white' : 'bg-silos-cream text-silos-muted'}`}>{item}</button>)}
|
|
</div>
|
|
{visibleTemplates.map((template) => (
|
|
<div key={template.id} className="group mb-1 flex items-start gap-2 rounded-lg px-3 py-3 hover:bg-silos-cream">
|
|
<button type="button" onClick={() => void createCustomTemplate(template)} className="flex min-w-0 flex-1 items-start gap-3 text-left">
|
|
<span className="text-lg">{template.emoji}</span>
|
|
<span className="min-w-0"><span className="block text-sm font-medium text-silos-text">{template.name}</span><span className="block text-xs text-silos-muted">{template.description || template.category}</span></span>
|
|
</button>
|
|
<button type="button" onClick={() => setShareTemplateId(template.id)} className="rounded p-1 text-xs text-silos-muted opacity-0 hover:bg-silos-cream hover:text-silos-brown group-hover:opacity-100" aria-label={`Share ${template.name}`}>Share</button>
|
|
<button type="button" onClick={() => void removeTemplate(template.id)} className="rounded p-1 text-silos-muted opacity-0 hover:bg-red-50 hover:text-red-700 group-hover:opacity-100" aria-label={`Delete ${template.name}`}><Trash2 className="h-3.5 w-3.5" /></button>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
{customTemplates.length > 0 && (
|
|
<label className="mb-4 block rounded-lg border border-silos-cream-dark bg-silos-cream/30 p-3 text-xs text-silos-muted">
|
|
Create new pages in
|
|
<select value={placement} onChange={(event) => setPlacement(event.target.value)} className="mt-1 w-full rounded-lg border border-silos-cream-dark bg-white px-2 py-2 text-sm text-silos-text">
|
|
<option value="top">Top level</option>
|
|
{placements.filter((item) => item.type === 'stead').map((item) => <option key={`space-${item.id}`} value={`space:${item.id}`}>Space: {item.title}</option>)}
|
|
{placements.filter((item) => item.type === 'silo').map((item) => <option key={`page-${item.id}`} value={`page:${item.id}`}>Child of: {item.title}</option>)}
|
|
</select>
|
|
</label>
|
|
)}
|
|
{error && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700">{error}</p>}
|
|
<p className="px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Shed — Live docs</p>
|
|
{shedTemplates.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
disabled={!t.available}
|
|
onClick={() => void createShed(t.id)}
|
|
className="mb-1 flex w-full items-start gap-3 rounded-lg px-3 py-3 text-left hover:bg-silos-cream disabled:opacity-50"
|
|
>
|
|
<span className="text-lg">{t.emoji}</span>
|
|
<div>
|
|
<p className="text-sm font-medium text-silos-text">{t.label}</p>
|
|
<p className="text-xs text-silos-muted">{t.description}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
<p className="mt-4 px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Silo — Pages</p>
|
|
{siloTemplates.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
onClick={() => void createSilo(t.html, t.label)}
|
|
className="mb-1 flex w-full flex-col rounded-lg px-3 py-3 text-left hover:bg-silos-cream"
|
|
>
|
|
<p className="text-sm font-medium text-silos-text">{t.label}</p>
|
|
<p className="text-xs text-silos-muted">{t.description}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
{shareTemplateId && (
|
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/20 p-4">
|
|
<form onSubmit={(event) => { event.preventDefault(); void shareTemplate(shareTemplateId) }} className="w-full max-w-sm rounded-xl bg-white p-4 shadow-2xl">
|
|
<div className="mb-3 flex items-center justify-between"><h2 className="font-semibold text-silos-text">Share template</h2><button type="button" onClick={() => setShareTemplateId(null)} aria-label="Close"><X className="h-4 w-4" /></button></div>
|
|
<input autoFocus value={shareIdentifier} onChange={(event) => setShareIdentifier(event.target.value)} placeholder="Email or username" className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" />
|
|
<button type="submit" className="mt-3 w-full rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white">Share</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|