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:
parent
e905184c7f
commit
eed9504a53
21 changed files with 891 additions and 125 deletions
|
|
@ -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 />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ export default function DashboardLayout() {
|
|||
setActiveNav('templates')
|
||||
setFlyout('templates')
|
||||
}}
|
||||
onOpenProjects={() => {
|
||||
setActiveNav('all-projects')
|
||||
setFlyout('all-projects')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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` }}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
76
frontend/src/components/notifications/NotificationBell.tsx
Normal file
76
frontend/src/components/notifications/NotificationBell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
51
frontend/src/lib/documentDrafts.ts
Normal file
51
frontend/src/lib/documentDrafts.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue