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

@ -320,3 +320,64 @@ Next tenant work:
- Workspace navigation now mirrors dashboard controls: “For you” links to the - Workspace navigation now mirrors dashboard controls: “For you” links to the
dashboard, while Recent, Favourite Projects, All Projects, and Templates dashboard, while Recent, Favourite Projects, All Projects, and Templates
use trailing chevrons and active flyout states. use trailing chevrons and active flyout states.
- Dashboard and workspace notification bells now share the same notification
feed, unread indicator, read-state behavior, and notification panel. The
workspace header no longer displays the redundant people icon.
- Dashboard Feed activity now loads persisted per-document activity from the
collaboration API, orders it by newest event, links each event to its page,
and refreshes live every 15 seconds. Discover actions now open the existing
Templates or All Projects flyouts instead of being inert buttons.
- Dashboard workspace selection now validates the locally stored workspace ID
against the authenticated user’s workspace memberships before requesting
documents. Stale or revoked workspace IDs are cleared, and 403 membership
errors are no longer incorrectly reported as MongoDB outages.
- Authentication failures are now handled centrally: a protected API `401`
clears the stale local session and redirects to login, while protected routes
also require an access token instead of trusting only cached user metadata.
This prevents an expired token from leaving the dashboard visible while all
document and workspace requests fail.
- Page editing now follows Confluence-style keyboard actions: `E` enters edit
mode, `Ctrl/Cmd+S` updates the draft, and `Ctrl/Cmd+Enter` publishes (or
unpublishes) the page. The buttons expose the same shortcuts visually and
through `aria-keyshortcuts`, while typing in form fields or the editor does
not trigger the edit shortcut.
- Read-only page content now requires an explicit confirmation before editing:
double-clicking the content opens a “Would you like to edit this page?”
dialog, and only “Yes, edit” enables the editor. Escape and “No” leave the
page read-only.
- Workspace titles now support fast renaming: double-clicking a title enters
title-edit mode and selects the complete name, while the sidebar Rename
action preselects the full existing name. Pressing Enter commits the title
change once and stops the event from affecting page editing shortcuts.
- Unsaved edits now persist as per-document local drafts across workspace
navigation and reloads. Text pages and whiteboards restore a draft only when
it is newer than the server version, while successful Update, Publish,
history restore, archive, and delete operations clear the local draft.
- Deleting the active file from the workspace sidebar now keeps navigation
inside the workspace: it opens the parent page, a remaining sibling, or the
containing space instead of always redirecting to the dashboard.
- The workspace sidebar’s existing “Create new” button now opens the same
creation menu and uses the same page, whiteboard, space, and template
creation flows as the header Create button. Its original compact sidebar
appearance is preserved, with the menu opening upward to remain visible.
- The workspace sidebar creation menu now floats between the sidebar and the
workspace content instead of being constrained inside the sidebar. It is
aligned to the existing button and remains upward-opening and accessible
without changing the button’s appearance.
- File action menus opened from the workspace sidebar three-dot controls now
float outside the sidebar. They align to the left edge of the action button,
open downward for items with room below, and automatically open upward with
bottom-left alignment near the bottom of the list.
- Dashboard Recent Activities now includes the user’s recently visited files,
even when no separate activity event exists, and labels those fallback
entries as recently opened. The Discover something new panel no longer
displays the redundant See all button.
- Workspace editing shortcuts and title controls now apply consistently to
Silo pages, Sketch whiteboards, Shed documents, and Stead spaces. Each
supports E to edit, Ctrl/Cmd+S to update, Ctrl/Cmd+Enter to publish or
unpublish, double-click title selection, and Enter-to-commit title changes.
Stead plan controls also remain read-only until Edit mode is enabled.
- Every workspace sidebar file row now has a plus create action. Its floating
Confluence-style menu supports Live Doc, Page, Whiteboard, Database, Smart
Link, and Folder actions, creating the corresponding Silos document or
container in the current workspace context.

View file

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

View file

@ -99,13 +99,6 @@ export default function LoginForm({
</div> </div>
<SSOButtons onSelect={onSSOSelect} compact /> <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> </form>
) )
} }

View file

@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import type { ReactNode } from 'react'
import { import {
Plus, Plus,
ChevronRight, ChevronRight,
@ -24,10 +25,25 @@ const iconMap: Record<string, LucideIcon> = {
LayoutGrid, 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 [open, setOpen] = useState(false)
const [shedOpen, setShedOpen] = useState(false) const [shedOpen, setShedOpen] = useState(false)
const [pageOpen, setPageOpen] = useState(false) const [pageOpen, setPageOpen] = useState(false)
const [floatingPosition, setFloatingPosition] = useState({ left: 0, bottom: 0 })
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const navigate = useNavigate() const navigate = useNavigate()
@ -46,6 +62,19 @@ export default function CreateDropdown({ steadId, parentPageId }: { steadId?: st
const user = getStoredUser() const user = getStoredUser()
const author = user?.full_name ?? 'You' 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') { async function handleCreate(type: 'silo' | 'sketch') {
const defaults: Record<string, Record<string, unknown>> = { const defaults: Record<string, Record<string, unknown>> = {
silo: { body: '', bodyHtml: '', status: 'draft' }, silo: { body: '', bodyHtml: '', status: 'draft' },
@ -103,20 +132,25 @@ export default function CreateDropdown({ steadId, parentPageId }: { steadId?: st
return ( return (
<div ref={ref} className="relative"> <div ref={ref} className="relative">
<button {trigger ? trigger(open, toggleOpen) : <button
onClick={() => { onClick={() => {
setOpen(!open) toggleOpen()
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" 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" /> <Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create</span> <span className="hidden sm:inline">Create</span>
</button> </button>}
{open && ( {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"> <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-sm font-semibold text-silos-text">Create new</p>
<p className="text-xs text-silos-muted">Choose what you want to build</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 error?: string | null
onOpenRecent?: () => void onOpenRecent?: () => void
onOpenTemplates: () => void onOpenTemplates: () => void
onOpenProjects: () => void
} }
export default function DashboardFeed({ export default function DashboardFeed({
@ -43,6 +44,7 @@ export default function DashboardFeed({
error, error,
onOpenRecent, onOpenRecent,
onOpenTemplates, onOpenTemplates,
onOpenProjects,
}: DashboardFeedProps) { }: DashboardFeedProps) {
const [activeTab, setActiveTab] = useState<'feed' | 'discover'>('feed') const [activeTab, setActiveTab] = useState<'feed' | 'discover'>('feed')
const user = getStoredUser() const user = getStoredUser()
@ -144,12 +146,12 @@ export default function DashboardFeed({
{!(!loading && recent.length === 0) && ( {!(!loading && recent.length === 0) && (
<ActivityStatus documentCount={recent.length} /> <ActivityStatus documentCount={recent.length} />
)} )}
<DiscoverPanel /> <DiscoverPanel onOpenTemplates={onOpenTemplates} onOpenProjects={onOpenProjects} />
</div> </div>
</div> </div>
) : ( ) : (
<div className="mt-6 max-w-2xl"> <div className="mt-6 max-w-2xl">
<DiscoverPanel /> <DiscoverPanel onOpenTemplates={onOpenTemplates} onOpenProjects={onOpenProjects} />
</div> </div>
)} )}
</div> </div>

View file

@ -3,13 +3,13 @@ import { Link } from 'react-router-dom'
import { import {
Menu, Menu,
Search, Search,
Bell,
ChevronDown, ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import { logos } from '../../data/brandAssets' import { logos } from '../../data/brandAssets'
import { getStoredUser } from '../../lib/api' import { getStoredUser } from '../../lib/api'
import CreateDropdown from '../create/CreateDropdown' import CreateDropdown from '../create/CreateDropdown'
import SearchOverlay from '../search/SearchOverlay' import SearchOverlay from '../search/SearchOverlay'
import NotificationBell from '../notifications/NotificationBell'
interface DashboardHeaderProps { interface DashboardHeaderProps {
onToggleSidebar: () => void onToggleSidebar: () => void
@ -81,12 +81,7 @@ export default function DashboardHeader({
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <NotificationBell />
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>
<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"> <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"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-silos-tan text-sm font-semibold text-white">
{displayName[0]} {displayName[0]}

View file

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

View file

@ -13,15 +13,22 @@ const iconMap: Record<string, LucideIcon> = {
BookOpen, 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-2xl border border-silos-cream-dark bg-white p-5"> <div className="rounded-2xl border border-silos-cream-dark bg-white p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-semibold text-silos-text">Discover something new</h3> <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> </div>
<ul className="mt-4 space-y-1"> <ul className="mt-4 space-y-1">
@ -29,7 +36,7 @@ export default function DiscoverPanel() {
const Icon = iconMap[item.icon] const Icon = iconMap[item.icon]
return ( return (
<li key={item.id}> <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 <div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${item.color}22` }} style={{ backgroundColor: `${item.color}22` }}

View file

@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, ChevronRight } from 'lucide-react' import { FileText, Zap, PenTool, LayoutGrid, ChevronRight } from 'lucide-react'
import type { CreateType } from '../../data/createData' import type { CreateType } from '../../data/createData'
import type { DocumentRecord } from '../../lib/documents' import type { DocumentRecord } from '../../lib/documents'
import { documentsApi, type ApiActivity } from '../../lib/api'
import { import {
formatRelativeTime, formatRelativeTime,
getActivityDate,
getProjectColor, getProjectColor,
getProjectIcon, getProjectIcon,
typeColors, typeColors,
@ -18,15 +19,6 @@ const typeIcons: Record<CreateType, typeof FileText> = {
stead: LayoutGrid, 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 { interface RecentActivitiesProps {
documents: DocumentRecord[] documents: DocumentRecord[]
loading?: boolean loading?: boolean
@ -38,7 +30,65 @@ export default function RecentActivities({
loading, loading,
onViewAll, onViewAll,
}: RecentActivitiesProps) { }: 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 ( return (
<div className="rounded-2xl border border-silos-cream-dark bg-white"> <div className="rounded-2xl border border-silos-cream-dark bg-white">
@ -69,7 +119,7 @@ export default function RecentActivities({
</div> </div>
) : ( ) : (
<ul className="divide-y divide-silos-cream-dark"> <ul className="divide-y divide-silos-cream-dark">
{items.map((doc) => { {items.map(({ activity, document: doc }) => {
const Icon = typeIcons[doc.type] const Icon = typeIcons[doc.type]
const color = doc.type === 'stead' ? getProjectColor(doc) : typeColors[doc.type] const color = doc.type === 'stead' ? getProjectColor(doc) : typeColors[doc.type]
const initial = doc.createdBy[0]?.toUpperCase() ?? '?' const initial = doc.createdBy[0]?.toUpperCase() ?? '?'
@ -93,12 +143,12 @@ export default function RecentActivities({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium text-silos-text">{doc.title}</p> <p className="truncate font-medium text-silos-text">{doc.title}</p>
<p className="mt-0.5 truncate text-sm text-silos-muted"> <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> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-3"> <div className="flex shrink-0 items-center gap-3">
<span className="hidden text-xs text-silos-muted sm:inline"> <span className="hidden text-xs text-silos-muted sm:inline">
{formatRelativeTime(getActivityDate(doc))} {formatRelativeTime(activity.created_at)}
</span> </span>
<div <div
className="flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold text-white" 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 { useNavigate } from 'react-router-dom'
import { Plus, FileText, PenTool, Zap, LayoutGrid } from 'lucide-react' import { Plus, FileText, PenTool, Zap, LayoutGrid } from 'lucide-react'
import type { SteadContent } from '../../data/steadData' import type { SteadContent } from '../../data/steadData'
@ -16,6 +16,10 @@ interface SteadHubProps {
onSectionChange: (section: string) => void onSectionChange: (section: string) => void
onAddPlan: () => void onAddPlan: () => void
onUpdatePlan: (index: number, field: string, value: string) => 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> = { const typeIcons: Record<string, typeof FileText> = {
@ -31,9 +35,14 @@ export default function SteadHub({
onSectionChange, onSectionChange,
onAddPlan, onAddPlan,
onUpdatePlan, onUpdatePlan,
editing,
onTitleChange,
onTitleDoubleClick,
onTitleCommit,
}: SteadHubProps) { }: SteadHubProps) {
const navigate = useNavigate() const navigate = useNavigate()
const [linkedDocs, setLinkedDocs] = useState<DocumentRecord[]>([]) const [linkedDocs, setLinkedDocs] = useState<DocumentRecord[]>([])
const titleRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
void listDocumentsByStead(doc.id).then(setLinkedDocs) void listDocumentsByStead(doc.id).then(setLinkedDocs)
@ -66,7 +75,29 @@ export default function SteadHub({
> >
{content.icon} {content.icon}
</span> </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> </div>
<nav className="mt-6 space-y-0.5"> <nav className="mt-6 space-y-0.5">
{spaceNavItems.map((item) => ( {spaceNavItems.map((item) => (
@ -95,6 +126,7 @@ export default function SteadHub({
<button <button
type="button" type="button"
onClick={() => void createInSpace('silo')} 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" 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 <Plus className="h-3 w-3" /> Silo
@ -102,6 +134,7 @@ export default function SteadHub({
<button <button
type="button" type="button"
onClick={() => void createInSpace('sketch')} 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" 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 <Plus className="h-3 w-3" /> Sketch
@ -109,6 +142,7 @@ export default function SteadHub({
<button <button
type="button" type="button"
onClick={() => void createInSpace('shed')} 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" 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 <Plus className="h-3 w-3" /> Shed
@ -155,6 +189,7 @@ export default function SteadHub({
<button <button
type="button" type="button"
onClick={onAddPlan} 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" 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 <Plus className="h-3 w-3" /> Add plan
@ -173,6 +208,7 @@ export default function SteadHub({
<input <input
value={plan.title} value={plan.title}
onChange={(e) => onUpdatePlan(i, 'title', e.target.value)} onChange={(e) => onUpdatePlan(i, 'title', e.target.value)}
readOnly={!editing}
placeholder="Plan title" placeholder="Plan title"
className="w-full border-none bg-transparent text-sm font-medium text-silos-text outline-none" 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 <select
value={plan.status} value={plan.status}
onChange={(e) => onUpdatePlan(i, 'status', e.target.value)} 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" className="rounded border border-silos-cream-dark px-2 py-1 text-xs text-silos-text"
> >
<option value="planned">Planned</option> <option value="planned">Planned</option>
@ -190,6 +227,7 @@ export default function SteadHub({
type="date" type="date"
value={plan.dueDate} value={plan.dueDate}
onChange={(e) => onUpdatePlan(i, 'dueDate', e.target.value)} 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" className="rounded border border-silos-cream-dark px-2 py-1 text-xs text-silos-text"
/> />
</div> </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 { 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 { 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 { getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import CreateDropdown from '../create/CreateDropdown' import CreateDropdown from '../create/CreateDropdown'
import WorkspaceSidebar from './WorkspaceSidebar' import WorkspaceSidebar from './WorkspaceSidebar'
@ -12,6 +12,7 @@ import DocumentFlyout, { type FlyoutMode } from '../dashboard/DocumentFlyout'
import TemplatesFlyout from '../dashboard/TemplatesFlyout' import TemplatesFlyout from '../dashboard/TemplatesFlyout'
import { useDashboardDocuments } from '../../hooks/useDashboardDocuments' import { useDashboardDocuments } from '../../hooks/useDashboardDocuments'
import type { NavItemId } from '../../data/dashboardData' import type { NavItemId } from '../../data/dashboardData'
import NotificationBell from '../notifications/NotificationBell'
type WorkspaceLayout = 'default' | 'page' | 'canvas' | 'space' type WorkspaceLayout = 'default' | 'page' | 'canvas' | 'space'
@ -24,6 +25,12 @@ interface WorkspaceShellProps {
hideTitle?: boolean hideTitle?: boolean
titleReadOnly?: boolean titleReadOnly?: boolean
onTitleChange?: (title: string) => void onTitleChange?: (title: string) => void
onTitleCommit?: () => void
onTitleDoubleClick?: () => void
editing?: boolean
onEdit?: () => void
onUpdateShortcut?: () => void
onPublishShortcut?: () => void
presence?: { name: string; initial: string }[] presence?: { name: string; initial: string }[]
onCreateChild?: () => void onCreateChild?: () => void
onDuplicate?: () => void onDuplicate?: () => void
@ -40,6 +47,12 @@ export default function WorkspaceShell({
hideTitle = false, hideTitle = false,
titleReadOnly = false, titleReadOnly = false,
onTitleChange, onTitleChange,
onTitleCommit,
onTitleDoubleClick,
editing = false,
onEdit,
onUpdateShortcut,
onPublishShortcut,
presence = [], presence = [],
onCreateChild, onCreateChild,
onDuplicate, onDuplicate,
@ -52,14 +65,33 @@ export default function WorkspaceShell({
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [parentStead, setParentStead] = useState<DocumentRecord | null>(null) const [parentStead, setParentStead] = useState<DocumentRecord | null>(null)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [notifications, setNotifications] = useState<ApiNotification[]>([])
const [notificationsOpen, setNotificationsOpen] = useState(false)
const [workspaceFlyout, setWorkspaceFlyout] = useState<FlyoutMode | 'templates' | null>(null) const [workspaceFlyout, setWorkspaceFlyout] = useState<FlyoutMode | 'templates' | null>(null)
const titleRef = useRef<HTMLInputElement>(null)
const { recent, projects, starredProjects, refresh } = useDashboardDocuments() const { recent, projects, starredProjects, refresh } = useDashboardDocuments()
useEffect(() => { 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(() => { useEffect(() => {
if (!doc.steadId) { if (!doc.steadId) {
@ -173,7 +205,6 @@ export default function WorkspaceShell({
{user?.full_name?.[0] ?? 'Y'} {user?.full_name?.[0] ?? 'Y'}
</div> </div>
</div> </div>
<Users className="ml-1 hidden h-3.5 w-3.5 text-silos-muted sm:block" />
<button <button
type="button" type="button"
onClick={() => setShareOpen(true)} onClick={() => setShareOpen(true)}
@ -191,16 +222,7 @@ export default function WorkspaceShell({
> >
<Link2 className="h-3.5 w-3.5" /> <Link2 className="h-3.5 w-3.5" />
</button> </button>
<div className="relative"> <NotificationBell compact />
<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"> <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" /> <MoreHorizontal className="h-3.5 w-3.5" />
</button> </button>
@ -256,9 +278,29 @@ export default function WorkspaceShell({
{!hideTitle && ( {!hideTitle && (
<> <>
<input <input
ref={titleRef}
value={doc.title} value={doc.title}
readOnly={titleReadOnly} readOnly={titleReadOnly}
onChange={(e) => handleTitleChange(e.target.value)} 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 ${ className={`w-full border-none bg-transparent font-bold text-silos-text outline-none ${
titleReadOnly ? 'cursor-default' : '' titleReadOnly ? 'cursor-default' : ''
} ${isPage ? 'text-3xl' : 'text-2xl' } ${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 { Link, useNavigate } from 'react-router-dom'
import { import {
Archive, Archive,
@ -13,6 +13,9 @@ import {
Clock, Clock,
Star, Star,
FolderOpen, FolderOpen,
Folder,
Database,
ExternalLink,
Link2, Link2,
MoreHorizontal, MoreHorizontal,
PenTool, PenTool,
@ -24,6 +27,7 @@ import {
import { import {
createDocument, createDocument,
deleteDocument, deleteDocument,
getDefaultShedContent,
listDocuments, listDocuments,
listDocumentsByStead, listDocumentsByStead,
toggleStar, toggleStar,
@ -33,6 +37,8 @@ import {
import { getStoredUser } from '../../lib/api' import { getStoredUser } from '../../lib/api'
import type { CreateType } from '../../data/createData' import type { CreateType } from '../../data/createData'
import { navItems, type NavItemId } from '../../data/dashboardData' import { navItems, type NavItemId } from '../../data/dashboardData'
import CreateDropdown from '../create/CreateDropdown'
import { defaultSteadContent } from '../../data/steadData'
const typeIcons: Record<CreateType, typeof FileText> = { const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText, silo: FileText,
@ -73,11 +79,20 @@ export default function WorkspaceSidebar({
}: WorkspaceSidebarProps) { }: WorkspaceSidebarProps) {
const [docs, setDocs] = useState<DocumentRecord[]>([]) const [docs, setDocs] = useState<DocumentRecord[]>([])
const [steads, setSteads] = 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 [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [menuId, setMenuId] = useState<string | null>(null) 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 [renameId, setRenameId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const savingRename = useRef(false)
const [moveId, setMoveId] = useState<string | null>(null) const [moveId, setMoveId] = useState<string | null>(null)
const navigate = useNavigate() const navigate = useNavigate()
@ -137,22 +152,54 @@ export default function WorkspaceSidebar({
return descendants return descendants
} }
async function createChildPage(doc: DocumentRecord) { async function createFromMenu(
setCreatingChildFor(doc.id) source: DocumentRecord,
try { kind: 'live-doc' | 'page' | 'whiteboard' | 'database' | 'smart-link' | 'folder',
const child = await createDocument( ) {
'silo', setCreateMenuId(null)
'Untitled', setCreateMenuPosition(null)
{ body: '', bodyHtml: '', status: 'draft' }, 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, undefined,
getStoredUser()?.full_name ?? 'You', getStoredUser()?.full_name ?? 'You',
doc.steadId,
doc.id,
) )
navigate(`/workspace/silo/${child.id}`) navigate(`/workspace/stead/${created.id}`)
} finally { return
setCreatingChildFor(null)
} }
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) { function startRename(doc: DocumentRecord) {
@ -162,11 +209,20 @@ export default function WorkspaceSidebar({
} }
async function saveRename(doc: DocumentRecord) { async function saveRename(doc: DocumentRecord) {
if (savingRename.current) return
savingRename.current = true
const title = renameValue.trim() const title = renameValue.trim()
setRenameId(null) setRenameId(null)
if (!title || title === doc.title) return if (!title || title === doc.title) {
savingRename.current = false
return
}
try {
const updated = await updateDocument(doc.id, { title }) const updated = await updateDocument(doc.id, { title })
if (updated) setDocs((current) => current.map((item) => (item.id === doc.id ? updated : item))) if (updated) setDocs((current) => current.map((item) => (item.id === doc.id ? updated : item)))
} finally {
savingRename.current = false
}
} }
async function duplicatePage(doc: DocumentRecord) { async function duplicatePage(doc: DocumentRecord) {
@ -196,7 +252,26 @@ export default function WorkspaceSidebar({
if (!window.confirm(`Delete "${doc.title}" permanently?`)) return if (!window.confirm(`Delete "${doc.title}" permanently?`)) return
if (await deleteDocument(doc.id)) { if (await deleteDocument(doc.id)) {
setDocs((current) => current.filter((item) => item.id !== 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 autoFocus
value={renameValue} value={renameValue}
onChange={(event) => setRenameValue(event.target.value)} 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)} 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" 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}`} aria-label={`Rename ${doc.title}`}
@ -272,29 +355,95 @@ export default function WorkspaceSidebar({
</Link> </Link>
)} )}
{doc.type === 'silo' && !isRenaming && ( {!isRenaming && (
<button <button
type="button" type="button"
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() 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" 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}`} aria-label={`Create content under ${doc.title}`}
title="Create child page" title="Create content"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</button> </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 && ( {!isRenaming && (
<button <button
type="button" type="button"
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() 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" 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}`} aria-label={`More options for ${doc.title}`}
@ -305,7 +454,10 @@ export default function WorkspaceSidebar({
)} )}
{isMenuOpen && ( {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={() => 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={() => 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={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))} {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>
<div className="border-t border-silos-cream-dark p-2"> <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> </div>
{movingDoc && ( {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="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 { useCallback, useEffect, useMemo, useState } from 'react'
import { listDocuments, type DocumentRecord } from '../lib/documents' import { listDocuments, type DocumentRecord } from '../lib/documents'
import { sortByRecent } from '../lib/documentUtils' import { sortByRecent } from '../lib/documentUtils'
import { tenantApi } from '../lib/api' import { ApiError, tenantApi } from '../lib/api'
export function useDashboardDocuments() { export function useDashboardDocuments() {
const [documents, setDocuments] = useState<DocumentRecord[]>([]) const [documents, setDocuments] = useState<DocumentRecord[]>([])
@ -13,14 +13,24 @@ export function useDashboardDocuments() {
setError(null) setError(null)
try { try {
const workspaces = await tenantApi.listWorkspaces() 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) { if (selected) {
localStorage.setItem('silos_workspace_id', String(selected)) localStorage.setItem('silos_workspace_id', String(selected))
} }
const docs = await listDocuments(undefined, { sort: 'last_visited_at', workspaceId: selected }) const docs = await listDocuments(undefined, { sort: 'last_visited_at', workspaceId: selected })
setDocuments(docs) setDocuments(docs)
} catch { } catch (error) {
setError('Could not load documents. Is MongoDB running?') 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([]) setDocuments([])
} finally { } finally {
setLoading(false) setLoading(false)

View file

@ -137,6 +137,17 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
if (!response.ok) { 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) 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) => { const handleSSOSelect = (providerId: string) => {
if (providerId === 'google') {
window.location.href = authApi.googleLoginUrl()
return
}
setSelectedSSO(providerId) setSelectedSSO(providerId)
setActiveTab('sso') setActiveTab('sso')
setView('sso') setView('sso')

View file

@ -4,11 +4,11 @@ import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import ArchitectureReviewTemplate from '../../components/shed/templates/ArchitectureReviewTemplate' import ArchitectureReviewTemplate from '../../components/shed/templates/ArchitectureReviewTemplate'
import MeetingNotesTemplate from '../../components/shed/templates/MeetingNotesTemplate' import MeetingNotesTemplate from '../../components/shed/templates/MeetingNotesTemplate'
import RetrospectiveTemplate from '../../components/shed/templates/RetrospectiveTemplate' import RetrospectiveTemplate from '../../components/shed/templates/RetrospectiveTemplate'
import { getDocument, type DocumentRecord } from '../../lib/documents' import { getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { useRecordVisit } from '../../hooks/useRecordVisit' import { useRecordVisit } from '../../hooks/useRecordVisit'
import { useCollab } from '../../hooks/useCollab' import { useCollab } from '../../hooks/useCollab'
import { ApiError } from '../../lib/api' import { ApiError } from '../../lib/api'
import { clearDocumentDraft, restoreDocumentDraft, saveDocumentDraft } from '../../lib/documentDrafts'
function ShedTemplate({ function ShedTemplate({
doc, doc,
@ -33,6 +33,8 @@ export default function ShedPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null) const [doc, setDoc] = useState<DocumentRecord | null>(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
@ -42,7 +44,7 @@ export default function ShedPage() {
navigate('/dashboard') navigate('/dashboard')
return return
} }
setDoc(found) setDoc(restoreDocumentDraft(found))
}) })
.catch((err) => { .catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden') if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
@ -50,8 +52,10 @@ export default function ShedPage() {
}) })
}, [id, navigate]) }, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id) useRecordVisit(doc?.id)
useEffect(() => {
if (doc && editing) saveDocumentDraft(doc)
}, [doc, editing])
const collab = useCollab( const collab = useCollab(
doc?.id, doc?.id,
Boolean(doc && doc.templateId === 'meeting-notes'), Boolean(doc && doc.templateId === 'meeting-notes'),
@ -60,10 +64,63 @@ export default function ShedPage() {
if (!doc) return null 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 ( return (
<WorkspaceShell <WorkspaceShell
doc={doc} doc={doc}
onDocChange={setDoc} 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 }))} presence={collab.peers.map((p) => ({ name: p.name, initial: p.initial }))}
> >
<ShedTemplate <ShedTemplate

View file

@ -8,6 +8,7 @@ import { ApiError, documentsApi, type ApiDocumentVersion } from '../../lib/api'
import { formatRelativeTime } from '../../lib/documentUtils' import { formatRelativeTime } from '../../lib/documentUtils'
import CommentsPanel from '../../components/workspace/CommentsPanel' import CommentsPanel from '../../components/workspace/CommentsPanel'
import { Clock3, Download, MessageSquare, RotateCcw, X } from 'lucide-react' import { Clock3, Download, MessageSquare, RotateCcw, X } from 'lucide-react'
import { clearDocumentDraft, restoreDocumentDraft, saveDocumentDraft } from '../../lib/documentDrafts'
function siloHtml(content: Record<string, unknown>): string { function siloHtml(content: Record<string, unknown>): string {
if (typeof content.bodyHtml === 'string' && content.bodyHtml) return content.bodyHtml if (typeof content.bodyHtml === 'string' && content.bodyHtml) return content.bodyHtml
@ -26,6 +27,7 @@ export default function SiloPage() {
const [historyOpen, setHistoryOpen] = useState(false) const [historyOpen, setHistoryOpen] = useState(false)
const [history, setHistory] = useState<ApiDocumentVersion[]>([]) const [history, setHistory] = useState<ApiDocumentVersion[]>([])
const [historyError, setHistoryError] = useState<string | null>(null) const [historyError, setHistoryError] = useState<string | null>(null)
const [editPromptOpen, setEditPromptOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
@ -35,7 +37,7 @@ export default function SiloPage() {
navigate('/dashboard') navigate('/dashboard')
return return
} }
setDoc(found) setDoc(restoreDocumentDraft(found))
}) })
.catch((err) => { .catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden') if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
@ -46,16 +48,17 @@ export default function SiloPage() {
useRecordVisit(doc?.id) useRecordVisit(doc?.id)
useEffect(() => { useEffect(() => {
function onShortcut(event: KeyboardEvent) { if (doc && editing) saveDocumentDraft(doc)
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { }, [doc, editing])
event.preventDefault()
if (editing && !saving) void update() useEffect(() => {
} function onEscape(event: KeyboardEvent) {
if (event.key === 'Escape' && historyOpen) setHistoryOpen(false) if (event.key === 'Escape' && historyOpen) setHistoryOpen(false)
if (event.key === 'Escape' && editPromptOpen) setEditPromptOpen(false)
} }
window.addEventListener('keydown', onShortcut) window.addEventListener('keydown', onEscape)
return () => window.removeEventListener('keydown', onShortcut) return () => window.removeEventListener('keydown', onEscape)
}, [editing, saving, historyOpen]) }, [historyOpen, editPromptOpen])
if (!doc) return null if (!doc) return null
@ -77,6 +80,7 @@ export default function SiloPage() {
setSaving(false) setSaving(false)
if (updated) { if (updated) {
setDoc(updated) setDoc(updated)
clearDocumentDraft(updated.id)
setEditing(false) setEditing(false)
} else { } else {
setSaveError('Could not save this page. Check your connection and try again.') setSaveError('Could not save this page. Check your connection and try again.')
@ -94,6 +98,7 @@ export default function SiloPage() {
setSaving(false) setSaving(false)
if (updated) { if (updated) {
setDoc(updated) setDoc(updated)
clearDocumentDraft(updated.id)
setEditing(false) setEditing(false)
} else { } else {
setSaveError('Could not publish this page. Check your connection and try again.') setSaveError('Could not publish this page. Check your connection and try again.')
@ -117,7 +122,12 @@ export default function SiloPage() {
try { try {
await documentsApi.restoreHistory(doc.id, version.id) await documentsApi.restoreHistory(doc.id, version.id)
const restored = await getDocument(doc.id) const restored = await getDocument(doc.id)
if (!restored) {
setHistoryError('Could not load the restored page.')
return
}
setDoc(restored) setDoc(restored)
clearDocumentDraft(restored.id)
setHistoryOpen(false) setHistoryOpen(false)
setEditing(false) setEditing(false)
} catch (err) { } catch (err) {
@ -156,13 +166,19 @@ export default function SiloPage() {
async function archivePage() { async function archivePage() {
if (!doc) return if (!doc) return
const updated = await updateDocument(doc.id, { content: { ...doc.content, archived: true, archivedAt: new Date().toISOString() } }) 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.') else setSaveError('Could not archive this page. Check your connection and try again.')
} }
async function removePage() { async function removePage() {
if (!doc || !window.confirm('Delete this page permanently?')) return 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.') else setSaveError('Could not delete this page. Check your connection and try again.')
} }
@ -175,26 +191,38 @@ export default function SiloPage() {
onDocChange={setDoc} onDocChange={setDoc}
layout="page" layout="page"
titleReadOnly={!editing} titleReadOnly={!editing}
editing={editing}
onEdit={() => setEditing(true)}
onUpdateShortcut={() => { if (!saving) void update() }}
onPublishShortcut={() => { if (!saving) void publish() }}
onTitleChange={(title) => onTitleChange={(title) =>
setDoc((prev) => (prev ? { ...prev, title } : prev)) setDoc((prev) => (prev ? { ...prev, title } : prev))
} }
onTitleCommit={() => {
if (editing && !saving) void update()
}}
onTitleDoubleClick={() => setEditing(true)}
headerActions={ headerActions={
<> <>
{!editing ? ( {!editing ? (
<button <button
type="button" type="button"
onClick={() => setEditing(true)} 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" 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>
) : ( ) : (
<> <>
<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"> <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'} <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>
<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"> <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'} <span>{status === 'published' ? 'Unpublish' : 'Publish'}</span>
<kbd className="ml-2 rounded border border-white/30 px-1 text-[10px] font-normal">⌘/Ctrl ↵</kbd>
</button> </button>
</> </>
)} )}
@ -243,9 +271,51 @@ export default function SiloPage() {
</div> </div>
{saveError && <span className="text-red-700">{saveError}</span>} {saveError && <span className="text-red-700">{saveError}</span>}
</div> </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} /> <SiloEditor key={doc.id} html={siloHtml(doc.content)} onChange={patchHtml} editable={editing} />
</div> </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)} />} {commentsOpen && <CommentsPanel documentId={doc.id} onClose={() => setCommentsOpen(false)} />}
{historyOpen && ( {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"> <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 { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell' import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SketchCanvas from '../../components/sketch/SketchCanvas' import SketchCanvas from '../../components/sketch/SketchCanvas'
@ -11,6 +11,7 @@ import {
type SketchElement, type SketchElement,
type SketchViewport, type SketchViewport,
} from '../../types/sketch' } from '../../types/sketch'
import { clearDocumentDraft, restoreDocumentDraft, saveDocumentDraft } from '../../lib/documentDrafts'
export default function SketchPage() { export default function SketchPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@ -18,6 +19,7 @@ export default function SketchPage() {
const [doc, setDoc] = useState<DocumentRecord | null>(null) const [doc, setDoc] = useState<DocumentRecord | null>(null)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const titleRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
@ -27,7 +29,7 @@ export default function SketchPage() {
navigate('/dashboard') navigate('/dashboard')
return return
} }
setDoc(found) setDoc(restoreDocumentDraft(found))
}) })
.catch((err) => { .catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden') if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
@ -37,6 +39,10 @@ export default function SketchPage() {
useRecordVisit(doc?.id) useRecordVisit(doc?.id)
useEffect(() => {
if (doc && editing) saveDocumentDraft(doc)
}, [doc, editing])
const sketchContent = useMemo( const sketchContent = useMemo(
() => (doc ? normalizeSketchContent(doc.content) : { elements: [] }), () => (doc ? normalizeSketchContent(doc.content) : { elements: [] }),
[doc?.content], [doc?.content],
@ -56,6 +62,7 @@ export default function SketchPage() {
setSaving(false) setSaving(false)
if (updated) { if (updated) {
setDoc(updated) setDoc(updated)
clearDocumentDraft(updated.id)
setEditing(false) setEditing(false)
} }
} }
@ -70,6 +77,7 @@ export default function SketchPage() {
setSaving(false) setSaving(false)
if (updated) { if (updated) {
setDoc(updated) setDoc(updated)
clearDocumentDraft(updated.id)
setEditing(false) setEditing(false)
} }
} }
@ -80,6 +88,10 @@ export default function SketchPage() {
onDocChange={setDoc} onDocChange={setDoc}
layout="canvas" layout="canvas"
hideTitle hideTitle
editing={editing}
onEdit={() => setEditing(true)}
onUpdateShortcut={() => { if (!saving) void update() }}
onPublishShortcut={() => { if (!saving) void publish() }}
headerActions={ headerActions={
!editing ? ( !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"> <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 h-full flex-col">
<div className="flex items-center border-b border-silos-cream-dark px-6 py-3"> <div className="flex items-center border-b border-silos-cream-dark px-6 py-3">
<input <input
ref={titleRef}
value={doc.title} value={doc.title}
readOnly={!editing} readOnly={!editing}
onChange={(e) => setDoc((prev) => (prev ? { ...prev, title: e.target.value } : prev))} 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" className="border-none bg-transparent text-lg font-semibold text-silos-text outline-none read-only:cursor-default"
placeholder="Untitled whiteboard" placeholder="Untitled whiteboard"
/> />

View file

@ -4,14 +4,17 @@ import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SteadHub from '../../components/stead/SteadHub' import SteadHub from '../../components/stead/SteadHub'
import { defaultSteadContent, type SteadContent } from '../../data/steadData' import { defaultSteadContent, type SteadContent } from '../../data/steadData'
import { getDocument, type DocumentRecord } from '../../lib/documents' import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave' import { updateDocument } from '../../lib/documents'
import { useRecordVisit } from '../../hooks/useRecordVisit' import { useRecordVisit } from '../../hooks/useRecordVisit'
import { ApiError } from '../../lib/api' import { ApiError } from '../../lib/api'
import { clearDocumentDraft, restoreDocumentDraft, saveDocumentDraft } from '../../lib/documentDrafts'
export default function SteadPage() { export default function SteadPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null) const [doc, setDoc] = useState<DocumentRecord | null>(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
@ -26,7 +29,7 @@ export default function SteadPage() {
navigate('/workspace/stead/new') navigate('/workspace/stead/new')
return return
} }
setDoc(found) setDoc(restoreDocumentDraft(found))
}) })
.catch((err) => { .catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden') if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
@ -34,11 +37,42 @@ export default function SteadPage() {
}) })
}, [id, navigate]) }, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id) useRecordVisit(doc?.id)
useEffect(() => {
if (doc && editing) saveDocumentDraft(doc)
}, [doc, editing])
if (!doc) return null 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 = { const content: SteadContent = {
...defaultSteadContent(), ...defaultSteadContent(),
...(doc.content as unknown as Partial<SteadContent>), ...(doc.content as unknown as Partial<SteadContent>),
@ -65,13 +99,45 @@ export default function SteadPage() {
} }
return ( 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 <SteadHub
doc={doc} doc={doc}
content={content} content={content}
onSectionChange={(section) => patchContent({ activeSection: section })} onSectionChange={(section) => patchContent({ activeSection: section })}
onAddPlan={handleAddPlan} onAddPlan={handleAddPlan}
onUpdatePlan={handleUpdatePlan} onUpdatePlan={handleUpdatePlan}
editing={editing}
onTitleChange={(title) => setDoc((prev) => (prev ? { ...prev, title } : prev))}
onTitleDoubleClick={() => setEditing(true)}
onTitleCommit={() => { if (editing && !saving) void update() }}
/> />
</WorkspaceShell> </WorkspaceShell>
) )