feat: implement comments and activity panel in workspace
- Added CommentsPanel component for managing comments on documents. - Integrated comment creation, deletion, and resolution functionalities. - Implemented activity tracking for document interactions. - Enhanced SketchPage with editing and saving capabilities. - Introduced template management API endpoints for creating, sharing, and deleting templates. - Added runtime validation report for frontend and backend. - Created GettingStartedPanel for user onboarding with template options. - Implemented Playwright E2E tests for accessibility and export functionality.
This commit is contained in:
parent
6e354ba041
commit
809235e962
41 changed files with 2840 additions and 285 deletions
13
frontend/e2e/helpers.ts
Normal file
13
frontend/e2e/helpers.ts
Normal 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
|
||||
}
|
||||
11
frontend/e2e/phase8-accessibility.spec.ts
Normal file
11
frontend/e2e/phase8-accessibility.spec.ts
Normal 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(() => {})
|
||||
})
|
||||
10
frontend/e2e/phase8-export.spec.ts
Normal file
10
frontend/e2e/phase8-export.spec.ts
Normal 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)
|
||||
})
|
||||
16
frontend/e2e/phase8-history.spec.ts
Normal file
16
frontend/e2e/phase8-history.spec.ts
Normal 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)
|
||||
})
|
||||
46
frontend/package-lock.json
generated
46
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
frontend/playwright.config.ts
Normal file
12
frontend/playwright.config.ts
Normal 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']],
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ export default function DashboardLayout() {
|
|||
setActiveNav('recent')
|
||||
setFlyout('recent')
|
||||
}}
|
||||
onOpenTemplates={() => {
|
||||
setActiveNav('templates')
|
||||
setFlyout('templates')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
144
frontend/src/components/dashboard/GettingStartedPanel.tsx
Normal file
144
frontend/src/components/dashboard/GettingStartedPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
123
frontend/src/components/workspace/CommentsPanel.tsx
Normal file
123
frontend/src/components/workspace/CommentsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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...'}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue