Silos/frontend/src/components/dashboard/TemplatesFlyout.tsx
AnuragJangid18 809235e962 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.
2026-10-22 21:18:10 +05:30

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>
)}
</>
)
}