Discover something new
-
- See all
-
@@ -29,7 +36,7 @@ export default function DiscoverPanel() {
const Icon = iconMap[item.icon]
return (
-
+ handleDiscover(item.id)} className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition-colors hover:bg-silos-cream/50">
= {
stead: LayoutGrid,
}
-function getActionLabel(doc: DocumentRecord): string {
- const created = new Date(doc.createdAt).getTime()
- const updated = new Date(doc.updatedAt).getTime()
- if (Math.abs(updated - created) < 5000) {
- return `Created by ${doc.createdBy}`
- }
- return `Updated by ${doc.createdBy}`
-}
-
interface RecentActivitiesProps {
documents: DocumentRecord[]
loading?: boolean
@@ -38,7 +30,65 @@ export default function RecentActivities({
loading,
onViewAll,
}: RecentActivitiesProps) {
- const items = documents.slice(0, 8)
+ const [activities, setActivities] = useState
([])
+
+ useEffect(() => {
+ let cancelled = false
+ async function loadActivity() {
+ const results = await Promise.all(
+ documents.slice(0, 12).map(async (document) => {
+ try {
+ return await documentsApi.listActivity(document.id)
+ } catch {
+ return []
+ }
+ }),
+ )
+ if (!cancelled) {
+ const latestActivityByDocument = new Map()
+ results.flat().forEach((activity) => {
+ const current = latestActivityByDocument.get(activity.document_id)
+ if (
+ !current ||
+ new Date(activity.created_at).getTime() > new Date(current.created_at).getTime()
+ ) {
+ latestActivityByDocument.set(activity.document_id, activity)
+ }
+ })
+ setActivities(
+ documents
+ .filter((document) => document.lastVisitedAt)
+ .slice(0, 8)
+ .map((document) => {
+ const activity = latestActivityByDocument.get(document.id)
+ return (
+ activity
+ ? { ...activity, created_at: document.lastVisitedAt ?? activity.created_at }
+ : {
+ id: `recent-${document.id}`,
+ document_id: document.id,
+ kind: 'view',
+ message: 'You recently opened this file',
+ actor_name: 'You',
+ created_at: document.lastVisitedAt ?? document.updatedAt,
+ }
+ )
+ }),
+ )
+ }
+ }
+ void loadActivity()
+ const timer = window.setInterval(() => void loadActivity(), 15000)
+ return () => {
+ cancelled = true
+ window.clearInterval(timer)
+ }
+ }, [documents])
+
+ const documentsById = new Map(documents.map((document) => [document.id, document]))
+ const items = activities
+ .map((activity) => ({ activity, document: documentsById.get(activity.document_id) }))
+ .filter((item): item is { activity: ApiActivity; document: DocumentRecord } => Boolean(item.document))
return (
@@ -69,7 +119,7 @@ export default function RecentActivities({
) : (
-
setShareOpen(true)}
@@ -191,16 +222,7 @@ export default function WorkspaceShell({
>
-
-
{ 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">
-
- {notifications.some((item) => !item.read) && }
-
- {notificationsOpen &&
-
Notifications
setNotificationsOpen(false)} aria-label="Close notifications">
- {notifications.length === 0 ?
You’re all caught up.
: notifications.slice(0, 8).map((notification) =>
{notification.message}
{new Date(notification.created_at).toLocaleString()} )}
-
}
-
+
setMenuOpen((value) => !value)} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="More">
@@ -256,9 +278,29 @@ export default function WorkspaceShell({
{!hideTitle && (
<>
handleTitleChange(e.target.value)}
+ onDoubleClick={() => {
+ if (titleReadOnly) {
+ onTitleDoubleClick?.()
+ requestAnimationFrame(() => {
+ titleRef.current?.focus()
+ titleRef.current?.select()
+ })
+ } else {
+ titleRef.current?.select()
+ }
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ event.stopPropagation()
+ onTitleCommit?.()
+ titleRef.current?.blur()
+ }
+ }}
className={`w-full border-none bg-transparent font-bold text-silos-text outline-none ${
titleReadOnly ? 'cursor-default' : ''
} ${isPage ? 'text-3xl' : 'text-2xl'
diff --git a/frontend/src/components/workspace/WorkspaceSidebar.tsx b/frontend/src/components/workspace/WorkspaceSidebar.tsx
index 1cf6ab0..4ef5c98 100644
--- a/frontend/src/components/workspace/WorkspaceSidebar.tsx
+++ b/frontend/src/components/workspace/WorkspaceSidebar.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, type ReactNode } from 'react'
+import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import {
Archive,
@@ -13,6 +13,9 @@ import {
Clock,
Star,
FolderOpen,
+ Folder,
+ Database,
+ ExternalLink,
Link2,
MoreHorizontal,
PenTool,
@@ -24,6 +27,7 @@ import {
import {
createDocument,
deleteDocument,
+ getDefaultShedContent,
listDocuments,
listDocumentsByStead,
toggleStar,
@@ -33,6 +37,8 @@ import {
import { getStoredUser } from '../../lib/api'
import type { CreateType } from '../../data/createData'
import { navItems, type NavItemId } from '../../data/dashboardData'
+import CreateDropdown from '../create/CreateDropdown'
+import { defaultSteadContent } from '../../data/steadData'
const typeIcons: Record
= {
silo: FileText,
@@ -73,11 +79,20 @@ export default function WorkspaceSidebar({
}: WorkspaceSidebarProps) {
const [docs, setDocs] = useState([])
const [steads, setSteads] = useState([])
- const [creatingChildFor, setCreatingChildFor] = useState(null)
+ const [createMenuId, setCreateMenuId] = useState(null)
+ const [createMenuPosition, setCreateMenuPosition] = useState<
+ { left: number; top?: number; bottom?: number } | null
+ >(null)
const [expanded, setExpanded] = useState>({})
const [menuId, setMenuId] = useState(null)
+ const [menuPosition, setMenuPosition] = useState<{
+ left: number
+ top?: number
+ bottom?: number
+ } | null>(null)
const [renameId, setRenameId] = useState(null)
const [renameValue, setRenameValue] = useState('')
+ const savingRename = useRef(false)
const [moveId, setMoveId] = useState(null)
const navigate = useNavigate()
@@ -137,22 +152,54 @@ export default function WorkspaceSidebar({
return descendants
}
- async function createChildPage(doc: DocumentRecord) {
- setCreatingChildFor(doc.id)
- try {
- const child = await createDocument(
- 'silo',
- 'Untitled',
- { body: '', bodyHtml: '', status: 'draft' },
- undefined,
- getStoredUser()?.full_name ?? 'You',
- doc.steadId,
- doc.id,
- )
- navigate(`/workspace/silo/${child.id}`)
- } finally {
- setCreatingChildFor(null)
- }
+ async function createFromMenu(
+ source: DocumentRecord,
+ kind: 'live-doc' | 'page' | 'whiteboard' | 'database' | 'smart-link' | 'folder',
+ ) {
+ setCreateMenuId(null)
+ setCreateMenuPosition(null)
+ const steadId = source.type === 'stead' ? source.id : source.steadId
+ const parentPageId = source.type === 'silo' ? source.id : undefined
+ if (kind === 'folder' || kind === 'database') {
+ const created = await createDocument(
+ 'stead',
+ kind === 'folder' ? 'Untitled Folder' : 'Untitled Database',
+ { ...defaultSteadContent(), setupComplete: true },
+ undefined,
+ getStoredUser()?.full_name ?? 'You',
+ )
+ navigate(`/workspace/stead/${created.id}`)
+ return
+ }
+ if (kind === 'live-doc') {
+ const created = await createDocument(
+ 'shed',
+ 'Untitled Live Doc',
+ getDefaultShedContent('meeting-notes'),
+ 'meeting-notes',
+ getStoredUser()?.full_name ?? 'You',
+ steadId,
+ )
+ navigate(`/workspace/shed/${created.id}`)
+ return
+ }
+ const type = kind === 'whiteboard' ? 'sketch' : 'silo'
+ const content =
+ kind === 'whiteboard'
+ ? { elements: [] }
+ : kind === 'smart-link'
+ ? { body: 'Paste a link here', bodyHtml: 'Paste a link here
', status: 'draft' }
+ : { body: '', bodyHtml: '', status: 'draft' }
+ const created = await createDocument(
+ type,
+ kind === 'whiteboard' ? 'Untitled Whiteboard' : kind === 'smart-link' ? 'Untitled Smart Link' : 'Untitled Page',
+ content,
+ undefined,
+ getStoredUser()?.full_name ?? 'You',
+ steadId,
+ parentPageId,
+ )
+ navigate(`/workspace/${type}/${created.id}`)
}
function startRename(doc: DocumentRecord) {
@@ -162,11 +209,20 @@ export default function WorkspaceSidebar({
}
async function saveRename(doc: DocumentRecord) {
+ if (savingRename.current) return
+ savingRename.current = true
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)))
+ if (!title || title === doc.title) {
+ savingRename.current = false
+ return
+ }
+ try {
+ const updated = await updateDocument(doc.id, { title })
+ if (updated) setDocs((current) => current.map((item) => (item.id === doc.id ? updated : item)))
+ } finally {
+ savingRename.current = false
+ }
}
async function duplicatePage(doc: DocumentRecord) {
@@ -196,7 +252,26 @@ export default function WorkspaceSidebar({
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')
+ if (doc.id === activeDocId) {
+ const parentPage = doc.parentPageId
+ ? docs.find((item) => item.id === doc.parentPageId)
+ : undefined
+ const remainingSibling = docs.find(
+ (item) =>
+ item.id !== doc.id &&
+ item.type === doc.type &&
+ item.steadId === doc.steadId &&
+ item.parentPageId === doc.parentPageId,
+ )
+ const destination = parentPage ?? remainingSibling
+ if (destination) {
+ navigate(`/workspace/${destination.type}/${destination.id}`)
+ } else if (doc.steadId) {
+ navigate(`/workspace/stead/${doc.steadId}`)
+ } else {
+ navigate('/dashboard')
+ }
+ }
}
}
@@ -250,6 +325,14 @@ export default function WorkspaceSidebar({
autoFocus
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
+ onFocus={(event) => event.currentTarget.select()}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ event.stopPropagation()
+ void saveRename(doc)
+ }
+ }}
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}`}
@@ -272,29 +355,95 @@ export default function WorkspaceSidebar({
)}
- {doc.type === 'silo' && !isRenaming && (
+ {!isRenaming && (
{
event.preventDefault()
event.stopPropagation()
- void createChildPage(doc)
+ if (createMenuId === doc.id) {
+ setCreateMenuId(null)
+ setCreateMenuPosition(null)
+ return
+ }
+ const bounds = event.currentTarget.getBoundingClientRect()
+ const menuHeight = 360
+ const openUpward =
+ bounds.bottom + menuHeight > window.innerHeight && bounds.top > menuHeight
+ const left = Math.min(
+ Math.max(8, bounds.right + 8),
+ window.innerWidth - 288 - 8,
+ )
+ setCreateMenuPosition({
+ left,
+ ...(openUpward
+ ? { bottom: window.innerHeight - bounds.top + 4 }
+ : { top: bounds.bottom + 4 }),
+ })
+ setCreateMenuId(doc.id)
}}
- 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"
+ aria-label={`Create content under ${doc.title}`}
+ title="Create content"
>
)}
+ {createMenuId === doc.id && (
+
+ {[
+ { kind: 'live-doc' as const, label: 'Live Doc', icon: Zap },
+ { kind: 'page' as const, label: 'Page', icon: FileText },
+ { kind: 'whiteboard' as const, label: 'Whiteboard', icon: PenTool },
+ { kind: 'database' as const, label: 'Database', icon: Database },
+ { kind: 'smart-link' as const, label: 'Smart Link', icon: ExternalLink },
+ { kind: 'folder' as const, label: 'Folder', icon: Folder },
+ ].map(({ kind, label, icon: MenuIcon }, index) => (
+ void createFromMenu(doc, kind)}
+ className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm text-silos-text hover:bg-silos-cream ${
+ index === 5 ? 'mt-1 border-t border-silos-cream-dark pt-3' : ''
+ }`}
+ >
+
+ {label}
+
+ ))}
+
+ )}
{!isRenaming && (
{
event.preventDefault()
event.stopPropagation()
- setMenuId(isMenuOpen ? null : doc.id)
+ if (isMenuOpen) {
+ setMenuId(null)
+ setMenuPosition(null)
+ return
+ }
+ const bounds = event.currentTarget.getBoundingClientRect()
+ const menuHeight = 360
+ const openUpward =
+ bounds.bottom + menuHeight > window.innerHeight && bounds.top > menuHeight
+ const left = Math.min(
+ Math.max(8, bounds.left),
+ window.innerWidth - 224 - 8,
+ )
+ setMenuPosition(
+ openUpward
+ ? { left, bottom: window.innerHeight - bounds.top + 4 }
+ : { left, top: bounds.bottom + 4 },
+ )
+ setMenuId(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}`}
@@ -305,7 +454,10 @@ export default function WorkspaceSidebar({
)}
{isMenuOpen && (
-
+
navigate(`/workspace/${doc.type}/${doc.id}`)} className="page-menu-item"> Edit
startRename(doc)} className="page-menu-item"> Rename
{ 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"> {doc.starred ? 'Unstar' : 'Star'}
@@ -383,7 +535,21 @@ export default function WorkspaceSidebar({
{docs.length === 0 ?
No documents yet
: roots.map((doc) => renderPage(doc))}
-
Create new
+
doc.id === activeDocId && doc.type === 'silo')?.id}
+ dropUp
+ floating
+ trigger={(_, onClick) => (
+
+ Create new
+
+ )}
+ />
{movingDoc && (
diff --git a/frontend/src/hooks/useDashboardDocuments.ts b/frontend/src/hooks/useDashboardDocuments.ts
index 3b47a0d..7947e83 100644
--- a/frontend/src/hooks/useDashboardDocuments.ts
+++ b/frontend/src/hooks/useDashboardDocuments.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { listDocuments, type DocumentRecord } from '../lib/documents'
import { sortByRecent } from '../lib/documentUtils'
-import { tenantApi } from '../lib/api'
+import { ApiError, tenantApi } from '../lib/api'
export function useDashboardDocuments() {
const [documents, setDocuments] = useState
([])
@@ -13,14 +13,24 @@ export function useDashboardDocuments() {
setError(null)
try {
const workspaces = await tenantApi.listWorkspaces()
- const selected = Number(localStorage.getItem('silos_workspace_id')) || workspaces[0]?.id
+ const storedId = Number(localStorage.getItem('silos_workspace_id'))
+ const selected =
+ workspaces.find((workspace) => workspace.id === storedId)?.id ??
+ workspaces[0]?.id
if (selected) {
localStorage.setItem('silos_workspace_id', String(selected))
}
const docs = await listDocuments(undefined, { sort: 'last_visited_at', workspaceId: selected })
setDocuments(docs)
- } catch {
- setError('Could not load documents. Is MongoDB running?')
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 403) {
+ localStorage.removeItem('silos_workspace_id')
+ setError('You no longer have access to the selected workspace. We reset it to your available workspace.')
+ } else if (error instanceof ApiError && error.status >= 500) {
+ setError('Could not load documents because the document service is unavailable. Is MongoDB running?')
+ } else {
+ setError(error instanceof Error ? error.message : 'Could not load documents.')
+ }
setDocuments([])
} finally {
setLoading(false)
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 38a669f..43669e1 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -137,6 +137,17 @@ async function request(path: string, options: RequestInit = {}): Promise {
const data = await response.json().catch(() => ({}))
if (!response.ok) {
+ if (
+ response.status === 401 &&
+ token &&
+ !path.startsWith('/auth/login') &&
+ !path.startsWith('/auth/register')
+ ) {
+ clearAuth()
+ if (window.location.pathname !== '/login') {
+ window.location.assign(`/login?returnTo=${encodeURIComponent(window.location.pathname)}`)
+ }
+ }
throw new ApiError(parseErrorMessage(data, response.status), response.status)
}
diff --git a/frontend/src/lib/documentDrafts.ts b/frontend/src/lib/documentDrafts.ts
new file mode 100644
index 0000000..b1b9253
--- /dev/null
+++ b/frontend/src/lib/documentDrafts.ts
@@ -0,0 +1,51 @@
+import type { DocumentRecord } from './documents'
+
+interface StoredDraft {
+ title: string
+ content: Record
+ savedAt: string
+}
+
+function draftKey(documentId: string): string {
+ return `silos_document_draft_${documentId}`
+}
+
+export function saveDocumentDraft(doc: DocumentRecord): void {
+ try {
+ const draft: StoredDraft = {
+ title: doc.title,
+ content: doc.content,
+ savedAt: new Date().toISOString(),
+ }
+ localStorage.setItem(draftKey(doc.id), JSON.stringify(draft))
+ } catch {
+ // Draft persistence is best effort; the in-memory editor remains authoritative.
+ }
+}
+
+export function restoreDocumentDraft(doc: DocumentRecord): DocumentRecord {
+ try {
+ const raw = localStorage.getItem(draftKey(doc.id))
+ if (!raw) return doc
+ const draft = JSON.parse(raw) as Partial
+ if (
+ typeof draft.savedAt !== 'string' ||
+ typeof draft.title !== 'string' ||
+ !draft.content ||
+ new Date(draft.savedAt).getTime() <= new Date(doc.updatedAt).getTime()
+ ) {
+ return doc
+ }
+ return { ...doc, title: draft.title, content: draft.content }
+ } catch {
+ return doc
+ }
+}
+
+export function clearDocumentDraft(documentId: string): void {
+ try {
+ localStorage.removeItem(draftKey(documentId))
+ } catch {
+ // Ignore storage cleanup failures; a future save can still replace the draft.
+ }
+}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index b44a265..e9ee169 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -143,6 +143,10 @@ export default function LoginPage() {
}
const handleSSOSelect = (providerId: string) => {
+ if (providerId === 'google') {
+ window.location.href = authApi.googleLoginUrl()
+ return
+ }
setSelectedSSO(providerId)
setActiveTab('sso')
setView('sso')
diff --git a/frontend/src/pages/workspace/ShedPage.tsx b/frontend/src/pages/workspace/ShedPage.tsx
index 1182e10..ee112a1 100644
--- a/frontend/src/pages/workspace/ShedPage.tsx
+++ b/frontend/src/pages/workspace/ShedPage.tsx
@@ -4,11 +4,11 @@ import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import ArchitectureReviewTemplate from '../../components/shed/templates/ArchitectureReviewTemplate'
import MeetingNotesTemplate from '../../components/shed/templates/MeetingNotesTemplate'
import RetrospectiveTemplate from '../../components/shed/templates/RetrospectiveTemplate'
-import { getDocument, type DocumentRecord } from '../../lib/documents'
-import { useAutoSave } from '../../hooks/useAutoSave'
+import { getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { useCollab } from '../../hooks/useCollab'
import { ApiError } from '../../lib/api'
+import { clearDocumentDraft, restoreDocumentDraft, saveDocumentDraft } from '../../lib/documentDrafts'
function ShedTemplate({
doc,
@@ -33,6 +33,8 @@ export default function ShedPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState(null)
+ const [editing, setEditing] = useState(false)
+ const [saving, setSaving] = useState(false)
useEffect(() => {
if (!id) return
@@ -42,7 +44,7 @@ export default function ShedPage() {
navigate('/dashboard')
return
}
- setDoc(found)
+ setDoc(restoreDocumentDraft(found))
})
.catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
@@ -50,8 +52,10 @@ export default function ShedPage() {
})
}, [id, navigate])
- useAutoSave(doc)
useRecordVisit(doc?.id)
+ useEffect(() => {
+ if (doc && editing) saveDocumentDraft(doc)
+ }, [doc, editing])
const collab = useCollab(
doc?.id,
Boolean(doc && doc.templateId === 'meeting-notes'),
@@ -60,10 +64,63 @@ export default function ShedPage() {
if (!doc) return null
+ const status = (doc.content.status as string) || 'draft'
+
+ 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)
+ clearDocumentDraft(updated.id)
+ setEditing(false)
+ }
+ }
+
+ async function publish() {
+ if (!doc) return
+ setSaving(true)
+ const updated = await updateDocument(doc.id, {
+ title: doc.title,
+ content: { ...doc.content, status: status === 'published' ? 'draft' : 'published' },
+ })
+ setSaving(false)
+ if (updated) {
+ setDoc(updated)
+ clearDocumentDraft(updated.id)
+ setEditing(false)
+ }
+ }
+
return (
setEditing(true)}
+ onTitleChange={(title) => setDoc((prev) => (prev ? { ...prev, title } : prev))}
+ onTitleCommit={() => { if (editing && !saving) void update() }}
+ onTitleDoubleClick={() => setEditing(true)}
+ onUpdateShortcut={() => { if (!saving) void update() }}
+ onPublishShortcut={() => { if (!saving) void publish() }}
+ headerActions={
+ !editing ? (
+ setEditing(true)} aria-keyshortcuts="E" className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark">
+ Edit E
+
+ ) : (
+ <>
+ void update()} disabled={saving} aria-keyshortcuts="Control+S Meta+S" 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'} {!saving && ⌘/Ctrl S }
+
+ void publish()} disabled={saving} aria-keyshortcuts="Control+Enter Meta+Enter" 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'} ⌘/Ctrl ↵
+
+ >
+ )
+ }
presence={collab.peers.map((p) => ({ name: p.name, initial: p.initial }))}
>
): string {
if (typeof content.bodyHtml === 'string' && content.bodyHtml) return content.bodyHtml
@@ -26,6 +27,7 @@ export default function SiloPage() {
const [historyOpen, setHistoryOpen] = useState(false)
const [history, setHistory] = useState([])
const [historyError, setHistoryError] = useState(null)
+ const [editPromptOpen, setEditPromptOpen] = useState(false)
useEffect(() => {
if (!id) return
@@ -35,7 +37,7 @@ export default function SiloPage() {
navigate('/dashboard')
return
}
- setDoc(found)
+ setDoc(restoreDocumentDraft(found))
})
.catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
@@ -46,16 +48,17 @@ export default function SiloPage() {
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 (doc && editing) saveDocumentDraft(doc)
+ }, [doc, editing])
+
+ useEffect(() => {
+ function onEscape(event: KeyboardEvent) {
if (event.key === 'Escape' && historyOpen) setHistoryOpen(false)
+ if (event.key === 'Escape' && editPromptOpen) setEditPromptOpen(false)
}
- window.addEventListener('keydown', onShortcut)
- return () => window.removeEventListener('keydown', onShortcut)
- }, [editing, saving, historyOpen])
+ window.addEventListener('keydown', onEscape)
+ return () => window.removeEventListener('keydown', onEscape)
+ }, [historyOpen, editPromptOpen])
if (!doc) return null
@@ -77,6 +80,7 @@ export default function SiloPage() {
setSaving(false)
if (updated) {
setDoc(updated)
+ clearDocumentDraft(updated.id)
setEditing(false)
} else {
setSaveError('Could not save this page. Check your connection and try again.')
@@ -94,6 +98,7 @@ export default function SiloPage() {
setSaving(false)
if (updated) {
setDoc(updated)
+ clearDocumentDraft(updated.id)
setEditing(false)
} else {
setSaveError('Could not publish this page. Check your connection and try again.')
@@ -117,7 +122,12 @@ export default function SiloPage() {
try {
await documentsApi.restoreHistory(doc.id, version.id)
const restored = await getDocument(doc.id)
+ if (!restored) {
+ setHistoryError('Could not load the restored page.')
+ return
+ }
setDoc(restored)
+ clearDocumentDraft(restored.id)
setHistoryOpen(false)
setEditing(false)
} catch (err) {
@@ -156,13 +166,19 @@ export default function SiloPage() {
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')
+ if (updated) {
+ clearDocumentDraft(updated.id)
+ 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')
+ if (await deleteDocument(doc.id)) {
+ clearDocumentDraft(doc.id)
+ navigate('/dashboard')
+ }
else setSaveError('Could not delete this page. Check your connection and try again.')
}
@@ -175,26 +191,38 @@ export default function SiloPage() {
onDocChange={setDoc}
layout="page"
titleReadOnly={!editing}
+ editing={editing}
+ onEdit={() => setEditing(true)}
+ onUpdateShortcut={() => { if (!saving) void update() }}
+ onPublishShortcut={() => { if (!saving) void publish() }}
onTitleChange={(title) =>
setDoc((prev) => (prev ? { ...prev, title } : prev))
}
+ onTitleCommit={() => {
+ if (editing && !saving) void update()
+ }}
+ onTitleDoubleClick={() => setEditing(true)}
headerActions={
<>
{!editing ? (
setEditing(true)}
+ aria-keyshortcuts="E"
className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark"
>
- Edit
+ Edit
+ E
) : (
<>
- 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'}
+ void update()} disabled={saving} aria-keyshortcuts="Control+S Meta+S" 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'}
+ {!saving && ⌘/Ctrl S }
- 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'}
+ void publish()} disabled={saving} aria-keyshortcuts="Control+Enter Meta+Enter" 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'}
+ ⌘/Ctrl ↵
>
)}
@@ -243,9 +271,51 @@ export default function SiloPage() {
{saveError &&
{saveError} }
-
+
{
+ if (!editing) setEditPromptOpen(true)
+ }}
+ >
+ {editPromptOpen && !editing && (
+
+
+
+ Would you like to edit this page?
+
+
+ Editing is currently locked. Choose “Yes, edit” to make changes.
+
+
+ setEditPromptOpen(false)}
+ className="rounded-lg border border-silos-cream-dark px-3 py-2 text-sm text-silos-text hover:bg-silos-cream"
+ >
+ No
+
+ {
+ setEditPromptOpen(false)
+ setEditing(true)
+ }}
+ className="rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white hover:bg-silos-brown-dark"
+ >
+ Yes, edit
+
+
+
+
+ )}
{commentsOpen &&
setCommentsOpen(false)} />}
{historyOpen && (
diff --git a/frontend/src/pages/workspace/SketchPage.tsx b/frontend/src/pages/workspace/SketchPage.tsx
index ec14173..ea92870 100644
--- a/frontend/src/pages/workspace/SketchPage.tsx
+++ b/frontend/src/pages/workspace/SketchPage.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useMemo } from 'react'
+import { useState, useEffect, useMemo, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SketchCanvas from '../../components/sketch/SketchCanvas'
@@ -11,6 +11,7 @@ import {
type SketchElement,
type SketchViewport,
} from '../../types/sketch'
+import { clearDocumentDraft, restoreDocumentDraft, saveDocumentDraft } from '../../lib/documentDrafts'
export default function SketchPage() {
const { id } = useParams<{ id: string }>()
@@ -18,6 +19,7 @@ export default function SketchPage() {
const [doc, setDoc] = useState(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
+ const titleRef = useRef(null)
useEffect(() => {
if (!id) return
@@ -27,7 +29,7 @@ export default function SketchPage() {
navigate('/dashboard')
return
}
- setDoc(found)
+ setDoc(restoreDocumentDraft(found))
})
.catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
@@ -37,6 +39,10 @@ export default function SketchPage() {
useRecordVisit(doc?.id)
+ useEffect(() => {
+ if (doc && editing) saveDocumentDraft(doc)
+ }, [doc, editing])
+
const sketchContent = useMemo(
() => (doc ? normalizeSketchContent(doc.content) : { elements: [] }),
[doc?.content],
@@ -56,6 +62,7 @@ export default function SketchPage() {
setSaving(false)
if (updated) {
setDoc(updated)
+ clearDocumentDraft(updated.id)
setEditing(false)
}
}
@@ -70,6 +77,7 @@ export default function SketchPage() {
setSaving(false)
if (updated) {
setDoc(updated)
+ clearDocumentDraft(updated.id)
setEditing(false)
}
}
@@ -80,6 +88,10 @@ export default function SketchPage() {
onDocChange={setDoc}
layout="canvas"
hideTitle
+ editing={editing}
+ onEdit={() => setEditing(true)}
+ onUpdateShortcut={() => { if (!saving) void update() }}
+ onPublishShortcut={() => { if (!saving) void publish() }}
headerActions={
!editing ? (
setEditing(true)} className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark">
@@ -105,9 +117,25 @@ export default function SketchPage() {
setDoc((prev) => (prev ? { ...prev, title: e.target.value } : prev))}
+ onDoubleClick={() => {
+ setEditing(true)
+ requestAnimationFrame(() => {
+ titleRef.current?.focus()
+ titleRef.current?.select()
+ })
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ event.stopPropagation()
+ if (!saving) void update()
+ titleRef.current?.blur()
+ }
+ }}
className="border-none bg-transparent text-lg font-semibold text-silos-text outline-none read-only:cursor-default"
placeholder="Untitled whiteboard"
/>
diff --git a/frontend/src/pages/workspace/SteadPage.tsx b/frontend/src/pages/workspace/SteadPage.tsx
index 0c18838..efb45e1 100644
--- a/frontend/src/pages/workspace/SteadPage.tsx
+++ b/frontend/src/pages/workspace/SteadPage.tsx
@@ -4,14 +4,17 @@ import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SteadHub from '../../components/stead/SteadHub'
import { defaultSteadContent, type SteadContent } from '../../data/steadData'
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 { clearDocumentDraft, restoreDocumentDraft, saveDocumentDraft } from '../../lib/documentDrafts'
export default function SteadPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState(null)
+ const [editing, setEditing] = useState(false)
+ const [saving, setSaving] = useState(false)
useEffect(() => {
if (!id) return
@@ -26,7 +29,7 @@ export default function SteadPage() {
navigate('/workspace/stead/new')
return
}
- setDoc(found)
+ setDoc(restoreDocumentDraft(found))
})
.catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
@@ -34,11 +37,42 @@ export default function SteadPage() {
})
}, [id, navigate])
- useAutoSave(doc)
useRecordVisit(doc?.id)
+ useEffect(() => {
+ if (doc && editing) saveDocumentDraft(doc)
+ }, [doc, editing])
if (!doc) return null
+ const status = (doc.content.status as string) || 'draft'
+
+ 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)
+ clearDocumentDraft(updated.id)
+ setEditing(false)
+ }
+ }
+
+ async function publish() {
+ if (!doc) return
+ setSaving(true)
+ const updated = await updateDocument(doc.id, {
+ title: doc.title,
+ content: { ...doc.content, status: status === 'published' ? 'draft' : 'published' },
+ })
+ setSaving(false)
+ if (updated) {
+ setDoc(updated)
+ clearDocumentDraft(updated.id)
+ setEditing(false)
+ }
+ }
+
const content: SteadContent = {
...defaultSteadContent(),
...(doc.content as unknown as Partial),
@@ -65,13 +99,45 @@ export default function SteadPage() {
}
return (
-
+ setEditing(true)}
+ onTitleChange={(title) => setDoc((prev) => (prev ? { ...prev, title } : prev))}
+ onTitleCommit={() => { if (editing && !saving) void update() }}
+ onTitleDoubleClick={() => setEditing(true)}
+ onUpdateShortcut={() => { if (!saving) void update() }}
+ onPublishShortcut={() => { if (!saving) void publish() }}
+ headerActions={
+ !editing ? (
+ setEditing(true)} aria-keyshortcuts="E" className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark">
+ Edit E
+
+ ) : (
+ <>
+ void update()} disabled={saving} aria-keyshortcuts="Control+S Meta+S" 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'} {!saving && ⌘/Ctrl S }
+
+ void publish()} disabled={saving} aria-keyshortcuts="Control+Enter Meta+Enter" 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'} ⌘/Ctrl ↵
+
+ >
+ )
+ }
+ >
patchContent({ activeSection: section })}
onAddPlan={handleAddPlan}
onUpdatePlan={handleUpdatePlan}
+ editing={editing}
+ onTitleChange={(title) => setDoc((prev) => (prev ? { ...prev, title } : prev))}
+ onTitleDoubleClick={() => setEditing(true)}
+ onTitleCommit={() => { if (editing && !saving) void update() }}
/>
)