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

@ -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"

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 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()

View file

@ -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

View file

@ -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">

View file

@ -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">

View file

@ -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 ? (

View file

@ -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>}

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() {
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 && (