feat: add personal workspace creation for legacy users and invitation landing page
This commit is contained in:
parent
bf5cd44dc1
commit
e905184c7f
12 changed files with 240 additions and 21 deletions
28
README.md
28
README.md
|
|
@ -265,6 +265,8 @@ npm run build
|
|||
- Added workspace-scoped MongoDB document metadata and dashboard filtering.
|
||||
- Existing owner-owned spaces and pages are backfilled into the selected
|
||||
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
|
||||
access.
|
||||
- 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.
|
||||
- Updated Invite Fellow to create a workspace invitation and open a prefilled
|
||||
email containing the invitation link.
|
||||
- Added the invitation landing page at `/invite/:token`.
|
||||
|
||||
Validation completed:
|
||||
|
||||
|
|
@ -292,3 +295,28 @@ Next tenant work:
|
|||
- Add invitation and audit history views to the administration page.
|
||||
- Add organization-level security settings.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from app.services.tenant_service import (
|
|||
list_members,
|
||||
list_workspaces,
|
||||
update_member_role,
|
||||
ensure_personal_workspace,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tenants", tags=["tenants"])
|
||||
|
|
@ -48,6 +49,7 @@ def workspace_response(row) -> WorkspaceResponse:
|
|||
|
||||
@router.get("/workspaces", response_model=list[WorkspaceResponse])
|
||||
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)]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,37 @@ def create_workspace(db: Session, owner: User, data) -> 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):
|
||||
return (
|
||||
db.query(Workspace, Organization, WorkspaceMembership.role)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import ForbiddenPage from './pages/ForbiddenPage'
|
|||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import AccountPage from './pages/AccountPage'
|
||||
import WorkspaceAdminPage from './pages/WorkspaceAdminPage'
|
||||
import InvitationPage from './pages/InvitationPage'
|
||||
import { authApi, saveAuth } from './lib/api'
|
||||
|
||||
function GoogleCallback() {
|
||||
|
|
@ -79,6 +80,7 @@ export default function App() {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/invite/:token" element={<InvitationPage />} />
|
||||
<Route path="/auth/google/callback" element={<GoogleCallback />} />
|
||||
<Route
|
||||
path="/forbidden"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import DashboardHeader from './DashboardHeader'
|
||||
import DashboardSidebar from './DashboardSidebar'
|
||||
import DashboardFeed from './DashboardFeed'
|
||||
|
|
@ -15,10 +16,18 @@ const flyoutMap: Partial<Record<NavItemId, FlyoutMode | 'templates'>> = {
|
|||
}
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [activeNav, setActiveNav] = useState<NavItemId>('for-you')
|
||||
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 } =
|
||||
useDashboardDocuments()
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export default function DashboardSidebar({
|
|||
|
||||
if (collapsed) {
|
||||
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) => {
|
||||
const Icon = iconMap[item.icon]
|
||||
const hasFlyout = flyoutNavIds.includes(item.id)
|
||||
|
|
@ -75,7 +75,7 @@ export default function DashboardSidebar({
|
|||
key={item.id}
|
||||
onClick={() => onNavChange(item.id)}
|
||||
title={item.label}
|
||||
className={`mb-1 rounded-lg p-2.5 transition-colors ${
|
||||
className={`mb-1 rounded-lg p-2 transition-colors ${
|
||||
isActive
|
||||
? 'bg-silos-brown/10 text-silos-brown'
|
||||
: 'text-silos-muted hover:bg-silos-cream hover:text-silos-text'
|
||||
|
|
@ -90,8 +90,8 @@ export default function DashboardSidebar({
|
|||
}
|
||||
|
||||
return (
|
||||
<aside className="relative z-30 flex w-60 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
|
||||
<nav className="flex-1 overflow-y-auto p-3">
|
||||
<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-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = iconMap[item.icon]
|
||||
const hasFlyout = flyoutNavIds.includes(item.id)
|
||||
|
|
@ -101,7 +101,7 @@ export default function DashboardSidebar({
|
|||
<button
|
||||
key={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
|
||||
? 'bg-silos-brown/10 font-medium text-silos-brown'
|
||||
: 'text-silos-text hover:bg-silos-cream'
|
||||
|
|
@ -124,7 +124,7 @@ export default function DashboardSidebar({
|
|||
|
||||
<button
|
||||
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
|
||||
{personalOpen ? (
|
||||
|
|
@ -143,7 +143,7 @@ export default function DashboardSidebar({
|
|||
<Link
|
||||
key={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">
|
||||
{(space.content.icon as string) || '📁'}
|
||||
|
|
@ -154,7 +154,7 @@ export default function DashboardSidebar({
|
|||
)}
|
||||
<Link
|
||||
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" />
|
||||
Add space
|
||||
|
|
@ -163,7 +163,7 @@ export default function DashboardSidebar({
|
|||
)}
|
||||
</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">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Invite Fellow
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ interface DocumentFlyoutProps {
|
|||
mode: FlyoutMode
|
||||
documents: DocumentRecord[]
|
||||
sidebarCollapsed: boolean
|
||||
workspace?: boolean
|
||||
onClose: () => void
|
||||
onStarChange?: () => void
|
||||
}
|
||||
|
|
@ -39,6 +40,7 @@ export default function DocumentFlyout({
|
|||
mode,
|
||||
documents,
|
||||
sidebarCollapsed,
|
||||
workspace = false,
|
||||
onClose,
|
||||
onStarChange,
|
||||
}: DocumentFlyoutProps) {
|
||||
|
|
@ -70,7 +72,7 @@ export default function DocumentFlyout({
|
|||
<div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} />
|
||||
<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 ${
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ import { templatesApi, type ApiTemplate } from '../../lib/api'
|
|||
|
||||
interface TemplatesFlyoutProps {
|
||||
sidebarCollapsed: boolean
|
||||
workspace?: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function TemplatesFlyout({ sidebarCollapsed, onClose }: TemplatesFlyoutProps) {
|
||||
export default function TemplatesFlyout({ sidebarCollapsed, workspace = false, onClose }: TemplatesFlyoutProps) {
|
||||
const navigate = useNavigate()
|
||||
const author = getStoredUser()?.full_name ?? 'You'
|
||||
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} />
|
||||
<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 ${
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import CreateDropdown from '../create/CreateDropdown'
|
|||
import WorkspaceSidebar from './WorkspaceSidebar'
|
||||
import ShareModal from './ShareModal'
|
||||
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'
|
||||
|
||||
|
|
@ -50,6 +54,8 @@ export default function WorkspaceShell({
|
|||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [notifications, setNotifications] = useState<ApiNotification[]>([])
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false)
|
||||
const [workspaceFlyout, setWorkspaceFlyout] = useState<FlyoutMode | 'templates' | null>(null)
|
||||
const { recent, projects, starredProjects, refresh } = useDashboardDocuments()
|
||||
|
||||
useEffect(() => {
|
||||
void documentsApi.listNotifications().then(setNotifications).catch(() => setNotifications([]))
|
||||
|
|
@ -96,6 +102,23 @@ export default function WorkspaceShell({
|
|||
const isCanvas = layout === 'canvas'
|
||||
const isSpace = layout === 'space'
|
||||
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 (
|
||||
<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">
|
||||
{!isSpace && (
|
||||
<WorkspaceSidebar collapsed={false} activeDocId={doc.id} steadId={doc.steadId} />
|
||||
<WorkspaceSidebar
|
||||
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'}`}>
|
||||
{isCanvas || isSpace ? (
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@ import {
|
|||
Copy,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Home,
|
||||
LayoutTemplate,
|
||||
Clock,
|
||||
Star,
|
||||
FolderOpen,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PenTool,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
|
|
@ -28,6 +32,7 @@ import {
|
|||
} from '../../lib/documents'
|
||||
import { getStoredUser } from '../../lib/api'
|
||||
import type { CreateType } from '../../data/createData'
|
||||
import { navItems, type NavItemId } from '../../data/dashboardData'
|
||||
|
||||
const typeIcons: Record<CreateType, typeof FileText> = {
|
||||
silo: FileText,
|
||||
|
|
@ -43,13 +48,29 @@ const typeColors: Record<CreateType, string> = {
|
|||
stead: '#9B7BB8',
|
||||
}
|
||||
|
||||
const navigationIcons = {
|
||||
'for-you': Home,
|
||||
templates: LayoutTemplate,
|
||||
recent: Clock,
|
||||
favourites: Star,
|
||||
'all-projects': FolderOpen,
|
||||
} as const
|
||||
|
||||
interface WorkspaceSidebarProps {
|
||||
collapsed: boolean
|
||||
activeDocId: 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 [steads, setSteads] = useState<DocumentRecord[]>([])
|
||||
const [creatingChildFor, setCreatingChildFor] = useState<string | null>(null)
|
||||
|
|
@ -90,6 +111,20 @@ export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: Wo
|
|||
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> {
|
||||
const descendants = new Set<string>()
|
||||
const visit = (parentId: string) => {
|
||||
|
|
@ -107,7 +142,7 @@ export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: Wo
|
|||
try {
|
||||
const child = await createDocument(
|
||||
'silo',
|
||||
`Child of ${doc.title}`,
|
||||
'Untitled',
|
||||
{ body: '', bodyHtml: '', status: 'draft' },
|
||||
undefined,
|
||||
getStoredUser()?.full_name ?? 'You',
|
||||
|
|
@ -305,7 +340,42 @@ export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: Wo
|
|||
return (
|
||||
<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">
|
||||
<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 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>}
|
||||
|
|
|
|||
30
frontend/src/pages/InvitationPage.tsx
Normal file
30
frontend/src/pages/InvitationPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -143,7 +143,7 @@ export default function SiloPage() {
|
|||
|
||||
async function createChildPage() {
|
||||
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}`)
|
||||
}
|
||||
|
||||
|
|
@ -244,7 +244,7 @@ export default function SiloPage() {
|
|||
{saveError && <span className="text-red-700">{saveError}</span>}
|
||||
</div>
|
||||
<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>
|
||||
{commentsOpen && <CommentsPanel documentId={doc.id} onClose={() => setCommentsOpen(false)} />}
|
||||
{historyOpen && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue