feat: enhance workspace sidebar with create menu and document draft management

- Added a create menu in the WorkspaceSidebar for creating new documents (live docs, pages, whiteboards, databases, smart links, folders).
- Implemented document draft functionality to save and restore drafts for documents across various pages (ShedPage, SiloPage, SketchPage, SteadPage).
- Improved error handling in useDashboardDocuments for better user feedback on API errors.
- Introduced a NotificationBell component for displaying notifications with a read/unread state.
- Updated document update and publish logic to clear drafts upon successful updates.
- Enhanced user experience with keyboard shortcuts for editing and saving documents.
This commit is contained in:
Anurag Jangid 2026-10-23 21:54:48 +05:30
parent e905184c7f
commit eed9504a53
21 changed files with 891 additions and 125 deletions

View file

@ -1,12 +1,13 @@
import { type ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { getStoredUser } from '../lib/api'
import { getStoredUser, clearAuth } from '../lib/api'
export default function ProtectedRoute({ children }: { children: ReactNode }) {
const location = useLocation()
const user = getStoredUser()
if (!user) {
if (!user || !localStorage.getItem('silos_access_token')) {
clearAuth()
return <Navigate to="/login" state={{ from: location }} replace />
}

View file

@ -99,13 +99,6 @@ export default function LoginForm({
</div>
<SSOButtons onSelect={onSSOSelect} compact />
<button
type="button"
onClick={() => { window.location.href = `${import.meta.env.VITE_API_URL || '/api/v1'}/auth/google/login` }}
className="w-full rounded-xl border border-silos-cream-dark py-3 text-sm font-semibold text-silos-text transition-colors hover:bg-silos-cream"
>
Continue with Google
</button>
</form>
)
}

View file

@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import type { ReactNode } from 'react'
import {
Plus,
ChevronRight,
@ -24,10 +25,25 @@ const iconMap: Record<string, LucideIcon> = {
LayoutGrid,
}
export default function CreateDropdown({ steadId, parentPageId }: { steadId?: string; parentPageId?: string }) {
interface CreateDropdownProps {
steadId?: string
parentPageId?: string
trigger?: (open: boolean, onClick: () => void) => ReactNode
dropUp?: boolean
floating?: boolean
}
export default function CreateDropdown({
steadId,
parentPageId,
trigger,
dropUp = false,
floating = false,
}: CreateDropdownProps) {
const [open, setOpen] = useState(false)
const [shedOpen, setShedOpen] = useState(false)
const [pageOpen, setPageOpen] = useState(false)
const [floatingPosition, setFloatingPosition] = useState({ left: 0, bottom: 0 })
const ref = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
@ -46,6 +62,19 @@ export default function CreateDropdown({ steadId, parentPageId }: { steadId?: st
const user = getStoredUser()
const author = user?.full_name ?? 'You'
function toggleOpen() {
if (!open && floating && ref.current) {
const bounds = ref.current.getBoundingClientRect()
setFloatingPosition({
left: bounds.right + 8,
bottom: window.innerHeight - bounds.bottom,
})
}
setOpen(!open)
setShedOpen(false)
setPageOpen(false)
}
async function handleCreate(type: 'silo' | 'sketch') {
const defaults: Record<string, Record<string, unknown>> = {
silo: { body: '', bodyHtml: '', status: 'draft' },
@ -103,20 +132,25 @@ export default function CreateDropdown({ steadId, parentPageId }: { steadId?: st
return (
<div ref={ref} className="relative">
<button
{trigger ? trigger(open, toggleOpen) : <button
onClick={() => {
setOpen(!open)
setShedOpen(false)
setPageOpen(false)
toggleOpen()
}}
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"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create</span>
</button>
</button>}
{open && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 overflow-hidden rounded-xl border border-silos-cream-dark bg-white shadow-2xl">
<div
className={`${floating ? 'fixed' : 'absolute'} z-50 w-80 overflow-hidden rounded-xl border border-silos-cream-dark bg-white shadow-2xl ${
floating
? ''
: `right-0 ${dropUp ? 'bottom-full mb-2' : 'top-full mt-2'}`
}`}
style={floating ? floatingPosition : undefined}
>
<div className="border-b border-silos-cream-dark px-4 py-3">
<p className="text-sm font-semibold text-silos-text">Create new</p>
<p className="text-xs text-silos-muted">Choose what you want to build</p>

View file

@ -35,6 +35,7 @@ interface DashboardFeedProps {
error?: string | null
onOpenRecent?: () => void
onOpenTemplates: () => void
onOpenProjects: () => void
}
export default function DashboardFeed({
@ -43,6 +44,7 @@ export default function DashboardFeed({
error,
onOpenRecent,
onOpenTemplates,
onOpenProjects,
}: DashboardFeedProps) {
const [activeTab, setActiveTab] = useState<'feed' | 'discover'>('feed')
const user = getStoredUser()
@ -144,12 +146,12 @@ export default function DashboardFeed({
{!(!loading && recent.length === 0) && (
<ActivityStatus documentCount={recent.length} />
)}
<DiscoverPanel />
<DiscoverPanel onOpenTemplates={onOpenTemplates} onOpenProjects={onOpenProjects} />
</div>
</div>
) : (
<div className="mt-6 max-w-2xl">
<DiscoverPanel />
<DiscoverPanel onOpenTemplates={onOpenTemplates} onOpenProjects={onOpenProjects} />
</div>
)}
</div>

View file

@ -3,13 +3,13 @@ import { Link } from 'react-router-dom'
import {
Menu,
Search,
Bell,
ChevronDown,
} from 'lucide-react'
import { logos } from '../../data/brandAssets'
import { getStoredUser } from '../../lib/api'
import CreateDropdown from '../create/CreateDropdown'
import SearchOverlay from '../search/SearchOverlay'
import NotificationBell from '../notifications/NotificationBell'
interface DashboardHeaderProps {
onToggleSidebar: () => void
@ -81,12 +81,7 @@ export default function DashboardHeader({
</div>
<div className="flex items-center gap-1">
<button
className="relative rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Notifications"
>
<Bell className="h-5 w-5" />
</button>
<NotificationBell />
<Link to="/profile" className="ml-1 flex items-center gap-1.5 rounded-full py-1 pl-1 pr-2 transition-colors hover:bg-silos-cream" aria-label="Open profile">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-silos-tan text-sm font-semibold text-white">
{displayName[0]}

View file

@ -91,6 +91,10 @@ export default function DashboardLayout() {
setActiveNav('templates')
setFlyout('templates')
}}
onOpenProjects={() => {
setActiveNav('all-projects')
setFlyout('all-projects')
}}
/>
</div>
</div>

View file

@ -13,15 +13,22 @@ const iconMap: Record<string, LucideIcon> = {
BookOpen,
}
export default function DiscoverPanel() {
interface DiscoverPanelProps {
onOpenTemplates?: () => void
onOpenProjects?: () => void
}
export default function DiscoverPanel({ onOpenTemplates, onOpenProjects }: DiscoverPanelProps) {
function handleDiscover(id: string) {
if (id === '1') onOpenTemplates?.()
else onOpenProjects?.()
}
return (
<div className="space-y-6">
<div className="rounded-2xl border border-silos-cream-dark bg-white p-5">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-silos-text">Discover something new</h3>
<button className="text-sm font-medium text-silos-tan hover:text-silos-brown">
See all
</button>
</div>
<ul className="mt-4 space-y-1">
@ -29,7 +36,7 @@ export default function DiscoverPanel() {
const Icon = iconMap[item.icon]
return (
<li key={item.id}>
<button className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition-colors hover:bg-silos-cream/50">
<button type="button" onClick={() => handleDiscover(item.id)} className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition-colors hover:bg-silos-cream/50">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${item.color}22` }}

View file

@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, ChevronRight } from 'lucide-react'
import type { CreateType } from '../../data/createData'
import type { DocumentRecord } from '../../lib/documents'
import { documentsApi, type ApiActivity } from '../../lib/api'
import {
formatRelativeTime,
getActivityDate,
getProjectColor,
getProjectIcon,
typeColors,
@ -18,15 +19,6 @@ const typeIcons: Record<CreateType, typeof FileText> = {
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<ApiActivity[]>([])
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<string, ApiActivity>()
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 (
<div className="rounded-2xl border border-silos-cream-dark bg-white">
@ -69,7 +119,7 @@ export default function RecentActivities({
</div>
) : (
<ul className="divide-y divide-silos-cream-dark">
{items.map((doc) => {
{items.map(({ activity, document: doc }) => {
const Icon = typeIcons[doc.type]
const color = doc.type === 'stead' ? getProjectColor(doc) : typeColors[doc.type]
const initial = doc.createdBy[0]?.toUpperCase() ?? '?'
@ -93,12 +143,12 @@ export default function RecentActivities({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-silos-text">{doc.title}</p>
<p className="mt-0.5 truncate text-sm text-silos-muted">
{getActionLabel(doc)} · {typeLabels[doc.type]}
{activity.message || `${activity.kind} by ${activity.actor_name}`} · {typeLabels[doc.type]}
</p>
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="hidden text-xs text-silos-muted sm:inline">
{formatRelativeTime(getActivityDate(doc))}
{formatRelativeTime(activity.created_at)}
</span>
<div
className="flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold text-white"

View file

@ -0,0 +1,76 @@
import { useEffect, useRef, useState } from 'react'
import { Bell, X } from 'lucide-react'
import { documentsApi, type ApiNotification } from '../../lib/api'
interface NotificationBellProps {
compact?: boolean
}
export default function NotificationBell({ compact = false }: NotificationBellProps) {
const [notifications, setNotifications] = useState<ApiNotification[]>([])
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
void documentsApi.listNotifications().then(setNotifications).catch(() => setNotifications([]))
}, [])
useEffect(() => {
function closeOnOutside(event: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', closeOnOutside)
return () => document.removeEventListener('mousedown', closeOnOutside)
}, [])
function toggle() {
const nextOpen = !open
setOpen(nextOpen)
if (nextOpen) {
void documentsApi.markNotificationsRead().then(() => {
setNotifications((items) => items.map((item) => ({ ...item, read: true })))
})
}
}
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={toggle}
className={`relative rounded-lg text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text ${
compact ? 'p-1.5' : 'p-2'
}`}
aria-label="Notifications"
aria-expanded={open}
>
<Bell className={compact ? 'h-3.5 w-3.5' : 'h-5 w-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>
{open && (
<div className="absolute right-0 top-10 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={() => setOpen(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>
)
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, FileText, PenTool, Zap, LayoutGrid } from 'lucide-react'
import type { SteadContent } from '../../data/steadData'
@ -16,6 +16,10 @@ interface SteadHubProps {
onSectionChange: (section: string) => void
onAddPlan: () => void
onUpdatePlan: (index: number, field: string, value: string) => void
editing: boolean
onTitleChange: (title: string) => void
onTitleDoubleClick: () => void
onTitleCommit: () => void
}
const typeIcons: Record<string, typeof FileText> = {
@ -31,9 +35,14 @@ export default function SteadHub({
onSectionChange,
onAddPlan,
onUpdatePlan,
editing,
onTitleChange,
onTitleDoubleClick,
onTitleCommit,
}: SteadHubProps) {
const navigate = useNavigate()
const [linkedDocs, setLinkedDocs] = useState<DocumentRecord[]>([])
const titleRef = useRef<HTMLInputElement>(null)
useEffect(() => {
void listDocumentsByStead(doc.id).then(setLinkedDocs)
@ -66,7 +75,29 @@ export default function SteadHub({
>
{content.icon}
</span>
<span className="truncate text-sm font-semibold text-silos-text">{doc.title}</span>
<input
ref={titleRef}
value={doc.title}
readOnly={!editing}
onChange={(event) => onTitleChange(event.target.value)}
onDoubleClick={() => {
onTitleDoubleClick()
requestAnimationFrame(() => {
titleRef.current?.focus()
titleRef.current?.select()
})
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
onTitleCommit()
titleRef.current?.blur()
}
}}
className="min-w-0 flex-1 border-none bg-transparent text-sm font-semibold text-silos-text outline-none read-only:cursor-default"
aria-label="Space title"
/>
</div>
<nav className="mt-6 space-y-0.5">
{spaceNavItems.map((item) => (
@ -95,6 +126,7 @@ export default function SteadHub({
<button
type="button"
onClick={() => void createInSpace('silo')}
disabled={!editing}
className="flex items-center gap-1 rounded-lg border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-text hover:bg-silos-cream"
>
<Plus className="h-3 w-3" /> Silo
@ -102,6 +134,7 @@ export default function SteadHub({
<button
type="button"
onClick={() => void createInSpace('sketch')}
disabled={!editing}
className="flex items-center gap-1 rounded-lg border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-text hover:bg-silos-cream"
>
<Plus className="h-3 w-3" /> Sketch
@ -109,6 +142,7 @@ export default function SteadHub({
<button
type="button"
onClick={() => void createInSpace('shed')}
disabled={!editing}
className="flex items-center gap-1 rounded-lg bg-silos-brown px-3 py-1.5 text-xs text-white hover:bg-silos-brown-dark"
>
<Plus className="h-3 w-3" /> Shed
@ -155,6 +189,7 @@ export default function SteadHub({
<button
type="button"
onClick={onAddPlan}
disabled={!editing}
className="flex items-center gap-1 rounded-lg bg-silos-brown px-3 py-1.5 text-xs text-white hover:bg-silos-brown-dark"
>
<Plus className="h-3 w-3" /> Add plan
@ -173,6 +208,7 @@ export default function SteadHub({
<input
value={plan.title}
onChange={(e) => onUpdatePlan(i, 'title', e.target.value)}
readOnly={!editing}
placeholder="Plan title"
className="w-full border-none bg-transparent text-sm font-medium text-silos-text outline-none"
/>
@ -180,6 +216,7 @@ export default function SteadHub({
<select
value={plan.status}
onChange={(e) => onUpdatePlan(i, 'status', e.target.value)}
disabled={!editing}
className="rounded border border-silos-cream-dark px-2 py-1 text-xs text-silos-text"
>
<option value="planned">Planned</option>
@ -190,6 +227,7 @@ export default function SteadHub({
type="date"
value={plan.dueDate}
onChange={(e) => onUpdatePlan(i, 'dueDate', e.target.value)}
readOnly={!editing}
className="rounded border border-silos-cream-dark px-2 py-1 text-xs text-silos-text"
/>
</div>

View file

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { Menu, Share2, Link2, MoreHorizontal, Users, Search, Copy, Archive, Trash2, Plus, X, Bell } from 'lucide-react'
import { Menu, Share2, Link2, MoreHorizontal, Users, Search, Copy, Archive, Trash2, Plus, X } from 'lucide-react'
import { logos } from '../../data/brandAssets'
import { documentsApi, getStoredUser, type ApiNotification } from '../../lib/api'
import { getStoredUser } from '../../lib/api'
import { getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import CreateDropdown from '../create/CreateDropdown'
import WorkspaceSidebar from './WorkspaceSidebar'
@ -12,6 +12,7 @@ import DocumentFlyout, { type FlyoutMode } from '../dashboard/DocumentFlyout'
import TemplatesFlyout from '../dashboard/TemplatesFlyout'
import { useDashboardDocuments } from '../../hooks/useDashboardDocuments'
import type { NavItemId } from '../../data/dashboardData'
import NotificationBell from '../notifications/NotificationBell'
type WorkspaceLayout = 'default' | 'page' | 'canvas' | 'space'
@ -24,6 +25,12 @@ interface WorkspaceShellProps {
hideTitle?: boolean
titleReadOnly?: boolean
onTitleChange?: (title: string) => void
onTitleCommit?: () => void
onTitleDoubleClick?: () => void
editing?: boolean
onEdit?: () => void
onUpdateShortcut?: () => void
onPublishShortcut?: () => void
presence?: { name: string; initial: string }[]
onCreateChild?: () => void
onDuplicate?: () => void
@ -40,6 +47,12 @@ export default function WorkspaceShell({
hideTitle = false,
titleReadOnly = false,
onTitleChange,
onTitleCommit,
onTitleDoubleClick,
editing = false,
onEdit,
onUpdateShortcut,
onPublishShortcut,
presence = [],
onCreateChild,
onDuplicate,
@ -52,14 +65,33 @@ export default function WorkspaceShell({
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)
const [workspaceFlyout, setWorkspaceFlyout] = useState<FlyoutMode | 'templates' | null>(null)
const titleRef = useRef<HTMLInputElement>(null)
const { recent, projects, starredProjects, refresh } = useDashboardDocuments()
useEffect(() => {
void documentsApi.listNotifications().then(setNotifications).catch(() => setNotifications([]))
}, [])
function onShortcut(event: KeyboardEvent) {
const target = event.target as HTMLElement
const isTyping =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
if (!isTyping && !editing && event.key.toLowerCase() === 'e') {
event.preventDefault()
onEdit?.()
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
if (editing) onUpdateShortcut?.()
}
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault()
if (editing) onPublishShortcut?.()
}
}
window.addEventListener('keydown', onShortcut)
return () => window.removeEventListener('keydown', onShortcut)
}, [editing, onEdit, onPublishShortcut, onUpdateShortcut])
useEffect(() => {
if (!doc.steadId) {
@ -173,7 +205,6 @@ export default function WorkspaceShell({
{user?.full_name?.[0] ?? 'Y'}
</div>
</div>
<Users className="ml-1 hidden h-3.5 w-3.5 text-silos-muted sm:block" />
<button
type="button"
onClick={() => setShareOpen(true)}
@ -191,16 +222,7 @@ export default function WorkspaceShell({
>
<Link2 className="h-3.5 w-3.5" />
</button>
<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>
<NotificationBell compact />
<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>
@ -256,9 +278,29 @@ export default function WorkspaceShell({
{!hideTitle && (
<>
<input
ref={titleRef}
value={doc.title}
readOnly={titleReadOnly}
onChange={(e) => 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'

View file

@ -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<CreateType, typeof FileText> = {
silo: FileText,
@ -73,11 +79,20 @@ export default function WorkspaceSidebar({
}: WorkspaceSidebarProps) {
const [docs, setDocs] = useState<DocumentRecord[]>([])
const [steads, setSteads] = useState<DocumentRecord[]>([])
const [creatingChildFor, setCreatingChildFor] = useState<string | null>(null)
const [createMenuId, setCreateMenuId] = useState<string | null>(null)
const [createMenuPosition, setCreateMenuPosition] = useState<
{ left: number; top?: number; bottom?: number } | null
>(null)
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [menuId, setMenuId] = useState<string | null>(null)
const [menuPosition, setMenuPosition] = useState<{
left: number
top?: number
bottom?: number
} | null>(null)
const [renameId, setRenameId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const savingRename = useRef(false)
const [moveId, setMoveId] = useState<string | null>(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: '<p>Paste a link here</p>', 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({
</Link>
)}
{doc.type === 'silo' && !isRenaming && (
{!isRenaming && (
<button
type="button"
onClick={(event) => {
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"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
{createMenuId === doc.id && (
<div
className="fixed z-[75] w-72 rounded-xl border border-silos-cream-dark bg-white p-2 shadow-2xl"
style={createMenuPosition ?? undefined}
role="menu"
aria-label={`Create content under ${doc.title}`}
>
{[
{ 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) => (
<button
key={kind}
type="button"
role="menuitem"
onClick={() => 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' : ''
}`}
>
<MenuIcon className="h-5 w-5 text-silos-muted" />
{label}
</button>
))}
</div>
)}
{!isRenaming && (
<button
type="button"
onClick={(event) => {
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 && (
<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">
<div
className="fixed z-[70] w-56 rounded-xl border border-silos-cream-dark bg-white p-1.5 shadow-xl"
style={menuPosition ?? undefined}
>
<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>
@ -383,7 +535,21 @@ export default function WorkspaceSidebar({
{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>
<CreateDropdown
steadId={parent?.id ?? steadId}
parentPageId={docs.find((doc) => doc.id === activeDocId && doc.type === 'silo')?.id}
dropUp
floating
trigger={(_, onClick) => (
<button
type="button"
onClick={onClick}
className="flex w-full 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
</button>
)}
/>
</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">

View file

@ -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<DocumentRecord[]>([])
@ -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)

View file

@ -137,6 +137,17 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
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)
}

View file

@ -0,0 +1,51 @@
import type { DocumentRecord } from './documents'
interface StoredDraft {
title: string
content: Record<string, unknown>
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<StoredDraft>
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.
}
}

View file

@ -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')

View file

@ -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<DocumentRecord | null>(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 (
<WorkspaceShell
doc={doc}
onDocChange={setDoc}
titleReadOnly={!editing}
editing={editing}
onEdit={() => 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 ? (
<button type="button" onClick={() => 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 <kbd className="ml-2 rounded border border-white/30 px-1 text-[10px] font-normal">E</kbd>
</button>
) : (
<>
<button type="button" onClick={() => 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 && <kbd className="ml-2 rounded border border-silos-cream-dark px-1 text-[10px] text-silos-muted">⌘/Ctrl S</kbd>}
</button>
<button type="button" onClick={() => 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'} <kbd className="ml-2 rounded border border-white/30 px-1 text-[10px]">⌘/Ctrl ↵</kbd>
</button>
</>
)
}
presence={collab.peers.map((p) => ({ name: p.name, initial: p.initial }))}
>
<ShedTemplate

View file

@ -8,6 +8,7 @@ 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'
import { clearDocumentDraft, restoreDocumentDraft, saveDocumentDraft } from '../../lib/documentDrafts'
function siloHtml(content: Record<string, unknown>): 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<ApiDocumentVersion[]>([])
const [historyError, setHistoryError] = useState<string | null>(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 ? (
<button
type="button"
onClick={() => 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
<span>Edit</span>
<kbd className="ml-2 rounded border border-white/30 px-1 text-[10px] font-normal">E</kbd>
</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 type="button" onClick={() => 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">
<span>{saving ? 'Saving…' : 'Update'}</span>
{!saving && <kbd className="ml-2 rounded border border-silos-cream-dark px-1 text-[10px] font-normal text-silos-muted">⌘/Ctrl S</kbd>}
</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 type="button" onClick={() => 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">
<span>{status === 'published' ? 'Unpublish' : 'Publish'}</span>
<kbd className="ml-2 rounded border border-white/30 px-1 text-[10px] font-normal">⌘/Ctrl ↵</kbd>
</button>
</>
)}
@ -243,9 +271,51 @@ export default function SiloPage() {
</div>
{saveError && <span className="text-red-700">{saveError}</span>}
</div>
<div aria-live="polite">
<div
aria-live="polite"
onDoubleClick={() => {
if (!editing) setEditPromptOpen(true)
}}
>
<SiloEditor key={doc.id} html={siloHtml(doc.content)} onChange={patchHtml} editable={editing} />
</div>
{editPromptOpen && !editing && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/20 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="edit-page-prompt-title"
>
<div className="w-full max-w-sm rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-2xl">
<h2 id="edit-page-prompt-title" className="text-base font-semibold text-silos-text">
Would you like to edit this page?
</h2>
<p className="mt-2 text-sm leading-6 text-silos-muted">
Editing is currently locked. Choose “Yes, edit” to make changes.
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setEditPromptOpen(false)}
className="rounded-lg border border-silos-cream-dark px-3 py-2 text-sm text-silos-text hover:bg-silos-cream"
>
No
</button>
<button
type="button"
autoFocus
onClick={() => {
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
</button>
</div>
</div>
</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">

View file

@ -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<DocumentRecord | null>(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const titleRef = useRef<HTMLInputElement>(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 ? (
<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">
@ -105,9 +117,25 @@ export default function SketchPage() {
<div className="flex h-full flex-col">
<div className="flex items-center border-b border-silos-cream-dark px-6 py-3">
<input
ref={titleRef}
value={doc.title}
readOnly={!editing}
onChange={(e) => 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"
/>

View file

@ -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<DocumentRecord | null>(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<SteadContent>),
@ -65,13 +99,45 @@ export default function SteadPage() {
}
return (
<WorkspaceShell doc={doc} onDocChange={setDoc} layout="space" hideTitle>
<WorkspaceShell
doc={doc}
onDocChange={setDoc}
layout="space"
titleReadOnly={!editing}
editing={editing}
onEdit={() => 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 ? (
<button type="button" onClick={() => 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 <kbd className="ml-2 rounded border border-white/30 px-1 text-[10px] font-normal">E</kbd>
</button>
) : (
<>
<button type="button" onClick={() => 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 && <kbd className="ml-2 rounded border border-silos-cream-dark px-1 text-[10px] text-silos-muted">⌘/Ctrl S</kbd>}
</button>
<button type="button" onClick={() => 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'} <kbd className="ml-2 rounded border border-white/30 px-1 text-[10px]">⌘/Ctrl ↵</kbd>
</button>
</>
)
}
>
<SteadHub
doc={doc}
content={content}
onSectionChange={(section) => 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() }}
/>
</WorkspaceShell>
)