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:
Anurag Jangid 2026-10-22 21:18:10 +05:30
parent 6e354ba041
commit 809235e962
41 changed files with 2840 additions and 285 deletions

13
frontend/e2e/helpers.ts Normal file
View file

@ -0,0 +1,13 @@
import { expect, type Page } from '@playwright/test'
export async function login(page: Page) {
const email = process.env.E2E_USERNAME
const password = process.env.E2E_PASSWORD
if (!email || !password) return false
await page.goto('/login')
await page.getByLabel(/email|username/i).first().fill(email)
await page.getByLabel(/password/i).fill(password)
await page.getByRole('button', { name: /sign in|log in/i }).click()
await expect(page).toHaveURL(/dashboard|workspace/, { timeout: 15_000 })
return true
}

View file

@ -0,0 +1,11 @@
import { test, expect } from '@playwright/test'
import { login } from './helpers'
test('workspace controls expose accessible names and keyboard navigation', async ({ page }) => {
test.skip(!process.env.E2E_USERNAME || !process.env.E2E_PASSWORD, 'Set E2E_USERNAME and E2E_PASSWORD')
expect(await login(page)).toBeTruthy()
await expect(page.getByRole('button', { name: /search/i }).first()).toBeVisible()
await expect(page.getByRole('button', { name: /notifications/i }).first()).toBeVisible()
await page.keyboard.press('Control+KeyK')
await expect(page.getByRole('dialog').or(page.getByPlaceholder(/search/i)).first()).toBeVisible().catch(() => {})
})

View file

@ -0,0 +1,10 @@
import { test, expect } from '@playwright/test'
import { login } from './helpers'
test('page export control is available from a readable page', async ({ page }) => {
test.skip(!process.env.E2E_USERNAME || !process.env.E2E_PASSWORD, 'Set E2E_USERNAME and E2E_PASSWORD')
expect(await login(page)).toBeTruthy()
const exportButton = page.getByRole('button', { name: /export page/i }).first()
await expect(exportButton).toBeVisible()
await expect(exportButton).toHaveAttribute('title', /export html/i)
})

View file

@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test'
import { login } from './helpers'
test('protected page editing exposes history and restore controls', async ({ page }) => {
test.skip(!process.env.E2E_USERNAME || !process.env.E2E_PASSWORD, 'Set E2E_USERNAME and E2E_PASSWORD')
expect(await login(page)).toBeTruthy()
await page.getByRole('link', { name: /recent|pages|silo/i }).first().click().catch(() => {})
const edit = page.getByRole('button', { name: /^edit$/i }).first()
await expect(edit).toBeVisible()
await edit.click()
await expect(page.getByRole('button', { name: /update/i })).toBeEnabled()
await page.getByRole('button', { name: /version history/i }).click()
await expect(page.getByRole('heading', { name: /version history/i })).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByRole('heading', { name: /version history/i })).toHaveCount(0)
})

View file

@ -15,6 +15,7 @@
"react-router-dom": "^7.2.0"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
"@tailwindcss/vite": "^4.0.9",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@ -815,6 +816,22 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -2214,6 +2231,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",

View file

@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test"
},
"dependencies": {
"framer-motion": "^12.4.7",
@ -22,6 +23,7 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.9",
"typescript": "~5.7.2",
"vite": "^6.2.0"
"vite": "^6.2.0",
"@playwright/test": "^1.64.1"
}
}

View file

@ -0,0 +1,12 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
use: {
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:5173',
headless: true,
trace: 'retain-on-failure',
},
reporter: [['list']],
})

View file

@ -9,7 +9,7 @@ import {
LayoutGrid,
type LucideIcon,
} from 'lucide-react'
import { createOptions, shedTemplates } from '../../data/createData'
import { createOptions, shedTemplates, siloTemplates } from '../../data/createData'
import {
createDocument,
getDefaultShedContent,
@ -24,9 +24,10 @@ const iconMap: Record<string, LucideIcon> = {
LayoutGrid,
}
export default function CreateDropdown({ steadId }: { steadId?: string }) {
export default function CreateDropdown({ steadId, parentPageId }: { steadId?: string; parentPageId?: string }) {
const [open, setOpen] = useState(false)
const [shedOpen, setShedOpen] = useState(false)
const [pageOpen, setPageOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
@ -35,6 +36,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
setShedOpen(false)
setPageOpen(false)
}
}
if (open) document.addEventListener('mousedown', handleClickOutside)
@ -56,11 +58,28 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
undefined,
author,
steadId,
type === 'silo' ? parentPageId : undefined,
)
setOpen(false)
setPageOpen(false)
navigate(`/workspace/${type}/${doc.id}`)
}
async function handleCreatePageTemplate(template: (typeof siloTemplates)[number]) {
const doc = await createDocument(
'silo',
template.label,
{ body: '', bodyHtml: template.html, status: 'draft' },
undefined,
author,
steadId,
parentPageId,
)
setOpen(false)
setPageOpen(false)
navigate(`/workspace/silo/${doc.id}`)
}
function handleCreateStead() {
setOpen(false)
navigate('/workspace/stead/new')
@ -88,6 +107,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
onClick={() => {
setOpen(!open)
setShedOpen(false)
setPageOpen(false)
}}
className="flex shrink-0 items-center gap-1.5 rounded-full bg-silos-brown px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-silos-brown-dark"
>
@ -106,6 +126,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
{createOptions.map((option) => {
const Icon = iconMap[option.icon]
const isShed = option.id === 'shed'
const isPage = option.id === 'silo'
return (
<div key={option.id} className="relative">
@ -113,6 +134,10 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
onClick={() => {
if (isShed) {
setShedOpen(!shedOpen)
setPageOpen(false)
} else if (isPage) {
setPageOpen(!pageOpen)
setShedOpen(false)
} else if (option.id === 'stead') {
handleCreateStead()
} else if (option.id !== 'shed') {
@ -136,11 +161,9 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
</p>
<p className="mt-0.5 text-xs text-silos-muted">{option.description}</p>
</div>
{isShed && (
{(isShed || isPage) && (
<ChevronRight
className={`mt-1 h-4 w-4 shrink-0 text-silos-muted transition-transform ${
shedOpen ? 'rotate-90' : ''
}`}
className={`mt-1 h-4 w-4 shrink-0 text-silos-muted transition-transform ${(isShed ? shedOpen : pageOpen) ? 'rotate-90' : ''}`}
/>
)}
</button>
@ -165,6 +188,23 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
))}
</div>
)}
{isPage && pageOpen && (
<div className="ml-12 mr-2 mb-2 space-y-0.5 rounded-lg border border-silos-cream-dark bg-silos-cream/30 p-1.5">
{siloTemplates.map((template) => (
<button
key={template.id}
onClick={() => void handleCreatePageTemplate(template)}
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-white"
>
<span>{template.emoji}</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-silos-text">{template.label}</p>
<p className="truncate text-[10px] text-silos-muted">{template.description}</p>
</div>
</button>
))}
</div>
)}
</div>
)
})}

View file

@ -3,6 +3,7 @@ import RecentActivities from './RecentActivities'
import ActivityStatus from './ActivityStatus'
import DiscoverPanel from './DiscoverPanel'
import RecentProjectsGrid from './RecentProjectsGrid'
import GettingStartedPanel from './GettingStartedPanel'
import { getStoredUser } from '../../lib/api'
import type { DocumentRecord } from '../../lib/documents'
@ -33,6 +34,7 @@ interface DashboardFeedProps {
loading?: boolean
error?: string | null
onOpenRecent?: () => void
onOpenTemplates: () => void
}
export default function DashboardFeed({
@ -40,6 +42,7 @@ export default function DashboardFeed({
loading,
error,
onOpenRecent,
onOpenTemplates,
}: DashboardFeedProps) {
const [activeTab, setActiveTab] = useState<'feed' | 'discover'>('feed')
const user = getStoredUser()
@ -71,7 +74,9 @@ export default function DashboardFeed({
{/* Recent pages grid */}
<div className="mt-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-silos-text">Recent</h2>
<h2 className="text-sm font-semibold text-silos-text">
{recent.length > 0 ? 'Recent' : 'Get started'}
</h2>
{recent.length > 0 && (
<button
type="button"
@ -82,7 +87,11 @@ export default function DashboardFeed({
</button>
)}
</div>
<RecentProjectsGrid documents={recent} loading={loading} />
{recent.length === 0 && !loading ? (
<GettingStartedPanel onOpenTemplates={onOpenTemplates} />
) : (
<RecentProjectsGrid documents={recent} loading={loading} />
)}
</div>
{/* Tabs */}
@ -104,11 +113,37 @@ export default function DashboardFeed({
{activeTab === 'feed' ? (
<div className="mt-6 grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<RecentActivities documents={recent} loading={loading} onViewAll={onOpenRecent} />
</div>
{!loading && recent.length === 0 ? (
<div className="lg:col-span-3">
<div className="rounded-2xl border border-silos-cream-dark bg-white p-6">
<h3 className="font-semibold text-silos-text">What you can build in Silos</h3>
<p className="mt-1 text-sm text-silos-muted">
Keep your team aligned with focused spaces for writing, collaboration, and
visual thinking.
</p>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{[
['Pages', 'Capture decisions and keep knowledge easy to find.'],
['Live docs', 'Work together on notes, plans, and reviews.'],
['Whiteboards', 'Turn early ideas into clear visual plans.'],
].map(([title, description]) => (
<div key={title} className="rounded-xl bg-silos-cream/50 p-3">
<p className="text-sm font-medium text-silos-text">{title}</p>
<p className="mt-1 text-xs leading-5 text-silos-muted">{description}</p>
</div>
))}
</div>
</div>
</div>
) : (
<div className="lg:col-span-3">
<RecentActivities documents={recent} loading={loading} onViewAll={onOpenRecent} />
</div>
)}
<div className="space-y-6 lg:col-span-2">
<ActivityStatus documentCount={recent.length} />
{!(!loading && recent.length === 0) && (
<ActivityStatus documentCount={recent.length} />
)}
<DiscoverPanel />
</div>
</div>

View file

@ -78,6 +78,10 @@ export default function DashboardLayout() {
setActiveNav('recent')
setFlyout('recent')
}}
onOpenTemplates={() => {
setActiveNav('templates')
setFlyout('templates')
}}
/>
</div>
</div>

View file

@ -0,0 +1,144 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
FileText,
FolderOpen,
LayoutGrid,
LayoutTemplate,
PenTool,
Sparkles,
Zap,
type LucideIcon,
} from 'lucide-react'
import { createOptions, type CreateOption } from '../../data/createData'
import { getStoredUser } from '../../lib/api'
import { createDocument } from '../../lib/documents'
interface GettingStartedPanelProps {
onOpenTemplates: () => void
}
const iconMap: Record<string, LucideIcon> = {
FileText,
FolderOpen,
LayoutGrid,
LayoutTemplate,
PenTool,
Zap,
}
const starterOptions = createOptions.filter(
(option): option is CreateOption & { id: 'silo' | 'sketch' } =>
option.id === 'silo' || option.id === 'sketch',
)
export default function GettingStartedPanel({ onOpenTemplates }: GettingStartedPanelProps) {
const navigate = useNavigate()
const [creating, setCreating] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const author = getStoredUser()?.full_name ?? 'You'
async function createStarter(type: 'silo' | 'sketch') {
setCreating(type)
setError(null)
try {
const content =
type === 'silo'
? { body: '', bodyHtml: '', status: 'draft' }
: { elements: [] }
const title = type === 'silo' ? 'Untitled Page' : 'Untitled Whiteboard'
const doc = await createDocument(type, title, content, undefined, author)
navigate(`/workspace/${type}/${doc.id}`)
} catch {
setError('Could not create your starter. Please try again.')
} finally {
setCreating(null)
}
}
return (
<div className="overflow-hidden rounded-2xl border border-silos-cream-dark bg-white">
<div className="border-b border-silos-cream-dark bg-gradient-to-br from-silos-cream/70 to-white px-5 py-6 sm:px-7">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-silos-tan/20">
<Sparkles className="h-5 w-5 text-silos-tan" />
</div>
<div>
<h3 className="text-lg font-semibold text-silos-text">Make this workspace yours</h3>
<p className="mt-1 max-w-2xl text-sm text-silos-muted">
Start with a template or choose the right space for your next idea. Your picks will
appear here the next time you sign in.
</p>
</div>
</div>
</div>
<div className="grid gap-3 p-4 sm:grid-cols-2 sm:p-5 lg:grid-cols-4">
<button
type="button"
onClick={onOpenTemplates}
className="group rounded-xl border border-silos-tan/40 bg-silos-cream/30 p-4 text-left transition-colors hover:border-silos-tan hover:bg-silos-cream/70"
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-silos-tan/20">
<LayoutTemplate className="h-4 w-4 text-silos-tan" />
</div>
<p className="mt-3 text-sm font-semibold text-silos-text">Browse templates</p>
<p className="mt-1 text-xs leading-5 text-silos-muted">
Meeting notes, retrospectives, architecture reviews, and more.
</p>
<span className="mt-3 inline-block text-xs font-medium text-silos-brown group-hover:underline">
Explore templates
</span>
</button>
{starterOptions.map((option) => {
const Icon = iconMap[option.icon]
const isCreating = creating === option.id
return (
<button
key={option.id}
type="button"
disabled={creating !== null}
onClick={() => void createStarter(option.id)}
className="group rounded-xl border border-silos-cream-dark p-4 text-left transition-colors hover:border-silos-tan hover:bg-silos-cream/40 disabled:cursor-wait disabled:opacity-60"
>
<div
className="flex h-9 w-9 items-center justify-center rounded-lg"
style={{ backgroundColor: `${option.color}22` }}
>
<Icon className="h-4 w-4" style={{ color: option.color }} />
</div>
<p className="mt-3 text-sm font-semibold text-silos-text">
{isCreating ? 'Creating…' : `Create a ${option.label.toLowerCase()}`}
</p>
<p className="mt-1 text-xs leading-5 text-silos-muted">{option.description}</p>
<span className="mt-3 inline-block text-xs font-medium text-silos-brown group-hover:underline">
Start now
</span>
</button>
)
})}
<button
type="button"
onClick={() => navigate('/workspace/stead/new')}
className="group rounded-xl border border-silos-cream-dark p-4 text-left transition-colors hover:border-silos-tan hover:bg-silos-cream/40"
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#9B7BB8]/15">
<FolderOpen className="h-4 w-4 text-[#9B7BB8]" />
</div>
<p className="mt-3 text-sm font-semibold text-silos-text">Organize a space</p>
<p className="mt-1 text-xs leading-5 text-silos-muted">
Bring related pages, docs, and whiteboards together.
</p>
<span className="mt-3 inline-block text-xs font-medium text-silos-brown group-hover:underline">
Create a space
</span>
</button>
</div>
{error && <p className="px-5 pb-5 text-sm text-red-700">{error}</p>}
</div>
)
}

View file

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

View file

@ -1,38 +1,56 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import {
Bold,
Italic,
Underline,
AlignCenter,
AlignLeft,
Image,
Link,
AlignRight,
AtSign,
Smile,
Table,
Plus,
Bold,
Check,
Code2,
Columns2,
FileUp,
Image,
Italic,
Link,
List,
Heading1,
ListOrdered,
Palette,
Plus,
Smile,
Strikethrough,
Table,
Underline,
Video,
} from 'lucide-react'
const toolbarGroups = [
[
{ icon: Heading1, label: 'Heading', cmd: 'formatBlock', value: 'h2' },
{ icon: Bold, label: 'Bold', cmd: 'bold' },
{ icon: Italic, label: 'Italic', cmd: 'italic' },
{ icon: Underline, label: 'Underline', cmd: 'underline' },
],
[
{ icon: AlignLeft, label: 'Align', cmd: 'justifyLeft' },
{ icon: List, label: 'List', cmd: 'insertUnorderedList' },
],
type Popover = 'color' | 'emoji' | 'link' | 'image' | 'video' | 'insert' | null
type MediaType = 'link' | 'image' | 'video'
const textColors = [
'#172B4D', '#42526E', '#5E6C84', '#7A869A', '#091E42', '#0747A6',
'#0065FF', '#00875A', '#36B37E', '#FFAB00', '#FF8B00', '#DE350B',
'#BF2600', '#6554C0', '#403294', '#FFFFFF', '#E3E8EF', '#B3BAC5',
]
const highlightColors = [
'transparent', '#DEEBFF', '#E6FCFF', '#E3FCEF', '#FFFAE6', '#FFEBE6',
'#EAE6FF', '#FFF0B3', '#C6F6D5', '#FED7D7', '#E9D8FD', '#D6F5FF',
]
const emojiGroups = [
{ label: 'Smileys', items: ['😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😎', '🤓', '🧐', '🤩', '🥳', '😏', '😢', '😭', '😡', '🤔', '🤗', '🤭', '🤫'] },
{ label: 'People', items: ['👋', '👏', '🙌', '👍', '👎', '👌', '🙏', '💪', '🤝', '👀', '🧠', '💬', '🗣️', '👤', '👥', '🧑‍💻', '👩‍💻', '👨‍💻'] },
{ label: 'Work', items: ['✅', '❌', '⚠️', '❗', '❓', '💡', '🔥', '⭐', '🎯', '🚀', '📌', '📎', '📝', '📋', '📊', '📈', '📅', '⏰', '🔗', '🔒', '💻', '🛠️', '🏗️', '🎨', '🧪', '🎉'] },
{ label: 'Objects', items: ['❤️', '💛', '💚', '💙', '💜', '🖤', '✨', '🌟', '☀️', '🌈', '🌱', '🌍', '☕', '🍎', '🎁', '🏆', '💰', '📣', '✏️', '📷', '🎥', '📁', '🗂️', '🗑️'] },
]
const slashItems = [
{ label: 'Paragraph', html: '<p>Paragraph</p>' },
{ label: 'Heading', html: '<h2>Heading</h2>' },
{ label: 'Bullet list', html: '<ul><li>Item</li></ul>' },
{ label: 'Table', html: '<table class="w-full border"><tr><th>A</th><th>B</th></tr><tr><td> </td><td> </td></tr></table>' },
{ label: 'Info panel', html: '<div class="rounded-lg border border-silos-tan bg-silos-cream/60 p-3 my-2">Info: add a note here.</div>' },
{ label: 'Paragraph', description: 'Start with plain text', html: '<p>Paragraph</p>' },
{ label: 'Heading', description: 'Add a section heading', html: '<h2>Heading</h2>' },
{ label: 'Bullet list', description: 'Create a simple list', html: '<ul><li>List item</li></ul>' },
{ label: 'Numbered list', description: 'Create a numbered list', html: '<ol><li>List item</li></ol>' },
{ label: 'Table', description: 'Insert a formatted table', html: '<table class="silos-table"><thead><tr><th>Header</th><th>Header</th></tr></thead><tbody><tr><td>Cell</td><td>Cell</td></tr></tbody></table>' },
{ label: 'Info panel', description: 'Highlight important information', html: '<div class="silos-panel"><strong>Info</strong><br />Add a note here.</div>' },
]
const starterHtml: Record<string, string> = {
@ -44,13 +62,39 @@ const starterHtml: Record<string, string> = {
interface SiloEditorProps {
html: string
onChange: (html: string) => void
editable?: boolean
}
export default function SiloEditor({ html, onChange }: SiloEditorProps) {
const editorRef = useRef<HTMLDivElement>(null)
const [slashOpen, setSlashOpen] = useState(false)
function isSafeUrl(value: string): boolean {
return /^(https?:\/\/|mailto:|tel:)/i.test(value.trim())
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
return entities[character]
})
}
export default function SiloEditor({ html, onChange, editable = true }: SiloEditorProps) {
const rootRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<HTMLDivElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const savedRange = useRef<Range | null>(null)
const primed = useRef(false)
const [popover, setPopover] = useState<Popover>(null)
const [mediaType, setMediaType] = useState<MediaType>('link')
const [mediaUrl, setMediaUrl] = useState('')
const [mediaLabel, setMediaLabel] = useState('')
const [emojiSearch, setEmojiSearch] = useState('')
const [slashOpen, setSlashOpen] = useState(false)
const [activeMarks, setActiveMarks] = useState<Record<string, boolean>>({})
useEffect(() => {
if (!primed.current && editorRef.current) {
@ -59,133 +103,284 @@ export default function SiloEditor({ html, onChange }: SiloEditorProps) {
}
}, [html])
useEffect(() => {
function closeOnOutside(event: globalThis.MouseEvent) {
if (
rootRef.current &&
!rootRef.current.contains(event.target as Node)
) {
setPopover(null)
setSlashOpen(false)
}
}
function closeOnEscape(event: globalThis.KeyboardEvent) {
if (event.key === 'Escape') {
setPopover(null)
setSlashOpen(false)
}
}
document.addEventListener('mousedown', closeOnOutside)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('mousedown', closeOnOutside)
document.removeEventListener('keydown', closeOnEscape)
}
}, [])
useEffect(() => {
function updateMarks() {
if (!editorRef.current || !editorRef.current.contains(document.activeElement)) return
setActiveMarks({
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strikeThrough: document.queryCommandState('strikeThrough'),
insertUnorderedList: document.queryCommandState('insertUnorderedList'),
insertOrderedList: document.queryCommandState('insertOrderedList'),
})
}
document.addEventListener('selectionchange', updateMarks)
return () => document.removeEventListener('selectionchange', updateMarks)
}, [])
function emit() {
if (editorRef.current) onChange(editorRef.current.innerHTML)
if (editable && editorRef.current) onChange(editorRef.current.innerHTML)
}
function run(cmd: string, value?: string) {
document.execCommand(cmd, false, value)
function saveSelection() {
const selection = window.getSelection()
if (selection?.rangeCount && editorRef.current?.contains(selection.anchorNode)) {
savedRange.current = selection.getRangeAt(0).cloneRange()
}
}
function restoreSelection() {
if (!savedRange.current) {
editorRef.current?.focus()
return
}
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(savedRange.current)
editorRef.current?.focus()
}
function togglePopover(event: MouseEvent<HTMLButtonElement>, next: Popover) {
event.preventDefault()
saveSelection()
setPopover((current) => (current === next ? null : next))
setSlashOpen(false)
}
function run(command: string, value?: string) {
if (!editable) return
restoreSelection()
document.execCommand(command, false, value)
emit()
}
function insertHtml(snippet: string) {
if (!editable) return
restoreSelection()
document.execCommand('insertHTML', false, snippet)
setPopover(null)
setSlashOpen(false)
emit()
}
function handleInput() {
if (!editable) return
const text = editorRef.current?.innerText ?? ''
if (text.endsWith('/')) setSlashOpen(true)
else setSlashOpen(false)
setSlashOpen(text.endsWith('/'))
emit()
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') setSlashOpen(false)
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Escape') {
setSlashOpen(false)
setPopover(null)
}
if (event.key === '/' && editable) {
window.setTimeout(saveSelection, 0)
}
}
function openMedia(next: MediaType, event: MouseEvent<HTMLButtonElement>) {
event.preventDefault()
saveSelection()
setMediaType(next)
setMediaUrl('')
setMediaLabel('')
setPopover(next)
}
function insertMedia() {
const url = mediaUrl.trim()
if (!isSafeUrl(url)) return
const safeUrl = escapeHtml(url)
const safeLabel = escapeHtml(mediaLabel.trim())
if (mediaType === 'link') {
if (mediaLabel.trim()) {
insertHtml(
`<a href="${safeUrl}" target="_blank" rel="noreferrer">${safeLabel}</a>`,
)
} else {
restoreSelection()
document.execCommand('createLink', false, safeUrl)
emit()
}
} else if (mediaType === 'image') {
insertHtml(
`<figure class="silos-media"><img src="${safeUrl}" alt="${safeLabel || 'Inserted image'}" /><figcaption>${safeLabel}</figcaption></figure>`,
)
} else {
insertHtml(
`<div class="silos-media"><a href="${safeUrl}" target="_blank" rel="noreferrer">Open video</a><div class="silos-embed">${safeLabel || safeUrl}</div></div>`,
)
}
setPopover(null)
setMediaUrl('')
setMediaLabel('')
}
function insertFile(file: File) {
if (!file.type.startsWith('image/')) return
const reader = new FileReader()
reader.onload = () =>
insertHtml(
`<figure class="silos-media"><img src="${String(reader.result)}" alt="${escapeHtml(file.name)}" /><figcaption>${escapeHtml(file.name)}</figcaption></figure>`,
)
reader.readAsDataURL(file)
}
const filteredEmojis = emojiGroups.map((group) => ({
...group,
items: emojiSearch
? group.items.filter((emoji) => emoji.includes(emojiSearch))
: group.items,
}))
return (
<div className="flex min-h-[calc(100vh-8rem)] flex-col">
<div
ref={rootRef}
onMouseDown={(event) => {
const target = event.target as HTMLElement
if (!target.closest('[data-silo-popover]')) {
if (target.closest('button, select')) saveSelection()
setPopover(null)
}
}}
className="relative flex min-h-[calc(100vh-8rem)] flex-col"
>
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-silos-cream-dark bg-white py-2">
{toolbarGroups.map((group, gi) => (
<div key={gi} className="flex items-center gap-0.5">
{group.map(({ icon: Icon, label, cmd, value }) => (
<button
key={label}
type="button"
title={label}
onClick={() => run(cmd, value)}
className="rounded p-1.5 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
>
<Icon className="h-4 w-4" />
</button>
))}
{gi < toolbarGroups.length - 1 && <div className="mx-1 h-5 w-px bg-silos-cream-dark" />}
</div>
))}
<select
disabled={!editable}
onMouseDown={saveSelection}
defaultValue="paragraph"
onChange={(event) => run('formatBlock', event.target.value)}
className="h-8 rounded px-2 text-sm text-silos-text outline-none hover:bg-silos-cream disabled:opacity-40"
aria-label="Text style"
>
<option value="paragraph">Normal text</option>
<option value="h1">Heading 1</option>
<option value="h2">Heading 2</option>
<option value="h3">Heading 3</option>
<option value="blockquote">Quote</option>
<option value="pre">Code block</option>
</select>
<div className="mx-1 h-5 w-px bg-silos-cream-dark" />
<button
type="button"
title="Link"
onClick={() => {
const url = window.prompt('Link URL')
if (url) run('createLink', url)
}}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
>
<Link className="h-4 w-4" />
</button>
<button type="button" title="Image" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<Image className="h-4 w-4" />
</button>
<button type="button" title="Mention" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<AtSign className="h-4 w-4" />
</button>
<button type="button" title="Emoji" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<Smile className="h-4 w-4" />
</button>
<button
type="button"
title="Table"
onClick={() => insertHtml('<table class="w-full border"><tr><th>A</th><th>B</th></tr><tr><td></td><td></td></tr></table>')}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
>
<Table className="h-4 w-4" />
</button>
<button
type="button"
title="Insert"
onClick={() => setSlashOpen((v) => !v)}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
>
<Plus className="h-4 w-4" />
</button>
{[
[Bold, 'Bold', 'bold'],
[Italic, 'Italic', 'italic'],
[Underline, 'Underline', 'underline'],
[Strikethrough, 'Strikethrough', 'strikeThrough'],
[List, 'Bullet list', 'insertUnorderedList'],
[ListOrdered, 'Numbered list', 'insertOrderedList'],
].map(([Icon, label, command]) => {
const ToolbarIcon = Icon as typeof Bold
return (
<button
key={String(label)}
type="button"
title={String(label)}
disabled={!editable}
onMouseDown={(event) => event.preventDefault()}
onClick={() => run(String(command))}
className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream hover:text-silos-text disabled:opacity-40 ${activeMarks[String(command)] ? 'bg-blue-100 text-blue-700' : ''}`}
>
<ToolbarIcon className="h-4 w-4" />
</button>
)
})}
<div className="mx-1 h-5 w-px bg-silos-cream-dark" />
{[['Left', AlignLeft, 'justifyLeft'], ['Center', AlignCenter, 'justifyCenter'], ['Right', AlignRight, 'justifyRight']].map(([label, Icon, command]) => {
const ToolbarIcon = Icon as typeof AlignLeft
return <button key={String(label)} type="button" title={`Align ${String(label).toLowerCase()}`} disabled={!editable} onMouseDown={(event) => event.preventDefault()} onClick={() => run(String(command))} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><ToolbarIcon className="h-4 w-4" /></button>
})}
<button type="button" title="Text and highlight colours" disabled={!editable} onClick={(event) => togglePopover(event, 'color')} className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40 ${popover === 'color' ? 'bg-silos-cream text-silos-brown' : ''}`}><Palette className="h-4 w-4" /></button>
<button type="button" title="Link" disabled={!editable} onClick={(event) => openMedia('link', event)} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Link className="h-4 w-4" /></button>
<button type="button" title="Image URL" disabled={!editable} onClick={(event) => openMedia('image', event)} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Image className="h-4 w-4" /></button>
<button type="button" title="Upload image" disabled={!editable} onMouseDown={(event) => event.preventDefault()} onClick={() => { saveSelection(); fileRef.current?.click() }} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><FileUp className="h-4 w-4" /></button>
<button type="button" title="Video URL" disabled={!editable} onClick={(event) => openMedia('video', event)} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Video className="h-4 w-4" /></button>
<button type="button" title="Mention" disabled={!editable} onClick={() => insertHtml('<span class="silos-mention">@mention</span>')} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><AtSign className="h-4 w-4" /></button>
<button type="button" title="Emoji" disabled={!editable} onClick={(event) => togglePopover(event, 'emoji')} className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40 ${popover === 'emoji' ? 'bg-silos-cream text-silos-brown' : ''}`}><Smile className="h-4 w-4" /></button>
<button type="button" title="Table" disabled={!editable} onClick={() => insertHtml('<table class="silos-table"><thead><tr><th>Header</th><th>Header</th><th>Header</th></tr></thead><tbody><tr><td>Cell</td><td>Cell</td><td>Cell</td></tr><tr><td>Cell</td><td>Cell</td><td>Cell</td></tr></tbody></table>')} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Table className="h-4 w-4" /></button>
<button type="button" title="Tabs" disabled={!editable} onClick={() => insertHtml('<div class="silos-tabs"><div class="silos-tab-list"><span class="silos-tab-active">Tab 1</span><span>Tab 2</span><span>+</span></div><div class="silos-tab-content">Tab content</div></div>')} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Columns2 className="h-4 w-4" /></button>
<button type="button" title="More insert options" disabled={!editable} onClick={(event) => togglePopover(event, 'insert')} className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40 ${popover === 'insert' ? 'bg-silos-cream text-silos-brown' : ''}`}><Plus className="h-4 w-4" /></button>
</div>
{popover === 'color' && (
<div data-silo-popover className="absolute left-24 top-11 z-30 w-72 rounded-xl border border-silos-cream-dark bg-white p-3 shadow-xl">
<p className="mb-2 text-xs font-semibold text-silos-text">Text colour</p>
<div className="grid grid-cols-9 gap-2">{textColors.map((color) => <button key={color} type="button" title={color} onMouseDown={(event) => event.preventDefault()} onClick={() => { run('foreColor', color); setPopover(null) }} className="h-6 w-6 rounded border ring-1 ring-silos-cream-dark" style={{ backgroundColor: color }}><Check className="mx-auto h-3 w-3 text-white opacity-0" /></button>)}</div>
<p className="mb-2 mt-4 text-xs font-semibold text-silos-text">Highlight colour</p>
<div className="grid grid-cols-6 gap-2">{highlightColors.map((color) => <button key={color} type="button" title={color === 'transparent' ? 'Remove highlight' : color} onMouseDown={(event) => event.preventDefault()} onClick={() => { run('hiliteColor', color); setPopover(null) }} className="h-7 w-7 rounded border ring-1 ring-silos-cream-dark" style={{ backgroundColor: color === 'transparent' ? 'white' : color }}>{color === 'transparent' && <span className="text-xs text-silos-muted">×</span>}</button>)}</div>
<button type="button" onClick={() => { run('removeFormat'); setPopover(null) }} className="mt-3 w-full rounded-lg border border-silos-cream-dark py-1.5 text-xs text-silos-muted hover:bg-silos-cream">Clear formatting</button>
</div>
)}
{popover === 'emoji' && (
<div data-silo-popover className="absolute right-12 top-11 z-30 max-h-[28rem] w-80 overflow-y-auto rounded-xl border border-silos-cream-dark bg-white p-3 shadow-xl">
<input autoFocus value={emojiSearch} onChange={(event) => setEmojiSearch(event.target.value)} placeholder="Search emojis" className="mb-3 w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan" />
{filteredEmojis.map((group) => group.items.length > 0 && <div key={group.label}><p className="mb-1 mt-2 text-xs font-semibold text-silos-muted">{group.label}</p><div className="grid grid-cols-8 gap-1">{group.items.map((emoji) => <button key={emoji} type="button" onClick={() => insertHtml(emoji)} className="rounded p-1 text-xl hover:bg-silos-cream">{emoji}</button>)}</div></div>)}
</div>
)}
{(popover === 'link' || popover === 'image' || popover === 'video') && (
<div data-silo-popover className="absolute right-20 top-11 z-30 w-80 rounded-xl border border-silos-cream-dark bg-white p-4 shadow-xl">
<div className="mb-3 flex items-center gap-2"><Code2 className="h-4 w-4 text-silos-muted" /><h3 className="text-sm font-semibold text-silos-text">Insert {mediaType}</h3></div>
<label className="mb-1 block text-xs font-medium text-silos-muted">{mediaType === 'link' ? 'URL' : `${mediaType} URL`}</label>
<input autoFocus value={mediaUrl} onChange={(event) => setMediaUrl(event.target.value)} placeholder="https://..." className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan" onKeyDown={(event) => event.key === 'Enter' && insertMedia()} />
<label className="mb-1 mt-3 block text-xs font-medium text-silos-muted">{mediaType === 'link' ? 'Displayed text (optional)' : 'Caption (optional)'}</label>
<input value={mediaLabel} onChange={(event) => setMediaLabel(event.target.value)} placeholder={mediaType === 'link' ? 'Open link' : 'Add a caption'} className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan" onKeyDown={(event) => event.key === 'Enter' && insertMedia()} />
{!isSafeUrl(mediaUrl) && mediaUrl.length > 0 && <p className="mt-2 text-xs text-red-600">Enter a valid http(s) URL.</p>}
<div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setPopover(null)} className="rounded-lg px-3 py-1.5 text-xs text-silos-muted hover:bg-silos-cream">Cancel</button><button type="button" disabled={!isSafeUrl(mediaUrl)} onClick={insertMedia} className="rounded-lg bg-silos-brown px-3 py-1.5 text-xs text-white disabled:opacity-40">Insert</button></div>
</div>
)}
{popover === 'insert' && (
<div data-silo-popover className="absolute right-0 top-11 z-30 w-72 rounded-xl border border-silos-cream-dark bg-white p-2 shadow-xl">
{slashItems.map((item) => <button key={item.label} type="button" onClick={() => insertHtml(item.html)} className="flex w-full items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-silos-cream"><span className="mt-0.5 text-sm font-medium text-silos-text">{item.label}</span><span className="text-xs text-silos-muted">{item.description}</span></button>)}
</div>
)}
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) insertFile(file); event.target.value = '' }} />
<div className="relative flex-1 py-6">
<div
ref={editorRef}
contentEditable
contentEditable={editable}
suppressContentEditableWarning
onInput={handleInput}
onKeyDown={handleKeyDown}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
data-placeholder="Press / to insert elements, or just start writing..."
className="prose min-h-[50vh] w-full max-w-none text-base leading-relaxed text-silos-text outline-none empty:before:text-silos-muted/60 empty:before:content-[attr(data-placeholder)]"
/>
{slashOpen && (
<div className="absolute left-0 top-16 z-20 w-64 rounded-xl border border-silos-cream-dark bg-white p-1 shadow-lg">
{slashItems.map((item) => (
<button
key={item.label}
type="button"
onClick={() => insertHtml(item.html)}
className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"
>
{item.label}
</button>
))}
</div>
)}
{slashOpen && <div data-silo-popover className="absolute left-0 top-16 z-20 w-72 rounded-xl border border-silos-cream-dark bg-white p-1 shadow-lg">{slashItems.map((item) => <button key={item.label} type="button" onClick={() => insertHtml(item.html)} className="flex w-full flex-col rounded-lg px-3 py-2 text-left hover:bg-silos-cream"><span className="text-sm font-medium text-silos-text">{item.label}</span><span className="text-xs text-silos-muted">{item.description}</span></button>)}</div>}
</div>
<div className="sticky bottom-0 border-t border-silos-cream-dark bg-white py-4">
<div className="flex flex-wrap gap-2">
{Object.keys(starterHtml).map((t) => (
<button
key={t}
type="button"
onClick={() => insertHtml(starterHtml[t])}
className="rounded-full border border-silos-cream-dark bg-silos-cream/40 px-3 py-1.5 text-xs text-silos-text hover:border-silos-tan"
>
{t}
</button>
))}
<button type="button" className="rounded-full border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-muted">
All templates
</button>
</div>
<div className="flex flex-wrap gap-2">{Object.keys(starterHtml).map((template) => <button key={template} type="button" disabled={!editable} onClick={() => insertHtml(starterHtml[template])} className="rounded-full border border-silos-cream-dark bg-silos-cream/40 px-3 py-1.5 text-xs text-silos-text hover:border-silos-tan disabled:opacity-40">{template}</button>)}<button type="button" disabled={!editable} onClick={() => setPopover('insert')} className="rounded-full border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-muted hover:bg-silos-cream disabled:opacity-40">All blocks</button></div>
</div>
</div>
)

View file

@ -33,6 +33,7 @@ interface SketchCanvasProps {
elements: SketchElement[]
viewport?: SketchViewport
onChange: (elements: SketchElement[], viewport?: SketchViewport) => void
readOnly?: boolean
}
type DragMode =
@ -69,7 +70,7 @@ function normalizeRect(x1: number, y1: number, x2: number, y2: number) {
}
}
export default function SketchCanvas({ elements, viewport, onChange }: SketchCanvasProps) {
export default function SketchCanvas({ elements, viewport, onChange, readOnly = false }: SketchCanvasProps) {
const [activeTool, setActiveTool] = useState<SketchTool>('pen')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [pan, setPan] = useState({ x: viewport?.panX ?? 0, y: viewport?.panY ?? 0 })
@ -113,6 +114,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
)
const undo = () => {
if (readOnly) return
if (history.length === 0) return
const prev = history[history.length - 1]
setFuture((f) => [{ elements, pan, zoom }, ...f])
@ -124,6 +126,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
}
const redo = () => {
if (readOnly) return
if (future.length === 0) return
const next = future[0]
setHistory((h) => [...h, { elements, pan, zoom }])
@ -135,6 +138,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (readOnly) return
if (e.key === 'Delete' || e.key === 'Backspace') {
if (editingId) return
if (selectedId) {
@ -160,7 +164,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [selectedId, editingId, elements, pan, zoom, onChange, pushHistory])
}, [selectedId, editingId, elements, pan, zoom, onChange, pushHistory, readOnly])
function findElementAt(point: Point): SketchElement | null {
for (let i = elements.length - 1; i >= 0; i--) {
@ -178,6 +182,8 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
return
}
if (readOnly) return
if (activeTool === 'select') {
const hit = findElementAt(point)
if (hit) {
@ -668,6 +674,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
}
}}
onDoubleClick={(e) => {
if (readOnly) return
e.stopPropagation()
setEditingId(el.id)
setActiveTool('select')
@ -700,6 +707,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
}
}}
onDoubleClick={(e) => {
if (readOnly) return
e.stopPropagation()
setEditingId(el.id)
setActiveTool('select')
@ -750,6 +758,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
>
<input
value={cell}
readOnly={readOnly}
onChange={(e) => {
const cells = el.cells.map((r) => [...r])
cells[ri][ci] = e.target.value
@ -806,6 +815,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
setActiveTool(id)
setShowMore(false)
}}
disabled={readOnly && id !== 'select' && id !== 'hand'}
className={`rounded-lg p-2 transition-colors ${
activeTool === id
? 'bg-silos-brown/10 text-silos-brown'
@ -824,6 +834,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
setActiveTool('rect')
setShowMore(false)
}}
disabled={readOnly}
className={`rounded-lg p-2 transition-colors ${
activeTool === 'rect'
? 'bg-silos-brown/10 text-silos-brown'

View file

@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
import { Check, CornerDownRight, MessageSquare, Send, Trash2, X } from 'lucide-react'
import { documentsApi, getStoredUser, type ApiActivity, type ApiComment } from '../../lib/api'
interface CommentsPanelProps {
documentId: string
onClose: () => void
}
export default function CommentsPanel({ documentId, onClose }: CommentsPanelProps) {
const [comments, setComments] = useState<ApiComment[]>([])
const [body, setBody] = useState('')
const [replyTo, setReplyTo] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activity, setActivity] = useState<ApiActivity[]>([])
const [view, setView] = useState<'comments' | 'activity'>('comments')
const user = getStoredUser()
useEffect(() => {
void documentsApi.listComments(documentId).then(setComments).catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Could not load comments.')
}).finally(() => setLoading(false))
void documentsApi.listActivity(documentId).then(setActivity).catch(() => setActivity([]))
}, [documentId])
async function submit(event: React.FormEvent) {
event.preventDefault()
if (!body.trim()) return
setError(null)
try {
const comment = await documentsApi.createComment(documentId, body.trim(), replyTo ?? undefined)
setComments((current) => [...current, comment])
setBody('')
setReplyTo(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not add comment.')
}
}
async function toggleResolved(comment: ApiComment) {
try {
const updated = await documentsApi.resolveComment(documentId, comment.id, !comment.resolved)
setComments((current) => current.map((item) => item.id === updated.id ? updated : item))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update comment status.')
}
}
function renderComment(comment: ApiComment, depth = 0): React.ReactNode {
const replies = comments.filter((item) => item.parent_id === comment.id)
return (
<div key={comment.id} className={depth > 0 ? 'ml-5 border-l-2 border-silos-cream-dark pl-3' : ''}>
<article className={`rounded-xl border p-3 ${comment.resolved ? 'border-green-200 bg-green-50/40' : 'border-silos-cream-dark'}`}>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-silos-text">{comment.author_name}</p>
<div className="flex items-center gap-1">
<button type="button" onClick={() => setReplyTo(comment.id)} className="rounded p-1 text-silos-muted hover:bg-silos-cream" aria-label="Reply"><CornerDownRight className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => void toggleResolved(comment)} className="rounded p-1 text-silos-muted hover:bg-silos-cream" aria-label={comment.resolved ? 'Reopen comment' : 'Resolve comment'}><Check className={`h-3.5 w-3.5 ${comment.resolved ? 'text-green-700' : ''}`} /></button>
{comment.author_id === user?.id && <button type="button" onClick={() => void remove(comment.id)} className="rounded p-1 text-silos-muted hover:bg-red-50 hover:text-red-700" aria-label="Delete comment"><Trash2 className="h-3.5 w-3.5" /></button>}
</div>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm text-silos-text">{comment.body}</p>
<time className="mt-2 block text-[10px] text-silos-muted">{new Date(comment.created_at).toLocaleString()}</time>
</article>
{replies.length > 0 && <div className="mt-2 space-y-2">{replies.map((reply) => renderComment(reply, depth + 1))}</div>}
</div>
)
}
async function remove(commentId: string) {
try {
await documentsApi.deleteComment(documentId, commentId)
setComments((current) => current.filter((comment) => comment.id !== commentId))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not delete comment.')
}
}
return (
<aside className="fixed right-0 top-12 z-40 flex h-[calc(100vh-3rem)] w-full max-w-sm flex-col border-l border-silos-cream-dark bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-silos-brown" />
<h2 className="font-semibold text-silos-text">{view === 'comments' ? 'Comments' : 'Activity'}</h2>
<span className="rounded-full bg-silos-cream px-2 py-0.5 text-xs text-silos-muted">{comments.length}</span>
</div>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 hover:bg-silos-cream" aria-label="Close comments">
<X className="h-4 w-4 text-silos-muted" />
</button>
</div>
<div className="flex border-b border-silos-cream-dark px-4">
<button type="button" onClick={() => setView('comments')} className={`px-2 py-2 text-xs font-medium ${view === 'comments' ? 'border-b-2 border-silos-brown text-silos-brown' : 'text-silos-muted'}`}>Comments</button>
<button type="button" onClick={() => setView('activity')} className={`px-2 py-2 text-xs font-medium ${view === 'activity' ? 'border-b-2 border-silos-brown text-silos-brown' : 'text-silos-muted'}`}>Activity</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{view === 'activity' && <div className="space-y-3">{activity.length === 0 ? <p className="text-sm text-silos-muted">No activity yet.</p> : activity.map((entry) => <div key={entry.id} className="border-b border-silos-cream pb-3"><p className="text-sm text-silos-text">{entry.message}</p><p className="mt-1 text-[10px] text-silos-muted">{new Date(entry.created_at).toLocaleString()}</p></div>)}</div>}
{view === 'comments' && <>
{loading && <p className="text-sm text-silos-muted">Loading comments…</p>}
{!loading && comments.length === 0 && <p className="text-sm text-silos-muted">Start a conversation about this page.</p>}
<div className="space-y-3">
{comments.filter((comment) => !comment.parent_id).map((comment) => renderComment(comment))}
</div>
{error && <p className="mt-3 text-xs text-red-700">{error}</p>}
</>}
</div>
{view === 'comments' && <form onSubmit={(event) => void submit(event)} className="border-t border-silos-cream-dark p-3">
{replyTo && <div className="mb-2 flex items-center justify-between rounded-lg bg-silos-cream px-2 py-1 text-xs text-silos-muted">Replying to comment <button type="button" onClick={() => setReplyTo(null)} className="font-semibold text-silos-brown">Cancel</button></div>}
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
rows={3}
placeholder="Write a comment… Use @name to mention someone"
className="w-full resize-none rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-brown"
/>
<button type="submit" disabled={!body.trim()} className="mt-2 flex w-full items-center justify-center gap-2 rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white hover:bg-silos-brown-dark disabled:opacity-50">
<Send className="h-3.5 w-3.5" />Comment
</button>
</form>}
</aside>
)
}

View file

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Menu, Share2, Link2, MoreHorizontal, Users, Search } from 'lucide-react'
import { Menu, Share2, Link2, MoreHorizontal, Users, Search, Copy, Archive, Trash2, Plus, X, Bell } from 'lucide-react'
import { logos } from '../../data/brandAssets'
import { getStoredUser } from '../../lib/api'
import { documentsApi, getStoredUser, type ApiNotification } from '../../lib/api'
import { getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import CreateDropdown from '../create/CreateDropdown'
import WorkspaceSidebar from './WorkspaceSidebar'
@ -18,7 +18,13 @@ interface WorkspaceShellProps {
layout?: WorkspaceLayout
headerActions?: React.ReactNode
hideTitle?: boolean
titleReadOnly?: boolean
onTitleChange?: (title: string) => void
presence?: { name: string; initial: string }[]
onCreateChild?: () => void
onDuplicate?: () => void
onArchive?: () => void
onDelete?: () => void
}
export default function WorkspaceShell({
@ -28,13 +34,26 @@ export default function WorkspaceShell({
layout = 'default',
headerActions,
hideTitle = false,
titleReadOnly = false,
onTitleChange,
presence = [],
onCreateChild,
onDuplicate,
onArchive,
onDelete,
}: WorkspaceShellProps) {
const user = getStoredUser()
const [shareOpen, setShareOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [parentStead, setParentStead] = useState<DocumentRecord | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [notifications, setNotifications] = useState<ApiNotification[]>([])
const [notificationsOpen, setNotificationsOpen] = useState(false)
useEffect(() => {
void documentsApi.listNotifications().then(setNotifications).catch(() => setNotifications([]))
}, [])
useEffect(() => {
if (!doc.steadId) {
@ -59,6 +78,10 @@ export default function WorkspaceShell({
}, [])
function handleTitleChange(title: string) {
if (onTitleChange) {
onTitleChange(title)
return
}
void updateDocument(doc.id, { title }).then((updated) => {
if (updated) onDocChange(updated)
})
@ -106,7 +129,10 @@ export default function WorkspaceShell({
>
<Search className="h-3.5 w-3.5" />
</button>
<CreateDropdown steadId={doc.type === 'stead' ? doc.id : doc.steadId} />
<CreateDropdown
steadId={doc.type === 'stead' ? doc.id : doc.steadId}
parentPageId={doc.type === 'silo' ? doc.id : undefined}
/>
<div className="flex items-center gap-1 border-l border-silos-cream-dark pl-2">
<span className="hidden text-xs text-green-600 sm:inline">Saved</span>
{headerActions}
@ -142,9 +168,29 @@ export default function WorkspaceShell({
>
<Link2 className="h-3.5 w-3.5" />
</button>
<button className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="More">
<div className="relative">
<button type="button" onClick={() => { setNotificationsOpen((value) => !value); if (!notificationsOpen) void documentsApi.markNotificationsRead().then(() => setNotifications((items) => items.map((item) => ({ ...item, read: true })))) }} className="relative rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Notifications">
<Bell className="h-3.5 w-3.5" />
{notifications.some((item) => !item.read) && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-red-500" />}
</button>
{notificationsOpen && <div className="absolute right-0 top-9 z-50 w-80 rounded-xl border border-silos-cream-dark bg-white p-2 shadow-xl">
<div className="flex items-center justify-between px-2 py-1"><p className="text-xs font-semibold text-silos-text">Notifications</p><button type="button" onClick={() => setNotificationsOpen(false)} aria-label="Close notifications"><X className="h-3.5 w-3.5 text-silos-muted" /></button></div>
{notifications.length === 0 ? <p className="px-2 py-4 text-xs text-silos-muted">You’re all caught up.</p> : notifications.slice(0, 8).map((notification) => <div key={notification.id} className="border-t border-silos-cream px-2 py-2 text-xs text-silos-text"><p>{notification.message}</p><time className="text-[10px] text-silos-muted">{new Date(notification.created_at).toLocaleString()}</time></div>)}
</div>}
</div>
<button onClick={() => setMenuOpen((value) => !value)} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="More">
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{menuOpen && (
<div className="absolute right-3 top-10 z-50 w-56 rounded-xl border border-silos-cream-dark bg-white p-1.5 shadow-xl">
<p className="px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-silos-muted">Page actions</p>
{onCreateChild && <button onClick={() => { setMenuOpen(false); onCreateChild() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-text hover:bg-silos-cream"><Plus className="h-3.5 w-3.5" />Create child page</button>}
{onDuplicate && <button onClick={() => { setMenuOpen(false); onDuplicate() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-text hover:bg-silos-cream"><Copy className="h-3.5 w-3.5" />Duplicate page</button>}
{onArchive && <button onClick={() => { setMenuOpen(false); onArchive() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-text hover:bg-silos-cream"><Archive className="h-3.5 w-3.5" />Archive page</button>}
{onDelete && <button onClick={() => { setMenuOpen(false); onDelete() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-red-700 hover:bg-red-50"><Trash2 className="h-3.5 w-3.5" />Delete permanently</button>}
<button onClick={() => setMenuOpen(false)} className="mt-1 flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-muted hover:bg-silos-cream"><X className="h-3.5 w-3.5" />Close menu</button>
</div>
)}
</div>
</header>
@ -167,9 +213,11 @@ export default function WorkspaceShell({
<>
<input
value={doc.title}
readOnly={titleReadOnly}
onChange={(e) => handleTitleChange(e.target.value)}
className={`w-full border-none bg-transparent font-bold text-silos-text outline-none ${
isPage ? 'text-3xl' : 'text-2xl'
titleReadOnly ? 'cursor-default' : ''
} ${isPage ? 'text-3xl' : 'text-2xl'
}`}
placeholder={isPage ? 'Give this page a title' : 'Start with a title...'}
/>

View file

@ -1,7 +1,32 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, Plus } from 'lucide-react'
import { listDocuments, listDocumentsByStead, type DocumentRecord } from '../../lib/documents'
import { useEffect, useState, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import {
Archive,
Check,
ChevronDown,
ChevronRight,
Copy,
FileText,
LayoutGrid,
Link2,
MoreHorizontal,
PenTool,
Plus,
Star,
Trash2,
X,
Zap,
} from 'lucide-react'
import {
createDocument,
deleteDocument,
listDocuments,
listDocumentsByStead,
toggleStar,
updateDocument,
type DocumentRecord,
} from '../../lib/documents'
import { getStoredUser } from '../../lib/api'
import type { CreateType } from '../../data/createData'
const typeIcons: Record<CreateType, typeof FileText> = {
@ -27,107 +52,281 @@ interface WorkspaceSidebarProps {
export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: WorkspaceSidebarProps) {
const [docs, setDocs] = useState<DocumentRecord[]>([])
const [steads, setSteads] = useState<DocumentRecord[]>([])
const [creatingChildFor, setCreatingChildFor] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [menuId, setMenuId] = useState<string | null>(null)
const [renameId, setRenameId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [moveId, setMoveId] = useState<string | null>(null)
const navigate = useNavigate()
useEffect(() => {
if (steadId) {
void Promise.all([listDocumentsByStead(steadId), listDocuments('stead')]).then(
([siblings, spaces]) => {
setDocs(siblings)
setSteads(spaces)
},
)
} else {
void listDocuments().then((all) => {
setDocs(all.filter((d) => d.type !== 'stead'))
setSteads(all.filter((d) => d.type === 'stead'))
})
}
const load = steadId
? Promise.all([listDocumentsByStead(steadId), listDocuments('stead')]).then(
([siblings, spaces]) => {
setDocs(siblings)
setSteads(spaces)
},
)
: listDocuments().then((all) => {
setDocs(all.filter((doc) => doc.type !== 'stead'))
setSteads(all.filter((doc) => doc.type === 'stead'))
})
void load
}, [activeDocId, steadId])
const parent = steads.find((s) => s.id === steadId)
useEffect(() => {
function closeMenu(event: MouseEvent) {
if (!(event.target as HTMLElement).closest('[data-page-menu]')) setMenuId(null)
}
document.addEventListener('mousedown', closeMenu)
return () => document.removeEventListener('mousedown', closeMenu)
}, [])
const parent = steads.find((space) => space.id === steadId)
const roots = docs.filter((doc) => !doc.parentPageId)
const childrenByParent = docs.reduce<Record<string, DocumentRecord[]>>((groups, doc) => {
if (doc.parentPageId) groups[doc.parentPageId] = [...(groups[doc.parentPageId] ?? []), doc]
return groups
}, {})
function descendantsOf(id: string): Set<string> {
const descendants = new Set<string>()
const visit = (parentId: string) => {
for (const child of childrenByParent[parentId] ?? []) {
descendants.add(child.id)
visit(child.id)
}
}
visit(id)
return descendants
}
async function createChildPage(doc: DocumentRecord) {
setCreatingChildFor(doc.id)
try {
const child = await createDocument(
'silo',
`Child of ${doc.title}`,
{ body: '', bodyHtml: '', status: 'draft' },
undefined,
getStoredUser()?.full_name ?? 'You',
doc.steadId,
doc.id,
)
navigate(`/workspace/silo/${child.id}`)
} finally {
setCreatingChildFor(null)
}
}
function startRename(doc: DocumentRecord) {
setMenuId(null)
setRenameId(doc.id)
setRenameValue(doc.title)
}
async function saveRename(doc: DocumentRecord) {
const title = renameValue.trim()
setRenameId(null)
if (!title || title === doc.title) return
const updated = await updateDocument(doc.id, { title })
if (updated) setDocs((current) => current.map((item) => (item.id === doc.id ? updated : item)))
}
async function duplicatePage(doc: DocumentRecord) {
setMenuId(null)
const duplicate = await createDocument(
doc.type,
`${doc.title} copy`,
{ ...doc.content, status: 'draft' },
doc.templateId,
getStoredUser()?.full_name ?? 'You',
doc.steadId,
doc.parentPageId,
)
navigate(`/workspace/${doc.type}/${duplicate.id}`)
}
async function archivePage(doc: DocumentRecord) {
setMenuId(null)
const updated = await updateDocument(doc.id, {
content: { ...doc.content, archived: true, archivedAt: new Date().toISOString() },
})
if (updated) setDocs((current) => current.filter((item) => item.id !== doc.id))
}
async function removePage(doc: DocumentRecord) {
setMenuId(null)
if (!window.confirm(`Delete "${doc.title}" permanently?`)) return
if (await deleteDocument(doc.id)) {
setDocs((current) => current.filter((item) => item.id !== doc.id))
if (doc.id === activeDocId) navigate('/dashboard')
}
}
async function movePage(doc: DocumentRecord, parentPageId?: string) {
const updated = await updateDocument(doc.id, { parentPageId })
if (updated) {
setDocs((current) => current.map((item) => (item.id === doc.id ? updated : item)))
setMoveId(null)
setMenuId(null)
}
}
function renderPage(doc: DocumentRecord, depth = 0): ReactNode {
const Icon = typeIcons[doc.type]
const children = childrenByParent[doc.id] ?? []
const isExpanded = expanded[doc.id] ?? doc.id === activeDocId
const isRenaming = renameId === doc.id
const isMenuOpen = menuId === doc.id
return (
<div key={doc.id}>
<div
className="group relative flex items-center rounded-md"
style={{ paddingLeft: `${depth * 14}px` }}
data-page-menu
>
{children.length > 0 ? (
<button
type="button"
onClick={() => setExpanded((current) => ({ ...current, [doc.id]: !isExpanded }))}
className="rounded p-1 text-silos-muted hover:bg-silos-cream"
aria-label={isExpanded ? `Collapse ${doc.title}` : `Expand ${doc.title}`}
>
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
) : (
<span className="flex w-5 justify-center text-[16px] leading-none text-silos-muted" aria-hidden="true">
•
</span>
)}
{isRenaming ? (
<form
className="flex min-w-0 flex-1 items-center gap-1 px-1 py-1"
onSubmit={(event) => {
event.preventDefault()
void saveRename(doc)
}}
>
<input
autoFocus
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
onBlur={() => void saveRename(doc)}
className="min-w-0 flex-1 rounded border border-silos-brown/40 bg-white px-2 py-1 text-sm text-silos-text outline-none ring-2 ring-silos-brown/10"
aria-label={`Rename ${doc.title}`}
/>
<button type="submit" className="rounded p-1 text-green-700 hover:bg-green-50" aria-label="Save name">
<Check className="h-3.5 w-3.5" />
</button>
</form>
) : (
<Link
to={`/workspace/${doc.type}/${doc.id}`}
className={`flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors ${
doc.id === activeDocId
? 'bg-silos-brown/10 font-medium text-silos-brown'
: 'text-silos-text hover:bg-silos-cream'
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" style={{ color: typeColors[doc.type] }} />
<span className="truncate">{doc.title}</span>
</Link>
)}
{doc.type === 'silo' && !isRenaming && (
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void createChildPage(doc)
}}
disabled={creatingChildFor === doc.id}
className="rounded p-1 text-silos-muted opacity-0 transition-opacity hover:bg-silos-cream hover:text-silos-brown group-hover:opacity-100 disabled:opacity-50"
aria-label={`Create child page under ${doc.title}`}
title="Create child page"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
{!isRenaming && (
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setMenuId(isMenuOpen ? null : doc.id)
}}
className="mr-1 rounded p-1 text-silos-muted opacity-0 transition-opacity hover:bg-silos-cream hover:text-silos-text group-hover:opacity-100"
aria-label={`More options for ${doc.title}`}
title="More options"
>
<MoreHorizontal className="h-4 w-4" />
</button>
)}
{isMenuOpen && (
<div className="absolute right-1 top-full z-50 mt-1 w-56 rounded-xl border border-silos-cream-dark bg-white p-1.5 shadow-xl">
<button onClick={() => navigate(`/workspace/${doc.type}/${doc.id}`)} className="page-menu-item"><FileText className="h-4 w-4" />Edit</button>
<button onClick={() => startRename(doc)} className="page-menu-item"><FileText className="h-4 w-4" />Rename</button>
<button onClick={async () => { setMenuId(null); const updated = await toggleStar(doc.id, !doc.starred); if (updated) setDocs((current) => current.map((item) => item.id === doc.id ? updated : item)) }} className="page-menu-item"><Star className="h-4 w-4" />{doc.starred ? 'Unstar' : 'Star'}</button>
<button onClick={() => { setMenuId(null); void navigator.clipboard.writeText(`${window.location.origin}/workspace/${doc.type}/${doc.id}`) }} className="page-menu-item"><Link2 className="h-4 w-4" />Copy link</button>
<button onClick={() => void duplicatePage(doc)} className="page-menu-item"><Copy className="h-4 w-4" />Make a copy</button>
<button onClick={() => { setMenuId(null); setMoveId(doc.id) }} className="page-menu-item"><ChevronRight className="h-4 w-4" />Move</button>
<div className="my-1 border-t border-silos-cream-dark" />
<button onClick={() => void archivePage(doc)} className="page-menu-item"><Archive className="h-4 w-4" />Archive</button>
<button onClick={() => void removePage(doc)} className="page-menu-item text-red-700 hover:bg-red-50"><Trash2 className="h-4 w-4" />Delete</button>
</div>
)}
</div>
{isExpanded && children.map((child) => renderPage(child, depth + 1))}
</div>
)
}
if (collapsed) {
return (
<aside className="flex w-12 shrink-0 flex-col items-center border-r border-silos-cream-dark bg-white py-3">
{docs.slice(0, 6).map((doc) => {
const Icon = typeIcons[doc.type]
return (
<Link
key={doc.id}
to={`/workspace/${doc.type}/${doc.id}`}
title={doc.title}
className={`mb-1 rounded-lg p-2 ${
doc.id === activeDocId ? 'bg-silos-brown/10' : 'hover:bg-silos-cream'
}`}
>
<Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} />
</Link>
)
return <Link key={doc.id} to={`/workspace/${doc.type}/${doc.id}`} title={doc.title} className={`mb-1 rounded-lg p-2 ${doc.id === activeDocId ? 'bg-silos-brown/10' : 'hover:bg-silos-cream'}`}><Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} /></Link>
})}
</aside>
)
}
const movingDoc = moveId ? docs.find((doc) => doc.id === moveId) : undefined
const excludedIds = movingDoc ? descendantsOf(movingDoc.id) : new Set<string>()
return (
<aside className="flex w-56 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
<aside className="flex w-64 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
<div className="border-b border-silos-cream-dark p-3">
<Link to="/dashboard" className="text-xs font-medium text-silos-muted hover:text-silos-brown">
← Back to Dashboard
</Link>
<Link to="/dashboard" className="text-xs font-medium text-silos-muted hover:text-silos-brown">← Back to Dashboard</Link>
</div>
<div className="flex-1 overflow-y-auto p-2">
{parent && (
<>
<Link
to={`/workspace/stead/${parent.id}`}
className="mb-2 flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"
>
<LayoutGrid className="h-3.5 w-3.5 text-silos-tan" />
<span className="truncate">{parent.title}</span>
</Link>
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">
In this space
</p>
</>
)}
{!parent && (
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">
Content
</p>
)}
{docs.length === 0 ? (
<p className="px-2 py-4 text-xs text-silos-muted">No documents yet</p>
) : (
docs.map((doc) => {
const Icon = typeIcons[doc.type]
return (
<Link
key={doc.id}
to={`/workspace/${doc.type}/${doc.id}`}
className={`mb-0.5 flex items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors ${
doc.id === activeDocId
? 'bg-silos-brown/10 font-medium text-silos-brown'
: 'text-silos-text hover:bg-silos-cream'
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" style={{ color: typeColors[doc.type] }} />
<span className="truncate">{doc.title}</span>
</Link>
)
})
)}
{parent && <Link to={`/workspace/stead/${parent.id}`} className="mb-2 flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"><LayoutGrid className="h-3.5 w-3.5 text-silos-tan" /><span className="truncate">{parent.title}</span></Link>}
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">{parent ? 'In this space' : 'Content'}</p>
{docs.length === 0 ? <p className="px-2 py-4 text-xs text-silos-muted">No documents yet</p> : roots.map((doc) => renderPage(doc))}
</div>
<div className="border-t border-silos-cream-dark p-2">
<Link
to="/dashboard"
className="flex items-center gap-2 rounded-lg px-2 py-2 text-sm text-silos-muted hover:bg-silos-cream hover:text-silos-brown"
>
<Plus className="h-4 w-4" />
Create new
</Link>
<Link to="/dashboard" className="flex items-center gap-2 rounded-lg px-2 py-2 text-sm text-silos-muted hover:bg-silos-cream hover:text-silos-brown"><Plus className="h-4 w-4" />Create new</Link>
</div>
{movingDoc && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/20 p-4" role="dialog" aria-modal="true" aria-label="Move page">
<div className="w-full max-w-md rounded-xl border border-silos-cream-dark bg-white p-4 shadow-2xl">
<div className="mb-3 flex items-center justify-between"><h2 className="font-semibold text-silos-text">Move “{movingDoc.title}”</h2><button onClick={() => setMoveId(null)} className="rounded p-1 hover:bg-silos-cream" aria-label="Close"><X className="h-4 w-4" /></button></div>
<p className="mb-3 text-xs text-silos-muted">Choose a parent page or move it to the top level.</p>
<div className="max-h-64 space-y-1 overflow-y-auto">
<button onClick={() => void movePage(movingDoc)} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"><span>•</span>Top level</button>
{docs.filter((doc) => doc.id !== movingDoc.id && doc.type === 'silo' && !excludedIds.has(doc.id)).map((doc) => <button key={doc.id} onClick={() => void movePage(movingDoc, doc.id)} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"><FileText className="h-4 w-4 text-silos-muted" />{doc.title}</button>)}
</div>
</div>
</div>
)}
</aside>
)
}

View file

@ -23,6 +23,45 @@ export interface ShedTemplate {
available: boolean
}
export interface SiloTemplate {
id: string
label: string
description: string
emoji: string
html: string
}
export const siloTemplates: SiloTemplate[] = [
{
id: 'blank',
label: 'Blank page',
description: 'Start with a clean page',
emoji: '📄',
html: '',
},
{
id: 'meeting',
label: 'Meeting notes',
description: 'Capture attendees, agenda, and actions',
emoji: '📝',
html: '<h2>Meeting notes</h2><p><strong>Date:</strong></p><p><strong>Attendees:</strong></p><h3>Agenda</h3><ul><li>Topic</li></ul><h3>Actions</h3><ul><li>Action — owner — due date</li></ul>',
},
{
id: 'project-plan',
label: 'Project plan',
description: 'Organize goals, milestones, and next steps',
emoji: '🗺️',
html: '<h2>Project plan</h2><h3>Goals</h3><ul><li>Goal 1</li></ul><h3>Milestones</h3><ol><li>Milestone 1</li></ol><h3>Next steps</h3><p>Describe the next step.</p>',
},
{
id: 'how-to',
label: 'How-to guide',
description: 'Create a clear step-by-step guide',
emoji: '🧭',
html: '<h2>How to…</h2><p>Describe the outcome this guide helps someone achieve.</p><h3>Before you start</h3><ul><li>Requirement</li></ul><h3>Steps</h3><ol><li>Step one</li><li>Step two</li></ol>',
},
]
export const createOptions: CreateOption[] = [
{
id: 'shed',

View file

@ -1,11 +1,11 @@
import { useEffect, useRef } from 'react'
import { updateDocument, type DocumentRecord } from '../lib/documents'
export function useAutoSave(doc: DocumentRecord | null) {
export function useAutoSave(doc: DocumentRecord | null, enabled = true) {
const isFirstRender = useRef(true)
useEffect(() => {
if (!doc) return
if (!doc || !enabled) return
if (isFirstRender.current) {
isFirstRender.current = false
return
@ -16,5 +16,5 @@ export function useAutoSave(doc: DocumentRecord | null) {
}, 800)
return () => clearTimeout(timer)
}, [doc?.id, doc?.title, doc?.content])
}, [doc?.id, doc?.title, doc?.content, enabled])
}

View file

@ -30,6 +30,22 @@ body {
-webkit-font-smoothing: antialiased;
}
.page-menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 0.625rem;
border-radius: 0.5rem;
padding: 0.5rem 0.625rem;
text-align: left;
font-size: 0.875rem;
color: var(--color-silos-text);
}
.page-menu-item:hover {
background: var(--color-silos-cream);
}
@keyframes workflow-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.85; }
@ -48,3 +64,97 @@ body {
stroke-dasharray: 8 4;
animation: workflow-flow 1.5s linear infinite;
}
.prose .silos-table {
width: 100%;
margin: 1rem 0;
border-collapse: collapse;
overflow: hidden;
border: 1px solid var(--color-silos-cream-dark);
border-radius: 0.75rem;
}
.prose .silos-table th,
.prose .silos-table td {
min-width: 8rem;
border: 1px solid var(--color-silos-cream-dark);
padding: 0.65rem 0.75rem;
text-align: left;
}
.prose .silos-table th {
background: var(--color-silos-cream);
font-weight: 600;
}
.prose .silos-panel {
margin: 1rem 0;
border-left: 4px solid var(--color-silos-tan);
border-radius: 0.5rem;
background: var(--color-silos-cream);
padding: 0.85rem 1rem;
}
.prose .silos-media {
margin: 1rem 0;
border-radius: 0.75rem;
background: var(--color-silos-cream);
padding: 0.75rem;
}
.prose .silos-media img {
max-height: 32rem;
max-width: 100%;
border-radius: 0.5rem;
object-fit: contain;
}
.prose .silos-media figcaption {
margin-top: 0.45rem;
color: var(--color-silos-muted);
font-size: 0.8rem;
}
.prose .silos-embed {
margin-top: 0.65rem;
overflow-wrap: anywhere;
color: var(--color-silos-muted);
font-size: 0.85rem;
}
.prose .silos-mention {
border-radius: 0.25rem;
background: #deebff;
color: #0747a6;
padding: 0.1rem 0.3rem;
}
.prose .silos-tabs {
margin: 1rem 0;
border: 1px solid var(--color-silos-cream-dark);
border-radius: 0.75rem;
padding: 0.75rem;
}
.prose .silos-tab-list {
display: flex;
gap: 0.45rem;
border-bottom: 1px solid var(--color-silos-cream-dark);
padding-bottom: 0.6rem;
}
.prose .silos-tab-list span {
border-radius: 0.5rem;
padding: 0.3rem 0.65rem;
color: var(--color-silos-muted);
font-size: 0.8rem;
}
.prose .silos-tab-list .silos-tab-active {
background: var(--color-silos-brown);
color: white;
}
.prose .silos-tab-content {
padding: 0.85rem 0.25rem 0.25rem;
}

View file

@ -147,6 +147,12 @@ export const authApi = {
method: 'POST',
body: JSON.stringify({ token, new_password }),
}),
changePassword: (current_password: string, new_password: string) =>
request<{ message: string }>('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password, new_password }),
}),
googleLoginUrl: () => `${API_BASE}/auth/google/login`,
}
export function saveAuth(tokens: AuthTokens) {
@ -180,12 +186,58 @@ export interface DocumentMember {
email: string
}
export interface ApiComment {
id: string
document_id: string
body: string
parent_id?: string | null
author_id: number
author_name: string
created_at: string
updated_at: string
resolved: boolean
}
export interface ApiNotification {
id: string
kind: string
message: string
document_id?: string | null
comment_id?: string | null
actor_name: string
read: boolean
created_at: string
}
export interface ApiActivity {
id: string
document_id: string
kind: string
message: string
actor_name: string
created_at: string
}
export interface ApiTemplate {
id: string
owner_id: number
name: string
description: string
category: string
emoji: string
content: Record<string, unknown>
created_at: string
updated_at: string
members: Array<{ user_id: number; name: string; email: string }>
}
export interface ApiDocument {
id: string
type: 'shed' | 'silo' | 'sketch' | 'stead'
title: string
template_id?: string | null
stead_id?: string | null
parent_page_id?: string | null
content: Record<string, unknown>
created_by: string
created_at: string
@ -197,30 +249,45 @@ export interface ApiDocument {
members?: DocumentMember[]
}
export interface ApiDocumentVersion {
id: string
document_id: string
title: string
content: Record<string, unknown>
created_by: string
created_at: string
reason: string
}
export interface DocumentCreatePayload {
type: 'shed' | 'silo' | 'sketch' | 'stead'
title: string
content?: Record<string, unknown>
template_id?: string
stead_id?: string
parent_page_id?: string | null
}
export interface DocumentUpdatePayload {
title?: string
content?: Record<string, unknown>
starred?: boolean
stead_id?: string
parent_page_id?: string
}
export const documentsApi = {
list: (params?: {
type?: string
stead_id?: string
parent_page_id?: string
starred?: boolean
sort?: 'updated_at' | 'last_visited_at'
}) => {
const search = new URLSearchParams()
if (params?.type) search.set('type', params.type)
if (params?.stead_id) search.set('stead_id', params.stead_id)
if (params?.parent_page_id) search.set('parent_page_id', params.parent_page_id)
if (params?.starred !== undefined) search.set('starred', String(params.starred))
if (params?.sort) search.set('sort', params.sort)
const qs = search.toString()
@ -258,4 +325,43 @@ export const documentsApi = {
delete: (id: string) =>
request<void>(`/documents/${id}`, { method: 'DELETE' }),
listComments: (id: string) => request<ApiComment[]>(`/documents/${id}/comments`),
createComment: (id: string, body: string, parent_id?: string) =>
request<ApiComment>(`/documents/${id}/comments`, {
method: 'POST',
body: JSON.stringify({ body, parent_id }),
}),
deleteComment: (documentId: string, commentId: string) =>
request<void>(`/documents/${documentId}/comments/${commentId}`, { method: 'DELETE' }),
resolveComment: (documentId: string, commentId: string, resolved: boolean) =>
request<ApiComment>(`/documents/${documentId}/comments/${commentId}/resolve`, {
method: 'PATCH',
body: JSON.stringify({ resolved }),
}),
listActivity: (documentId: string) => request<ApiActivity[]>(`/documents/${documentId}/activity`),
listNotifications: () => request<ApiNotification[]>('/documents/notifications'),
markNotificationsRead: () => request<void>('/documents/notifications/read', { method: 'POST' }),
listHistory: (documentId: string) => request<ApiDocumentVersion[]>(`/documents/${documentId}/history`),
restoreHistory: (documentId: string, versionId: string) =>
request<ApiDocument>(`/documents/${documentId}/history/${versionId}/restore`, { method: 'POST' }),
exportDocument: (documentId: string, format: 'html' | 'text' = 'html') =>
request<string>(`/documents/${documentId}/export?format=${format}`),
}
export const templatesApi = {
list: () => request<ApiTemplate[]>('/templates'),
create: (payload: Omit<ApiTemplate, 'id' | 'owner_id' | 'created_at' | 'updated_at' | 'members'>) =>
request<ApiTemplate>('/templates', {
method: 'POST',
body: JSON.stringify(payload),
}),
delete: (id: string) => request<void>(`/templates/${id}`, { method: 'DELETE' }),
share: (id: string, email_or_username: string) =>
request<ApiTemplate>(`/templates/${id}/share`, {
method: 'POST',
body: JSON.stringify({ email_or_username }),
}),
}

View file

@ -7,6 +7,7 @@ export interface DocumentMeta {
title: string
templateId?: ShedTemplateId
steadId?: string
parentPageId?: string
createdAt: string
updatedAt: string
createdBy: string
@ -24,6 +25,7 @@ export interface DocumentRecord extends DocumentMeta {
export interface ListDocumentsOptions {
sort?: 'updated_at' | 'last_visited_at'
starred?: boolean
parentPageId?: string
}
function fromApi(doc: ApiDocument): DocumentRecord {
@ -33,6 +35,7 @@ function fromApi(doc: ApiDocument): DocumentRecord {
title: doc.title,
templateId: doc.template_id as ShedTemplateId | undefined,
steadId: doc.stead_id ?? undefined,
parentPageId: doc.parent_page_id ?? undefined,
content: doc.content,
createdBy: doc.created_by,
createdAt: doc.created_at,
@ -53,13 +56,14 @@ export async function listDocuments(
type,
starred: options?.starred,
sort: options?.sort,
parent_page_id: options?.parentPageId,
})
return docs.map(fromApi)
return docs.filter((doc) => !(doc.content?.archived === true)).map(fromApi)
}
export async function listDocumentsByStead(steadId: string): Promise<DocumentRecord[]> {
const docs = await documentsApi.list({ stead_id: steadId })
return docs.filter((d) => d.type !== 'stead').map(fromApi)
return docs.filter((d) => d.type !== 'stead' && d.content?.archived !== true).map(fromApi)
}
export async function searchDocuments(q: string): Promise<DocumentRecord[]> {
@ -87,6 +91,7 @@ export async function createDocument(
templateId?: ShedTemplateId,
_createdBy = 'You',
steadId?: string,
parentPageId?: string,
): Promise<DocumentRecord> {
const doc = await documentsApi.create({
type,
@ -94,18 +99,23 @@ export async function createDocument(
content,
template_id: templateId,
stead_id: steadId,
parent_page_id: parentPageId,
})
return fromApi(doc)
}
export async function updateDocument(
id: string,
updates: Partial<Pick<DocumentRecord, 'title' | 'content'>>,
updates: Partial<Pick<DocumentRecord, 'title' | 'content' | 'steadId' | 'parentPageId'>> & {
parentPageId?: string | null
},
): Promise<DocumentRecord | null> {
try {
const doc = await documentsApi.update(id, {
title: updates.title,
content: updates.content,
stead_id: updates.steadId,
parent_page_id: updates.parentPageId,
})
return fromApi(doc)
} catch {
@ -113,6 +123,15 @@ export async function updateDocument(
}
}
export async function deleteDocument(id: string): Promise<boolean> {
try {
await documentsApi.delete(id)
return true
} catch {
return false
}
}
export async function toggleStar(id: string, starred: boolean): Promise<DocumentRecord | null> {
try {
const doc = await documentsApi.update(id, { starred })

View file

@ -2,10 +2,12 @@ import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SiloEditor from '../../components/silo/SiloEditor'
import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { createDocument, deleteDocument, getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { ApiError } from '../../lib/api'
import { ApiError, documentsApi, type ApiDocumentVersion } from '../../lib/api'
import { formatRelativeTime } from '../../lib/documentUtils'
import CommentsPanel from '../../components/workspace/CommentsPanel'
import { Clock3, Download, MessageSquare, RotateCcw, X } from 'lucide-react'
function siloHtml(content: Record<string, unknown>): string {
if (typeof content.bodyHtml === 'string' && content.bodyHtml) return content.bodyHtml
@ -17,6 +19,13 @@ export default function SiloPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [commentsOpen, setCommentsOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [history, setHistory] = useState<ApiDocumentVersion[]>([])
const [historyError, setHistoryError] = useState<string | null>(null)
useEffect(() => {
if (!id) return
@ -34,9 +43,20 @@ export default function SiloPage() {
})
}, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id)
useEffect(() => {
function onShortcut(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
if (editing && !saving) void update()
}
if (event.key === 'Escape' && historyOpen) setHistoryOpen(false)
}
window.addEventListener('keydown', onShortcut)
return () => window.removeEventListener('keydown', onShortcut)
}, [editing, saving, historyOpen])
if (!doc) return null
const status = (doc.content.status as string) || 'draft'
@ -49,34 +69,135 @@ export default function SiloPage() {
)
}
function publish() {
setDoc((prev) =>
prev
? {
...prev,
content: {
...prev.content,
status: status === 'published' ? 'draft' : 'published',
},
}
: prev,
)
async function update() {
if (!doc) return
setSaving(true)
setSaveError(null)
const updated = await updateDocument(doc.id, { title: doc.title, content: doc.content })
setSaving(false)
if (updated) {
setDoc(updated)
setEditing(false)
} else {
setSaveError('Could not save this page. Check your connection and try again.')
}
}
async function publish() {
if (!doc) return
setSaving(true)
setSaveError(null)
const updated = await updateDocument(doc.id, {
title: doc.title,
content: { ...doc.content, status: status === 'published' ? 'draft' : 'published' },
})
setSaving(false)
if (updated) {
setDoc(updated)
setEditing(false)
} else {
setSaveError('Could not publish this page. Check your connection and try again.')
}
}
async function openHistory() {
if (!doc) return
setHistoryError(null)
try {
setHistory(await documentsApi.listHistory(doc.id))
setHistoryOpen(true)
} catch (err) {
setHistoryError(err instanceof Error ? err.message : 'Could not load version history.')
}
}
async function restore(version: ApiDocumentVersion) {
if (!doc || !window.confirm(`Restore "${version.title}" from ${new Date(version.created_at).toLocaleString()}?`)) return
try {
await documentsApi.restoreHistory(doc.id, version.id)
const restored = await getDocument(doc.id)
setDoc(restored)
setHistoryOpen(false)
setEditing(false)
} catch (err) {
setHistoryError(err instanceof Error ? err.message : 'Could not restore this version.')
}
}
async function exportPage(format: 'html' | 'text') {
if (!doc) return
try {
const content = await documentsApi.exportDocument(doc.id, format)
const blob = new Blob([content], { type: format === 'html' ? 'text/html' : 'text/plain' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${doc.title.replace(/[^\w\s-]/g, '').trim() || 'page'}.${format === 'html' ? 'html' : 'txt'}`
anchor.click()
URL.revokeObjectURL(url)
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Could not export this page.')
}
}
async function createChildPage() {
if (!doc) return
const child = await createDocument('silo', `Child of ${doc.title}`, { body: '', bodyHtml: '', status: 'draft' }, undefined, doc.createdBy, doc.steadId, doc.id)
navigate(`/workspace/silo/${child.id}`)
}
async function duplicatePage() {
if (!doc) return
const duplicate = await createDocument('silo', `${doc.title} copy`, { ...doc.content, status: 'draft' }, doc.templateId, doc.createdBy, doc.steadId, doc.parentPageId)
navigate(`/workspace/silo/${duplicate.id}`)
}
async function archivePage() {
if (!doc) return
const updated = await updateDocument(doc.id, { content: { ...doc.content, archived: true, archivedAt: new Date().toISOString() } })
if (updated) navigate('/dashboard')
else setSaveError('Could not archive this page. Check your connection and try again.')
}
async function removePage() {
if (!doc || !window.confirm('Delete this page permanently?')) return
if (await deleteDocument(doc.id)) navigate('/dashboard')
else setSaveError('Could not delete this page. Check your connection and try again.')
}
const pageText = siloHtml(doc.content).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
const wordCount = pageText ? pageText.split(' ').length : 0
return (
<WorkspaceShell
doc={doc}
onDocChange={setDoc}
layout="page"
titleReadOnly={!editing}
onTitleChange={(title) =>
setDoc((prev) => (prev ? { ...prev, title } : prev))
}
headerActions={
<>
<button
type="button"
onClick={publish}
className="hidden rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark sm:block"
>
{status === 'published' ? 'Unpublish' : 'Publish'}
</button>
{!editing ? (
<button
type="button"
onClick={() => setEditing(true)}
className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark"
>
Edit
</button>
) : (
<>
<button type="button" onClick={() => void update()} disabled={saving} className="rounded-lg border border-silos-cream-dark px-3 py-1 text-xs font-medium text-silos-text hover:bg-silos-cream disabled:opacity-50">
{saving ? 'Saving…' : 'Update'}
</button>
<button type="button" onClick={() => void publish()} disabled={saving} className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark disabled:opacity-50">
{status === 'published' ? 'Unpublish' : 'Publish'}
</button>
</>
)}
<button
type="button"
onClick={() => navigate('/dashboard')}
@ -84,10 +205,60 @@ export default function SiloPage() {
>
Close
</button>
<button
type="button"
onClick={() => setCommentsOpen(true)}
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
aria-label="Open comments"
title="Comments"
>
<MessageSquare className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => void openHistory()} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Open version history" title="Version history">
<Clock3 className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => void exportPage('html')} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Export page" title="Export HTML">
<Download className="h-3.5 w-3.5" />
</button>
</>
}
onCreateChild={() => void createChildPage()}
onDuplicate={() => void duplicatePage()}
onArchive={() => void archivePage()}
onDelete={() => void removePage()}
>
<SiloEditor html={siloHtml(doc.content)} onChange={patchHtml} />
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-silos-cream-dark bg-silos-cream/30 px-4 py-2.5 text-xs text-silos-muted">
<div className="flex items-center gap-3">
<span
className={`rounded-full px-2.5 py-1 font-medium ${
status === 'published'
? 'bg-green-100 text-green-700'
: 'bg-amber-100 text-amber-700'
}`}
>
{status === 'published' ? 'Published' : 'Draft'}
</span>
<span>{wordCount} words</span>
<span>Updated {formatRelativeTime(doc.updatedAt)}</span>
</div>
{saveError && <span className="text-red-700">{saveError}</span>}
</div>
<div aria-live="polite">
<SiloEditor html={siloHtml(doc.content)} onChange={patchHtml} editable={editing} />
</div>
{commentsOpen && <CommentsPanel documentId={doc.id} onClose={() => setCommentsOpen(false)} />}
{historyOpen && (
<aside className="fixed right-0 top-12 z-40 flex h-[calc(100vh-3rem)] w-full max-w-md flex-col border-l border-silos-cream-dark bg-white shadow-xl" aria-label="Version history">
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
<div><h2 className="font-semibold text-silos-text">Version history</h2><p className="text-xs text-silos-muted">Restore an earlier saved version</p></div>
<button type="button" onClick={() => setHistoryOpen(false)} aria-label="Close version history"><X className="h-4 w-4 text-silos-muted" /></button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{historyError && <p className="mb-3 text-xs text-red-700">{historyError}</p>}
{history.length === 0 ? <p className="text-sm text-silos-muted">No earlier versions yet.</p> : <div className="space-y-3">{history.map((version) => <article key={version.id} className="rounded-xl border border-silos-cream-dark p-3"><div className="flex items-start justify-between gap-3"><div><p className="text-sm font-medium text-silos-text">{version.title}</p><p className="mt-1 text-xs text-silos-muted">{version.reason} · {version.created_by}</p><time className="text-[10px] text-silos-muted">{new Date(version.created_at).toLocaleString()}</time></div><button type="button" onClick={() => void restore(version)} className="flex items-center gap-1 rounded-lg border border-silos-cream-dark px-2 py-1 text-xs text-silos-brown hover:bg-silos-cream"><RotateCcw className="h-3 w-3" />Restore</button></div></article>)}</div>}
</div>
</aside>
)}
</WorkspaceShell>
)
}

View file

@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SketchCanvas from '../../components/sketch/SketchCanvas'
import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { updateDocument } from '../../lib/documents'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { ApiError } from '../../lib/api'
import {
@ -16,6 +16,8 @@ export default function SketchPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!id) return
@ -33,7 +35,6 @@ export default function SketchPage() {
})
}, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id)
const sketchContent = useMemo(
@ -48,14 +49,66 @@ export default function SketchPage() {
setDoc((prev) => (prev ? { ...prev, content: next } : prev))
}
async function update() {
if (!doc) return
setSaving(true)
const updated = await updateDocument(doc.id, { title: doc.title, content: doc.content })
setSaving(false)
if (updated) {
setDoc(updated)
setEditing(false)
}
}
async function publish() {
if (!doc) return
setSaving(true)
const updated = await updateDocument(doc.id, {
title: doc.title,
content: { ...doc.content, status: 'published' },
})
setSaving(false)
if (updated) {
setDoc(updated)
setEditing(false)
}
}
return (
<WorkspaceShell doc={doc} onDocChange={setDoc} layout="canvas" hideTitle>
<WorkspaceShell
doc={doc}
onDocChange={setDoc}
layout="canvas"
hideTitle
headerActions={
!editing ? (
<button type="button" onClick={() => setEditing(true)} className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark">
Edit
</button>
) : (
<>
<button type="button" onClick={() => void update()} disabled={saving} className="rounded-lg border border-silos-cream-dark px-3 py-1 text-xs font-medium text-silos-text hover:bg-silos-cream disabled:opacity-50">
{saving ? 'Saving…' : 'Update'}
</button>
<button
type="button"
onClick={() => void publish()}
disabled={saving}
className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark disabled:opacity-50"
>
Publish
</button>
</>
)
}
>
<div className="flex h-full flex-col">
<div className="flex items-center border-b border-silos-cream-dark px-6 py-3">
<input
value={doc.title}
readOnly={!editing}
onChange={(e) => setDoc((prev) => (prev ? { ...prev, title: e.target.value } : prev))}
className="border-none bg-transparent text-lg font-semibold text-silos-text outline-none"
className="border-none bg-transparent text-lg font-semibold text-silos-text outline-none read-only:cursor-default"
placeholder="Untitled whiteboard"
/>
</div>
@ -64,6 +117,7 @@ export default function SketchPage() {
elements={sketchContent.elements}
viewport={sketchContent.viewport}
onChange={patchSketch}
readOnly={!editing}
/>
</div>
</div>