initial project setup

Fastify + Prisma backend, React + Vite frontend, Docker deployment.
Multi-board feedback platform with anonymous cookie auth, passkey
upgrade path, ALTCHA spam protection, plugin system, and full
privacy-first architecture.
This commit is contained in:
2026-03-19 18:05:16 +02:00
commit f07eddf29e
77 changed files with 7031 additions and 0 deletions

77
packages/web/src/App.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
import { AuthProvider, useAuthState } from './hooks/useAuth'
import { ThemeProvider, useThemeState } from './hooks/useTheme'
import Sidebar from './components/Sidebar'
import MobileNav from './components/MobileNav'
import ThemeToggle from './components/ThemeToggle'
import IdentityBanner from './components/IdentityBanner'
import CommandPalette from './components/CommandPalette'
import PasskeyModal from './components/PasskeyModal'
import BoardIndex from './pages/BoardIndex'
import BoardFeed from './pages/BoardFeed'
import PostDetail from './pages/PostDetail'
import ActivityFeed from './pages/ActivityFeed'
import IdentitySettings from './pages/IdentitySettings'
import MySubmissions from './pages/MySubmissions'
import PrivacyPage from './pages/PrivacyPage'
import AdminLogin from './pages/admin/AdminLogin'
import AdminDashboard from './pages/admin/AdminDashboard'
import AdminPosts from './pages/admin/AdminPosts'
import AdminBoards from './pages/admin/AdminBoards'
function Layout() {
const location = useLocation()
const [passkeyMode, setPasskeyMode] = useState<'register' | 'login' | null>(null)
const isAdmin = location.pathname.startsWith('/admin')
return (
<>
<CommandPalette />
<div className="flex min-h-screen" style={{ background: 'var(--bg)' }}>
{!isAdmin && <Sidebar />}
<main className="flex-1 pb-20 md:pb-0">
<Routes>
<Route path="/" element={<BoardIndex />} />
<Route path="/b/:boardSlug" element={<BoardFeed />} />
<Route path="/b/:boardSlug/post/:postId" element={<PostDetail />} />
<Route path="/b/:boardSlug/new" element={<BoardFeed />} />
<Route path="/activity" element={<ActivityFeed />} />
<Route path="/settings" element={<IdentitySettings />} />
<Route path="/my-posts" element={<MySubmissions />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/posts" element={<AdminPosts />} />
<Route path="/admin/boards" element={<AdminBoards />} />
</Routes>
</main>
</div>
{!isAdmin && <MobileNav />}
<ThemeToggle />
{!isAdmin && (
<IdentityBanner onRegister={() => setPasskeyMode('register')} />
)}
<PasskeyModal
mode={passkeyMode || 'register'}
open={passkeyMode !== null}
onClose={() => setPasskeyMode(null)}
/>
</>
)
}
export default function App() {
const auth = useAuthState()
const theme = useThemeState()
return (
<ThemeProvider value={theme}>
<AuthProvider value={auth}>
<BrowserRouter>
<Layout />
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
)
}

161
packages/web/src/app.css Normal file
View File

@@ -0,0 +1,161 @@
@import "tailwindcss";
@layer base {
:root {
--bg: #141420;
--surface: #1c1c2e;
--surface-hover: #24243a;
--border: rgba(245, 240, 235, 0.08);
--border-hover: rgba(245, 240, 235, 0.15);
--text: #f5f0eb;
--text-secondary: rgba(245, 240, 235, 0.6);
--text-tertiary: rgba(245, 240, 235, 0.35);
--accent: #F59E0B;
--accent-hover: #D97706;
--accent-subtle: rgba(245, 158, 11, 0.15);
--admin-accent: #06B6D4;
--admin-subtle: rgba(6, 182, 212, 0.15);
--success: #22C55E;
--warning: #EAB308;
--error: #EF4444;
--info: #3B82F6;
--font-heading: 'Space Grotesk', system-ui, sans-serif;
--font-body: 'Sora', system-ui, sans-serif;
}
html.light {
--bg: #faf9f6;
--surface: #ffffff;
--surface-hover: #f0eeea;
--border: rgba(20, 20, 32, 0.08);
--border-hover: rgba(20, 20, 32, 0.15);
--text: #1a1a2e;
--text-secondary: rgba(26, 26, 46, 0.6);
--text-tertiary: rgba(26, 26, 46, 0.35);
--accent: #D97706;
--accent-hover: #B45309;
--accent-subtle: rgba(217, 119, 6, 0.15);
--admin-accent: #0891B2;
--admin-subtle: rgba(8, 145, 178, 0.15);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
transition: background 200ms ease-out, color 200ms ease-out;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
}
}
@layer components {
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-family: var(--font-body);
font-weight: 500;
font-size: 0.875rem;
transition: all 200ms ease-out;
cursor: pointer;
border: none;
outline: none;
}
.btn-primary {
background: var(--accent);
color: #141420;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--surface-hover);
border-color: var(--border-hover);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--surface-hover);
color: var(--text);
}
.btn-admin {
background: var(--admin-accent);
color: #141420;
}
.btn-admin:hover {
opacity: 0.9;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
transition: border-color 200ms ease-out, box-shadow 200ms ease-out;
}
.card:hover {
border-color: var(--border-hover);
}
.input {
width: 100%;
padding: 0.625rem 0.875rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text);
font-family: var(--font-body);
font-size: 0.875rem;
transition: border-color 200ms ease-out;
outline: none;
}
.input:focus {
border-color: var(--accent);
}
.input::placeholder {
color: var(--text-tertiary);
}
.slide-up {
animation: slideUp 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.fade-in {
animation: fadeIn 200ms ease-out;
}
@keyframes slideUp {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
}

View File

@@ -0,0 +1,227 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../lib/api'
interface SearchResult {
type: 'post' | 'board'
id: string
title: string
slug?: string
boardSlug?: string
}
export default function CommandPalette() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [selected, setSelected] = useState(0)
const [loading, setLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const nav = useNavigate()
const toggle = useCallback(() => {
setOpen((v) => {
if (!v) {
setQuery('')
setResults([])
setSelected(0)
}
return !v
})
}, [])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
toggle()
}
if (e.key === 'Escape' && open) {
setOpen(false)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [open, toggle])
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])
useEffect(() => {
if (!query.trim()) {
setResults([])
return
}
const t = setTimeout(async () => {
setLoading(true)
try {
const res = await api.get<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
setResults(res)
setSelected(0)
} catch {
setResults([])
} finally {
setLoading(false)
}
}, 200)
return () => clearTimeout(t)
}, [query])
const navigate = (r: SearchResult) => {
if (r.type === 'board') {
nav(`/b/${r.slug}`)
} else {
nav(`/b/${r.boardSlug}/post/${r.id}`)
}
setOpen(false)
}
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelected((s) => Math.min(s + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelected((s) => Math.max(s - 1, 0))
} else if (e.key === 'Enter' && results[selected]) {
navigate(results[selected])
}
}
if (!open) return null
const boards = results.filter((r) => r.type === 'board')
const posts = results.filter((r) => r.type === 'post')
let idx = -1
return (
<div
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]"
onClick={() => setOpen(false)}
>
{/* Backdrop */}
<div
className="absolute inset-0 fade-in"
style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }}
/>
{/* Modal */}
<div
className="relative w-full max-w-lg mx-4 rounded-xl overflow-hidden shadow-2xl slide-up"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 px-4 py-3 border-b" style={{ borderColor: 'var(--border)' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Search posts and boards..."
className="flex-1 bg-transparent outline-none text-sm"
style={{ color: 'var(--text)', fontFamily: 'var(--font-body)' }}
/>
<kbd
className="text-[10px] px-1.5 py-0.5 rounded"
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
>
ESC
</kbd>
</div>
<div className="max-h-80 overflow-y-auto">
{loading && (
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
Searching...
</div>
)}
{!loading && query && results.length === 0 && (
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
No results for "{query}"
</div>
)}
{!loading && !query && (
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
Start typing to search...
</div>
)}
{boards.length > 0 && (
<div>
<div className="px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
Boards
</div>
{boards.map((r) => {
idx++
const i = idx
return (
<button
key={r.id}
onClick={() => navigate(r)}
className="w-full text-left px-4 py-2.5 flex items-center gap-3 text-sm"
style={{
background: selected === i ? 'var(--surface-hover)' : 'transparent',
color: 'var(--text)',
transition: 'background 100ms ease-out',
}}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--accent)', flexShrink: 0 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{r.title}
</button>
)
})}
</div>
)}
{posts.length > 0 && (
<div>
<div className="px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
Posts
</div>
{posts.map((r) => {
idx++
const i = idx
return (
<button
key={r.id}
onClick={() => navigate(r)}
className="w-full text-left px-4 py-2.5 flex items-center gap-3 text-sm"
style={{
background: selected === i ? 'var(--surface-hover)' : 'transparent',
color: 'var(--text)',
transition: 'background 100ms ease-out',
}}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{r.title}
</button>
)
})}
</div>
)}
</div>
<div
className="px-4 py-2 flex items-center gap-4 text-[10px] border-t"
style={{ borderColor: 'var(--border)', color: 'var(--text-tertiary)' }}
>
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}></kbd> navigate</span>
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}>Enter</kbd> open</span>
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}>Esc</kbd> close</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
interface Props {
title?: string
message?: string
actionLabel?: string
onAction?: () => void
}
export default function EmptyState({
title = 'Nothing here yet',
message = 'Be the first to share feedback',
actionLabel = 'Create a post',
onAction,
}: Props) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4 fade-in">
{/* Megaphone SVG */}
<svg
width="120"
height="120"
viewBox="0 0 120 120"
fill="none"
className="mb-6"
>
<circle cx="60" cy="60" r="55" stroke="var(--text-tertiary)" strokeWidth="1" strokeDasharray="4 4" />
<path
d="M75 35L45 50H35a5 5 0 00-5 5v10a5 5 0 005 5h10l30 15V35z"
stroke="var(--accent)"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M85 48a10 10 0 010 24"
stroke="var(--accent)"
strokeWidth="2.5"
strokeLinecap="round"
fill="none"
opacity="0.6"
/>
<path
d="M92 40a20 20 0 010 40"
stroke="var(--accent)"
strokeWidth="2"
strokeLinecap="round"
fill="none"
opacity="0.3"
/>
<path
d="M42 70v10a5 5 0 005 5h5a5 5 0 005-5v-7"
stroke="var(--text-tertiary)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
<h3
className="text-lg font-semibold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
{title}
</h3>
<p className="text-sm mb-6" style={{ color: 'var(--text-tertiary)' }}>
{message}
</p>
{onAction && (
<button onClick={onAction} className="btn btn-primary">
{actionLabel}
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
const DISMISSED_KEY = 'echoboard-identity-ack'
export default function IdentityBanner({ onRegister }: { onRegister: () => void }) {
const [visible, setVisible] = useState(false)
useEffect(() => {
if (!localStorage.getItem(DISMISSED_KEY)) {
const t = setTimeout(() => setVisible(true), 800)
return () => clearTimeout(t)
}
}, [])
if (!visible) return null
const dismiss = () => {
localStorage.setItem(DISMISSED_KEY, '1')
setVisible(false)
}
return (
<div
className="fixed bottom-0 left-0 right-0 z-50 md:left-[280px] slide-up"
style={{ pointerEvents: 'none' }}
>
<div
className="mx-4 mb-4 p-5 rounded-xl shadow-2xl md:max-w-lg md:mx-auto"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
pointerEvents: 'auto',
}}
>
<div className="flex items-start gap-4">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5"
style={{ background: 'var(--accent-subtle)' }}
>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--accent)' }}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div className="flex-1">
<h3
className="text-base font-semibold mb-1"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Your identity is cookie-based
</h3>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
You can post and vote right now - no signup needed. A cookie links your activity. Register a passkey to keep access across devices and browsers.
</p>
<div className="flex flex-wrap gap-2">
<button onClick={dismiss} className="btn btn-primary text-sm">
Continue anonymously
</button>
<button
onClick={() => { dismiss(); onRegister() }}
className="btn btn-secondary text-sm"
>
Register with passkey
</button>
<Link to="/privacy" onClick={dismiss} className="btn btn-ghost text-sm">
Learn more
</Link>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { Link, useLocation } from 'react-router-dom'
const tabs = [
{
path: '/',
label: 'Home',
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
</svg>
),
},
{
path: '/search',
label: 'Search',
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
),
},
{
path: '/new',
label: 'New',
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
),
accent: true,
},
{
path: '/activity',
label: 'Activity',
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
},
{
path: '/settings',
label: 'Profile',
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
),
},
]
export default function MobileNav() {
const location = useLocation()
return (
<nav
className="fixed bottom-0 left-0 right-0 md:hidden flex items-center justify-around py-2 border-t z-50"
style={{
background: 'var(--surface)',
borderColor: 'var(--border)',
paddingBottom: 'max(0.5rem, env(safe-area-inset-bottom))',
}}
>
{tabs.map((tab) => {
const active = location.pathname === tab.path ||
(tab.path === '/' && location.pathname === '/')
return (
<Link
key={tab.path}
to={tab.path}
className="flex flex-col items-center gap-0.5 px-3 py-1"
style={{ transition: 'color 200ms ease-out' }}
>
{tab.accent ? (
<div
className="w-10 h-10 rounded-full flex items-center justify-center -mt-4"
style={{ background: 'var(--accent)', color: '#141420' }}
>
{tab.icon}
</div>
) : (
<div style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}>
{tab.icon}
</div>
)}
<span
className="text-[10px]"
style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}
>
{tab.label}
</span>
</Link>
)
})}
</nav>
)
}

View File

@@ -0,0 +1,186 @@
import { useState, useEffect, useRef } from 'react'
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import { api } from '../lib/api'
import { useAuth } from '../hooks/useAuth'
interface Props {
mode: 'register' | 'login'
open: boolean
onClose: () => void
}
export default function PasskeyModal({ mode, open, onClose }: Props) {
const auth = useAuth()
const [username, setUsername] = useState('')
const [checking, setChecking] = useState(false)
const [available, setAvailable] = useState<boolean | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const checkTimer = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
if (open) {
setUsername('')
setAvailable(null)
setError('')
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [open])
useEffect(() => {
if (mode !== 'register' || !username.trim() || username.length < 3) {
setAvailable(null)
return
}
clearTimeout(checkTimer.current)
checkTimer.current = setTimeout(async () => {
setChecking(true)
try {
const res = await api.get<{ available: boolean }>(`/identity/check-username?name=${encodeURIComponent(username)}`)
setAvailable(res.available)
} catch {
setAvailable(null)
} finally {
setChecking(false)
}
}, 400)
return () => clearTimeout(checkTimer.current)
}, [username, mode])
const handleRegister = async () => {
if (!username.trim()) {
setError('Username is required')
return
}
setLoading(true)
setError('')
try {
const opts = await api.post<any>('/auth/passkey/register/options', { username })
const attestation = await startRegistration({ optionsJSON: opts })
await api.post('/auth/passkey/register/verify', { username, attestation })
await auth.refresh()
onClose()
} catch (e: any) {
setError(e?.message || 'Registration failed. Please try again.')
} finally {
setLoading(false)
}
}
const handleLogin = async () => {
setLoading(true)
setError('')
try {
const opts = await api.post<any>('/auth/passkey/login/options')
const assertion = await startAuthentication({ optionsJSON: opts })
await api.post('/auth/passkey/login/verify', { assertion })
await auth.refresh()
onClose()
} catch (e: any) {
setError(e?.message || 'Authentication failed. Please try again.')
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={onClose}
>
<div
className="absolute inset-0 fade-in"
style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }}
/>
<div
className="relative w-full max-w-sm mx-4 rounded-xl p-6 shadow-2xl slide-up"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<h2
className="text-lg font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
{mode === 'register' ? 'Register Passkey' : 'Login with Passkey'}
</h2>
<button onClick={onClose} className="btn btn-ghost p-1">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{mode === 'register' ? (
<>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
Choose a display name and register a passkey to keep your identity across devices.
</p>
<div className="relative mb-4">
<input
ref={inputRef}
className="input pr-8"
placeholder="Display name"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
{checking && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 rounded-full" style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }} />
</div>
)}
{!checking && available !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{available ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--success)" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--error)" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
</div>
)}
</div>
{!checking && available === false && (
<p className="text-xs mb-3" style={{ color: 'var(--error)' }}>This name is taken</p>
)}
</>
) : (
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
Use your registered passkey to sign in and restore your identity.
</p>
)}
{error && <p className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</p>}
<button
onClick={mode === 'register' ? handleRegister : handleLogin}
disabled={loading || (mode === 'register' && (!username.trim() || available === false))}
className="btn btn-primary w-full"
style={{ opacity: loading ? 0.6 : 1 }}
>
{loading ? (
<div className="w-4 h-4 border-2 rounded-full" style={{ borderColor: 'rgba(20,20,32,0.3)', borderTopColor: '#141420', animation: 'spin 0.6s linear infinite' }} />
) : mode === 'register' ? (
'Register Passkey'
) : (
'Sign in with Passkey'
)}
</button>
{mode === 'register' && (
<p className="text-xs mt-4 text-center" style={{ color: 'var(--text-tertiary)' }}>
Your passkey is stored on your device. No passwords involved.
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,110 @@
import { Link } from 'react-router-dom'
import StatusBadge from './StatusBadge'
interface Post {
id: string
title: string
excerpt?: string
type: 'feature' | 'bug' | 'general'
status: string
voteCount: number
commentCount: number
authorName: string
createdAt: string
boardSlug: string
hasVoted?: boolean
}
export default function PostCard({
post,
onVote,
}: {
post: Post
onVote?: (id: string) => void
}) {
const timeAgo = formatTimeAgo(post.createdAt)
return (
<div
className="card flex gap-0 overflow-hidden"
style={{ transition: 'border-color 200ms ease-out' }}
>
{/* Vote column */}
<button
onClick={(e) => { e.preventDefault(); onVote?.(post.id) }}
className="flex flex-col items-center justify-center px-3 py-4 shrink-0 gap-1"
style={{
width: 48,
background: post.hasVoted ? 'var(--accent-subtle)' : 'transparent',
color: post.hasVoted ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'all 200ms ease-out',
borderRight: '1px solid var(--border)',
}}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
<span className="text-xs font-semibold">{post.voteCount}</span>
</button>
{/* Content zone */}
<Link
to={`/b/${post.boardSlug}/post/${post.id}`}
className="flex-1 py-3 px-4 min-w-0"
>
<div className="flex items-center gap-2 mb-1">
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
}}
>
{post.type}
</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{post.authorName} - {timeAgo}
</span>
</div>
<h3
className="text-sm font-medium mb-1 truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
{post.title}
</h3>
{post.excerpt && (
<p
className="text-xs line-clamp-2"
style={{ color: 'var(--text-secondary)' }}
>
{post.excerpt}
</p>
)}
</Link>
{/* Status + comments */}
<div className="flex flex-col items-end justify-center px-4 py-3 shrink-0 gap-2">
<StatusBadge status={post.status} />
<div className="flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="text-xs">{post.commentCount}</span>
</div>
</div>
</div>
)
}
function formatTimeAgo(date: string): string {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
return `${months}mo ago`
}

View File

@@ -0,0 +1,183 @@
import { useState, useRef } from 'react'
import { api } from '../lib/api'
interface Props {
boardSlug: string
onSubmit?: () => void
}
type PostType = 'feature' | 'bug' | 'general'
export default function PostForm({ boardSlug, onSubmit }: Props) {
const [expanded, setExpanded] = useState(false)
const [type, setType] = useState<PostType>('feature')
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [expected, setExpected] = useState('')
const [actual, setActual] = useState('')
const [steps, setSteps] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const formRef = useRef<HTMLDivElement>(null)
const reset = () => {
setTitle('')
setBody('')
setExpected('')
setActual('')
setSteps('')
setError('')
setExpanded(false)
}
const submit = async () => {
if (!title.trim()) {
setError('Title is required')
return
}
setSubmitting(true)
setError('')
const payload: Record<string, string> = { title, type, body }
if (type === 'bug') {
payload.stepsToReproduce = steps
payload.expected = expected
payload.actual = actual
}
try {
await api.post(`/boards/${boardSlug}/posts`, payload)
reset()
onSubmit?.()
} catch (e) {
setError('Failed to submit. Please try again.')
} finally {
setSubmitting(false)
}
}
if (!expanded) {
return (
<button
onClick={() => setExpanded(true)}
className="card w-full px-4 py-3 text-left flex items-center gap-3"
style={{ cursor: 'pointer' }}
>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</div>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Share feedback...
</span>
</button>
)
}
return (
<div ref={formRef} className="card p-4 slide-up">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
New Post
</h3>
<button onClick={reset} className="btn btn-ghost text-xs">Cancel</button>
</div>
{/* Type selector */}
<div className="flex gap-2 mb-4">
{(['feature', 'bug', 'general'] as PostType[]).map((t) => (
<button
key={t}
onClick={() => setType(t)}
className="px-3 py-1.5 rounded-md text-xs font-medium capitalize"
style={{
background: type === t ? 'var(--accent-subtle)' : 'transparent',
color: type === t ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${type === t ? 'var(--accent)' : 'var(--border)'}`,
transition: 'all 200ms ease-out',
}}
>
{t === 'feature' ? 'Feature Request' : t === 'bug' ? 'Bug Report' : 'General'}
</button>
))}
</div>
<input
className="input mb-3"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className="input mb-3"
placeholder={type === 'bug' ? 'Describe the bug...' : type === 'feature' ? 'Describe the feature...' : 'What is on your mind?'}
rows={3}
value={body}
onChange={(e) => setBody(e.target.value)}
style={{ resize: 'vertical' }}
/>
{type === 'bug' && (
<>
<textarea
className="input mb-3"
placeholder="Steps to reproduce"
rows={2}
value={steps}
onChange={(e) => setSteps(e.target.value)}
style={{ resize: 'vertical' }}
/>
<div className="grid grid-cols-2 gap-3 mb-3">
<textarea
className="input"
placeholder="Expected behavior"
rows={2}
value={expected}
onChange={(e) => setExpected(e.target.value)}
style={{ resize: 'vertical' }}
/>
<textarea
className="input"
placeholder="Actual behavior"
rows={2}
value={actual}
onChange={(e) => setActual(e.target.value)}
style={{ resize: 'vertical' }}
/>
</div>
</>
)}
{/* ALTCHA widget placeholder */}
<div
className="mb-4 p-3 rounded-lg text-xs flex items-center gap-2"
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
ALTCHA verification
</div>
{error && (
<div className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
)}
<div className="flex justify-end">
<button
onClick={submit}
disabled={submitting}
className="btn btn-primary"
style={{ opacity: submitting ? 0.6 : 1 }}
>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react'
import { Link, useLocation, useParams } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { api } from '../lib/api'
interface Board {
id: string
slug: string
name: string
description: string
postCount: number
}
export default function Sidebar() {
const { boardSlug } = useParams()
const location = useLocation()
const auth = useAuth()
const [boards, setBoards] = useState<Board[]>([])
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
api.get<Board[]>('/boards').then(setBoards).catch(() => {})
}, [])
const isActive = (path: string) => location.pathname === path
const isBoardActive = (slug: string) => boardSlug === slug
if (collapsed) {
return (
<aside
className="hidden md:flex lg:hidden flex-col items-center py-4 gap-2 border-r"
style={{
width: 64,
background: 'var(--surface)',
borderColor: 'var(--border)',
}}
>
<button
onClick={() => setCollapsed(false)}
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
style={{ color: 'var(--accent)' }}
>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<Link
to="/"
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{
background: isActive('/') ? 'var(--accent-subtle)' : 'transparent',
color: isActive('/') ? 'var(--accent)' : 'var(--text-secondary)',
}}
>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
</svg>
</Link>
{boards.map((b) => (
<Link
key={b.id}
to={`/b/${b.slug}`}
className="w-10 h-10 rounded-lg flex items-center justify-center text-xs font-semibold"
style={{
fontFamily: 'var(--font-heading)',
background: isBoardActive(b.slug) ? 'var(--accent-subtle)' : 'transparent',
color: isBoardActive(b.slug) ? 'var(--accent)' : 'var(--text-secondary)',
}}
>
{b.name.charAt(0).toUpperCase()}
</Link>
))}
<div className="mt-auto">
<Link
to="/activity"
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ color: isActive('/activity') ? 'var(--accent)' : 'var(--text-secondary)' }}
>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</Link>
</div>
</aside>
)
}
return (
<aside
className="hidden lg:flex flex-col border-r h-screen sticky top-0"
style={{
width: 280,
background: 'var(--surface)',
borderColor: 'var(--border)',
}}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'var(--border)' }}>
<Link
to="/"
className="text-xl font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Echoboard
</Link>
<button
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ color: 'var(--text-secondary)' }}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
</div>
{/* Board list */}
<nav className="flex-1 overflow-y-auto py-3 px-3">
<Link
to="/"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-1"
style={{
background: isActive('/') ? 'var(--accent-subtle)' : 'transparent',
color: isActive('/') ? 'var(--accent)' : 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
</svg>
Home
</Link>
<div className="mt-4 mb-2 px-3">
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
Boards
</span>
</div>
{boards.map((b) => (
<Link
key={b.id}
to={`/b/${b.slug}`}
className="flex items-center justify-between px-3 py-2 rounded-lg text-sm mb-0.5"
style={{
background: isBoardActive(b.slug) ? 'var(--accent-subtle)' : 'transparent',
color: isBoardActive(b.slug) ? 'var(--accent)' : 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
<span>{b.name}</span>
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
>
{b.postCount}
</span>
</Link>
))}
<div className="mt-6 mb-2 px-3">
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
You
</span>
</div>
<Link
to="/activity"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
style={{
background: isActive('/activity') ? 'var(--accent-subtle)' : 'transparent',
color: isActive('/activity') ? 'var(--accent)' : 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Activity
</Link>
<Link
to="/my-posts"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
style={{
background: isActive('/my-posts') ? 'var(--accent-subtle)' : 'transparent',
color: isActive('/my-posts') ? 'var(--accent)' : 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
My Posts
</Link>
<Link
to="/settings"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
style={{
background: isActive('/settings') ? 'var(--accent-subtle)' : 'transparent',
color: isActive('/settings') ? 'var(--accent)' : 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</Link>
</nav>
{/* Identity footer */}
<div className="px-4 py-3 border-t" style={{ borderColor: 'var(--border)' }}>
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
{auth.displayName.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm truncate" style={{ color: 'var(--text)' }}>
{auth.displayName}
</div>
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{auth.isPasskeyUser ? 'Passkey user' : 'Cookie identity'}
</div>
</div>
</div>
{!auth.isPasskeyUser && (
<Link
to="/settings"
className="block mt-2 text-xs text-center py-1.5 rounded-md"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
Register passkey for persistence
</Link>
)}
</div>
</aside>
)
}

View File

@@ -0,0 +1,21 @@
const statusConfig: Record<string, { label: string; bg: string; color: string }> = {
OPEN: { label: 'Open', bg: 'var(--accent-subtle)', color: 'var(--accent)' },
UNDER_REVIEW: { label: 'Under Review', bg: 'var(--admin-subtle)', color: 'var(--admin-accent)' },
PLANNED: { label: 'Planned', bg: 'rgba(59, 130, 246, 0.15)', color: 'var(--info)' },
IN_PROGRESS: { label: 'In Progress', bg: 'rgba(234, 179, 8, 0.15)', color: 'var(--warning)' },
DONE: { label: 'Done', bg: 'rgba(34, 197, 94, 0.15)', color: 'var(--success)' },
DECLINED: { label: 'Declined', bg: 'rgba(239, 68, 68, 0.15)', color: 'var(--error)' },
}
export default function StatusBadge({ status }: { status: string }) {
const cfg = statusConfig[status] || { label: status, bg: 'var(--border)', color: 'var(--text-secondary)' }
return (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
style={{ background: cfg.bg, color: cfg.color }}
>
{cfg.label}
</span>
)
}

View File

@@ -0,0 +1,37 @@
import { useTheme } from '../hooks/useTheme'
export default function ThemeToggle() {
const { resolved, toggle } = useTheme()
const isDark = resolved === 'dark'
return (
<button
onClick={toggle}
className="fixed bottom-6 right-6 w-11 h-11 rounded-full flex items-center justify-center z-40 md:bottom-6 md:right-6 bottom-20 shadow-lg"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
color: 'var(--accent)',
transition: 'all 200ms ease-out',
}}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
<div
style={{
transition: 'transform 300ms cubic-bezier(0.16, 1, 0.3, 1)',
transform: isDark ? 'rotate(0deg)' : 'rotate(180deg)',
}}
>
{isDark ? (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</div>
</button>
)
}

View File

@@ -0,0 +1,165 @@
import { useState } from 'react'
interface TimelineEntry {
id: string
type: 'status_change' | 'admin_response' | 'comment'
authorName: string
content: string
oldStatus?: string
newStatus?: string
createdAt: string
reactions?: { emoji: string; count: number; hasReacted: boolean }[]
isAdmin?: boolean
}
export default function Timeline({
entries,
onReact,
}: {
entries: TimelineEntry[]
onReact?: (entryId: string, emoji: string) => void
}) {
return (
<div className="relative">
{/* Vertical line */}
<div
className="absolute left-4 top-0 bottom-0 w-px"
style={{ background: 'var(--border)' }}
/>
<div className="flex flex-col gap-0">
{entries.map((entry) => (
<TimelineItem key={entry.id} entry={entry} onReact={onReact} />
))}
</div>
</div>
)
}
function TimelineItem({
entry,
onReact,
}: {
entry: TimelineEntry
onReact?: (entryId: string, emoji: string) => void
}) {
const [showPicker, setShowPicker] = useState(false)
const quickEmojis = ['👍', '❤️', '🎉', '😄', '🤔', '👀']
const iconBg = entry.type === 'admin_response'
? 'var(--admin-subtle)'
: entry.type === 'status_change'
? 'var(--accent-subtle)'
: 'var(--border)'
const iconColor = entry.type === 'admin_response'
? 'var(--admin-accent)'
: entry.type === 'status_change'
? 'var(--accent)'
: 'var(--text-tertiary)'
return (
<div className="relative pl-10 pb-6">
{/* Dot */}
<div
className="absolute left-2 top-1 w-5 h-5 rounded-full flex items-center justify-center z-10"
style={{ background: iconBg }}
>
{entry.type === 'status_change' ? (
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
) : entry.type === 'admin_response' ? (
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
) : (
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01" />
</svg>
)}
</div>
{/* Content */}
<div
className="rounded-lg p-3"
style={{
background: entry.type === 'admin_response' ? 'var(--admin-subtle)' : 'var(--surface)',
border: entry.type === 'admin_response' ? `1px solid rgba(6, 182, 212, 0.2)` : `1px solid var(--border)`,
}}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium" style={{ color: entry.isAdmin ? 'var(--admin-accent)' : 'var(--text)' }}>
{entry.authorName}
{entry.isAdmin && (
<span className="ml-1 px-1 py-0.5 rounded text-[10px]" style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)' }}>
admin
</span>
)}
</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{new Date(entry.createdAt).toLocaleDateString()}
</span>
</div>
{entry.type === 'status_change' ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Changed status from <strong>{entry.oldStatus}</strong> to <strong>{entry.newStatus}</strong>
</p>
) : (
<p className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>
{entry.content}
</p>
)}
{/* Reactions */}
{(entry.reactions?.length || entry.type === 'comment') && (
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
{entry.reactions?.map((r) => (
<button
key={r.emoji}
onClick={() => onReact?.(entry.id, r.emoji)}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
style={{
background: r.hasReacted ? 'var(--accent-subtle)' : 'var(--border)',
border: r.hasReacted ? '1px solid var(--accent)' : '1px solid transparent',
color: 'var(--text-secondary)',
transition: 'all 200ms ease-out',
}}
>
{r.emoji} {r.count}
</button>
))}
<div className="relative">
<button
onClick={() => setShowPicker(!showPicker)}
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
>
+
</button>
{showPicker && (
<div
className="absolute bottom-full left-0 mb-1 flex gap-1 p-1.5 rounded-lg z-20 fade-in"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', boxShadow: '0 4px 12px rgba(0,0,0,0.3)' }}
>
{quickEmojis.map((e) => (
<button
key={e}
onClick={() => { onReact?.(entry.id, e); setShowPicker(false) }}
className="w-7 h-7 rounded flex items-center justify-center hover:scale-110"
style={{ transition: 'transform 200ms ease-out' }}
>
{e}
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import { useState } from 'react'
interface Props {
used: number
total: number
resetsAt?: string
}
export default function VoteBudget({ used, total, resetsAt }: Props) {
const [showTip, setShowTip] = useState(false)
const remaining = total - used
return (
<div className="relative inline-flex items-center gap-1">
<div
className="flex items-center gap-1 cursor-help"
onMouseEnter={() => setShowTip(true)}
onMouseLeave={() => setShowTip(false)}
>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
className="w-2 h-2 rounded-full"
style={{
background: i < used ? 'var(--accent)' : 'var(--border-hover)',
transition: 'background 200ms ease-out',
}}
/>
))}
</div>
<span className="text-xs ml-1" style={{ color: 'var(--text-tertiary)' }}>
{remaining} left
</span>
{showTip && (
<div
className="absolute bottom-full left-0 mb-2 px-3 py-2 rounded-lg text-xs whitespace-nowrap z-50 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
}}
>
<div className="font-medium mb-1" style={{ color: 'var(--text)' }}>
Vote Budget
</div>
<div>{used} of {total} votes used</div>
{resetsAt && (
<div style={{ color: 'var(--text-tertiary)' }}>
Resets {new Date(resetsAt).toLocaleDateString()}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { api } from '../lib/api'
interface User {
id: string
displayName: string
isPasskeyUser: boolean
createdAt: string
}
interface AuthState {
user: User | null
loading: boolean
isAuthenticated: boolean
isPasskeyUser: boolean
displayName: string
initIdentity: () => Promise<void>
updateProfile: (data: { displayName: string }) => Promise<void>
deleteIdentity: () => Promise<void>
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthState | null>(null)
export const AuthProvider = AuthContext.Provider
export function useAuthState(): AuthState {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const fetchMe = useCallback(async () => {
try {
const u = await api.get<User>('/me')
setUser(u)
} catch {
setUser(null)
} finally {
setLoading(false)
}
}, [])
const initIdentity = useCallback(async () => {
try {
const u = await api.post<User>('/identity')
setUser(u)
} catch {
await fetchMe()
}
}, [fetchMe])
const updateProfile = useCallback(async (data: { displayName: string }) => {
const u = await api.put<User>('/me', data)
setUser(u)
}, [])
const deleteIdentity = useCallback(async () => {
await api.delete('/me')
setUser(null)
}, [])
useEffect(() => {
fetchMe()
}, [fetchMe])
return {
user,
loading,
isAuthenticated: !!user,
isPasskeyUser: user?.isPasskeyUser ?? false,
displayName: user?.displayName ?? 'Anonymous',
initIdentity,
updateProfile,
deleteIdentity,
refresh: fetchMe,
}
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

View File

@@ -0,0 +1,47 @@
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
import {
type Theme,
getStoredTheme,
resolveTheme,
setTheme as applyTheme,
initTheme,
} from '../lib/theme'
interface ThemeState {
theme: Theme
resolved: 'dark' | 'light'
toggle: () => void
set: (t: Theme) => void
}
const ThemeContext = createContext<ThemeState | null>(null)
export const ThemeProvider = ThemeContext.Provider
export function useThemeState(): ThemeState {
const [theme, setThemeVal] = useState<Theme>(getStoredTheme)
const [resolved, setResolved] = useState<'dark' | 'light'>(() => resolveTheme(getStoredTheme()))
useEffect(() => {
initTheme()
}, [])
const set = useCallback((t: Theme) => {
applyTheme(t)
setThemeVal(t)
setResolved(resolveTheme(t))
}, [])
const toggle = useCallback(() => {
const next: Theme = resolved === 'dark' ? 'light' : 'dark'
set(next)
}, [resolved, set])
return { theme, resolved, toggle, set }
}
export function useTheme(): ThemeState {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}

View File

@@ -0,0 +1,62 @@
const BASE = '/api/v1'
class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.status = status
this.body = body
}
}
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...opts.headers,
},
...opts,
})
if (!res.ok) {
let body: unknown = null
try {
body = await res.json()
} catch {
body = await res.text()
}
throw new ApiError(res.status, body)
}
if (res.status === 204) return null as T
return res.json()
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data?: unknown) =>
request<T>(path, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
}),
put: <T>(path: string, data?: unknown) =>
request<T>(path, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
}),
patch: <T>(path: string, data?: unknown) =>
request<T>(path, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}
export { ApiError }

View File

@@ -0,0 +1,43 @@
export type Theme = 'dark' | 'light' | 'system'
const STORAGE_KEY = 'echoboard-theme'
function getSystemPref(): 'dark' | 'light' {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
}
function applyTheme(resolved: 'dark' | 'light') {
document.documentElement.classList.toggle('light', resolved === 'light')
}
export function getStoredTheme(): Theme {
return (localStorage.getItem(STORAGE_KEY) as Theme) || 'system'
}
export function resolveTheme(pref: Theme): 'dark' | 'light' {
if (pref === 'system') return getSystemPref()
return pref
}
export function setTheme(pref: Theme) {
localStorage.setItem(STORAGE_KEY, pref)
applyTheme(resolveTheme(pref))
}
export function initTheme() {
const pref = getStoredTheme()
applyTheme(resolveTheme(pref))
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
if (getStoredTheme() === 'system') {
applyTheme(getSystemPref())
}
})
}
export function toggleTheme(): Theme {
const current = resolveTheme(getStoredTheme())
const next: Theme = current === 'dark' ? 'light' : 'dark'
setTheme(next)
return next
}

16
packages/web/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import '@fontsource/space-grotesk/400.css'
import '@fontsource/space-grotesk/500.css'
import '@fontsource/space-grotesk/700.css'
import '@fontsource/sora/400.css'
import '@fontsource/sora/500.css'
import '@fontsource/sora/600.css'
import './app.css'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,154 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
interface Activity {
id: string
type: 'post_created' | 'status_changed' | 'comment_added' | 'admin_response' | 'vote'
postId: string
postTitle: string
boardSlug: string
boardName: string
actorName: string
detail?: string
createdAt: string
}
const typeLabels: Record<string, string> = {
post_created: 'created a post',
status_changed: 'changed status',
comment_added: 'commented',
admin_response: 'responded',
vote: 'voted on',
}
const typeIcons: Record<string, JSX.Element> = {
post_created: (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
),
status_changed: (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
),
comment_added: (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
),
admin_response: (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
vote: (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
),
}
export default function ActivityFeed() {
const [activities, setActivities] = useState<Activity[]>([])
const [loading, setLoading] = useState(true)
const [boardFilter, setBoardFilter] = useState('')
const [typeFilter, setTypeFilter] = useState('')
useEffect(() => {
const params = new URLSearchParams()
if (boardFilter) params.set('board', boardFilter)
if (typeFilter) params.set('type', typeFilter)
api.get<Activity[]>(`/activity?${params}`)
.then(setActivities)
.catch(() => {})
.finally(() => setLoading(false))
}, [boardFilter, typeFilter])
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<h1
className="text-2xl font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Activity
</h1>
{/* Filters */}
<div className="flex gap-3 mb-6">
<select
className="input"
style={{ maxWidth: 200 }}
value={boardFilter}
onChange={(e) => setBoardFilter(e.target.value)}
>
<option value="">All boards</option>
</select>
<select
className="input"
style={{ maxWidth: 200 }}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="">All types</option>
<option value="post_created">Posts</option>
<option value="comment_added">Comments</option>
<option value="status_changed">Status changes</option>
<option value="admin_response">Admin responses</option>
<option value="vote">Votes</option>
</select>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
) : activities.length === 0 ? (
<div className="text-center py-12">
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No activity yet</p>
</div>
) : (
<div className="flex flex-col gap-1">
{activities.map((a) => (
<Link
key={a.id}
to={`/b/${a.boardSlug}/post/${a.postId}`}
className="flex items-start gap-3 p-3 rounded-lg"
style={{ transition: 'background 200ms ease-out' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5"
style={{
background: a.type === 'admin_response' ? 'var(--admin-subtle)' : 'var(--accent-subtle)',
color: a.type === 'admin_response' ? 'var(--admin-accent)' : 'var(--accent)',
}}
>
{typeIcons[a.type]}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text)' }}>{a.actorName}</span>
{' '}{typeLabels[a.type] || a.type}{' '}
<span style={{ color: 'var(--text)' }}>{a.postTitle}</span>
</p>
{a.detail && (
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-tertiary)' }}>{a.detail}</p>
)}
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
{a.boardName} - {new Date(a.createdAt).toLocaleDateString()}
</p>
</div>
</Link>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,194 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import { api } from '../lib/api'
import PostCard from '../components/PostCard'
import PostForm from '../components/PostForm'
import VoteBudget from '../components/VoteBudget'
import EmptyState from '../components/EmptyState'
interface Post {
id: string
title: string
excerpt?: string
type: 'feature' | 'bug' | 'general'
status: string
voteCount: number
commentCount: number
authorName: string
createdAt: string
boardSlug: string
hasVoted?: boolean
}
interface Board {
id: string
name: string
slug: string
description: string
}
interface Budget {
used: number
total: number
resetsAt?: string
}
type SortOption = 'newest' | 'top' | 'trending'
type StatusFilter = 'all' | 'OPEN' | 'PLANNED' | 'IN_PROGRESS' | 'DONE' | 'DECLINED'
export default function BoardFeed() {
const { boardSlug } = useParams<{ boardSlug: string }>()
const [board, setBoard] = useState<Board | null>(null)
const [posts, setPosts] = useState<Post[]>([])
const [budget, setBudget] = useState<Budget>({ used: 0, total: 10 })
const [loading, setLoading] = useState(true)
const [sort, setSort] = useState<SortOption>('newest')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [search, setSearch] = useState('')
const [showForm, setShowForm] = useState(false)
const fetchPosts = useCallback(async () => {
if (!boardSlug) return
setLoading(true)
try {
const params = new URLSearchParams({ sort })
if (statusFilter !== 'all') params.set('status', statusFilter)
if (search) params.set('q', search)
const [b, p, bud] = await Promise.all([
api.get<Board>(`/boards/${boardSlug}`),
api.get<Post[]>(`/boards/${boardSlug}/posts?${params}`),
api.get<Budget>(`/boards/${boardSlug}/budget`).catch(() => ({ used: 0, total: 10 })),
])
setBoard(b)
setPosts(p)
setBudget(bud as Budget)
} catch {
setPosts([])
} finally {
setLoading(false)
}
}, [boardSlug, sort, statusFilter, search])
useEffect(() => { fetchPosts() }, [fetchPosts])
const handleVote = async (postId: string) => {
try {
await api.post(`/posts/${postId}/vote`)
fetchPosts()
} catch {}
}
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'newest', label: 'Newest' },
{ value: 'top', label: 'Top Voted' },
{ value: 'trending', label: 'Trending' },
]
const statuses: { value: StatusFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'OPEN', label: 'Open' },
{ value: 'PLANNED', label: 'Planned' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'DONE', label: 'Done' },
{ value: 'DECLINED', label: 'Declined' },
]
return (
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Header */}
{board && (
<div className="mb-6">
<h1
className="text-2xl font-bold mb-1"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
{board.name}
</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{board.description}
</p>
</div>
)}
{/* Budget */}
<div className="mb-4">
<VoteBudget used={budget.used} total={budget.total} resetsAt={budget.resetsAt} />
</div>
{/* Filter bar */}
<div className="flex flex-wrap items-center gap-3 mb-4">
<div className="flex-1 min-w-[200px]">
<input
className="input"
placeholder="Search posts..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-1">
{sortOptions.map((o) => (
<button
key={o.value}
onClick={() => setSort(o.value)}
className="px-3 py-1.5 rounded-md text-xs font-medium"
style={{
background: sort === o.value ? 'var(--accent-subtle)' : 'transparent',
color: sort === o.value ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'all 200ms ease-out',
}}
>
{o.label}
</button>
))}
</div>
</div>
{/* Status filter */}
<div className="flex gap-1 mb-6 overflow-x-auto pb-1">
{statuses.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className="px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap"
style={{
background: statusFilter === s.value ? 'var(--surface-hover)' : 'transparent',
color: statusFilter === s.value ? 'var(--text)' : 'var(--text-tertiary)',
border: `1px solid ${statusFilter === s.value ? 'var(--border-hover)' : 'var(--border)'}`,
transition: 'all 200ms ease-out',
}}
>
{s.label}
</button>
))}
</div>
{/* Post form */}
{boardSlug && (
<div className="mb-4">
<PostForm boardSlug={boardSlug} onSubmit={fetchPosts} />
</div>
)}
{/* Posts */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
) : posts.length === 0 ? (
<EmptyState
onAction={() => setShowForm(true)}
/>
) : (
<div className="flex flex-col gap-2">
{posts.map((post) => (
<PostCard key={post.id} post={post} onVote={handleVote} />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,142 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
interface Board {
id: string
slug: string
name: string
description: string
postCount: number
openCount: number
archived: boolean
}
export default function BoardIndex() {
const [boards, setBoards] = useState<Board[]>([])
const [loading, setLoading] = useState(true)
const [showArchived, setShowArchived] = useState(false)
useEffect(() => {
api.get<Board[]>('/boards')
.then(setBoards)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const active = boards.filter((b) => !b.archived)
const archived = boards.filter((b) => b.archived)
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
)
}
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<div className="mb-8">
<h1
className="text-3xl font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Feedback Boards
</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Choose a board to browse or submit feedback
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{active.map((board, i) => (
<Link
key={board.id}
to={`/b/${board.slug}`}
className="card p-5 block group"
style={{
animation: `fadeIn 200ms ease-out ${i * 80}ms both`,
}}
>
<div className="flex items-start justify-between mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-lg font-bold"
style={{
fontFamily: 'var(--font-heading)',
background: 'var(--accent-subtle)',
color: 'var(--accent)',
}}
>
{board.name.charAt(0)}
</div>
<svg
width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
style={{ color: 'var(--text-tertiary)', transition: 'transform 200ms ease-out' }}
className="group-hover:translate-x-0.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
<h2
className="text-base font-semibold mb-1"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
{board.name}
</h2>
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{board.description}
</p>
<div className="flex items-center gap-4 text-xs" style={{ color: 'var(--text-tertiary)' }}>
<span>{board.postCount} posts</span>
<span>{board.openCount} open</span>
</div>
</Link>
))}
</div>
{archived.length > 0 && (
<div className="mt-10">
<button
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-2 text-sm mb-4"
style={{ color: 'var(--text-tertiary)' }}
>
<svg
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
style={{
transition: 'transform 200ms ease-out',
transform: showArchived ? 'rotate(90deg)' : 'rotate(0deg)',
}}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
Archived boards ({archived.length})
</button>
{showArchived && (
<div className="grid gap-3 md:grid-cols-2 fade-in">
{archived.map((board) => (
<Link
key={board.id}
to={`/b/${board.slug}`}
className="card p-4 block opacity-60"
>
<h3 className="text-sm font-medium mb-1" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
{board.name}
</h3>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{board.postCount} posts - archived
</p>
</Link>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,205 @@
import { useState } from 'react'
import { useAuth } from '../hooks/useAuth'
import { api } from '../lib/api'
export default function IdentitySettings() {
const auth = useAuth()
const [name, setName] = useState(auth.displayName)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showPasskey, setShowPasskey] = useState(false)
const saveName = async () => {
if (!name.trim()) return
setSaving(true)
try {
await auth.updateProfile({ displayName: name })
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch {} finally {
setSaving(false)
}
}
const handleExport = async () => {
try {
const data = await api.get<unknown>('/me/export')
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'echoboard-data.json'
a.click()
URL.revokeObjectURL(url)
} catch {}
}
const handleDelete = async () => {
setDeleting(true)
try {
await auth.deleteIdentity()
window.location.href = '/'
} catch {} finally {
setDeleting(false)
}
}
return (
<div className="max-w-lg mx-auto px-4 py-8">
<h1
className="text-2xl font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Settings
</h1>
{/* Display name */}
<div className="card p-5 mb-4">
<h2
className="text-sm font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Display Name
</h2>
<div className="flex gap-2">
<input
className="input flex-1"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button
onClick={saveName}
disabled={saving}
className="btn btn-primary"
>
{saving ? 'Saving...' : saved ? 'Saved' : 'Save'}
</button>
</div>
</div>
{/* Identity status */}
<div className="card p-5 mb-4">
<h2
className="text-sm font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Identity
</h2>
<div
className="flex items-center gap-3 p-3 rounded-lg mb-3"
style={{ background: 'var(--bg)' }}
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{
background: auth.isPasskeyUser ? 'rgba(34, 197, 94, 0.15)' : 'var(--accent-subtle)',
color: auth.isPasskeyUser ? 'var(--success)' : 'var(--accent)',
}}
>
{auth.isPasskeyUser ? (
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)}
</div>
<div>
<div className="text-sm font-medium" style={{ color: 'var(--text)' }}>
{auth.isPasskeyUser ? 'Passkey registered' : 'Cookie-based identity'}
</div>
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{auth.isPasskeyUser
? 'Your identity is secured with a passkey'
: 'Your identity is tied to this browser cookie'}
</div>
</div>
</div>
{!auth.isPasskeyUser && (
<button onClick={() => setShowPasskey(true)} className="btn btn-primary w-full">
Upgrade to passkey
</button>
)}
</div>
{/* Data */}
<div className="card p-5 mb-4">
<h2
className="text-sm font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Your Data
</h2>
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
Export all your data in JSON format.
</p>
<button onClick={handleExport} className="btn btn-secondary">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Export Data
</button>
</div>
{/* Danger zone */}
<div className="card p-5" style={{ borderColor: 'rgba(239, 68, 68, 0.2)' }}>
<h2
className="text-sm font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)' }}
>
Danger Zone
</h2>
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
This will permanently delete your identity and all associated data. This cannot be undone.
</p>
<button
onClick={() => setShowDelete(true)}
className="btn text-sm"
style={{ background: 'rgba(239, 68, 68, 0.15)', color: 'var(--error)' }}
>
Delete my identity
</button>
</div>
{/* Delete confirmation */}
{showDelete && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={() => setShowDelete(false)}
>
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
<div
className="relative w-full max-w-sm mx-4 p-6 rounded-xl shadow-2xl slide-up"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-bold mb-2" style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)' }}>
Delete Identity
</h3>
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
Are you sure? All your posts, votes, and data will be permanently removed. This action cannot be reversed.
</p>
<div className="flex gap-3">
<button onClick={() => setShowDelete(false)} className="btn btn-secondary flex-1">
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="btn flex-1"
style={{ background: 'var(--error)', color: 'white', opacity: deleting ? 0.6 : 1 }}
>
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import StatusBadge from '../components/StatusBadge'
interface Post {
id: string
title: string
type: string
status: string
voteCount: number
commentCount: number
boardSlug: string
boardName: string
createdAt: string
}
export default function MySubmissions() {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get<Post[]>('/me/posts')
.then(setPosts)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<h1
className="text-2xl font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
My Posts
</h1>
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
) : posts.length === 0 ? (
<div className="text-center py-12">
<p className="text-sm mb-4" style={{ color: 'var(--text-tertiary)' }}>
You have not submitted any posts yet
</p>
<Link to="/" className="btn btn-primary">Browse boards</Link>
</div>
) : (
<div className="flex flex-col gap-2">
{posts.map((post) => (
<Link
key={post.id}
to={`/b/${post.boardSlug}/post/${post.id}`}
className="card p-4 flex items-center gap-4"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{post.boardName}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded capitalize"
style={{
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
}}
>
{post.type}
</span>
</div>
<h3
className="text-sm font-medium truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
{post.title}
</h3>
<div className="flex items-center gap-3 mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>
<span>{post.voteCount} votes</span>
<span>{post.commentCount} comments</span>
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
</div>
</div>
<StatusBadge status={post.status} />
</Link>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,241 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { api } from '../lib/api'
import StatusBadge from '../components/StatusBadge'
import Timeline from '../components/Timeline'
interface Post {
id: string
title: string
body: string
type: string
status: string
voteCount: number
hasVoted: boolean
authorName: string
createdAt: string
boardSlug: string
boardName: string
stepsToReproduce?: string
expected?: string
actual?: string
}
interface TimelineEntry {
id: string
type: 'status_change' | 'admin_response' | 'comment'
authorName: string
content: string
oldStatus?: string
newStatus?: string
createdAt: string
reactions?: { emoji: string; count: number; hasReacted: boolean }[]
isAdmin?: boolean
}
export default function PostDetail() {
const { boardSlug, postId } = useParams()
const [post, setPost] = useState<Post | null>(null)
const [timeline, setTimeline] = useState<TimelineEntry[]>([])
const [comment, setComment] = useState('')
const [submitting, setSubmitting] = useState(false)
const [loading, setLoading] = useState(true)
const fetchPost = async () => {
if (!postId) return
try {
const [p, t] = await Promise.all([
api.get<Post>(`/posts/${postId}`),
api.get<TimelineEntry[]>(`/posts/${postId}/timeline`),
])
setPost(p)
setTimeline(t)
} catch {} finally {
setLoading(false)
}
}
useEffect(() => { fetchPost() }, [postId])
const handleVote = async () => {
if (!postId) return
try {
await api.post(`/posts/${postId}/vote`)
fetchPost()
} catch {}
}
const handleComment = async () => {
if (!postId || !comment.trim()) return
setSubmitting(true)
try {
await api.post(`/posts/${postId}/comments`, { content: comment })
setComment('')
fetchPost()
} catch {} finally {
setSubmitting(false)
}
}
const handleReact = async (entryId: string, emoji: string) => {
try {
await api.post(`/timeline/${entryId}/react`, { emoji })
fetchPost()
} catch {}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
)
}
if (!post) {
return (
<div className="max-w-3xl mx-auto px-4 py-16 text-center">
<h2 className="text-lg font-semibold mb-2" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
Post not found
</h2>
<Link to={`/b/${boardSlug}`} className="btn btn-secondary mt-4">
Back to board
</Link>
</div>
)
}
return (
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-6 text-xs" style={{ color: 'var(--text-tertiary)' }}>
<Link to="/" className="hover:underline">Home</Link>
<span>/</span>
<Link to={`/b/${post.boardSlug}`} className="hover:underline">{post.boardName}</Link>
<span>/</span>
<span style={{ color: 'var(--text-secondary)' }}>{post.title}</span>
</div>
{/* Post header */}
<div className="card p-6 mb-6 fade-in">
<div className="flex items-start gap-4">
{/* Vote button */}
<button
onClick={handleVote}
className="flex flex-col items-center gap-1 px-3 py-2 rounded-lg shrink-0"
style={{
background: post.hasVoted ? 'var(--accent-subtle)' : 'var(--surface-hover)',
color: post.hasVoted ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'all 200ms ease-out',
}}
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
<span className="text-sm font-semibold">{post.voteCount}</span>
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span
className="text-xs px-1.5 py-0.5 rounded capitalize"
style={{
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
}}
>
{post.type}
</span>
<StatusBadge status={post.status} />
</div>
<h1
className="text-xl font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
{post.title}
</h1>
<div className="text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
by {post.authorName} - {new Date(post.createdAt).toLocaleDateString()}
</div>
<div className="text-sm whitespace-pre-wrap mb-4" style={{ color: 'var(--text-secondary)', lineHeight: 1.7 }}>
{post.body}
</div>
{/* Bug report fields */}
{post.type === 'bug' && (
<div className="grid gap-3 md:grid-cols-1">
{post.stepsToReproduce && (
<div className="p-3 rounded-lg" style={{ background: 'var(--bg)' }}>
<div className="text-xs font-medium mb-1" style={{ color: 'var(--text-tertiary)' }}>Steps to Reproduce</div>
<div className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>{post.stepsToReproduce}</div>
</div>
)}
<div className="grid gap-3 md:grid-cols-2">
{post.expected && (
<div className="p-3 rounded-lg" style={{ background: 'var(--bg)' }}>
<div className="text-xs font-medium mb-1" style={{ color: 'var(--text-tertiary)' }}>Expected</div>
<div className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>{post.expected}</div>
</div>
)}
{post.actual && (
<div className="p-3 rounded-lg" style={{ background: 'var(--bg)' }}>
<div className="text-xs font-medium mb-1" style={{ color: 'var(--text-tertiary)' }}>Actual</div>
<div className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>{post.actual}</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* Timeline */}
{timeline.length > 0 && (
<div className="mb-6">
<h2
className="text-sm font-semibold mb-4"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Activity
</h2>
<Timeline entries={timeline} onReact={handleReact} />
</div>
)}
{/* Comment form */}
<div className="card p-4">
<h3
className="text-sm font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Add a comment
</h3>
<textarea
className="input mb-3"
rows={3}
placeholder="Write your comment..."
value={comment}
onChange={(e) => setComment(e.target.value)}
style={{ resize: 'vertical' }}
/>
<div className="flex justify-end">
<button
onClick={handleComment}
disabled={submitting || !comment.trim()}
className="btn btn-primary"
style={{ opacity: submitting || !comment.trim() ? 0.5 : 1 }}
>
{submitting ? 'Posting...' : 'Post Comment'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,163 @@
import { useState, useEffect } from 'react'
import { api } from '../lib/api'
interface DataField {
field: string
purpose: string
retention: string
deletable: boolean
}
interface Manifest {
fields: DataField[]
cookieInfo: string
dataLocation: string
thirdParties: string[]
}
export default function PrivacyPage() {
const [manifest, setManifest] = useState<Manifest | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get<Manifest>('/privacy/data-manifest')
.then(setManifest)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h1
className="text-2xl font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Privacy
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Here is exactly what data this Echoboard instance collects and why.
</p>
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
) : manifest ? (
<>
{/* Quick summary */}
<div className="card p-5 mb-6">
<h2
className="text-base font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
The short version
</h2>
<ul className="flex flex-col gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<li className="flex items-start gap-2">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
No tracking scripts, no analytics, no third-party cookies
</li>
<li className="flex items-start gap-2">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
All data stays on this server - {manifest.dataLocation}
</li>
<li className="flex items-start gap-2">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
No external fonts or resources loaded
</li>
<li className="flex items-start gap-2">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
You can delete everything at any time
</li>
</ul>
</div>
{/* Cookie info */}
<div className="card p-5 mb-6">
<h2
className="text-base font-semibold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Cookies
</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{manifest.cookieInfo}
</p>
</div>
{/* Data fields */}
<div className="card p-5 mb-6">
<h2
className="text-base font-semibold mb-4"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
What we store
</h2>
<div className="flex flex-col gap-3">
{manifest.fields.map((f) => (
<div
key={f.field}
className="p-3 rounded-lg"
style={{ background: 'var(--bg)' }}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
{f.field}
</span>
{f.deletable && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'rgba(34, 197, 94, 0.15)', color: 'var(--success)' }}>
deletable
</span>
)}
</div>
<p className="text-xs mb-1" style={{ color: 'var(--text-secondary)' }}>
{f.purpose}
</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
Retained: {f.retention}
</p>
</div>
))}
</div>
</div>
{/* Third parties */}
{manifest.thirdParties.length > 0 && (
<div className="card p-5 mb-6">
<h2
className="text-base font-semibold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Third parties
</h2>
<ul className="flex flex-col gap-1">
{manifest.thirdParties.map((tp) => (
<li key={tp} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
- {tp}
</li>
))}
</ul>
</div>
)}
</>
) : (
<div className="card p-5">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
This Echoboard instance is self-hosted and privacy-focused. No tracking, no external services, no data sharing. Your identity is cookie-based unless you register a passkey. You can delete all your data at any time from the settings page.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,260 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
interface Board {
id: string
slug: string
name: string
description: string
postCount: number
archived: boolean
voteBudget: number
voteResetSchedule: string
}
export default function AdminBoards() {
const [boards, setBoards] = useState<Board[]>([])
const [loading, setLoading] = useState(true)
const [editBoard, setEditBoard] = useState<Board | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [form, setForm] = useState({
name: '',
slug: '',
description: '',
voteBudget: 10,
voteResetSchedule: 'monthly',
})
const [saving, setSaving] = useState(false)
const fetchBoards = async () => {
try {
const b = await api.get<Board[]>('/admin/boards')
setBoards(b)
} catch {} finally {
setLoading(false)
}
}
useEffect(() => { fetchBoards() }, [])
const resetForm = () => {
setForm({ name: '', slug: '', description: '', voteBudget: 10, voteResetSchedule: 'monthly' })
setEditBoard(null)
setShowCreate(false)
}
const openEdit = (b: Board) => {
setEditBoard(b)
setForm({
name: b.name,
slug: b.slug,
description: b.description,
voteBudget: b.voteBudget,
voteResetSchedule: b.voteResetSchedule,
})
setShowCreate(true)
}
const handleSave = async () => {
setSaving(true)
try {
if (editBoard) {
await api.put(`/admin/boards/${editBoard.id}`, form)
} else {
await api.post('/admin/boards', form)
}
resetForm()
fetchBoards()
} catch {} finally {
setSaving(false)
}
}
const handleArchive = async (id: string, archived: boolean) => {
try {
await api.patch(`/admin/boards/${id}`, { archived: !archived })
fetchBoards()
} catch {}
}
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1
className="text-2xl font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
>
Boards
</h1>
<div className="flex gap-2">
<Link to="/admin" className="btn btn-ghost text-sm">Back</Link>
<button onClick={() => { resetForm(); setShowCreate(true) }} className="btn btn-admin text-sm">
New Board
</button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
) : (
<div className="flex flex-col gap-3">
{boards.map((board) => (
<div
key={board.id}
className="card p-4 flex items-center gap-4"
style={{ opacity: board.archived ? 0.5 : 1 }}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-sm font-bold shrink-0"
style={{
fontFamily: 'var(--font-heading)',
background: 'var(--admin-subtle)',
color: 'var(--admin-accent)',
}}
>
{board.name.charAt(0)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
{board.name}
</h3>
{board.archived && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}>
archived
</span>
)}
</div>
<p className="text-xs truncate" style={{ color: 'var(--text-tertiary)' }}>
/{board.slug} - {board.postCount} posts - Budget: {board.voteBudget}/{board.voteResetSchedule}
</p>
</div>
<div className="flex gap-1 shrink-0">
<button onClick={() => openEdit(board)} className="btn btn-ghost text-xs px-2 py-1" style={{ color: 'var(--admin-accent)' }}>
Edit
</button>
<button
onClick={() => handleArchive(board.id, board.archived)}
className="btn btn-ghost text-xs px-2 py-1"
style={{ color: board.archived ? 'var(--success)' : 'var(--warning)' }}
>
{board.archived ? 'Restore' : 'Archive'}
</button>
</div>
</div>
))}
{boards.length === 0 && (
<div className="text-center py-12">
<p className="text-sm mb-4" style={{ color: 'var(--text-tertiary)' }}>No boards yet</p>
<button onClick={() => setShowCreate(true)} className="btn btn-admin">Create first board</button>
</div>
)}
</div>
)}
{/* Create/Edit modal */}
{showCreate && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={resetForm}
>
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
<div
className="relative w-full max-w-md mx-4 p-6 rounded-xl shadow-2xl slide-up"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
{editBoard ? 'Edit Board' : 'New Board'}
</h3>
<div className="flex flex-col gap-3">
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
<input
className="input"
value={form.name}
onChange={(e) => {
setForm((f) => ({
...f,
name: e.target.value,
slug: editBoard ? f.slug : slugify(e.target.value),
}))
}}
placeholder="Feature Requests"
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
<input
className="input"
value={form.slug}
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
placeholder="feature-requests"
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
<textarea
className="input"
rows={2}
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="What is this board for?"
style={{ resize: 'vertical' }}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Vote Budget</label>
<input
className="input"
type="number"
min={1}
max={100}
value={form.voteBudget}
onChange={(e) => setForm((f) => ({ ...f, voteBudget: parseInt(e.target.value) || 10 }))}
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Reset Schedule</label>
<select
className="input"
value={form.voteResetSchedule}
onChange={(e) => setForm((f) => ({ ...f, voteResetSchedule: e.target.value }))}
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="never">Never</option>
</select>
</div>
</div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={resetForm} className="btn btn-secondary flex-1">Cancel</button>
<button
onClick={handleSave}
disabled={saving || !form.name.trim() || !form.slug.trim()}
className="btn btn-admin flex-1"
style={{ opacity: saving ? 0.6 : 1 }}
>
{saving ? 'Saving...' : editBoard ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,135 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
interface Stats {
totalPosts: number
byStatus: Record<string, number>
thisWeek: number
topUnresolved: { id: string; title: string; voteCount: number; boardSlug: string }[]
}
export default function AdminDashboard() {
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get<Stats>('/admin/stats')
.then(setStats)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const statCards = stats ? [
{ label: 'Total Posts', value: stats.totalPosts, color: 'var(--admin-accent)' },
{ label: 'This Week', value: stats.thisWeek, color: 'var(--accent)' },
{ label: 'Open', value: stats.byStatus['OPEN'] || 0, color: 'var(--warning)' },
{ label: 'In Progress', value: stats.byStatus['IN_PROGRESS'] || 0, color: 'var(--info)' },
{ label: 'Done', value: stats.byStatus['DONE'] || 0, color: 'var(--success)' },
{ label: 'Declined', value: stats.byStatus['DECLINED'] || 0, color: 'var(--error)' },
] : []
const navLinks = [
{ to: '/admin/posts', label: 'Manage Posts', desc: 'View, filter, and respond to all posts' },
{ to: '/admin/boards', label: 'Manage Boards', desc: 'Create, edit, and archive feedback boards' },
]
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
)
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8">
<h1
className="text-2xl font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
>
Dashboard
</h1>
<Link to="/" className="btn btn-ghost text-sm">
View public site
</Link>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-8">
{statCards.map((s) => (
<div key={s.label} className="card p-4 fade-in">
<div className="text-2xl font-bold mb-1" style={{ fontFamily: 'var(--font-heading)', color: s.color }}>
{s.value}
</div>
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{s.label}
</div>
</div>
))}
</div>
{/* Nav links */}
<div className="grid md:grid-cols-2 gap-3 mb-8">
{navLinks.map((link) => (
<Link key={link.to} to={link.to} className="card p-5 block group">
<div className="flex items-center justify-between mb-2">
<h3
className="text-sm font-semibold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
>
{link.label}
</h3>
<svg
width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
style={{ color: 'var(--text-tertiary)', transition: 'transform 200ms ease-out' }}
className="group-hover:translate-x-0.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{link.desc}</p>
</Link>
))}
</div>
{/* Top unresolved */}
{stats && stats.topUnresolved.length > 0 && (
<div>
<h2
className="text-sm font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Most Voted Unresolved
</h2>
<div className="flex flex-col gap-1">
{stats.topUnresolved.map((p) => (
<Link
key={p.id}
to={`/b/${p.boardSlug}/post/${p.id}`}
className="flex items-center gap-3 p-3 rounded-lg"
style={{ transition: 'background 200ms ease-out' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span
className="text-sm font-semibold w-8 text-center"
style={{ color: 'var(--accent)' }}
>
{p.voteCount}
</span>
<span className="text-sm flex-1 truncate" style={{ color: 'var(--text)' }}>
{p.title}
</span>
</Link>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../../lib/api'
export default function AdminLogin() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const nav = useNavigate()
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (!email || !password) return
setLoading(true)
setError('')
try {
await api.post('/admin/login', { email, password })
nav('/admin')
} catch {
setError('Invalid credentials')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
<div
className="w-full max-w-sm p-6 rounded-xl shadow-xl fade-in"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="text-center mb-6">
<h1
className="text-xl font-bold mb-1"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
>
Admin Login
</h1>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
Echoboard administration
</p>
</div>
<form onSubmit={submit} className="flex flex-col gap-3">
<input
className="input"
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
style={{ borderColor: error ? 'var(--error)' : undefined }}
/>
<input
className="input"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
style={{ borderColor: error ? 'var(--error)' : undefined }}
/>
{error && (
<p className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>
)}
<button
type="submit"
disabled={loading}
className="btn w-full mt-1"
style={{
background: 'var(--admin-accent)',
color: '#141420',
opacity: loading ? 0.6 : 1,
}}
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,259 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import StatusBadge from '../../components/StatusBadge'
interface Post {
id: string
title: string
type: string
status: string
voteCount: number
commentCount: number
authorName: string
boardSlug: string
boardName: string
createdAt: string
}
type SortField = 'createdAt' | 'voteCount' | 'status'
const allStatuses = ['OPEN', 'UNDER_REVIEW', 'PLANNED', 'IN_PROGRESS', 'DONE', 'DECLINED']
export default function AdminPosts() {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
const [sortBy, setSortBy] = useState<SortField>('createdAt')
const [statusFilter, setStatusFilter] = useState('')
const [search, setSearch] = useState('')
const [actionPost, setActionPost] = useState<Post | null>(null)
const [newStatus, setNewStatus] = useState('')
const [response, setResponse] = useState('')
const [saving, setSaving] = useState(false)
const fetchPosts = async () => {
setLoading(true)
const params = new URLSearchParams({ sort: sortBy })
if (statusFilter) params.set('status', statusFilter)
if (search) params.set('q', search)
try {
const p = await api.get<Post[]>(`/admin/posts?${params}`)
setPosts(p)
} catch {} finally {
setLoading(false)
}
}
useEffect(() => { fetchPosts() }, [sortBy, statusFilter, search])
const handleStatusChange = async () => {
if (!actionPost || !newStatus) return
setSaving(true)
try {
await api.patch(`/admin/posts/${actionPost.id}`, {
status: newStatus,
response: response || undefined,
})
setActionPost(null)
setNewStatus('')
setResponse('')
fetchPosts()
} catch {} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Delete this post?')) return
try {
await api.delete(`/admin/posts/${id}`)
fetchPosts()
} catch {}
}
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1
className="text-2xl font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
>
Posts
</h1>
<Link to="/admin" className="btn btn-ghost text-sm">Back to dashboard</Link>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-6">
<input
className="input flex-1 min-w-[200px]"
placeholder="Search posts..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select
className="input"
style={{ maxWidth: 160 }}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">All statuses</option>
{allStatuses.map((s) => (
<option key={s} value={s}>{s.replace('_', ' ')}</option>
))}
</select>
<select
className="input"
style={{ maxWidth: 160 }}
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortField)}
>
<option value="createdAt">Newest</option>
<option value="voteCount">Most Voted</option>
<option value="status">Status</option>
</select>
</div>
{/* Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th className="text-left px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Title</th>
<th className="text-left px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Board</th>
<th className="text-left px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Status</th>
<th className="text-right px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Votes</th>
<th className="text-right px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Actions</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr
key={post.id}
style={{ borderBottom: '1px solid var(--border)' }}
className="group"
>
<td className="px-4 py-3">
<Link
to={`/b/${post.boardSlug}/post/${post.id}`}
className="font-medium hover:underline"
style={{ color: 'var(--text)' }}
>
{post.title}
</Link>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{post.authorName} - {new Date(post.createdAt).toLocaleDateString()}
</div>
</td>
<td className="px-4 py-3 text-xs" style={{ color: 'var(--text-secondary)' }}>
{post.boardName}
</td>
<td className="px-4 py-3">
<StatusBadge status={post.status} />
</td>
<td className="px-4 py-3 text-right" style={{ color: 'var(--accent)' }}>
{post.voteCount}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => { setActionPost(post); setNewStatus(post.status) }}
className="btn btn-ghost text-xs px-2 py-1"
style={{ color: 'var(--admin-accent)' }}
>
Manage
</button>
<button
onClick={() => handleDelete(post.id)}
className="btn btn-ghost text-xs px-2 py-1"
style={{ color: 'var(--error)' }}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{posts.length === 0 && (
<div className="text-center py-8 text-sm" style={{ color: 'var(--text-tertiary)' }}>
No posts found
</div>
)}
</div>
)}
{/* Action modal */}
{actionPost && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={() => setActionPost(null)}
>
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
<div
className="relative w-full max-w-md mx-4 p-6 rounded-xl shadow-2xl slide-up"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-bold mb-1" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
Manage Post
</h3>
<p className="text-sm mb-4 truncate" style={{ color: 'var(--text-secondary)' }}>
{actionPost.title}
</p>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>
Status
</label>
<select
className="input mb-4"
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
>
{allStatuses.map((s) => (
<option key={s} value={s}>{s.replace('_', ' ')}</option>
))}
</select>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>
Admin Response (optional)
</label>
<textarea
className="input mb-4"
rows={3}
placeholder="Add a public response..."
value={response}
onChange={(e) => setResponse(e.target.value)}
style={{ resize: 'vertical' }}
/>
<div className="flex gap-3">
<button onClick={() => setActionPost(null)} className="btn btn-secondary flex-1">Cancel</button>
<button
onClick={handleStatusChange}
disabled={saving}
className="btn btn-admin flex-1"
style={{ opacity: saving ? 0.6 : 1 }}
>
{saving ? 'Saving...' : 'Update'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

1
packages/web/src/vite-env.d.ts vendored Normal file
View File

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