feat: add personal workspace creation for legacy users and invitation landing page

This commit is contained in:
Anurag Jangid 2026-10-23 02:09:59 +05:30
parent bf5cd44dc1
commit e905184c7f
12 changed files with 240 additions and 21 deletions

View file

@ -265,6 +265,8 @@ npm run build
- Added workspace-scoped MongoDB document metadata and dashboard filtering. - Added workspace-scoped MongoDB document metadata and dashboard filtering.
- Existing owner-owned spaces and pages are backfilled into the selected - Existing owner-owned spaces and pages are backfilled into the selected
workspace on first workspace load without changing their content or sharing. workspace on first workspace load without changing their content or sharing.
- Legacy users without a tenant are automatically given a personal
organization and workspace, so Invite Fellow works immediately after login.
- New document creation rejects workspace IDs the authenticated user cannot - New document creation rejects workspace IDs the authenticated user cannot
access. access.
- Added a workspace administration page at `/settings/workspaces` for - Added a workspace administration page at `/settings/workspaces` for
@ -276,6 +278,7 @@ npm run build
- Removed the unused Customize sidebar action and its controls. - Removed the unused Customize sidebar action and its controls.
- Updated Invite Fellow to create a workspace invitation and open a prefilled - Updated Invite Fellow to create a workspace invitation and open a prefilled
email containing the invitation link. email containing the invitation link.
- Added the invitation landing page at `/invite/:token`.
Validation completed: Validation completed:
@ -292,3 +295,28 @@ Next tenant work:
- Add invitation and audit history views to the administration page. - Add invitation and audit history views to the administration page.
- Add organization-level security settings. - Add organization-level security settings.
- Add authenticated browser coverage for workspace creation and role denial. - Add authenticated browser coverage for workspace creation and role denial.
#### Workspace navigation polish
- Workspace and page views now retain the primary dashboard navigation:
For you, Templates, Recent, Favourite Projects, and All Projects.
- The navigation appears above the current space/page tree instead of replacing
the dashboard experience; the duplicate Dashboard button was removed because
the workspace header already provides that route.
- Space views now keep the sidebar visible, with the active space and its
content shown beneath the primary navigation.
- Navigating to a child page now automatically keeps its ancestor pages
expanded in the tree.
- New child pages are created as `Untitled` so users can name them directly.
- Template content now stays isolated to the page created from that template;
switching between pages remounts the editor for the active document instead
of reusing stale content from the previous page.
- Workspace sidebar navigation now opens the corresponding dashboard view:
Recent, Favourite Projects, All Projects, and Templates no longer all open
the default dashboard state.
- Workspace navigation now opens those flyouts in place without leaving the
current page or space. Dashboard and workspace navigation use matching
sidebar width, button height, padding, and text sizing.
- Workspace navigation now mirrors dashboard controls: “For you” links to the
dashboard, while Recent, Favourite Projects, All Projects, and Templates
use trailing chevrons and active flyout states.

View file

@ -28,6 +28,7 @@ from app.services.tenant_service import (
list_members, list_members,
list_workspaces, list_workspaces,
update_member_role, update_member_role,
ensure_personal_workspace,
) )
router = APIRouter(prefix="/tenants", tags=["tenants"]) router = APIRouter(prefix="/tenants", tags=["tenants"])
@ -48,6 +49,7 @@ def workspace_response(row) -> WorkspaceResponse:
@router.get("/workspaces", response_model=list[WorkspaceResponse]) @router.get("/workspaces", response_model=list[WorkspaceResponse])
def get_workspaces(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def get_workspaces(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
ensure_personal_workspace(db, current_user)
return [workspace_response(row) for row in list_workspaces(db, current_user.id)] return [workspace_response(row) for row in list_workspaces(db, current_user.id)]

View file

@ -53,6 +53,37 @@ def create_workspace(db: Session, owner: User, data) -> Workspace:
return workspace return workspace
def ensure_personal_workspace(db: Session, owner: User) -> Workspace:
existing = (
db.query(Workspace)
.join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id)
.filter(WorkspaceMembership.user_id == owner.id)
.order_by(Workspace.created_at.asc())
.first()
)
if existing is not None:
return existing
label = (owner.full_name or owner.username or "Personal").strip()
org_slug = f"{slugify(owner.username)}-personal"
if db.query(Organization).filter_by(slug=org_slug).first():
org_slug = f"{org_slug}-{secrets.token_hex(3)}"
organization = Organization(name=f"{label}'s organization", slug=org_slug, owner_id=owner.id)
db.add(organization)
db.flush()
workspace = Workspace(
organization_id=organization.id,
name="Personal workspace",
slug="personal-workspace",
)
db.add(workspace)
db.flush()
db.add(WorkspaceMembership(workspace_id=workspace.id, user_id=owner.id, role="owner"))
audit(db, organization.id, workspace.id, owner.id, "workspace.bootstrapped", workspace.name)
db.commit()
db.refresh(workspace)
return workspace
def list_workspaces(db: Session, user_id: int): def list_workspaces(db: Session, user_id: int):
return ( return (
db.query(Workspace, Organization, WorkspaceMembership.role) db.query(Workspace, Organization, WorkspaceMembership.role)

View file

@ -12,6 +12,7 @@ import ForbiddenPage from './pages/ForbiddenPage'
import ProtectedRoute from './components/ProtectedRoute' import ProtectedRoute from './components/ProtectedRoute'
import AccountPage from './pages/AccountPage' import AccountPage from './pages/AccountPage'
import WorkspaceAdminPage from './pages/WorkspaceAdminPage' import WorkspaceAdminPage from './pages/WorkspaceAdminPage'
import InvitationPage from './pages/InvitationPage'
import { authApi, saveAuth } from './lib/api' import { authApi, saveAuth } from './lib/api'
function GoogleCallback() { function GoogleCallback() {
@ -79,6 +80,7 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route path="/invite/:token" element={<InvitationPage />} />
<Route path="/auth/google/callback" element={<GoogleCallback />} /> <Route path="/auth/google/callback" element={<GoogleCallback />} />
<Route <Route
path="/forbidden" path="/forbidden"

View file

@ -1,4 +1,5 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import DashboardHeader from './DashboardHeader' import DashboardHeader from './DashboardHeader'
import DashboardSidebar from './DashboardSidebar' import DashboardSidebar from './DashboardSidebar'
import DashboardFeed from './DashboardFeed' import DashboardFeed from './DashboardFeed'
@ -15,10 +16,18 @@ const flyoutMap: Partial<Record<NavItemId, FlyoutMode | 'templates'>> = {
} }
export default function DashboardLayout() { export default function DashboardLayout() {
const [searchParams] = useSearchParams()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [activeNav, setActiveNav] = useState<NavItemId>('for-you') const [activeNav, setActiveNav] = useState<NavItemId>('for-you')
const [flyout, setFlyout] = useState<FlyoutMode | 'templates' | null>(null) const [flyout, setFlyout] = useState<FlyoutMode | 'templates' | null>(null)
useEffect(() => {
const view = searchParams.get('view') as NavItemId | null
if (!view || !(view in flyoutMap)) return
setActiveNav(view)
setFlyout(flyoutMap[view] ?? null)
}, [searchParams])
const { recent, projects, starredProjects, loading, error, refresh } = const { recent, projects, starredProjects, loading, error, refresh } =
useDashboardDocuments() useDashboardDocuments()

View file

@ -65,7 +65,7 @@ export default function DashboardSidebar({
if (collapsed) { if (collapsed) {
return ( return (
<aside className="relative z-30 flex w-14 shrink-0 flex-col items-center border-r border-silos-cream-dark bg-white py-4"> <aside className="relative z-30 flex w-12 shrink-0 flex-col items-center border-r border-silos-cream-dark bg-white py-3">
{navItems.map((item) => { {navItems.map((item) => {
const Icon = iconMap[item.icon] const Icon = iconMap[item.icon]
const hasFlyout = flyoutNavIds.includes(item.id) const hasFlyout = flyoutNavIds.includes(item.id)
@ -75,7 +75,7 @@ export default function DashboardSidebar({
key={item.id} key={item.id}
onClick={() => onNavChange(item.id)} onClick={() => onNavChange(item.id)}
title={item.label} title={item.label}
className={`mb-1 rounded-lg p-2.5 transition-colors ${ className={`mb-1 rounded-lg p-2 transition-colors ${
isActive isActive
? 'bg-silos-brown/10 text-silos-brown' ? 'bg-silos-brown/10 text-silos-brown'
: 'text-silos-muted hover:bg-silos-cream hover:text-silos-text' : 'text-silos-muted hover:bg-silos-cream hover:text-silos-text'
@ -90,8 +90,8 @@ export default function DashboardSidebar({
} }
return ( return (
<aside className="relative z-30 flex w-60 shrink-0 flex-col border-r border-silos-cream-dark bg-white"> <aside className="relative z-30 flex w-64 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
<nav className="flex-1 overflow-y-auto p-3"> <nav className="flex-1 overflow-y-auto p-2">
{navItems.map((item) => { {navItems.map((item) => {
const Icon = iconMap[item.icon] const Icon = iconMap[item.icon]
const hasFlyout = flyoutNavIds.includes(item.id) const hasFlyout = flyoutNavIds.includes(item.id)
@ -101,7 +101,7 @@ export default function DashboardSidebar({
<button <button
key={item.id} key={item.id}
onClick={() => onNavChange(item.id)} onClick={() => onNavChange(item.id)}
className={`mb-0.5 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${ className={`mb-0.5 flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-sm transition-colors ${
isActive isActive
? 'bg-silos-brown/10 font-medium text-silos-brown' ? 'bg-silos-brown/10 font-medium text-silos-brown'
: 'text-silos-text hover:bg-silos-cream' : 'text-silos-text hover:bg-silos-cream'
@ -124,7 +124,7 @@ export default function DashboardSidebar({
<button <button
onClick={() => setPersonalOpen(!personalOpen)} onClick={() => setPersonalOpen(!personalOpen)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream" className="flex w-full items-center justify-between rounded-lg px-2 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"
> >
Personal Space Personal Space
{personalOpen ? ( {personalOpen ? (
@ -143,7 +143,7 @@ export default function DashboardSidebar({
<Link <Link
key={space.id} key={space.id}
to={`/workspace/stead/${space.id}`} to={`/workspace/stead/${space.id}`}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-silos-text transition-colors hover:bg-silos-cream" className="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-sm text-silos-text transition-colors hover:bg-silos-cream"
> >
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-silos-tan/20 text-xs"> <span className="flex h-6 w-6 items-center justify-center rounded-md bg-silos-tan/20 text-xs">
{(space.content.icon as string) || '📁'} {(space.content.icon as string) || '📁'}
@ -154,7 +154,7 @@ export default function DashboardSidebar({
)} )}
<Link <Link
to="/workspace/stead/new" to="/workspace/stead/new"
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-brown" className="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-sm text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-brown"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add space Add space
@ -163,7 +163,7 @@ export default function DashboardSidebar({
)} )}
</nav> </nav>
<div className="space-y-2 border-t border-silos-cream-dark p-3"> <div className="space-y-2 border-t border-silos-cream-dark p-2">
<button onClick={() => setInviteOpen(true)} className="flex w-full items-center justify-center gap-2 rounded-xl border border-silos-cream-dark bg-silos-cream/40 py-2.5 text-sm font-medium text-silos-text transition-colors hover:bg-silos-cream"> <button onClick={() => setInviteOpen(true)} className="flex w-full items-center justify-center gap-2 rounded-xl border border-silos-cream-dark bg-silos-cream/40 py-2.5 text-sm font-medium text-silos-text transition-colors hover:bg-silos-cream">
<UserPlus className="h-4 w-4" /> <UserPlus className="h-4 w-4" />
Invite Fellow Invite Fellow

View file

@ -25,6 +25,7 @@ interface DocumentFlyoutProps {
mode: FlyoutMode mode: FlyoutMode
documents: DocumentRecord[] documents: DocumentRecord[]
sidebarCollapsed: boolean sidebarCollapsed: boolean
workspace?: boolean
onClose: () => void onClose: () => void
onStarChange?: () => void onStarChange?: () => void
} }
@ -39,6 +40,7 @@ export default function DocumentFlyout({
mode, mode,
documents, documents,
sidebarCollapsed, sidebarCollapsed,
workspace = false,
onClose, onClose,
onStarChange, onStarChange,
}: DocumentFlyoutProps) { }: DocumentFlyoutProps) {
@ -70,7 +72,7 @@ export default function DocumentFlyout({
<div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} /> <div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} />
<aside <aside
className={`fixed top-12 z-50 flex h-[calc(100vh-3rem)] w-80 flex-col border-r border-silos-cream-dark bg-white shadow-xl sm:w-96 ${ className={`fixed top-12 z-50 flex h-[calc(100vh-3rem)] w-80 flex-col border-r border-silos-cream-dark bg-white shadow-xl sm:w-96 ${
sidebarCollapsed ? 'left-14' : 'left-60' workspace ? 'left-64' : sidebarCollapsed ? 'left-12' : 'left-64'
}`} }`}
> >
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3"> <div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">

View file

@ -14,10 +14,11 @@ import { templatesApi, type ApiTemplate } from '../../lib/api'
interface TemplatesFlyoutProps { interface TemplatesFlyoutProps {
sidebarCollapsed: boolean sidebarCollapsed: boolean
workspace?: boolean
onClose: () => void onClose: () => void
} }
export default function TemplatesFlyout({ sidebarCollapsed, onClose }: TemplatesFlyoutProps) { export default function TemplatesFlyout({ sidebarCollapsed, workspace = false, onClose }: TemplatesFlyoutProps) {
const navigate = useNavigate() const navigate = useNavigate()
const author = getStoredUser()?.full_name ?? 'You' const author = getStoredUser()?.full_name ?? 'You'
const [customTemplates, setCustomTemplates] = useState<ApiTemplate[]>([]) const [customTemplates, setCustomTemplates] = useState<ApiTemplate[]>([])
@ -133,7 +134,7 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
<div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} /> <div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} />
<aside <aside
className={`fixed top-12 z-50 flex h-[calc(100vh-3rem)] w-80 flex-col border-r border-silos-cream-dark bg-white shadow-xl sm:w-96 ${ className={`fixed top-12 z-50 flex h-[calc(100vh-3rem)] w-80 flex-col border-r border-silos-cream-dark bg-white shadow-xl sm:w-96 ${
sidebarCollapsed ? 'left-14' : 'left-60' workspace ? 'left-64' : sidebarCollapsed ? 'left-12' : 'left-64'
}`} }`}
> >
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3"> <div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">

View file

@ -8,6 +8,10 @@ import CreateDropdown from '../create/CreateDropdown'
import WorkspaceSidebar from './WorkspaceSidebar' import WorkspaceSidebar from './WorkspaceSidebar'
import ShareModal from './ShareModal' import ShareModal from './ShareModal'
import SearchOverlay from '../search/SearchOverlay' import SearchOverlay from '../search/SearchOverlay'
import DocumentFlyout, { type FlyoutMode } from '../dashboard/DocumentFlyout'
import TemplatesFlyout from '../dashboard/TemplatesFlyout'
import { useDashboardDocuments } from '../../hooks/useDashboardDocuments'
import type { NavItemId } from '../../data/dashboardData'
type WorkspaceLayout = 'default' | 'page' | 'canvas' | 'space' type WorkspaceLayout = 'default' | 'page' | 'canvas' | 'space'
@ -50,6 +54,8 @@ export default function WorkspaceShell({
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [notifications, setNotifications] = useState<ApiNotification[]>([]) const [notifications, setNotifications] = useState<ApiNotification[]>([])
const [notificationsOpen, setNotificationsOpen] = useState(false) const [notificationsOpen, setNotificationsOpen] = useState(false)
const [workspaceFlyout, setWorkspaceFlyout] = useState<FlyoutMode | 'templates' | null>(null)
const { recent, projects, starredProjects, refresh } = useDashboardDocuments()
useEffect(() => { useEffect(() => {
void documentsApi.listNotifications().then(setNotifications).catch(() => setNotifications([])) void documentsApi.listNotifications().then(setNotifications).catch(() => setNotifications([]))
@ -96,6 +102,23 @@ export default function WorkspaceShell({
const isCanvas = layout === 'canvas' const isCanvas = layout === 'canvas'
const isSpace = layout === 'space' const isSpace = layout === 'space'
const isPage = layout === 'page' const isPage = layout === 'page'
const flyoutDocuments =
workspaceFlyout === 'recent'
? recent
: workspaceFlyout === 'favourites'
? starredProjects
: workspaceFlyout === 'all-projects'
? projects
: []
function handleWorkspaceNav(id: NavItemId) {
if (id === 'for-you') {
setWorkspaceFlyout(null)
return
}
const mode = id === 'templates' ? 'templates' : id
setWorkspaceFlyout((current) => (current === mode ? null : mode))
}
return ( return (
<div className="flex h-screen flex-col bg-silos-cream"> <div className="flex h-screen flex-col bg-silos-cream">
@ -201,8 +224,29 @@ export default function WorkspaceShell({
)} )}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{!isSpace && ( <WorkspaceSidebar
<WorkspaceSidebar collapsed={false} activeDocId={doc.id} steadId={doc.steadId} /> collapsed={false}
activeDocId={doc.id}
steadId={doc.type === 'stead' ? doc.id : doc.steadId}
onNavChange={handleWorkspaceNav}
activeNav={workspaceFlyout === 'templates' ? 'templates' : workspaceFlyout}
/>
{workspaceFlyout && workspaceFlyout !== 'templates' && (
<DocumentFlyout
mode={workspaceFlyout}
documents={flyoutDocuments}
sidebarCollapsed={false}
workspace
onClose={() => setWorkspaceFlyout(null)}
onStarChange={refresh}
/>
)}
{workspaceFlyout === 'templates' && (
<TemplatesFlyout
sidebarCollapsed={false}
workspace
onClose={() => setWorkspaceFlyout(null)}
/>
)} )}
<main className={`flex-1 bg-white ${isCanvas || isSpace ? 'overflow-hidden' : 'overflow-y-auto'}`}> <main className={`flex-1 bg-white ${isCanvas || isSpace ? 'overflow-hidden' : 'overflow-y-auto'}`}>
{isCanvas || isSpace ? ( {isCanvas || isSpace ? (

View file

@ -8,11 +8,15 @@ import {
Copy, Copy,
FileText, FileText,
LayoutGrid, LayoutGrid,
Home,
LayoutTemplate,
Clock,
Star,
FolderOpen,
Link2, Link2,
MoreHorizontal, MoreHorizontal,
PenTool, PenTool,
Plus, Plus,
Star,
Trash2, Trash2,
X, X,
Zap, Zap,
@ -28,6 +32,7 @@ import {
} from '../../lib/documents' } from '../../lib/documents'
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'
const typeIcons: Record<CreateType, typeof FileText> = { const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText, silo: FileText,
@ -43,13 +48,29 @@ const typeColors: Record<CreateType, string> = {
stead: '#9B7BB8', stead: '#9B7BB8',
} }
const navigationIcons = {
'for-you': Home,
templates: LayoutTemplate,
recent: Clock,
favourites: Star,
'all-projects': FolderOpen,
} as const
interface WorkspaceSidebarProps { interface WorkspaceSidebarProps {
collapsed: boolean collapsed: boolean
activeDocId: string activeDocId: string
steadId?: string steadId?: string
onNavChange?: (id: (typeof navItems)[number]['id']) => void
activeNav?: NavItemId | null
} }
export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: WorkspaceSidebarProps) { export default function WorkspaceSidebar({
collapsed,
activeDocId,
steadId,
onNavChange,
activeNav = null,
}: 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 [creatingChildFor, setCreatingChildFor] = useState<string | null>(null)
@ -90,6 +111,20 @@ export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: Wo
return groups return groups
}, {}) }, {})
useEffect(() => {
if (!activeDocId || docs.length === 0) return
const byId = new Map(docs.map((doc) => [doc.id, doc]))
const ancestors: Record<string, boolean> = {}
let current = byId.get(activeDocId)
while (current?.parentPageId) {
ancestors[current.parentPageId] = true
current = byId.get(current.parentPageId)
}
if (Object.keys(ancestors).length > 0) {
setExpanded((previous) => ({ ...previous, ...ancestors }))
}
}, [activeDocId, docs])
function descendantsOf(id: string): Set<string> { function descendantsOf(id: string): Set<string> {
const descendants = new Set<string>() const descendants = new Set<string>()
const visit = (parentId: string) => { const visit = (parentId: string) => {
@ -107,7 +142,7 @@ export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: Wo
try { try {
const child = await createDocument( const child = await createDocument(
'silo', 'silo',
`Child of ${doc.title}`, 'Untitled',
{ body: '', bodyHtml: '', status: 'draft' }, { body: '', bodyHtml: '', status: 'draft' },
undefined, undefined,
getStoredUser()?.full_name ?? 'You', getStoredUser()?.full_name ?? 'You',
@ -305,7 +340,42 @@ export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: Wo
return ( return (
<aside className="flex w-64 shrink-0 flex-col border-r border-silos-cream-dark bg-white"> <aside className="flex w-64 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
<div className="border-b border-silos-cream-dark p-3"> <div className="border-b border-silos-cream-dark p-3">
<Link to="/dashboard" className="text-xs font-medium text-silos-muted hover:text-silos-brown">← Back to Dashboard</Link> <nav aria-label="Workspace navigation" className="space-y-0.5">
{navItems.map((item) => {
const Icon = navigationIcons[item.id]
const isForYou = item.id === 'for-you'
const isActive = activeNav === item.id
const className = `mb-0.5 flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-sm transition-colors ${
isActive
? 'bg-silos-brown/10 font-medium text-silos-brown'
: 'text-silos-text hover:bg-silos-cream hover:text-silos-brown'
}`
if (isForYou) {
return (
<Link key={item.id} to="/dashboard" className={className}>
<Icon className="h-4 w-4 text-silos-muted" />
<span className="flex-1 text-left">{item.label}</span>
</Link>
)
}
return (
<button
key={item.id}
type="button"
onClick={() => onNavChange?.(item.id)}
className={className}
>
<Icon className="h-4 w-4 text-silos-muted" />
<span className="flex-1 text-left">{item.label}</span>
<ChevronRight
className={`h-4 w-4 shrink-0 text-silos-muted transition-transform ${
isActive ? 'rotate-90' : ''
}`}
/>
</button>
)
})}
</nav>
</div> </div>
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
{parent && <Link to={`/workspace/stead/${parent.id}`} className="mb-2 flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"><LayoutGrid className="h-3.5 w-3.5 text-silos-tan" /><span className="truncate">{parent.title}</span></Link>} {parent && <Link to={`/workspace/stead/${parent.id}`} className="mb-2 flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"><LayoutGrid className="h-3.5 w-3.5 text-silos-tan" /><span className="truncate">{parent.title}</span></Link>}

View file

@ -0,0 +1,30 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { tenantApi } from '../lib/api'
export default function InvitationPage() {
const { token = '' } = useParams()
const [message, setMessage] = useState('Sign in with the invited email, then accept this invitation.')
const [accepted, setAccepted] = useState(false)
async function accept() {
try {
await tenantApi.acceptInvitation(token)
setAccepted(true)
setMessage('Invitation accepted. You can now open the workspace from your dashboard.')
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Could not accept this invitation.')
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-silos-cream px-4">
<section className="w-full max-w-md rounded-2xl border border-silos-cream-dark bg-white p-7 text-center shadow-sm">
<h1 className="text-2xl font-semibold text-silos-text">You are invited to Silos</h1>
<p className="mt-3 text-sm text-silos-muted">{message}</p>
{!accepted && <button type="button" onClick={() => void accept()} className="mt-6 rounded-xl bg-silos-brown px-5 py-2.5 text-sm font-semibold text-white">Accept invitation</button>}
<div className="mt-5"><Link to="/login" className="text-sm text-silos-brown hover:underline">Go to sign in</Link></div>
</section>
</main>
)
}

View file

@ -143,7 +143,7 @@ export default function SiloPage() {
async function createChildPage() { async function createChildPage() {
if (!doc) return if (!doc) return
const child = await createDocument('silo', `Child of ${doc.title}`, { body: '', bodyHtml: '', status: 'draft' }, undefined, doc.createdBy, doc.steadId, doc.id) const child = await createDocument('silo', 'Untitled', { body: '', bodyHtml: '', status: 'draft' }, undefined, doc.createdBy, doc.steadId, doc.id)
navigate(`/workspace/silo/${child.id}`) navigate(`/workspace/silo/${child.id}`)
} }
@ -244,7 +244,7 @@ export default function SiloPage() {
{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">
<SiloEditor html={siloHtml(doc.content)} onChange={patchHtml} editable={editing} /> <SiloEditor key={doc.id} html={siloHtml(doc.content)} onChange={patchHtml} editable={editing} />
</div> </div>
{commentsOpen && <CommentsPanel documentId={doc.id} onClose={() => setCommentsOpen(false)} />} {commentsOpen && <CommentsPanel documentId={doc.id} onClose={() => setCommentsOpen(false)} />}
{historyOpen && ( {historyOpen && (