feat: implement comments and activity panel in workspace
- 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.
This commit is contained in:
parent
6e354ba041
commit
809235e962
41 changed files with 2840 additions and 285 deletions
|
|
@ -1,37 +1,51 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { shedTemplates } from '../../data/createData'
|
||||
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
|
||||
}
|
||||
|
||||
const siloStarters = [
|
||||
{ id: 'blank', label: 'Blank page', description: 'Empty Silo for your own content', html: '' },
|
||||
{
|
||||
id: 'meeting',
|
||||
label: 'Meeting notes',
|
||||
description: 'Silo starter for meetings',
|
||||
html: '<h2>Meeting notes</h2><p>Date:</p><ul><li>Attendees</li><li>Agenda</li><li>Actions</li></ul>',
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
label: 'Project plan',
|
||||
description: 'Silo starter for a project plan',
|
||||
html: '<h2>Project plan</h2><p>Goals</p><ul><li>Milestone 1</li><li>Milestone 2</li></ul>',
|
||||
},
|
||||
]
|
||||
|
||||
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
|
||||
|
|
@ -58,6 +72,62 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
|
|||
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} />
|
||||
|
|
@ -68,11 +138,54 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
|
|||
>
|
||||
<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>
|
||||
<button type="button" onClick={onClose} className="rounded-lg p-1.5 hover:bg-silos-cream">
|
||||
<X className="h-4 w-4 text-silos-muted" />
|
||||
</button>
|
||||
<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
|
||||
|
|
@ -90,7 +203,7 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
|
|||
</button>
|
||||
))}
|
||||
<p className="mt-4 px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Silo — Pages</p>
|
||||
{siloStarters.map((t) => (
|
||||
{siloTemplates.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
|
|
@ -103,6 +216,15 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
|
|||
))}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue