first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:44:15 +05:30
commit 6e354ba041
109 changed files with 12110 additions and 0 deletions

19
frontend/index.html Normal file
View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/Assets/Rusty brown logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Silos — Your knowledge. Your team. One place.</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2565
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
frontend/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "silos-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^12.4.7",
"lucide-react": "^0.475.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.9",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.9",
"typescript": "~5.7.2",
"vite": "^6.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 925 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Apple">
<path fill="#000" d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="GitHub">
<path fill="#24292F" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
</svg>

After

Width:  |  Height:  |  Size: 841 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Google">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>

After

Width:  |  Height:  |  Size: 727 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" role="img" aria-label="Microsoft">
<path fill="#f35325" d="M1 1h10v10H1z"/>
<path fill="#81bc06" d="M12 1h10v10H12z"/>
<path fill="#05a6f0" d="M1 12h10v10H1z"/>
<path fill="#ffba08" d="M12 12h10v10H12z"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Okta">
<circle cx="12" cy="12" r="11" fill="#007DC1"/>
<circle cx="12" cy="12" r="5" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 193 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Slack">
<path fill="#E01E5A" d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"/>
<path fill="#36C5F0" d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z"/>
<path fill="#2EB67D" d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312z"/>
<path fill="#ECB22E" d="M15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.528 2.528 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.528 2.528 0 0 1-2.52-2.523 2.528 2.528 0 0 1 2.52-2.52h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

76
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,76 @@
import { Routes, Route } from 'react-router-dom'
import LandingPage from './pages/LandingPage'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
import ShedPage from './pages/workspace/ShedPage'
import SiloPage from './pages/workspace/SiloPage'
import SketchPage from './pages/workspace/SketchPage'
import SteadPage from './pages/workspace/SteadPage'
import SteadCreatePage from './pages/workspace/SteadCreatePage'
import ForbiddenPage from './pages/ForbiddenPage'
import ProtectedRoute from './components/ProtectedRoute'
export default function App() {
return (
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/forbidden"
element={
<ProtectedRoute>
<ForbiddenPage />
</ProtectedRoute>
}
/>
<Route
path="/workspace/shed/:id"
element={
<ProtectedRoute>
<ShedPage />
</ProtectedRoute>
}
/>
<Route
path="/workspace/silo/:id"
element={
<ProtectedRoute>
<SiloPage />
</ProtectedRoute>
}
/>
<Route
path="/workspace/sketch/:id"
element={
<ProtectedRoute>
<SketchPage />
</ProtectedRoute>
}
/>
<Route
path="/workspace/stead/new"
element={
<ProtectedRoute>
<SteadCreatePage />
</ProtectedRoute>
}
/>
<Route
path="/workspace/stead/:id"
element={
<ProtectedRoute>
<SteadPage />
</ProtectedRoute>
}
/>
</Routes>
)
}

View file

@ -0,0 +1,152 @@
import { motion } from 'framer-motion'
import { features } from '../data/landingData'
function FeaturePreview({ type }: { type: string }) {
switch (type) {
case 'editor':
return (
<div className="rounded-lg border border-silos-cream-dark bg-white p-3">
<div className="flex gap-1.5 border-b border-silos-cream-dark pb-2">
{['H1', 'H2', 'B', '•'].map((btn) => (
<span
key={btn}
className="rounded px-2 py-0.5 text-[10px] font-medium text-silos-muted bg-silos-cream"
>
{btn}
</span>
))}
</div>
<div className="mt-2 space-y-1.5">
<div className="h-2 w-3/4 rounded bg-silos-text/20" />
<div className="h-1.5 w-full rounded bg-silos-muted/20" />
<div className="h-1.5 w-5/6 rounded bg-silos-muted/20" />
</div>
</div>
)
case 'database':
return (
<div className="rounded-lg border border-silos-cream-dark bg-white p-3">
<div className="grid grid-cols-3 gap-1 text-[9px] font-medium text-silos-muted">
<span>Name</span>
<span>Status</span>
<span>Owner</span>
</div>
{[1, 2, 3].map((row) => (
<div key={row} className="mt-1 grid grid-cols-3 gap-1">
<div className="h-1.5 rounded bg-silos-cream" />
<div className="h-1.5 rounded bg-silos-tan/30" />
<div className="h-1.5 rounded bg-silos-cream" />
</div>
))}
</div>
)
case 'whiteboard':
return (
<div className="rounded-lg border border-silos-cream-dark bg-white p-3">
<svg viewBox="0 0 120 60" className="w-full">
<rect x="10" y="10" width="30" height="20" rx="3" fill="#C17D65" opacity="0.3" stroke="#6B3A2C" strokeWidth="1" />
<rect x="50" y="25" width="30" height="20" rx="3" fill="#6B3A2C" opacity="0.2" stroke="#6B3A2C" strokeWidth="1" />
<line x1="40" y1="20" x2="50" y2="35" stroke="#C17D65" strokeWidth="1.5" strokeDasharray="3 2" />
<circle cx="90" cy="20" r="8" fill="#C17D65" opacity="0.3" stroke="#6B3A2C" strokeWidth="1" />
</svg>
</div>
)
case 'kanban':
return (
<div className="rounded-lg border border-silos-cream-dark bg-white p-3">
<div className="flex gap-2">
{['To Do', 'In Progress', 'Done'].map((col, i) => (
<div key={col} className="flex-1">
<p className="text-[8px] font-medium text-silos-muted">{col}</p>
<div className="mt-1 space-y-1">
{Array.from({ length: i === 1 ? 2 : 1 }).map((_, j) => (
<div
key={j}
className={`h-4 rounded ${i === 2 ? 'bg-silos-tan/30' : 'bg-silos-cream'}`}
/>
))}
</div>
</div>
))}
</div>
</div>
)
case 'integrations':
return (
<div className="flex items-center justify-center gap-4 rounded-lg border border-silos-cream-dark bg-white p-4">
{['Slack', 'GitHub', 'Drive'].map((name) => (
<div
key={name}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-silos-cream text-[8px] font-bold text-silos-muted"
>
{name[0]}
</div>
))}
</div>
)
case 'collaboration':
return (
<div className="flex items-center justify-center gap-2 rounded-lg border border-silos-cream-dark bg-white p-4">
<div className="flex -space-x-2">
{['#C17D65', '#6B3A2C', '#8B4D3B'].map((color, i) => (
<div
key={i}
className="flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-[9px] font-medium text-white"
style={{ backgroundColor: color }}
>
{['S', 'M', 'A'][i]}
</div>
))}
</div>
<span className="rounded-full bg-silos-cream px-2 py-0.5 text-[9px] text-silos-muted">
+3 people editing
</span>
</div>
)
default:
return null
}
}
export default function Features() {
return (
<section id="features" className="bg-white px-4 py-20 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center"
>
<h2 className="font-display text-3xl font-bold text-silos-text sm:text-4xl">
Everything your team needs to work better
</h2>
<p className="mx-auto mt-4 max-w-2xl text-silos-muted">
From documents to databases, whiteboards to boards — all in one connected workspace.
</p>
</motion.div>
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature, i) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.08 }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
className="group rounded-2xl border border-silos-cream-dark bg-silos-cream/50 p-6 transition-shadow hover:shadow-lg"
>
<FeaturePreview type={feature.preview} />
<h3 className="mt-4 text-lg font-semibold text-silos-text group-hover:text-silos-brown">
{feature.title}
</h3>
<p className="mt-2 text-sm text-silos-muted">{feature.description}</p>
</motion.div>
))}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,53 @@
import { Link } from 'react-router-dom'
const footerLinks = [
{ label: 'Privacy Policy', href: '#' },
{ label: 'Terms & Conditions', href: '#' },
{ label: 'Legal', href: '#' },
{ label: 'Contact Us', href: '#contact' },
]
export default function Footer() {
return (
<footer className="bg-silos-brown-dark">
{/* CTA bar */}
<div className="border-b border-white/10 bg-silos-brown px-4 py-10 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col items-center text-center">
<h3 className="text-2xl font-semibold text-white">Ready to get started?</h3>
<Link
to="/login"
className="mt-5 rounded-full bg-white px-8 py-3 text-sm font-semibold text-silos-brown shadow-lg transition-all hover:bg-silos-cream hover:shadow-xl"
>
Get Started Free
</Link>
<p className="mt-3 text-sm text-white/60">No credit card required</p>
</div>
</div>
{/* Bottom bar */}
<div className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-sm text-white/60">
&copy; {new Date().getFullYear()} Silos. All rights reserved.
</p>
<nav id="contact" className="flex flex-wrap items-center justify-center gap-1">
{footerLinks.map((link, i) => (
<span key={link.label} className="flex items-center">
<a
href={link.href}
className="px-2 text-sm text-white/70 transition-colors hover:text-white"
>
{link.label}
</a>
{i < footerLinks.length - 1 && (
<span className="text-white/30">|</span>
)}
</span>
))}
</nav>
</div>
</div>
</footer>
)
}

View file

@ -0,0 +1,290 @@
import { useState, useRef, useEffect } from 'react'
import { Link } from 'react-router-dom'
import {
ChevronDown,
Search,
X,
FileText,
LayoutGrid,
Database,
PenTool,
Plug,
BookOpen,
LayoutTemplate,
Users,
Newspaper,
HelpCircle,
type LucideIcon,
} from 'lucide-react'
import { logos } from '../data/brandAssets'
import { products, resources, productDetails } from '../data/landingData'
const iconMap: Record<string, LucideIcon> = {
FileText,
LayoutGrid,
Database,
PenTool,
Plug,
BookOpen,
LayoutTemplate,
Users,
Newspaper,
HelpCircle,
}
type DropdownItem = {
name: string
description: string
icon: string
}
function DropdownMenu({
label,
items,
isOpen,
onToggle,
onClose,
}: {
label: string
items: readonly DropdownItem[]
isOpen: boolean
onToggle: () => void
onClose: () => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose()
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, onClose])
return (
<div ref={ref} className="relative">
<button
onClick={onToggle}
className="flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-white/90 transition-colors hover:bg-white/10 hover:text-white"
>
{label}
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOpen && (
<div className="absolute left-0 top-full z-50 mt-2 w-80 overflow-hidden rounded-xl border border-white/10 bg-silos-brown-dark shadow-2xl">
<div className="p-2">
{items.map((item) => {
const Icon = iconMap[item.icon]
return (
<button
key={item.name}
onClick={onClose}
className="flex w-full items-start gap-3 rounded-lg p-3 text-left transition-colors hover:bg-white/10"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/10">
{Icon && <Icon className="h-4 w-4 text-silos-tan" />}
</div>
<div>
<p className="text-sm font-medium text-white">{item.name}</p>
<p className="mt-0.5 text-xs text-white/60">{item.description}</p>
</div>
</button>
)
})}
</div>
</div>
)}
</div>
)
}
function ProductDetailsDropdown({
isOpen,
onToggle,
onClose,
}: {
isOpen: boolean
onToggle: () => void
onClose: () => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose()
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, onClose])
return (
<div ref={ref} className="relative">
<button
onClick={onToggle}
className="flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-white/90 transition-colors hover:bg-white/10 hover:text-white"
>
Product Details
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOpen && (
<div className="absolute left-0 top-full z-50 mt-2 w-72 overflow-hidden rounded-xl border border-white/10 bg-silos-brown-dark shadow-2xl">
<div className="p-3">
{productDetails.map((item) => (
<button
key={item.title}
onClick={onClose}
className="block w-full rounded-lg p-3 text-left transition-colors hover:bg-white/10"
>
<p className="text-sm font-medium text-white">{item.title}</p>
<p className="mt-0.5 text-xs text-white/60">{item.description}</p>
</button>
))}
</div>
</div>
)}
</div>
)
}
function SearchOverlay({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isOpen) {
inputRef.current?.focus()
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
}
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[100] flex items-start justify-center bg-black/50 pt-24 backdrop-blur-sm">
<div className="mx-4 w-full max-w-2xl">
<div className="overflow-hidden rounded-2xl bg-white shadow-2xl">
<div className="flex items-center gap-3 border-b border-silos-cream-dark px-5 py-4">
<Search className="h-5 w-5 text-silos-muted" />
<input
ref={inputRef}
type="text"
placeholder="Search Silos..."
className="flex-1 bg-transparent text-lg text-silos-text outline-none placeholder:text-silos-muted"
/>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-5 py-4">
<p className="text-sm text-silos-muted">
Search for docs, spaces, templates, and help articles...
</p>
</div>
</div>
</div>
</div>
)
}
export default function Header() {
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
const closeAll = () => setOpenDropdown(null)
return (
<>
<header className="sticky top-0 z-40 bg-silos-brown shadow-lg">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-2 sm:px-6 lg:px-8">
<Link to="/" className="shrink-0 leading-none">
<img src={logos.white} alt="Silos" className="block h-9 w-auto sm:h-10" />
</Link>
<nav className="hidden items-center gap-1 lg:flex">
<DropdownMenu
label="Products"
items={products}
isOpen={openDropdown === 'products'}
onToggle={() =>
setOpenDropdown(openDropdown === 'products' ? null : 'products')
}
onClose={closeAll}
/>
<DropdownMenu
label="Resources"
items={resources}
isOpen={openDropdown === 'resources'}
onToggle={() =>
setOpenDropdown(openDropdown === 'resources' ? null : 'resources')
}
onClose={closeAll}
/>
<ProductDetailsDropdown
isOpen={openDropdown === 'details'}
onToggle={() =>
setOpenDropdown(openDropdown === 'details' ? null : 'details')
}
onClose={closeAll}
/>
</nav>
<div className="flex items-center gap-3">
<button
onClick={() => setSearchOpen(true)}
className="flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1.5 text-sm text-white/80 transition-all hover:border-white/30 hover:bg-white/15 hover:text-white"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search Silos...</span>
</button>
<Link
to="/login"
className="rounded-full border border-white/30 bg-white/10 px-4 py-1.5 text-sm font-medium text-white transition-all hover:bg-white hover:text-silos-brown"
>
Sign Up
</Link>
</div>
</div>
</header>
<SearchOverlay isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
</>
)
}

View file

@ -0,0 +1,164 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Users, FileText, Handshake, Shield } from 'lucide-react'
import { logos } from '../data/brandAssets'
import { stats } from '../data/landingData'
const statIcons = {
Users,
FileText,
Handshake,
Shield,
}
function DashboardPreview() {
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.3 }}
className="relative"
>
<div className="overflow-hidden rounded-2xl border border-silos-cream-dark bg-white shadow-2xl">
<div className="flex">
{/* Sidebar */}
<div className="w-44 shrink-0 bg-silos-brown p-4">
<img src={logos.white} alt="Silos" className="mb-6 h-6 w-auto" />
<nav className="space-y-1">
{['Home', 'Spaces', 'Pages', 'Boards', 'Whiteboards'].map((item, i) => (
<div
key={item}
className={`rounded-lg px-3 py-2 text-xs ${
i === 0
? 'bg-white/15 font-medium text-white'
: 'text-white/60'
}`}
>
{item}
</div>
))}
</nav>
</div>
{/* Main content */}
<div className="flex-1 p-5">
<p className="text-sm font-semibold text-silos-text">Welcome back, Alex</p>
<p className="mt-0.5 text-xs text-silos-muted">Here's what's happening in your workspace</p>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-lg border border-silos-cream-dark p-3">
<p className="text-xs font-medium text-silos-text">Recent Pages</p>
<div className="mt-2 space-y-1.5">
{['Q4 Planning', 'API Docs', 'Team Handbook'].map((page) => (
<div key={page} className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-silos-tan" />
<span className="text-[10px] text-silos-muted">{page}</span>
</div>
))}
</div>
</div>
<div className="rounded-lg border border-silos-cream-dark p-3">
<p className="text-xs font-medium text-silos-text">Team Activity</p>
<div className="mt-2 space-y-1.5">
{['Sarah edited Roadmap', 'Mike added comment'].map((act) => (
<div key={act} className="text-[10px] text-silos-muted">{act}</div>
))}
</div>
</div>
<div className="rounded-lg border border-silos-cream-dark p-3">
<p className="text-xs font-medium text-silos-text">My Tasks</p>
<div className="mt-2 flex gap-2">
{['To Do', 'In Progress', 'Done'].map((col, i) => (
<div key={col} className="flex-1">
<p className="text-[9px] font-medium text-silos-muted">{col}</p>
<div className={`mt-1 h-8 rounded ${i === 1 ? 'bg-silos-tan/20' : 'bg-silos-cream'}`} />
</div>
))}
</div>
</div>
<div className="rounded-lg border border-silos-cream-dark p-3">
<p className="text-xs font-medium text-silos-text">Pinned Spaces</p>
<div className="mt-2 flex gap-1.5">
{['Engineering', 'Design', 'Product'].map((space) => (
<span
key={space}
className="rounded-full bg-silos-cream px-2 py-0.5 text-[9px] text-silos-muted"
>
{space}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="absolute -bottom-4 -right-4 -z-10 h-full w-full rounded-2xl bg-silos-tan/20" />
</motion.div>
)
}
export default function Hero() {
return (
<section className="relative overflow-hidden bg-silos-cream px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
<div className="mx-auto grid max-w-7xl items-center gap-12 lg:grid-cols-2 lg:gap-16">
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className="font-display text-4xl font-bold leading-tight text-silos-text sm:text-5xl lg:text-[3.25rem]">
Your knowledge.
<br />
Your team.
<br />
One place.
</h1>
<p className="mt-6 max-w-lg text-lg leading-relaxed text-silos-muted">
Silos is the connected workspace where teams create, organize and share
knowledge effortlessly.
</p>
<div className="mt-8 flex flex-wrap gap-4">
<Link
to="/login"
className="rounded-full bg-silos-brown px-8 py-3 text-sm font-semibold text-white shadow-lg transition-all hover:bg-silos-brown-dark hover:shadow-xl"
>
Get Started
</Link>
<a
href="#features"
className="rounded-full border-2 border-silos-brown px-8 py-3 text-sm font-semibold text-silos-brown transition-all hover:bg-silos-brown hover:text-white"
>
Learn More
</a>
</div>
<div className="mt-12 grid grid-cols-2 gap-6 sm:grid-cols-4">
{stats.map((stat, i) => {
const Icon = statIcons[stat.icon as keyof typeof statIcons]
return (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.5 + i * 0.1 }}
className="flex flex-col items-center text-center sm:items-start sm:text-left"
>
<Icon className="mb-2 h-5 w-5 text-silos-tan" />
<span className="text-sm font-semibold text-silos-text">{stat.label}</span>
</motion.div>
)
})}
</div>
</motion.div>
<DashboardPreview />
</div>
</section>
)
}

View file

@ -0,0 +1,14 @@
import { type ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { getStoredUser } from '../lib/api'
export default function ProtectedRoute({ children }: { children: ReactNode }) {
const location = useLocation()
const user = getStoredUser()
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View file

@ -0,0 +1,148 @@
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
FilePlus,
FolderOpen,
Users,
Share2,
Flag,
type LucideIcon,
} from 'lucide-react'
import { workflowSteps } from '../data/landingData'
const stepIcons: Record<string, LucideIcon> = {
FilePlus,
FolderOpen,
Users,
Share2,
Flag,
}
export default function Workflow() {
const [activeStep, setActiveStep] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setActiveStep((prev) => (prev + 1) % workflowSteps.length)
}, 2500)
return () => clearInterval(interval)
}, [])
return (
<section className="bg-silos-cream px-4 py-20 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center"
>
<h2 className="font-display text-3xl font-bold text-silos-text sm:text-4xl">
A workflow that flows with you
</h2>
<p className="mx-auto mt-4 max-w-2xl text-silos-muted">
From idea to execution — Silos adapts to how your team actually works.
</p>
</motion.div>
<div className="mt-16 hidden lg:block">
<div className="relative flex items-start justify-between">
{workflowSteps.map((step, i) => {
const Icon = stepIcons[step.icon]
const isActive = activeStep === i
const isPast = i < activeStep
return (
<div key={step.title} className="relative flex flex-1 flex-col items-center">
{i < workflowSteps.length - 1 && (
<div className="absolute left-[calc(50%+28px)] top-7 h-0.5 w-[calc(100%-56px)]">
<div className="h-full w-full bg-silos-cream-dark" />
<motion.div
className="absolute inset-0 h-full origin-left bg-silos-tan"
initial={{ scaleX: 0 }}
animate={{ scaleX: isPast ? 1 : isActive ? 0.5 : 0 }}
transition={{ duration: 0.8 }}
/>
</div>
)}
<motion.button
onClick={() => setActiveStep(i)}
className={`relative z-10 flex h-14 w-14 items-center justify-center rounded-full border-2 transition-colors ${
isActive
? 'border-silos-brown bg-silos-brown text-white shadow-lg'
: isPast
? 'border-silos-tan bg-silos-tan text-white'
: 'border-silos-cream-dark bg-white text-silos-muted'
}`}
animate={isActive ? { scale: [1, 1.1, 1] } : { scale: 1 }}
transition={{ duration: 0.6, repeat: isActive ? Infinity : 0, repeatDelay: 1.5 }}
>
<Icon className="h-6 w-6" />
</motion.button>
<AnimatePresence mode="wait">
<motion.div
key={isActive ? 'active' : 'inactive'}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
className="mt-4 text-center"
>
<p
className={`text-sm font-semibold ${
isActive ? 'text-silos-brown' : 'text-silos-text'
}`}
>
{step.title}
</p>
<p className="mt-1 max-w-[140px] text-xs text-silos-muted">
{step.description}
</p>
</motion.div>
</AnimatePresence>
</div>
)
})}
</div>
</div>
{/* Mobile workflow */}
<div className="mt-12 space-y-6 lg:hidden">
{workflowSteps.map((step, i) => {
const Icon = stepIcons[step.icon]
const isActive = activeStep === i
return (
<motion.div
key={step.title}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className={`flex items-center gap-4 rounded-xl p-4 transition-colors ${
isActive ? 'bg-white shadow-md' : 'bg-white/50'
}`}
>
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full ${
isActive
? 'bg-silos-brown text-white'
: 'bg-silos-cream text-silos-muted'
}`}
>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="font-semibold text-silos-text">{step.title}</p>
<p className="text-sm text-silos-muted">{step.description}</p>
</div>
</motion.div>
)
})}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,49 @@
import { Link } from 'react-router-dom'
const footerLinks = [
{ label: 'Privacy Policy', href: '#' },
{ label: 'Terms & Conditions', href: '#' },
{ label: 'Legal', href: '#' },
{ label: 'Contact Us', href: '#contact' },
{ label: 'Support', href: '#support' },
]
export default function AuthFooter() {
return (
<footer className="bg-silos-brown-dark">
<div className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 lg:flex-row">
<p className="text-sm text-white/60 lg:flex-1">
&copy; {new Date().getFullYear()} Silos. All rights reserved.
</p>
<Link
to="/login"
className="rounded-full border border-white/40 px-6 py-2 text-sm font-medium text-white transition-colors hover:border-white hover:bg-white/10"
>
Get Started
</Link>
<nav
id="support"
className="flex flex-wrap items-center justify-center gap-1 lg:flex-1 lg:justify-end"
>
{footerLinks.map((link, i) => (
<span key={link.label} className="flex items-center">
<a
href={link.href}
className="px-2 text-sm text-white/70 transition-colors hover:text-white"
>
{link.label}
</a>
{i < footerLinks.length - 1 && (
<span className="text-white/30">|</span>
)}
</span>
))}
</nav>
</div>
</div>
</footer>
)
}

View file

@ -0,0 +1,83 @@
import { Link } from 'react-router-dom'
import { Shield, Users, LayoutGrid } from 'lucide-react'
import { logos } from '../../data/brandAssets'
const features = [
{
icon: Shield,
title: 'Secure & Private',
description: 'Your data is protected with enterprise-grade security',
},
{
icon: Users,
title: 'Seamless Collaboration',
description: 'Work together in real time across your entire team',
},
{
icon: LayoutGrid,
title: 'All in One Place',
description: 'Docs, boards, databases, and whiteboards unified',
},
]
function AuthIllustration() {
return (
<svg viewBox="0 0 320 200" className="w-full max-w-sm opacity-80" aria-hidden>
<rect x="20" y="30" width="100" height="70" rx="8" fill="#C17D65" opacity="0.25" stroke="#6B3A2C" strokeWidth="1.5" />
<rect x="40" y="50" width="60" height="8" rx="2" fill="#6B3A2C" opacity="0.3" />
<rect x="40" y="65" width="45" height="6" rx="2" fill="#6B3A2C" opacity="0.2" />
<rect x="40" y="78" width="50" height="6" rx="2" fill="#6B3A2C" opacity="0.2" />
<rect x="200" y="20" width="90" height="60" rx="8" fill="#C17D65" opacity="0.2" stroke="#6B3A2C" strokeWidth="1.5" />
<circle cx="245" cy="50" r="12" fill="#6B3A2C" opacity="0.25" />
<path d="M120 65 L200 50" stroke="#C17D65" strokeWidth="2" strokeDasharray="4 3" />
<path d="M120 85 L200 70" stroke="#C17D65" strokeWidth="2" strokeDasharray="4 3" />
<rect x="130" y="100" width="70" height="80" rx="8" fill="#6B3A2C" opacity="0.15" stroke="#6B3A2C" strokeWidth="1.5" />
<rect x="145" y="115" width="40" height="30" rx="4" fill="#C17D65" opacity="0.4" />
<circle cx="165" cy="160" r="8" fill="none" stroke="#6B3A2C" strokeWidth="2" />
<rect x="161" y="164" width="8" height="10" rx="2" fill="#6B3A2C" opacity="0.4" />
<path d="M80 100 Q100 120 130 100" stroke="#C17D65" strokeWidth="1.5" fill="none" />
<path d="M270 80 Q250 100 230 90" stroke="#C17D65" strokeWidth="1.5" fill="none" />
</svg>
)
}
export default function AuthSidebar() {
return (
<aside className="hidden w-full max-w-xs shrink-0 flex-col py-8 pl-8 pr-4 xl:flex xl:max-w-sm xl:self-start xl:py-10 xl:pl-12">
<Link to="/">
<img src={logos.rustyBrown} alt="Silos" className="h-9 w-auto" />
</Link>
<div className="mt-10">
<h1 className="text-3xl font-bold leading-tight text-silos-text">
Welcome to Silos
</h1>
<p className="mt-3 text-sm leading-relaxed text-silos-muted">
Your knowledge. Your team. One secure workspace.
</p>
<div className="my-8">
<AuthIllustration />
</div>
<ul className="space-y-5">
{features.map(({ icon: Icon, title, description }) => (
<li key={title} className="flex gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-silos-brown/10">
<Icon className="h-4 w-4 text-silos-brown" />
</div>
<div>
<p className="text-sm font-semibold text-silos-text">{title}</p>
<p className="mt-0.5 text-xs text-silos-muted">{description}</p>
</div>
</li>
))}
</ul>
</div>
</aside>
)
}

View file

@ -0,0 +1,65 @@
import { ArrowLeft, Mail } from 'lucide-react'
interface ForgotPasswordFormProps {
email: string
onEmailChange: (v: string) => void
onSubmit: () => void
onBack: () => void
}
export default function ForgotPasswordForm({
email,
onEmailChange,
onSubmit,
onBack,
}: ForgotPasswordFormProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="space-y-5"
>
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-silos-muted hover:text-silos-brown"
>
<ArrowLeft className="h-4 w-4" />
Back to login
</button>
<div>
<h2 className="text-xl font-semibold text-silos-text">Forgot password?</h2>
<p className="mt-1 text-sm text-silos-muted">
Enter your email and we&apos;ll send you a reset link.
</p>
</div>
<div>
<label htmlFor="forgot-email" className="mb-1.5 block text-sm font-medium text-silos-text">
Email
</label>
<div className="relative">
<Mail className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
id="forgot-email"
type="email"
value={email}
onChange={(e) => onEmailChange(e.target.value)}
placeholder="you@company.com"
className="w-full rounded-xl border border-silos-cream-dark bg-white py-3 pl-10 pr-4 text-sm text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
</div>
</div>
<button
type="submit"
className="w-full rounded-xl bg-silos-brown py-3 text-sm font-semibold text-white transition-colors hover:bg-silos-brown-dark"
>
Send Reset Link
</button>
</form>
)
}

View file

@ -0,0 +1,104 @@
import { User } from 'lucide-react'
import PasswordField from './PasswordField'
import SSOButtons from './SSOButtons'
interface LoginFormProps {
username: string
password: string
rememberMe: boolean
onUsernameChange: (v: string) => void
onPasswordChange: (v: string) => void
onRememberMeChange: (v: boolean) => void
onSubmit: () => void
onForgotPassword: () => void
onSSOSelect: (providerId: string) => void
}
export default function LoginForm({
username,
password,
rememberMe,
onUsernameChange,
onPasswordChange,
onRememberMeChange,
onSubmit,
onForgotPassword,
onSSOSelect,
}: LoginFormProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="space-y-5"
>
<div>
<h2 className="text-xl font-semibold text-silos-text">Login to your account</h2>
<p className="mt-1 text-sm text-silos-muted">
Welcome back! Please enter your details.
</p>
</div>
<div>
<label htmlFor="login-username" className="mb-1.5 block text-sm font-medium text-silos-text">
Username or Email
</label>
<div className="relative">
<User className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
id="login-username"
type="text"
value={username}
onChange={(e) => onUsernameChange(e.target.value)}
placeholder="Enter your username or email"
className="w-full rounded-xl border border-silos-cream-dark bg-white py-3 pl-10 pr-4 text-sm text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
</div>
</div>
<PasswordField
id="login-password"
value={password}
onChange={onPasswordChange}
/>
<div className="flex items-center justify-between">
<label className="flex cursor-pointer items-center gap-2 text-sm text-silos-muted">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => onRememberMeChange(e.target.checked)}
className="h-4 w-4 rounded border-silos-cream-dark text-silos-brown focus:ring-silos-tan"
/>
Remember me
</label>
<button
type="button"
onClick={onForgotPassword}
className="text-sm font-medium text-silos-tan hover:text-silos-brown"
>
Forgot password?
</button>
</div>
<button
type="submit"
className="w-full rounded-xl bg-silos-brown py-3 text-sm font-semibold text-white transition-colors hover:bg-silos-brown-dark"
>
Login
</button>
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-silos-cream-dark" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-3 text-silos-muted">or continue with</span>
</div>
</div>
<SSOButtons onSelect={onSSOSelect} compact />
</form>
)
}

View file

@ -0,0 +1,47 @@
import { useState } from 'react'
import { Eye, EyeOff, Lock } from 'lucide-react'
interface PasswordFieldProps {
label?: string
value: string
onChange: (value: string) => void
placeholder?: string
id?: string
}
export default function PasswordField({
label = 'Password',
value,
onChange,
placeholder = 'Enter your password',
id = 'password',
}: PasswordFieldProps) {
const [show, setShow] = useState(false)
return (
<div>
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-silos-text">
{label}
</label>
<div className="relative">
<Lock className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
id={id}
type={show ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-silos-cream-dark bg-white py-3 pl-10 pr-12 text-sm text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
<button
type="button"
onClick={() => setShow(!show)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-silos-muted hover:text-silos-text"
aria-label={show ? 'Hide password' : 'Show password'}
>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,52 @@
import { KeyRound, UserPlus } from 'lucide-react'
interface QuickActionsProps {
onResetPassword: () => void
onCreateAccount: () => void
}
export default function QuickActions({
onResetPassword,
onCreateAccount,
}: QuickActionsProps) {
return (
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold text-silos-text">Quick Actions</h3>
<div className="mt-3 space-y-2">
<button
type="button"
onClick={onResetPassword}
className="flex w-full items-start gap-3 rounded-xl border border-silos-cream-dark bg-silos-cream/30 p-4 text-left transition-colors hover:border-silos-tan hover:bg-silos-cream/60"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-silos-brown/10">
<KeyRound className="h-4 w-4 text-silos-brown" />
</div>
<div>
<p className="text-sm font-semibold text-silos-text">Reset Password</p>
<p className="mt-0.5 text-xs text-silos-muted">
Reset your account password securely
</p>
</div>
</button>
<button
type="button"
onClick={onCreateAccount}
className="flex w-full items-start gap-3 rounded-xl border border-silos-cream-dark bg-silos-cream/30 p-4 text-left transition-colors hover:border-silos-tan hover:bg-silos-cream/60"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-silos-brown/10">
<UserPlus className="h-4 w-4 text-silos-brown" />
</div>
<div>
<p className="text-sm font-semibold text-silos-text">Create New Account</p>
<p className="mt-0.5 text-xs text-silos-muted">
Join Silos and start your journey today
</p>
</div>
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,69 @@
import { ArrowLeft } from 'lucide-react'
import PasswordField from './PasswordField'
interface ResetPasswordFormProps {
newPassword: string
confirmPassword: string
onNewPasswordChange: (v: string) => void
onConfirmPasswordChange: (v: string) => void
onSubmit: () => void
onBack: () => void
}
export default function ResetPasswordForm({
newPassword,
confirmPassword,
onNewPasswordChange,
onConfirmPasswordChange,
onSubmit,
onBack,
}: ResetPasswordFormProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="space-y-5"
>
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-silos-muted hover:text-silos-brown"
>
<ArrowLeft className="h-4 w-4" />
Back to login
</button>
<div>
<h2 className="text-xl font-semibold text-silos-text">Reset your password</h2>
<p className="mt-1 text-sm text-silos-muted">
Enter a new password for your account.
</p>
</div>
<PasswordField
id="reset-new-password"
label="New Password"
value={newPassword}
onChange={onNewPasswordChange}
placeholder="Enter new password"
/>
<PasswordField
id="reset-confirm-password"
label="Confirm New Password"
value={confirmPassword}
onChange={onConfirmPasswordChange}
placeholder="Confirm new password"
/>
<button
type="submit"
className="w-full rounded-xl bg-silos-brown py-3 text-sm font-semibold text-white transition-colors hover:bg-silos-brown-dark"
>
Reset Password
</button>
</form>
)
}

View file

@ -0,0 +1,34 @@
import { ssoProviders } from '../../data/authData'
interface SSOButtonsProps {
onSelect: (providerId: string) => void
selectedProvider?: string | null
compact?: boolean
}
export default function SSOButtons({ onSelect, selectedProvider, compact = false }: SSOButtonsProps) {
return (
<div className={`grid gap-2 ${compact ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2 sm:grid-cols-3'}`}>
{ssoProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => onSelect(provider.id)}
className={`flex items-center justify-center gap-2 rounded-xl border bg-white text-sm font-medium transition-all hover:shadow-md ${
selectedProvider === provider.id
? 'border-silos-brown bg-silos-brown/5 text-silos-brown ring-2 ring-silos-brown/20'
: 'border-silos-cream-dark text-silos-text hover:border-silos-tan'
} ${compact ? 'px-3 py-2' : 'px-4 py-2.5'}`}
>
<img
src={provider.logo}
alt=""
className="h-5 w-5 shrink-0 object-contain"
aria-hidden
/>
{provider.name}
</button>
))}
</div>
)
}

View file

@ -0,0 +1,64 @@
import SSOButtons from './SSOButtons'
import UserInfoFields from './UserInfoFields'
import type { UserInfo } from '../../data/authData'
interface SSOFormProps {
selectedProvider: string | null
userInfo: UserInfo
onProviderSelect: (providerId: string) => void
onUserInfoChange: (field: keyof UserInfo, value: string) => void
onSubmit: () => void
}
export default function SSOForm({
selectedProvider,
userInfo,
onProviderSelect,
onUserInfoChange,
onSubmit,
}: SSOFormProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="space-y-5"
>
<div>
<h2 className="text-xl font-semibold text-silos-text">SSO Login</h2>
<p className="mt-1 text-sm text-silos-muted">
Sign in with your organization&apos;s identity provider.
</p>
</div>
<SSOButtons
onSelect={onProviderSelect}
selectedProvider={selectedProvider}
/>
{selectedProvider && (
<p className="rounded-lg bg-silos-brown/5 px-3 py-2 text-sm text-silos-brown">
Continuing with{' '}
<span className="font-semibold capitalize">{selectedProvider}</span>
</p>
)}
<div className="rounded-xl border border-silos-cream-dark bg-silos-cream/40 p-4">
<p className="mb-3 text-sm font-semibold text-silos-text">User Information</p>
<p className="mb-3 text-xs text-silos-muted">
Complete your profile after SSO authentication.
</p>
<UserInfoFields values={userInfo} onChange={onUserInfoChange} compact />
</div>
<button
type="submit"
disabled={!selectedProvider}
className="w-full rounded-xl bg-silos-brown py-3 text-sm font-semibold text-white transition-colors hover:bg-silos-brown-dark disabled:cursor-not-allowed disabled:opacity-50"
>
Continue with SSO
</button>
</form>
)
}

View file

@ -0,0 +1,90 @@
import { AtSign } from 'lucide-react'
import PasswordField from './PasswordField'
import UserInfoFields from './UserInfoFields'
import type { UserInfo } from '../../data/authData'
interface SignUpFormProps {
email: string
password: string
confirmPassword: string
userInfo: UserInfo
onEmailChange: (v: string) => void
onPasswordChange: (v: string) => void
onConfirmPasswordChange: (v: string) => void
onUserInfoChange: (field: keyof UserInfo, value: string) => void
onSubmit: () => void
}
export default function SignUpForm({
email,
password,
confirmPassword,
userInfo,
onEmailChange,
onPasswordChange,
onConfirmPasswordChange,
onUserInfoChange,
onSubmit,
}: SignUpFormProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
className="space-y-5"
>
<div>
<h2 className="text-xl font-semibold text-silos-text">Create your account</h2>
<p className="mt-1 text-sm text-silos-muted">
Join Silos and start your journey today.
</p>
</div>
<div>
<label htmlFor="signup-email" className="mb-1.5 block text-sm font-medium text-silos-text">
Email
</label>
<div className="relative">
<AtSign className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
id="signup-email"
type="email"
value={email}
onChange={(e) => onEmailChange(e.target.value)}
placeholder="you@company.com"
className="w-full rounded-xl border border-silos-cream-dark bg-white py-3 pl-10 pr-4 text-sm text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
</div>
</div>
<PasswordField
id="signup-password"
label="Password"
value={password}
onChange={onPasswordChange}
placeholder="Create a strong password"
/>
<PasswordField
id="signup-confirm-password"
label="Confirm Password"
value={confirmPassword}
onChange={onConfirmPasswordChange}
placeholder="Confirm your password"
/>
<div className="rounded-xl border border-silos-cream-dark bg-silos-cream/40 p-4">
<p className="mb-3 text-sm font-semibold text-silos-text">User Information</p>
<UserInfoFields values={userInfo} onChange={onUserInfoChange} compact />
</div>
<button
type="submit"
className="w-full rounded-xl bg-silos-brown py-3 text-sm font-semibold text-white transition-colors hover:bg-silos-brown-dark"
>
Create Account
</button>
</form>
)
}

View file

@ -0,0 +1,78 @@
import { User, AtSign, Hash, Phone, Briefcase } from 'lucide-react'
import type { UserInfo } from '../../data/authData'
interface UserInfoFieldsProps {
values: UserInfo
onChange: (field: keyof UserInfo, value: string) => void
compact?: boolean
}
const fields: {
key: keyof UserInfo
label: string
placeholder: string
icon: typeof User
type?: string
}[] = [
{ key: 'fullName', label: 'Full Name', placeholder: 'Enter your full name', icon: User },
{ key: 'username', label: 'Username', placeholder: 'Choose a username', icon: AtSign },
{ key: 'employeeId', label: 'Employee ID', placeholder: 'Enter your employee ID', icon: Hash },
{ key: 'phone', label: 'Phone Number', placeholder: 'Enter your phone number', icon: Phone, type: 'tel' },
{ key: 'occupation', label: 'Occupation', placeholder: 'Enter your occupation', icon: Briefcase },
]
export default function UserInfoFields({ values, onChange, compact = false }: UserInfoFieldsProps) {
return (
<div className={compact ? 'space-y-3' : 'space-y-4'}>
{fields.map(({ key, label, placeholder, icon: Icon, type = 'text' }) => (
<div key={key}>
<label
htmlFor={`user-${key}`}
className={`mb-1.5 block font-medium text-silos-text ${compact ? 'text-xs' : 'text-sm'}`}
>
{label}
</label>
<div className="relative">
<Icon
className={`absolute left-3.5 top-1/2 -translate-y-1/2 text-silos-muted ${compact ? 'h-3.5 w-3.5' : 'h-4 w-4'}`}
/>
<input
id={`user-${key}`}
type={type}
value={values[key]}
onChange={(e) => onChange(key, e.target.value)}
placeholder={placeholder}
className={`w-full rounded-xl border border-silos-cream-dark bg-white text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20 ${
compact ? 'py-2 pl-9 pr-3 text-xs' : 'py-3 pl-10 pr-4 text-sm'
}`}
/>
</div>
</div>
))}
</div>
)
}
export function UserInfoList({ values }: { values: UserInfo }) {
const items = [
{ label: 'Full Name', value: values.fullName, icon: User },
{ label: 'Username', value: values.username, icon: AtSign },
{ label: 'Employee ID', value: values.employeeId, icon: Hash },
{ label: 'Phone Number', value: values.phone, icon: Phone },
{ label: 'Occupation', value: values.occupation, icon: Briefcase },
]
return (
<ul className="space-y-2.5">
{items.map(({ label, value, icon: Icon }) => (
<li key={label} className="flex items-center gap-2.5 text-sm">
<Icon className="h-3.5 w-3.5 shrink-0 text-silos-muted" />
<span className="text-silos-muted">{label}:</span>
<span className="truncate font-medium text-silos-text">
{value || '—'}
</span>
</li>
))}
</ul>
)
}

View file

@ -0,0 +1,176 @@
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Plus,
ChevronRight,
Zap,
FileText,
PenTool,
LayoutGrid,
type LucideIcon,
} from 'lucide-react'
import { createOptions, shedTemplates } from '../../data/createData'
import {
createDocument,
getDefaultShedContent,
getDefaultTitle,
} from '../../lib/documents'
import { getStoredUser } from '../../lib/api'
const iconMap: Record<string, LucideIcon> = {
Zap,
FileText,
PenTool,
LayoutGrid,
}
export default function CreateDropdown({ steadId }: { steadId?: string }) {
const [open, setOpen] = useState(false)
const [shedOpen, setShedOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
setShedOpen(false)
}
}
if (open) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
const user = getStoredUser()
const author = user?.full_name ?? 'You'
async function handleCreate(type: 'silo' | 'sketch') {
const defaults: Record<string, Record<string, unknown>> = {
silo: { body: '', bodyHtml: '', status: 'draft' },
sketch: { elements: [] },
}
const doc = await createDocument(
type,
getDefaultTitle(type),
defaults[type],
undefined,
author,
steadId,
)
setOpen(false)
navigate(`/workspace/${type}/${doc.id}`)
}
function handleCreateStead() {
setOpen(false)
navigate('/workspace/stead/new')
}
async function handleCreateShed(templateId: typeof shedTemplates[number]['id']) {
if (templateId === 'templates') return
const content = getDefaultShedContent(templateId)
const doc = await createDocument(
'shed',
getDefaultTitle('shed', templateId),
content,
templateId,
author,
steadId,
)
setOpen(false)
setShedOpen(false)
navigate(`/workspace/shed/${doc.id}`)
}
return (
<div ref={ref} className="relative">
<button
onClick={() => {
setOpen(!open)
setShedOpen(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"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create</span>
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 overflow-hidden rounded-xl border border-silos-cream-dark bg-white shadow-2xl">
<div className="border-b border-silos-cream-dark px-4 py-3">
<p className="text-sm font-semibold text-silos-text">Create new</p>
<p className="text-xs text-silos-muted">Choose what you want to build</p>
</div>
<div className="p-2">
{createOptions.map((option) => {
const Icon = iconMap[option.icon]
const isShed = option.id === 'shed'
return (
<div key={option.id} className="relative">
<button
onClick={() => {
if (isShed) {
setShedOpen(!shedOpen)
} else if (option.id === 'stead') {
handleCreateStead()
} else if (option.id !== 'shed') {
void handleCreate(option.id)
}
}}
className="flex w-full items-start gap-3 rounded-lg p-3 text-left transition-colors hover:bg-silos-cream/60"
>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${option.color}22` }}
>
{Icon && <Icon className="h-4 w-4" style={{ color: option.color }} />}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-silos-text">
{option.product}
<span className="ml-1.5 text-xs font-normal text-silos-muted">
— {option.label}
</span>
</p>
<p className="mt-0.5 text-xs text-silos-muted">{option.description}</p>
</div>
{isShed && (
<ChevronRight
className={`mt-1 h-4 w-4 shrink-0 text-silos-muted transition-transform ${
shedOpen ? 'rotate-90' : ''
}`}
/>
)}
</button>
{isShed && shedOpen && (
<div className="ml-12 mr-2 mb-2 space-y-0.5 rounded-lg border border-silos-cream-dark bg-silos-cream/30 p-1.5">
{shedTemplates.map((template) => (
<button
key={template.id}
disabled={!template.available}
onClick={() => void handleCreateShed(template.id)}
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-white disabled:cursor-not-allowed disabled:opacity-50"
>
<span>{template.emoji}</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-silos-text">{template.label}</p>
<p className="truncate text-[10px] text-silos-muted">
{template.description}
</p>
</div>
</button>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,50 @@
import { ChevronDown, TrendingUp } from 'lucide-react'
import { activityStats } from '../../data/dashboardData'
interface ActivityStatusProps {
documentCount?: number
}
export default function ActivityStatus({ documentCount = 0 }: ActivityStatusProps) {
return (
<div className="rounded-2xl border border-silos-cream-dark bg-white p-5">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-silos-text">Your Activity Status</h3>
<button className="flex items-center gap-1 rounded-lg border border-silos-cream-dark px-2.5 py-1 text-xs text-silos-muted hover:bg-silos-cream">
This Week
<ChevronDown className="h-3 w-3" />
</button>
</div>
<div className="mt-5 grid grid-cols-3 gap-4">
<div>
<p className="text-2xl font-bold text-silos-text">{documentCount}</p>
<p className="mt-0.5 text-xs text-silos-muted">Documents</p>
</div>
{activityStats.slice(1).map((stat) => (
<div key={stat.label}>
<p className="text-2xl font-bold text-silos-text">{stat.value}</p>
<p className="mt-0.5 text-xs text-silos-muted">{stat.label}</p>
<p className="mt-1 flex items-center gap-0.5 text-[10px] font-medium text-green-600">
<TrendingUp className="h-3 w-3" />
{stat.change}
</p>
</div>
))}
</div>
<div className="mt-6">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-silos-text">Your Contribution</span>
<span className="text-silos-muted">Top 15%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-silos-cream">
<div className="h-full w-[85%] rounded-full bg-silos-brown" />
</div>
<p className="mt-2 text-xs text-silos-muted">
You&apos;re in the top 15% of contributors in your team this week
</p>
</div>
</div>
)
}

View file

@ -0,0 +1,123 @@
import { useState } from 'react'
import RecentActivities from './RecentActivities'
import ActivityStatus from './ActivityStatus'
import DiscoverPanel from './DiscoverPanel'
import RecentProjectsGrid from './RecentProjectsGrid'
import { getStoredUser } from '../../lib/api'
import type { DocumentRecord } from '../../lib/documents'
function WorkspaceIllustration() {
return (
<svg viewBox="0 0 200 120" className="h-24 w-40 opacity-90" aria-hidden>
<rect x="60" y="50" width="80" height="50" rx="4" fill="#C17D65" opacity="0.2" />
<rect x="70" y="60" width="60" height="30" rx="2" fill="#6B3A2C" opacity="0.15" />
<circle cx="100" cy="35" r="12" fill="#C17D65" opacity="0.3" />
<rect x="85" y="48" width="30" height="20" rx="2" fill="#6B3A2C" opacity="0.2" />
<rect x="30" y="70" width="25" height="35" rx="3" fill="#7BAF8E" opacity="0.25" />
<rect x="145" y="65" width="30" height="40" rx="3" fill="#6B8CAE" opacity="0.25" />
<line x1="55" y1="85" x2="70" y2="75" stroke="#C17D65" strokeWidth="1.5" />
<line x1="140" y1="80" x2="145" y2="75" stroke="#C17D65" strokeWidth="1.5" />
</svg>
)
}
function getGreeting() {
const hour = new Date().getHours()
if (hour < 12) return 'Good morning'
if (hour < 17) return 'Good afternoon'
return 'Good evening'
}
interface DashboardFeedProps {
recent: DocumentRecord[]
loading?: boolean
error?: string | null
onOpenRecent?: () => void
}
export default function DashboardFeed({
recent,
loading,
error,
onOpenRecent,
}: DashboardFeedProps) {
const [activeTab, setActiveTab] = useState<'feed' | 'discover'>('feed')
const user = getStoredUser()
const firstName = user?.full_name?.split(' ')[0] || 'Alex'
return (
<div className="flex-1 overflow-y-auto bg-silos-cream/60 p-6 lg:p-8">
<div className="mx-auto max-w-6xl">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-silos-text sm:text-3xl">
{getGreeting()}, {firstName} 👋
</h1>
<p className="mt-1 text-silos-muted">
Here&apos;s what&apos;s happening in your workspace today.
</p>
</div>
<div className="hidden lg:block">
<WorkspaceIllustration />
</div>
</div>
{error && (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{error}
</div>
)}
{/* Recent pages grid */}
<div className="mt-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-silos-text">Recent</h2>
{recent.length > 0 && (
<button
type="button"
onClick={onOpenRecent}
className="text-xs font-medium text-silos-brown hover:underline"
>
View all
</button>
)}
</div>
<RecentProjectsGrid documents={recent} loading={loading} />
</div>
{/* Tabs */}
<div className="mt-8 flex gap-6 border-b border-silos-cream-dark">
{(['feed', 'discover'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`border-b-2 pb-3 text-sm font-medium capitalize transition-colors ${
activeTab === tab
? 'border-silos-brown text-silos-brown'
: 'border-transparent text-silos-muted hover:text-silos-text'
}`}
>
{tab}
</button>
))}
</div>
{activeTab === 'feed' ? (
<div className="mt-6 grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<RecentActivities documents={recent} loading={loading} onViewAll={onOpenRecent} />
</div>
<div className="space-y-6 lg:col-span-2">
<ActivityStatus documentCount={recent.length} />
<DiscoverPanel />
</div>
</div>
) : (
<div className="mt-6 max-w-2xl">
<DiscoverPanel />
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Menu,
Search,
Bell,
HelpCircle,
Settings,
ChevronDown,
} from 'lucide-react'
import { logos } from '../../data/brandAssets'
import { getStoredUser } from '../../lib/api'
import CreateDropdown from '../create/CreateDropdown'
import SearchOverlay from '../search/SearchOverlay'
interface DashboardHeaderProps {
onToggleSidebar: () => void
sidebarCollapsed: boolean
}
export default function DashboardHeader({
onToggleSidebar,
}: DashboardHeaderProps) {
const user = getStoredUser()
const displayName = user?.full_name?.split(' ')[0] || 'Alex'
const [searchOpen, setSearchOpen] = useState(false)
const [draft, setDraft] = useState('')
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setSearchOpen(true)
}
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) {
e.preventDefault()
setSearchOpen(true)
}
if (e.key === 'Escape') setSearchOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
return (
<>
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b border-silos-cream-dark bg-white px-4">
<div className="flex items-center gap-3">
<button
onClick={onToggleSidebar}
className="rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Toggle sidebar"
>
<Menu className="h-5 w-5" />
</button>
<Link to="/dashboard" className="hidden sm:block">
<img src={logos.rustyBrown} alt="Silos" className="h-7 w-auto" />
</Link>
<Link
to="/"
className="hidden text-sm text-silos-muted transition-colors hover:text-silos-brown md:block"
>
Home
</Link>
</div>
<div className="flex flex-1 items-center justify-center gap-3">
<div className="relative hidden w-full max-w-xl sm:block">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onFocus={() => setSearchOpen(true)}
placeholder="Search projects, docs, people..."
className="w-full rounded-full border border-silos-cream-dark bg-silos-cream/50 py-2 pl-10 pr-16 text-sm text-silos-text outline-none transition-colors focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
<kbd className="absolute right-3 top-1/2 hidden -translate-y-1/2 rounded border border-silos-cream-dark bg-white px-1.5 py-0.5 text-[10px] text-silos-muted lg:inline">
⌘ K
</kbd>
</div>
<CreateDropdown />
</div>
<div className="flex items-center gap-1">
<button
className="relative rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Notifications"
>
<Bell className="h-5 w-5" />
</button>
<button
className="rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Help"
>
<HelpCircle className="h-5 w-5" />
</button>
<button
className="rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Settings"
>
<Settings className="h-5 w-5" />
</button>
<button className="ml-1 flex items-center gap-1.5 rounded-full py-1 pl-1 pr-2 transition-colors hover:bg-silos-cream">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-silos-tan text-sm font-semibold text-white">
{displayName[0]}
</div>
<ChevronDown className="h-4 w-4 text-silos-muted" />
</button>
</div>
</header>
<SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} initialQuery={draft} />
</>
)
}

View file

@ -0,0 +1,85 @@
import { useState } from 'react'
import DashboardHeader from './DashboardHeader'
import DashboardSidebar from './DashboardSidebar'
import DashboardFeed from './DashboardFeed'
import DocumentFlyout, { type FlyoutMode } from './DocumentFlyout'
import TemplatesFlyout from './TemplatesFlyout'
import { useDashboardDocuments } from '../../hooks/useDashboardDocuments'
import type { NavItemId } from '../../data/dashboardData'
const flyoutMap: Partial<Record<NavItemId, FlyoutMode | 'templates'>> = {
recent: 'recent',
favourites: 'favourites',
'all-projects': 'all-projects',
templates: 'templates',
}
export default function DashboardLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [activeNav, setActiveNav] = useState<NavItemId>('for-you')
const [flyout, setFlyout] = useState<FlyoutMode | 'templates' | null>(null)
const { recent, projects, starredProjects, loading, error, refresh } =
useDashboardDocuments()
function handleNavChange(id: NavItemId) {
const mode = flyoutMap[id]
if (mode) {
setFlyout((current) => (current === mode ? null : mode))
setActiveNav(id)
} else {
setFlyout(null)
setActiveNav(id)
}
}
const flyoutDocuments =
flyout === 'recent'
? recent
: flyout === 'favourites'
? starredProjects
: flyout === 'all-projects'
? projects
: []
return (
<div className="flex h-screen flex-col bg-silos-cream">
<DashboardHeader
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
sidebarCollapsed={sidebarCollapsed}
/>
<div className="relative flex flex-1 overflow-hidden">
<DashboardSidebar
collapsed={sidebarCollapsed}
activeNav={activeNav}
onNavChange={handleNavChange}
flyoutOpen={flyout !== null}
spaces={projects}
/>
{flyout && flyout !== 'templates' && (
<DocumentFlyout
mode={flyout}
documents={flyoutDocuments}
sidebarCollapsed={sidebarCollapsed}
onClose={() => setFlyout(null)}
onStarChange={refresh}
/>
)}
{flyout === 'templates' && (
<TemplatesFlyout sidebarCollapsed={sidebarCollapsed} onClose={() => setFlyout(null)} />
)}
<DashboardFeed
recent={recent}
loading={loading}
error={error}
onOpenRecent={() => {
setActiveNav('recent')
setFlyout('recent')
}}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,158 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import {
Home,
LayoutTemplate,
Clock,
Star,
FolderOpen,
ChevronDown,
ChevronRight,
Plus,
Palette,
UserPlus,
type LucideIcon,
} from 'lucide-react'
import { navItems, type NavItemId } from '../../data/dashboardData'
import type { DocumentRecord } from '../../lib/documents'
const iconMap: Record<string, LucideIcon> = {
Home,
LayoutTemplate,
Clock,
Star,
FolderOpen,
}
const flyoutNavIds: NavItemId[] = ['recent', 'favourites', 'all-projects', 'templates']
interface DashboardSidebarProps {
collapsed: boolean
activeNav: NavItemId
onNavChange: (id: NavItemId) => void
flyoutOpen?: boolean
spaces?: DocumentRecord[]
}
export default function DashboardSidebar({
collapsed,
activeNav,
onNavChange,
flyoutOpen = false,
spaces = [],
}: DashboardSidebarProps) {
const [personalOpen, setPersonalOpen] = useState(true)
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">
{navItems.map((item) => {
const Icon = iconMap[item.icon]
const hasFlyout = flyoutNavIds.includes(item.id)
const isActive = activeNav === item.id && (hasFlyout ? flyoutOpen : true)
return (
<button
key={item.id}
onClick={() => onNavChange(item.id)}
title={item.label}
className={`mb-1 rounded-lg p-2.5 transition-colors ${
isActive
? 'bg-silos-brown/10 text-silos-brown'
: 'text-silos-muted hover:bg-silos-cream hover:text-silos-text'
}`}
>
{Icon && <Icon className="h-5 w-5" />}
</button>
)
})}
</aside>
)
}
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">
{navItems.map((item) => {
const Icon = iconMap[item.icon]
const hasFlyout = flyoutNavIds.includes(item.id)
const isActive = activeNav === item.id && (hasFlyout ? flyoutOpen : true)
return (
<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 ${
isActive
? 'bg-silos-brown/10 font-medium text-silos-brown'
: 'text-silos-text hover:bg-silos-cream'
}`}
>
{Icon && <Icon className="h-4 w-4 shrink-0" />}
<span className="flex-1 text-left">{item.label}</span>
{hasFlyout && (
<ChevronRight
className={`h-4 w-4 shrink-0 text-silos-muted transition-transform ${
isActive && flyoutOpen ? 'rotate-90' : ''
}`}
/>
)}
</button>
)
})}
<div className="my-4 border-t border-dashed border-silos-cream-dark" />
<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"
>
Personal Space
{personalOpen ? (
<ChevronDown className="h-4 w-4 text-silos-muted" />
) : (
<ChevronRight className="h-4 w-4 text-silos-muted" />
)}
</button>
{personalOpen && (
<div className="mt-1 space-y-0.5 pl-1">
{spaces.length === 0 ? (
<p className="px-3 py-2 text-xs text-silos-muted">No spaces yet</p>
) : (
spaces.map((space) => (
<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"
>
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-silos-tan/20 text-xs">
{(space.content.icon as string) || '📁'}
</span>
<span className="truncate">{space.title}</span>
</Link>
))
)}
<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"
>
<Plus className="h-4 w-4" />
Add space
</Link>
</div>
)}
</nav>
<div className="space-y-2 border-t border-silos-cream-dark p-3">
<button 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">
<Palette className="h-4 w-4" />
Customize
</button>
<button 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
</button>
</div>
</aside>
)
}

View file

@ -0,0 +1,52 @@
import {
LayoutTemplate,
FolderOpen,
BookOpen,
ChevronRight,
type LucideIcon,
} from 'lucide-react'
import { discoverItems } from '../../data/dashboardData'
const iconMap: Record<string, LucideIcon> = {
LayoutTemplate,
FolderOpen,
BookOpen,
}
export default function DiscoverPanel() {
return (
<div className="space-y-6">
<div className="rounded-2xl border border-silos-cream-dark bg-white p-5">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-silos-text">Discover something new</h3>
<button className="text-sm font-medium text-silos-tan hover:text-silos-brown">
See all
</button>
</div>
<ul className="mt-4 space-y-1">
{discoverItems.map((item) => {
const Icon = iconMap[item.icon]
return (
<li key={item.id}>
<button className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition-colors hover:bg-silos-cream/50">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${item.color}22` }}
>
{Icon && <Icon className="h-5 w-5" style={{ color: item.color }} />}
</div>
<div className="min-w-0 flex-1">
<p className="font-medium text-silos-text">{item.title}</p>
<p className="text-xs text-silos-muted">{item.description}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-silos-muted" />
</button>
</li>
)
})}
</ul>
</div>
</div>
)
}

View file

@ -0,0 +1,185 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { X, Search, FileText, Zap, PenTool, LayoutGrid, Star } from 'lucide-react'
import type { CreateType } from '../../data/createData'
import type { DocumentRecord } from '../../lib/documents'
import {
groupByTimePeriod,
formatRelativeTime,
getActivityDate,
typeColors,
typeLabels,
} from '../../lib/documentUtils'
import { toggleStar } from '../../lib/documents'
const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText,
shed: Zap,
sketch: PenTool,
stead: LayoutGrid,
}
export type FlyoutMode = 'recent' | 'favourites' | 'all-projects'
interface DocumentFlyoutProps {
mode: FlyoutMode
documents: DocumentRecord[]
sidebarCollapsed: boolean
onClose: () => void
onStarChange?: () => void
}
const titles: Record<FlyoutMode, string> = {
recent: 'Recent',
favourites: 'Favourite Projects',
'all-projects': 'All Projects',
}
export default function DocumentFlyout({
mode,
documents,
sidebarCollapsed,
onClose,
onStarChange,
}: DocumentFlyoutProps) {
const [filter, setFilter] = useState('')
const filtered = useMemo(() => {
const q = filter.toLowerCase().trim()
if (!q) return documents
return documents.filter(
(d) =>
d.title.toLowerCase().includes(q) ||
d.createdBy.toLowerCase().includes(q) ||
typeLabels[d.type].toLowerCase().includes(q),
)
}, [documents, filter])
const groups = useMemo(
() => (mode === 'recent' ? groupByTimePeriod(filtered) : [{ label: '', items: filtered }]),
[mode, filtered],
)
async function handleToggleStar(doc: DocumentRecord) {
await toggleStar(doc.id, !doc.starred)
onStarChange?.()
}
return (
<>
<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'
}`}
>
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
<h2 className="text-lg font-semibold text-silos-text">{titles[mode]}</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="border-b border-silos-cream-dark px-4 py-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-silos-muted" />
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={
mode === 'recent' ? 'Filter recent items' : 'Filter projects'
}
className="w-full rounded-lg border border-silos-cream-dark bg-silos-cream/30 py-2 pl-9 pr-3 text-sm outline-none focus:border-silos-brown"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-silos-muted">
{mode === 'favourites'
? 'No starred projects yet. Star a Stead space to add it here.'
: mode === 'all-projects'
? 'No projects yet. Create a Stead space to get started.'
: 'No recent items. Create a page, whiteboard, or space.'}
</div>
) : (
groups.map((group) => (
<div key={group.label || 'all'}>
{group.label && (
<p className="px-4 pb-1 pt-4 text-xs font-semibold uppercase tracking-wide text-silos-muted">
{group.label}
</p>
)}
<ul>
{group.items.map((doc) => {
const Icon = typeIcons[doc.type]
return (
<li key={doc.id} className="group">
<div className="flex items-center hover:bg-silos-cream/50">
<Link
to={`/workspace/${doc.type}/${doc.id}`}
onClick={onClose}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${typeColors[doc.type]}22` }}
>
<Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-silos-text">
{doc.title}
</p>
<p className="truncate text-xs text-silos-muted">
{doc.createdBy} · {formatRelativeTime(getActivityDate(doc))}
</p>
</div>
</Link>
{doc.type === 'stead' && (
<button
type="button"
onClick={() => void handleToggleStar(doc)}
className="mr-3 rounded p-1.5 opacity-0 transition-opacity group-hover:opacity-100"
title={doc.starred ? 'Unstar' : 'Star'}
>
<Star
className={`h-4 w-4 ${
doc.starred
? 'fill-amber-400 text-amber-400'
: 'text-silos-muted'
}`}
/>
</button>
)}
</div>
</li>
)
})}
</ul>
</div>
))
)}
</div>
{mode === 'recent' && filtered.length > 0 && (
<div className="border-t border-silos-cream-dark p-3">
<button
type="button"
onClick={onClose}
className="w-full text-center text-sm font-medium text-silos-brown hover:underline"
>
View all recent items
</button>
</div>
)}
</aside>
</>
)
}

View file

@ -0,0 +1,129 @@
import { Link } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, ChevronRight } from 'lucide-react'
import type { CreateType } from '../../data/createData'
import type { DocumentRecord } from '../../lib/documents'
import {
formatRelativeTime,
getActivityDate,
getProjectColor,
getProjectIcon,
typeColors,
typeLabels,
} from '../../lib/documentUtils'
const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText,
shed: Zap,
sketch: PenTool,
stead: LayoutGrid,
}
function getActionLabel(doc: DocumentRecord): string {
const created = new Date(doc.createdAt).getTime()
const updated = new Date(doc.updatedAt).getTime()
if (Math.abs(updated - created) < 5000) {
return `Created by ${doc.createdBy}`
}
return `Updated by ${doc.createdBy}`
}
interface RecentActivitiesProps {
documents: DocumentRecord[]
loading?: boolean
onViewAll?: () => void
}
export default function RecentActivities({
documents,
loading,
onViewAll,
}: RecentActivitiesProps) {
const items = documents.slice(0, 8)
return (
<div className="rounded-2xl border border-silos-cream-dark bg-white">
<div className="flex items-center justify-between border-b border-silos-cream-dark px-5 py-4">
<h3 className="font-semibold text-silos-text">Recent Activities</h3>
{items.length > 0 && (
<button
type="button"
onClick={onViewAll}
className="text-sm font-medium text-silos-tan hover:text-silos-brown"
>
View all
</button>
)}
</div>
{loading ? (
<ul className="divide-y divide-silos-cream-dark">
{Array.from({ length: 4 }).map((_, i) => (
<li key={i} className="px-5 py-4">
<div className="h-10 animate-pulse rounded-lg bg-silos-cream" />
</li>
))}
</ul>
) : items.length === 0 ? (
<div className="px-5 py-12 text-center text-sm text-silos-muted">
No activity yet. Create a Silo, Sketch, Shed, or Stead to get started.
</div>
) : (
<ul className="divide-y divide-silos-cream-dark">
{items.map((doc) => {
const Icon = typeIcons[doc.type]
const color = doc.type === 'stead' ? getProjectColor(doc) : typeColors[doc.type]
const initial = doc.createdBy[0]?.toUpperCase() ?? '?'
return (
<li key={doc.id}>
<Link
to={`/workspace/${doc.type}/${doc.id}`}
className="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-silos-cream/40"
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${color}22` }}
>
{doc.type === 'stead' ? (
<span className="text-lg">{getProjectIcon(doc)}</span>
) : (
<Icon className="h-5 w-5" style={{ color }} />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-silos-text">{doc.title}</p>
<p className="mt-0.5 truncate text-sm text-silos-muted">
{getActionLabel(doc)} · {typeLabels[doc.type]}
</p>
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="hidden text-xs text-silos-muted sm:inline">
{formatRelativeTime(getActivityDate(doc))}
</span>
<div
className="flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: color }}
>
{initial}
</div>
</div>
</Link>
</li>
)
})}
</ul>
)}
{items.length > 0 && (
<button
type="button"
onClick={onViewAll}
className="flex w-full items-center justify-center gap-1 border-t border-silos-cream-dark py-3.5 text-sm font-medium text-silos-brown transition-colors hover:bg-silos-cream/40"
>
View all recent activities
<ChevronRight className="h-4 w-4" />
</button>
)}
</div>
)
}

View file

@ -0,0 +1,90 @@
import { Link } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid } from 'lucide-react'
import type { CreateType } from '../../data/createData'
import type { DocumentRecord } from '../../lib/documents'
import {
formatVisitedDate,
getActivityDate,
getProjectColor,
getProjectIcon,
typeColors,
typeLabels,
} from '../../lib/documentUtils'
const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText,
shed: Zap,
sketch: PenTool,
stead: LayoutGrid,
}
interface RecentProjectsGridProps {
documents: DocumentRecord[]
loading?: boolean
}
export default function RecentProjectsGrid({ documents, loading }: RecentProjectsGridProps) {
const items = documents.slice(0, 6)
if (loading) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-36 animate-pulse rounded-2xl border border-silos-cream-dark bg-white"
/>
))}
</div>
)
}
if (items.length === 0) {
return (
<div className="rounded-2xl border-2 border-dashed border-silos-cream-dark bg-white py-16 text-center">
<p className="text-sm text-silos-muted">
No recent pages yet. Use Create to start a Silo, Sketch, Shed, or Stead.
</p>
</div>
)
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((doc) => {
const Icon = typeIcons[doc.type]
const color = doc.type === 'stead' ? getProjectColor(doc) : typeColors[doc.type]
return (
<Link
key={doc.id}
to={`/workspace/${doc.type}/${doc.id}`}
className="group rounded-2xl border border-silos-cream-dark bg-white p-5 transition-all hover:border-silos-tan hover:shadow-md"
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-lg"
style={{ backgroundColor: color + '33' }}
>
{doc.type === 'stead' ? (
<span>{getProjectIcon(doc)}</span>
) : (
<Icon className="h-5 w-5" style={{ color }} />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-silos-text group-hover:text-silos-brown">
{doc.title}
</p>
<p className="mt-1 text-xs text-silos-muted">{typeLabels[doc.type]}</p>
</div>
</div>
<p className="mt-4 text-xs text-silos-muted">
{doc.createdBy} · {formatVisitedDate(getActivityDate(doc))}
</p>
</Link>
)
})}
</div>
)
}

View file

@ -0,0 +1,108 @@
import { useNavigate } from 'react-router-dom'
import { X } from 'lucide-react'
import { shedTemplates } from '../../data/createData'
import {
createDocument,
getDefaultShedContent,
getDefaultTitle,
} from '../../lib/documents'
import { getStoredUser } from '../../lib/api'
interface TemplatesFlyoutProps {
sidebarCollapsed: boolean
onClose: () => void
}
const siloStarters = [
{ id: 'blank', label: 'Blank page', description: 'Empty Silo for your own content', html: '' },
{
id: 'meeting',
label: 'Meeting notes',
description: 'Silo starter for meetings',
html: '<h2>Meeting notes</h2><p>Date:</p><ul><li>Attendees</li><li>Agenda</li><li>Actions</li></ul>',
},
{
id: 'plan',
label: 'Project plan',
description: 'Silo starter for a project plan',
html: '<h2>Project plan</h2><p>Goals</p><ul><li>Milestone 1</li><li>Milestone 2</li></ul>',
},
]
export default function TemplatesFlyout({ sidebarCollapsed, onClose }: TemplatesFlyoutProps) {
const navigate = useNavigate()
const author = getStoredUser()?.full_name ?? 'You'
async function createShed(templateId: (typeof shedTemplates)[number]['id']) {
if (templateId === 'templates') return
const doc = await createDocument(
'shed',
getDefaultTitle('shed', templateId),
getDefaultShedContent(templateId),
templateId,
author,
)
onClose()
navigate(`/workspace/shed/${doc.id}`)
}
async function createSilo(html: string, label: string) {
const doc = await createDocument(
'silo',
`${label} — ${new Date().toLocaleDateString()}`,
{ body: '', bodyHtml: html, status: 'draft' },
undefined,
author,
)
onClose()
navigate(`/workspace/silo/${doc.id}`)
}
return (
<>
<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'
}`}
>
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
<h2 className="text-lg font-semibold text-silos-text">Templates</h2>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 hover:bg-silos-cream">
<X className="h-4 w-4 text-silos-muted" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-3">
<p className="px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Shed — Live docs</p>
{shedTemplates.map((t) => (
<button
key={t.id}
type="button"
disabled={!t.available}
onClick={() => void createShed(t.id)}
className="mb-1 flex w-full items-start gap-3 rounded-lg px-3 py-3 text-left hover:bg-silos-cream disabled:opacity-50"
>
<span className="text-lg">{t.emoji}</span>
<div>
<p className="text-sm font-medium text-silos-text">{t.label}</p>
<p className="text-xs text-silos-muted">{t.description}</p>
</div>
</button>
))}
<p className="mt-4 px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Silo — Pages</p>
{siloStarters.map((t) => (
<button
key={t.id}
type="button"
onClick={() => void createSilo(t.html, t.label)}
className="mb-1 flex w-full flex-col rounded-lg px-3 py-3 text-left hover:bg-silos-cream"
>
<p className="text-sm font-medium text-silos-text">{t.label}</p>
<p className="text-xs text-silos-muted">{t.description}</p>
</button>
))}
</div>
</aside>
</>
)
}

View file

@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, Search, Loader2 } from 'lucide-react'
import { searchDocuments, type DocumentRecord } from '../../lib/documents'
import { formatRelativeTime, getActivityDate, typeColors, typeLabels } from '../../lib/documentUtils'
import type { CreateType } from '../../data/createData'
const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText,
shed: Zap,
sketch: PenTool,
stead: LayoutGrid,
}
interface SearchOverlayProps {
open: boolean
onClose: () => void
initialQuery?: string
}
export default function SearchOverlay({ open, onClose, initialQuery = '' }: SearchOverlayProps) {
const [query, setQuery] = useState(initialQuery)
const [results, setResults] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
useEffect(() => {
if (open) {
setQuery(initialQuery)
setTimeout(() => inputRef.current?.focus(), 0)
}
}, [open, initialQuery])
const runSearch = useCallback(async (value: string) => {
if (!value.trim()) {
setResults([])
setError(null)
return
}
setLoading(true)
setError(null)
try {
const docs = await searchDocuments(value)
setResults(docs)
} catch {
setError('Could not search. Is MongoDB running?')
setResults([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!open) return
const timer = setTimeout(() => void runSearch(query), 250)
return () => clearTimeout(timer)
}, [query, open, runSearch])
if (!open) return null
return (
<div className="fixed inset-0 z-[80] flex items-start justify-center bg-black/30 pt-24 px-4">
<button type="button" className="absolute inset-0 cursor-default" onClick={onClose} aria-label="Close search" />
<div className="relative z-10 w-full max-w-xl overflow-hidden rounded-2xl border border-silos-cream-dark bg-white shadow-2xl">
<div className="flex items-center gap-2 border-b border-silos-cream-dark px-4">
<Search className="h-4 w-4 text-silos-muted" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search pages, spaces, whiteboards..."
className="w-full bg-transparent py-3 text-sm text-silos-text outline-none"
/>
{loading && <Loader2 className="h-4 w-4 animate-spin text-silos-muted" />}
<kbd className="rounded border border-silos-cream-dark px-1.5 py-0.5 text-[10px] text-silos-muted">Esc</kbd>
</div>
<div className="max-h-80 overflow-y-auto">
{error && <p className="px-4 py-6 text-center text-sm text-amber-700">{error}</p>}
{!error && query.trim() && !loading && results.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-silos-muted">No matches for “{query}”.</p>
)}
{!query.trim() && (
<p className="px-4 py-6 text-center text-sm text-silos-muted">
Type to search your documents.
</p>
)}
{results.map((doc) => {
const Icon = typeIcons[doc.type]
return (
<button
key={doc.id}
type="button"
onClick={() => {
onClose()
navigate(`/workspace/${doc.type}/${doc.id}`)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-silos-cream/60"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${typeColors[doc.type]}22` }}
>
<Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-silos-text">{doc.title}</p>
<p className="text-xs text-silos-muted">
{typeLabels[doc.type]} · {formatRelativeTime(getActivityDate(doc))}
</p>
</div>
</button>
)
})}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,105 @@
interface SectionProps {
icon: string
title: string
children: React.ReactNode
}
export function DocSection({ icon, title, children }: SectionProps) {
return (
<section className="mb-8">
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold text-silos-text">
<span>{icon}</span>
{title}
</h2>
{children}
</section>
)
}
export function InfoBanner({ children }: { children: React.ReactNode }) {
return (
<div className="mb-6 flex gap-3 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900">
<span className="shrink-0 font-bold">ℹ️</span>
<div>{children}</div>
</div>
)
}
export function FieldLabel({ children }: { children: React.ReactNode }) {
return <label className="mb-1.5 block text-sm font-medium text-silos-text">{children}</label>
}
export function TextInput({
value,
onChange,
placeholder,
className = '',
}: {
value: string
onChange: (v: string) => void
placeholder?: string
className?: string
}) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`w-full rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm text-silos-text outline-none focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20 ${className}`}
/>
)
}
export function TextArea({
value,
onChange,
placeholder,
rows = 3,
}: {
value: string
onChange: (v: string) => void
placeholder?: string
rows?: number
}) {
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="w-full resize-y rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm text-silos-text outline-none focus:border-silos-tan focus:ring-2 focus:ring-silos-tan/20"
/>
)
}
export function MetaTable({ rows }: { rows: { label: string; value: React.ReactNode }[] }) {
return (
<table className="mb-6 w-full border-collapse overflow-hidden rounded-lg border border-silos-cream-dark">
<tbody>
{rows.map((row) => (
<tr key={row.label} className="border-b border-silos-cream-dark last:border-0">
<td className="w-48 bg-silos-cream/40 px-4 py-3 text-sm font-medium text-silos-text">
{row.label}
</td>
<td className="px-4 py-3">{row.value}</td>
</tr>
))}
</tbody>
</table>
)
}
export function PriorityBadge({ priority }: { priority: string }) {
const colors: Record<string, string> = {
HIGH: 'bg-red-100 text-red-700',
MEDIUM: 'bg-yellow-100 text-yellow-700',
LOW: 'bg-green-100 text-green-700',
}
return (
<span
className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${colors[priority] ?? colors.MEDIUM}`}
>
{priority}
</span>
)
}

View file

@ -0,0 +1,326 @@
import {
DocSection,
InfoBanner,
MetaTable,
TextInput,
TextArea,
PriorityBadge,
} from '../DocBlocks'
import type { DocumentRecord } from '../../../lib/documents'
type ArchContent = {
reviewDate: string
projectLead: string
overview: string
architectureIssues: { issue: string; impact: string; priority: string; notes: string }[]
stakeholders: { name: string; role: string }[]
qualityAttributes: { attribute: string; definition: string; metrics: string; notes: string }[]
goals: string
nextSteps: {
project: string
description: string
estimate: string
documentation: string
releaseDate: string
}[]
}
interface Props {
doc: DocumentRecord
onUpdate: (u: Partial<DocumentRecord>) => void
}
export default function ArchitectureReviewTemplate({ doc, onUpdate }: Props) {
const content = doc.content as ArchContent
function saveContent(next: ArchContent) {
onUpdate({ content: next })
}
function patch<K extends keyof ArchContent>(key: K, value: ArchContent[K]) {
saveContent({ ...content, [key]: value })
}
return (
<>
<InfoBanner>
Reflect on architecture decisions, identify issues, and align stakeholders on quality
attributes and next steps.
</InfoBanner>
<MetaTable
rows={[
{
label: 'Architecture review date',
value: (
<TextInput
value={content.reviewDate}
onChange={(v) => patch('reviewDate', v)}
placeholder="Type // to add a date"
/>
),
},
{
label: 'Project lead',
value: (
<TextInput
value={content.projectLead}
onChange={(v) => patch('projectLead', v)}
placeholder="@ mention lead"
/>
),
},
]}
/>
<DocSection icon="📋" title="Overview">
<TextArea
value={content.overview}
onChange={(v) => patch('overview', v)}
placeholder="Type /image to add an architectural diagram that explains how the structural components of your architecture work together."
rows={4}
/>
</DocSection>
<DocSection icon="🚩" title="Architecture issues">
<p className="mb-3 text-sm text-silos-muted">
Document known architecture issues and their business impact.
</p>
<div className="overflow-x-auto rounded-lg border border-silos-cream-dark">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="bg-silos-cream/60 text-left">
<th className="px-3 py-2 font-medium">Architecture issue</th>
<th className="px-3 py-2 font-medium">Business impact</th>
<th className="px-3 py-2 font-medium">Priority</th>
<th className="px-3 py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody>
{content.architectureIssues.map((row, i) => (
<tr key={i} className="border-t border-silos-cream-dark">
<td className="px-3 py-2">
<TextInput
value={row.issue}
onChange={(v) => {
const next = [...content.architectureIssues]
next[i] = { ...row, issue: v }
patch('architectureIssues', next)
}}
placeholder="e.g., Monolithic deployment"
/>
</td>
<td className="px-3 py-2">
<TextInput
value={row.impact}
onChange={(v) => {
const next = [...content.architectureIssues]
next[i] = { ...row, impact: v }
patch('architectureIssues', next)
}}
placeholder="Business impact"
/>
</td>
<td className="px-3 py-2">
<select
value={row.priority}
onChange={(e) => {
const next = [...content.architectureIssues]
next[i] = { ...row, priority: e.target.value }
patch('architectureIssues', next)
}}
className="rounded border border-silos-cream-dark px-2 py-1 text-xs"
>
<option value="HIGH">HIGH</option>
<option value="MEDIUM">MEDIUM</option>
<option value="LOW">LOW</option>
</select>
<div className="mt-1">
<PriorityBadge priority={row.priority} />
</div>
</td>
<td className="px-3 py-2">
<TextInput
value={row.notes}
onChange={(v) => {
const next = [...content.architectureIssues]
next[i] = { ...row, notes: v }
patch('architectureIssues', next)
}}
placeholder="Notes"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() =>
patch('architectureIssues', [
...content.architectureIssues,
{ issue: '', impact: '', priority: 'MEDIUM', notes: '' },
])
}
className="mt-2 text-sm text-silos-brown hover:underline"
>
+ Add row
</button>
</DocSection>
<DocSection icon="👤" title="Stakeholders">
<div className="overflow-x-auto rounded-lg border border-silos-cream-dark">
<table className="w-full text-sm">
<thead>
<tr className="bg-silos-cream/60 text-left">
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Role</th>
</tr>
</thead>
<tbody>
{content.stakeholders.map((row, i) => (
<tr key={i} className="border-t border-silos-cream-dark">
<td className="px-3 py-2">
<TextInput
value={row.name}
onChange={(v) => {
const next = [...content.stakeholders]
next[i] = { ...row, name: v }
patch('stakeholders', next)
}}
placeholder="@ stakeholder"
/>
</td>
<td className="px-3 py-2">
<TextInput
value={row.role}
onChange={(v) => {
const next = [...content.stakeholders]
next[i] = { ...row, role: v }
patch('stakeholders', next)
}}
placeholder="e.g., Lead Architect"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() => patch('stakeholders', [...content.stakeholders, { name: '', role: '' }])}
className="mt-2 text-sm text-silos-brown hover:underline"
>
+ Add stakeholder
</button>
</DocSection>
<DocSection icon="✏️" title="Software quality attributes">
<p className="mb-3 text-sm text-silos-muted">
Select 1–2 attributes your team will focus on during the architecture review.
</p>
<div className="overflow-x-auto rounded-lg border border-silos-cream-dark">
<table className="w-full min-w-[700px] text-sm">
<thead>
<tr className="bg-yellow-50 text-left">
<th className="px-3 py-2 font-medium">Attribute</th>
<th className="px-3 py-2 font-medium">Definition</th>
<th className="px-3 py-2 font-medium">Key success metrics</th>
<th className="px-3 py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody>
{content.qualityAttributes.map((row, i) => (
<tr key={i} className="border-t border-silos-cream-dark">
{(['attribute', 'definition', 'metrics', 'notes'] as const).map((field) => (
<td key={field} className="px-3 py-2">
<TextInput
value={row[field]}
onChange={(v) => {
const next = [...content.qualityAttributes]
next[i] = { ...row, [field]: v }
patch('qualityAttributes', next)
}}
placeholder={
field === 'attribute'
? 'e.g., Availability'
: field === 'definition'
? 'How often the system is up'
: field === 'metrics'
? 'Time to fix a failure'
: 'Links to docs'
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</DocSection>
<DocSection icon="🎯" title="Goals">
<TextArea
value={content.goals}
onChange={(v) => patch('goals', v)}
placeholder="Type / to add an image of an architectural diagram..."
rows={3}
/>
</DocSection>
<DocSection icon="👣" title="Next steps">
<p className="mb-3 text-sm text-silos-muted">
Use this space to plan projects and estimate work.
</p>
<div className="overflow-x-auto rounded-lg border border-silos-cream-dark">
<table className="w-full min-w-[800px] text-sm">
<thead>
<tr className="bg-silos-cream/60 text-left">
<th className="px-3 py-2 font-medium">Project</th>
<th className="px-3 py-2 font-medium">Description</th>
<th className="px-3 py-2 font-medium">Estimate</th>
<th className="px-3 py-2 font-medium">Documentation</th>
<th className="px-3 py-2 font-medium">Target release</th>
</tr>
</thead>
<tbody>
{content.nextSteps.map((row, i) => (
<tr key={i} className="border-t border-silos-cream-dark">
{(['project', 'description', 'estimate', 'documentation', 'releaseDate'] as const).map(
(field) => (
<td key={field} className="px-3 py-2">
<TextInput
value={row[field]}
onChange={(v) => {
const next = [...content.nextSteps]
next[i] = { ...row, [field]: v }
patch('nextSteps', next)
}}
placeholder={
field === 'project' ? 'e.g., Frontend improvements' : ''
}
/>
</td>
),
)}
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() =>
patch('nextSteps', [
...content.nextSteps,
{ project: '', description: '', estimate: '', documentation: '', releaseDate: '' },
])
}
className="mt-2 text-sm text-silos-brown hover:underline"
>
+ Add project
</button>
</DocSection>
</>
)
}

View file

@ -0,0 +1,173 @@
import { DocSection, TextInput, TextArea } from '../DocBlocks'
import type { DocumentRecord } from '../../../lib/documents'
type MeetingContent = {
date: string
participants: string
goals: string
discussionTopics: { time: string; topic: string; presenter: string; notes: string }[]
actionItems: string[]
decisions: string[]
relatedInfo: string
}
interface Props {
doc: DocumentRecord
onUpdate: (u: Partial<DocumentRecord>) => void
}
export default function MeetingNotesTemplate({ doc, onUpdate }: Props) {
const content = doc.content as MeetingContent
function saveContent(next: MeetingContent) {
onUpdate({ content: next })
}
function patch<K extends keyof MeetingContent>(key: K, value: MeetingContent[K]) {
saveContent({ ...content, [key]: value })
}
return (
<>
<DocSection icon="📅" title="Date">
<TextInput
value={content.date}
onChange={(v) => patch('date', v)}
placeholder="30 Aug 2026"
/>
</DocSection>
<DocSection icon="👥" title="Participants">
<TextArea
value={content.participants}
onChange={(v) => patch('participants', v)}
placeholder="Type @name to mention a person and add them as a meeting attendee"
rows={2}
/>
</DocSection>
<DocSection icon="🎯" title="Goals">
<TextArea
value={content.goals}
onChange={(v) => patch('goals', v)}
placeholder="List goals for this meeting (e.g., Set design priorities for FY27)"
rows={2}
/>
</DocSection>
<DocSection icon="🎨" title="Brainstorm">
<div className="rounded-lg border border-dashed border-silos-cream-dark bg-silos-cream/30 px-4 py-6 text-center text-sm text-silos-muted">
Type /whiteboard to create an interactive canvas for icebreakers, brainstorms, and
diagramming
</div>
</DocSection>
<DocSection icon="🗣️" title="Discussion topics">
<div className="overflow-x-auto rounded-lg border border-silos-cream-dark">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="bg-silos-cream/60 text-left">
<th className="px-3 py-2 font-medium">Time</th>
<th className="px-3 py-2 font-medium">Topic</th>
<th className="px-3 py-2 font-medium">Presenter</th>
<th className="px-3 py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody>
{content.discussionTopics.map((row, i) => (
<tr key={i} className="border-t border-silos-cream-dark">
{(['time', 'topic', 'presenter', 'notes'] as const).map((field) => (
<td key={field} className="px-3 py-2">
<TextInput
value={row[field]}
onChange={(v) => {
const next = [...content.discussionTopics]
next[i] = { ...row, [field]: v }
patch('discussionTopics', next)
}}
placeholder={
field === 'presenter' ? '@ mention teammate' : ''
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() =>
patch('discussionTopics', [
...content.discussionTopics,
{ time: '', topic: '', presenter: '', notes: '' },
])
}
className="mt-2 text-sm text-silos-brown hover:underline"
>
+ Add topic
</button>
</DocSection>
<DocSection icon="✅" title="Action items">
<p className="mb-2 text-sm text-silos-muted">
Type your action, use @ to assign to someone.
</p>
{content.actionItems.map((item, i) => (
<div key={i} className="mb-2 flex items-center gap-2">
<input type="checkbox" className="h-4 w-4 rounded" />
<TextInput
value={item}
onChange={(v) => {
const next = [...content.actionItems]
next[i] = v
patch('actionItems', next)
}}
placeholder="Type your action..."
/>
</div>
))}
<button
onClick={() => patch('actionItems', [...content.actionItems, ''])}
className="text-sm text-silos-brown hover:underline"
>
+ Add action item
</button>
</DocSection>
<DocSection icon="⤴️" title="Decisions">
<p className="mb-2 text-sm text-silos-muted">
Use /decision to record decisions made during the meeting.
</p>
{content.decisions.map((item, i) => (
<div key={i} className="mb-2">
<TextInput
value={item}
onChange={(v) => {
const next = [...content.decisions]
next[i] = v
patch('decisions', next)
}}
placeholder="Record a decision..."
/>
</div>
))}
<button
onClick={() => patch('decisions', [...content.decisions, ''])}
className="text-sm text-silos-brown hover:underline"
>
+ Add decision
</button>
</DocSection>
<DocSection icon="💼" title="Related info">
<TextArea
value={content.relatedInfo}
onChange={(v) => patch('relatedInfo', v)}
placeholder="Type /database to link assets or other meetings"
rows={2}
/>
</DocSection>
</>
)
}

View file

@ -0,0 +1,157 @@
import { DocSection, InfoBanner, TextArea } from '../DocBlocks'
import type { DocumentRecord } from '../../../lib/documents'
type RetroContent = {
overview: string
startDoing: string[]
stopDoing: string[]
keepDoing: string[]
actionItems: string[]
}
interface Props {
doc: DocumentRecord
onUpdate: (u: Partial<DocumentRecord>) => void
}
function RetroColumn({
title,
icon,
color,
items,
onChange,
placeholder,
}: {
title: string
icon: string
color: string
items: string[]
onChange: (items: string[]) => void
placeholder: string
}) {
return (
<div className="flex-1 min-w-[180px]">
<div
className="rounded-t-lg px-3 py-2 text-sm font-semibold"
style={{ backgroundColor: color }}
>
{icon} {title}
</div>
<div className="rounded-b-lg border border-t-0 border-silos-cream-dark bg-white p-3">
<ul className="space-y-2">
{items.map((item, i) => (
<li key={i} className="flex gap-2">
<span className="text-silos-muted">•</span>
<input
value={item}
onChange={(e) => {
const next = [...items]
next[i] = e.target.value
onChange(next)
}}
placeholder={placeholder}
className="flex-1 border-none bg-transparent text-sm outline-none placeholder:text-silos-muted"
/>
</li>
))}
</ul>
<button
onClick={() => onChange([...items, ''])}
className="mt-2 text-xs text-silos-brown hover:underline"
>
+ Add item
</button>
</div>
</div>
)
}
export default function RetrospectiveTemplate({ doc, onUpdate }: Props) {
const content = doc.content as RetroContent
function saveContent(next: RetroContent) {
onUpdate({ content: next })
}
function patch<K extends keyof RetroContent>(key: K, value: RetroContent[K]) {
saveContent({ ...content, [key]: value })
}
return (
<>
<InfoBanner>
Reflect on past work and identify opportunities for improvement by following the
instructions for the Retrospective Play.
</InfoBanner>
<DocSection icon="📋" title="Overview">
<TextArea
value={content.overview}
onChange={(v) => patch('overview', v)}
placeholder="Add context for this retrospective — team, sprint, or project scope"
rows={2}
/>
</DocSection>
<DocSection icon="💭" title="Retrospective">
<div className="flex flex-col gap-4 lg:flex-row">
<RetroColumn
title="Start doing"
icon="▶️"
color="#dbeafe"
items={content.startDoing}
onChange={(v) => patch('startDoing', v)}
placeholder="e.g., Sparring work earlier in the cycle"
/>
<RetroColumn
title="Stop doing"
icon="⏹️"
color="#fee2e2"
items={content.stopDoing}
onChange={(v) => patch('stopDoing', v)}
placeholder="e.g., Meetings without a clear agenda"
/>
<RetroColumn
title="Keep doing"
icon="🏆"
color="#fef9c3"
items={content.keepDoing}
onChange={(v) => patch('keepDoing', v)}
placeholder="e.g., Sharing progress at daily standups"
/>
</div>
</DocSection>
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-900">
💡 Pro tip: You can also run a retro on a Silos whiteboard by typing /retro
</div>
<DocSection icon="✅" title="Action items">
<p className="mb-2 text-sm text-silos-muted">
Add 1–2 follow-up action items to help the team apply what they learned:
</p>
{content.actionItems.map((item, i) => (
<div key={i} className="mb-2 flex items-center gap-2">
<input type="checkbox" className="h-4 w-4 rounded" />
<input
value={item}
onChange={(e) => {
const next = [...content.actionItems]
next[i] = e.target.value
patch('actionItems', next)
}}
placeholder="Type your action, use @ to assign to someone"
className="flex-1 rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan"
/>
</div>
))}
<button
onClick={() => patch('actionItems', [...content.actionItems, ''])}
className="text-sm text-silos-brown hover:underline"
>
+ Add action item
</button>
</DocSection>
</>
)
}

View file

@ -0,0 +1,192 @@
import { useEffect, useRef, useState } from 'react'
import {
Bold,
Italic,
Underline,
AlignLeft,
Image,
Link,
AtSign,
Smile,
Table,
Plus,
List,
Heading1,
} from 'lucide-react'
const toolbarGroups = [
[
{ icon: Heading1, label: 'Heading', cmd: 'formatBlock', value: 'h2' },
{ icon: Bold, label: 'Bold', cmd: 'bold' },
{ icon: Italic, label: 'Italic', cmd: 'italic' },
{ icon: Underline, label: 'Underline', cmd: 'underline' },
],
[
{ icon: AlignLeft, label: 'Align', cmd: 'justifyLeft' },
{ icon: List, label: 'List', cmd: 'insertUnorderedList' },
],
]
const slashItems = [
{ label: 'Paragraph', html: '<p>Paragraph</p>' },
{ label: 'Heading', html: '<h2>Heading</h2>' },
{ label: 'Bullet list', html: '<ul><li>Item</li></ul>' },
{ label: 'Table', html: '<table class="w-full border"><tr><th>A</th><th>B</th></tr><tr><td> </td><td> </td></tr></table>' },
{ label: 'Info panel', html: '<div class="rounded-lg border border-silos-tan bg-silos-cream/60 p-3 my-2">Info: add a note here.</div>' },
]
const starterHtml: Record<string, string> = {
'End of week status report': '<h2>End of week status</h2><p>Wins</p><ul><li></li></ul><p>Blockers</p><ul><li></li></ul>',
'Meeting notes': '<h2>Meeting notes</h2><p>Date:</p><ul><li>Attendees</li><li>Agenda</li><li>Actions</li></ul>',
'Project plan': '<h2>Project plan</h2><p>Goals</p><ul><li>Milestone 1</li></ul>',
}
interface SiloEditorProps {
html: string
onChange: (html: string) => void
}
export default function SiloEditor({ html, onChange }: SiloEditorProps) {
const editorRef = useRef<HTMLDivElement>(null)
const [slashOpen, setSlashOpen] = useState(false)
const primed = useRef(false)
useEffect(() => {
if (!primed.current && editorRef.current) {
editorRef.current.innerHTML = html || ''
primed.current = true
}
}, [html])
function emit() {
if (editorRef.current) onChange(editorRef.current.innerHTML)
}
function run(cmd: string, value?: string) {
document.execCommand(cmd, false, value)
editorRef.current?.focus()
emit()
}
function insertHtml(snippet: string) {
document.execCommand('insertHTML', false, snippet)
setSlashOpen(false)
emit()
}
function handleInput() {
const text = editorRef.current?.innerText ?? ''
if (text.endsWith('/')) setSlashOpen(true)
else setSlashOpen(false)
emit()
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') setSlashOpen(false)
}
return (
<div className="flex min-h-[calc(100vh-8rem)] flex-col">
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-silos-cream-dark bg-white py-2">
{toolbarGroups.map((group, gi) => (
<div key={gi} className="flex items-center gap-0.5">
{group.map(({ icon: Icon, label, cmd, value }) => (
<button
key={label}
type="button"
title={label}
onClick={() => run(cmd, value)}
className="rounded p-1.5 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
>
<Icon className="h-4 w-4" />
</button>
))}
{gi < toolbarGroups.length - 1 && <div className="mx-1 h-5 w-px bg-silos-cream-dark" />}
</div>
))}
<div className="mx-1 h-5 w-px bg-silos-cream-dark" />
<button
type="button"
title="Link"
onClick={() => {
const url = window.prompt('Link URL')
if (url) run('createLink', url)
}}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
>
<Link className="h-4 w-4" />
</button>
<button type="button" title="Image" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<Image className="h-4 w-4" />
</button>
<button type="button" title="Mention" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<AtSign className="h-4 w-4" />
</button>
<button type="button" title="Emoji" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<Smile className="h-4 w-4" />
</button>
<button
type="button"
title="Table"
onClick={() => insertHtml('<table class="w-full border"><tr><th>A</th><th>B</th></tr><tr><td></td><td></td></tr></table>')}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
>
<Table className="h-4 w-4" />
</button>
<button
type="button"
title="Insert"
onClick={() => setSlashOpen((v) => !v)}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="relative flex-1 py-6">
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={handleInput}
onKeyDown={handleKeyDown}
data-placeholder="Press / to insert elements, or just start writing..."
className="prose min-h-[50vh] w-full max-w-none text-base leading-relaxed text-silos-text outline-none empty:before:text-silos-muted/60 empty:before:content-[attr(data-placeholder)]"
/>
{slashOpen && (
<div className="absolute left-0 top-16 z-20 w-64 rounded-xl border border-silos-cream-dark bg-white p-1 shadow-lg">
{slashItems.map((item) => (
<button
key={item.label}
type="button"
onClick={() => insertHtml(item.html)}
className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"
>
{item.label}
</button>
))}
</div>
)}
</div>
<div className="sticky bottom-0 border-t border-silos-cream-dark bg-white py-4">
<div className="flex flex-wrap gap-2">
{Object.keys(starterHtml).map((t) => (
<button
key={t}
type="button"
onClick={() => insertHtml(starterHtml[t])}
className="rounded-full border border-silos-cream-dark bg-silos-cream/40 px-3 py-1.5 text-xs text-silos-text hover:border-silos-tan"
>
{t}
</button>
))}
<button type="button" className="rounded-full border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-muted">
All templates
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,894 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
MousePointer2,
Hand,
StickyNote,
Pencil,
Type,
Minus,
Circle,
Square,
Grid3x3,
Plus,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
Triangle,
} from 'lucide-react'
import {
type SketchElement,
type SketchTool,
type Point,
type SketchViewport,
DEFAULT_COLOR,
STICKY_COLORS,
createId,
getElementBounds,
hitTestElement,
translateElement,
} from '../../types/sketch'
interface SketchCanvasProps {
elements: SketchElement[]
viewport?: SketchViewport
onChange: (elements: SketchElement[], viewport?: SketchViewport) => void
}
type DragMode =
| { kind: 'pan'; startX: number; startY: number; origPanX: number; origPanY: number }
| { kind: 'draw-pen'; points: Point[] }
| { kind: 'draw-line'; x1: number; y1: number; x2: number; y2: number }
| { kind: 'draw-rect'; x: number; y: number; width: number; height: number }
| { kind: 'draw-ellipse'; x: number; y: number; width: number; height: number }
| { kind: 'draw-triangle'; x: number; y: number; width: number; height: number }
| { kind: 'move'; elementId: string; startX: number; startY: number }
const MAIN_TOOLS: { id: SketchTool; icon: typeof MousePointer2; label: string }[] = [
{ id: 'select', icon: MousePointer2, label: 'Select' },
{ id: 'hand', icon: Hand, label: 'Pan' },
{ id: 'sticky', icon: StickyNote, label: 'Sticky note' },
{ id: 'pen', icon: Pencil, label: 'Draw' },
{ id: 'text', icon: Type, label: 'Text' },
{ id: 'line', icon: Minus, label: 'Line' },
{ id: 'ellipse', icon: Circle, label: 'Ellipse' },
{ id: 'table', icon: Grid3x3, label: 'Table' },
]
function penToPath(points: Point[]): string {
if (points.length < 2) return ''
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
}
function normalizeRect(x1: number, y1: number, x2: number, y2: number) {
return {
x: Math.min(x1, x2),
y: Math.min(y1, y2),
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
}
}
export default function SketchCanvas({ elements, viewport, onChange }: SketchCanvasProps) {
const [activeTool, setActiveTool] = useState<SketchTool>('pen')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [pan, setPan] = useState({ x: viewport?.panX ?? 0, y: viewport?.panY ?? 0 })
const [zoom, setZoom] = useState(viewport?.zoom ?? 100)
const [drag, setDrag] = useState<DragMode | null>(null)
const [showMore, setShowMore] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [history, setHistory] = useState<{ elements: SketchElement[]; pan: typeof pan; zoom: number }[]>([])
const [future, setFuture] = useState<{ elements: SketchElement[]; pan: typeof pan; zoom: number }[]>([])
const viewportRef = useRef<HTMLDivElement>(null)
const stickyColorIndex = useRef(0)
const scale = zoom / 100
const pushHistory = useCallback(() => {
setHistory((h) => [...h.slice(-49), { elements, pan, zoom }])
setFuture([])
}, [elements, pan, zoom])
const commit = useCallback(
(nextElements: SketchElement[], nextPan = pan, nextZoom = zoom, saveHistory = true) => {
if (saveHistory) pushHistory()
onChange(nextElements, { panX: nextPan.x, panY: nextPan.y, zoom: nextZoom })
setPan(nextPan)
setZoom(nextZoom)
},
[onChange, pan, zoom, pushHistory],
)
const getPoint = useCallback(
(e: React.MouseEvent | MouseEvent): Point => {
const rect = viewportRef.current?.getBoundingClientRect()
if (!rect) return { x: 0, y: 0 }
return {
x: (e.clientX - rect.left - pan.x) / scale,
y: (e.clientY - rect.top - pan.y) / scale,
}
},
[pan, scale],
)
const undo = () => {
if (history.length === 0) return
const prev = history[history.length - 1]
setFuture((f) => [{ elements, pan, zoom }, ...f])
setHistory((h) => h.slice(0, -1))
onChange(prev.elements, { panX: prev.pan.x, panY: prev.pan.y, zoom: prev.zoom })
setPan(prev.pan)
setZoom(prev.zoom)
setSelectedId(null)
}
const redo = () => {
if (future.length === 0) return
const next = future[0]
setHistory((h) => [...h, { elements, pan, zoom }])
setFuture((f) => f.slice(1))
onChange(next.elements, { panX: next.pan.x, panY: next.pan.y, zoom: next.zoom })
setPan(next.pan)
setZoom(next.zoom)
}
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (editingId) return
if (selectedId) {
e.preventDefault()
pushHistory()
onChange(
elements.filter((el) => el.id !== selectedId),
{ panX: pan.x, panY: pan.y, zoom },
)
setSelectedId(null)
}
}
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault()
if (e.shiftKey) redo()
else undo()
}
if (e.key === 'Escape') {
setSelectedId(null)
setEditingId(null)
setShowMore(false)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [selectedId, editingId, elements, pan, zoom, onChange, pushHistory])
function findElementAt(point: Point): SketchElement | null {
for (let i = elements.length - 1; i >= 0; i--) {
if (hitTestElement(elements[i], point)) return elements[i]
}
return null
}
function handleMouseDown(e: React.MouseEvent) {
if (e.button !== 0) return
const point = getPoint(e)
if (activeTool === 'hand') {
setDrag({ kind: 'pan', startX: e.clientX, startY: e.clientY, origPanX: pan.x, origPanY: pan.y })
return
}
if (activeTool === 'select') {
const hit = findElementAt(point)
if (hit) {
setSelectedId(hit.id)
setDrag({ kind: 'move', elementId: hit.id, startX: point.x, startY: point.y })
} else {
setSelectedId(null)
}
return
}
if (activeTool === 'sticky') {
const color = STICKY_COLORS[stickyColorIndex.current % STICKY_COLORS.length]
stickyColorIndex.current += 1
const sticky: SketchElement = {
id: createId(),
type: 'sticky',
x: point.x,
y: point.y,
width: 180,
height: 140,
text: '',
color,
}
commit([...elements, sticky])
setSelectedId(sticky.id)
setEditingId(sticky.id)
return
}
if (activeTool === 'text') {
const textEl: SketchElement = {
id: createId(),
type: 'text',
x: point.x,
y: point.y,
text: 'Text',
fontSize: 18,
color: DEFAULT_COLOR,
}
commit([...elements, textEl])
setSelectedId(textEl.id)
setEditingId(textEl.id)
return
}
if (activeTool === 'table') {
const rows = 3
const cols = 3
const table: SketchElement = {
id: createId(),
type: 'table',
x: point.x,
y: point.y,
rows,
cols,
cellWidth: 80,
cellHeight: 32,
color: DEFAULT_COLOR,
cells: Array.from({ length: rows }, () => Array(cols).fill('')),
}
commit([...elements, table])
setSelectedId(table.id)
return
}
if (activeTool === 'pen') {
setDrag({ kind: 'draw-pen', points: [point] })
return
}
if (activeTool === 'line') {
setDrag({ kind: 'draw-line', x1: point.x, y1: point.y, x2: point.x, y2: point.y })
return
}
if (activeTool === 'rect') {
setDrag({ kind: 'draw-rect', x: point.x, y: point.y, width: 0, height: 0 })
return
}
if (activeTool === 'ellipse') {
setDrag({ kind: 'draw-ellipse', x: point.x, y: point.y, width: 0, height: 0 })
return
}
if (activeTool === 'triangle') {
setDrag({ kind: 'draw-triangle', x: point.x, y: point.y, width: 0, height: 0 })
}
}
function handleMouseMove(e: React.MouseEvent) {
if (!drag) return
const point = getPoint(e)
if (drag.kind === 'pan') {
setPan({
x: drag.origPanX + (e.clientX - drag.startX),
y: drag.origPanY + (e.clientY - drag.startY),
})
return
}
if (drag.kind === 'move') {
const el = elements.find((item) => item.id === drag.elementId)
if (!el) return
const dx = point.x - drag.startX
const dy = point.y - drag.startY
onChange(
elements.map((item) => (item.id === drag.elementId ? translateElement(el, dx, dy) : item)),
{ panX: pan.x, panY: pan.y, zoom },
)
setDrag({ ...drag, startX: point.x, startY: point.y })
return
}
if (drag.kind === 'draw-pen') {
setDrag({ ...drag, points: [...drag.points, point] })
return
}
if (drag.kind === 'draw-line') {
setDrag({ ...drag, x2: point.x, y2: point.y })
return
}
if (drag.kind === 'draw-rect' || drag.kind === 'draw-ellipse' || drag.kind === 'draw-triangle') {
const rect = normalizeRect(drag.x, drag.y, point.x, point.y)
setDrag({ ...drag, ...rect })
}
}
function handleMouseUp(e?: React.MouseEvent) {
if (!drag) return
if (drag.kind === 'pan' && e) {
const finalPan = {
x: drag.origPanX + (e.clientX - drag.startX),
y: drag.origPanY + (e.clientY - drag.startY),
}
setPan(finalPan)
onChange(elements, { panX: finalPan.x, panY: finalPan.y, zoom })
setDrag(null)
return
}
if (drag.kind === 'move') {
pushHistory()
setDrag(null)
return
}
if (drag.kind === 'draw-pen' && drag.points.length > 1) {
const stroke: SketchElement = {
id: createId(),
type: 'pen',
points: drag.points,
color: DEFAULT_COLOR,
strokeWidth: 2,
}
commit([...elements, stroke])
}
if (drag.kind === 'draw-line') {
const { x1, y1, x2, y2 } = drag
if (Math.hypot(x2 - x1, y2 - y1) > 4) {
commit([
...elements,
{
id: createId(),
type: 'line',
x1,
y1,
x2,
y2,
color: DEFAULT_COLOR,
strokeWidth: 2,
},
])
}
}
if (drag.kind === 'draw-rect' && drag.width > 4 && drag.height > 4) {
commit([
...elements,
{
id: createId(),
type: 'rect',
x: drag.x,
y: drag.y,
width: drag.width,
height: drag.height,
color: DEFAULT_COLOR,
strokeWidth: 2,
fill: 'transparent',
},
])
}
if (drag.kind === 'draw-ellipse' && drag.width > 4 && drag.height > 4) {
commit([
...elements,
{
id: createId(),
type: 'ellipse',
x: drag.x,
y: drag.y,
width: drag.width,
height: drag.height,
color: DEFAULT_COLOR,
strokeWidth: 2,
fill: 'transparent',
},
])
}
if (drag.kind === 'draw-triangle' && drag.width > 4 && drag.height > 4) {
commit([
...elements,
{
id: createId(),
type: 'triangle',
x: drag.x,
y: drag.y,
width: drag.width,
height: drag.height,
color: DEFAULT_COLOR,
strokeWidth: 2,
fill: 'transparent',
},
])
}
setDrag(null)
}
function updateElement(id: string, patch: Partial<SketchElement>) {
onChange(
elements.map((el) => (el.id === id ? { ...el, ...patch } as SketchElement : el)),
{ panX: pan.x, panY: pan.y, zoom },
)
}
const cursorClass =
activeTool === 'hand'
? 'cursor-grab active:cursor-grabbing'
: activeTool === 'select'
? 'cursor-default'
: 'cursor-crosshair'
const previewElement = drag?.kind.startsWith('draw-') ? drag : null
return (
<div className="relative flex h-full flex-col">
<div
ref={viewportRef}
className={`relative flex-1 overflow-hidden ${cursorClass}`}
style={{ backgroundColor: '#faf8f6' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={(e) => handleMouseUp(e)}
onMouseLeave={(e) => handleMouseUp(e)}
>
<div
className="absolute origin-top-left"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
backgroundImage: 'radial-gradient(circle, #d4c4b8 1px, transparent 1px)',
backgroundSize: `${20 * scale}px ${20 * scale}px`,
width: '4000px',
height: '3000px',
}}
>
<svg className="absolute inset-0 h-full w-full overflow-visible">
{elements.map((el) => {
const selected = el.id === selectedId
if (el.type === 'pen') {
return (
<g key={el.id}>
<path
d={penToPath(el.points)}
fill="none"
stroke={el.color}
strokeWidth={el.strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
{selected && (
<rect
{...getElementBounds(el)}
fill="none"
stroke="#3B82F6"
strokeWidth={1 / scale}
strokeDasharray="4 2"
/>
)}
</g>
)
}
if (el.type === 'line') {
return (
<g key={el.id}>
<line
x1={el.x1}
y1={el.y1}
x2={el.x2}
y2={el.y2}
stroke={el.color}
strokeWidth={el.strokeWidth}
strokeLinecap="round"
/>
{selected && (
<rect
{...getElementBounds(el)}
fill="none"
stroke="#3B82F6"
strokeWidth={1 / scale}
strokeDasharray="4 2"
/>
)}
</g>
)
}
if (el.type === 'rect') {
return (
<g key={el.id}>
<rect
x={el.x}
y={el.y}
width={el.width}
height={el.height}
stroke={el.color}
strokeWidth={el.strokeWidth}
fill={el.fill}
/>
{selected && (
<rect
x={el.x - 2}
y={el.y - 2}
width={el.width + 4}
height={el.height + 4}
fill="none"
stroke="#3B82F6"
strokeWidth={1 / scale}
strokeDasharray="4 2"
/>
)}
</g>
)
}
if (el.type === 'ellipse') {
return (
<g key={el.id}>
<ellipse
cx={el.x + el.width / 2}
cy={el.y + el.height / 2}
rx={el.width / 2}
ry={el.height / 2}
stroke={el.color}
strokeWidth={el.strokeWidth}
fill={el.fill}
/>
{selected && (
<rect
x={el.x - 2}
y={el.y - 2}
width={el.width + 4}
height={el.height + 4}
fill="none"
stroke="#3B82F6"
strokeWidth={1 / scale}
strokeDasharray="4 2"
/>
)}
</g>
)
}
if (el.type === 'triangle') {
const x1 = el.x + el.width / 2
const y1 = el.y
const x2 = el.x
const y2 = el.y + el.height
const x3 = el.x + el.width
const y3 = el.y + el.height
return (
<g key={el.id}>
<polygon
points={`${x1},${y1} ${x2},${y2} ${x3},${y3}`}
stroke={el.color}
strokeWidth={el.strokeWidth}
fill={el.fill}
/>
{selected && (
<rect
x={el.x - 2}
y={el.y - 2}
width={el.width + 4}
height={el.height + 4}
fill="none"
stroke="#3B82F6"
strokeWidth={1 / scale}
strokeDasharray="4 2"
/>
)}
</g>
)
}
return null
})}
{/* Preview while drawing */}
{previewElement?.kind === 'draw-pen' && previewElement.points.length > 1 && (
<path
d={penToPath(previewElement.points)}
fill="none"
stroke={DEFAULT_COLOR}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.7}
/>
)}
{previewElement?.kind === 'draw-line' && (
<line
x1={previewElement.x1}
y1={previewElement.y1}
x2={previewElement.x2}
y2={previewElement.y2}
stroke={DEFAULT_COLOR}
strokeWidth={2}
opacity={0.7}
/>
)}
{previewElement?.kind === 'draw-rect' && (
<rect
x={previewElement.x}
y={previewElement.y}
width={previewElement.width}
height={previewElement.height}
stroke={DEFAULT_COLOR}
strokeWidth={2}
fill="transparent"
opacity={0.7}
/>
)}
{previewElement?.kind === 'draw-ellipse' && (
<ellipse
cx={previewElement.x + previewElement.width / 2}
cy={previewElement.y + previewElement.height / 2}
rx={previewElement.width / 2}
ry={previewElement.height / 2}
stroke={DEFAULT_COLOR}
strokeWidth={2}
fill="transparent"
opacity={0.7}
/>
)}
{previewElement?.kind === 'draw-triangle' && (
<polygon
points={`${previewElement.x + previewElement.width / 2},${previewElement.y} ${previewElement.x},${previewElement.y + previewElement.height} ${previewElement.x + previewElement.width},${previewElement.y + previewElement.height}`}
stroke={DEFAULT_COLOR}
strokeWidth={2}
fill="transparent"
opacity={0.7}
/>
)}
</svg>
{/* HTML overlays: sticky, text, table */}
{elements.map((el) => {
if (el.type === 'sticky') {
const isEditing = editingId === el.id
return (
<div
key={el.id}
className={`absolute rounded-lg shadow-md ${selectedId === el.id ? 'ring-2 ring-blue-500' : ''}`}
style={{
left: el.x,
top: el.y,
width: el.width,
height: el.height,
backgroundColor: el.color,
}}
onMouseDown={(e) => {
if (activeTool === 'select') {
e.stopPropagation()
setSelectedId(el.id)
}
}}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingId(el.id)
setActiveTool('select')
}}
>
<textarea
value={el.text}
readOnly={!isEditing}
onChange={(e) => updateElement(el.id, { text: e.target.value })}
onBlur={() => setEditingId(null)}
onMouseDown={(e) => isEditing && e.stopPropagation()}
placeholder="Type a note..."
className="h-full w-full resize-none rounded-lg border-none bg-transparent p-3 text-sm text-silos-text outline-none"
/>
</div>
)
}
if (el.type === 'text') {
const isEditing = editingId === el.id
return (
<div
key={el.id}
className={`absolute ${selectedId === el.id ? 'ring-2 ring-blue-500 rounded' : ''}`}
style={{ left: el.x, top: el.y }}
onMouseDown={(e) => {
if (activeTool === 'select') {
e.stopPropagation()
setSelectedId(el.id)
}
}}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingId(el.id)
setActiveTool('select')
}}
>
<input
value={el.text}
readOnly={!isEditing}
onChange={(e) => updateElement(el.id, { text: e.target.value })}
onBlur={() => setEditingId(null)}
onMouseDown={(e) => isEditing && e.stopPropagation()}
style={{ fontSize: el.fontSize, color: el.color }}
className="min-w-[60px] border-none bg-transparent font-medium outline-none"
/>
</div>
)
}
if (el.type === 'table') {
return (
<div
key={el.id}
className={`absolute overflow-hidden rounded border-2 bg-white ${selectedId === el.id ? 'ring-2 ring-blue-500' : ''}`}
style={{
left: el.x,
top: el.y,
borderColor: el.color,
}}
onMouseDown={(e) => {
if (activeTool === 'select') {
e.stopPropagation()
setSelectedId(el.id)
}
}}
>
<table className="border-collapse">
<tbody>
{el.cells.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
className="border border-silos-cream-dark p-0"
style={{
width: el.cellWidth,
height: el.cellHeight,
}}
>
<input
value={cell}
onChange={(e) => {
const cells = el.cells.map((r) => [...r])
cells[ri][ci] = e.target.value
updateElement(el.id, { cells })
}}
onMouseDown={(e) => e.stopPropagation()}
className="h-full w-full border-none bg-transparent px-2 text-xs outline-none"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
return null
})}
</div>
</div>
{/* Undo / Redo */}
<div className="absolute bottom-20 left-4 flex gap-1">
<button
type="button"
onClick={undo}
disabled={history.length === 0}
className="rounded-lg border border-silos-cream-dark bg-white p-2 shadow-sm hover:bg-silos-cream disabled:opacity-40"
title="Undo (Ctrl+Z)"
>
<Undo2 className="h-4 w-4 text-silos-muted" />
</button>
<button
type="button"
onClick={redo}
disabled={future.length === 0}
className="rounded-lg border border-silos-cream-dark bg-white p-2 shadow-sm hover:bg-silos-cream disabled:opacity-40"
title="Redo (Ctrl+Shift+Z)"
>
<Redo2 className="h-4 w-4 text-silos-muted" />
</button>
</div>
{/* Bottom toolbar — 10 tools */}
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-silos-cream-dark bg-white px-2 py-1.5 shadow-lg">
{MAIN_TOOLS.map(({ id, icon: Icon, label }) => (
<button
key={id}
type="button"
title={label}
onClick={() => {
setActiveTool(id)
setShowMore(false)
}}
className={`rounded-lg p-2 transition-colors ${
activeTool === id
? 'bg-silos-brown/10 text-silos-brown'
: 'text-silos-muted hover:bg-silos-cream'
}`}
>
<Icon className="h-4 w-4" />
</button>
))}
<div className="mx-1 h-6 w-px bg-silos-cream-dark" />
{/* Tool 9: Rectangle */}
<button
type="button"
title="Rectangle"
onClick={() => {
setActiveTool('rect')
setShowMore(false)
}}
className={`rounded-lg p-2 transition-colors ${
activeTool === 'rect'
? 'bg-silos-brown/10 text-silos-brown'
: 'text-silos-muted hover:bg-silos-cream'
}`}
>
<Square className="h-4 w-4" />
</button>
{/* Tool 10: More shapes */}
<div className="relative">
<button
type="button"
title="More shapes"
onClick={() => setShowMore(!showMore)}
className={`rounded-lg p-2 transition-colors ${
activeTool === 'triangle' || showMore
? 'bg-silos-brown/10 text-silos-brown'
: 'text-silos-muted hover:bg-silos-cream'
}`}
>
<Plus className="h-4 w-4" />
</button>
{showMore && (
<div className="absolute bottom-full left-1/2 mb-2 -translate-x-1/2 rounded-lg border border-silos-cream-dark bg-white p-1 shadow-lg">
<button
type="button"
title="Triangle"
onClick={() => {
setActiveTool('triangle')
setShowMore(false)
}}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-silos-text hover:bg-silos-cream"
>
<Triangle className="h-4 w-4" />
Triangle
</button>
</div>
)}
</div>
</div>
{/* Zoom */}
<div className="absolute bottom-4 right-4 flex items-center gap-1 rounded-lg border border-silos-cream-dark bg-white px-2 py-1 shadow-sm">
<button
type="button"
onClick={() => {
const next = Math.max(50, zoom - 10)
setZoom(next)
onChange(elements, { panX: pan.x, panY: pan.y, zoom: next })
}}
>
<ZoomOut className="h-4 w-4 text-silos-muted" />
</button>
<span className="w-10 text-center text-xs text-silos-muted">{zoom}%</span>
<button
type="button"
onClick={() => {
const next = Math.min(200, zoom + 10)
setZoom(next)
onChange(elements, { panX: pan.x, panY: pan.y, zoom: next })
}}
>
<ZoomIn className="h-4 w-4 text-silos-muted" />
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,215 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, FileText, PenTool, Zap, LayoutGrid } from 'lucide-react'
import type { SteadContent } from '../../data/steadData'
import { spaceNavItems } from '../../data/steadData'
import {
listDocumentsByStead,
createDocument,
getDefaultTitle,
type DocumentRecord,
} from '../../lib/documents'
interface SteadHubProps {
doc: DocumentRecord
content: SteadContent
onSectionChange: (section: string) => void
onAddPlan: () => void
onUpdatePlan: (index: number, field: string, value: string) => void
}
const typeIcons: Record<string, typeof FileText> = {
shed: Zap,
silo: FileText,
sketch: PenTool,
stead: LayoutGrid,
}
export default function SteadHub({
doc,
content,
onSectionChange,
onAddPlan,
onUpdatePlan,
}: SteadHubProps) {
const navigate = useNavigate()
const [linkedDocs, setLinkedDocs] = useState<DocumentRecord[]>([])
useEffect(() => {
void listDocumentsByStead(doc.id).then(setLinkedDocs)
}, [doc.id, doc.updatedAt])
async function createInSpace(type: 'shed' | 'silo' | 'sketch') {
const defaults: Record<string, Record<string, unknown>> = {
silo: { body: '' },
sketch: { elements: [] },
shed: { overview: '' },
}
const newDoc = await createDocument(
type,
getDefaultTitle(type),
defaults[type],
undefined,
doc.createdBy,
doc.id,
)
navigate(`/workspace/${type}/${newDoc.id}`)
}
return (
<div className="flex h-full">
<aside className="w-52 shrink-0 border-r border-silos-cream-dark bg-silos-cream/30 p-4">
<div className="flex items-center gap-2">
<span
className="flex h-8 w-8 items-center justify-center rounded-lg text-lg"
style={{ backgroundColor: content.iconColor + '44' }}
>
{content.icon}
</span>
<span className="truncate text-sm font-semibold text-silos-text">{doc.title}</span>
</div>
<nav className="mt-6 space-y-0.5">
{spaceNavItems.map((item) => (
<button
key={item}
type="button"
onClick={() => onSectionChange(item)}
className={`w-full rounded-lg px-3 py-2 text-left text-sm transition-colors ${
content.activeSection === item
? 'bg-white font-medium text-silos-text shadow-sm'
: 'text-silos-muted hover:bg-white/60'
}`}
>
{item}
</button>
))}
</nav>
</aside>
<div className="flex-1 overflow-y-auto p-8">
{content.activeSection === 'Content' && (
<div>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-silos-text">Content</h2>
<div className="flex gap-2">
<button
type="button"
onClick={() => void createInSpace('silo')}
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
</button>
<button
type="button"
onClick={() => void createInSpace('sketch')}
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
</button>
<button
type="button"
onClick={() => void createInSpace('shed')}
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
</button>
</div>
</div>
{linkedDocs.length === 0 ? (
<div className="mt-8 rounded-xl border-2 border-dashed border-silos-cream-dark py-16 text-center">
<p className="text-sm text-silos-muted">
No documents yet. Create a Silo, Sketch, or Shed to get started.
</p>
</div>
) : (
<div className="mt-6 space-y-2">
{linkedDocs.map((d) => {
const Icon = typeIcons[d.type] ?? FileText
return (
<button
key={d.id}
type="button"
onClick={() => navigate(`/workspace/${d.type}/${d.id}`)}
className="flex w-full items-center gap-3 rounded-lg border border-silos-cream-dark px-4 py-3 text-left transition-colors hover:bg-silos-cream/40"
>
<Icon className="h-4 w-4 text-silos-tan" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-silos-text">{d.title}</p>
<p className="text-xs capitalize text-silos-muted">{d.type}</p>
</div>
<span className="text-xs text-silos-muted">
{new Date(d.updatedAt).toLocaleDateString()}
</span>
</button>
)
})}
</div>
)}
</div>
)}
{content.activeSection === 'Execution plans' && (
<div>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-silos-text">Execution plans</h2>
<button
type="button"
onClick={onAddPlan}
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
</button>
</div>
{content.executionPlans.length === 0 ? (
<div className="mt-8 rounded-xl border-2 border-dashed border-silos-cream-dark py-16 text-center">
<p className="text-sm text-silos-muted">
Track milestones, deliverables, and execution timelines here.
</p>
</div>
) : (
<div className="mt-6 space-y-3">
{content.executionPlans.map((plan, i) => (
<div key={i} className="rounded-lg border border-silos-cream-dark p-4">
<input
value={plan.title}
onChange={(e) => onUpdatePlan(i, 'title', e.target.value)}
placeholder="Plan title"
className="w-full border-none bg-transparent text-sm font-medium text-silos-text outline-none"
/>
<div className="mt-2 flex gap-3">
<select
value={plan.status}
onChange={(e) => onUpdatePlan(i, 'status', e.target.value)}
className="rounded border border-silos-cream-dark px-2 py-1 text-xs text-silos-text"
>
<option value="planned">Planned</option>
<option value="in-progress">In progress</option>
<option value="done">Done</option>
</select>
<input
type="date"
value={plan.dueDate}
onChange={(e) => onUpdatePlan(i, 'dueDate', e.target.value)}
className="rounded border border-silos-cream-dark px-2 py-1 text-xs text-silos-text"
/>
</div>
</div>
))}
</div>
)}
</div>
)}
{!['Content', 'Execution plans'].includes(content.activeSection) && (
<div className="flex min-h-[40vh] flex-col items-center justify-center text-center">
<p className="text-lg font-medium text-silos-text">{content.activeSection}</p>
<p className="mt-2 max-w-sm text-sm text-silos-muted">
This section is ready for your team. Add content from the Content tab or create new
documents directly in this space.
</p>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,125 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { shareDocument, unshareDocument, type DocumentRecord } from '../../lib/documents'
import { getStoredUser } from '../../lib/api'
interface ShareModalProps {
doc: DocumentRecord
onClose: () => void
onDocChange: (doc: DocumentRecord) => void
}
export default function ShareModal({ doc, onClose, onDocChange }: ShareModalProps) {
const user = getStoredUser()
const isOwner = user?.id === doc.ownerId
const [identifier, setIdentifier] = useState('')
const [role, setRole] = useState<'editor' | 'viewer'>('editor')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function handleInvite(e: React.FormEvent) {
e.preventDefault()
if (!identifier.trim()) return
setBusy(true)
setError(null)
try {
const updated = await shareDocument(doc.id, identifier.trim(), role)
if (updated) {
onDocChange(updated)
setIdentifier('')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not share')
} finally {
setBusy(false)
}
}
async function handleRemove(userId: number) {
setBusy(true)
setError(null)
try {
const updated = await unshareDocument(doc.id, userId)
if (updated) onDocChange(updated)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update sharing')
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/30 px-4">
<button type="button" className="absolute inset-0" onClick={onClose} aria-label="Close" />
<div className="relative w-full max-w-md rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-silos-text">Share</h2>
<button type="button" onClick={onClose} className="rounded-lg p-1 hover:bg-silos-cream">
<X className="h-4 w-4 text-silos-muted" />
</button>
</div>
<p className="mt-1 text-xs text-silos-muted">
Sharing this item does not automatically share documents inside a Stead. Invite people to
each page they should see.
</p>
{isOwner ? (
<form onSubmit={handleInvite} className="mt-4 flex gap-2">
<input
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="Email or username"
className="flex-1 rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-brown"
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as 'editor' | 'viewer')}
className="rounded-lg border border-silos-cream-dark px-2 text-sm"
>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<button
type="submit"
disabled={busy}
className="rounded-lg bg-silos-brown px-3 py-2 text-sm text-white disabled:opacity-50"
>
Invite
</button>
</form>
) : (
<p className="mt-4 text-sm text-silos-muted">Only the owner can invite people.</p>
)}
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<ul className="mt-4 space-y-2">
<li className="flex items-center justify-between rounded-lg bg-silos-cream/50 px-3 py-2 text-sm">
<span>{doc.createdBy} (you)</span>
<span className="text-xs text-silos-muted">Owner</span>
</li>
{doc.members.map((member) => (
<li
key={member.user_id}
className="flex items-center justify-between rounded-lg border border-silos-cream-dark px-3 py-2 text-sm"
>
<div>
<p className="text-silos-text">{member.name || member.email}</p>
<p className="text-xs capitalize text-silos-muted">{member.role}</p>
</div>
{isOwner && (
<button
type="button"
onClick={() => void handleRemove(member.user_id)}
className="text-xs text-silos-brown hover:underline"
>
Remove
</button>
)}
</li>
))}
</ul>
</div>
</div>
)
}

View file

@ -0,0 +1,199 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Menu, Share2, Link2, MoreHorizontal, Users, Search } from 'lucide-react'
import { logos } from '../../data/brandAssets'
import { getStoredUser } from '../../lib/api'
import { getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import CreateDropdown from '../create/CreateDropdown'
import WorkspaceSidebar from './WorkspaceSidebar'
import ShareModal from './ShareModal'
import SearchOverlay from '../search/SearchOverlay'
type WorkspaceLayout = 'default' | 'page' | 'canvas' | 'space'
interface WorkspaceShellProps {
doc: DocumentRecord
onDocChange: (doc: DocumentRecord) => void
children: React.ReactNode
layout?: WorkspaceLayout
headerActions?: React.ReactNode
hideTitle?: boolean
presence?: { name: string; initial: string }[]
}
export default function WorkspaceShell({
doc,
onDocChange,
children,
layout = 'default',
headerActions,
hideTitle = false,
presence = [],
}: WorkspaceShellProps) {
const user = getStoredUser()
const [shareOpen, setShareOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [parentStead, setParentStead] = useState<DocumentRecord | null>(null)
useEffect(() => {
if (!doc.steadId) {
setParentStead(null)
return
}
void getDocument(doc.steadId)
.then((found) => setParentStead(found))
.catch(() => setParentStead(null))
}, [doc.steadId])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setSearchOpen(true)
}
if (e.key === 'Escape') setSearchOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
function handleTitleChange(title: string) {
void updateDocument(doc.id, { title }).then((updated) => {
if (updated) onDocChange(updated)
})
}
async function copyLink() {
await navigator.clipboard.writeText(window.location.href)
setCopied(true)
setTimeout(() => setCopied(false), 1600)
}
const isCanvas = layout === 'canvas'
const isSpace = layout === 'space'
const isPage = layout === 'page'
return (
<div className="flex h-screen flex-col bg-silos-cream">
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-silos-cream-dark bg-white px-3">
<Link to="/dashboard" className="rounded-lg p-2 text-silos-muted hover:bg-silos-cream">
<Menu className="h-4 w-4" />
</Link>
<Link to="/dashboard">
<img src={logos.rustyBrown} alt="Silos" className="h-6 w-auto" />
</Link>
<div className="hidden min-w-0 flex-1 items-center gap-1 truncate text-xs text-silos-muted sm:flex">
<Link to="/dashboard" className="hover:text-silos-brown">
Dashboard
</Link>
{parentStead && (
<>
<span>/</span>
<Link to={`/workspace/stead/${parentStead.id}`} className="truncate hover:text-silos-brown">
{parentStead.title}
</Link>
</>
)}
<span>/</span>
<span className="truncate text-silos-text">{doc.title}</span>
</div>
<button
type="button"
onClick={() => setSearchOpen(true)}
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
aria-label="Search"
>
<Search className="h-3.5 w-3.5" />
</button>
<CreateDropdown steadId={doc.type === 'stead' ? doc.id : doc.steadId} />
<div className="flex items-center gap-1 border-l border-silos-cream-dark pl-2">
<span className="hidden text-xs text-green-600 sm:inline">Saved</span>
{headerActions}
<div className="flex items-center -space-x-1">
{presence.map((p) => (
<div
key={p.name}
title={p.name}
className="flex h-6 w-6 items-center justify-center rounded-full bg-silos-brown text-[10px] font-bold text-white ring-2 ring-white"
>
{p.initial}
</div>
))}
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-silos-tan text-[10px] font-bold text-white ring-2 ring-white">
{user?.full_name?.[0] ?? 'Y'}
</div>
</div>
<Users className="ml-1 hidden h-3.5 w-3.5 text-silos-muted sm:block" />
<button
type="button"
onClick={() => setShareOpen(true)}
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
aria-label="Share"
>
<Share2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => void copyLink()}
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
aria-label="Copy link"
title={copied ? 'Copied' : 'Copy link'}
>
<Link2 className="h-3.5 w-3.5" />
</button>
<button className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="More">
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</div>
</header>
{copied && (
<div className="absolute right-4 top-14 z-50 rounded-lg bg-silos-text px-3 py-1.5 text-xs text-white">
Link copied
</div>
)}
<div className="flex flex-1 overflow-hidden">
{!isSpace && (
<WorkspaceSidebar collapsed={false} activeDocId={doc.id} steadId={doc.steadId} />
)}
<main className={`flex-1 bg-white ${isCanvas || isSpace ? 'overflow-hidden' : 'overflow-y-auto'}`}>
{isCanvas || isSpace ? (
children
) : (
<div className={`mx-auto ${isPage ? 'max-w-5xl' : 'max-w-4xl'} px-6 sm:px-10 ${isPage ? 'py-4' : 'py-8'}`}>
{!hideTitle && (
<>
<input
value={doc.title}
onChange={(e) => handleTitleChange(e.target.value)}
className={`w-full border-none bg-transparent font-bold text-silos-text outline-none ${
isPage ? 'text-3xl' : 'text-2xl'
}`}
placeholder={isPage ? 'Give this page a title' : 'Start with a title...'}
/>
<div className="mt-2 flex items-center gap-3 text-sm text-silos-muted">
<Users className="h-3.5 w-3.5" />
<span>By {doc.createdBy}</span>
{!isPage && (
<>
<span>·</span>
<span className="capitalize">{doc.type} — Live collaboration</span>
</>
)}
</div>
</>
)}
<div className={hideTitle ? 'h-full' : 'mt-8'}>{children}</div>
</div>
)}
</main>
</div>
{shareOpen && (
<ShareModal doc={doc} onClose={() => setShareOpen(false)} onDocChange={onDocChange} />
)}
<SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} />
</div>
)
}

View file

@ -0,0 +1,133 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, Plus } from 'lucide-react'
import { listDocuments, listDocumentsByStead, type DocumentRecord } from '../../lib/documents'
import type { CreateType } from '../../data/createData'
const typeIcons: Record<CreateType, typeof FileText> = {
silo: FileText,
shed: Zap,
sketch: PenTool,
stead: LayoutGrid,
}
const typeColors: Record<CreateType, string> = {
silo: '#6B8CAE',
shed: '#C17D65',
sketch: '#7BAF8E',
stead: '#9B7BB8',
}
interface WorkspaceSidebarProps {
collapsed: boolean
activeDocId: string
steadId?: string
}
export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: WorkspaceSidebarProps) {
const [docs, setDocs] = useState<DocumentRecord[]>([])
const [steads, setSteads] = useState<DocumentRecord[]>([])
useEffect(() => {
if (steadId) {
void Promise.all([listDocumentsByStead(steadId), listDocuments('stead')]).then(
([siblings, spaces]) => {
setDocs(siblings)
setSteads(spaces)
},
)
} else {
void listDocuments().then((all) => {
setDocs(all.filter((d) => d.type !== 'stead'))
setSteads(all.filter((d) => d.type === 'stead'))
})
}
}, [activeDocId, steadId])
const parent = steads.find((s) => s.id === steadId)
if (collapsed) {
return (
<aside className="flex w-12 shrink-0 flex-col items-center border-r border-silos-cream-dark bg-white py-3">
{docs.slice(0, 6).map((doc) => {
const Icon = typeIcons[doc.type]
return (
<Link
key={doc.id}
to={`/workspace/${doc.type}/${doc.id}`}
title={doc.title}
className={`mb-1 rounded-lg p-2 ${
doc.id === activeDocId ? 'bg-silos-brown/10' : 'hover:bg-silos-cream'
}`}
>
<Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} />
</Link>
)
})}
</aside>
)
}
return (
<aside className="flex w-56 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>
</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>
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">
In this space
</p>
</>
)}
{!parent && (
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">
Content
</p>
)}
{docs.length === 0 ? (
<p className="px-2 py-4 text-xs text-silos-muted">No documents yet</p>
) : (
docs.map((doc) => {
const Icon = typeIcons[doc.type]
return (
<Link
key={doc.id}
to={`/workspace/${doc.type}/${doc.id}`}
className={`mb-0.5 flex items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors ${
doc.id === activeDocId
? 'bg-silos-brown/10 font-medium text-silos-brown'
: 'text-silos-text hover:bg-silos-cream'
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" style={{ color: typeColors[doc.type] }} />
<span className="truncate">{doc.title}</span>
</Link>
)
})
)}
</div>
<div className="border-t border-silos-cream-dark p-2">
<Link
to="/dashboard"
className="flex items-center gap-2 rounded-lg px-2 py-2 text-sm text-silos-muted hover:bg-silos-cream hover:text-silos-brown"
>
<Plus className="h-4 w-4" />
Create new
</Link>
</div>
</aside>
)
}

View file

@ -0,0 +1,33 @@
export type AuthTab = 'login' | 'signup' | 'sso'
export type AuthView = AuthTab | 'forgot-password' | 'reset-password'
export interface UserInfo {
fullName: string
username: string
employeeId: string
phone: string
occupation: string
}
export const emptyUserInfo = (): UserInfo => ({
fullName: '',
username: '',
employeeId: '',
phone: '',
occupation: '',
})
export const ssoProviders = [
{ id: 'google', name: 'Google', logo: '/Assets/sso/google.svg' },
{ id: 'microsoft', name: 'Microsoft', logo: '/Assets/sso/microsoft.svg' },
{ id: 'slack', name: 'Slack', logo: '/Assets/sso/slack.svg' },
{ id: 'okta', name: 'Okta', logo: '/Assets/sso/okta.svg' },
{ id: 'apple', name: 'Apple', logo: '/Assets/sso/apple.svg' },
{ id: 'github', name: 'GitHub', logo: '/Assets/sso/github.svg' },
] as const
export const authTabs: { id: AuthTab; label: string }[] = [
{ id: 'login', label: 'Login' },
{ id: 'signup', label: 'Sign Up' },
{ id: 'sso', label: 'SSO Login' },
]

View file

@ -0,0 +1,5 @@
export const logos = {
white: '/Assets/White logo.png',
black: '/Assets/Black logo.png',
rustyBrown: '/Assets/Rusty brown logo.png',
} as const

View file

@ -0,0 +1,90 @@
export type CreateType = 'shed' | 'silo' | 'sketch' | 'stead'
export type ShedTemplateId =
| 'architecture-review'
| 'meeting-notes'
| 'retrospective'
| 'templates'
export interface CreateOption {
id: CreateType
label: string
product: string
description: string
icon: string
color: string
}
export interface ShedTemplate {
id: ShedTemplateId
label: string
description: string
emoji: string
available: boolean
}
export const createOptions: CreateOption[] = [
{
id: 'shed',
label: 'Live Doc',
product: 'Shed',
description: 'Collaborate in real time with your team',
icon: 'Zap',
color: '#C17D65',
},
{
id: 'silo',
label: 'Page',
product: 'Silo',
description: 'Document and store information',
icon: 'FileText',
color: '#6B8CAE',
},
{
id: 'sketch',
label: 'Whiteboard',
product: 'Sketch',
description: 'Rough diagrams and visual plans',
icon: 'PenTool',
color: '#7BAF8E',
},
{
id: 'stead',
label: 'Space',
product: 'Stead',
description: 'Organize project docs in one area',
icon: 'LayoutGrid',
color: '#9B7BB8',
},
]
export const shedTemplates: ShedTemplate[] = [
{
id: 'architecture-review',
label: 'Software architecture review',
description: 'Review architecture, stakeholders, and quality attributes',
emoji: '🏗️',
available: true,
},
{
id: 'meeting-notes',
label: 'Meeting Notes',
description: 'Capture goals, discussion topics, and action items',
emoji: '📝',
available: true,
},
{
id: 'retrospective',
label: 'Retrospective',
description: 'Reflect on what to start, stop, and keep doing',
emoji: '💭',
available: true,
},
{
id: 'templates',
label: 'Templates',
description: 'More templates coming soon (5–9 planned)',
emoji: '📋',
available: false,
},
]

View file

@ -0,0 +1,110 @@
export const navItems = [
{ id: 'for-you', label: 'For you', icon: 'Home' },
{ id: 'templates', label: 'Templates', icon: 'LayoutTemplate' },
{ id: 'recent', label: 'Recent', icon: 'Clock' },
{ id: 'favourites', label: 'Favourite Projects', icon: 'Star' },
{ id: 'all-projects', label: 'All Projects', icon: 'FolderOpen' },
] as const
export type NavItemId = (typeof navItems)[number]['id']
export const personalProjects = [
{ id: '1', name: 'Product Roadmap', color: '#C17D65', letter: 'P' },
{ id: '2', name: 'Design System', color: '#D4956A', letter: 'D' },
{ id: '3', name: 'Marketing Plan', color: '#9B7BB8', letter: 'M' },
{ id: '4', name: 'Engineering Docs', color: '#6B8CAE', letter: 'E' },
{ id: '5', name: 'Research', color: '#7BAF8E', letter: 'R' },
] as const
export const recentActivities = [
{
id: '1',
title: 'Project Roadmap',
action: 'Updated by Priya',
project: 'Product Roadmap',
time: '2h ago',
iconColor: '#6B8CAE',
userInitial: 'P',
userColor: '#C17D65',
},
{
id: '2',
title: 'Sprint Planning Notes',
action: 'Commented by James',
project: 'Engineering Docs',
time: '4h ago',
iconColor: '#C17D65',
userInitial: 'J',
userColor: '#6B8CAE',
},
{
id: '3',
title: 'Brand Guidelines v2',
action: 'Created by Sarah',
project: 'Design System',
time: 'Yesterday',
iconColor: '#D4956A',
userInitial: 'S',
userColor: '#9B7BB8',
},
{
id: '4',
title: 'Q1 Campaign Brief',
action: 'Shared by Mike',
project: 'Marketing Plan',
time: 'Yesterday',
iconColor: '#9B7BB8',
userInitial: 'M',
userColor: '#7BAF8E',
},
{
id: '5',
title: 'User Research Findings',
action: 'Updated by Alex',
project: 'Research',
time: '2 days ago',
iconColor: '#7BAF8E',
userInitial: 'A',
userColor: '#C17D65',
},
{
id: '6',
title: 'API Documentation',
action: 'Reviewed by David',
project: 'Engineering Docs',
time: '3 days ago',
iconColor: '#6B8CAE',
userInitial: 'D',
userColor: '#D4956A',
},
] as const
export const activityStats = [
{ label: 'Pages Created', value: 12, change: '+20% from last week' },
{ label: 'Comments', value: 28, change: '+15% from last week' },
{ label: 'Reactions', value: 56, change: '+32% from last week' },
] as const
export const discoverItems = [
{
id: '1',
title: 'Popular Templates',
description: 'Get started faster with ready-made templates',
icon: 'LayoutTemplate',
color: '#C17D65',
},
{
id: '2',
title: 'Recommended Projects',
description: 'Projects your team is working on',
icon: 'FolderOpen',
color: '#6B8CAE',
},
{
id: '3',
title: 'Knowledge Hub',
description: 'Company-wide docs and resources',
icon: 'BookOpen',
color: '#7BAF8E',
},
] as const

View file

@ -0,0 +1,142 @@
export const products = [
{
name: 'Silos Docs',
description: 'Create rich documents with powerful editing tools',
icon: 'FileText',
},
{
name: 'Silos Boards',
description: 'Kanban boards and task management for your team',
icon: 'LayoutGrid',
},
{
name: 'Silos Base',
description: 'Structured databases to organize your knowledge',
icon: 'Database',
},
{
name: 'Silos Whiteboard',
description: 'Visual collaboration with infinite canvas',
icon: 'PenTool',
},
{
name: 'Silos Integrations',
description: 'Connect with Slack, GitHub, Google Drive & more',
icon: 'Plug',
},
] as const
export const resources = [
{
name: 'Documentation',
description: 'Guides and API references to get you started',
icon: 'BookOpen',
},
{
name: 'Templates',
description: 'Ready-made templates for common workflows',
icon: 'LayoutTemplate',
},
{
name: 'Community',
description: 'Join discussions with other Silos users',
icon: 'Users',
},
{
name: 'Blog',
description: 'Product updates, tips, and best practices',
icon: 'Newspaper',
},
{
name: 'Help Center',
description: 'FAQs and support articles',
icon: 'HelpCircle',
},
] as const
export const productDetails = [
{
title: 'Enterprise Security',
description: 'SSO, audit logs, and role-based access control',
},
{
title: 'Cloud & On-Premise',
description: 'Deploy in the cloud or on your own infrastructure',
},
{
title: 'API & Webhooks',
description: 'Build custom integrations with our REST API',
},
{
title: 'Analytics',
description: 'Insights into team productivity and content usage',
},
] as const
export const stats = [
{ label: '10K+ Teams', icon: 'Users' },
{ label: '2M+ Pages created', icon: 'FileText' },
{ label: '50M+ Collaborations', icon: 'Handshake' },
{ label: '99.9% Uptime SLA', icon: 'Shield' },
] as const
export const features = [
{
title: 'Rich Editor',
description: 'Write with headings, lists, code blocks, and embeds',
preview: 'editor',
},
{
title: 'Databases',
description: 'Tables, filters, and views for structured data',
preview: 'database',
},
{
title: 'Whiteboards',
description: 'Sketch ideas and map workflows visually',
preview: 'whiteboard',
},
{
title: 'Tasks & Boards',
description: 'Kanban boards to track work from start to finish',
preview: 'kanban',
},
{
title: 'Integrations',
description: 'Connect your favorite tools in one workspace',
preview: 'integrations',
},
{
title: 'Real-time Collaboration',
description: 'Edit together with live cursors and comments',
preview: 'collaboration',
},
] as const
export const workflowSteps = [
{
title: 'Create',
description: 'Start with docs, boards, or whiteboards',
icon: 'FilePlus',
},
{
title: 'Organize',
description: 'Structure content in spaces and folders',
icon: 'FolderOpen',
},
{
title: 'Collaborate',
description: 'Work together in real time with your team',
icon: 'Users',
},
{
title: 'Share',
description: 'Publish and share with the right people',
icon: 'Share2',
},
{
title: 'Achieve',
description: 'Track goals and celebrate milestones',
icon: 'Flag',
},
] as const

View file

@ -0,0 +1,76 @@
export const spaceIcons = [
{ id: 'sliders', emoji: '🎛️', color: '#E57373' },
{ id: 'flag', emoji: '🚩', color: '#EF5350' },
{ id: 'cat', emoji: '🐱', color: '#FF7043' },
{ id: 'lifebuoy', emoji: '🛟', color: '#42A5F5' },
{ id: 'code', emoji: '💻', color: '#66BB6A' },
{ id: 'rocket', emoji: '🚀', color: '#AB47BC' },
{ id: 'folder', emoji: '📁', color: '#FFA726' },
{ id: 'star', emoji: '⭐', color: '#FFCA28' },
] as const
export const spacePurposes = [
{
id: 'collaboration' as const,
title: 'Collaboration',
description: 'Explore and iterate on work with your team in real time.',
},
{
id: 'knowledge' as const,
title: 'Knowledge base',
description: 'Create, publish, and share important documentation.',
},
{
id: 'custom' as const,
title: 'Custom',
description: 'Manually configure your space.',
},
]
export const spaceFeatures = [
{ id: 'blogs' as const, label: 'Blogs', icon: '💬', defaultOn: false },
{ id: 'liveDocs' as const, label: 'Live docs', icon: '📄', defaultOn: true },
{ id: 'calendars' as const, label: 'Calendars', icon: '📅', defaultOn: true },
{ id: 'whiteboards' as const, label: 'Whiteboards', icon: '✏️', defaultOn: true },
]
export const spaceNavItems = [
'Content',
'Team goals',
'Ideas',
'Brainstorm',
'Research',
'Slides',
'Calendar',
'Execution plans',
] as const
export type SpacePurpose = 'collaboration' | 'knowledge' | 'custom'
export type SpaceFeatureId = 'blogs' | 'liveDocs' | 'calendars' | 'whiteboards'
export interface SteadContent {
icon: string
iconColor: string
purpose: SpacePurpose
features: Record<SpaceFeatureId, boolean>
description: string
setupComplete: boolean
executionPlans: { title: string; status: 'planned' | 'in-progress' | 'done'; dueDate: string }[]
activeSection: string
}
export const defaultSteadContent = (): SteadContent => ({
icon: '🎛️',
iconColor: '#E57373',
purpose: 'collaboration',
features: {
blogs: false,
liveDocs: true,
calendars: true,
whiteboards: true,
},
description: '',
setupComplete: false,
executionPlans: [],
activeSection: 'Content',
})

View file

@ -0,0 +1,20 @@
import { useEffect, useRef } from 'react'
import { updateDocument, type DocumentRecord } from '../lib/documents'
export function useAutoSave(doc: DocumentRecord | null) {
const isFirstRender = useRef(true)
useEffect(() => {
if (!doc) return
if (isFirstRender.current) {
isFirstRender.current = false
return
}
const timer = setTimeout(() => {
void updateDocument(doc.id, { title: doc.title, content: doc.content })
}, 800)
return () => clearTimeout(timer)
}, [doc?.id, doc?.title, doc?.content])
}

View file

@ -0,0 +1,65 @@
import { useEffect, useRef, useState } from 'react'
export interface CollabPeer {
id: number
name: string
initial: string
}
export function useCollab(
docId: string | undefined,
enabled: boolean,
onRemoteContent: (content: Record<string, unknown>) => void,
) {
const [peers, setPeers] = useState<CollabPeer[]>([])
const socketRef = useRef<WebSocket | null>(null)
const onRemote = useRef(onRemoteContent)
onRemote.current = onRemoteContent
useEffect(() => {
if (!enabled || !docId) return
const token = localStorage.getItem('silos_access_token')
if (!token) return
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(
`${protocol}://${window.location.host}/api/v1/ws/collab/${docId}?token=${encodeURIComponent(token)}`,
)
socketRef.current = ws
ws.onmessage = (event) => {
const payload = JSON.parse(event.data) as {
type: string
content?: Record<string, unknown>
peers?: { id: number; name: string }[]
from?: number
}
if (payload.type === 'presence' && payload.peers) {
setPeers(
payload.peers.map((p) => ({
id: p.id,
name: p.name,
initial: p.name[0]?.toUpperCase() ?? '?',
})),
)
}
if (payload.type === 'content' && payload.content) {
onRemote.current(payload.content)
}
}
return () => {
ws.close()
socketRef.current = null
setPeers([])
}
}, [docId, enabled])
function sendContent(content: Record<string, unknown>) {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: 'content', content }))
}
}
return { peers, sendContent }
}

View file

@ -0,0 +1,47 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { listDocuments, type DocumentRecord } from '../lib/documents'
import { sortByRecent } from '../lib/documentUtils'
export function useDashboardDocuments() {
const [documents, setDocuments] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const docs = await listDocuments(undefined, { sort: 'last_visited_at' })
setDocuments(docs)
} catch {
setError('Could not load documents. Is MongoDB running?')
setDocuments([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const recent = useMemo(() => sortByRecent(documents), [documents])
const projects = useMemo(
() => documents.filter((d) => d.type === 'stead'),
[documents],
)
const starredProjects = useMemo(
() => projects.filter((d) => d.starred),
[projects],
)
return {
documents,
recent,
projects,
starredProjects,
loading,
error,
refresh,
}
}

View file

@ -0,0 +1,9 @@
import { useEffect } from 'react'
import { recordVisit } from '../lib/documents'
export function useRecordVisit(docId: string | undefined) {
useEffect(() => {
if (!docId) return
void recordVisit(docId)
}, [docId])
}

50
frontend/src/index.css Normal file
View file

@ -0,0 +1,50 @@
@import "tailwindcss";
@theme {
--color-silos-brown: #6b3a2c;
--color-silos-brown-dark: #4a281f;
--color-silos-brown-light: #8b4d3b;
--color-silos-tan: #c17d65;
--color-silos-cream: #f5ede4;
--color-silos-cream-dark: #ebe0d4;
--color-silos-text: #3d2b24;
--color-silos-muted: #7a6a62;
--font-sans: "Inter", system-ui, sans-serif;
--font-display: "Playfair Display", Georgia, serif;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: var(--font-sans);
color: var(--color-silos-text);
background-color: var(--color-silos-cream);
-webkit-font-smoothing: antialiased;
}
@keyframes workflow-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.85; }
}
@keyframes workflow-flow {
0% { stroke-dashoffset: 24; }
100% { stroke-dashoffset: 0; }
}
.workflow-step-active {
animation: workflow-pulse 2s ease-in-out infinite;
}
.workflow-arrow-animate {
stroke-dasharray: 8 4;
animation: workflow-flow 1.5s linear infinite;
}

261
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,261 @@
const API_BASE = import.meta.env.VITE_API_URL || '/api/v1'
export interface User {
id: number
email: string
username: string
full_name: string
employee_id: string | null
phone: string | null
occupation: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface AuthTokens {
access_token: string
refresh_token: string
token_type: string
user: User
}
export interface RegisterPayload {
email: string
username: string
password: string
full_name: string
employee_id?: string
phone?: string
occupation?: string
}
export interface LoginPayload {
username_or_email: string
password: string
remember_me?: boolean
}
export interface UserUpdatePayload {
full_name?: string
employee_id?: string
phone?: string
occupation?: string
}
class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
function parseErrorMessage(data: unknown, status: number): string {
if (!data || typeof data !== 'object') {
if (status >= 500) return 'Server error. Please ensure the backend is running.'
return 'Something went wrong'
}
const record = data as Record<string, unknown>
const detail = record.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail
.map((item) => {
if (item && typeof item === 'object' && 'msg' in item) {
return String((item as { msg: unknown }).msg)
}
return JSON.stringify(item)
})
.join(', ')
}
if (typeof record.message === 'string') return record.message
if (status >= 500) return 'Server error. Please ensure the backend is running.'
return 'Something went wrong'
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('silos_access_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
let response: Response
try {
response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
})
} catch {
throw new ApiError('Cannot reach the server. Is the backend running on port 8000?', 0)
}
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new ApiError(parseErrorMessage(data, response.status), response.status)
}
if (response.status === 204) {
return undefined as T
}
return data as T
}
export const authApi = {
register: (payload: RegisterPayload) =>
request<User>('/auth/register', {
method: 'POST',
body: JSON.stringify(payload),
}),
login: (payload: LoginPayload) =>
request<AuthTokens>('/auth/login', {
method: 'POST',
body: JSON.stringify(payload),
}),
me: () => request<User>('/auth/me'),
updateMe: (payload: UserUpdatePayload) =>
request<User>('/auth/me', {
method: 'PATCH',
body: JSON.stringify(payload),
}),
forgotPassword: (email: string) =>
request<{ message: string; reset_token?: string }>('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
}),
resetPassword: (token: string, new_password: string) =>
request<{ message: string }>('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, new_password }),
}),
}
export function saveAuth(tokens: AuthTokens) {
localStorage.setItem('silos_access_token', tokens.access_token)
localStorage.setItem('silos_refresh_token', tokens.refresh_token)
localStorage.setItem('silos_user', JSON.stringify(tokens.user))
}
export function clearAuth() {
localStorage.removeItem('silos_access_token')
localStorage.removeItem('silos_refresh_token')
localStorage.removeItem('silos_user')
}
export function getStoredUser(): User | null {
const raw = localStorage.getItem('silos_user')
if (!raw) return null
try {
return JSON.parse(raw) as User
} catch {
return null
}
}
export { ApiError }
export interface DocumentMember {
user_id: number
role: 'editor' | 'viewer'
name: string
email: string
}
export interface ApiDocument {
id: string
type: 'shed' | 'silo' | 'sketch' | 'stead'
title: string
template_id?: string | null
stead_id?: string | null
content: Record<string, unknown>
created_by: string
created_at: string
updated_at: string
starred: boolean
last_visited_at: string | null
owner_id?: number | null
visibility?: 'private' | 'members'
members?: DocumentMember[]
}
export interface DocumentCreatePayload {
type: 'shed' | 'silo' | 'sketch' | 'stead'
title: string
content?: Record<string, unknown>
template_id?: string
stead_id?: string
}
export interface DocumentUpdatePayload {
title?: string
content?: Record<string, unknown>
starred?: boolean
}
export const documentsApi = {
list: (params?: {
type?: string
stead_id?: string
starred?: boolean
sort?: 'updated_at' | 'last_visited_at'
}) => {
const search = new URLSearchParams()
if (params?.type) search.set('type', params.type)
if (params?.stead_id) search.set('stead_id', params.stead_id)
if (params?.starred !== undefined) search.set('starred', String(params.starred))
if (params?.sort) search.set('sort', params.sort)
const qs = search.toString()
return request<ApiDocument[]>(`/documents${qs ? `?${qs}` : ''}`)
},
search: (q: string) =>
request<ApiDocument[]>(`/documents/search?q=${encodeURIComponent(q)}`),
get: (id: string) => request<ApiDocument>(`/documents/${id}`),
create: (payload: DocumentCreatePayload) =>
request<ApiDocument>('/documents', {
method: 'POST',
body: JSON.stringify(payload),
}),
update: (id: string, payload: DocumentUpdatePayload) =>
request<ApiDocument>(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
visit: (id: string) =>
request<ApiDocument>(`/documents/${id}/visit`, { method: 'POST' }),
share: (id: string, email_or_username: string, role: 'editor' | 'viewer' = 'editor') =>
request<ApiDocument>(`/documents/${id}/share`, {
method: 'POST',
body: JSON.stringify({ email_or_username, role }),
}),
unshare: (id: string, userId: number) =>
request<ApiDocument>(`/documents/${id}/share/${userId}`, { method: 'DELETE' }),
delete: (id: string) =>
request<void>(`/documents/${id}`, { method: 'DELETE' }),
}

View file

@ -0,0 +1,95 @@
import type { CreateType } from '../data/createData'
import type { DocumentRecord } from './documents'
export const typeColors: Record<CreateType, string> = {
silo: '#6B8CAE',
shed: '#C17D65',
sketch: '#7BAF8E',
stead: '#9B7BB8',
}
export const typeLabels: Record<CreateType, string> = {
silo: 'Silo',
shed: 'Shed',
sketch: 'Sketch',
stead: 'Stead',
}
export function getActivityDate(doc: DocumentRecord): string {
return doc.lastVisitedAt ?? doc.updatedAt
}
export function formatRelativeTime(iso: string): string {
const date = new Date(iso)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays === 1) return 'Yesterday'
if (diffDays < 7) return `${diffDays} days ago`
return date.toLocaleDateString('en-US', {
day: 'numeric',
month: 'long',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
})
}
export function formatVisitedDate(iso: string): string {
const date = new Date(iso)
const now = new Date()
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000)
if (diffDays === 0) return 'Visited today'
if (diffDays === 1) return 'Visited yesterday'
return `Visited ${date.toLocaleDateString('en-US', { day: 'numeric', month: 'long', year: 'numeric' })}`
}
export function groupByTimePeriod(docs: DocumentRecord[]): { label: string; items: DocumentRecord[] }[] {
const now = new Date()
const groups: { label: string; items: DocumentRecord[] }[] = [
{ label: 'Today', items: [] },
{ label: 'Yesterday', items: [] },
{ label: 'In the last week', items: [] },
{ label: 'Earlier', items: [] },
]
for (const doc of docs) {
const date = new Date(getActivityDate(doc))
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000)
if (diffDays === 0) groups[0].items.push(doc)
else if (diffDays === 1) groups[1].items.push(doc)
else if (diffDays < 7) groups[2].items.push(doc)
else groups[3].items.push(doc)
}
return groups.filter((g) => g.items.length > 0)
}
export function sortByRecent(docs: DocumentRecord[]): DocumentRecord[] {
return [...docs].sort(
(a, b) => new Date(getActivityDate(b)).getTime() - new Date(getActivityDate(a)).getTime(),
)
}
export function getProjectIcon(doc: DocumentRecord): string {
if (doc.type === 'stead') {
const content = doc.content as { icon?: string }
return content.icon ?? '📁'
}
return '📄'
}
export function getProjectColor(doc: DocumentRecord): string {
if (doc.type === 'stead') {
const content = doc.content as { iconColor?: string }
return content.iconColor ?? typeColors.stead
}
return typeColors[doc.type]
}

View file

@ -0,0 +1,215 @@
import type { CreateType, ShedTemplateId } from '../data/createData'
import { documentsApi, ApiError, type ApiDocument, type DocumentMember } from './api'
export interface DocumentMeta {
id: string
type: CreateType
title: string
templateId?: ShedTemplateId
steadId?: string
createdAt: string
updatedAt: string
createdBy: string
starred: boolean
lastVisitedAt?: string
ownerId?: number
visibility?: 'private' | 'members'
members: DocumentMember[]
}
export interface DocumentRecord extends DocumentMeta {
content: Record<string, unknown>
}
export interface ListDocumentsOptions {
sort?: 'updated_at' | 'last_visited_at'
starred?: boolean
}
function fromApi(doc: ApiDocument): DocumentRecord {
return {
id: doc.id,
type: doc.type,
title: doc.title,
templateId: doc.template_id as ShedTemplateId | undefined,
steadId: doc.stead_id ?? undefined,
content: doc.content,
createdBy: doc.created_by,
createdAt: doc.created_at,
updatedAt: doc.updated_at,
starred: doc.starred,
lastVisitedAt: doc.last_visited_at ?? undefined,
ownerId: doc.owner_id ?? undefined,
visibility: doc.visibility,
members: doc.members ?? [],
}
}
export async function listDocuments(
type?: CreateType,
options?: ListDocumentsOptions,
): Promise<DocumentRecord[]> {
const docs = await documentsApi.list({
type,
starred: options?.starred,
sort: options?.sort,
})
return docs.map(fromApi)
}
export async function listDocumentsByStead(steadId: string): Promise<DocumentRecord[]> {
const docs = await documentsApi.list({ stead_id: steadId })
return docs.filter((d) => d.type !== 'stead').map(fromApi)
}
export async function searchDocuments(q: string): Promise<DocumentRecord[]> {
if (!q.trim()) return []
const docs = await documentsApi.search(q)
return docs.map(fromApi)
}
export async function getDocument(id: string): Promise<DocumentRecord | null> {
try {
const doc = await documentsApi.get(id)
return fromApi(doc)
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
throw error
}
return null
}
}
export async function createDocument(
type: CreateType,
title: string,
content: Record<string, unknown>,
templateId?: ShedTemplateId,
_createdBy = 'You',
steadId?: string,
): Promise<DocumentRecord> {
const doc = await documentsApi.create({
type,
title,
content,
template_id: templateId,
stead_id: steadId,
})
return fromApi(doc)
}
export async function updateDocument(
id: string,
updates: Partial<Pick<DocumentRecord, 'title' | 'content'>>,
): Promise<DocumentRecord | null> {
try {
const doc = await documentsApi.update(id, {
title: updates.title,
content: updates.content,
})
return fromApi(doc)
} catch {
return null
}
}
export async function toggleStar(id: string, starred: boolean): Promise<DocumentRecord | null> {
try {
const doc = await documentsApi.update(id, { starred })
return fromApi(doc)
} catch {
return null
}
}
export async function recordVisit(id: string): Promise<void> {
try {
await documentsApi.visit(id)
} catch {
// Non-critical — ignore if visit tracking fails
}
}
export async function shareDocument(
id: string,
emailOrUsername: string,
role: 'editor' | 'viewer' = 'editor',
): Promise<DocumentRecord | null> {
const doc = await documentsApi.share(id, emailOrUsername, role)
return fromApi(doc)
}
export async function unshareDocument(id: string, userId: number): Promise<DocumentRecord | null> {
const doc = await documentsApi.unshare(id, userId)
return fromApi(doc)
}
export function getDefaultShedContent(templateId: ShedTemplateId): Record<string, unknown> {
switch (templateId) {
case 'architecture-review':
return {
reviewDate: '',
projectLead: '',
overview: '',
architectureIssues: [
{ issue: '', impact: '', priority: 'MEDIUM', notes: '' },
{ issue: '', impact: '', priority: 'LOW', notes: '' },
],
stakeholders: [{ name: '', role: '' }],
qualityAttributes: [
{ attribute: 'Availability', definition: '', metrics: '', notes: '' },
{ attribute: '', definition: '', metrics: '', notes: '' },
],
goals: '',
nextSteps: [
{ project: '', description: '', estimate: '', documentation: '', releaseDate: '' },
],
}
case 'meeting-notes':
return {
date: new Date().toISOString().split('T')[0],
participants: '',
goals: '',
discussionTopics: [
{ time: '', topic: '', presenter: '', notes: '' },
{ time: '', topic: '', presenter: '', notes: '' },
],
actionItems: [''],
decisions: [''],
relatedInfo: '',
}
case 'retrospective':
return {
overview: '',
startDoing: [''],
stopDoing: [''],
keepDoing: [''],
actionItems: [''],
}
default:
return {}
}
}
export function getDefaultTitle(type: CreateType, templateId?: ShedTemplateId): string {
const date = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
if (type === 'shed' && templateId) {
const names: Record<string, string> = {
'architecture-review': 'Software architecture review',
'meeting-notes': 'Meeting Notes',
retrospective: 'Retrospective',
}
return `${names[templateId] ?? 'Live Doc'} — ${date}`
}
const typeNames: Record<CreateType, string> = {
shed: 'Untitled Live Doc',
silo: 'Untitled Page',
sketch: 'Untitled Whiteboard',
stead: 'Untitled Space',
}
return `${typeNames[type]} — ${date}`
}

13
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View file

@ -0,0 +1,5 @@
import DashboardLayout from '../components/dashboard/DashboardLayout'
export default function DashboardPage() {
return <DashboardLayout />
}

View file

@ -0,0 +1,19 @@
import { Link } from 'react-router-dom'
export default function ForbiddenPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-silos-cream px-6 text-center">
<p className="text-sm font-semibold uppercase tracking-wide text-silos-tan">403</p>
<h1 className="mt-2 text-2xl font-bold text-silos-text">You don’t have access</h1>
<p className="mt-2 max-w-md text-sm text-silos-muted">
This document is private. Ask the owner to invite you by email or username.
</p>
<Link
to="/dashboard"
className="mt-6 rounded-lg bg-silos-brown px-4 py-2 text-sm font-medium text-white hover:bg-silos-brown-dark"
>
Back to dashboard
</Link>
</div>
)
}

View file

@ -0,0 +1,19 @@
import Header from '../components/Header'
import Hero from '../components/Hero'
import Features from '../components/Features'
import Workflow from '../components/Workflow'
import Footer from '../components/Footer'
export default function LandingPage() {
return (
<div className="min-h-screen">
<Header />
<main>
<Hero />
<Features />
<Workflow />
</main>
<Footer />
</div>
)
}

View file

@ -0,0 +1,367 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { logos } from '../data/brandAssets'
import {
type AuthTab,
type AuthView,
type UserInfo,
emptyUserInfo,
authTabs,
} from '../data/authData'
import { authApi, saveAuth, ApiError } from '../lib/api'
import AuthSidebar from '../components/auth/AuthSidebar'
import AuthFooter from '../components/auth/AuthFooter'
import LoginForm from '../components/auth/LoginForm'
import SignUpForm from '../components/auth/SignUpForm'
import SSOForm from '../components/auth/SSOForm'
import ForgotPasswordForm from '../components/auth/ForgotPasswordForm'
import ResetPasswordForm from '../components/auth/ResetPasswordForm'
import QuickActions from '../components/auth/QuickActions'
function StatusMessage({ message, type }: { message: string; type: 'success' | 'error' }) {
return (
<div
className={`rounded-xl px-4 py-3 text-sm ${
type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{message}
</div>
)
}
export default function LoginPage() {
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<AuthTab>('login')
const [view, setView] = useState<AuthView>('login')
const [status, setStatus] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
const [loginUsername, setLoginUsername] = useState('')
const [loginPassword, setLoginPassword] = useState('')
const [rememberMe, setRememberMe] = useState(false)
const [signupEmail, setSignupEmail] = useState('')
const [signupPassword, setSignupPassword] = useState('')
const [signupConfirmPassword, setSignupConfirmPassword] = useState('')
const [forgotEmail, setForgotEmail] = useState('')
const [resetPassword, setResetPassword] = useState('')
const [resetConfirmPassword, setResetConfirmPassword] = useState('')
const [selectedSSO, setSelectedSSO] = useState<string | null>(null)
const [userInfo, setUserInfo] = useState<UserInfo>(emptyUserInfo())
const [resetToken, setResetToken] = useState('')
const [loading, setLoading] = useState(false)
const showStatus = (message: string, type: 'success' | 'error' = 'success') => {
setStatus({ message, type })
if (!loading) {
setTimeout(() => setStatus(null), 4000)
}
}
const switchTab = (tab: AuthTab) => {
setActiveTab(tab)
setView(tab)
setStatus(null)
}
const handleUserInfoChange = (field: keyof UserInfo, value: string) => {
setUserInfo((prev) => ({ ...prev, [field]: value }))
}
const handleLogin = async () => {
if (!loginUsername || !loginPassword) {
showStatus('Please enter your username and password.', 'error')
return
}
setLoading(true)
try {
const tokens = await authApi.login({
username_or_email: loginUsername,
password: loginPassword,
remember_me: rememberMe,
})
saveAuth(tokens)
showStatus(`Welcome back, ${tokens.user.full_name}! Login successful.`)
setTimeout(() => navigate('/dashboard'), 800)
} catch (err) {
showStatus(err instanceof ApiError ? err.message : 'Login failed.', 'error')
} finally {
setLoading(false)
}
}
const handleSignUp = async () => {
if (!signupEmail || !signupPassword || !signupConfirmPassword) {
showStatus('Please fill in all required fields.', 'error')
return
}
if (signupPassword !== signupConfirmPassword) {
showStatus('Passwords do not match.', 'error')
return
}
if (!userInfo.fullName || !userInfo.username) {
showStatus('Please complete your user information.', 'error')
return
}
setLoading(true)
try {
await authApi.register({
email: signupEmail,
username: userInfo.username,
password: signupPassword,
full_name: userInfo.fullName,
employee_id: userInfo.employeeId || undefined,
phone: userInfo.phone || undefined,
occupation: userInfo.occupation || undefined,
})
const tokens = await authApi.login({
username_or_email: signupEmail,
password: signupPassword,
})
saveAuth(tokens)
showStatus(`Welcome, ${tokens.user.full_name}! Account created.`)
setTimeout(() => navigate('/dashboard'), 800)
} catch (err) {
showStatus(err instanceof ApiError ? err.message : 'Registration failed.', 'error')
} finally {
setLoading(false)
}
}
const handleSSO = () => {
if (!selectedSSO) {
showStatus('Please select an SSO provider.', 'error')
return
}
showStatus(`Redirecting to ${selectedSSO} authentication...`)
}
const handleSSOSelect = (providerId: string) => {
setSelectedSSO(providerId)
setActiveTab('sso')
setView('sso')
}
const handleForgotPassword = async () => {
if (!forgotEmail) {
showStatus('Please enter your email address.', 'error')
return
}
setLoading(true)
try {
const result = await authApi.forgotPassword(forgotEmail)
if (result.reset_token) {
setResetToken(result.reset_token)
}
showStatus('Reset link sent. Check your inbox.')
setView('reset-password')
} catch (err) {
showStatus(err instanceof ApiError ? err.message : 'Request failed.', 'error')
} finally {
setLoading(false)
}
}
const handleResetPassword = async () => {
if (!resetToken) {
showStatus('Reset token missing. Please request a new reset link.', 'error')
return
}
if (!resetPassword || !resetConfirmPassword) {
showStatus('Please enter and confirm your new password.', 'error')
return
}
if (resetPassword !== resetConfirmPassword) {
showStatus('Passwords do not match.', 'error')
return
}
setLoading(true)
try {
await authApi.resetPassword(resetToken, resetPassword)
showStatus('Password reset successfully! You can now log in.')
setResetPassword('')
setResetConfirmPassword('')
setResetToken('')
switchTab('login')
} catch (err) {
showStatus(err instanceof ApiError ? err.message : 'Reset failed.', 'error')
} finally {
setLoading(false)
}
}
const renderMainForm = () => {
switch (view) {
case 'forgot-password':
return (
<ForgotPasswordForm
email={forgotEmail}
onEmailChange={setForgotEmail}
onSubmit={handleForgotPassword}
onBack={() => switchTab('login')}
/>
)
case 'reset-password':
return (
<ResetPasswordForm
newPassword={resetPassword}
confirmPassword={resetConfirmPassword}
onNewPasswordChange={setResetPassword}
onConfirmPasswordChange={setResetConfirmPassword}
onSubmit={handleResetPassword}
onBack={() => switchTab('login')}
/>
)
case 'signup':
return (
<SignUpForm
email={signupEmail}
password={signupPassword}
confirmPassword={signupConfirmPassword}
userInfo={userInfo}
onEmailChange={setSignupEmail}
onPasswordChange={setSignupPassword}
onConfirmPasswordChange={setSignupConfirmPassword}
onUserInfoChange={handleUserInfoChange}
onSubmit={handleSignUp}
/>
)
case 'sso':
return (
<SSOForm
selectedProvider={selectedSSO}
userInfo={userInfo}
onProviderSelect={setSelectedSSO}
onUserInfoChange={handleUserInfoChange}
onSubmit={handleSSO}
/>
)
default:
return (
<LoginForm
username={loginUsername}
password={loginPassword}
rememberMe={rememberMe}
onUsernameChange={setLoginUsername}
onPasswordChange={setLoginPassword}
onRememberMeChange={setRememberMe}
onSubmit={handleLogin}
onForgotPassword={() => setView('forgot-password')}
onSSOSelect={handleSSOSelect}
/>
)
}
}
const isSubView = view === 'forgot-password' || view === 'reset-password'
return (
<div className="flex min-h-screen flex-col bg-silos-cream">
<div className="flex flex-1">
<AuthSidebar />
<main className="flex flex-1 flex-col items-center px-4 py-6 sm:px-6 lg:py-8">
<div className="mb-6 w-full max-w-5xl xl:hidden">
<Link to="/">
<img src={logos.rustyBrown} alt="Silos" className="h-8 w-auto" />
</Link>
</div>
<div className="w-full max-w-5xl overflow-hidden rounded-2xl border border-silos-cream-dark bg-white shadow-xl">
{/* Tab bar */}
{!isSubView && (
<div className="flex flex-col gap-3 border-b border-silos-cream-dark px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-1">
{authTabs.map((tab) => (
<button
key={tab.id}
onClick={() => switchTab(tab.id)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-silos-brown text-white'
: 'text-silos-muted hover:bg-silos-cream hover:text-silos-text'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'login' && (
<p className="text-sm text-silos-muted">
New to Silos?{' '}
<button
onClick={() => switchTab('signup')}
className="font-semibold text-silos-brown hover:underline"
>
Sign up
</button>
</p>
)}
{activeTab === 'signup' && (
<p className="text-sm text-silos-muted">
Already have an account?{' '}
<button
onClick={() => switchTab('login')}
className="font-semibold text-silos-brown hover:underline"
>
Login
</button>
</p>
)}
</div>
)}
{/* Content */}
<div className="grid lg:grid-cols-5">
<div className="border-b border-silos-cream-dark p-6 lg:col-span-3 lg:border-b-0 lg:border-r lg:p-8">
<AnimatePresence mode="wait">
<motion.div
key={view}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{(status || loading) && (
<div className="mb-5">
{loading ? (
<div className="rounded-xl border border-silos-cream-dark bg-silos-cream/50 px-4 py-3 text-sm text-silos-muted">
Processing...
</div>
) : status ? (
<StatusMessage message={status.message} type={status.type} />
) : null}
</div>
)}
{renderMainForm()}
</motion.div>
</AnimatePresence>
</div>
<div className="bg-silos-cream/30 p-6 lg:col-span-2 lg:p-8">
<QuickActions
onResetPassword={() => setView('reset-password')}
onCreateAccount={() => switchTab('signup')}
/>
</div>
</div>
</div>
<p className="mt-6">
<Link to="/" className="text-sm text-silos-muted hover:text-silos-brown">
&larr; Back to home
</Link>
</p>
</main>
</div>
<AuthFooter />
</div>
)
}

View file

@ -0,0 +1,78 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import ArchitectureReviewTemplate from '../../components/shed/templates/ArchitectureReviewTemplate'
import MeetingNotesTemplate from '../../components/shed/templates/MeetingNotesTemplate'
import RetrospectiveTemplate from '../../components/shed/templates/RetrospectiveTemplate'
import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { useCollab } from '../../hooks/useCollab'
import { ApiError } from '../../lib/api'
function ShedTemplate({
doc,
onUpdate,
}: {
doc: DocumentRecord
onUpdate: (u: Partial<DocumentRecord>) => void
}) {
switch (doc.templateId) {
case 'architecture-review':
return <ArchitectureReviewTemplate doc={doc} onUpdate={onUpdate} />
case 'meeting-notes':
return <MeetingNotesTemplate doc={doc} onUpdate={onUpdate} />
case 'retrospective':
return <RetrospectiveTemplate doc={doc} onUpdate={onUpdate} />
default:
return <p className="text-silos-muted">Unknown template</p>
}
}
export default function ShedPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null)
useEffect(() => {
if (!id) return
void getDocument(id)
.then((found) => {
if (!found || found.type !== 'shed') {
navigate('/dashboard')
return
}
setDoc(found)
})
.catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
else navigate('/dashboard')
})
}, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id)
const collab = useCollab(
doc?.id,
Boolean(doc && doc.templateId === 'meeting-notes'),
(content) => setDoc((prev) => (prev ? { ...prev, content } : prev)),
)
if (!doc) return null
return (
<WorkspaceShell
doc={doc}
onDocChange={setDoc}
presence={collab.peers.map((p) => ({ name: p.name, initial: p.initial }))}
>
<ShedTemplate
doc={doc}
onUpdate={(u) => {
setDoc({ ...doc, ...u })
if (u.content) collab.sendContent(u.content)
}}
/>
</WorkspaceShell>
)
}

View file

@ -0,0 +1,93 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SiloEditor from '../../components/silo/SiloEditor'
import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { ApiError } from '../../lib/api'
function siloHtml(content: Record<string, unknown>): string {
if (typeof content.bodyHtml === 'string' && content.bodyHtml) return content.bodyHtml
if (typeof content.body === 'string') return content.body.replace(/\n/g, '<br/>')
return ''
}
export default function SiloPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null)
useEffect(() => {
if (!id) return
void getDocument(id)
.then((found) => {
if (!found || found.type !== 'silo') {
navigate('/dashboard')
return
}
setDoc(found)
})
.catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
else navigate('/dashboard')
})
}, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id)
if (!doc) return null
const status = (doc.content.status as string) || 'draft'
function patchHtml(bodyHtml: string) {
setDoc((prev) =>
prev
? { ...prev, content: { ...prev.content, bodyHtml, body: prev.content.body ?? '' } }
: prev,
)
}
function publish() {
setDoc((prev) =>
prev
? {
...prev,
content: {
...prev.content,
status: status === 'published' ? 'draft' : 'published',
},
}
: prev,
)
}
return (
<WorkspaceShell
doc={doc}
onDocChange={setDoc}
layout="page"
headerActions={
<>
<button
type="button"
onClick={publish}
className="hidden rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark sm:block"
>
{status === 'published' ? 'Unpublish' : 'Publish'}
</button>
<button
type="button"
onClick={() => navigate('/dashboard')}
className="hidden rounded-lg px-2 py-1 text-xs text-silos-muted hover:bg-silos-cream sm:block"
>
Close
</button>
</>
}
>
<SiloEditor html={siloHtml(doc.content)} onChange={patchHtml} />
</WorkspaceShell>
)
}

View file

@ -0,0 +1,72 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SketchCanvas from '../../components/sketch/SketchCanvas'
import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { ApiError } from '../../lib/api'
import {
normalizeSketchContent,
type SketchElement,
type SketchViewport,
} from '../../types/sketch'
export default function SketchPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null)
useEffect(() => {
if (!id) return
void getDocument(id)
.then((found) => {
if (!found || found.type !== 'sketch') {
navigate('/dashboard')
return
}
setDoc(found)
})
.catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
else navigate('/dashboard')
})
}, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id)
const sketchContent = useMemo(
() => (doc ? normalizeSketchContent(doc.content) : { elements: [] }),
[doc?.content],
)
if (!doc) return null
function patchSketch(elements: SketchElement[], viewport?: SketchViewport) {
const next = { elements, viewport } as Record<string, unknown>
setDoc((prev) => (prev ? { ...prev, content: next } : prev))
}
return (
<WorkspaceShell doc={doc} onDocChange={setDoc} layout="canvas" hideTitle>
<div className="flex h-full flex-col">
<div className="flex items-center border-b border-silos-cream-dark px-6 py-3">
<input
value={doc.title}
onChange={(e) => setDoc((prev) => (prev ? { ...prev, title: e.target.value } : prev))}
className="border-none bg-transparent text-lg font-semibold text-silos-text outline-none"
placeholder="Untitled whiteboard"
/>
</div>
<div className="relative flex-1">
<SketchCanvas
elements={sketchContent.elements}
viewport={sketchContent.viewport}
onChange={patchSketch}
/>
</div>
</div>
</WorkspaceShell>
)
}

View file

@ -0,0 +1,306 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { Link } from 'react-router-dom'
import { logos } from '../../data/brandAssets'
import {
spaceIcons,
spacePurposes,
spaceFeatures,
spaceNavItems,
defaultSteadContent,
type SpacePurpose,
type SpaceFeatureId,
} from '../../data/steadData'
import { createDocument, getDefaultTitle } from '../../lib/documents'
import { getStoredUser } from '../../lib/api'
export default function SteadCreatePage() {
const navigate = useNavigate()
const user = getStoredUser()
const [step, setStep] = useState(1)
const [name, setName] = useState('')
const [selectedIcon, setSelectedIcon] = useState<(typeof spaceIcons)[number]>(spaceIcons[0])
const [purpose, setPurpose] = useState<SpacePurpose>('collaboration')
const [features, setFeatures] = useState<Record<SpaceFeatureId, boolean>>(() => {
const initial: Record<SpaceFeatureId, boolean> = {
blogs: false,
liveDocs: true,
calendars: true,
whiteboards: true,
}
spaceFeatures.forEach((f) => {
initial[f.id] = f.defaultOn
})
return initial
})
function toggleFeature(id: SpaceFeatureId) {
setFeatures((prev) => ({ ...prev, [id]: !prev[id] }))
}
async function handleCreate() {
const content = {
...defaultSteadContent(),
icon: selectedIcon.emoji,
iconColor: selectedIcon.color,
purpose,
features,
setupComplete: true,
}
const doc = await createDocument(
'stead',
name.trim() || getDefaultTitle('stead'),
content,
undefined,
user?.full_name ?? 'You',
)
navigate(`/workspace/stead/${doc.id}`)
}
const previewNav = spaceNavItems.filter((item) => {
if (item === 'Calendar' && !features.calendars) return false
if (item === 'Brainstorm' && !features.whiteboards) return false
return true
})
return (
<div className="flex min-h-screen flex-col bg-silos-cream">
<header className="flex h-12 items-center gap-3 border-b border-silos-cream-dark bg-white px-4">
<Link to="/dashboard">
<img src={logos.rustyBrown} alt="Silos" className="h-6 w-auto" />
</Link>
</header>
<div className="flex flex-1 flex-col lg:flex-row">
{/* Form panel */}
<div className="flex w-full flex-col justify-between bg-white px-8 py-10 lg:w-1/2 lg:px-12">
<AnimatePresence mode="wait">
{step === 1 ? (
<motion.div
key="step1"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="max-w-lg"
>
<h1 className="text-2xl font-bold text-silos-text">Create new space</h1>
<p className="mt-1 text-sm text-silos-muted">
Required fields are marked with an asterisk *
</p>
<label className="mt-8 block text-sm font-medium text-silos-text">
Name this space *
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Engineering, Product, Q3 Planning"
className="mt-2 w-full rounded-lg border border-silos-cream-dark px-4 py-2.5 text-silos-text outline-none focus:border-silos-brown"
/>
<p className="mt-8 text-sm font-medium text-silos-text">Choose an icon</p>
<div className="mt-3 flex flex-wrap gap-2">
{spaceIcons.map((icon) => (
<button
key={icon.id}
type="button"
onClick={() => setSelectedIcon(icon)}
className={`flex h-11 w-11 items-center justify-center rounded-lg text-xl transition-all ${
selectedIcon.id === icon.id
? 'ring-2 ring-silos-brown ring-offset-2'
: 'hover:scale-105'
}`}
style={{ backgroundColor: icon.color + '33' }}
>
{icon.emoji}
</button>
))}
</div>
<p className="mt-8 text-sm font-medium text-silos-text">
Choose a purpose for this space
</p>
<div className="mt-3 space-y-3">
{spacePurposes.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setPurpose(p.id)}
className={`w-full rounded-xl border p-4 text-left transition-colors ${
purpose === p.id
? 'border-silos-brown bg-silos-brown/5'
: 'border-silos-cream-dark hover:border-silos-tan'
}`}
>
<p className="font-medium text-silos-text">{p.title}</p>
<p className="mt-1 text-sm text-silos-muted">{p.description}</p>
</button>
))}
</div>
</motion.div>
) : (
<motion.div
key="step2"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="max-w-lg"
>
<h1 className="text-2xl font-bold text-silos-text">
Confirm your {purpose === 'knowledge' ? 'knowledge base' : 'collaboration'} space
</h1>
<p className="mt-1 text-sm text-silos-muted">
Your space comes with:
</p>
<div className="mt-6 space-y-4">
{features.liveDocs && (
<div className="flex gap-3">
<span className="text-xl">📄</span>
<div>
<p className="font-medium text-silos-text">Live docs</p>
<p className="text-sm text-silos-muted">Create and edit in real time</p>
</div>
</div>
)}
{features.whiteboards && (
<div className="flex gap-3">
<span className="text-xl">✏️</span>
<div>
<p className="font-medium text-silos-text">Visual brainstorming</p>
<p className="text-sm text-silos-muted">
Whiteboards help you capture early ideas
</p>
</div>
</div>
)}
{features.calendars && (
<div className="flex gap-3">
<span className="text-xl">📅</span>
<div>
<p className="font-medium text-silos-text">Calendars</p>
<p className="text-sm text-silos-muted">
Keep your team on track with shared schedules
</p>
</div>
</div>
)}
</div>
<p className="mt-8 text-sm font-medium text-silos-text">Features</p>
<div className="mt-3 space-y-2">
{spaceFeatures.map((f) => (
<div
key={f.id}
className="flex items-center justify-between rounded-lg border border-silos-cream-dark px-4 py-3"
>
<div className="flex items-center gap-3">
<span>{f.icon}</span>
<span className="text-sm text-silos-text">{f.label}</span>
</div>
<button
type="button"
onClick={() => toggleFeature(f.id)}
className={`relative h-6 w-11 rounded-full transition-colors ${
features[f.id] ? 'bg-green-500' : 'bg-silos-cream-dark'
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
features[f.id] ? 'left-5' : 'left-0.5'
}`}
/>
</button>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<div className="mt-10 flex items-center justify-between border-t border-silos-cream-dark pt-6">
<span className="text-sm text-silos-muted">Step {step} of 2</span>
<div className="flex gap-3">
{step === 2 && (
<button
type="button"
onClick={() => setStep(1)}
className="rounded-lg border border-silos-cream-dark px-5 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"
>
Back
</button>
)}
{step === 1 ? (
<button
type="button"
onClick={() => setStep(2)}
disabled={!name.trim()}
className="rounded-lg bg-silos-brown px-5 py-2 text-sm font-medium text-white hover:bg-silos-brown-dark disabled:opacity-50"
>
Next
</button>
) : (
<button
type="button"
onClick={() => void handleCreate()}
className="rounded-lg bg-silos-brown px-5 py-2 text-sm font-medium text-white hover:bg-silos-brown-dark"
>
Create space
</button>
)}
</div>
</div>
</div>
{/* Live preview */}
<div className="relative hidden flex-1 items-center justify-center bg-gradient-to-br from-sky-100 to-blue-50 p-8 lg:flex">
<div className="w-full max-w-md overflow-hidden rounded-xl border border-white/60 bg-white shadow-xl">
<div className="flex">
<div className="w-36 shrink-0 border-r border-silos-cream-dark bg-silos-cream/30 p-3">
<div className="flex items-center gap-2">
<span
className="flex h-6 w-6 items-center justify-center rounded text-sm"
style={{ backgroundColor: selectedIcon.color + '44' }}
>
{selectedIcon.emoji}
</span>
<span className="truncate text-xs font-semibold text-silos-text">
{name || 'Your space'}
</span>
</div>
<nav className="mt-4 space-y-1">
{previewNav.map((item) => (
<div
key={item}
className={`rounded px-2 py-1 text-[10px] ${
item === 'Content'
? 'bg-white font-medium text-silos-text'
: 'text-silos-muted'
}`}
>
{item}
</div>
))}
</nav>
</div>
<div className="flex-1 p-4">
<div className="flex items-center gap-2">
<span>🚀</span>
<span className="text-sm font-semibold text-silos-text">Q3 Planning</span>
</div>
<div className="mt-4 space-y-2">
<div className="h-2 w-full rounded bg-silos-cream-dark" />
<div className="h-2 w-4/5 rounded bg-green-200" />
<div className="h-2 w-full rounded bg-silos-cream-dark" />
<div className="h-2 w-3/5 rounded bg-purple-200" />
<div className="h-2 w-full rounded bg-silos-cream-dark" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,78 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SteadHub from '../../components/stead/SteadHub'
import { defaultSteadContent, type SteadContent } from '../../data/steadData'
import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { ApiError } from '../../lib/api'
export default function SteadPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null)
useEffect(() => {
if (!id) return
void getDocument(id)
.then((found) => {
if (!found || found.type !== 'stead') {
navigate('/dashboard')
return
}
const content = found.content as unknown as SteadContent
if (!content?.setupComplete) {
navigate('/workspace/stead/new')
return
}
setDoc(found)
})
.catch((err) => {
if (err instanceof ApiError && err.status === 403) navigate('/forbidden')
else navigate('/dashboard')
})
}, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id)
if (!doc) return null
const content: SteadContent = {
...defaultSteadContent(),
...(doc.content as unknown as Partial<SteadContent>),
}
function patchContent(updates: Partial<SteadContent>) {
const next = { ...content, ...updates }
setDoc((prev) => (prev ? { ...prev, content: next } : prev))
}
function handleAddPlan() {
patchContent({
executionPlans: [
...content.executionPlans,
{ title: '', status: 'planned', dueDate: '' },
],
})
}
function handleUpdatePlan(index: number, field: string, value: string) {
const plans = [...content.executionPlans]
plans[index] = { ...plans[index], [field]: value }
patchContent({ executionPlans: plans })
}
return (
<WorkspaceShell doc={doc} onDocChange={setDoc} layout="space" hideTitle>
<SteadHub
doc={doc}
content={content}
onSectionChange={(section) => patchContent({ activeSection: section })}
onAddPlan={handleAddPlan}
onUpdatePlan={handleUpdatePlan}
/>
</WorkspaceShell>
)
}

View file

@ -0,0 +1,215 @@
export type SketchTool =
| 'select'
| 'hand'
| 'sticky'
| 'pen'
| 'text'
| 'line'
| 'ellipse'
| 'rect'
| 'table'
| 'triangle'
export type ShapeKind = 'rect' | 'ellipse' | 'triangle'
export interface Point {
x: number
y: number
}
interface SketchElementBase {
id: string
color: string
}
export interface PenElement extends SketchElementBase {
type: 'pen'
points: Point[]
strokeWidth: number
}
export interface LineElement extends SketchElementBase {
type: 'line'
x1: number
y1: number
x2: number
y2: number
strokeWidth: number
}
export interface RectElement extends SketchElementBase {
type: 'rect'
x: number
y: number
width: number
height: number
strokeWidth: number
fill: string
}
export interface EllipseElement extends SketchElementBase {
type: 'ellipse'
x: number
y: number
width: number
height: number
strokeWidth: number
fill: string
}
export interface TriangleElement extends SketchElementBase {
type: 'triangle'
x: number
y: number
width: number
height: number
strokeWidth: number
fill: string
}
export interface TextElement extends SketchElementBase {
type: 'text'
x: number
y: number
text: string
fontSize: number
}
export interface StickyElement extends SketchElementBase {
type: 'sticky'
x: number
y: number
width: number
height: number
text: string
}
export interface TableElement extends SketchElementBase {
type: 'table'
x: number
y: number
rows: number
cols: number
cellWidth: number
cellHeight: number
cells: string[][]
}
export type SketchElement =
| PenElement
| LineElement
| RectElement
| EllipseElement
| TriangleElement
| TextElement
| StickyElement
| TableElement
export interface SketchViewport {
panX: number
panY: number
zoom: number
}
export interface SketchContent {
elements: SketchElement[]
viewport?: SketchViewport
}
export const DEFAULT_COLOR = '#6B3A2C'
export const STICKY_COLORS = ['#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#E9D5FF']
export function createId(): string {
return crypto.randomUUID()
}
export function normalizeSketchContent(raw: Record<string, unknown>): SketchContent {
if (Array.isArray(raw.elements)) {
return {
elements: raw.elements as SketchElement[],
viewport: raw.viewport as SketchViewport | undefined,
}
}
const legacyStrokes = raw.strokes as { points: Point[]; color: string }[] | undefined
if (legacyStrokes?.length) {
return {
elements: legacyStrokes.map((stroke) => ({
id: createId(),
type: 'pen',
points: stroke.points,
color: stroke.color || DEFAULT_COLOR,
strokeWidth: 2,
})),
}
}
return { elements: [] }
}
export function getElementBounds(el: SketchElement): {
x: number
y: number
width: number
height: number
} {
switch (el.type) {
case 'pen': {
const xs = el.points.map((p) => p.x)
const ys = el.points.map((p) => p.y)
const minX = Math.min(...xs)
const minY = Math.min(...ys)
return {
x: minX,
y: minY,
width: Math.max(...xs) - minX,
height: Math.max(...ys) - minY,
}
}
case 'line':
return {
x: Math.min(el.x1, el.x2),
y: Math.min(el.y1, el.y2),
width: Math.abs(el.x2 - el.x1),
height: Math.abs(el.y2 - el.y1),
}
case 'text':
return { x: el.x, y: el.y, width: 200, height: el.fontSize + 8 }
case 'table':
return {
x: el.x,
y: el.y,
width: el.cols * el.cellWidth,
height: el.rows * el.cellHeight,
}
default:
return { x: el.x, y: el.y, width: el.width, height: el.height }
}
}
export function hitTestElement(el: SketchElement, point: Point, tolerance = 8): boolean {
const bounds = getElementBounds(el)
const pad = el.type === 'line' || el.type === 'pen' ? tolerance : 0
return (
point.x >= bounds.x - pad &&
point.x <= bounds.x + bounds.width + pad &&
point.y >= bounds.y - pad &&
point.y <= bounds.y + bounds.height + pad
)
}
export function translateElement(el: SketchElement, dx: number, dy: number): SketchElement {
switch (el.type) {
case 'pen':
return {
...el,
points: el.points.map((p) => ({ x: p.x + dx, y: p.y + dy })),
}
case 'line':
return { ...el, x1: el.x1 + dx, y1: el.y1 + dy, x2: el.x2 + dx, y2: el.y2 + dy }
case 'text':
return { ...el, x: el.x + dx, y: el.y + dy }
default:
return { ...el, x: el.x + dx, y: el.y + dy }
}
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
},
},
})