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
61
README.md
61
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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` }}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
undefined,
|
const parentPageId = source.type === 'silo' ? source.id : undefined
|
||||||
getStoredUser()?.full_name ?? 'You',
|
if (kind === 'folder' || kind === 'database') {
|
||||||
doc.steadId,
|
const created = await createDocument(
|
||||||
doc.id,
|
'stead',
|
||||||
)
|
kind === 'folder' ? 'Untitled Folder' : 'Untitled Database',
|
||||||
navigate(`/workspace/silo/${child.id}`)
|
{ ...defaultSteadContent(), setupComplete: true },
|
||||||
} finally {
|
undefined,
|
||||||
setCreatingChildFor(null)
|
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) {
|
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) {
|
||||||
const updated = await updateDocument(doc.id, { title })
|
savingRename.current = false
|
||||||
if (updated) setDocs((current) => current.map((item) => (item.id === doc.id ? updated : item)))
|
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) {
|
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">
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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) => {
|
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')
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue