security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -1,8 +1,14 @@
import { useState } from 'react'
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
import { useState, useEffect, useRef } from 'react'
import { BrowserRouter, Routes, Route, useLocation, useNavigate, Link } from 'react-router-dom'
import { IconShieldCheck, IconArrowRight, IconX, IconSearch, IconChevronUp, IconMessageCircle, IconBug, IconBulb, IconCommand, IconArrowNarrowRight } from '@tabler/icons-react'
import { AuthProvider, useAuthState } from './hooks/useAuth'
import { AdminProvider, useAdminState, useAdmin } from './hooks/useAdmin'
import { ThemeProvider, useThemeState } from './hooks/useTheme'
import { TranslationProvider, useTranslationState } from './i18n'
import { BrandingProvider } from './hooks/useBranding'
import { ConfirmProvider } from './hooks/useConfirm'
import Sidebar from './components/Sidebar'
import AdminSidebar from './components/AdminSidebar'
import MobileNav from './components/MobileNav'
import ThemeToggle from './components/ThemeToggle'
import IdentityBanner from './components/IdentityBanner'
@@ -19,37 +25,495 @@ import AdminLogin from './pages/admin/AdminLogin'
import AdminDashboard from './pages/admin/AdminDashboard'
import AdminPosts from './pages/admin/AdminPosts'
import AdminBoards from './pages/admin/AdminBoards'
import AdminCategories from './pages/admin/AdminCategories'
import AdminDataRetention from './pages/admin/AdminDataRetention'
import AdminTags from './pages/admin/AdminTags'
import RoadmapPage from './pages/RoadmapPage'
import ChangelogPage from './pages/ChangelogPage'
import AdminChangelog from './pages/admin/AdminChangelog'
import AdminWebhooks from './pages/admin/AdminWebhooks'
import AdminEmbed from './pages/admin/AdminEmbed'
import AdminStatuses from './pages/admin/AdminStatuses'
import AdminExport from './pages/admin/AdminExport'
import AdminTemplates from './pages/admin/AdminTemplates'
import AdminSettings from './pages/admin/AdminSettings'
import AdminTeam from './pages/admin/AdminTeam'
import AdminJoin from './pages/admin/AdminJoin'
import ProfilePage from './pages/ProfilePage'
import RecoverPage from './pages/RecoverPage'
import EmbedBoard from './pages/EmbedBoard'
import { api } from './lib/api'
import BoardIcon from './components/BoardIcon'
import StatusBadge from './components/StatusBadge'
import Avatar from './components/Avatar'
interface SearchBoard {
type: 'board'
id: string
title: string
slug: string
iconName: string | null
iconColor: string | null
description: string | null
postCount: number
}
interface SearchPost {
type: 'post'
id: string
title: string
postType: 'FEATURE_REQUEST' | 'BUG_REPORT'
status: string
voteCount: number
commentCount: number
boardSlug: string
boardName: string
boardIconName: string | null
boardIconColor: string | null
author: { id: string; displayName: string | null; avatarUrl: string | null } | null
createdAt: string
}
function searchTimeAgo(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`
return `${Math.floor(days / 30)}mo ago`
}
function SearchPage() {
const [query, setQuery] = useState('')
const [boards, setBoards] = useState<SearchBoard[]>([])
const [posts, setPosts] = useState<SearchPost[]>([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
useEffect(() => {
if (!query.trim()) { setBoards([]); setPosts([]); setSearched(false); return }
const t = setTimeout(async () => {
setLoading(true)
try {
const res = await api.get<{ boards: SearchBoard[]; posts: SearchPost[] }>(`/search?q=${encodeURIComponent(query)}`)
setBoards(res.boards)
setPosts(res.posts)
} catch { setBoards([]); setPosts([]) }
setSearched(true)
setLoading(false)
}, 200)
return () => clearTimeout(t)
}, [query])
const totalResults = boards.length + posts.length
const isMac = navigator.platform?.includes('Mac')
return (
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<h1 className="sr-only">Search</h1>
{/* Search input */}
<div className="relative mb-6">
<IconSearch
size={18} stroke={2}
style={{ position: 'absolute', left: 14, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-tertiary)', pointerEvents: 'none' }}
/>
<input
className="input"
style={{ paddingLeft: 42, fontSize: 'var(--text-base)' }}
placeholder="Search posts, feedback, and boards..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
aria-label="Search posts, feedback, and boards"
/>
</div>
{/* Status line */}
<div aria-live="polite" style={{ minHeight: 20 }}>
{loading && <p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>Searching...</p>}
{!loading && searched && (
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
{totalResults === 0 ? 'No results found - try different keywords or check for typos' : `${totalResults} result${totalResults === 1 ? '' : 's'} found`}
</p>
)}
</div>
{/* Empty state - before searching */}
{!query.trim() && !searched && (
<div className="flex flex-col items-center py-16 fade-in" style={{ textAlign: 'center' }}>
<div
className="flex items-center justify-center mb-5"
style={{
width: 72, height: 72,
borderRadius: 'var(--radius-lg)',
background: 'var(--accent-subtle)',
}}
>
<IconSearch size={32} stroke={1.5} style={{ color: 'var(--accent)' }} />
</div>
<h2
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-xl)' }}
>
Search across all boards
</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6, maxWidth: 380 }}>
Find posts, bug reports, feature requests, and boards. Typos are forgiven - the search is fuzzy.
</p>
<div
className="flex items-center gap-2 mt-6"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
>
<span
className="inline-flex items-center gap-1 px-2 py-1"
style={{
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
fontFamily: 'var(--font-mono, monospace)',
}}
>
{isMac ? <IconCommand size={11} stroke={2} /> : 'Ctrl+'}K
</span>
<span>for quick search anywhere</span>
</div>
</div>
)}
{/* No results state */}
{!loading && searched && totalResults === 0 && (
<div className="flex flex-col items-center py-12 fade-in" style={{ textAlign: 'center' }}>
<div
className="flex items-center justify-center mb-4"
style={{
width: 56, height: 56,
borderRadius: 'var(--radius-lg)',
background: 'var(--surface-hover)',
}}
>
<IconSearch size={24} stroke={1.5} style={{ color: 'var(--text-tertiary)' }} />
</div>
<p className="font-medium mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>
Nothing matched "{query}"
</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
Try broader terms or check the spelling
</p>
</div>
)}
{/* Board results */}
{boards.length > 0 && (
<div className="mb-6 mt-4">
<h3
className="font-medium mb-3"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
>
Boards
</h3>
<div className="flex flex-col gap-2">
{boards.map((b, i) => (
<Link
key={b.id}
to={`/b/${b.slug}`}
className="card card-interactive flex items-center gap-4 stagger-in"
style={{ padding: '14px 16px', '--stagger': i } as React.CSSProperties}
>
<BoardIcon name={b.title} iconName={b.iconName} iconColor={b.iconColor} size={36} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{b.title}
</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{b.postCount} post{b.postCount !== 1 ? 's' : ''}
</span>
</div>
{b.description && (
<p className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
{b.description}
</p>
)}
</div>
<IconArrowNarrowRight size={16} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
</Link>
))}
</div>
</div>
)}
{/* Post results */}
{posts.length > 0 && (
<div className="mt-4">
<h3
className="font-medium mb-3"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
>
Posts
</h3>
<div className="flex flex-col gap-2">
{posts.map((p, i) => (
<Link
key={p.id}
to={`/b/${p.boardSlug}/post/${p.id}`}
className="card card-interactive stagger-in"
style={{ padding: '14px 16px', '--stagger': i } as React.CSSProperties}
>
<div className="flex items-start gap-3">
{/* Vote count pill */}
<div
className="flex flex-col items-center shrink-0"
style={{
minWidth: 40,
padding: '6px 8px',
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
}}
>
<IconChevronUp size={14} stroke={2.5} style={{ color: 'var(--text-tertiary)' }} />
<span className="font-semibold" style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
{p.voteCount}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5"
style={{
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
background: p.postType === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
color: p.postType === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
}}
>
{p.postType === 'BUG_REPORT'
? <IconBug size={11} stroke={2} aria-hidden="true" />
: <IconBulb size={11} stroke={2} aria-hidden="true" />
}
{p.postType === 'BUG_REPORT' ? 'Bug' : 'Feature'}
</span>
<StatusBadge status={p.status} />
</div>
<h4
className="font-medium truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
{p.title}
</h4>
<div className="flex items-center gap-3 mt-2 flex-wrap" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
{/* Author */}
<span className="inline-flex items-center gap-1.5">
<Avatar
userId={p.author?.id ?? '0000'}
name={p.author?.displayName ?? null}
avatarUrl={p.author?.avatarUrl}
size={16}
/>
{p.author?.displayName ?? `Anonymous #${(p.author?.id ?? '0000').slice(-4)}`}
</span>
{/* Board */}
<span className="inline-flex items-center gap-1.5">
<BoardIcon name={p.boardName} iconName={p.boardIconName} iconColor={p.boardIconColor} size={16} />
{p.boardName}
</span>
{/* Comments */}
<span className="inline-flex items-center gap-1">
<IconMessageCircle size={12} stroke={2} />
{p.commentCount}
</span>
{/* Time */}
<time dateTime={p.createdAt}>{searchTimeAgo(p.createdAt)}</time>
</div>
</div>
</div>
</Link>
))}
</div>
</div>
)}
</div>
)
}
function RequireAdmin({ children }: { children: React.ReactNode }) {
const [ok, setOk] = useState<boolean | null>(null)
const nav = useNavigate()
useEffect(() => {
api.get('/admin/boards')
.then(() => setOk(true))
.catch(() => nav('/admin/login', { replace: true }))
}, [nav])
if (!ok) return null
return <>{children}</>
}
function NewPostRedirect() {
const nav = useNavigate()
useEffect(() => { nav('/') }, [nav])
return null
}
function AdminBanner() {
const admin = useAdmin()
const location = useLocation()
const isAdminPage = location.pathname.startsWith('/admin')
if (!admin.isAdmin || isAdminPage) return null
return (
<div
className="flex items-center gap-3 px-4 py-2 slide-down"
style={{
position: 'sticky',
top: 0,
zIndex: 50,
background: 'rgba(6, 182, 212, 0.08)',
backdropFilter: 'blur(12px)',
borderBottom: '1px solid rgba(6, 182, 212, 0.15)',
fontSize: 'var(--text-xs)',
}}
>
<IconShieldCheck size={14} stroke={2} style={{ color: 'var(--admin-accent)', flexShrink: 0 }} />
<span style={{ color: 'var(--admin-accent)', fontWeight: 600 }}>Admin mode</span>
<div className="flex items-center gap-2 ml-auto">
<Link
to="/admin"
className="inline-flex items-center gap-1 px-2.5 py-1 rounded"
style={{
color: 'var(--admin-accent)',
background: 'rgba(6, 182, 212, 0.1)',
borderRadius: 'var(--radius-sm)',
transition: 'background var(--duration-fast) ease-out',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.2)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.1)' }}
onFocus={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.2)' }}
onBlur={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.1)' }}
>
Admin panel <IconArrowRight size={11} stroke={2.5} />
</Link>
<button
onClick={admin.exitAdminMode}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded"
style={{
color: 'var(--text-tertiary)',
borderRadius: 'var(--radius-sm)',
transition: 'color var(--duration-fast) ease-out',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--text)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--text)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
>
<IconX size={11} stroke={2} /> Exit
</button>
</div>
</div>
)
}
function RouteAnnouncer() {
const location = useLocation()
const [title, setTitle] = useState('')
const timerRef = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
setTitle(document.title || location.pathname)
}, 300)
return () => clearTimeout(timerRef.current)
}, [location.pathname])
return (
<div
aria-live="assertive"
aria-atomic="true"
role="status"
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
}}
>
{title}
</div>
)
}
function Layout() {
const location = useLocation()
const admin = useAdmin()
const [passkeyMode, setPasskeyMode] = useState<'register' | 'login' | null>(null)
const isAdmin = location.pathname.startsWith('/admin')
const isAdminPage = location.pathname.startsWith('/admin')
const showAdminMode = admin.isAdmin && !isAdminPage
return (
<>
<a href="#main" className="sr-only">Skip to main content</a>
<RouteAnnouncer />
<CommandPalette />
<div className="flex min-h-screen" style={{ background: 'var(--bg)' }}>
{!isAdmin && <Sidebar />}
<main className="flex-1 pb-20 md:pb-0">
<div className={`flex min-h-screen ${showAdminMode ? 'admin-mode' : ''}`} style={{ background: 'var(--bg)' }}>
{isAdminPage ? <AdminSidebar /> : <Sidebar />}
<main id="main" className="flex-1 pb-20 md:pb-0">
<AdminBanner />
<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="/b/:boardSlug/roadmap" element={<RoadmapPage />} />
<Route path="/b/:boardSlug/changelog" element={<ChangelogPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/new" element={<NewPostRedirect />} />
<Route path="/activity" element={<ActivityFeed />} />
<Route path="/settings" element={<IdentitySettings />} />
<Route path="/my-posts" element={<MySubmissions />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/recover" element={<RecoverPage />} />
<Route path="/roadmap" element={<RoadmapPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/posts" element={<AdminPosts />} />
<Route path="/admin/boards" element={<AdminBoards />} />
<Route path="/admin" element={<RequireAdmin><AdminDashboard /></RequireAdmin>} />
<Route path="/admin/posts" element={<RequireAdmin><AdminPosts /></RequireAdmin>} />
<Route path="/admin/boards" element={<RequireAdmin><AdminBoards /></RequireAdmin>} />
<Route path="/admin/categories" element={<RequireAdmin><AdminCategories /></RequireAdmin>} />
<Route path="/admin/tags" element={<RequireAdmin><AdminTags /></RequireAdmin>} />
<Route path="/admin/changelog" element={<RequireAdmin><AdminChangelog /></RequireAdmin>} />
<Route path="/admin/webhooks" element={<RequireAdmin><AdminWebhooks /></RequireAdmin>} />
<Route path="/admin/embed" element={<RequireAdmin><AdminEmbed /></RequireAdmin>} />
<Route path="/admin/statuses" element={<RequireAdmin><AdminStatuses /></RequireAdmin>} />
<Route path="/admin/export" element={<RequireAdmin><AdminExport /></RequireAdmin>} />
<Route path="/admin/templates" element={<RequireAdmin><AdminTemplates /></RequireAdmin>} />
<Route path="/admin/data-retention" element={<RequireAdmin><AdminDataRetention /></RequireAdmin>} />
<Route path="/admin/team" element={<RequireAdmin><AdminTeam /></RequireAdmin>} />
<Route path="/admin/join/:token" element={<AdminJoin />} />
<Route path="/admin/settings" element={<RequireAdmin><AdminSettings /></RequireAdmin>} />
</Routes>
</main>
</div>
{!isAdmin && <MobileNav />}
{!isAdminPage && <MobileNav />}
<ThemeToggle />
{!isAdmin && (
{!isAdminPage && (
<IdentityBanner onRegister={() => setPasskeyMode('register')} />
)}
<PasskeyModal
@@ -63,15 +527,38 @@ function Layout() {
export default function App() {
const auth = useAuthState()
const theme = useThemeState()
const admin = useAdminState()
const i18n = useTranslationState()
const theme = useThemeState((t) => {
if (auth.user?.isPasskeyUser) {
api.put('/me', { darkMode: t }).catch(() => {})
}
})
useEffect(() => {
if (auth.user?.isPasskeyUser && auth.user.darkMode) {
theme.set(auth.user.darkMode as 'dark' | 'light' | 'system')
}
}, [auth.user?.isPasskeyUser])
return (
<ThemeProvider value={theme}>
<AuthProvider value={auth}>
<BrowserRouter>
<Layout />
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
<BrandingProvider>
<ConfirmProvider>
<TranslationProvider value={i18n}>
<ThemeProvider value={theme}>
<AuthProvider value={auth}>
<AdminProvider value={admin}>
<BrowserRouter>
<Routes>
<Route path="/embed/:boardSlug" element={<EmbedBoard />} />
<Route path="*" element={<Layout />} />
</Routes>
</BrowserRouter>
</AdminProvider>
</AuthProvider>
</ThemeProvider>
</TranslationProvider>
</ConfirmProvider>
</BrandingProvider>
)
}

View File

@@ -2,41 +2,154 @@
@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);
/* Neutral dark palette */
--bg: #161616;
--surface: #1e1e1e;
--surface-hover: #272727;
--surface-raised: #2a2a2a;
--border: rgba(255, 255, 255, 0.08);
--border-hover: rgba(255, 255, 255, 0.15);
--border-accent: rgba(245, 158, 11, 0.3);
/* Text */
--text: #f0f0f0;
--text-secondary: rgba(240, 240, 240, 0.72);
--text-tertiary: rgba(240, 240, 240, 0.71);
/* Accent - amber/gold */
--accent: #F59E0B;
--accent-hover: #D97706;
--accent-subtle: rgba(245, 158, 11, 0.15);
--admin-accent: #06B6D4;
--admin-subtle: rgba(6, 182, 212, 0.15);
--accent-hover: #FBBF24;
--accent-dim: #D97706;
--accent-subtle: rgba(245, 158, 11, 0.12);
--accent-glow: rgba(245, 158, 11, 0.25);
/* Admin - cyan */
--admin-accent: #08C4E4;
--admin-subtle: rgba(8, 196, 228, 0.12);
/* Semantic */
--success: #22C55E;
--warning: #EAB308;
--error: #EF4444;
--info: #3B82F6;
--error: #F98A8A;
--info: #6DB5FC;
/* Status badge colors (bright for dark bg) */
--status-open: #F59E0B;
--status-review: #22D3EE;
--status-planned: #6DB5FC;
--status-progress: #EAB308;
--status-done: #22C55E;
--status-declined: #F98A8A;
/* Typography */
--font-heading: 'Space Grotesk', system-ui, sans-serif;
--font-body: 'Sora', system-ui, sans-serif;
/* Type scale - Perfect Fourth (1.333) */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.313rem;
--text-xl: 1.75rem;
--text-2xl: 2.375rem;
--text-3xl: 3.125rem;
/* Spacing scale */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--space-3xl: 64px;
/* Radii - soft friendly */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
/* Shadows - soft layered */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.15);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.15);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.35), 0 8px 16px rgba(0, 0, 0, 0.2);
--shadow-glow: 0 0 20px rgba(245, 158, 11, 0.15);
/* Transitions */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
/* Layout */
--sidebar-expanded: 300px;
--sidebar-collapsed: 64px;
--content-max: 920px;
}
html.light {
--bg: #faf9f6;
--bg: #f7f8fa;
--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);
--surface-hover: #f0f1f3;
--surface-raised: #f5f6f8;
--border: rgba(0, 0, 0, 0.08);
--border-hover: rgba(0, 0, 0, 0.15);
--border-accent: rgba(112, 73, 9, 0.3);
--text: #1a1a1a;
--text-secondary: #4a4a4a;
--text-tertiary: #545454;
--accent: #704909;
--accent-hover: #855609;
--accent-dim: #5C3C06;
--accent-subtle: rgba(112, 73, 9, 0.1);
--accent-glow: rgba(112, 73, 9, 0.2);
--admin-accent: #0A5C73;
--admin-subtle: rgba(10, 92, 115, 0.1);
--success: #166534;
--warning: #74430C;
--error: #991919;
--info: #1A40B0;
/* Status badge colors (darker for light bg) */
--status-open: #704909;
--status-review: #0A5C73;
--status-planned: #1A40B0;
--status-progress: #74430C;
--status-done: #166534;
--status-declined: #991919;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1), 0 4px 8px rgba(0, 0, 0, 0.06);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.06);
--shadow-glow: 0 0 20px rgba(112, 73, 9, 0.1);
}
/* Admin mode - override accent with cyan */
.admin-mode {
--accent: #08C4E4;
--accent-hover: #22D3EE;
--accent-dim: #06A3BE;
--accent-subtle: rgba(8, 196, 228, 0.12);
--accent-glow: rgba(8, 196, 228, 0.25);
--border-accent: rgba(8, 196, 228, 0.3);
--shadow-glow: 0 0 20px rgba(8, 196, 228, 0.15);
}
html.light .admin-mode {
--accent: #0A5C73;
--accent-hover: #0C6D87;
--accent-dim: #084B5E;
--accent-subtle: rgba(10, 92, 115, 0.1);
--accent-glow: rgba(10, 92, 115, 0.2);
--border-accent: rgba(10, 92, 115, 0.3);
--shadow-glow: 0 0 20px rgba(10, 92, 115, 0.1);
}
* {
@@ -45,42 +158,75 @@
box-sizing: border-box;
}
html {
scroll-padding-top: 48px;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
transition: background 200ms ease-out, color 200ms ease-out;
transition: background var(--duration-normal) ease-out, color var(--duration-normal) ease-out;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
line-height: 1.3;
}
/* Focus ring - visible on all interactive elements */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
}
@layer components {
/* ---------- Buttons ---------- */
.btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
padding: 0.75rem 1.25rem;
min-height: 44px;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-weight: 500;
font-size: 0.875rem;
transition: all 200ms ease-out;
font-size: var(--text-sm);
transition: all var(--duration-normal) var(--ease-out);
cursor: pointer;
border: none;
outline: none;
overflow: hidden;
}
.btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
transform: translateX(-100%);
transition: transform var(--duration-slow) ease-out;
}
.btn:hover::before {
transform: translateX(100%);
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: var(--accent);
color: #141420;
color: #161616;
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background: var(--accent-hover);
box-shadow: var(--shadow-glow), var(--shadow-md);
}
.btn-secondary {
@@ -92,6 +238,9 @@
background: var(--surface-hover);
border-color: var(--border-hover);
}
.btn-secondary::before {
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent);
}
.btn-ghost {
background: transparent;
@@ -101,49 +250,296 @@
background: var(--surface-hover);
color: var(--text);
}
.btn-ghost::before { display: none; }
html.light .btn-primary { color: #fff; }
html.light .btn-admin { color: #fff; }
.icon-picker-btn {
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out;
}
.icon-picker-btn:hover {
background: var(--surface-hover) !important;
color: var(--text) !important;
}
.number-input-btn {
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out;
}
.number-input-btn:not(:disabled):hover {
background: var(--surface-hover) !important;
color: var(--text) !important;
}
.number-input-btn:not(:disabled):active {
background: var(--border) !important;
}
/* Small inline action buttons (edit, delete, lock, etc.) */
.action-btn {
cursor: pointer;
border: none;
background: transparent;
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out, opacity var(--duration-fast) ease-out;
}
.action-btn:hover {
background: var(--surface-hover) !important;
opacity: 0.85;
filter: brightness(1.15);
}
.action-btn:active {
background: var(--border) !important;
}
/* Sidebar/nav links with hover feedback */
.nav-link {
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out;
}
.nav-link:hover {
background: var(--surface-hover) !important;
color: var(--text) !important;
}
.roadmap-card:hover {
border-color: var(--border-hover) !important;
box-shadow: var(--shadow-md) !important;
}
.btn-admin {
background: var(--admin-accent);
color: #141420;
color: #161616;
box-shadow: var(--shadow-sm);
}
.btn-admin:hover {
opacity: 0.9;
box-shadow: var(--shadow-glow), var(--shadow-md);
}
/* ---------- Cards ---------- */
.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);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out, border-left-color var(--duration-normal) ease-out;
}
/* Interactive cards get accent line on hover */
.card-interactive {
border-left: 3px solid transparent;
}
.card-interactive:hover {
border-color: var(--border-hover);
border-left-color: var(--accent);
box-shadow: var(--shadow-md);
}
/* Static cards (forms, settings) have no hover effect */
.card-static {
cursor: default;
}
.card-static:hover {
border-color: var(--border);
box-shadow: var(--shadow-sm);
transform: none;
}
/* ---------- Inputs ---------- */
.input {
width: 100%;
padding: 0.625rem 0.875rem;
background: var(--surface);
padding: 0.75rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
border-radius: var(--radius-md);
color: var(--text);
font-family: var(--font-body);
font-size: 0.875rem;
transition: border-color 200ms ease-out;
font-size: var(--text-sm);
transition: border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out;
outline: none;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.input::placeholder {
color: var(--text-tertiary);
}
/* ---------- Markdown body ---------- */
.markdown-body {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.7;
word-wrap: break-word;
}
.markdown-body p { margin-bottom: 1em; }
.markdown-body p:last-child { margin-bottom: 0; }
.markdown-body strong { color: var(--text); font-weight: 600; }
.markdown-body em { font-style: italic; }
.markdown-body del { text-decoration: line-through; opacity: 0.7; }
.markdown-body a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
transition: opacity var(--duration-fast) ease-out;
}
.markdown-body a:hover { opacity: 0.8; }
.markdown-body code {
background: var(--bg);
padding: 0.15em 0.4em;
border-radius: var(--radius-sm);
font-size: 0.88em;
font-family: var(--font-mono);
}
.markdown-body pre {
background: var(--bg);
padding: 12px 16px;
border-radius: var(--radius-md);
overflow-x: auto;
margin: 0.5em 0;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: 0.85em;
}
.markdown-body ul, .markdown-body ol {
padding-left: 1.5em;
margin: 0.4em 0;
}
.markdown-body ul { list-style: disc; }
.markdown-body ol { list-style: decimal; }
.markdown-body li { margin: 0.25em 0; }
.markdown-body blockquote {
border-left: 3px solid var(--border);
padding-left: 12px;
color: var(--text-tertiary);
margin: 0.5em 0;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
overflow-wrap: break-word;
word-break: break-word;
}
.markdown-body h1 {
font-size: 1.5em;
font-weight: 700;
color: var(--text);
margin: 0.8em 0 0.4em;
line-height: 1.3;
}
.markdown-body h2 {
font-size: 1.25em;
font-weight: 600;
color: var(--text);
margin: 0.7em 0 0.3em;
line-height: 1.35;
}
.markdown-body h3 {
font-size: 1.1em;
font-weight: 600;
color: var(--text);
margin: 0.6em 0 0.25em;
line-height: 1.4;
}
.markdown-body h1:first-child,
.markdown-body h2:first-child,
.markdown-body h3:first-child { margin-top: 0; }
.markdown-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 1em 0;
}
.markdown-body img {
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: var(--radius-md);
margin: 0.5em 0;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 0.5em 0;
font-size: 0.9em;
}
.markdown-body th, .markdown-body td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
}
.markdown-body th {
background: var(--bg);
font-weight: 600;
color: var(--text);
}
.markdown-body input[type="checkbox"] {
margin-right: 6px;
accent-color: var(--accent);
pointer-events: none;
}
.markdown-body li:has(> input[type="checkbox"]) {
list-style: none;
margin-left: -1.5em;
}
/* ---------- Skeleton loading ---------- */
.skeleton {
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-hover) 50%, var(--surface) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
.skeleton-text {
height: 14px;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-title {
height: 22px;
margin-bottom: 12px;
width: 60%;
border-radius: 4px;
}
.skeleton-card {
height: 100px;
border-radius: var(--radius-lg);
}
/* ---------- Progress bar ---------- */
.progress-bar {
position: relative;
height: 2px;
background: var(--border);
overflow: hidden;
border-radius: 1px;
}
.progress-bar::after {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 30%;
background: var(--accent);
border-radius: 1px;
animation: progressSlide 1.2s ease-in-out infinite;
}
/* ---------- Animations ---------- */
.slide-up {
animation: slideUp 300ms cubic-bezier(0.16, 1, 0.3, 1);
animation: slideUp 300ms var(--ease-out);
}
.fade-in {
animation: fadeIn 200ms ease-out;
animation: fadeIn var(--duration-normal) ease-out;
}
.slide-down {
animation: slideDown var(--duration-normal) ease-out;
}
/* Staggered entrance - use with style="--stagger: N" */
.stagger-in {
animation: staggerFadeIn var(--duration-slow) var(--ease-out) both;
animation-delay: calc(var(--stagger, 0) * 50ms);
}
@keyframes slideUp {
@@ -158,4 +554,91 @@
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes checkDraw {
from { stroke-dashoffset: 30; }
to { stroke-dashoffset: 0; }
}
@keyframes paletteIn {
from { transform: scale(0.97); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes staggerFadeIn {
from { transform: translateY(12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes progressSlide {
0% { left: -30%; }
100% { left: 100%; }
}
@keyframes voteBounce {
0% { transform: translateY(0); }
30% { transform: translateY(-4px); }
50% { transform: translateY(0); }
70% { transform: translateY(-2px); }
100% { transform: translateY(0); }
}
@keyframes countTick {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
@keyframes successCheck {
from { stroke-dashoffset: 30; }
to { stroke-dashoffset: 0; }
}
.vote-bounce {
animation: voteBounce 400ms var(--ease-spring);
}
.count-tick {
animation: countTick 300ms var(--ease-spring);
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.sr-only:focus {
position: fixed;
top: 8px;
left: 8px;
width: auto;
height: auto;
padding: 12px 24px;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
z-index: 9999;
background: var(--surface);
color: var(--text);
border: 2px solid var(--accent);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
box-shadow: var(--shadow-lg);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { api } from '../lib/api'
import { useAdmin } from '../hooks/useAdmin'
import { IconHome, IconFileText, IconLayoutGrid, IconTag, IconTags, IconTrash, IconPlug, IconArrowLeft, IconNews, IconWebhook, IconCode, IconPalette, IconDownload, IconTemplate, IconSettings, IconUsers } from '@tabler/icons-react'
import type { Icon } from '@tabler/icons-react'
interface PluginInfo {
name: string
version: string
adminRoutes?: { path: string; label: string }[]
}
const roleLevel: Record<string, number> = { SUPER_ADMIN: 3, ADMIN: 2, MODERATOR: 1 }
const links: { to: string; label: string; icon: Icon; minLevel: number }[] = [
{ to: '/admin', label: 'Dashboard', icon: IconHome, minLevel: 1 },
{ to: '/admin/posts', label: 'All Posts', icon: IconFileText, minLevel: 1 },
{ to: '/admin/boards', label: 'Boards', icon: IconLayoutGrid, minLevel: 2 },
{ to: '/admin/categories', label: 'Categories', icon: IconTag, minLevel: 1 },
{ to: '/admin/tags', label: 'Tags', icon: IconTags, minLevel: 1 },
{ to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 },
{ to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 },
{ to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 },
{ to: '/admin/statuses', label: 'Custom Statuses', icon: IconPalette, minLevel: 2 },
{ to: '/admin/templates', label: 'Templates', icon: IconTemplate, minLevel: 2 },
{ to: '/admin/embed', label: 'Embed Widget', icon: IconCode, minLevel: 2 },
{ to: '/admin/export', label: 'Export Data', icon: IconDownload, minLevel: 2 },
{ to: '/admin/data-retention', label: 'Data Retention', icon: IconTrash, minLevel: 1 },
{ to: '/admin/settings', label: 'Branding', icon: IconSettings, minLevel: 3 },
]
export default function AdminSidebar() {
const location = useLocation()
const admin = useAdmin()
const [plugins, setPlugins] = useState<PluginInfo[]>([])
useEffect(() => {
api.get<PluginInfo[]>('/plugins/active').then(setPlugins).catch(() => {})
}, [])
const pluginLinks = plugins.flatMap((p) =>
(p.adminRoutes ?? []).map((r) => ({
to: `/admin/plugins${r.path}`,
label: r.label,
}))
)
const currentLevel = roleLevel[admin.role as string] ?? 0
const visibleLinks = links.filter(l => currentLevel >= l.minLevel)
const isActive = (path: string) =>
path === '/admin' ? location.pathname === '/admin' : location.pathname.startsWith(path)
return (
<aside
aria-label="Admin navigation"
className="flex flex-col border-r h-screen sticky top-0"
style={{
width: 240,
background: 'var(--surface)',
borderColor: 'var(--border)',
}}
>
<div
className="border-b"
style={{ borderColor: 'var(--border)', padding: '14px 20px' }}
>
<Link
to="/admin"
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-lg)' }}
>
Admin
</Link>
</div>
<nav className="flex-1 overflow-y-auto" style={{ padding: '8px 10px' }}>
{visibleLinks.map((link) => {
const Icon = link.icon
return (
<Link
key={link.to}
to={link.to}
className="flex items-center gap-3 nav-link"
aria-current={isActive(link.to) ? 'page' : undefined}
style={{
padding: '8px 12px',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-sm)',
marginBottom: 2,
background: isActive(link.to) ? 'var(--admin-subtle)' : 'transparent',
color: isActive(link.to) ? 'var(--admin-accent)' : 'var(--text-secondary)',
fontWeight: isActive(link.to) ? 600 : 400,
transition: 'all var(--duration-fast) ease-out',
}}
>
<Icon size={18} stroke={2} aria-hidden="true" />
{link.label}
</Link>
)
})}
{pluginLinks.length > 0 && (
<>
<div style={{ padding: '16px 12px 6px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em' }}>
Plugins
</div>
{pluginLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="flex items-center gap-3 nav-link"
aria-current={isActive(link.to) ? 'page' : undefined}
style={{
padding: '8px 12px',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-sm)',
marginBottom: 2,
background: isActive(link.to) ? 'var(--admin-subtle)' : 'transparent',
color: isActive(link.to) ? 'var(--admin-accent)' : 'var(--text-secondary)',
transition: 'all var(--duration-fast) ease-out',
}}
>
<IconPlug size={18} stroke={2} aria-hidden="true" />
{link.label}
</Link>
))}
</>
)}
</nav>
<div className="border-t" style={{ borderColor: 'var(--border)', padding: '12px 16px' }}>
<Link
to="/"
className="flex items-center gap-2 nav-link"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', padding: '6px 4px', borderRadius: 'var(--radius-sm)' }}
>
<IconArrowLeft size={14} stroke={2} />
Back to public site
</Link>
</div>
</aside>
)
}

View File

@@ -0,0 +1,72 @@
import { useMemo } from 'react'
function hashCode(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
}
return Math.abs(hash)
}
interface AvatarProps {
userId: string
name?: string | null
avatarUrl?: string | null
size?: number
}
export default function Avatar({ userId, name, avatarUrl, size = 28 }: AvatarProps) {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt={name ? `${name}'s avatar` : 'User avatar'}
style={{
width: size,
height: size,
borderRadius: 'var(--radius-full, 50%)',
objectFit: 'cover',
flexShrink: 0,
}}
loading="lazy"
/>
)
}
return <GeneratedAvatar id={userId} size={size} />
}
function GeneratedAvatar({ id, size }: { id: string; size: number }) {
const { hue, shapes } = useMemo(() => {
let hash = 0
for (let i = 0; i < id.length; i++) hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0
const h = Math.abs(hash % 360)
const s: { x: number; y: number; r: number; op: number }[] = []
for (let i = 0; i < 4; i++) {
const seed = Math.abs((hash >> (i * 4)) % 256)
s.push({
x: 4 + (seed % 24),
y: 4 + ((seed * 7) % 24),
r: 3 + (seed % 6),
op: 0.3 + (seed % 5) * 0.15,
})
}
return { hue: h, shapes: s }
}, [id])
return (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
aria-hidden="true"
style={{ borderRadius: 'var(--radius-full, 50%)', flexShrink: 0 }}
>
<rect width="32" height="32" fill={`hsl(${hue}, 55%, 45%)`} />
{shapes.map((s, i) => (
<circle key={i} cx={s.x} cy={s.y} r={s.r} fill={`hsla(${(hue + 60) % 360}, 60%, 70%, ${s.op})`} />
))}
<rect x="8" y="8" width="16" height="16" rx="3" fill={`hsla(${hue}, 40%, 90%, 0.15)`} />
</svg>
)
}

View File

@@ -0,0 +1,50 @@
import { renderIcon } from './IconPicker'
export default function BoardIcon({
name,
iconName,
iconColor,
size = 32,
}: {
name: string
iconName?: string | null
iconColor?: string | null
size?: number
}) {
const color = iconColor || 'var(--accent)'
const fontSize = size * 0.45
if (iconName) {
return (
<div
className="flex items-center justify-center shrink-0"
style={{
width: size,
height: size,
borderRadius: 'var(--radius-sm)',
background: iconColor ? `${iconColor}18` : 'var(--accent-subtle)',
color,
}}
>
{renderIcon(iconName, size * 0.55, color)}
</div>
)
}
return (
<div
className="flex items-center justify-center shrink-0 font-bold"
style={{
width: size,
height: size,
borderRadius: 'var(--radius-sm)',
background: 'var(--accent-subtle)',
color: 'var(--accent)',
fontFamily: 'var(--font-heading)',
fontSize,
}}
>
{name.charAt(0).toUpperCase()}
</div>
)
}

View File

@@ -1,29 +1,60 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../lib/api'
import { useFocusTrap } from '../hooks/useFocusTrap'
import { IconSearch, IconBug, IconBulb } from '@tabler/icons-react'
import BoardIcon from './BoardIcon'
import StatusBadge from './StatusBadge'
import Avatar from './Avatar'
interface SearchResult {
type: 'post' | 'board'
interface BoardResult {
type: 'board'
id: string
title: string
slug?: string
boardSlug?: string
slug: string
iconName: string | null
iconColor: string | null
description: string | null
postCount: number
}
interface PostResult {
type: 'post'
id: string
title: string
postType: 'FEATURE_REQUEST' | 'BUG_REPORT'
status: string
voteCount: number
commentCount: number
boardSlug: string
boardName: string
boardIconName: string | null
boardIconColor: string | null
author: { id: string; displayName: string | null; avatarUrl: string | null } | null
createdAt: string
}
type FlatResult = (BoardResult | PostResult)
export default function CommandPalette() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [boards, setBoards] = useState<BoardResult[]>([])
const [posts, setPosts] = useState<PostResult[]>([])
const [selected, setSelected] = useState(0)
const [loading, setLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const trapRef = useFocusTrap(open)
const nav = useNavigate()
const allResults: FlatResult[] = [...boards, ...posts]
const toggle = useCallback(() => {
setOpen((v) => {
if (!v) {
setQuery('')
setResults([])
setBoards([])
setPosts([])
setSelected(0)
}
return !v
@@ -52,17 +83,20 @@ export default function CommandPalette() {
useEffect(() => {
if (!query.trim()) {
setResults([])
setBoards([])
setPosts([])
return
}
const t = setTimeout(async () => {
setLoading(true)
try {
const res = await api.get<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
setResults(res)
const res = await api.get<{ boards: BoardResult[]; posts: PostResult[] }>(`/search?q=${encodeURIComponent(query)}`)
setBoards(res.boards)
setPosts(res.posts)
setSelected(0)
} catch {
setResults([])
setBoards([])
setPosts([])
} finally {
setLoading(false)
}
@@ -70,7 +104,7 @@ export default function CommandPalette() {
return () => clearTimeout(t)
}, [query])
const navigate = (r: SearchResult) => {
const go = (r: FlatResult) => {
if (r.type === 'board') {
nav(`/b/${r.slug}`)
} else {
@@ -82,19 +116,17 @@ export default function CommandPalette() {
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelected((s) => Math.min(s + 1, results.length - 1))
setSelected((s) => Math.min(s + 1, allResults.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])
} else if (e.key === 'Enter' && allResults[selected]) {
go(allResults[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 (
@@ -102,34 +134,49 @@ export default function CommandPalette() {
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)' }}
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="command-palette-title"
className="relative w-full max-w-lg mx-4 overflow-hidden fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-xl)',
}}
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>
<h2 id="command-palette-title" className="sr-only">Search</h2>
<div
className="flex items-center gap-3 border-b"
style={{ borderColor: 'var(--border)', padding: '12px 16px' }}
>
<IconSearch size={18} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<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)' }}
placeholder="Search posts, feedback, and boards..."
className="flex-1 bg-transparent outline-none"
style={{ color: 'var(--text)', fontFamily: 'var(--font-body)', fontSize: 'var(--text-sm)' }}
aria-label="Search"
/>
<kbd
className="text-[10px] px-1.5 py-0.5 rounded"
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
className="px-1.5 py-0.5"
style={{
background: 'var(--surface-hover)',
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
>
ESC
</kbd>
@@ -137,46 +184,52 @@ export default function CommandPalette() {
<div className="max-h-80 overflow-y-auto">
{loading && (
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
<div className="text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', padding: '32px 16px' }}>
Searching...
</div>
)}
{!loading && query && results.length === 0 && (
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
{!loading && query && allResults.length === 0 && (
<div className="text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', padding: '32px 16px' }}>
No results for "{query}"
</div>
)}
{!loading && !query && (
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
<div className="text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', padding: '32px 16px' }}>
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)' }}>
<div style={{ padding: '8px 16px 4px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em', textTransform: 'uppercase' }}>
Boards
</div>
{boards.map((r) => {
{boards.map((b) => {
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"
key={b.id}
onClick={() => go(b)}
className="w-full text-left flex items-center gap-3 action-btn"
style={{
padding: '10px 16px',
background: selected === i ? 'var(--surface-hover)' : 'transparent',
color: 'var(--text)',
transition: 'background 100ms ease-out',
fontSize: 'var(--text-sm)',
transition: 'background var(--duration-fast) ease-out',
}}
aria-label={"Go to board: " + b.title}
>
<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}
<BoardIcon name={b.title} iconName={b.iconName} iconColor={b.iconColor} size={24} />
<span className="flex-1 truncate">{b.title}</span>
{b.description && (
<span className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', maxWidth: 180 }}>
{b.description}
</span>
)}
</button>
)
})}
@@ -185,27 +238,50 @@ export default function CommandPalette() {
{posts.length > 0 && (
<div>
<div className="px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
<div style={{ padding: '8px 16px 4px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em', textTransform: 'uppercase' }}>
Posts
</div>
{posts.map((r) => {
{posts.map((p) => {
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"
key={p.id}
onClick={() => go(p)}
className="w-full text-left flex items-center gap-3 action-btn"
style={{
padding: '10px 16px',
background: selected === i ? 'var(--surface-hover)' : 'transparent',
color: 'var(--text)',
transition: 'background 100ms ease-out',
fontSize: 'var(--text-sm)',
transition: 'background var(--duration-fast) ease-out',
}}
aria-label={"Go to post: " + p.title}
>
<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}
<span
className="flex items-center justify-center shrink-0"
style={{
width: 24, height: 24,
borderRadius: 'var(--radius-sm)',
background: p.postType === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
color: p.postType === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
}}
>
{p.postType === 'BUG_REPORT'
? <IconBug size={13} stroke={2} />
: <IconBulb size={13} stroke={2} />
}
</span>
<div className="flex-1 min-w-0">
<span className="truncate block">{p.title}</span>
<span className="flex items-center gap-2" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', marginTop: 1 }}>
<Avatar userId={p.author?.id ?? '0000'} avatarUrl={p.author?.avatarUrl} size={12} />
{p.author?.displayName ?? `Anonymous #${(p.author?.id ?? '0000').slice(-4)}`}
<span style={{ opacity: 0.5 }}>in</span>
{p.boardName}
</span>
</div>
<StatusBadge status={p.status} />
</button>
)
})}
@@ -214,12 +290,12 @@ export default function CommandPalette() {
</div>
<div
className="px-4 py-2 flex items-center gap-4 text-[10px] border-t"
style={{ borderColor: 'var(--border)', color: 'var(--text-tertiary)' }}
className="flex items-center gap-4 border-t"
style={{ borderColor: 'var(--border)', padding: '8px 16px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
>
<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>
<span><kbd className="px-1 py-0.5" style={{ background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}>&#x2191;&#x2193;</kbd> navigate</span>
<span><kbd className="px-1 py-0.5" style={{ background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}>Enter</kbd> open</span>
<span><kbd className="px-1 py-0.5" style={{ background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}>Esc</kbd> close</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,253 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { IconChevronDown, IconCheck, IconSearch } from '@tabler/icons-react'
interface Option {
value: string
label: string
}
function fuzzyMatch(text: string, query: string): boolean {
const lower = text.toLowerCase()
const q = query.toLowerCase()
let qi = 0
for (let i = 0; i < lower.length && qi < q.length; i++) {
if (lower[i] === q[qi]) qi++
}
return qi === q.length
}
export default function Dropdown({
value,
options,
onChange,
placeholder = 'Select...',
searchable = false,
'aria-label': ariaLabel,
}: {
value: string
options: Option[]
onChange: (value: string) => void
placeholder?: string
searchable?: boolean
'aria-label'?: string
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(-1)
const ref = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const selected = options.find((o) => o.value === value)
const filtered = searchable && query
? options.filter((o) => fuzzyMatch(o.label, query))
: options
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (open && searchable) {
setQuery('')
setTimeout(() => searchRef.current?.focus(), 0)
}
if (open) {
const idx = filtered.findIndex((o) => o.value === value)
setActiveIndex(idx >= 0 ? idx : 0)
}
}, [open])
useEffect(() => {
if (!open || activeIndex < 0) return
const list = listRef.current
if (!list) return
const item = list.children[activeIndex] as HTMLElement | undefined
item?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, open])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setOpen(true)
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex((i) => Math.max(i - 1, 0))
break
case 'Enter':
case ' ':
if (!searchable || e.key === 'Enter') {
e.preventDefault()
if (activeIndex >= 0 && filtered[activeIndex]) {
onChange(filtered[activeIndex].value)
setOpen(false)
triggerRef.current?.focus()
}
}
break
case 'Escape':
e.preventDefault()
setOpen(false)
triggerRef.current?.focus()
break
case 'Home':
e.preventDefault()
setActiveIndex(0)
break
case 'End':
e.preventDefault()
setActiveIndex(filtered.length - 1)
break
}
}, [open, filtered, activeIndex, onChange, searchable])
const listboxId = 'dropdown-listbox'
const activeId = activeIndex >= 0 ? `dropdown-opt-${activeIndex}` : undefined
return (
<div ref={ref} className="relative" onKeyDown={handleKeyDown}>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between"
role="combobox"
aria-label={ariaLabel}
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={open ? listboxId : undefined}
aria-activedescendant={open ? activeId : undefined}
style={{
padding: '0.75rem 1rem',
background: 'var(--bg)',
border: open ? '1px solid var(--accent)' : '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: selected ? 'var(--text)' : 'var(--text-tertiary)',
fontSize: 'var(--text-sm)',
fontFamily: 'var(--font-body)',
transition: 'border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out',
boxShadow: open ? '0 0 0 3px var(--accent-subtle)' : 'none',
cursor: 'pointer',
textAlign: 'left',
}}
>
<span className="truncate">{selected?.label || placeholder}</span>
<IconChevronDown
size={14}
stroke={2}
aria-hidden="true"
style={{
color: 'var(--text-tertiary)',
transition: 'transform var(--duration-fast) ease-out',
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
flexShrink: 0,
}}
/>
</button>
{open && (
<div
className="absolute left-0 right-0 z-50 mt-1 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-lg)',
maxHeight: 300,
display: 'flex',
flexDirection: 'column',
}}
>
{searchable && (
<div
style={{
padding: '8px 8px 4px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
}}
>
<div className="flex items-center gap-2" style={{
padding: '6px 10px',
background: 'var(--bg)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
}}>
<IconSearch size={13} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} aria-hidden="true" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setActiveIndex(0) }}
placeholder="Search..."
aria-label="Search options"
style={{
background: 'transparent',
border: 'none',
outline: 'none',
color: 'var(--text)',
fontSize: 'var(--text-sm)',
width: '100%',
fontFamily: 'var(--font-body)',
}}
/>
</div>
</div>
)}
<div ref={listRef} role="listbox" id={listboxId} style={{ overflowY: 'auto', padding: '4px 0' }}>
{filtered.length === 0 && (
<div
className="px-3 py-2 text-center"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}
>
No matches
</div>
)}
{filtered.map((opt, i) => (
<button
key={opt.value}
id={`dropdown-opt-${i}`}
type="button"
role="option"
aria-selected={opt.value === value}
onClick={() => { onChange(opt.value); setOpen(false) }}
className="w-full flex items-center justify-between px-3 text-left"
style={{
fontSize: 'var(--text-sm)',
minHeight: 44,
color: opt.value === value ? 'var(--accent)' : 'var(--text)',
background: i === activeIndex
? 'var(--surface-hover)'
: opt.value === value
? 'var(--accent-subtle)'
: 'transparent',
transition: 'background var(--duration-fast) ease-out',
}}
onMouseEnter={() => setActiveIndex(i)}
onFocus={() => setActiveIndex(i)}
>
<span className="truncate">{opt.label}</span>
{opt.value === value && <IconCheck size={14} stroke={2.5} style={{ flexShrink: 0 }} aria-hidden="true" />}
</button>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,157 @@
import { IconX, IconHistory } from '@tabler/icons-react'
import { useFocusTrap } from '../hooks/useFocusTrap'
import Markdown from './Markdown'
interface EditEntry {
id: string
previousTitle?: string | null
previousDescription?: Record<string, string> | null
previousBody?: string | null
editedBy?: { id: string; displayName: string | null } | null
createdAt: string
}
interface Props {
open: boolean
onClose: () => void
entries: EditEntry[]
type: 'post' | 'comment'
isAdmin?: boolean
onRollback?: (editHistoryId: string) => void
}
export default function EditHistoryModal({ open, onClose, entries, type, isAdmin, onRollback }: Props) {
const trapRef = useFocusTrap(open)
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
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="edit-history-modal-title"
className="relative w-full mx-4 fade-in"
style={{
maxWidth: 560,
maxHeight: '80vh',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
display: 'flex',
flexDirection: 'column',
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
>
{/* Header */}
<div
className="flex items-center justify-between px-6 py-4"
style={{ borderBottom: '1px solid var(--border)' }}
>
<h2 id="edit-history-modal-title" className="font-semibold" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
Edit history
</h2>
<button
onClick={onClose}
className="flex items-center justify-center"
style={{ width: 44, height: 44, color: 'var(--text-tertiary)', background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}
aria-label="Close"
>
<IconX size={14} stroke={2} />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 overflow-y-auto" style={{ flex: 1 }}>
{entries.length === 0 ? (
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--text-tertiary)' }}>No edit history found.</p>
) : (
<div className="flex flex-col gap-4">
{entries.map((edit) => (
<div
key={edit.id}
className="p-4"
style={{
background: 'var(--bg)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
}}
>
<div className="flex items-center justify-between mb-3">
<time dateTime={edit.createdAt} style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
{new Date(edit.createdAt).toLocaleString()}
{edit.editedBy?.displayName && (
<> - {edit.editedBy.displayName}</>
)}
</time>
{isAdmin && onRollback && (
<button
onClick={() => onRollback(edit.id)}
className="inline-flex items-center gap-1 px-2.5 py-1"
style={{
fontSize: 'var(--text-xs)',
color: 'var(--accent)',
background: 'var(--accent-subtle)',
borderRadius: 'var(--radius-sm)',
transition: 'all var(--duration-fast) ease-out',
}}
>
<IconHistory size={11} stroke={2} aria-hidden="true" /> Restore
</button>
)}
</div>
{type === 'post' && (
<>
{edit.previousTitle && (
<div
className="font-semibold mb-2"
style={{ fontSize: 'var(--text-sm)', color: 'var(--text)' }}
>
{edit.previousTitle}
</div>
)}
{edit.previousDescription && (
<div className="flex flex-col gap-2">
{Object.entries(edit.previousDescription).map(([k, v]) => (
<div key={k}>
<span
className="font-medium"
style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', letterSpacing: '0.04em' }}
>
{k}
</span>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)', marginTop: 2 }}>
<Markdown>{typeof v === 'string' ? v : ''}</Markdown>
</div>
</div>
))}
</div>
)}
</>
)}
{type === 'comment' && edit.previousBody && (
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
<Markdown>{edit.previousBody}</Markdown>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,68 +1,54 @@
import { IconSpeakerphone, IconSearch, IconActivity, IconFileText } from '@tabler/icons-react'
import type { Icon } from '@tabler/icons-react'
interface Props {
title?: string
message?: string
actionLabel?: string
onAction?: () => void
icon?: Icon
}
export default function EmptyState({
title = 'Nothing here yet',
message = 'Be the first to share feedback',
actionLabel = 'Create a post',
message = 'Be the first to share your thoughts and ideas',
actionLabel = 'Share feedback',
onAction,
icon: CustomIcon,
}: 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>
const Icon = CustomIcon || IconSpeakerphone
<h3
className="text-lg font-semibold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
return (
<div className="flex flex-col items-center justify-center py-20 px-4 fade-in">
<div
className="w-24 h-24 rounded-full flex items-center justify-center mb-8"
style={{
background: 'var(--accent-subtle)',
boxShadow: 'var(--shadow-glow)',
}}
>
<Icon size={44} stroke={1.5} style={{ color: 'var(--accent)' }} />
</div>
<h2
className="font-bold mb-3"
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--text)',
fontSize: 'var(--text-xl)',
}}
>
{title}
</h3>
<p className="text-sm mb-6" style={{ color: 'var(--text-tertiary)' }}>
</h2>
<p
className="mb-8 text-center"
style={{
color: 'var(--text-secondary)',
fontSize: 'var(--text-base)',
maxWidth: 360,
lineHeight: 1.6,
}}
>
{message}
</p>
{onAction && (

View File

@@ -0,0 +1,196 @@
import { useState, useRef, useCallback } from 'react'
import { IconUpload, IconX, IconLoader2 } from '@tabler/icons-react'
interface Attachment {
id: string
filename: string
mimeType: string
size: number
}
interface Props {
attachmentIds: string[]
onChange: (ids: string[]) => void
}
const MAX_SIZE = 5 * 1024 * 1024
const ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'
export default function FileUpload({ attachmentIds, onChange }: Props) {
const [previews, setPreviews] = useState<Map<string, string>>(new Map())
const [filenames, setFilenames] = useState<Map<string, string>>(new Map())
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [dragOver, setDragOver] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const upload = useCallback(async (file: File) => {
if (file.size > MAX_SIZE) {
setError('File too large (max 5MB)')
return null
}
if (!file.type.match(/^image\/(jpeg|png|gif|webp)$/)) {
setError('Only jpg, png, gif, webp images are allowed')
return null
}
const form = new FormData()
form.append('file', file)
const res = await fetch('/api/v1/attachments', {
method: 'POST',
credentials: 'include',
body: form,
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as any).error || 'Upload failed')
}
return (await res.json()) as Attachment
}, [])
const handleFiles = useCallback(async (files: FileList | File[]) => {
setError('')
const toUpload = Array.from(files).slice(0, 10 - attachmentIds.length)
if (!toUpload.length) return
setUploading(true)
const newIds: string[] = []
const newPreviews = new Map(previews)
const newFilenames = new Map(filenames)
for (const file of toUpload) {
try {
const att = await upload(file)
if (att) {
newIds.push(att.id)
newPreviews.set(att.id, URL.createObjectURL(file))
newFilenames.set(att.id, att.filename)
}
} catch (err: any) {
setError(err.message || 'Upload failed')
}
}
setPreviews(newPreviews)
setFilenames(newFilenames)
onChange([...attachmentIds, ...newIds])
setUploading(false)
}, [attachmentIds, previews, onChange, upload])
const remove = useCallback(async (id: string) => {
await fetch(`/api/v1/attachments/${id}`, {
method: 'DELETE',
credentials: 'include',
}).catch(() => {})
const url = previews.get(id)
if (url) URL.revokeObjectURL(url)
const next = new Map(previews)
next.delete(id)
setPreviews(next)
const nextNames = new Map(filenames)
nextNames.delete(id)
setFilenames(nextNames)
onChange(attachmentIds.filter((a) => a !== id))
}, [attachmentIds, previews, filenames, onChange])
const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files)
}, [handleFiles])
return (
<div>
<div
role="button"
tabIndex={0}
aria-label="Upload files by clicking or dragging"
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); inputRef.current?.click() } }}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
style={{
border: `2px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
padding: '16px',
textAlign: 'center',
cursor: 'pointer',
background: dragOver ? 'var(--accent-subtle)' : 'transparent',
transition: 'all var(--duration-fast) ease-out',
}}
>
<input
ref={inputRef}
type="file"
accept={ACCEPT}
multiple
style={{ display: 'none' }}
onChange={(e) => { if (e.target.files) handleFiles(e.target.files); e.target.value = '' }}
/>
<div className="flex items-center justify-center gap-2" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{uploading ? (
<IconLoader2 size={16} stroke={2} className="animate-spin" />
) : (
<IconUpload size={16} stroke={2} />
)}
<span>{uploading ? 'Uploading...' : 'Drop images or click to upload'}</span>
</div>
</div>
{error && (
<div role="alert" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)', marginTop: 4 }}>{error}</div>
)}
{attachmentIds.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{attachmentIds.map((id) => (
<div
key={id}
className="relative group"
style={{
width: 72,
height: 72,
borderRadius: 'var(--radius-sm)',
overflow: 'hidden',
border: '1px solid var(--border)',
}}
>
<img
src={previews.get(id) || `/api/v1/attachments/${id}`}
alt={filenames.get(id) || 'Uploaded image'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<button
onClick={(e) => { e.stopPropagation(); remove(id) }}
className="absolute top-0.5 right-0.5 opacity-0 group-hover:opacity-100 focus:opacity-100"
style={{
background: 'rgba(0,0,0,0.6)',
color: '#fff',
borderRadius: '50%',
width: 44,
height: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'opacity var(--duration-fast) ease-out',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
aria-label="Remove attachment"
>
<IconX size={12} stroke={2.5} />
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,283 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import * as tablerIcons from '@tabler/icons-react'
import { IconSearch, IconX } from '@tabler/icons-react'
const ALL_ICON_NAMES: string[] = Object.keys(tablerIcons).filter(
(k) => k.startsWith('Icon') && !k.includes('Filled') && k !== 'Icon'
)
const PRESET_COLORS = [
'#F59E0B', '#EF4444', '#22C55E', '#3B82F6', '#8B5CF6',
'#EC4899', '#06B6D4', '#F97316', '#14B8A6', '#6366F1',
'#A855F7', '#64748B', '#E11D48', '#0EA5E9', '#84CC16',
'#D946EF', '#FB923C', '#2DD4BF', '#818CF8', '#F43F5E',
'#34D399',
]
const COLOR_NAMES: Record<string, string> = {
'#F59E0B': 'Amber', '#EF4444': 'Red', '#22C55E': 'Green', '#3B82F6': 'Blue', '#8B5CF6': 'Violet',
'#EC4899': 'Pink', '#06B6D4': 'Cyan', '#F97316': 'Orange', '#14B8A6': 'Teal', '#6366F1': 'Indigo',
'#A855F7': 'Purple', '#64748B': 'Slate', '#E11D48': 'Rose', '#0EA5E9': 'Sky', '#84CC16': 'Lime',
'#D946EF': 'Fuchsia', '#FB923C': 'Light orange', '#2DD4BF': 'Mint', '#818CF8': 'Periwinkle', '#F43F5E': 'Coral',
'#34D399': 'Emerald',
}
// common/popular icons shown first before search
const POPULAR = [
'IconHome', 'IconStar', 'IconHeart', 'IconMessage', 'IconBell',
'IconSettings', 'IconUser', 'IconFolder', 'IconTag', 'IconFlag',
'IconBulb', 'IconRocket', 'IconCode', 'IconBug', 'IconShield',
'IconLock', 'IconEye', 'IconMusic', 'IconPhoto', 'IconBook',
'IconCalendar', 'IconClock', 'IconMap', 'IconGlobe', 'IconCloud',
'IconBolt', 'IconFlame', 'IconDiamond', 'IconCrown', 'IconTrophy',
'IconPuzzle', 'IconBrush', 'IconPalette', 'IconWand', 'IconSparkles',
'IconTarget', 'IconCompass', 'IconAnchor', 'IconFeather', 'IconLeaf',
'IconDroplet', 'IconSun', 'IconMoon', 'IconZap', 'IconActivity',
'IconChartBar', 'IconDatabase', 'IconServer', 'IconCpu', 'IconWifi',
'IconBriefcase', 'IconGift', 'IconTruck', 'IconShoppingCart', 'IconCreditCard',
'IconMicrophone', 'IconHeadphones', 'IconCamera', 'IconVideo', 'IconGamepad',
]
function renderIcon(name: string, size: number, color?: string) {
const Comp = (tablerIcons as Record<string, any>)[name]
if (!Comp) return null
return <Comp size={size} stroke={1.5} style={color ? { color } : undefined} />
}
export { renderIcon }
function IconGrid({ names, value, currentColor, onPick }: { names: string[], value: string | null, currentColor: string, onPick: (name: string) => void }) {
return (
<div className="grid grid-cols-8 gap-0.5">
{names.map((name) => (
<button
key={name}
type="button"
onClick={() => onPick(name)}
title={name.replace('Icon', '')}
aria-label={name.replace('Icon', '')}
className="flex items-center justify-center icon-picker-btn"
style={{
width: 44,
height: 44,
color: name === value ? currentColor : 'var(--text-secondary)',
background: name === value ? 'var(--accent-subtle)' : 'transparent',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
{renderIcon(name, 18)}
</button>
))}
</div>
)
}
export default function IconPicker({
value,
color,
onChangeIcon,
onChangeColor,
}: {
value: string | null
color: string | null
onChangeIcon: (name: string | null) => void
onChangeColor: (color: string | null) => void
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [visibleCount, setVisibleCount] = useState(120)
const ref = useRef<HTMLDivElement>(null)
const gridRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open])
// reset scroll on search change
useEffect(() => {
setVisibleCount(120)
if (gridRef.current) gridRef.current.scrollTop = 0
}, [search])
const popular = useMemo(() => POPULAR.filter((n) => ALL_ICON_NAMES.includes(n)), [])
const allExceptPopular = useMemo(() => ALL_ICON_NAMES.filter((n) => !POPULAR.includes(n)), [])
const filtered = useMemo(() => {
if (!search.trim()) return null
const q = search.toLowerCase().replace(/\s+/g, '')
return ALL_ICON_NAMES.filter((name) =>
name.toLowerCase().replace('icon', '').includes(q)
)
}, [search])
const totalCount = filtered ? filtered.length : popular.length + allExceptPopular.length
const handleScroll = useCallback(() => {
const el = gridRef.current
if (!el) return
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
setVisibleCount((c) => Math.min(c + 120, totalCount))
}
}, [totalCount])
const currentColor = color || 'var(--accent)'
return (
<div ref={ref} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center gap-2"
style={{
padding: '8px 12px',
background: 'var(--bg)',
border: open ? '1px solid var(--accent)' : '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: 'var(--text)',
fontSize: 'var(--text-sm)',
transition: 'border-color var(--duration-normal) ease-out',
cursor: 'pointer',
flex: 1,
}}
>
{value ? (
<span style={{ color: currentColor }}>
{renderIcon(value, 18, currentColor)}
</span>
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Choose icon</span>
)}
<span className="truncate" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>
{value ? value.replace('Icon', '') : 'None'}
</span>
{value && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onChangeIcon(null); onChangeColor(null) }}
className="ml-auto"
style={{ color: 'var(--text-tertiary)', padding: 2 }}
aria-label="Clear icon"
>
<IconX size={12} stroke={2} />
</button>
)}
</button>
</div>
{open && (
<div
className="absolute left-0 z-50 mt-1 fade-in"
style={{
width: 320,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-xl)',
overflow: 'hidden',
}}
>
{/* Search */}
<div className="p-2" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="flex items-center gap-2 px-2" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
<IconSearch size={14} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search icons..."
aria-label="Search icons"
autoFocus
style={{
width: '100%',
padding: '8px 0',
background: 'transparent',
border: 'none',
outline: 'none',
color: 'var(--text)',
fontSize: 'var(--text-xs)',
fontFamily: 'var(--font-body)',
}}
/>
{search && (
<button type="button" onClick={() => setSearch('')} aria-label="Clear search" style={{ color: 'var(--text-tertiary)' }}>
<IconX size={12} stroke={2} />
</button>
)}
</div>
</div>
{/* Color picker */}
<div className="px-3 py-2 flex items-center gap-1.5 flex-wrap" style={{ borderBottom: '1px solid var(--border)' }}>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginRight: 4 }}>Color</span>
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => onChangeColor(c)}
className="rounded-full"
aria-label={COLOR_NAMES[c] || c}
style={{
width: 44,
height: 44,
background: c,
border: c === color ? '2px solid var(--text)' : '2px solid transparent',
borderRadius: '50%',
cursor: 'pointer',
transition: 'border-color var(--duration-fast) ease-out',
}}
/>
))}
</div>
{/* Icon grid */}
<div
ref={gridRef}
className="p-2"
style={{ maxHeight: 320, overflowY: 'auto' }}
onScroll={handleScroll}
>
{filtered && filtered.length === 0 ? (
<div className="py-6 text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
No icons found
</div>
) : filtered ? (
<>
<div className="mb-1 px-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{filtered.length} results
</div>
<IconGrid names={filtered.slice(0, visibleCount)} value={value} currentColor={currentColor} onPick={(n) => { onChangeIcon(n); setOpen(false) }} />
</>
) : (
<>
<div className="mb-1 px-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Popular
</div>
<IconGrid names={popular} value={value} currentColor={currentColor} onPick={(n) => { onChangeIcon(n); setOpen(false) }} />
<div className="my-2 mx-1" style={{ borderTop: '1px solid var(--border)' }} />
<div className="mb-1 px-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
All icons
</div>
<IconGrid names={allExceptPopular.slice(0, Math.max(0, visibleCount - popular.length))} value={value} currentColor={currentColor} onPick={(n) => { onChangeIcon(n); setOpen(false) }} />
</>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -1,73 +1,237 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { useFocusTrap } from '../hooks/useFocusTrap'
import { IconLock, IconFingerprint, IconShieldCheck } from '@tabler/icons-react'
const DISMISSED_KEY = 'echoboard-identity-ack'
const UPGRADE_DISMISSED_KEY = 'echoboard-upgrade-dismissed'
export default function IdentityBanner({ onRegister }: { onRegister: () => void }) {
const [visible, setVisible] = useState(false)
const [showMore, setShowMore] = useState(false)
const [upgradeNudge, setUpgradeNudge] = useState(false)
const auth = useAuth()
const trapRef = useFocusTrap(visible)
useEffect(() => {
if (!localStorage.getItem(DISMISSED_KEY)) {
const t = setTimeout(() => setVisible(true), 800)
const t = setTimeout(() => setVisible(true), 400)
return () => clearTimeout(t)
}
}, [])
if (!visible) return null
if (!auth.isPasskeyUser && !auth.user?.hasRecoveryCode && auth.isAuthenticated) {
const dismissed = localStorage.getItem(UPGRADE_DISMISSED_KEY)
if (dismissed) {
const ts = parseInt(dismissed, 10)
if (Date.now() - ts < 7 * 24 * 60 * 60 * 1000) return
}
import('../lib/api').then(({ api }) => {
api.get<{ length: number } & any[]>('/me/posts').then((posts) => {
if (Array.isArray(posts) && posts.length >= 3) {
setUpgradeNudge(true)
}
}).catch(() => {})
})
}
}, [auth.isPasskeyUser, auth.isAuthenticated, auth.user?.hasRecoveryCode])
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' }}
>
const dismissUpgrade = () => {
localStorage.setItem(UPGRADE_DISMISSED_KEY, String(Date.now()))
setUpgradeNudge(false)
}
// Upgrade nudge - subtle toast
if (upgradeNudge && !visible) {
return (
<div
className="mx-4 mb-4 p-5 rounded-xl shadow-2xl md:max-w-lg md:mx-auto"
className="fixed bottom-20 md:bottom-4 left-4 right-4 md:left-auto md:right-20 md:max-w-sm z-40 p-4 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
pointerEvents: 'auto',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-lg)',
}}
>
<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)' }}
<p className="mb-3" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
You have been active! If you clear your cookies, you'll lose access to your posts. Save your identity with a passkey, or grab a recovery code.
</p>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => { dismissUpgrade(); onRegister() }}
className="btn btn-primary flex items-center gap-1.5"
style={{ fontSize: 'var(--text-xs)' }}
>
<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)' }}
<IconFingerprint size={14} stroke={2} />
Save identity
</button>
<Link
to="/settings"
onClick={dismissUpgrade}
className="btn btn-secondary flex items-center gap-1.5"
style={{ fontSize: 'var(--text-xs)' }}
>
<IconShieldCheck size={14} stroke={2} />
Recovery code
</Link>
<button
onClick={dismissUpgrade}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-xs)' }}
>
Dismiss
</button>
</div>
</div>
)
}
if (!visible) return null
// First-visit modal (centered)
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div
className="absolute inset-0 fade-in"
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}
/>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="identity-banner-title"
className="relative w-full max-w-xl mx-4 overflow-y-auto fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
maxHeight: '85vh',
padding: '32px',
}}
onKeyDown={(e) => e.key === 'Escape' && dismiss()}
>
<div
className="w-12 h-12 flex items-center justify-center mb-5"
style={{
background: 'var(--accent-subtle)',
borderRadius: 'var(--radius-md)',
}}
>
<IconLock size={24} stroke={2} style={{ color: 'var(--accent)' }} />
</div>
<h2
id="identity-banner-title"
className="font-bold mb-5"
style={{
fontFamily: 'var(--font-heading)',
fontSize: 'var(--text-xl)',
color: 'var(--text)',
}}
>
Before you dive in
</h2>
<div className="mb-4">
<h3 className="font-semibold mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
How your identity works
</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Your identity on this board starts as a cookie in your browser. It contains a random token - nothing personal. This token ties your posts, votes, and comments to you so you can manage them.
</p>
</div>
<div
className="mb-4 p-4"
style={{
background: 'rgba(234, 179, 8, 0.08)',
border: '1px solid rgba(234, 179, 8, 0.25)',
borderRadius: 'var(--radius-md)',
}}
>
<h3 className="font-semibold mb-2" style={{ color: 'var(--warning)', fontSize: 'var(--text-sm)' }}>
Your cookie expires when you close the browser
</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Your identity is a session cookie. Close your browser, clear cookies, switch device - and you lose access to everything you created. Posts stay on the board but become uneditable by you.
</p>
</div>
<div className="mb-5">
<h3 className="font-semibold mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
Want persistent access?
</h3>
<p className="mb-2" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Pick a username and save a passkey - no email, no account, no personal data. Your passkey syncs across your devices through your platform's credential manager, so you can always get back to your posts.
</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', lineHeight: 1.5 }}>
Browser doesn't support passkeys? You can generate a recovery code instead - a phrase you save that lets you get back to your posts. Visit Settings at any time to set one up.
</p>
</div>
<div
style={{
display: 'grid',
gridTemplateRows: showMore ? '1fr' : '0fr',
transition: 'grid-template-rows var(--duration-slow) var(--ease-out)',
}}
>
<div style={{ overflow: 'hidden' }}>
<div
className="mb-5 p-4"
style={{
background: 'var(--bg)',
color: 'var(--text-secondary)',
fontSize: 'var(--text-sm)',
lineHeight: 1.6,
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
opacity: showMore ? 1 : 0,
transition: 'opacity var(--duration-normal) ease-out',
}}
>
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>
<p className="mb-2">
<strong style={{ color: 'var(--text)' }}>What is stored:</strong> A random token (SHA-256 hashed on the server). The cookie is session-only and does not persist after you close your browser. If you register, a username and passkey public key (both encrypted at rest).
</p>
<p className="mb-2">
<strong style={{ color: 'var(--text)' }}>What is never stored:</strong> Your email, IP address, browser fingerprint, or any personal information. This site sets exactly one cookie containing your random identifier. No tracking, no analytics.
</p>
<p>
<strong style={{ color: 'var(--text)' }}>Passkeys explained:</strong> Passkeys use the WebAuthn standard. Your device generates a key pair - the private key stays on your device (or syncs via iCloud Keychain, Google Password Manager, Windows Hello). The server only stores the public key.
</p>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={() => { dismiss(); onRegister() }}
className="btn btn-primary flex items-center justify-center gap-2"
>
<IconFingerprint size={16} stroke={2} />
Save my identity
</button>
<button onClick={dismiss} className="btn btn-secondary">
Continue anonymously
</button>
{!showMore && (
<button
onClick={() => setShowMore(true)}
className="btn btn-ghost"
>
Learn more
</button>
)}
</div>
</div>
</div>
)

View File

@@ -0,0 +1,36 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
export default function Markdown({ children }: { children: string }) {
return (
<div className="markdown-body">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
a: ({ href, children }) => {
const safe = href && /^https?:\/\//i.test(href)
return safe
? <a href={href} target="_blank" rel="noopener noreferrer">{children}<span className="sr-only"> (opens in new tab)</span></a>
: <span>{children}</span>
},
img: ({ src, alt }) => {
const safe = src && /^https?:\/\//i.test(src)
return safe
? <img src={src} alt={alt || ''} style={{ maxWidth: '100%', maxHeight: '400px', objectFit: 'contain' }} loading="lazy" referrerPolicy="no-referrer" />
: <span>{alt || ''}</span>
},
script: () => null,
iframe: () => null,
form: () => null,
input: () => null,
object: () => null,
embed: () => null,
style: () => null,
}}
>
{children}
</ReactMarkdown>
</div>
)
}

View File

@@ -0,0 +1,499 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import {
IconBold, IconItalic, IconStrikethrough, IconLink, IconCode,
IconList, IconListNumbers, IconQuote, IconPhoto, IconTable,
IconH1, IconH2, IconH3, IconMinus, IconCheckbox, IconSourceCode,
IconEye, IconPencil,
} from '@tabler/icons-react'
import Markdown from './Markdown'
import Avatar from './Avatar'
import { api } from '../lib/api'
interface MentionUser {
id: string
username: string
avatarUrl: string | null
}
interface Props {
value: string
onChange: (value: string) => void
placeholder?: string
rows?: number
autoFocus?: boolean
preview?: boolean
ariaRequired?: boolean
ariaLabel?: string
mentions?: boolean
}
type Action =
| { prefix: string; suffix: string }
| { linePrefix: string }
| { block: string }
interface ToolbarItem {
icon: typeof IconBold
title: string
action: Action
}
type ToolbarEntry = ToolbarItem | 'table'
type ToolbarGroup = ToolbarEntry[]
const toolbar: ToolbarGroup[] = [
[
{ icon: IconH1, title: 'Heading 1', action: { linePrefix: '# ' } },
{ icon: IconH2, title: 'Heading 2', action: { linePrefix: '## ' } },
{ icon: IconH3, title: 'Heading 3', action: { linePrefix: '### ' } },
],
[
{ icon: IconBold, title: 'Bold', action: { prefix: '**', suffix: '**' } },
{ icon: IconItalic, title: 'Italic', action: { prefix: '*', suffix: '*' } },
{ icon: IconStrikethrough, title: 'Strikethrough', action: { prefix: '~~', suffix: '~~' } },
],
[
{ icon: IconCode, title: 'Inline code', action: { prefix: '`', suffix: '`' } },
{ icon: IconSourceCode, title: 'Code block', action: { block: '```\n\n```' } },
{ icon: IconQuote, title: 'Blockquote', action: { linePrefix: '> ' } },
],
[
{ icon: IconList, title: 'Bullet list', action: { linePrefix: '- ' } },
{ icon: IconListNumbers, title: 'Numbered list', action: { linePrefix: '1. ' } },
{ icon: IconCheckbox, title: 'Task list', action: { linePrefix: '- [ ] ' } },
],
[
{ icon: IconLink, title: 'Link', action: { prefix: '[', suffix: '](url)' } },
{ icon: IconPhoto, title: 'Image', action: { prefix: '![', suffix: '](url)' } },
{ icon: IconMinus, title: 'Horizontal rule', action: { block: '---' } },
'table',
],
]
const GRID_COLS = 8
const GRID_ROWS = 6
const CELL = 44
const GAP = 1
function buildTable(cols: number, rows: number): string {
const header = '| ' + Array.from({ length: cols }, (_, i) => `Column ${i + 1}`).join(' | ') + ' |'
const sep = '| ' + Array.from({ length: cols }, () => '---').join(' | ') + ' |'
const dataRows = Array.from({ length: rows }, () =>
'| ' + Array.from({ length: cols }, () => ' ').join(' | ') + ' |'
)
return [header, sep, ...dataRows].join('\n')
}
function TablePicker({ onSelect, onClose }: { onSelect: (cols: number, rows: number) => void; onClose: () => void }) {
const [hover, setHover] = useState<[number, number]>([0, 0])
const popRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (popRef.current && !popRef.current.contains(e.target as Node)) onClose()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [onClose])
return (
<div
ref={popRef}
className="absolute z-50"
style={{
top: '100%',
left: 0,
marginTop: 4,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-lg)',
padding: 8,
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${GRID_COLS}, ${CELL}px)`,
gap: GAP,
}}
>
{Array.from({ length: GRID_ROWS * GRID_COLS }, (_, i) => {
const col = i % GRID_COLS
const row = Math.floor(i / GRID_COLS)
const active = col < hover[0] && row < hover[1]
return (
<button
key={i}
type="button"
aria-label={`${col + 1} by ${row + 1} table`}
onMouseEnter={() => setHover([col + 1, row + 1])}
onFocus={() => setHover([col + 1, row + 1])}
onClick={() => { onSelect(col + 1, row + 1); onClose() }}
style={{
width: CELL,
height: CELL,
borderRadius: 3,
border: active ? '1px solid var(--accent)' : '1px solid var(--border)',
background: active ? 'var(--accent-subtle)' : 'transparent',
cursor: 'pointer',
padding: 0,
transition: 'background 60ms ease-out, border-color 60ms ease-out',
}}
/>
)
})}
</div>
<div
className="text-center mt-1.5"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
>
{hover[0] > 0 && hover[1] > 0 ? `${hover[0]} x ${hover[1]}` : 'Select size'}
</div>
</div>
)
}
export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions }: Props) {
const ref = useRef<HTMLTextAreaElement>(null)
const [previewing, setPreviewing] = useState(false)
const [tablePicker, setTablePicker] = useState(false)
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([])
const [mentionActive, setMentionActive] = useState(0)
const mentionDebounce = useRef<ReturnType<typeof setTimeout>>()
const mentionDropdownRef = useRef<HTMLDivElement>(null)
const [mentionQuery, setMentionQuery] = useState('')
const getMentionQuery = useCallback((): string | null => {
const ta = ref.current
if (!ta || !enableMentions) return null
const pos = ta.selectionStart
const before = value.slice(0, pos)
const match = before.match(/@([a-zA-Z0-9_]{2,30})$/)
return match ? match[1] : null
}, [value, enableMentions])
useEffect(() => {
if (!enableMentions) return
const q = getMentionQuery()
if (!q || q.length < 2) {
setMentionUsers([])
setMentionQuery('')
return
}
if (q === mentionQuery) return
setMentionQuery(q)
if (mentionDebounce.current) clearTimeout(mentionDebounce.current)
mentionDebounce.current = setTimeout(() => {
api.get<{ users: MentionUser[] }>(`/users/search?q=${encodeURIComponent(q)}`)
.then((r) => { setMentionUsers(r.users); setMentionActive(0) })
.catch(() => setMentionUsers([]))
}, 200)
return () => { if (mentionDebounce.current) clearTimeout(mentionDebounce.current) }
}, [value, getMentionQuery, enableMentions])
const insertMention = (username: string) => {
const ta = ref.current
if (!ta) return
const pos = ta.selectionStart
const before = value.slice(0, pos)
const after = value.slice(pos)
const atIdx = before.lastIndexOf('@')
if (atIdx === -1) return
const newVal = before.slice(0, atIdx) + '@' + username + ' ' + after
onChange(newVal)
setMentionUsers([])
setMentionQuery('')
const newPos = atIdx + username.length + 2
requestAnimationFrame(() => { ta.focus(); ta.setSelectionRange(newPos, newPos) })
}
useEffect(() => {
if (!enableMentions) return
const handler = (e: MouseEvent) => {
if (mentionDropdownRef.current && !mentionDropdownRef.current.contains(e.target as Node)) {
setMentionUsers([])
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [enableMentions])
const insertBlock = (block: string) => {
const ta = ref.current
if (!ta) return
const start = ta.selectionStart
const end = ta.selectionEnd
const before = value.slice(0, start)
const after = value.slice(end)
const needsBefore = before.length > 0 && !before.endsWith('\n\n')
const needsAfter = after.length > 0 && !after.startsWith('\n')
const prefix = needsBefore ? (before.endsWith('\n') ? '\n' : '\n\n') : ''
const suffix = needsAfter ? '\n' : ''
const newValue = before + prefix + block + suffix + after
const firstNewline = block.indexOf('\n')
const blockStart = before.length + prefix.length
const cursorPos = firstNewline > -1 ? blockStart + firstNewline + 1 : blockStart + block.length
onChange(newValue)
requestAnimationFrame(() => {
ta.focus()
ta.setSelectionRange(cursorPos, cursorPos)
})
}
const apply = (action: Action) => {
const ta = ref.current
if (!ta) return
const start = ta.selectionStart
const end = ta.selectionEnd
const selected = value.slice(start, end)
let newValue: string
let cursorPos: number
if ('block' in action) {
insertBlock(action.block)
return
} else if ('linePrefix' in action) {
const lines = selected ? selected.split('\n') : ['']
const prefixed = lines.map((l) => action.linePrefix + l).join('\n')
newValue = value.slice(0, start) + prefixed + value.slice(end)
cursorPos = start + prefixed.length
} else {
const wrapped = action.prefix + (selected || 'text') + action.suffix
newValue = value.slice(0, start) + wrapped + value.slice(end)
if (selected) {
cursorPos = start + wrapped.length
} else {
cursorPos = start + action.prefix.length + 4
}
}
onChange(newValue)
requestAnimationFrame(() => {
ta.focus()
if (!selected && 'prefix' in action && 'suffix' in action) {
ta.setSelectionRange(start + action.prefix.length, start + action.prefix.length + 4)
} else {
ta.setSelectionRange(cursorPos, cursorPos)
}
})
}
const btnStyle = {
color: 'var(--text-tertiary)',
borderRadius: 'var(--radius-sm)',
transition: 'color var(--duration-fast) ease-out, background var(--duration-fast) ease-out',
}
const hover = (e: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement>, enter: boolean) => {
e.currentTarget.style.color = enter ? 'var(--text)' : 'var(--text-tertiary)'
e.currentTarget.style.background = enter ? 'var(--surface-hover)' : 'transparent'
}
return (
<div>
<div
className="flex items-center gap-0.5 mb-1.5 px-1 flex-wrap"
style={{ minHeight: 28 }}
>
{toolbar.map((group, gi) => (
<div key={gi} className="flex items-center gap-0.5">
{gi > 0 && (
<div
style={{
width: 1,
height: 16,
background: 'var(--border)',
margin: '0 4px',
flexShrink: 0,
}}
/>
)}
{group.map((entry) => {
if (entry === 'table') {
return (
<div key="table" className="relative">
<button
type="button"
title="Table"
aria-label="Table"
onClick={() => { setPreviewing(false); setTablePicker(!tablePicker) }}
className="w-11 h-11 flex items-center justify-center"
style={{
...btnStyle,
color: tablePicker ? 'var(--accent)' : 'var(--text-tertiary)',
}}
onMouseEnter={(e) => hover(e, true)}
onMouseLeave={(e) => {
e.currentTarget.style.color = tablePicker ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
onFocus={(e) => hover(e, true)}
onBlur={(e) => {
e.currentTarget.style.color = tablePicker ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
>
<IconTable size={14} stroke={2} />
</button>
{tablePicker && (
<TablePicker
onSelect={(cols, rows) => {
setPreviewing(false)
insertBlock(buildTable(cols, rows))
}}
onClose={() => setTablePicker(false)}
/>
)}
</div>
)
}
const { icon: Icon, title, action } = entry
return (
<button
key={title}
type="button"
title={title}
aria-label={title}
onClick={() => { setPreviewing(false); apply(action) }}
className="w-11 h-11 flex items-center justify-center"
style={btnStyle}
onMouseEnter={(e) => hover(e, true)}
onMouseLeave={(e) => hover(e, false)}
onFocus={(e) => hover(e, true)}
onBlur={(e) => hover(e, false)}
>
<Icon size={14} stroke={2} />
</button>
)
})}
</div>
))}
{enablePreview && (
<>
<div
style={{
width: 1,
height: 16,
background: 'var(--border)',
margin: '0 4px',
flexShrink: 0,
}}
/>
<button
type="button"
title={previewing ? 'Edit' : 'Preview'}
aria-label={previewing ? 'Edit' : 'Preview'}
onClick={() => setPreviewing(!previewing)}
className="w-11 h-11 flex items-center justify-center"
style={{
...btnStyle,
color: previewing ? 'var(--accent)' : 'var(--text-tertiary)',
}}
onMouseEnter={(e) => hover(e, true)}
onMouseLeave={(e) => {
e.currentTarget.style.color = previewing ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
onFocus={(e) => hover(e, true)}
onBlur={(e) => {
e.currentTarget.style.color = previewing ? 'var(--accent)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
>
{previewing ? <IconPencil size={14} stroke={2} /> : <IconEye size={14} stroke={2} />}
</button>
</>
)}
</div>
{previewing ? (
<div
className="input w-full"
style={{
minHeight: rows * 24 + 24,
padding: '12px 14px',
overflow: 'auto',
resize: 'vertical',
}}
>
{value.trim() ? (
<Markdown>{value}</Markdown>
) : (
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
Nothing to preview
</span>
)}
</div>
) : (
<div style={{ position: 'relative' }}>
<textarea
ref={ref}
className="input w-full"
placeholder={placeholder}
rows={rows}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={enableMentions && mentionUsers.length > 0 ? (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionActive((a) => (a + 1) % mentionUsers.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setMentionActive((a) => (a - 1 + mentionUsers.length) % mentionUsers.length) }
else if ((e.key === 'Enter' || e.key === 'Tab') && mentionUsers[mentionActive]) { e.preventDefault(); insertMention(mentionUsers[mentionActive].username) }
else if (e.key === 'Escape') { setMentionUsers([]); setMentionQuery('') }
} : undefined}
style={{ resize: 'vertical' }}
autoFocus={autoFocus}
aria-label={ariaLabel || 'Markdown content'}
aria-required={ariaRequired || undefined}
/>
{enableMentions && mentionUsers.length > 0 && (
<div
ref={mentionDropdownRef}
role="listbox"
aria-label="Mention suggestions"
style={{
position: 'absolute',
left: 0,
bottom: -4,
transform: 'translateY(100%)',
zIndex: 50,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
minWidth: 200,
maxHeight: 240,
overflow: 'auto',
}}
>
{mentionUsers.map((u, i) => (
<button
key={u.id}
role="option"
aria-selected={i === mentionActive}
onClick={() => insertMention(u.username)}
className="flex items-center gap-2 w-full px-3"
style={{
minHeight: 44,
background: i === mentionActive ? 'var(--surface-hover)' : 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-sm)',
color: 'var(--text)',
textAlign: 'left',
}}
onMouseEnter={() => setMentionActive(i)}
>
<Avatar userId={u.id} name={u.username} avatarUrl={u.avatarUrl} size={22} />
<span>@{u.username}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,164 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { api } from '../lib/api'
import Avatar from './Avatar'
interface MentionUser {
id: string
username: string
avatarUrl: string | null
}
interface Props {
value: string
onChange: (value: string) => void
placeholder?: string
rows?: number
ariaLabel?: string
}
export default function MentionInput({ value, onChange, placeholder, rows = 3, ariaLabel }: Props) {
const ref = useRef<HTMLTextAreaElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [users, setUsers] = useState<MentionUser[]>([])
const [active, setActive] = useState(0)
const [query, setQuery] = useState('')
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
const getMentionQuery = useCallback((): string | null => {
const ta = ref.current
if (!ta) return null
const pos = ta.selectionStart
const before = value.slice(0, pos)
const match = before.match(/@([a-zA-Z0-9_]{2,30})$/)
return match ? match[1] : null
}, [value])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value)
}
useEffect(() => {
const q = getMentionQuery()
if (!q || q.length < 2) {
setUsers([])
setQuery('')
return
}
if (q === query) return
setQuery(q)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
api.get<{ users: MentionUser[] }>(`/users/search?q=${encodeURIComponent(q)}`)
.then((r) => {
setUsers(r.users)
setActive(0)
})
.catch(() => setUsers([]))
}, 200)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [value, getMentionQuery])
const insertMention = (username: string) => {
const ta = ref.current
if (!ta) return
const pos = ta.selectionStart
const before = value.slice(0, pos)
const after = value.slice(pos)
const atIdx = before.lastIndexOf('@')
if (atIdx === -1) return
const newValue = before.slice(0, atIdx) + '@' + username + ' ' + after
onChange(newValue)
setUsers([])
setQuery('')
const newPos = atIdx + username.length + 2
requestAnimationFrame(() => {
ta.focus()
ta.setSelectionRange(newPos, newPos)
})
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (users.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((a) => (a + 1) % users.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((a) => (a - 1 + users.length) % users.length)
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (users[active]) {
e.preventDefault()
insertMention(users[active].username)
}
} else if (e.key === 'Escape') {
setUsers([])
setQuery('')
}
}
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setUsers([])
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
return (
<div style={{ position: 'relative' }}>
<textarea
ref={ref}
className="input w-full"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
style={{ resize: 'vertical' }}
aria-label={ariaLabel || 'Comment'}
/>
{users.length > 0 && (
<div
ref={dropdownRef}
style={{
position: 'absolute',
left: 0,
bottom: -4,
transform: 'translateY(100%)',
zIndex: 50,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
minWidth: 200,
maxHeight: 240,
overflow: 'auto',
}}
>
{users.map((u, i) => (
<button
key={u.id}
onClick={() => insertMention(u.username)}
className="flex items-center gap-2 w-full px-3"
style={{
minHeight: 44,
background: i === active ? 'var(--surface-hover)' : 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-sm)',
color: 'var(--text)',
textAlign: 'left',
}}
onMouseEnter={() => setActive(i)}
>
<Avatar userId={u.id} name={u.username} avatarUrl={u.avatarUrl} size={22} />
<span>@{u.username}</span>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -1,92 +1,63 @@
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>
),
},
]
import { Link, useLocation, useParams } from 'react-router-dom'
import { IconHome, IconSearch, IconPlus, IconBell, IconUser, IconShieldCheck } from '@tabler/icons-react'
import { useAdmin } from '../hooks/useAdmin'
export default function MobileNav() {
const location = useLocation()
const { boardSlug } = useParams()
const admin = useAdmin()
const isActive = (path: string) => location.pathname === path
const newPath = boardSlug ? `/b/${boardSlug}?new=1` : '/'
const tabs = [
{ path: '/', label: 'Home', icon: IconHome },
{ path: '/search', label: 'Search', icon: IconSearch },
{ path: newPath, label: 'New', icon: IconPlus, accent: true },
{ path: '/activity', label: 'Activity', icon: IconBell },
{ path: '/settings', label: admin.isAdmin ? 'Admin' : 'Profile', icon: admin.isAdmin ? IconShieldCheck : IconUser },
]
return (
<nav
className="fixed bottom-0 left-0 right-0 md:hidden flex items-center justify-around py-2 border-t z-50"
aria-label="Main navigation"
className="fixed bottom-0 left-0 right-0 md:hidden flex items-center justify-around border-t z-50"
style={{
background: 'var(--surface)',
borderColor: 'var(--border)',
paddingBottom: 'max(0.5rem, env(safe-area-inset-bottom))',
borderColor: admin.isAdmin ? 'rgba(6, 182, 212, 0.15)' : 'var(--border)',
padding: '6px 0',
paddingBottom: 'max(6px, env(safe-area-inset-bottom))',
boxShadow: '0 -1px 8px rgba(0,0,0,0.08)',
}}
>
{tabs.map((tab) => {
const active = location.pathname === tab.path ||
(tab.path === '/' && location.pathname === '/')
const active = isActive(tab.path)
const Icon = tab.icon
return (
<Link
key={tab.path}
key={tab.label}
to={tab.path}
className="flex flex-col items-center gap-0.5 px-3 py-1"
style={{ transition: 'color 200ms ease-out' }}
className="flex flex-col items-center gap-0.5 px-3 py-1 nav-link"
style={{ transition: 'color var(--duration-fast) ease-out', borderRadius: 'var(--radius-sm)' }}
aria-current={active ? 'page' : undefined}
>
{tab.accent ? (
<div
className="w-10 h-10 rounded-full flex items-center justify-center -mt-4"
style={{ background: 'var(--accent)', color: '#141420' }}
className="w-11 h-11 rounded-full flex items-center justify-center -mt-4"
style={{
background: 'var(--accent)',
color: 'var(--bg)',
boxShadow: 'var(--shadow-glow)',
}}
>
{tab.icon}
<Icon size={22} stroke={2.5} />
</div>
) : (
<div style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}>
{tab.icon}
<Icon size={22} stroke={2} />
</div>
)}
<span
className="text-[10px]"
style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}
>
<span style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{tab.label}
</span>
</Link>

View File

@@ -0,0 +1,138 @@
import { useRef, useCallback, useEffect } from 'react'
import { IconMinus, IconPlus } from '@tabler/icons-react'
function SpinButton({
direction,
disabled,
onTick,
}: {
direction: 'down' | 'up'
disabled: boolean
onTick: () => void
}) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const tickRef = useRef(onTick)
tickRef.current = onTick
const stopRepeat = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current)
if (intervalRef.current) clearInterval(intervalRef.current)
timerRef.current = null
intervalRef.current = null
}, [])
useEffect(() => stopRepeat, [stopRepeat])
const startRepeat = useCallback(() => {
if (disabled) return
tickRef.current()
timerRef.current = setTimeout(() => {
intervalRef.current = setInterval(() => tickRef.current(), 80)
}, 400)
}, [disabled])
const Icon = direction === 'down' ? IconMinus : IconPlus
return (
<button
type="button"
disabled={disabled}
onMouseDown={startRepeat}
onMouseUp={stopRepeat}
onMouseLeave={stopRepeat}
onTouchStart={startRepeat}
onTouchEnd={stopRepeat}
aria-label={direction === 'down' ? 'Decrease' : 'Increase'}
className="number-input-btn"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
alignSelf: 'stretch',
background: 'transparent',
border: 'none',
borderRight: direction === 'down' ? '1px solid var(--border)' : 'none',
borderLeft: direction === 'up' ? '1px solid var(--border)' : 'none',
color: disabled ? 'var(--text-tertiary)' : 'var(--text-secondary)',
cursor: disabled ? 'default' : 'pointer',
padding: 0,
flexShrink: 0,
}}
>
<Icon size={13} stroke={2} />
</button>
)
}
export default function NumberInput({
value,
onChange,
min = 1,
max = 999,
step = 1,
style,
'aria-label': ariaLabel,
}: {
value: number
onChange: (value: number) => void
min?: number
max?: number
step?: number
style?: React.CSSProperties
'aria-label'?: string
}) {
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback((n: number) => Math.max(min, Math.min(max, n)), [min, max])
const tickDown = useCallback(() => {
onChange(clamp(valueRef.current - step))
}, [onChange, clamp, step])
const tickUp = useCallback(() => {
onChange(clamp(valueRef.current + step))
}, [onChange, clamp, step])
return (
<div
className="number-input-wrapper"
style={{
display: 'flex',
alignItems: 'stretch',
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
...style,
}}
>
<SpinButton direction="down" disabled={value <= min} onTick={tickDown} />
<input
type="text"
inputMode="numeric"
value={value}
aria-label={ariaLabel}
onChange={(e) => {
const n = parseInt(e.target.value)
if (!isNaN(n)) onChange(clamp(n))
}}
style={{
flex: 1,
minWidth: 0,
padding: '8px 4px',
background: 'transparent',
border: 'none',
outline: 'none',
color: 'var(--text)',
fontSize: 'var(--text-sm)',
fontFamily: 'var(--font-body)',
textAlign: 'center',
}}
/>
<SpinButton direction="up" disabled={value >= max} onTick={tickUp} />
</div>
)
}

View File

@@ -1,7 +1,10 @@
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import { api } from '../lib/api'
import { useAuth } from '../hooks/useAuth'
import { useFocusTrap } from '../hooks/useFocusTrap'
import { IconX, IconCheck, IconAlertTriangle, IconFingerprint } from '@tabler/icons-react'
interface Props {
mode: 'register' | 'login'
@@ -11,25 +14,48 @@ interface Props {
export default function PasskeyModal({ mode, open, onClose }: Props) {
const auth = useAuth()
const trapRef = useFocusTrap(open)
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 [success, setSuccess] = useState(false)
const [browserSupport, setBrowserSupport] = useState<'checking' | 'full' | 'basic' | 'none'>('checking')
const inputRef = useRef<HTMLInputElement>(null)
const checkTimer = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
async function check() {
if (!window.PublicKeyCredential) {
setBrowserSupport('none')
return
}
try {
const platform = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
setBrowserSupport(platform ? 'full' : 'basic')
} catch {
setBrowserSupport('basic')
}
}
check()
}, [])
useEffect(() => {
if (open) {
setUsername('')
const prefill = mode === 'register' && auth.displayName && auth.displayName !== 'Anonymous'
? auth.displayName
: ''
setUsername(prefill)
setAvailable(null)
setError('')
setSuccess(false)
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [open])
useEffect(() => {
if (mode !== 'register' || !username.trim() || username.length < 3) {
if (!username.trim() || username.length < 3) {
setAvailable(null)
return
}
@@ -37,16 +63,16 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
checkTimer.current = setTimeout(async () => {
setChecking(true)
try {
const res = await api.get<{ available: boolean }>(`/identity/check-username?name=${encodeURIComponent(username)}`)
const res = await api.get<{ available: boolean }>(`/auth/passkey/check-username/${encodeURIComponent(username)}`)
setAvailable(res.available)
} catch {
setAvailable(null)
} finally {
setChecking(false)
}
}, 400)
}, 300)
return () => clearTimeout(checkTimer.current)
}, [username, mode])
}, [username])
const handleRegister = async () => {
if (!username.trim()) {
@@ -59,9 +85,10 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
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 api.post('/auth/passkey/register/verify', { username, response: attestation })
setSuccess(true)
await auth.refresh()
onClose()
setTimeout(onClose, 2000)
} catch (e: any) {
setError(e?.message || 'Registration failed. Please try again.')
} finally {
@@ -74,11 +101,12 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
setError('')
try {
const opts = await api.post<any>('/auth/passkey/login/options')
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 api.post('/auth/passkey/login/verify', { response: assertion })
setSuccess(true)
await auth.refresh()
onClose()
setTimeout(onClose, 2000)
} catch (e: any) {
setError(e?.message || 'Authentication failed. Please try again.')
} finally {
@@ -98,87 +126,194 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
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)' }}
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="passkey-modal-title"
className="relative w-full max-w-sm mx-4 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
padding: '24px',
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
>
<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.
{success ? (
<div className="flex flex-col items-center py-6 fade-in">
<div
className="w-16 h-16 flex items-center justify-center mb-4"
style={{ background: 'rgba(34, 197, 94, 0.12)', borderRadius: 'var(--radius-lg)' }}
>
<IconCheck size={32} stroke={2.5} color="var(--success)" />
</div>
<p className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>
{mode === 'register' ? 'Identity saved' : 'Signed in'}
</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>
) : (
<>
<div className="flex items-center justify-between mb-5">
<h2
id="passkey-modal-title"
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-lg)' }}
>
{mode === 'register' ? 'Save my identity' : 'Sign in with passkey'}
</h2>
<button
onClick={onClose}
className="w-11 h-11 flex items-center justify-center"
style={{
color: 'var(--text-tertiary)',
borderRadius: 'var(--radius-sm)',
transition: 'color var(--duration-fast) ease-out',
}}
aria-label="Close"
>
<IconX size={18} stroke={2} />
</button>
</div>
{mode === 'register' ? (
<>
{/* Passkey explainer */}
<div
className="mb-4 p-3 flex gap-3"
style={{
background: 'var(--bg)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
color: 'var(--text-secondary)',
lineHeight: 1.5,
}}
>
<IconFingerprint size={20} stroke={1.5} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 1 }} />
<div>
<p style={{ fontWeight: 600, color: 'var(--text)', marginBottom: 4 }}>What's a passkey?</p>
<p>
A passkey is a modern replacement for passwords. It uses your device's built-in security
(fingerprint, face, PIN, or security key) to prove it's you - no password to remember or type.
Your passkey stays on your device and is never sent to our server.
</p>
</div>
</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>
{browserSupport === 'none' && (
<div
className="mb-4 p-3 flex items-start gap-3"
style={{
background: 'rgba(239, 68, 68, 0.06)',
borderRadius: 'var(--radius-md)',
border: '1px solid rgba(239, 68, 68, 0.15)',
fontSize: 'var(--text-xs)',
color: 'var(--text-secondary)',
lineHeight: 1.5,
}}
>
<IconAlertTriangle size={16} stroke={2} style={{ color: 'var(--error)', flexShrink: 0, marginTop: 1 }} />
<div>
<p>Your browser doesn't support passkeys. Try a recent version of Chrome, Safari, Firefox, or Edge.</p>
<p className="mt-1">
You can still protect your identity with a{' '}
<Link
to="/settings"
onClick={onClose}
style={{ color: 'var(--accent)', textDecoration: 'underline', textUnderlineOffset: '2px' }}
>
recovery code
</Link>
.
</p>
</div>
</div>
)}
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Pick a username to save your identity. No email, no account - just a name so you can get back to your posts.
</p>
<div className="relative mb-4">
<input
ref={inputRef}
className="input pr-8"
placeholder="Username"
aria-label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
maxLength={30}
autoComplete="username"
aria-describedby={error ? 'passkey-error' : undefined}
/>
{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 ? (
<IconCheck size={16} stroke={2.5} color="var(--success)" />
) : (
<IconX size={16} stroke={2.5} color="var(--error)" />
)}
</div>
)}
</div>
{!checking && available === false && (
<p role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>This username is taken. Try adding numbers or pick a different name.</p>
)}
</>
) : (
<>
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Use your registered passkey to sign in and restore your identity.
</p>
<div className="mb-4">
<input
ref={inputRef}
className="input"
placeholder="Username (optional - helps find your passkey)"
aria-label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
aria-describedby={error ? 'passkey-error' : undefined}
/>
</div>
</>
)}
{error && <p id="passkey-error" role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{error}</p>}
<button
onClick={mode === 'register' ? handleRegister : handleLogin}
disabled={loading || browserSupport === 'none' || (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' ? (
'Save my identity'
) : (
'Sign in with passkey'
)}
</div>
{!checking && available === false && (
<p className="text-xs mb-3" style={{ color: 'var(--error)' }}>This name is taken</p>
</button>
{mode === 'register' && (
<p className="mt-4 text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Your passkey is stored on your device. No passwords involved.
</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,51 @@
import { useState, useEffect } from 'react'
import DOMPurify from 'dompurify'
import { api } from '../lib/api'
interface PluginInfo {
name: string
version: string
components?: Record<string, string>
}
let pluginCache: PluginInfo[] | null = null
let pluginPromise: Promise<PluginInfo[]> | null = null
function fetchPlugins(): Promise<PluginInfo[]> {
if (pluginCache) return Promise.resolve(pluginCache)
if (!pluginPromise) {
pluginPromise = api.get<PluginInfo[]>('/plugins/active')
.then((data) => { pluginCache = data; return data })
.catch(() => { pluginCache = []; return [] })
}
return pluginPromise
}
export default function PluginSlot({ name }: { name: string }) {
const [html, setHtml] = useState<string[]>([])
useEffect(() => {
fetchPlugins().then((plugins) => {
const parts: string[] = []
for (const p of plugins) {
if (p.components?.[name]) parts.push(p.components[name])
}
if (parts.length > 0) setHtml(parts)
})
}, [name])
if (html.length === 0) return null
return (
<>
{html.map((h, i) => (
<div key={i} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(h, {
ALLOWED_TAGS: ['div', 'span', 'p', 'a', 'b', 'i', 'em', 'strong', 'ul', 'ol', 'li', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'img', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt', 'class'],
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
}) }} />
))}
</>
)
}

View File

@@ -1,101 +1,367 @@
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import StatusBadge from './StatusBadge'
import type { StatusConfig } from './StatusBadge'
import { IconChevronUp, IconMessageCircle, IconBug, IconBulb, IconPin, IconClock, IconEye } from '@tabler/icons-react'
import Avatar from './Avatar'
interface Post {
id: string
title: string
excerpt?: string
type: 'feature' | 'bug' | 'general'
description?: Record<string, string>
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
status: string
statusReason?: string | null
category?: string | null
voteCount: number
commentCount: number
authorName: string
viewCount?: number
isPinned?: boolean
isStale?: boolean
onBehalfOf?: string | null
author?: { id: string; displayName: string; avatarUrl?: string | null } | null
createdAt: string
boardSlug: string
hasVoted?: boolean
voted?: boolean
voteWeight?: number
}
function descriptionExcerpt(desc?: Record<string, string>, max = 120): string {
if (!desc) return ''
const text = Object.values(desc).join(' ').replace(/\s+/g, ' ').trim()
return text.length > max ? text.slice(0, max) + '...' : text
}
const IMPORTANCE_OPTIONS = [
{ value: 'critical', label: 'Critical', color: '#ef4444' },
{ value: 'important', label: 'Important', color: '#f59e0b' },
{ value: 'nice_to_have', label: 'Nice to have', color: '#3b82f6' },
{ value: 'minor', label: 'Minor', color: '#9ca3af' },
] as const
export default function PostCard({
post,
onVote,
onUnvote,
onImportance,
showImportancePopup,
budgetDepleted,
customStatuses,
index = 0,
}: {
post: Post
onVote?: (id: string) => void
onUnvote?: (id: string) => void
onImportance?: (id: string, importance: string) => void
showImportancePopup?: boolean
budgetDepleted?: boolean
customStatuses?: StatusConfig[]
index?: number
}) {
const [voteAnimating, setVoteAnimating] = useState(false)
const [popupVisible, setPopupVisible] = useState(false)
const popupTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const cardRef = useRef<HTMLDivElement>(null)
const timeAgo = formatTimeAgo(post.createdAt)
const typeLabel = post.type === 'BUG_REPORT' ? 'Bug' : 'Feature'
useEffect(() => {
if (showImportancePopup) {
setPopupVisible(true)
popupTimer.current = setTimeout(() => setPopupVisible(false), 5000)
return () => {
if (popupTimer.current) clearTimeout(popupTimer.current)
}
} else {
setPopupVisible(false)
}
}, [showImportancePopup])
const pickImportance = (val: string) => {
if (popupTimer.current) clearTimeout(popupTimer.current)
setPopupVisible(false)
onImportance?.(post.id, val)
}
const handleVoteClick = (e: React.MouseEvent) => {
e.preventDefault()
if (post.voted) {
onUnvote?.(post.id)
} else {
setVoteAnimating(true)
setTimeout(() => setVoteAnimating(false), 400)
onVote?.(post.id)
}
}
return (
<div
className="card flex gap-0 overflow-hidden"
style={{ transition: 'border-color 200ms ease-out' }}
ref={cardRef}
className="card card-interactive flex gap-0 overflow-hidden stagger-in"
style={{ '--stagger': index, position: 'relative' } as React.CSSProperties}
>
{/* Vote column */}
{/* Vote column - desktop */}
<button
onClick={(e) => { e.preventDefault(); onVote?.(post.id) }}
className="flex flex-col items-center justify-center px-3 py-4 shrink-0 gap-1"
className="hidden md:flex flex-col items-center justify-center shrink-0 gap-1 action-btn"
onClick={handleVoteClick}
style={{
width: 48,
background: post.hasVoted ? 'var(--accent-subtle)' : 'transparent',
color: post.hasVoted ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'all 200ms ease-out',
width: 56,
padding: '20px 12px',
background: post.voted ? 'var(--accent-subtle)' : 'transparent',
borderRight: '1px solid var(--border)',
border: 'none',
borderRadius: 0,
cursor: 'pointer',
transition: 'background var(--duration-normal) ease-out',
}}
title={post.voted ? 'Click to remove your vote' : 'Click to vote'}
aria-label="Vote"
>
<IconChevronUp
size={18}
stroke={2.5}
className={voteAnimating ? 'vote-bounce' : ''}
style={{
color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'color var(--duration-fast) ease-out',
}}
/>
<span
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
style={{
color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)',
fontSize: 'var(--text-sm)',
}}
aria-live="polite"
aria-atomic="true"
>
{post.voteCount}
</span>
{budgetDepleted && !post.voted && (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', textAlign: 'center', lineHeight: 1.2, marginTop: 2 }}>
0 left
</span>
)}
</button>
{/* Mobile vote strip */}
<div
className="flex md:hidden items-center gap-2 px-4 py-2"
style={{
background: post.voted ? 'var(--accent-subtle)' : 'transparent',
borderBottom: '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>
<button
onClick={handleVoteClick}
className="flex items-center gap-2"
style={{ color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)', cursor: 'pointer', background: 'none', border: 'none' }}
aria-label="Vote"
>
<IconChevronUp size={14} stroke={2.5} className={voteAnimating ? 'vote-bounce' : ''} />
<span className="font-semibold" style={{ fontSize: 'var(--text-xs)' }} aria-live="polite" aria-atomic="true">{post.voteCount}</span>
</button>
<StatusBadge status={post.status} customStatuses={customStatuses} />
<div className="flex items-center gap-1 ml-auto" style={{ color: 'var(--text-tertiary)' }}>
<IconMessageCircle size={12} stroke={2} />
<span style={{ fontSize: 'var(--text-xs)' }}>{post.commentCount}</span>
</div>
</div>
{/* Content zone */}
<Link
to={`/b/${post.boardSlug}/post/${post.id}`}
className="flex-1 py-3 px-4 min-w-0"
className="flex-1 min-w-0"
style={{ padding: '16px 20px' }}
>
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span
className="text-xs px-1.5 py-0.5 rounded"
className="inline-flex items-center gap-1 px-2 py-0.5"
style={{
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
background: post.type === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
color: post.type === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
}}
>
{post.type}
{post.type === 'BUG_REPORT' ? <IconBug size={11} stroke={2} aria-hidden="true" /> : <IconBulb size={11} stroke={2} aria-hidden="true" />}
{typeLabel}
</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{post.authorName} - {timeAgo}
{post.category && (
<span
className="px-2 py-0.5"
style={{
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface-hover)',
color: 'var(--text-secondary)',
}}
>
{post.category}
</span>
)}
{post.isPinned && (
<IconPin size={12} stroke={2} style={{ color: 'var(--accent)' }} />
)}
{post.isStale && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5"
style={{
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
background: 'rgba(245, 158, 11, 0.1)',
color: 'rgb(245, 158, 11)',
}}
title="No recent activity"
>
<IconClock size={10} stroke={2} aria-hidden="true" />
Stale
<span className="sr-only"> - no recent activity</span>
</span>
)}
<span className="inline-flex items-center gap-1.5" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
<Avatar
userId={post.author?.id ?? '0000'}
name={post.onBehalfOf ?? post.author?.displayName ?? null}
avatarUrl={post.author?.avatarUrl}
size={18}
/>
{post.onBehalfOf
? <>on behalf of {post.onBehalfOf}</>
: (post.author?.displayName ?? `Anonymous #${(post.author?.id ?? '0000').slice(-4)}`)
} - <time dateTime={post.createdAt}>{timeAgo}</time>
</span>
</div>
<h3
className="text-sm font-medium mb-1 truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
<h2
className="font-medium mb-1 truncate"
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--text)',
fontSize: 'var(--text-lg)',
}}
>
{post.title}
</h3>
{post.excerpt && (
</h2>
{post.statusReason && (
<p
className="text-xs line-clamp-2"
style={{ color: 'var(--text-secondary)' }}
className="truncate mb-1"
style={{
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
}}
>
{post.excerpt}
Reason: {post.statusReason}
</p>
)}
{post.description && (
<p
className="line-clamp-2"
style={{
color: 'var(--text-secondary)',
lineHeight: 1.6,
fontSize: 'var(--text-sm)',
}}
>
{descriptionExcerpt(post.description)}
</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>
{/* Status + comments - desktop */}
<div className="hidden md:flex flex-col items-end justify-center px-5 py-4 shrink-0 gap-2">
<StatusBadge status={post.status} customStatuses={customStatuses} />
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)' }}>
<IconMessageCircle size={14} stroke={2} aria-hidden="true" />
<span style={{ fontSize: 'var(--text-xs)' }}>{post.commentCount}</span>
<span className="sr-only">comments</span>
</div>
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)' }}>
<IconEye size={14} stroke={2} aria-hidden="true" />
<span style={{ fontSize: 'var(--text-xs)' }}>{post.viewCount ?? 0}</span>
<span className="sr-only">views</span>
</div>
</div>
{/* Importance popup */}
{popupVisible && (
<div
role="menu"
aria-label="Rate importance"
onKeyDown={(e) => { if (e.key === 'Escape') setPopupVisible(false) }}
style={{
position: 'absolute',
left: 4,
bottom: -4,
transform: 'translateY(100%)',
zIndex: 50,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
padding: '8px 10px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
display: 'flex',
flexDirection: 'column',
gap: 2,
minWidth: 150,
}}
onClick={(e) => e.preventDefault()}
>
<span
style={{
fontSize: 'var(--text-xs)',
color: 'var(--text-tertiary)',
marginBottom: 4,
fontWeight: 500,
}}
>
How important is this?
</span>
{IMPORTANCE_OPTIONS.map((opt) => (
<button
key={opt.value}
role="menuitem"
onClick={(e) => { e.preventDefault(); pickImportance(opt.value) }}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
minHeight: 44,
borderRadius: 'var(--radius-sm)',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: 'var(--text-xs)',
color: 'var(--text-secondary)',
textAlign: 'left',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--surface-hover)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
onFocus={(e) => { e.currentTarget.style.background = 'var(--surface-hover)' }}
onBlur={(e) => { e.currentTarget.style.background = 'transparent' }}
>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: opt.color,
flexShrink: 0,
}}
/>
{opt.label}
</button>
))}
</div>
)}
</div>
)
}
export { IMPORTANCE_OPTIONS }
function formatTimeAgo(date: string): string {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
if (seconds < 60) return 'just now'

View File

@@ -1,174 +1,495 @@
import { useState, useRef } from 'react'
import { useState, useEffect } from 'react'
import { api } from '../lib/api'
import { useAuth } from '../hooks/useAuth'
import { solveAltcha } from '../lib/altcha'
import { IconFingerprint } from '@tabler/icons-react'
import Dropdown from './Dropdown'
import MarkdownEditor from './MarkdownEditor'
import FileUpload from './FileUpload'
interface SimilarPost {
id: string
title: string
status: string
voteCount: number
similarity: number
}
interface TemplateField {
key: string
label: string
type: 'text' | 'textarea' | 'select'
required: boolean
placeholder?: string
options?: string[]
}
interface Template {
id: string
name: string
fields: TemplateField[]
isDefault: boolean
}
interface Props {
boardSlug: string
boardId?: string
onSubmit?: () => void
onCancel?: () => void
}
type PostType = 'feature' | 'bug' | 'general'
type PostType = 'FEATURE_REQUEST' | 'BUG_REPORT'
export default function PostForm({ boardSlug, onSubmit }: Props) {
const [expanded, setExpanded] = useState(false)
const [type, setType] = useState<PostType>('feature')
interface FieldErrors {
[key: string]: string
}
export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Props) {
const auth = useAuth()
const [type, setType] = useState<PostType>('FEATURE_REQUEST')
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [expected, setExpected] = useState('')
const [actual, setActual] = useState('')
const [steps, setSteps] = useState('')
const [category, setCategory] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const formRef = useRef<HTMLDivElement>(null)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
const [similar, setSimilar] = useState<SimilarPost[]>([])
// templates
const [templates, setTemplates] = useState<Template[]>([])
const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [templateValues, setTemplateValues] = useState<Record<string, string>>({})
useEffect(() => {
api.get<{ id: string; name: string; slug: string }[]>('/categories')
.then(setCategories)
.catch(() => {})
}, [])
// fetch templates for this board
useEffect(() => {
if (!boardSlug) return
api.get<{ templates: Template[] }>(`/boards/${boardSlug}/templates`)
.then((r) => {
setTemplates(r.templates)
const def = r.templates.find((t) => t.isDefault)
if (def) setSelectedTemplateId(def.id)
})
.catch(() => {})
}, [boardSlug])
// debounced duplicate detection
useEffect(() => {
if (!boardId || title.trim().length < 5) { setSimilar([]); return }
const t = setTimeout(() => {
api.get<{ posts: SimilarPost[] }>(`/similar?title=${encodeURIComponent(title)}&boardId=${encodeURIComponent(boardId)}`)
.then((r) => setSimilar(r.posts))
.catch(() => setSimilar([]))
}, 400)
return () => clearTimeout(t)
}, [title, boardId])
// bug report fields
const [steps, setSteps] = useState('')
const [expected, setExpected] = useState('')
const [actual, setActual] = useState('')
const [environment, setEnvironment] = useState('')
const [bugContext, setBugContext] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
// feature request fields
const [useCase, setUseCase] = useState('')
const [proposedSolution, setProposedSolution] = useState('')
const [alternatives, setAlternatives] = useState('')
const [featureContext, setFeatureContext] = useState('')
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) || null
const reset = () => {
setTitle('')
setBody('')
setCategory('')
setSteps('')
setExpected('')
setActual('')
setSteps('')
setEnvironment('')
setBugContext('')
setUseCase('')
setProposedSolution('')
setAlternatives('')
setFeatureContext('')
setAttachmentIds([])
setTemplateValues({})
setError('')
setExpanded(false)
setFieldErrors({})
}
const validate = (): boolean => {
const errors: FieldErrors = {}
if (title.trim().length < 5) {
errors.title = 'Title must be at least 5 characters'
}
if (selectedTemplate) {
for (const f of selectedTemplate.fields) {
if (f.required && !templateValues[f.key]?.trim()) {
errors[f.key] = `${f.label} is required`
}
}
} else if (type === 'BUG_REPORT') {
if (!steps.trim()) errors.steps = 'Steps to reproduce are required'
if (!expected.trim()) errors.expected = 'Expected behavior is required'
if (!actual.trim()) errors.actual = 'Actual behavior is required'
} else {
if (!useCase.trim()) errors.useCase = 'Use case is required'
}
setFieldErrors(errors)
return Object.keys(errors).length === 0
}
const submit = async () => {
if (!title.trim()) {
setError('Title is required')
return
}
if (!validate()) return
setSubmitting(true)
setError('')
const payload: Record<string, string> = { title, type, body }
if (type === 'bug') {
payload.stepsToReproduce = steps
payload.expected = expected
payload.actual = actual
let altcha: string
try {
altcha = await solveAltcha()
} catch {
setError('Verification failed. Please try again.')
setSubmitting(false)
return
}
let description: Record<string, string>
if (selectedTemplate) {
description = {}
for (const f of selectedTemplate.fields) {
description[f.key] = templateValues[f.key] || ''
}
} else if (type === 'BUG_REPORT') {
description = {
stepsToReproduce: steps,
expectedBehavior: expected,
actualBehavior: actual,
environment: environment || '',
additionalContext: bugContext || '',
}
} else {
description = {
useCase,
proposedSolution: proposedSolution || '',
alternativesConsidered: alternatives || '',
additionalContext: featureContext || '',
}
}
try {
await api.post(`/boards/${boardSlug}/posts`, payload)
await api.post(`/boards/${boardSlug}/posts`, {
title,
type,
description,
category: category || undefined,
templateId: selectedTemplate ? selectedTemplate.id : undefined,
attachmentIds: attachmentIds.length ? attachmentIds : undefined,
altcha,
})
reset()
onSubmit?.()
} catch (e) {
} catch {
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>
)
const fieldError = (key: string) =>
fieldErrors[key] ? (
<span id={`err-${key}`} role="alert" className="mt-0.5 block" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{fieldErrors[key]}</span>
) : null
const label = (text: string, required?: boolean, htmlFor?: string) => (
<label htmlFor={htmlFor} className="block font-medium mb-1 uppercase tracking-wider" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{text}{required && <span style={{ color: 'var(--error)' }}> *</span>}
</label>
)
const handleTemplateChange = (id: string) => {
setSelectedTemplateId(id)
setTemplateValues({})
setFieldErrors({})
}
const renderTemplateFields = () => {
if (!selectedTemplate) return null
return selectedTemplate.fields.map((f) => (
<div key={f.key} className="mb-3">
{label(f.label, f.required)}
{f.type === 'text' && (
<input
className="input w-full"
placeholder={f.placeholder || ''}
value={templateValues[f.key] || ''}
onChange={(e) => setTemplateValues((prev) => ({ ...prev, [f.key]: e.target.value }))}
aria-required={f.required || undefined}
aria-invalid={!!fieldErrors[f.key]}
aria-describedby={fieldErrors[f.key] ? `err-${f.key}` : undefined}
/>
)}
{f.type === 'textarea' && (
<MarkdownEditor
value={templateValues[f.key] || ''}
onChange={(val) => setTemplateValues((prev) => ({ ...prev, [f.key]: val }))}
placeholder={f.placeholder || ''}
rows={3}
ariaRequired={f.required}
ariaLabel={f.label}
/>
)}
{f.type === 'select' && f.options && (
<Dropdown
value={templateValues[f.key] || ''}
onChange={(val) => setTemplateValues((prev) => ({ ...prev, [f.key]: val }))}
placeholder={f.placeholder || 'Select...'}
options={[
{ value: '', label: f.placeholder || 'Select...' },
...f.options.map((o) => ({ value: o, label: o })),
]}
/>
)}
{fieldError(f.key)}
</div>
))
}
return (
<div ref={formRef} className="card p-4 slide-up">
<div
className="card card-static p-5"
style={{ animation: 'slideUp var(--duration-normal) var(--ease-out)' }}
>
<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) => (
<h2
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-base)' }}
>
Share feedback
</h2>
{onCancel && (
<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',
}}
onClick={() => { reset(); onCancel() }}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-xs)' }}
>
{t === 'feature' ? 'Feature Request' : t === 'bug' ? 'Bug Report' : 'General'}
Cancel
</button>
))}
)}
</div>
<input
className="input mb-3"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
{/* Template selector */}
{templates.length > 0 && (
<div className="mb-4">
{label('Template')}
<div className="flex gap-2 flex-wrap">
<button
onClick={() => handleTemplateChange('')}
className="px-3 py-1.5 font-medium"
style={{
background: !selectedTemplateId ? 'var(--accent-subtle)' : 'transparent',
color: !selectedTemplateId ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${!selectedTemplateId ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
>
Default
</button>
{templates.map((t) => (
<button
key={t.id}
onClick={() => handleTemplateChange(t.id)}
className="px-3 py-1.5 font-medium"
style={{
background: selectedTemplateId === t.id ? 'var(--accent-subtle)' : 'transparent',
color: selectedTemplateId === t.id ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${selectedTemplateId === t.id ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
>
{t.name}
</button>
))}
</div>
</div>
)}
<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 selector - only when not using a template */}
{!selectedTemplate && (
<div className="flex gap-2 mb-4">
{([
['FEATURE_REQUEST', 'Feature Request'],
['BUG_REPORT', 'Bug Report'],
] as const).map(([value, lbl]) => (
<button
key={value}
onClick={() => setType(value)}
className="px-3 py-1.5 font-medium"
style={{
background: type === value ? 'var(--accent-subtle)' : 'transparent',
color: type === value ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${type === value ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
>
{lbl}
</button>
))}
</div>
)}
{type === 'bug' && (
<>
<textarea
className="input mb-3"
placeholder="Steps to reproduce"
rows={2}
value={steps}
onChange={(e) => setSteps(e.target.value)}
style={{ resize: 'vertical' }}
{/* Title */}
<div className="mb-3">
{label('Title', true, 'post-title')}
<input
id="post-title"
className="input w-full"
placeholder="Brief summary"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
aria-required="true"
aria-invalid={!!fieldErrors.title}
aria-describedby={fieldErrors.title ? 'err-title' : undefined}
/>
{fieldError('title')}
{similar.length > 0 && (
<div
className="mt-2 rounded-lg overflow-hidden"
style={{ border: '1px solid var(--border-accent)', background: 'var(--surface)' }}
>
<div className="px-3 py-1.5" style={{ background: 'var(--accent-subtle)', color: 'var(--accent)', fontSize: 'var(--text-xs)', fontWeight: 500 }}>
Similar posts already exist - vote instead?
</div>
{similar.map((p) => (
<a
key={p.id}
href={`/b/${boardSlug}/post/${p.id}`}
className="flex items-center justify-between px-3 py-2"
style={{ borderTop: '1px solid var(--border)', fontSize: 'var(--text-xs)', color: 'var(--text)' }}
>
<span className="truncate mr-2">{p.title}</span>
<span className="shrink-0 flex items-center gap-2" style={{ color: 'var(--text-tertiary)' }}>
<span style={{ color: 'var(--accent)' }}>{p.voteCount} votes</span>
<span>{p.similarity}% match</span>
</span>
</a>
))}
</div>
)}
</div>
{/* Category */}
{categories.length > 0 && (
<div className="mb-3">
{label('Category')}
<Dropdown
value={category}
onChange={setCategory}
placeholder="No category"
options={[
{ value: '', label: 'No category' },
...categories.map((c) => ({ value: c.slug, label: c.name })),
]}
/>
<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' }}
</div>
)}
{/* Template fields or default fields */}
{selectedTemplate ? (
renderTemplateFields()
) : type === 'BUG_REPORT' ? (
<>
<div className="mb-3">
{label('Steps to reproduce', true)}
<MarkdownEditor
value={steps}
onChange={setSteps}
placeholder="1. Go to... 2. Click on... 3. See error"
rows={3}
ariaRequired
ariaLabel="Steps to reproduce"
/>
<textarea
className="input"
placeholder="Actual behavior"
rows={2}
value={actual}
onChange={(e) => setActual(e.target.value)}
style={{ resize: 'vertical' }}
{fieldError('steps')}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div>
{label('Expected behavior', true)}
<MarkdownEditor value={expected} onChange={setExpected} placeholder="What should happen?" rows={2} ariaRequired ariaLabel="Expected behavior" />
{fieldError('expected')}
</div>
<div>
{label('Actual behavior', true)}
<MarkdownEditor value={actual} onChange={setActual} placeholder="What actually happens?" rows={2} ariaRequired ariaLabel="Actual behavior" />
{fieldError('actual')}
</div>
</div>
<div className="mb-3">
{label('Environment / version')}
<input
className="input w-full"
placeholder="OS, browser, app version..."
value={environment}
onChange={(e) => setEnvironment(e.target.value)}
/>
</div>
<div className="mb-3">
{label('Additional context')}
<MarkdownEditor value={bugContext} onChange={setBugContext} placeholder="Screenshots, logs, anything else..." rows={2} />
</div>
</>
) : (
<>
<div className="mb-3">
{label('Use case / problem statement', true)}
<MarkdownEditor value={useCase} onChange={setUseCase} placeholder="What are you trying to accomplish?" rows={3} ariaRequired ariaLabel="Use case" />
{fieldError('useCase')}
</div>
<div className="mb-3">
{label('Proposed solution')}
<MarkdownEditor value={proposedSolution} onChange={setProposedSolution} placeholder="How do you think this should work?" rows={2} />
</div>
<div className="mb-3">
{label('Alternatives considered')}
<MarkdownEditor value={alternatives} onChange={setAlternatives} placeholder="Have you tried any workarounds?" rows={2} />
</div>
<div className="mb-3">
{label('Additional context')}
<MarkdownEditor value={featureContext} onChange={setFeatureContext} placeholder="Anything else to add?" rows={2} />
</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 className="mb-3">
{label('Attachments')}
<FileUpload attachmentIds={attachmentIds} onChange={setAttachmentIds} />
</div>
{error && (
<div className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
<div role="alert" className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
)}
<div className="flex justify-end">
<div className="flex items-center justify-between">
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Your ability to edit this post depends on your browser cookie.
</span>
<button
onClick={submit}
disabled={submitting}
@@ -178,6 +499,18 @@ export default function PostForm({ boardSlug, onSubmit }: Props) {
{submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
{!auth.isPasskeyUser && (
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--border)' }}>
<a
href="/settings"
className="flex items-center gap-2"
style={{ color: 'var(--accent)', fontSize: 'var(--text-xs)' }}
>
<IconFingerprint size={14} stroke={2} />
Save my identity to keep your posts across devices
</a>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,191 @@
import { useState } from 'react'
import { IconShieldCheck, IconCopy, IconCheck, IconRefresh } from '@tabler/icons-react'
import { api } from '../lib/api'
import { useAuth } from '../hooks/useAuth'
import { useFocusTrap } from '../hooks/useFocusTrap'
interface Props {
open: boolean
onClose: () => void
}
export default function RecoveryCodeModal({ open, onClose }: Props) {
const auth = useAuth()
const trapRef = useFocusTrap(open)
const [phrase, setPhrase] = useState<string | null>(null)
const [expiresAt, setExpiresAt] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [copied, setCopied] = useState<'phrase' | 'link' | null>(null)
const [error, setError] = useState('')
const hasExisting = auth.user?.hasRecoveryCode
const generate = async () => {
setGenerating(true)
setError('')
try {
const res = await api.post<{ phrase: string; expiresAt: string }>('/me/recovery-code', {})
setPhrase(res.phrase)
setExpiresAt(res.expiresAt)
auth.refresh()
} catch {
setError('Failed to generate recovery code')
} finally {
setGenerating(false)
}
}
const copyPhrase = () => {
if (!phrase) return
navigator.clipboard.writeText(phrase)
setCopied('phrase')
setTimeout(() => setCopied(null), 2000)
}
const copyLink = () => {
if (!phrase) return
navigator.clipboard.writeText(`${window.location.origin}/recover#${phrase}`)
setCopied('link')
setTimeout(() => setCopied(null), 2000)
}
const handleClose = () => {
setPhrase(null)
setExpiresAt(null)
setError('')
setCopied(null)
onClose()
}
if (!open) return null
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div
className="absolute inset-0 fade-in"
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}
onClick={handleClose}
/>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="recovery-modal-title"
className="relative w-full max-w-md mx-4 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
padding: '28px',
maxHeight: '85vh',
overflowY: 'auto',
}}
onKeyDown={(e) => e.key === 'Escape' && handleClose()}
>
<div
className="w-10 h-10 flex items-center justify-center mb-4"
style={{
background: 'var(--accent-subtle)',
borderRadius: 'var(--radius-md)',
}}
>
<IconShieldCheck size={20} stroke={2} style={{ color: 'var(--accent)' }} />
</div>
<h2
id="recovery-modal-title"
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-lg)', color: 'var(--text)' }}
>
Recovery Code
</h2>
{phrase ? (
<>
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Save this phrase somewhere safe. It will only be shown once. If you lose access to your cookies, enter this phrase to recover your identity.
</p>
<div
className="p-4 mb-3"
style={{
background: 'var(--bg)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
}}
>
<div
className="font-mono font-semibold text-center mb-3"
style={{
color: 'var(--accent)',
fontSize: 'var(--text-lg)',
wordBreak: 'break-all',
letterSpacing: '0.02em',
lineHeight: 1.6,
}}
>
{phrase}
</div>
<div className="flex gap-2">
<button onClick={copyPhrase} className="btn btn-secondary flex-1 flex items-center justify-center gap-2" style={{ fontSize: 'var(--text-xs)' }}>
{copied === 'phrase' ? <IconCheck size={14} stroke={2} /> : <IconCopy size={14} stroke={2} />}
{copied === 'phrase' ? 'Copied' : 'Copy phrase'}
</button>
<button onClick={copyLink} className="btn btn-secondary flex-1 flex items-center justify-center gap-2" style={{ fontSize: 'var(--text-xs)' }}>
{copied === 'link' ? <IconCheck size={14} stroke={2} /> : <IconCopy size={14} stroke={2} />}
{copied === 'link' ? 'Copied' : 'Copy link'}
</button>
</div>
</div>
<div
className="p-3 mb-4"
style={{
background: 'rgba(234, 179, 8, 0.08)',
border: '1px solid rgba(234, 179, 8, 0.25)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
color: 'var(--text-secondary)',
lineHeight: 1.5,
}}
>
Expires on <strong style={{ color: 'var(--warning)' }}>{new Date(expiresAt!).toLocaleDateString()}</strong>.
The code can only be used once - after recovery you'll need to generate a new one.
</div>
<button onClick={handleClose} className="btn btn-primary w-full">
I've saved it
</button>
</>
) : (
<>
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
{hasExisting
? `You already have a recovery code (expires ${new Date(auth.user!.recoveryCodeExpiresAt!).toLocaleDateString()}). Generating a new one will replace it.`
: 'Generate a 6-word phrase you can use to recover your identity if your cookies get cleared. Save it somewhere safe - a notes app, a password manager, or even a piece of paper.'}
</p>
{error && (
<p role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{error}</p>
)}
<div className="flex gap-2">
<button
onClick={generate}
disabled={generating}
className="btn btn-primary flex-1 flex items-center justify-center gap-2"
>
{hasExisting ? <IconRefresh size={16} stroke={2} /> : <IconShieldCheck size={16} stroke={2} />}
{generating ? 'Generating...' : hasExisting ? 'Generate new code' : 'Generate recovery code'}
</button>
<button onClick={handleClose} className="btn btn-secondary">
Cancel
</button>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -1,246 +1,668 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Link, useLocation, useParams } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import { useAdmin } from '../hooks/useAdmin'
import { useTheme } from '../hooks/useTheme'
import { useBranding } from '../hooks/useBranding'
import { useTranslation } from '../i18n'
import { api } from '../lib/api'
import {
IconHome, IconSearch, IconBell, IconActivity, IconFileText, IconSettings, IconUser,
IconFingerprint, IconCookie, IconChevronLeft, IconChevronRight, IconChevronDown,
IconSun, IconMoon, IconShieldLock, IconShieldCheck, IconLayoutKanban, IconNews,
} from '@tabler/icons-react'
import BoardIcon from './BoardIcon'
import Avatar from './Avatar'
interface Board {
id: string
slug: string
name: string
description: string
iconName: string | null
iconColor: string | null
postCount: number
}
interface Notification {
id: string
type: string
title: string
body: string
postId: string | null
post?: { id: string; board: { slug: string } } | null
read: boolean
createdAt: string
}
function timeAgo(date: string): string {
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
if (s < 60) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
if (d < 30) return `${d}d ago`
return `${Math.floor(d / 30)}mo ago`
}
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false
)
useEffect(() => {
const mq = window.matchMedia(query)
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [query])
return matches
}
export default function Sidebar() {
const { boardSlug } = useParams()
const location = useLocation()
const auth = useAuth()
const admin = useAdmin()
const { resolved, toggle: toggleTheme } = useTheme()
const { appName, logoUrl } = useBranding()
const { t } = useTranslation()
const isMobile = useMediaQuery('(max-width: 767px)')
const isLarge = useMediaQuery('(min-width: 1024px)')
const [collapsed, setCollapsed] = useState(() => {
if (typeof window === 'undefined') return false
const stored = localStorage.getItem('echoboard_sidebar')
return stored === 'collapsed'
})
const userToggled = useRef(!!localStorage.getItem('echoboard_sidebar'))
const [boards, setBoards] = useState<Board[]>([])
const [collapsed, setCollapsed] = useState(false)
const [bellOpen, setBellOpen] = useState(false)
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
useEffect(() => {
api.get<Board[]>('/boards').then(setBoards).catch(() => {})
}, [])
const isActive = (path: string) => location.pathname === path
const isBoardActive = (slug: string) => boardSlug === slug
// poll unread notification count
useEffect(() => {
const fetch = () => {
api.get<{ notifications: Notification[]; unread: number }>('/notifications')
.then((r) => {
setNotifications(r.notifications.slice(0, 8))
setUnreadCount(r.unread)
})
.catch(() => {})
}
fetch()
const iv = setInterval(fetch, 30000)
return () => clearInterval(iv)
}, [])
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>
// Default collapsed on tablet, expanded on desktop - only if user hasn't toggled
useEffect(() => {
if (userToggled.current) return
setCollapsed(!isLarge)
}, [isLarge])
<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>
)
const toggleCollapse = () => {
userToggled.current = true
setCollapsed((c) => {
const next = !c
localStorage.setItem('echoboard_sidebar', next ? 'collapsed' : 'expanded')
return next
})
}
const isActive = (path: string) => location.pathname === path
const isBoardActive = (slug: string) => boardSlug === slug || location.pathname.startsWith(`/b/${slug}`)
if (isMobile) return null
const width = collapsed ? 64 : 300
const isDark = resolved === 'dark'
const navItem = (to: string, icon: React.ReactNode, label: string, active: boolean) => (
<Link
to={to}
className="flex items-center gap-3 rounded-lg nav-link"
style={{
padding: collapsed ? '10px' : '8px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
background: active ? 'var(--accent-subtle)' : 'transparent',
color: active ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 'var(--text-sm)',
transition: 'all var(--duration-fast) ease-out',
borderRadius: 'var(--radius-md)',
}}
title={collapsed ? label : undefined}
aria-current={active ? 'page' : undefined}
>
{icon}
{!collapsed && <span style={{ fontWeight: active ? 600 : 400 }}>{label}</span>}
</Link>
)
return (
<aside
className="hidden lg:flex flex-col border-r h-screen sticky top-0"
aria-label="Main navigation"
className="relative flex flex-col border-r h-screen sticky top-0"
style={{
width: 280,
width,
minWidth: width,
background: 'var(--surface)',
borderColor: 'var(--border)',
transition: 'width var(--duration-normal) var(--ease-out), min-width var(--duration-normal) var(--ease-out)',
overflow: 'visible',
zIndex: 20,
}}
>
{/* 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) => (
<div
className="flex items-center border-b"
style={{
borderColor: 'var(--border)',
padding: collapsed ? '12px 12px' : '12px 16px',
gap: 8,
minHeight: 56,
}}
>
{!collapsed && (
<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"
to="/"
className="font-bold truncate flex items-center gap-2"
style={{
background: isBoardActive(b.slug) ? 'var(--accent-subtle)' : 'transparent',
color: isBoardActive(b.slug) ? 'var(--accent)' : 'var(--text-secondary)',
transition: 'all 200ms ease-out',
fontFamily: 'var(--font-heading)',
color: 'var(--text)',
fontSize: 'var(--text-lg)',
flex: 1,
minWidth: 0,
}}
>
<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>
{logoUrl ? (
<img src={logoUrl} alt={appName} style={{ height: 24, objectFit: 'contain' }} />
) : (
appName
)}
</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)' }}
)}
{collapsed && (
<Link
to="/"
className="font-bold flex items-center justify-center"
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--accent)',
fontSize: 'var(--text-lg)',
flex: 1,
textAlign: 'center',
}}
>
{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'}
{logoUrl ? (
<img src={logoUrl} alt={appName} style={{ height: 20, objectFit: 'contain' }} />
) : (
appName.charAt(0)
)}
</Link>
)}
{!collapsed && (
<div className="flex items-center gap-1">
<button
onClick={toggleTheme}
className="w-11 h-11 rounded-lg flex items-center justify-center action-btn"
style={{
color: 'var(--text-secondary)',
transition: 'color var(--duration-fast) ease-out',
borderRadius: 'var(--radius-sm)',
}}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Light mode' : 'Dark mode'}
>
{isDark ? <IconSun size={16} stroke={2} /> : <IconMoon size={16} stroke={2} />}
</button>
<div className="relative">
<button
onClick={() => setBellOpen(!bellOpen)}
className="w-11 h-11 rounded-lg flex items-center justify-center relative action-btn"
style={{
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
}}
aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'}
aria-expanded={bellOpen}
aria-haspopup="true"
>
<IconBell size={16} stroke={2} />
{unreadCount > 0 && (
<span
className="absolute flex items-center justify-center"
style={{
top: 2, right: 0,
minWidth: 14, height: 14,
borderRadius: 7,
background: 'var(--accent)',
color: 'var(--bg)',
fontSize: 'var(--text-xs)',
fontWeight: 700,
padding: '0 3px',
lineHeight: 1,
}}
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{bellOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setBellOpen(false)} />
<div
role="region"
aria-label="Notifications"
className="absolute left-0 top-12 w-80 z-50 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-lg)',
}}
>
<div
className="flex items-center justify-between"
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
}}
>
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-xs)' }}>
{t('notifications')}
</span>
{unreadCount > 0 && (
<button
className="action-btn"
style={{ color: 'var(--accent)', fontSize: 'var(--text-xs)', padding: '4px 8px', borderRadius: 'var(--radius-sm)' }}
onClick={() => {
api.put('/notifications/read', {}).then(() => {
setUnreadCount(0)
setNotifications((n) => n.map((x) => ({ ...x, read: true })))
}).catch(() => {})
}}
>
{t('markAllRead')}
</button>
)}
</div>
<div className="max-h-72 overflow-y-auto" aria-live="polite">
{notifications.length === 0 ? (
<div className="p-4 text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{t('noNotifications')}
</div>
) : (
notifications.map((n) => (
<Link
key={n.id}
to={n.post ? `/b/${n.post.board.slug}/post/${n.post.id}` : '/activity'}
className="block nav-link"
style={{
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
borderLeft: n.read ? '3px solid transparent' : '3px solid var(--accent)',
background: n.read ? 'transparent' : 'var(--accent-subtle)',
}}
onClick={() => setBellOpen(false)}
>
<div className="truncate" style={{ color: 'var(--text)', fontSize: 'var(--text-xs)', fontWeight: n.read ? 400 : 600 }}>
{n.title}
</div>
<div className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
{n.body}
</div>
<time dateTime={n.createdAt} style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2, display: 'block' }}>
{timeAgo(n.createdAt)}
</time>
</Link>
))
)}
</div>
<Link
to="/activity"
className="block text-center border-t nav-link"
style={{
borderColor: 'var(--border)',
color: 'var(--accent)',
fontSize: 'var(--text-xs)',
padding: '8px',
}}
onClick={() => setBellOpen(false)}
>
{t('viewAllActivity')}
</Link>
</div>
</>
)}
</div>
</div>
</div>
{!auth.isPasskeyUser && (
)}
</div>
{/* Search */}
<div style={{ padding: collapsed ? '8px 10px' : '8px 12px' }}>
<Link
to="/search"
className="flex items-center gap-2"
style={{
padding: collapsed ? '8px' : '8px 12px',
justifyContent: collapsed ? 'center' : 'flex-start',
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: 'var(--text-tertiary)',
fontSize: 'var(--text-sm)',
transition: 'border-color var(--duration-fast) ease-out',
}}
title={collapsed ? 'Search' : undefined}
>
<IconSearch size={15} stroke={2} />
{!collapsed && <span>{t('searchPlaceholder')}</span>}
</Link>
</div>
{/* Navigation */}
<div className="flex-1 flex flex-col overflow-hidden" style={{ padding: collapsed ? '4px 8px 0' : '4px 12px 0' }}>
{navItem('/', <IconHome size={18} stroke={2} aria-hidden="true" />, t('home'), isActive('/'))}
{navItem('/activity', <IconActivity size={18} stroke={2} aria-hidden="true" />, t('activity'), isActive('/activity'))}
{!collapsed && (
<div style={{ padding: '16px 12px 6px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em' }}>
{t('boards')}
</div>
)}
{collapsed && <div style={{ height: 12 }} />}
<nav className="flex-1 overflow-y-auto" style={{ paddingBottom: 4 }}>
{boards.map((b) => {
const active = isBoardActive(b.slug)
const expanded = active && !collapsed
const isBoardSubpage = active && (
location.pathname === `/b/${b.slug}/roadmap` ||
location.pathname === `/b/${b.slug}/changelog`
)
return (
<div key={b.id} style={{ marginBottom: 2 }}>
<Link
to={`/b/${b.slug}`}
className="flex items-center justify-between rounded-lg relative nav-link"
style={{
padding: collapsed ? '10px' : '8px 12px',
justifyContent: collapsed ? 'center' : 'space-between',
background: active ? 'var(--accent-subtle)' : 'transparent',
color: active ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 'var(--text-sm)',
fontWeight: active ? 600 : 400,
transition: 'all var(--duration-fast) ease-out',
borderRadius: 'var(--radius-md)',
}}
title={collapsed ? `${b.name} (${b.postCount})` : undefined}
>
{active && (
<span
className="absolute left-0 top-2 bottom-2 rounded-r"
style={{ width: 3, background: 'var(--accent)' }}
/>
)}
{collapsed ? (
<BoardIcon name={b.name} iconName={b.iconName} iconColor={active ? undefined : b.iconColor} size={24} />
) : (
<>
<span className="flex items-center gap-2 truncate">
<BoardIcon name={b.name} iconName={b.iconName} iconColor={active ? undefined : b.iconColor} size={20} />
<span className="truncate">{b.name}</span>
</span>
<span className="flex items-center gap-1">
<span
className="shrink-0"
style={{
padding: '1px 8px',
background: 'var(--surface-hover)',
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
aria-label={`${b.postCount} posts`}
>
{b.postCount}
</span>
{active && (
<IconChevronDown
size={12}
stroke={2}
style={{
color: 'var(--text-tertiary)',
flexShrink: 0,
}}
aria-hidden="true"
/>
)}
</span>
</>
)}
</Link>
{expanded && (
<div style={{ paddingLeft: 32, paddingTop: 2 }}>
<Link
to={`/b/${b.slug}`}
className="flex items-center gap-2 rounded-md nav-link"
style={{
padding: '5px 10px',
fontSize: 'var(--text-xs)',
color: !isBoardSubpage ? 'var(--accent)' : 'var(--text-tertiary)',
fontWeight: !isBoardSubpage ? 500 : 400,
transition: 'color var(--duration-fast) ease-out',
}}
>
<IconFileText size={13} stroke={2} aria-hidden="true" />
Posts
</Link>
<Link
to={`/b/${b.slug}/roadmap`}
className="flex items-center gap-2 rounded-md nav-link"
style={{
padding: '5px 10px',
fontSize: 'var(--text-xs)',
color: location.pathname === `/b/${b.slug}/roadmap` ? 'var(--accent)' : 'var(--text-tertiary)',
fontWeight: location.pathname === `/b/${b.slug}/roadmap` ? 500 : 400,
transition: 'color var(--duration-fast) ease-out',
}}
>
<IconLayoutKanban size={13} stroke={2} aria-hidden="true" />
{t('roadmap')}
</Link>
<Link
to={`/b/${b.slug}/changelog`}
className="flex items-center gap-2 rounded-md nav-link"
style={{
padding: '5px 10px',
fontSize: 'var(--text-xs)',
color: location.pathname === `/b/${b.slug}/changelog` ? 'var(--accent)' : 'var(--text-tertiary)',
fontWeight: location.pathname === `/b/${b.slug}/changelog` ? 500 : 400,
transition: 'color var(--duration-fast) ease-out',
}}
>
<IconNews size={13} stroke={2} aria-hidden="true" />
{t('changelog')}
</Link>
</div>
)}
</div>
)
})}
</nav>
</div>
{/* You section - pinned above profile footer */}
<div className="border-t" style={{ borderColor: 'var(--border)', padding: collapsed ? '4px 8px' : '4px 12px' }}>
{!collapsed && (
<div style={{ padding: '8px 12px 6px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em' }}>
{t('you')}
</div>
)}
{collapsed && <div style={{ height: 4 }} />}
{admin.isAdmin
? <>
{navItem('/admin/team', <IconUser size={18} stroke={2} aria-hidden="true" />, t('profile'), isActive('/admin/team'))}
{navItem('/admin/posts', <IconFileText size={18} stroke={2} aria-hidden="true" />, t('myPosts'), isActive('/admin/posts'))}
{admin.isSuperAdmin && navItem('/admin/settings', <IconSettings size={18} stroke={2} aria-hidden="true" />, t('settings'), isActive('/admin/settings'))}
</>
: <>
{navItem('/profile', <IconUser size={18} stroke={2} aria-hidden="true" />, t('profile'), isActive('/profile'))}
{navItem('/my-posts', <IconFileText size={18} stroke={2} aria-hidden="true" />, t('myPosts'), isActive('/my-posts'))}
{navItem('/settings', <IconSettings size={18} stroke={2} aria-hidden="true" />, t('settings'), isActive('/settings'))}
</>
}
{navItem('/privacy', <IconShieldLock size={18} stroke={2} aria-hidden="true" />, t('privacy'), isActive('/privacy'))}
</div>
{/* Profile footer */}
<div
className="border-t"
style={{
borderColor: admin.isAdmin ? 'rgba(6, 182, 212, 0.15)' : 'var(--border)',
padding: collapsed ? '12px 8px' : '14px 16px',
}}
>
{collapsed ? (
<div className="flex flex-col items-center gap-2">
{admin.isAdmin ? (
<div
className="w-8 h-8 rounded flex items-center justify-center"
style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)', borderRadius: 'var(--radius-sm)' }}
>
<IconShieldCheck size={16} stroke={2} />
</div>
) : auth.user?.id ? (
<Avatar userId={auth.user.id} avatarUrl={auth.user.avatarUrl} size={32} />
) : (
<div
className="w-8 h-8 rounded flex items-center justify-center"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)', borderRadius: 'var(--radius-sm)' }}
>
<IconCookie size={16} stroke={2} />
</div>
)}
<button
onClick={toggleTheme}
className="w-11 h-11 rounded-lg flex items-center justify-center action-btn"
style={{ color: 'var(--text-secondary)', borderRadius: 'var(--radius-sm)' }}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? <IconSun size={14} stroke={2} /> : <IconMoon size={14} stroke={2} />}
</button>
</div>
) : admin.isAdmin ? (
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded flex items-center justify-center shrink-0"
style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)', borderRadius: 'var(--radius-sm)' }}
>
<IconShieldCheck size={18} stroke={2} />
</div>
<div className="flex-1 min-w-0">
<div className="truncate font-medium" style={{ color: 'var(--admin-accent)', fontSize: 'var(--text-sm)' }}>
{admin.displayName || t('admin')}
</div>
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
<IconShieldCheck size={11} stroke={2} aria-hidden="true" />
<span>{admin.role === 'SUPER_ADMIN' ? 'Super Admin' : admin.role === 'MODERATOR' ? 'Moderator' : 'Admin'}</span>
</div>
</div>
</div>
) : (
<div className="flex items-center gap-3">
{auth.user?.id ? (
<Avatar userId={auth.user.id} avatarUrl={auth.user.avatarUrl} size={36} />
) : (
<div
className="w-9 h-9 rounded flex items-center justify-center shrink-0"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)', borderRadius: 'var(--radius-sm)' }}
>
<IconCookie size={18} stroke={2} />
</div>
)}
<div className="flex-1 min-w-0">
<div className="truncate font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{auth.displayName !== 'Anonymous'
? auth.displayName
: auth.isPasskeyUser && auth.user?.username
? `@${auth.user.username}`
: auth.user?.id
? `Anonymous #${auth.user.id.slice(-4)}`
: t('anonymous')}
</div>
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
{auth.isPasskeyUser ? (
<>
<IconFingerprint size={11} stroke={2} aria-hidden="true" />
<span>{t('passkeyUser')}</span>
</>
) : (
<>
<IconCookie size={11} stroke={2} aria-hidden="true" />
<span>{t('cookieIdentity')}</span>
</>
)}
</div>
</div>
</div>
)}
{!collapsed && !admin.isAdmin && !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)' }}
className="block mt-3 text-center py-2 nav-link"
style={{
background: 'var(--accent-subtle)',
color: 'var(--accent)',
fontSize: 'var(--text-xs)',
fontWeight: 500,
borderRadius: 'var(--radius-md)',
transition: 'opacity var(--duration-fast) ease-out',
}}
>
Register passkey for persistence
{t('saveIdentity')}
</Link>
)}
</div>
{/* Collapse toggle */}
<button
onClick={toggleCollapse}
className="absolute flex items-center justify-center action-btn"
style={{
top: '50%',
right: -22,
transform: 'translateY(-50%)',
width: 44,
height: 44,
borderRadius: '50%',
background: 'var(--surface)',
border: '1px solid var(--border)',
color: 'var(--text-tertiary)',
cursor: 'pointer',
zIndex: 30,
boxShadow: 'var(--shadow-sm)',
transition: 'color var(--duration-fast) ease-out',
}}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <IconChevronRight size={14} stroke={2} /> : <IconChevronLeft size={14} stroke={2} />}
</button>
</aside>
)
}

View File

@@ -1,21 +1,64 @@
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)' },
import { IconCircleDot, IconEye, IconCalendarEvent, IconLoader, IconCircleCheck, IconCircleX } from '@tabler/icons-react'
import type { Icon } from '@tabler/icons-react'
export interface StatusConfig {
status: string
label: string
color: string
}
export default function StatusBadge({ status }: { status: string }) {
const cfg = statusConfig[status] || { label: status, bg: 'var(--border)', color: 'var(--text-secondary)' }
const defaultConfig: Record<string, { label: string; colorVar: string; fallback: string; icon: Icon }> = {
OPEN: { label: 'Open', colorVar: '--status-open', fallback: '#F59E0B', icon: IconCircleDot },
UNDER_REVIEW: { label: 'Under Review', colorVar: '--status-review', fallback: '#06B6D4', icon: IconEye },
PLANNED: { label: 'Planned', colorVar: '--status-planned', fallback: '#3B82F6', icon: IconCalendarEvent },
IN_PROGRESS: { label: 'In Progress', colorVar: '--status-progress', fallback: '#EAB308', icon: IconLoader },
DONE: { label: 'Done', colorVar: '--status-done', fallback: '#22C55E', icon: IconCircleCheck },
DECLINED: { label: 'Declined', colorVar: '--status-declined', fallback: '#EF4444', icon: IconCircleX },
}
const iconMap: Record<string, Icon> = {
OPEN: IconCircleDot,
UNDER_REVIEW: IconEye,
PLANNED: IconCalendarEvent,
IN_PROGRESS: IconLoader,
DONE: IconCircleCheck,
DECLINED: IconCircleX,
}
export default function StatusBadge({ status, customStatuses }: { status: string; customStatuses?: StatusConfig[] }) {
let label: string
let colorVar: string | null = null
let fallbackColor: string
let StatusIcon: Icon
const custom = customStatuses?.find((s) => s.status === status)
if (custom) {
label = custom.label
fallbackColor = custom.color
StatusIcon = iconMap[status] || IconCircleDot
} else {
const def = defaultConfig[status]
label = def?.label || status
colorVar = def?.colorVar || null
fallbackColor = def?.fallback || '#64748B'
StatusIcon = def?.icon || IconCircleDot
}
const color = colorVar ? `var(${colorVar})` : fallbackColor
const bgColor = colorVar ? `color-mix(in srgb, var(${colorVar}) 12%, transparent)` : `${fallbackColor}20`
return (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
style={{ background: cfg.bg, color: cfg.color }}
className="inline-flex items-center gap-1 px-2 py-0.5 font-medium"
style={{
background: bgColor,
color,
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
>
{cfg.label}
<StatusIcon size={12} stroke={2} aria-hidden="true" />
{label}
</span>
)
}

View File

@@ -1,4 +1,5 @@
import { useTheme } from '../hooks/useTheme'
import { IconSun, IconMoon } from '@tabler/icons-react'
export default function ThemeToggle() {
const { resolved, toggle } = useTheme()
@@ -7,30 +8,32 @@ export default function ThemeToggle() {
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"
className="fixed z-40 rounded-full flex items-center justify-center md:hidden"
style={{
bottom: 80,
right: 16,
width: 44,
height: 44,
background: 'var(--surface)',
border: '1px solid var(--border)',
color: 'var(--accent)',
transition: 'all 200ms ease-out',
boxShadow: 'var(--shadow-md)',
cursor: 'pointer',
transition: 'transform var(--duration-fast) var(--ease-out), background var(--duration-fast) ease-out',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)' }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
onFocus={(e) => { e.currentTarget.style.transform = 'scale(1.1)' }}
onBlur={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
<div
style={{
transition: 'transform 300ms cubic-bezier(0.16, 1, 0.3, 1)',
transition: 'transform var(--duration-fast) var(--ease-spring)',
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>
)}
{isDark ? <IconSun size={18} stroke={2} /> : <IconMoon size={18} stroke={2} />}
</div>
</button>
)

View File

@@ -1,37 +1,84 @@
import { useState } from 'react'
import { IconTrendingUp, IconShieldCheck, IconMessageDots, IconEdit, IconTrash, IconArrowBackUp, IconLock, IconLockOpen } from '@tabler/icons-react'
import Avatar from './Avatar'
import Markdown from './Markdown'
import MarkdownEditor from './MarkdownEditor'
import { useConfirm } from '../hooks/useConfirm'
interface ReplyTo {
id: string
body: string
isAdmin: boolean
authorName: string
}
interface TimelineAttachment {
id: string
filename: string
mimeType: string
size: number
}
interface TimelineEntry {
id: string
type: 'status_change' | 'admin_response' | 'comment'
type: 'status_change' | 'comment'
authorId?: string | null
authorName: string
authorAvatarUrl?: string | null
content: string
oldStatus?: string
newStatus?: string
reason?: string | null
createdAt: string
reactions?: { emoji: string; count: number; hasReacted: boolean }[]
isAdmin?: boolean
authorTitle?: string | null
replyTo?: ReplyTo | null
attachments?: TimelineAttachment[]
editCount?: number
isEditLocked?: boolean
}
export type { TimelineEntry, ReplyTo }
export default function Timeline({
entries,
onReact,
currentUserId,
isCurrentAdmin,
onEditComment,
onDeleteComment,
onReply,
onShowEditHistory,
onLockComment,
}: {
entries: TimelineEntry[]
onReact?: (entryId: string, emoji: string) => void
currentUserId?: string
isCurrentAdmin?: boolean
onEditComment?: (entryId: string, body: string) => void
onDeleteComment?: (entryId: string) => void
onReply?: (entry: TimelineEntry) => void
onShowEditHistory?: (entryId: string) => void
onLockComment?: (entryId: 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 className="flex flex-col gap-3">
{entries.map((entry, i) => (
<TimelineItem
key={entry.id}
entry={entry}
onReact={onReact}
isOwn={!!currentUserId && entry.type === 'comment' && entry.authorId === currentUserId}
isCurrentAdmin={isCurrentAdmin}
onEdit={onEditComment}
onDelete={onDeleteComment}
onReply={onReply}
onShowEditHistory={onShowEditHistory}
onLockComment={onLockComment}
index={i}
/>
))}
</div>
)
}
@@ -39,127 +86,421 @@ export default function Timeline({
function TimelineItem({
entry,
onReact,
isOwn,
isCurrentAdmin,
onEdit,
onDelete,
onReply,
onShowEditHistory,
onLockComment,
index,
}: {
entry: TimelineEntry
onReact?: (entryId: string, emoji: string) => void
isOwn?: boolean
isCurrentAdmin?: boolean
onEdit?: (entryId: string, body: string) => void
onDelete?: (entryId: string) => void
onReply?: (entry: TimelineEntry) => void
onShowEditHistory?: (entryId: string) => void
onLockComment?: (entryId: string) => void
index: number
}) {
const confirm = useConfirm()
const [showPicker, setShowPicker] = useState(false)
const quickEmojis = ['👍', '❤️', '🎉', '😄', '🤔', '👀']
const [hovered, setHovered] = useState(false)
const [focused, setFocused] = useState(false)
const [editMode, setEditMode] = useState(false)
const [editText, setEditText] = useState(entry.content)
const quickEmojis = ['\uD83D\uDC4D', '\u2764\uFE0F', '\uD83C\uDF89', '\uD83D\uDE04', '\uD83D\uDE80']
const iconBg = entry.type === 'admin_response'
? 'var(--admin-subtle)'
: entry.type === 'status_change'
? 'var(--accent-subtle)'
: 'var(--border)'
const isAdmin = entry.isAdmin === true
const isStatusChange = entry.type === 'status_change'
const canEdit = (isOwn || (isCurrentAdmin && isAdmin)) && !entry.isEditLocked
const canDelete = isOwn || isCurrentAdmin
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 */}
if (isStatusChange) {
return (
<div
className="absolute left-2 top-1 w-5 h-5 rounded-full flex items-center justify-center z-10"
style={{ background: iconBg }}
className="flex flex-col items-center py-2 stagger-in"
style={{ '--stagger': index } as React.CSSProperties}
>
{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>
)}
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded-full flex items-center justify-center"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
<IconTrendingUp size={12} stroke={2.5} />
</div>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Status changed from <strong style={{ color: 'var(--text-secondary)' }}>{entry.oldStatus}</strong> to <strong style={{ color: 'var(--text-secondary)' }}>{entry.newStatus}</strong>
</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
<time dateTime={entry.createdAt} style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{new Date(entry.createdAt).toLocaleDateString()}
</span>
</time>
</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>
{entry.reason && (
<div
className="mt-1 px-3 py-1.5"
style={{
fontSize: 'var(--text-xs)',
color: 'var(--text-secondary)',
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
maxWidth: '80%',
}}
>
Reason: {entry.reason}
</div>
)}
</div>
)
}
return (
<div
className={`flex stagger-in ${isAdmin ? 'justify-end' : 'justify-start'}`}
style={{ '--stagger': index, maxWidth: '85%', marginLeft: isAdmin ? 'auto' : 0 } as React.CSSProperties}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowPicker(false) }}
onFocusCapture={() => setFocused(true)}
onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) { setFocused(false); setShowPicker(false) } }}
>
<div className="flex-1">
{/* Bubble */}
<div
className="p-4"
style={{
background: isAdmin ? 'var(--admin-subtle)' : 'var(--surface)',
border: isAdmin ? '1px solid rgba(6, 182, 212, 0.15)' : '1px solid var(--border)',
borderRadius: isAdmin
? 'var(--radius-lg) var(--radius-lg) var(--radius-sm) var(--radius-lg)'
: 'var(--radius-lg) var(--radius-lg) var(--radius-lg) var(--radius-sm)',
boxShadow: 'var(--shadow-sm)',
}}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<Avatar
userId={entry.authorId ?? '0000'}
name={isAdmin ? null : entry.authorName}
avatarUrl={entry.authorAvatarUrl}
size={22}
/>
<span
className="font-medium"
style={{
color: isAdmin ? 'var(--admin-accent)' : 'var(--text)',
fontSize: 'var(--text-xs)',
}}
>
{entry.authorName}
{entry.authorTitle && (
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}> - {entry.authorTitle}</span>
)}
</span>
{isAdmin && (
<span
className="px-1.5 py-0.5 rounded font-medium"
style={{
background: 'var(--admin-subtle)',
color: 'var(--admin-accent)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
>
Official
</span>
)}
<time dateTime={entry.createdAt} style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{new Date(entry.createdAt).toLocaleDateString()}
</time>
{entry.isEditLocked && (
<span title="Editing locked" style={{ color: 'var(--error)', display: 'inline-flex' }}>
<IconLock size={11} stroke={2} aria-label="Editing locked" />
</span>
)}
{entry.editCount != null && entry.editCount > 0 && (
<button
onClick={() => onShowEditHistory?.(entry.id)}
style={{
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
fontStyle: 'italic',
background: 'none',
border: 'none',
padding: '2px 4px',
cursor: 'pointer',
textDecoration: 'underline',
textDecorationStyle: 'dotted' as const,
textUnderlineOffset: '2px',
borderRadius: 'var(--radius-sm)',
transition: 'color var(--duration-fast) ease-out, background var(--duration-fast) ease-out',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)'; e.currentTarget.style.background = 'var(--accent-subtle)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)'; e.currentTarget.style.background = 'none' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)'; e.currentTarget.style.background = 'var(--accent-subtle)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)'; e.currentTarget.style.background = 'none' }}
>
(edited)
</button>
)}
{!editMode && (
<div
className="ml-auto flex items-center gap-1"
style={{
opacity: hovered || focused ? 1 : 0,
transition: 'opacity var(--duration-fast) ease-out',
}}
>
{onReply && (
<button
onClick={() => onReply(entry)}
className="inline-flex items-center gap-1 px-2 rounded action-btn"
style={{
minHeight: 44,
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
transition: 'color var(--duration-fast) ease-out',
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
>
<IconArrowBackUp size={11} stroke={2} aria-hidden="true" /> Reply
</button>
)}
{canEdit && (
<button
onClick={() => { setEditText(entry.content); setEditMode(true) }}
className="inline-flex items-center gap-1 px-2 rounded action-btn"
style={{
minHeight: 44,
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
transition: 'color var(--duration-fast) ease-out',
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
>
<IconEdit size={11} stroke={2} aria-hidden="true" /> Edit
</button>
)}
{canDelete && (
<button
onClick={async () => { if (await confirm('Delete this comment?')) onDelete?.(entry.id) }}
className="inline-flex items-center gap-1 px-2 rounded action-btn"
style={{
minHeight: 44,
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
transition: 'color var(--duration-fast) ease-out',
background: 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--error)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--error)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
>
<IconTrash size={11} stroke={2} aria-hidden="true" /> Delete
</button>
)}
{onLockComment && isCurrentAdmin && (
<button
onClick={() => onLockComment(entry.id)}
className="inline-flex items-center gap-1 px-2 rounded action-btn"
style={{
minHeight: 44,
color: entry.isEditLocked ? 'var(--error)' : 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
transition: 'color var(--duration-fast) ease-out',
background: entry.isEditLocked ? 'rgba(239, 68, 68, 0.1)' : 'var(--surface-hover)',
borderRadius: 'var(--radius-sm)',
}}
>
{entry.isEditLocked
? <><IconLock size={11} stroke={2} aria-hidden="true" /> Unlock</>
: <><IconLockOpen size={11} stroke={2} aria-hidden="true" /> Lock</>
}
</button>
)}
</div>
)}
</div>
{/* Quoted message */}
{entry.replyTo && (
<div
className="mb-3 px-3 py-2"
style={{
borderLeft: entry.replyTo.isAdmin
? '2px solid var(--admin-accent)'
: '2px solid var(--border-hover)',
background: 'var(--bg)',
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
fontSize: 'var(--text-xs)',
}}
>
<span
className="font-medium"
style={{ color: entry.replyTo.isAdmin ? 'var(--admin-accent)' : 'var(--text-secondary)' }}
>
{entry.replyTo.authorName}
</span>
<p style={{ color: 'var(--text-tertiary)', marginTop: 2, lineHeight: 1.4 }}>
{entry.replyTo.body.length > 150 ? entry.replyTo.body.slice(0, 150) + '...' : entry.replyTo.body}
</p>
</div>
)}
{/* Content or edit form */}
{editMode ? (
<div>
<MarkdownEditor
value={editText}
onChange={setEditText}
rows={3}
autoFocus
/>
<div className="flex gap-2 mt-2">
<button
onClick={() => { onEdit?.(entry.id, editText); setEditMode(false) }}
className="btn btn-primary"
style={{ fontSize: 'var(--text-xs)', padding: '6px 12px' }}
>
Save
</button>
<button
onClick={() => setEditMode(false)}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-xs)', padding: '6px 12px' }}
>
Cancel
</button>
</div>
</div>
) : (
<Markdown>{entry.content}</Markdown>
)}
{/* Comment attachments */}
{entry.attachments && entry.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{entry.attachments.map((att) => (
<a
key={att.id}
href={`/api/v1/attachments/${att.id}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
width: 80,
height: 80,
borderRadius: 'var(--radius-sm)',
overflow: 'hidden',
border: '1px solid var(--border)',
transition: 'border-color var(--duration-fast) ease-out',
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--accent)' }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)' }}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border)' }}
>
<img
src={`/api/v1/attachments/${att.id}`}
alt={att.filename}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/>
</a>
))}
</div>
)}
{/* Reactions */}
{!editMode && (
<div
className="flex items-center gap-1.5 mt-3 flex-wrap"
style={{
opacity: hovered || focused || (entry.reactions && entry.reactions.length > 0) ? 1 : 0,
transition: 'opacity var(--duration-normal) ease-out',
}}
>
{entry.reactions?.map((r) => (
<button
key={r.emoji}
onClick={() => onReact?.(entry.id, r.emoji)}
className="inline-flex items-center gap-1 px-2.5 rounded-full"
style={{
minHeight: 44,
fontSize: 'var(--text-xs)',
background: r.hasReacted ? 'var(--accent-subtle)' : 'var(--surface-hover)',
border: r.hasReacted ? '1px solid var(--border-accent)' : '1px solid transparent',
color: 'var(--text-secondary)',
cursor: 'pointer',
transition: 'all var(--duration-fast) ease-out',
}}
>
{r.emoji} {r.count}
</button>
))}
<div className="relative">
<button
onClick={() => setShowPicker(!showPicker)}
className="w-11 h-11 rounded-full flex items-center justify-center action-btn"
style={{
background: 'var(--surface-hover)',
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
aria-label="Add reaction"
>
+
</button>
{showPicker && (
<div
className="absolute bottom-full left-0 mb-2 flex gap-1 p-2 z-20 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-lg)',
}}
>
{quickEmojis.map((e) => (
<button
key={e}
onClick={() => { onReact?.(entry.id, e); setShowPicker(false) }}
className="w-11 h-11 rounded flex items-center justify-center"
style={{
transition: 'transform var(--duration-fast) var(--ease-spring)',
borderRadius: 'var(--radius-sm)',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.2)' }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
onFocus={(e) => { e.currentTarget.style.transform = 'scale(1.2)' }}
onBlur={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
>
{e}
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -14,42 +14,52 @@ export default function VoteBudget({ used, total, resetsAt }: Props) {
<div className="relative inline-flex items-center gap-1">
<div
className="flex items-center gap-1 cursor-help"
tabIndex={0}
role="group"
aria-label={`Vote budget: ${used} of ${total} used, ${remaining} remaining`}
onMouseEnter={() => setShowTip(true)}
onMouseLeave={() => setShowTip(false)}
onFocus={() => setShowTip(true)}
onBlur={() => setShowTip(false)}
>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
className="w-2 h-2 rounded-full"
className="rounded-full"
style={{
width: 6,
height: 6,
background: i < used ? 'var(--accent)' : 'var(--border-hover)',
transition: 'background 200ms ease-out',
transition: 'background var(--duration-fast) ease-out',
}}
/>
))}
</div>
<span className="text-xs ml-1" style={{ color: 'var(--text-tertiary)' }}>
<span className="ml-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{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"
role="tooltip"
className="absolute bottom-full left-0 mb-2 px-3 py-2 whitespace-nowrap z-50 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: 'var(--text-secondary)',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
fontSize: 'var(--text-xs)',
boxShadow: 'var(--shadow-lg)',
}}
>
<div className="font-medium mb-1" style={{ color: 'var(--text)' }}>
<div className="font-medium mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
Vote Budget
</div>
<div>{used} of {total} votes used</div>
{resetsAt && (
<div style={{ color: 'var(--text-tertiary)' }}>
Resets {new Date(resetsAt).toLocaleDateString()}
Resets <time dateTime={resetsAt}>{new Date(resetsAt).toLocaleDateString()}</time>
</div>
)}
</div>

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
import { api } from '../lib/api'
interface AdminState {
isAdmin: boolean
loading: boolean
role: string | null
displayName: string | null
teamTitle: string | null
isSuperAdmin: boolean
canInvite: boolean
canAccessSettings: boolean
refresh: () => Promise<void>
exitAdminMode: () => Promise<void>
}
const AdminContext = createContext<AdminState>({
isAdmin: false,
loading: true,
role: null,
displayName: null,
teamTitle: null,
isSuperAdmin: false,
canInvite: false,
canAccessSettings: false,
refresh: async () => {},
exitAdminMode: async () => {},
})
export const AdminProvider = AdminContext.Provider
export function useAdminState(): AdminState {
const [isAdmin, setIsAdmin] = useState(false)
const [loading, setLoading] = useState(true)
const [role, setRole] = useState<string | null>(null)
const [displayName, setDisplayName] = useState<string | null>(null)
const [teamTitle, setTeamTitle] = useState<string | null>(null)
const refresh = useCallback(async () => {
try {
const res = await api.get<{ isAdmin: boolean; role?: string; displayName?: string | null; teamTitle?: string | null }>('/admin/me')
setIsAdmin(res.isAdmin)
setRole(res.role ?? null)
setDisplayName(res.displayName ?? null)
setTeamTitle(res.teamTitle ?? null)
} catch {
setIsAdmin(false)
setRole(null)
} finally {
setLoading(false)
}
}, [])
const exitAdminMode = useCallback(async () => {
try {
await api.post('/admin/exit')
} catch {}
setIsAdmin(false)
setRole(null)
}, [])
useEffect(() => { refresh() }, [refresh])
const isSuperAdmin = role === 'SUPER_ADMIN'
const canInvite = role === 'SUPER_ADMIN' || role === 'ADMIN'
const canAccessSettings = role === 'SUPER_ADMIN'
return { isAdmin, loading, role, displayName, teamTitle, isSuperAdmin, canInvite, canAccessSettings, refresh, exitAdminMode }
}
export function useAdmin(): AdminState {
return useContext(AdminContext)
}

View File

@@ -1,10 +1,15 @@
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
import { api } from '../lib/api'
interface User {
id: string
displayName: string
username?: string
isPasskeyUser: boolean
avatarUrl?: string | null
darkMode?: string
hasRecoveryCode?: boolean
recoveryCodeExpiresAt?: string | null
createdAt: string
}
@@ -15,8 +20,8 @@ interface AuthState {
isPasskeyUser: boolean
displayName: string
initIdentity: () => Promise<void>
updateProfile: (data: { displayName: string }) => Promise<void>
deleteIdentity: () => Promise<void>
updateProfile: (data: { displayName: string; altcha?: string }) => Promise<void>
deleteIdentity: (altcha: string) => Promise<void>
refresh: () => Promise<void>
}
@@ -28,12 +33,23 @@ export function useAuthState(): AuthState {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const creating = useRef(false)
const fetchMe = useCallback(async () => {
try {
const u = await api.get<User>('/me')
setUser(u)
} catch {
setUser(null)
if (creating.current) return
creating.current = true
try {
const res = await api.post<User>('/identity', {})
setUser(res)
} catch {
setUser(null)
} finally {
creating.current = false
}
} finally {
setLoading(false)
}
@@ -41,20 +57,20 @@ export function useAuthState(): AuthState {
const initIdentity = useCallback(async () => {
try {
const u = await api.post<User>('/identity')
setUser(u)
const res = await api.post<User>('/identity')
setUser(res)
} catch {
await fetchMe()
}
}, [fetchMe])
const updateProfile = useCallback(async (data: { displayName: string }) => {
const updateProfile = useCallback(async (data: { displayName: string; altcha?: string }) => {
const u = await api.put<User>('/me', data)
setUser(u)
}, [])
const deleteIdentity = useCallback(async () => {
await api.delete('/me')
const deleteIdentity = useCallback(async (altcha: string) => {
await api.delete('/me', { altcha })
setUser(null)
}, [])

View File

@@ -0,0 +1,97 @@
import { createContext, useContext, useState, useEffect } from 'react'
interface SiteSettings {
appName: string
logoUrl: string | null
faviconUrl: string | null
accentColor: string
headerFont: string | null
bodyFont: string | null
poweredByVisible: boolean
customCss: string | null
}
const defaults: SiteSettings = {
appName: 'Echoboard',
logoUrl: null,
faviconUrl: null,
accentColor: '#F59E0B',
headerFont: null,
bodyFont: null,
poweredByVisible: true,
customCss: null,
}
const BrandingContext = createContext<SiteSettings>(defaults)
export function BrandingProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState<SiteSettings>(defaults)
useEffect(() => {
fetch('/api/v1/site-settings')
.then(r => r.ok ? r.json() : defaults)
.then(data => {
setSettings({ ...defaults, ...data })
})
.catch(() => {})
}, [])
// apply accent color as CSS variable
useEffect(() => {
if (settings.accentColor && settings.accentColor !== '#F59E0B') {
document.documentElement.style.setProperty('--accent', settings.accentColor)
document.documentElement.style.setProperty('--accent-subtle', settings.accentColor + '15')
}
if (settings.headerFont) {
document.documentElement.style.setProperty('--font-heading', `'${settings.headerFont}', sans-serif`)
}
if (settings.bodyFont) {
document.documentElement.style.setProperty('--font-body', `'${settings.bodyFont}', sans-serif`)
}
}, [settings])
// apply favicon
useEffect(() => {
if (settings.faviconUrl) {
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = settings.faviconUrl
}
}, [settings.faviconUrl])
// apply page title
useEffect(() => {
document.title = settings.appName
}, [settings.appName])
// apply custom CSS
useEffect(() => {
const id = 'custom-branding-css'
let style = document.getElementById(id) as HTMLStyleElement | null
if (settings.customCss) {
if (!style) {
style = document.createElement('style')
style.id = id
document.head.appendChild(style)
}
style.textContent = settings.customCss
} else if (style) {
style.remove()
}
return () => { document.getElementById(id)?.remove() }
}, [settings.customCss])
return (
<BrandingContext.Provider value={settings}>
{children}
</BrandingContext.Provider>
)
}
export function useBranding() {
return useContext(BrandingContext)
}

View File

@@ -0,0 +1,99 @@
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
import { useFocusTrap } from './useFocusTrap'
interface ConfirmState {
message: string
resolve: (value: boolean) => void
}
const ConfirmContext = createContext<(message: string) => Promise<boolean>>(
() => Promise.resolve(false)
)
export function useConfirm() {
return useContext(ConfirmContext)
}
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<ConfirmState | null>(null)
const trapRef = useFocusTrap(!!state)
const confirm = useCallback((message: string) => {
return new Promise<boolean>((resolve) => {
setState({ message, resolve })
})
}, [])
const close = (result: boolean) => {
state?.resolve(result)
setState(null)
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
{state && (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}
onClick={() => close(false)}
onKeyDown={(e) => { if (e.key === 'Escape') close(false) }}
>
<div
ref={trapRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
className="fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
padding: '24px',
maxWidth: 400,
width: '90%',
}}
onClick={(e) => e.stopPropagation()}
>
<h2
id="confirm-title"
className="font-medium mb-2"
style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}
>
Confirm
</h2>
<p
id="confirm-message"
className="mb-5"
style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.5 }}
>
{state.message}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => close(false)}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-sm)' }}
>
Cancel
</button>
<button
onClick={() => close(true)}
className="btn"
style={{
background: 'var(--error)',
color: '#fff',
fontSize: 'var(--text-sm)',
}}
>
Confirm
</button>
</div>
</div>
</div>
)}
</ConfirmContext.Provider>
)
}

View File

@@ -0,0 +1,11 @@
import { useEffect } from 'react'
import { useBranding } from './useBranding'
export function useDocumentTitle(title?: string) {
const { appName } = useBranding()
useEffect(() => {
document.title = title ? `${title} - ${appName}` : appName
return () => { document.title = appName }
}, [title, appName])
}

View File

@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react'
export function useFocusTrap(active: boolean) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!active || !ref.current) return
const el = ref.current
const prev = document.activeElement as HTMLElement | null
const focusable = () =>
el.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
// Focus first focusable element
const items = focusable()
if (items.length) items[0].focus()
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') return // let modals handle their own close
if (e.key !== 'Tab') return
const nodes = focusable()
if (!nodes.length) return
const first = nodes[0]
const last = nodes[nodes.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
el.addEventListener('keydown', onKeyDown)
return () => {
el.removeEventListener('keydown', onKeyDown)
prev?.focus()
}
}, [active])
return ref
}

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
import {
type Theme,
getStoredTheme,
@@ -18,9 +18,11 @@ const ThemeContext = createContext<ThemeState | null>(null)
export const ThemeProvider = ThemeContext.Provider
export function useThemeState(): ThemeState {
export function useThemeState(onSync?: (theme: Theme) => void): ThemeState {
const [theme, setThemeVal] = useState<Theme>(getStoredTheme)
const [resolved, setResolved] = useState<'dark' | 'light'>(() => resolveTheme(getStoredTheme()))
const syncRef = useRef(onSync)
syncRef.current = onSync
useEffect(() => {
initTheme()
@@ -30,6 +32,7 @@ export function useThemeState(): ThemeState {
applyTheme(t)
setThemeVal(t)
setResolved(resolveTheme(t))
syncRef.current?.(t)
}, [])
const toggle = useCallback(() => {

129
packages/web/src/i18n/en.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { Translations } from './types'
const en: Translations = {
// common
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
create: 'Create',
loading: 'Loading',
error: 'Error',
success: 'Success',
confirm: 'Confirm',
close: 'Close',
search: 'Search',
back: 'Back',
next: 'Next',
previous: 'Previous',
submit: 'Submit',
copy: 'Copy',
copied: 'Copied',
// navigation
home: 'Home',
roadmap: 'Roadmap',
changelog: 'Changelog',
activity: 'Activity',
myPosts: 'My Posts',
settings: 'Settings',
privacy: 'Privacy',
profile: 'Profile',
searchPlaceholder: 'Search...',
// auth
anonymous: 'Anonymous',
passkeyUser: 'Passkey user',
cookieIdentity: 'Cookie identity',
saveIdentity: 'Save my identity',
adminMode: 'Admin mode',
// posts
newPost: 'New post',
featureRequest: 'Feature request',
bugReport: 'Bug report',
votes: 'Votes',
comments: 'Comments',
pinned: 'Pinned',
stale: 'Stale',
noPostsYet: 'No posts yet',
submitFeedback: 'Submit feedback',
// status
statusUpdated: 'Status updated',
declinedReason: 'Declined reason',
// board
boards: 'Boards',
archived: 'Archived',
voteBudget: 'Vote budget',
// admin
dashboard: 'Dashboard',
posts: 'Posts',
categories: 'Categories',
tags: 'Tags',
webhooks: 'Webhooks',
embed: 'Embed',
statuses: 'Statuses',
templates: 'Templates',
dataRetention: 'Data retention',
exportData: 'Export data',
// notifications
notifications: 'Notifications',
markAllRead: 'Mark all read',
noNotifications: 'No notifications yet',
// profile
joinedDate: 'Joined',
postsCount: 'Posts',
commentsCount: 'Comments',
votesGiven: 'Votes given',
votesReceived: 'Votes received',
// embed
embedWidget: 'Embed widget',
inlineEmbed: 'Inline embed',
floatingButton: 'Floating button',
widgetType: 'Widget type',
buttonLabel: 'Button label',
position: 'Position',
// templates
requestTemplates: 'Request templates',
addField: 'Add field',
fieldLabel: 'Field label',
fieldType: 'Field type',
fieldRequired: 'Required',
// bulk
selectedCount: 'Selected',
changeStatus: 'Change status',
addTag: 'Add tag',
bulkDelete: 'Delete selected',
// export
exportFormat: 'Export format',
exportType: 'Export type',
allData: 'All data',
// importance
howImportant: 'How important is this?',
critical: 'Critical',
important: 'Important',
niceToHave: 'Nice to have',
minor: 'Minor',
// proxy
onBehalfOf: 'On behalf of',
submitOnBehalf: 'Submit on behalf',
// sidebar
you: 'You',
admin: 'Admin',
adminModeActive: 'Admin mode active',
viewAllActivity: 'View all activity',
}
export default en

View File

@@ -0,0 +1,34 @@
import { createContext, useContext, useEffect } from 'react'
import type { Translations } from './types'
import en from './en'
const translations: Record<string, Translations> = { en }
interface I18nState {
locale: string
t: (key: string) => string
}
const I18nContext = createContext<I18nState>({
locale: 'en',
t: (key) => en[key] ?? key,
})
export const TranslationProvider = I18nContext.Provider
export function useTranslationState(locale = 'en'): I18nState {
const dict = translations[locale] ?? translations.en
useEffect(() => {
document.documentElement.lang = locale
}, [locale])
return {
locale,
t: (key: string) => dict[key] ?? key,
}
}
export function useTranslation(): I18nState {
return useContext(I18nContext)
}

View File

@@ -0,0 +1,5 @@
export const locales = [
{ code: 'en', name: 'English' },
] as const
export type LocaleCode = (typeof locales)[number]['code']

View File

@@ -0,0 +1 @@
export type Translations = Record<string, string>

View File

@@ -0,0 +1,34 @@
import { api } from './api'
interface Challenge {
algorithm: string
challenge: string
maxnumber: number
salt: string
signature: string
}
async function hashHex(algorithm: string, data: string): Promise<string> {
const alg = algorithm.replace('-', '').toUpperCase() === 'SHA256' ? 'SHA-256' : algorithm
const buf = await crypto.subtle.digest(alg, new TextEncoder().encode(data))
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
}
export async function solveAltcha(difficulty: 'normal' | 'light' = 'normal'): Promise<string> {
const ch = await api.get<Challenge>(`/altcha/challenge?difficulty=${difficulty}`)
for (let n = 0; n <= ch.maxnumber; n++) {
const hash = await hashHex(ch.algorithm, ch.salt + n)
if (hash === ch.challenge) {
return btoa(JSON.stringify({
algorithm: ch.algorithm,
challenge: ch.challenge,
number: n,
salt: ch.salt,
signature: ch.signature,
}))
}
}
throw new Error('Failed to solve challenge')
}

View File

@@ -12,12 +12,13 @@ class ApiError extends Error {
}
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {}
if (opts.body) headers['Content-Type'] = 'application/json'
Object.assign(headers, opts.headers)
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...opts.headers,
},
headers,
...opts,
})
@@ -56,7 +57,11 @@ export const api = {
body: data ? JSON.stringify(data) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
delete: <T>(path: string, data?: unknown) =>
request<T>(path, {
method: 'DELETE',
body: data ? JSON.stringify(data) : undefined,
}),
}
export { ApiError }

View File

@@ -14,3 +14,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<App />
</React.StrictMode>,
)
if ('serviceWorker' in navigator && location.protocol === 'https:') {
navigator.serviceWorker.register('/sw.js').catch(() => {})
}

View File

@@ -1,152 +1,165 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import EmptyState from '../components/EmptyState'
import Dropdown from '../components/Dropdown'
import { IconPlus, IconTrendingUp, IconMessageCircle, IconShieldCheck, IconArrowUp, IconActivity } from '@tabler/icons-react'
import type { Icon } from '@tabler/icons-react'
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
type: 'post_created' | 'status_changed' | 'comment_created' | 'admin_responded' | 'vote'
board: { slug: string; name: string } | null
post: { id: string; title: string } | null
metadata: Record<string, string> | null
createdAt: string
}
const typeLabels: Record<string, string> = {
post_created: 'created a post',
status_changed: 'changed status',
comment_added: 'commented',
admin_response: 'responded',
comment_created: 'commented',
admin_responded: '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>
),
const typeIcons: Record<string, Icon> = {
post_created: IconPlus,
status_changed: IconTrendingUp,
comment_created: IconMessageCircle,
admin_responded: IconShieldCheck,
vote: IconArrowUp,
}
function ActivitySkeleton() {
return (
<div className="flex flex-col gap-1">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-start gap-3 p-3" style={{ opacity: 1 - i * 0.15 }}>
<div className="skeleton w-7 h-7 rounded-full shrink-0" />
<div className="flex-1">
<div className="skeleton h-4 mb-2" style={{ width: '70%' }} />
<div className="skeleton h-3" style={{ width: '40%' }} />
</div>
</div>
))}
</div>
)
}
export default function ActivityFeed() {
useDocumentTitle('Activity')
const [activities, setActivities] = useState<Activity[]>([])
const [loading, setLoading] = useState(true)
const [boardFilter, setBoardFilter] = useState('')
const [typeFilter, setTypeFilter] = useState('')
useEffect(() => {
setLoading(true)
const params = new URLSearchParams()
if (boardFilter) params.set('board', boardFilter)
if (typeFilter) params.set('type', typeFilter)
api.get<Activity[]>(`/activity?${params}`)
.then(setActivities)
api.get<{ events: Activity[] }>(`/activity?${params}`)
.then((r) => setActivities(r.events))
.catch(() => {})
.finally(() => setLoading(false))
}, [boardFilter, typeFilter])
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<h1
className="text-2xl font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
className="font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
>
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 style={{ maxWidth: 200, flex: 1 }}>
<Dropdown
value={boardFilter}
onChange={setBoardFilter}
placeholder="All boards"
options={[{ value: '', label: 'All boards' }]}
/>
</div>
<div style={{ maxWidth: 200, flex: 1 }}>
<Dropdown
value={typeFilter}
onChange={setTypeFilter}
placeholder="All types"
options={[
{ value: '', label: 'All types' },
{ value: 'post_created', label: 'Posts' },
{ value: 'comment_created', label: 'Comments' },
{ value: 'status_changed', label: 'Status changes' },
{ value: 'admin_responded', label: 'Admin responses' },
{ value: 'vote', label: 'Votes' },
]}
/>
</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(--accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
<>
<div className="progress-bar mb-4" />
<ActivitySkeleton />
</>
) : activities.length === 0 ? (
<div className="text-center py-12">
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No activity yet</p>
</div>
<EmptyState
icon={IconActivity}
title="No activity yet"
message="Activity from boards you follow will show up here"
/>
) : (
<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"
{activities.filter((a) => a.post && a.board).map((a, i) => {
const Icon = typeIcons[a.type] || IconPlus
const postTitle = a.post!.title || a.metadata?.title || 'Untitled'
return (
<Link
key={a.id}
to={`/b/${a.board!.slug}/post/${a.post!.id}`}
className="flex items-start gap-3 stagger-in"
style={{
background: a.type === 'admin_response' ? 'var(--admin-subtle)' : 'var(--accent-subtle)',
color: a.type === 'admin_response' ? 'var(--admin-accent)' : 'var(--accent)',
}}
'--stagger': i,
padding: '12px',
borderRadius: 'var(--radius-md)',
transition: 'background var(--duration-fast) ease-out',
} as React.CSSProperties}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
onFocus={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
onBlur={(e) => (e.currentTarget.style.background = 'transparent')}
>
{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
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5"
style={{
background: a.type === 'admin_responded' ? 'var(--admin-subtle)' : 'var(--accent-subtle)',
color: a.type === 'admin_responded' ? 'var(--admin-accent)' : 'var(--accent)',
}}
>
<Icon size={14} stroke={2} />
</div>
<div className="flex-1 min-w-0">
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
{typeLabels[a.type] || a.type}{' '}
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{postTitle}</span>
</p>
{a.metadata?.status && (
<p className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
{a.metadata.status.replace(/_/g, ' ').toLowerCase()}
</p>
)}
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 4 }}>
{a.board!.name} - <time dateTime={a.createdAt}>{new Date(a.createdAt).toLocaleDateString()}</time>
</p>
</div>
</Link>
)
})}
</div>
)}
</div>

View File

@@ -1,23 +1,46 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import { useParams, useSearchParams, Link } from 'react-router-dom'
import { api } from '../lib/api'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import PostCard from '../components/PostCard'
import PostForm from '../components/PostForm'
import VoteBudget from '../components/VoteBudget'
import EmptyState from '../components/EmptyState'
import PluginSlot from '../components/PluginSlot'
import { IconExternalLink, IconRss, IconX, IconFilter, IconChevronDown, IconPlus, IconBell, IconBellOff } from '@tabler/icons-react'
import BoardIcon from '../components/BoardIcon'
import Dropdown from '../components/Dropdown'
import { solveAltcha } from '../lib/altcha'
interface Post {
id: string
title: string
excerpt?: string
type: 'feature' | 'bug' | 'general'
description?: Record<string, string>
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
status: string
category?: string | null
voteCount: number
commentCount: number
authorName: string
viewCount?: number
isPinned?: boolean
isStale?: boolean
author?: { id: string; displayName: string; avatarUrl?: string | null } | null
createdAt: string
boardSlug: string
hasVoted?: boolean
voted?: boolean
}
interface PostsResponse {
posts: Post[]
total: number
page: number
pages: number
}
interface BoardStatusConfig {
status: string
label: string
color: string
}
interface Board {
@@ -25,170 +48,538 @@ interface Board {
name: string
slug: string
description: string
externalUrl: string | null
iconName: string | null
iconColor: string | null
statuses?: BoardStatusConfig[]
}
interface Budget {
used: number
total: number
resetsAt?: string
remaining: number
resetSchedule: string
nextReset: string
}
type SortOption = 'newest' | 'top' | 'trending'
type StatusFilter = 'all' | 'OPEN' | 'PLANNED' | 'IN_PROGRESS' | 'DONE' | 'DECLINED'
function PostCardSkeleton({ index }: { index: number }) {
return (
<div
className="card card-static flex gap-0 overflow-hidden"
style={{ animationDelay: `${index * 60}ms` }}
>
<div className="hidden md:flex flex-col items-center justify-center px-4 py-5 shrink-0" style={{ width: 56, borderRight: '1px solid var(--border)' }}>
<div className="skeleton" style={{ width: 16, height: 16, borderRadius: 4, marginBottom: 6 }} />
<div className="skeleton" style={{ width: 20, height: 14, borderRadius: 4 }} />
</div>
<div className="flex-1 py-4 px-5">
<div className="flex gap-2 mb-2">
<div className="skeleton" style={{ width: 60, height: 18, borderRadius: 6 }} />
<div className="skeleton" style={{ width: 50, height: 18, borderRadius: 6 }} />
</div>
<div className="skeleton skeleton-title" style={{ width: '70%' }} />
<div className="skeleton skeleton-text" style={{ width: '90%' }} />
</div>
<div className="hidden md:flex flex-col items-end justify-center px-5 py-4 shrink-0 gap-2">
<div className="skeleton" style={{ width: 70, height: 20, borderRadius: 6 }} />
<div className="skeleton" style={{ width: 40, height: 14, borderRadius: 4 }} />
</div>
</div>
)
}
export default function BoardFeed() {
const { boardSlug } = useParams<{ boardSlug: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const [board, setBoard] = useState<Board | null>(null)
useDocumentTitle(board?.name)
const [posts, setPosts] = useState<Post[]>([])
const [budget, setBudget] = useState<Budget>({ used: 0, total: 10 })
const [pagination, setPagination] = useState({ page: 1, pages: 1, total: 0 })
const [budget, setBudget] = useState<Budget | null>(null)
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 [formOpen, setFormOpen] = useState(false)
const [filtersOpen, setFiltersOpen] = useState(false)
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
const [subscribed, setSubscribed] = useState(false)
const [subLoading, setSubLoading] = useState(false)
const sort = searchParams.get('sort') ?? 'newest'
const statusFilter = searchParams.get('status') ?? ''
const typeFilter = searchParams.get('type') ?? ''
const categoryFilter = searchParams.get('category') ?? ''
const page = parseInt(searchParams.get('page') ?? '1', 10)
const activeFilters: { key: string; label: string }[] = []
if (statusFilter) activeFilters.push({ key: 'status', label: statusFilter.replace('_', ' ').toLowerCase() })
if (typeFilter) activeFilters.push({ key: 'type', label: typeFilter === 'BUG_REPORT' ? 'Bug reports' : 'Feature requests' })
if (categoryFilter) activeFilters.push({ key: 'category', label: categoryFilter })
if (sort !== 'newest') activeFilters.push({ key: 'sort', label: sort })
const setFilter = (key: string, value: string | null) => {
const next = new URLSearchParams(searchParams)
if (value) next.set(key, value)
else next.delete(key)
next.delete('page')
setSearchParams(next)
}
const dismissFilter = (key: string) => {
const next = new URLSearchParams(searchParams)
next.delete(key)
next.delete('page')
setSearchParams(next)
}
const setPage = (p: number) => {
const next = new URLSearchParams(searchParams)
if (p <= 1) next.delete('page')
else next.set('page', String(p))
setSearchParams(next)
}
useEffect(() => {
api.get<{ id: string; name: string; slug: string }[]>('/categories')
.then(setCategories)
.catch(() => {})
}, [])
useEffect(() => {
if (!boardSlug) return
api.get<{ subscribed: boolean }>(`/boards/${boardSlug}/subscription`)
.then((r) => setSubscribed(r.subscribed))
.catch(() => {})
}, [boardSlug])
const toggleSubscription = async () => {
if (subLoading || !boardSlug) return
setSubLoading(true)
try {
if (subscribed) {
await api.delete(`/boards/${boardSlug}/subscribe`)
setSubscribed(false)
} else {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
alert('Push notifications are not supported in this browser')
return
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') return
const reg = await navigator.serviceWorker.ready
const vapid = await api.get<{ publicKey: string }>('/push/vapid')
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapid.publicKey,
})
const json = sub.toJSON()
await api.post(`/boards/${boardSlug}/subscribe`, {
endpoint: json.endpoint,
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
})
setSubscribed(true)
}
} catch {} finally {
setSubLoading(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 params = new URLSearchParams()
if (sort !== 'newest') params.set('sort', sort)
if (statusFilter) params.set('status', statusFilter)
if (typeFilter) params.set('type', typeFilter)
if (categoryFilter) params.set('category', categoryFilter)
if (search) params.set('search', search)
if (page > 1) params.set('page', String(page))
const [b, p, bud] = await Promise.all([
const [b, res, 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 })),
api.get<PostsResponse>(`/boards/${boardSlug}/posts?${params}`),
api.get<Budget>(`/boards/${boardSlug}/budget`).catch(() => null),
])
setBoard(b)
setPosts(p)
setBudget(bud as Budget)
setPosts(res.posts.map((p) => ({ ...p, boardSlug: boardSlug! })))
setPagination({ page: res.page, pages: res.pages, total: res.total })
setBudget(bud)
} catch {
setPosts([])
} finally {
setLoading(false)
}
}, [boardSlug, sort, statusFilter, search])
}, [boardSlug, sort, statusFilter, typeFilter, categoryFilter, search, page])
useEffect(() => { fetchPosts() }, [fetchPosts])
const refreshBudget = () => {
if (boardSlug) {
api.get<Budget>(`/boards/${boardSlug}/budget`).then(setBudget).catch(() => {})
}
}
const [importancePostId, setImportancePostId] = useState<string | null>(null)
const handleVote = async (postId: string) => {
setPosts((prev) => prev.map((p) =>
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount + 1 } : p
))
if (budget) setBudget({ ...budget, remaining: budget.remaining - 1 })
try {
await api.post(`/posts/${postId}/vote`)
fetchPosts()
const altcha = await solveAltcha('light')
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
refreshBudget()
setImportancePostId(postId)
} catch {
setPosts((prev) => prev.map((p) =>
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount - 1 } : p
))
refreshBudget()
}
}
const handleImportance = async (postId: string, importance: string) => {
setImportancePostId(null)
try {
await api.put(`/boards/${boardSlug}/posts/${postId}/vote/importance`, { importance })
} 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' },
]
const handleUnvote = async (postId: string) => {
setPosts((prev) => prev.map((p) =>
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount - 1 } : p
))
if (budget) setBudget({ ...budget, remaining: budget.remaining + 1 })
try {
await api.delete(`/boards/${boardSlug}/posts/${postId}/vote`)
refreshBudget()
} catch {
setPosts((prev) => prev.map((p) =>
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount + 1 } : p
))
refreshBudget()
}
}
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<div className="mx-auto px-6 py-10" style={{ maxWidth: 'var(--content-max)' }}>
{/* Breadcrumb */}
{board && (
<nav aria-label="Breadcrumb" className="mb-4">
<ol className="flex items-center gap-1.5" style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 'var(--text-sm)', color: 'var(--text-tertiary)' }}>
<li>
<Link to="/" style={{ color: 'var(--text-tertiary)', transition: 'color var(--duration-fast) ease-out' }} onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)' }} onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }} onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)' }} onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}>Home</Link>
</li>
<li aria-hidden="true" style={{ color: 'var(--text-tertiary)' }}>/</li>
<li>
<span aria-current="page" style={{ color: 'var(--text-secondary)' }}>{board.name}</span>
</li>
</ol>
</nav>
)}
{/* 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)' }}>
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<BoardIcon name={board.name} iconName={board.iconName} iconColor={board.iconColor} size={40} />
<h1
style={{
fontFamily: 'var(--font-heading)',
fontSize: 'var(--text-2xl)',
fontWeight: 700,
color: 'var(--text)',
}}
>
{board.name}
</h1>
{board.externalUrl && /^https?:\/\//i.test(board.externalUrl) && (
<a
href={board.externalUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-2.5 py-1 rounded-full action-btn"
style={{
fontSize: 'var(--text-xs)',
color: 'var(--text-tertiary)',
background: 'var(--surface)',
border: '1px solid var(--border)',
}}
>
<IconExternalLink size={12} stroke={2} />
Source
</a>
)}
<a
href={`/api/v1/boards/${boardSlug}/feed.rss`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-2.5 py-1 rounded-full action-btn"
style={{
fontSize: 'var(--text-xs)',
color: 'var(--text-tertiary)',
background: 'var(--surface)',
border: '1px solid var(--border)',
}}
title="RSS feed"
>
<IconRss size={12} stroke={2} />
RSS
</a>
<button
onClick={toggleSubscription}
disabled={subLoading}
className="flex items-center gap-1 px-2.5 rounded-full action-btn"
style={{
fontSize: 'var(--text-xs)',
minHeight: 36,
color: subscribed ? 'var(--accent)' : 'var(--text-tertiary)',
background: subscribed ? 'var(--accent-subtle)' : 'var(--surface)',
border: subscribed ? '1px solid var(--border-accent)' : '1px solid var(--border)',
cursor: subLoading ? 'wait' : 'pointer',
opacity: subLoading ? 0.6 : 1,
transition: 'all var(--duration-fast) ease-out',
}}
title={subscribed ? 'Unsubscribe from new posts' : 'Get notified of new posts'}
aria-label={subscribed ? 'Unsubscribe from board notifications' : 'Subscribe to board notifications'}
>
{subscribed ? <IconBell size={12} stroke={2} /> : <IconBellOff size={12} stroke={2} />}
{subscribed ? 'Subscribed' : 'Subscribe'}
</button>
</div>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
{board.description}
</p>
</div>
)}
{/* Budget */}
<div className="mb-4">
<VoteBudget used={budget.used} total={budget.total} resetsAt={budget.resetsAt} />
</div>
<PluginSlot name="board-feed-top" />
{/* Filter bar */}
<div className="flex flex-wrap items-center gap-3 mb-4">
<div className="flex-1 min-w-[200px]">
{/* Budget */}
{budget && (
<div className="mb-5">
<VoteBudget used={budget.total - budget.remaining} total={budget.total} resetsAt={budget.nextReset} />
</div>
)}
{/* Toolbar: search + filters + new post */}
<div className="flex items-center gap-3 mb-4">
<div className="flex-1">
<input
className="input"
placeholder="Search posts..."
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Search posts"
/>
</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"
<button
onClick={() => setFiltersOpen(!filtersOpen)}
className="btn btn-secondary"
aria-label="Filters"
aria-expanded={filtersOpen}
style={{
color: filtersOpen || activeFilters.length > 0 ? 'var(--accent)' : 'var(--text-secondary)',
borderColor: filtersOpen ? 'var(--border-accent)' : undefined,
}}
>
<IconFilter size={15} stroke={2} />
<span className="hidden sm:inline">Filters</span>
{activeFilters.length > 0 && (
<span
className="inline-flex items-center justify-center rounded-full font-bold"
style={{
background: sort === o.value ? 'var(--accent-subtle)' : 'transparent',
color: sort === o.value ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'all 200ms ease-out',
width: 18,
height: 18,
background: 'var(--accent)',
color: '#161616',
fontSize: 'var(--text-xs)',
}}
>
{o.label}
{activeFilters.length}
</span>
)}
</button>
<button
onClick={() => setFormOpen(!formOpen)}
className="btn btn-primary flex items-center gap-2"
aria-expanded={formOpen}
>
<IconPlus size={15} stroke={2.5} />
<span className="hidden sm:inline">Feedback</span>
</button>
</div>
{/* Filter bar - horizontal, in content area */}
{filtersOpen && (
<div
className="flex flex-wrap items-center gap-3 mb-5 p-4 rounded-xl slide-down"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div style={{ minWidth: 140 }}>
<Dropdown
value={typeFilter}
onChange={(v) => setFilter('type', v || null)}
placeholder="All types"
options={[
{ value: '', label: 'All types' },
{ value: 'FEATURE_REQUEST', label: 'Feature requests' },
{ value: 'BUG_REPORT', label: 'Bug reports' },
]}
/>
</div>
<div style={{ minWidth: 140 }}>
<Dropdown
value={statusFilter}
onChange={(v) => setFilter('status', v || null)}
placeholder="All statuses"
options={[
{ value: '', label: 'All statuses' },
...(board?.statuses || []).map((s) => ({ value: s.status, label: s.label })),
]}
/>
</div>
{categories.length > 0 && (
<div style={{ minWidth: 140 }}>
<Dropdown
value={categoryFilter}
onChange={(v) => setFilter('category', v || null)}
placeholder="All categories"
options={[
{ value: '', label: 'All categories' },
...categories.map((c) => ({ value: c.slug, label: c.name })),
]}
/>
</div>
)}
<div style={{ minWidth: 140 }}>
<Dropdown
value={sort}
onChange={(v) => setFilter('sort', v === 'newest' ? null : v)}
options={[
{ value: 'newest', label: 'Newest' },
{ value: 'oldest', label: 'Oldest' },
{ value: 'top', label: 'Most voted' },
{ value: 'updated', label: 'Recently updated' },
]}
/>
</div>
</div>
)}
{/* Active filter pills */}
{activeFilters.length > 0 && (
<div className="flex flex-wrap gap-2 mb-5">
{activeFilters.map((f) => (
<button
key={f.key}
onClick={() => dismissFilter(f.key)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full action-btn"
style={{
fontSize: 'var(--text-xs)',
background: 'var(--accent-subtle)',
color: 'var(--accent)',
border: '1px solid var(--border-accent)',
cursor: 'pointer',
transition: 'all var(--duration-fast) ease-out',
}}
>
{f.label}
<IconX size={12} stroke={3} />
</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' }}
/>
{/* Post form with expand animation */}
<div
style={{
display: 'grid',
gridTemplateRows: formOpen ? '1fr' : '0fr',
opacity: formOpen ? 1 : 0,
transition: `grid-template-rows var(--duration-slow) var(--ease-out), opacity var(--duration-normal) ease-out`,
}}
>
<div style={{ overflow: 'hidden' }}>
{boardSlug && (
<div className="mb-6">
<PostForm boardSlug={boardSlug} boardId={board?.id} onSubmit={() => { setFormOpen(false); fetchPosts() }} onCancel={() => setFormOpen(false)} />
</div>
)}
</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>
{/* Loading skeletons */}
<div aria-live="polite" aria-busy={loading}>
{loading && (
<div className="flex flex-col gap-3">
{/* Progress bar */}
<div className="progress-bar mb-2" />
{[0, 1, 2, 3, 4].map((i) => (
<PostCardSkeleton key={i} index={i} />
))}
</div>
)}
{/* Empty state */}
{!loading && posts.length === 0 && (
<EmptyState onAction={() => setFormOpen(true)} />
)}
{/* Posts */}
{!loading && posts.length > 0 && (
<>
{posts.some(p => p.isPinned) && (
<div
className="mb-6 pb-6"
style={{ borderBottom: '1px solid var(--border)' }}
>
<div
className="font-medium uppercase tracking-wider mb-3 px-1"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em' }}
>
Pinned
</div>
<div className="flex flex-col gap-3">
{posts.filter(p => p.isPinned).map((post, i) => (
<PostCard key={post.id} post={post} onVote={handleVote} onUnvote={handleUnvote} onImportance={handleImportance} showImportancePopup={importancePostId === post.id} budgetDepleted={budget ? budget.remaining <= 0 : false} customStatuses={board?.statuses} index={i} />
))}
</div>
</div>
)}
<div className="flex flex-col gap-3">
{posts.filter(p => !p.isPinned).map((post, i) => (
<PostCard key={post.id} post={post} onVote={handleVote} onUnvote={handleUnvote} onImportance={handleImportance} showImportancePopup={importancePostId === post.id} budgetDepleted={budget ? budget.remaining <= 0 : false} index={i} />
))}
</div>
{/* Pagination */}
{pagination.pages > 1 && (
<div className="flex items-center justify-center gap-3 mt-10">
<button
onClick={() => setPage(page - 1)}
disabled={page <= 1}
className="btn btn-secondary"
style={{ opacity: page <= 1 ? 0.4 : 1 }}
>
Previous
</button>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
{page} / {pagination.pages}
</span>
<button
onClick={() => setPage(page + 1)}
disabled={page >= pagination.pages}
className="btn btn-secondary"
style={{ opacity: page >= pagination.pages ? 0.4 : 1 }}
>
Next
</button>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -1,18 +1,57 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import PluginSlot from '../components/PluginSlot'
import { IconExternalLink, IconChevronRight, IconMessages, IconCircleDot, IconClock, IconArchive } from '@tabler/icons-react'
import BoardIcon from '../components/BoardIcon'
interface Board {
id: string
slug: string
name: string
description: string
externalUrl: string | null
iconName: string | null
iconColor: string | null
postCount: number
openCount: number
lastActivity: string | null
archived: boolean
}
function timeAgo(date: 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`
return `${Math.floor(days / 30)}mo ago`
}
function BoardCardSkeleton({ index }: { index: number }) {
return (
<div
className="card card-static p-6"
style={{ animationDelay: `${index * 80}ms` }}
>
<div className="skeleton skeleton-title" style={{ width: '50%' }} />
<div className="skeleton skeleton-text" style={{ width: '90%' }} />
<div className="skeleton skeleton-text" style={{ width: '70%' }} />
<div className="flex gap-4 mt-4">
<div className="skeleton" style={{ width: 70, height: 14 }} />
<div className="skeleton" style={{ width: 60, height: 14 }} />
<div className="skeleton" style={{ width: 50, height: 14 }} />
</div>
</div>
)
}
export default function BoardIndex() {
useDocumentTitle('Boards')
const [boards, setBoards] = useState<Board[]>([])
const [loading, setLoading] = useState(true)
const [showArchived, setShowArchived] = useState(false)
@@ -27,110 +66,170 @@ export default function BoardIndex() {
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">
<div className="mx-auto px-6 py-10" style={{ maxWidth: 'var(--content-max)' }}>
{/* Hero */}
<div className="mb-10">
<h1
className="text-3xl font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
style={{
fontFamily: 'var(--font-heading)',
fontSize: 'var(--text-3xl)',
fontWeight: 700,
color: 'var(--text)',
lineHeight: 1.1,
}}
>
Feedback Boards
Feedback
</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Choose a board to browse or submit feedback
<p
className="mt-3"
style={{
color: 'var(--text-secondary)',
fontSize: 'var(--text-lg)',
lineHeight: 1.6,
}}
>
Browse boards and share what matters
</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>
<PluginSlot name="board-index-top" />
{archived.length > 0 && (
<div className="mt-10">
{/* Loading skeletons */}
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{[0, 1, 2, 3].map((i) => (
<BoardCardSkeleton key={i} index={i} />
))}
</div>
)}
{/* Board grid */}
{!loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{active.map((board, i) => (
<Link
key={board.id}
to={`/b/${board.slug}`}
className="card card-interactive p-6 block stagger-in"
style={{ '--stagger': i } as React.CSSProperties}
>
<div className="flex items-center gap-3 mb-2">
<BoardIcon name={board.name} iconName={board.iconName} iconColor={board.iconColor} size={36} />
<h2
style={{
fontFamily: 'var(--font-heading)',
fontSize: 'var(--text-xl)',
fontWeight: 700,
color: 'var(--text)',
transition: 'color var(--duration-normal) ease-out',
}}
>
{board.name}
</h2>
{board.externalUrl && (
<IconExternalLink size={14} stroke={2} style={{ color: 'var(--text-tertiary)' }} />
)}
</div>
{board.description && (
<p
className="mb-4 line-clamp-2"
style={{
fontSize: 'var(--text-sm)',
color: 'var(--text-secondary)',
lineHeight: 1.6,
}}
>
{board.description}
</p>
)}
<div className="flex items-center gap-5" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
<span className="inline-flex items-center gap-1.5">
<IconMessages size={13} stroke={2} />
{board.postCount} posts
</span>
<span className="inline-flex items-center gap-1.5" style={{ color: 'var(--accent)' }}>
<IconCircleDot size={13} stroke={2} />
{board.openCount} open
</span>
{board.lastActivity && (
<span className="inline-flex items-center gap-1.5">
<IconClock size={13} stroke={2} />
<time dateTime={board.lastActivity}>{timeAgo(board.lastActivity)}</time>
</span>
)}
</div>
</Link>
))}
</div>
)}
{/* Empty state */}
{!loading && active.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 fade-in">
<div
className="w-20 h-20 rounded-full flex items-center justify-center mb-6"
style={{ background: 'var(--accent-subtle)' }}
>
<IconMessages size={36} stroke={1.5} style={{ color: 'var(--accent)' }} />
</div>
<h2
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-lg)' }}
>
No boards yet
</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
Boards will appear here once an admin creates them
</p>
</div>
)}
{/* Archived boards */}
{!loading && archived.length > 0 && (
<div className="mt-12">
<button
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-2 text-sm mb-4"
style={{ color: 'var(--text-tertiary)' }}
aria-expanded={showArchived}
className="flex items-center gap-2 mb-4"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}
>
<svg
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
<IconChevronRight
size={14}
stroke={2}
style={{
transition: 'transform 200ms ease-out',
transition: 'transform var(--duration-normal) var(--ease-out)',
transform: showArchived ? 'rotate(90deg)' : 'rotate(0deg)',
}}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
Archived boards ({archived.length})
/>
<IconArchive size={14} stroke={2} />
Archived ({archived.length})
</button>
{showArchived && (
<div className="grid gap-3 md:grid-cols-2 fade-in">
{archived.map((board) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{archived.map((board, i) => (
<Link
key={board.id}
to={`/b/${board.slug}`}
className="card p-4 block opacity-60"
className="card card-interactive p-5 block stagger-in"
style={{ '--stagger': i, opacity: 0.6 } as React.CSSProperties}
>
<h3 className="text-sm font-medium mb-1" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
<h2
className="font-semibold mb-1"
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--text)',
fontSize: 'var(--text-base)',
}}
>
{board.name}
</h3>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
</h2>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{board.postCount} posts - archived
</p>
</span>
</Link>
))}
</div>

View File

@@ -0,0 +1,99 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { api } from '../lib/api'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import Markdown from '../components/Markdown'
interface Entry {
id: string
title: string
body: string
publishedAt: string
}
export default function ChangelogPage() {
const { boardSlug } = useParams()
useDocumentTitle(boardSlug ? `${boardSlug} Changelog` : 'Changelog')
const [entries, setEntries] = useState<Entry[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
const url = boardSlug ? `/b/${boardSlug}/changelog` : '/changelog'
api.get<{ entries: Entry[] }>(url)
.then((r) => setEntries(r.entries))
.catch(() => {})
.finally(() => setLoading(false))
}, [boardSlug])
return (
<div style={{ maxWidth: 680, margin: '0 auto', padding: '32px 24px' }}>
<div className="mb-8">
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
>
Changelog
</h1>
<p className="mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
Latest updates and improvements
</p>
</div>
{loading ? (
<div className="flex flex-col gap-6">
{[0, 1, 2].map((i) => (
<div key={i} style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton h-3 w-24 rounded mb-2" />
<div className="skeleton h-5 w-2/3 rounded mb-3" />
<div className="skeleton h-16 w-full rounded" />
</div>
))}
</div>
) : entries.length === 0 ? (
<div className="text-center py-12" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
No changelog entries yet
</div>
) : (
<div className="relative" style={{ paddingLeft: 24 }}>
{/* Timeline line */}
<div
className="absolute top-2 bottom-2"
style={{ left: 5, width: 2, background: 'var(--border)', borderRadius: 1 }}
/>
{entries.map((entry, i) => (
<div key={entry.id} className="relative mb-8" style={{ paddingBottom: i === entries.length - 1 ? 0 : undefined }}>
{/* Timeline dot */}
<div
className="absolute"
style={{
left: -22,
top: 6,
width: 10,
height: 10,
background: i === 0 ? 'var(--accent)' : 'var(--surface-hover)',
border: `2px solid ${i === 0 ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: '50%',
}}
/>
<time
dateTime={entry.publishedAt}
className="block mb-1 font-medium"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
>
{new Date(entry.publishedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}
</time>
<h2
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-lg)' }}
>
{entry.title}
</h2>
<Markdown>{entry.body}</Markdown>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,194 @@
import { useState, useEffect } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { useBranding } from '../hooks/useBranding'
interface EmbedPost {
id: string
title: string
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
status: string
voteCount: number
isPinned: boolean
commentCount: number
createdAt: string
}
interface EmbedData {
board: { name: string; slug: string }
posts: EmbedPost[]
}
const STATUS_DARK: Record<string, { bg: string; color: string; label: string }> = {
OPEN: { bg: 'rgba(245,158,11,0.12)', color: '#F59E0B', label: 'Open' },
UNDER_REVIEW: { bg: 'rgba(8,196,228,0.12)', color: '#08C4E4', label: 'Under Review' },
PLANNED: { bg: 'rgba(109,181,252,0.12)', color: '#6DB5FC', label: 'Planned' },
IN_PROGRESS: { bg: 'rgba(234,179,8,0.12)', color: '#EAB308', label: 'In Progress' },
DONE: { bg: 'rgba(34,197,94,0.12)', color: '#22C55E', label: 'Done' },
DECLINED: { bg: 'rgba(249,138,138,0.12)', color: '#F98A8A', label: 'Declined' },
}
const STATUS_LIGHT: Record<string, { bg: string; color: string; label: string }> = {
OPEN: { bg: 'rgba(112,73,9,0.12)', color: '#704909', label: 'Open' },
UNDER_REVIEW: { bg: 'rgba(10,92,115,0.12)', color: '#0A5C73', label: 'Under Review' },
PLANNED: { bg: 'rgba(26,64,176,0.12)', color: '#1A40B0', label: 'Planned' },
IN_PROGRESS: { bg: 'rgba(116,67,12,0.12)', color: '#74430C', label: 'In Progress' },
DONE: { bg: 'rgba(22,101,52,0.12)', color: '#166534', label: 'Done' },
DECLINED: { bg: 'rgba(153,25,25,0.12)', color: '#991919', label: 'Declined' },
}
function timeAgo(date: string): string {
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
if (s < 60) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
if (d < 30) return `${d}d ago`
return `${Math.floor(d / 30)}mo ago`
}
export default function EmbedBoard() {
const { boardSlug } = useParams<{ boardSlug: string }>()
const [searchParams] = useSearchParams()
const [data, setData] = useState<EmbedData | null>(null)
const [error, setError] = useState(false)
const { appName, poweredByVisible } = useBranding()
const theme = searchParams.get('theme') || 'dark'
const limit = searchParams.get('limit') || '10'
const sort = searchParams.get('sort') || 'top'
const isDark = theme === 'dark'
useEffect(() => {
if (!boardSlug) return
const params = new URLSearchParams({ limit, sort })
fetch(`/api/v1/embed/${boardSlug}/posts?${params}`)
.then((r) => { if (!r.ok) throw new Error(); return r.json() })
.then(setData)
.catch(() => setError(true))
}, [boardSlug, limit, sort])
const colors = isDark
? { bg: '#161616', surface: '#1e1e1e', border: 'rgba(255,255,255,0.08)', text: '#f0f0f0', textSec: 'rgba(240,240,240,0.72)', textTer: 'rgba(240,240,240,0.71)', accent: '#F59E0B' }
: { bg: '#f7f8fa', surface: '#ffffff', border: 'rgba(0,0,0,0.08)', text: '#1a1a1a', textSec: '#4a4a4a', textTer: '#545454', accent: '#704909' }
if (error) {
return (
<div style={{ padding: 24, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', color: colors.textSec, fontSize: 14, background: colors.bg, minHeight: '100vh' }}>
Board not found
</div>
)
}
if (!data) {
return (
<div style={{ padding: 24, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', color: colors.textTer, fontSize: 14, background: colors.bg, minHeight: '100vh' }}>
Loading...
</div>
)
}
// build the base URL for linking back to the main app
const origin = window.location.origin
return (
<div style={{ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', background: colors.bg, color: colors.text, minHeight: '100vh', padding: '16px 20px' }}>
{/* Header */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<a
href={`${origin}/b/${data.board.slug}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: colors.text, textDecoration: 'none', fontWeight: 600, fontSize: 16 }}
>
{data.board.name}
</a>
<a
href={`${origin}/b/${data.board.slug}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: colors.accent, textDecoration: 'none', fontSize: 12, fontWeight: 500 }}
>
View all
</a>
</div>
{/* Posts */}
{data.posts.length === 0 && (
<div style={{ color: colors.textTer, fontSize: 13, padding: '20px 0', textAlign: 'center' }}>
No posts yet
</div>
)}
{data.posts.map((post) => {
const statusMap = isDark ? STATUS_DARK : STATUS_LIGHT
const sc = statusMap[post.status] || { bg: colors.surface, color: colors.textSec, label: post.status }
return (
<a
key={post.id}
href={`${origin}/b/${data.board.slug}/post/${post.id}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex',
gap: 12,
padding: '12px 14px',
marginBottom: 4,
borderRadius: 8,
background: colors.surface,
border: `1px solid ${colors.border}`,
textDecoration: 'none',
color: colors.text,
transition: 'border-color 0.15s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = colors.accent }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = colors.border }}
onFocus={(e) => { e.currentTarget.style.borderColor = colors.accent }}
onBlur={(e) => { e.currentTarget.style.borderColor = colors.border }}
>
{/* Vote count */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 36 }}>
<svg width="12" height="8" viewBox="0 0 12 8" style={{ marginBottom: 2 }}>
<path d="M6 0L12 8H0Z" fill={colors.accent} />
</svg>
<span style={{ fontSize: 13, fontWeight: 600, color: colors.accent }}>{post.voteCount}</span>
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{post.isPinned && <span style={{ color: colors.accent, marginRight: 4, fontSize: 12, fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.04em' }}>Pinned</span>}
{post.title}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, padding: '1px 6px', borderRadius: 4, background: sc.bg, color: sc.color, fontWeight: 500 }}>
{sc.label}
</span>
<span style={{ fontSize: 12, color: colors.textTer }}>
{post.commentCount} comment{post.commentCount !== 1 ? 's' : ''}
</span>
<time dateTime={post.createdAt} style={{ fontSize: 12, color: colors.textTer }}>
{timeAgo(post.createdAt)}
</time>
</div>
</div>
</a>
)
})}
{/* Footer */}
{poweredByVisible && (
<div style={{ marginTop: 12, textAlign: 'center' }}>
<a
href={`${origin}/b/${data.board.slug}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: colors.textTer, textDecoration: 'none', fontSize: 12 }}
>
Powered by {appName}
</a>
</div>
)}
</div>
)
}

View File

@@ -1,8 +1,25 @@
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useAuth } from '../hooks/useAuth'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import { useFocusTrap } from '../hooks/useFocusTrap'
import { useConfirm } from '../hooks/useConfirm'
import { api } from '../lib/api'
import { solveAltcha } from '../lib/altcha'
import { IconCheck, IconLock, IconKey, IconDownload, IconCamera, IconTrash, IconShieldCheck, IconArrowBack } from '@tabler/icons-react'
import PasskeyModal from '../components/PasskeyModal'
import RecoveryCodeModal from '../components/RecoveryCodeModal'
import Avatar from '../components/Avatar'
interface PasskeyInfo {
id: string
credentialDeviceType: string
credentialBackedUp: boolean
createdAt: string
}
export default function IdentitySettings() {
useDocumentTitle('Settings')
const confirm = useConfirm()
const auth = useAuth()
const [name, setName] = useState(auth.displayName)
const [saving, setSaving] = useState(false)
@@ -10,12 +27,89 @@ export default function IdentitySettings() {
const [showDelete, setShowDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showPasskey, setShowPasskey] = useState(false)
const [showRecovery, setShowRecovery] = useState(false)
const [showRedeemInput, setShowRedeemInput] = useState(false)
const [redeemPhrase, setRedeemPhrase] = useState('')
const [redeeming, setRedeeming] = useState(false)
const [redeemError, setRedeemError] = useState('')
const [redeemSuccess, setRedeemSuccess] = useState(false)
const [passkeys, setPasskeys] = useState<PasskeyInfo[]>([])
const [countdown, setCountdown] = useState(0)
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const countdownRef = useRef<ReturnType<typeof setInterval>>()
const deleteTrapRef = useFocusTrap(showDelete)
useEffect(() => {
if (showDelete) {
setCountdown(10)
countdownRef.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
clearInterval(countdownRef.current)
return 0
}
return c - 1
})
}, 1000)
} else {
clearInterval(countdownRef.current)
setCountdown(0)
}
return () => clearInterval(countdownRef.current)
}, [showDelete])
const fetchPasskeys = () => {
if (auth.isPasskeyUser) {
api.get<PasskeyInfo[]>('/me/passkeys').then(setPasskeys).catch(() => {})
}
}
useEffect(() => { fetchPasskeys() }, [auth.isPasskeyUser])
useEffect(() => {
api.get<{ avatarUrl: string | null }>('/me').then((data) => {
setAvatarUrl(data.avatarUrl ?? null)
}).catch(() => {})
}, [])
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (file.size > 2 * 1024 * 1024) return
setUploadingAvatar(true)
try {
const form = new FormData()
form.append('file', file)
const res = await fetch('/api/v1/me/avatar', {
method: 'POST',
body: form,
credentials: 'include',
})
if (res.ok) {
const data = await res.json()
setAvatarUrl(data.avatarUrl + '?t=' + Date.now())
}
} catch {} finally {
setUploadingAvatar(false)
if (avatarInputRef.current) avatarInputRef.current.value = ''
}
}
const handleAvatarRemove = async () => {
try {
await api.delete('/me/avatar')
setAvatarUrl(null)
} catch {}
}
const saveName = async () => {
if (!name.trim()) return
setSaving(true)
try {
await auth.updateProfile({ displayName: name })
const altcha = await solveAltcha('light')
await auth.updateProfile({ displayName: name, altcha })
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch {} finally {
@@ -23,6 +117,31 @@ export default function IdentitySettings() {
}
}
const handleRedeem = async () => {
const clean = redeemPhrase.toLowerCase().trim()
if (!/^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/.test(clean)) {
setRedeemError('Enter a valid 6-word recovery phrase separated by dashes')
return
}
setRedeeming(true)
setRedeemError('')
try {
const altcha = await solveAltcha()
await api.post('/auth/recover', { phrase: clean, altcha })
setRedeemSuccess(true)
auth.refresh()
setTimeout(() => {
setShowRedeemInput(false)
setRedeemPhrase('')
setRedeemSuccess(false)
}, 2000)
} catch (e: any) {
setRedeemError(e?.message || 'Invalid or expired recovery code')
} finally {
setRedeeming(false)
}
}
const handleExport = async () => {
try {
const data = await api.get<unknown>('/me/export')
@@ -39,7 +158,8 @@ export default function IdentitySettings() {
const handleDelete = async () => {
setDeleting(true)
try {
await auth.deleteIdentity()
const altcha = await solveAltcha()
await auth.deleteIdentity(altcha)
window.location.href = '/'
} catch {} finally {
setDeleting(false)
@@ -47,72 +167,153 @@ export default function IdentitySettings() {
}
return (
<div className="max-w-lg mx-auto px-4 py-8">
<div style={{ maxWidth: 520, margin: '0 auto', padding: '32px 24px' }}>
<h1
className="text-2xl font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
className="font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
>
Settings
</h1>
{/* Display name */}
<div className="card p-5 mb-4">
{/* Avatar */}
<div className="card card-static p-5 mb-4">
<h2
className="text-sm font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
className="font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
Avatar
</h2>
<div className="flex items-center gap-4">
<Avatar
userId={auth.user?.id ?? ''}
name={auth.displayName || null}
avatarUrl={avatarUrl}
size={64}
/>
<div className="flex flex-col gap-2">
{auth.isPasskeyUser ? (
<>
<input
ref={avatarInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarUpload}
className="sr-only"
id="avatar-upload"
/>
<label
htmlFor="avatar-upload"
className="btn btn-secondary inline-flex items-center gap-2"
style={{ cursor: uploadingAvatar ? 'wait' : 'pointer', opacity: uploadingAvatar ? 0.6 : 1 }}
>
<IconCamera size={16} stroke={2} />
{uploadingAvatar ? 'Uploading...' : avatarUrl ? 'Change photo' : 'Upload photo'}
</label>
{avatarUrl && (
<button
onClick={handleAvatarRemove}
className="btn inline-flex items-center gap-2"
style={{
color: 'var(--error)',
background: 'rgba(239, 68, 68, 0.1)',
fontSize: 'var(--text-xs)',
}}
>
<IconTrash size={14} stroke={2} />
Remove
</button>
)}
</>
) : (
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
<button
onClick={() => setShowPasskey(true)}
style={{
color: 'var(--accent)',
background: 'none',
border: 'none',
padding: 0,
fontSize: 'inherit',
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: '2px',
}}
>
Save your identity
</button>
{' '}to upload a custom avatar
</p>
)}
</div>
</div>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 12 }}>
JPG, PNG or WebP. Max 2MB.
</p>
</div>
{/* Display name */}
<div className="card card-static p-5 mb-4">
<h2
className="font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
Display Name
</h2>
<div className="flex gap-2">
<div className="flex gap-2 mb-3">
<input
className="input flex-1"
value={name}
onChange={(e) => setName(e.target.value)}
aria-label="Display name"
/>
<button
onClick={saveName}
disabled={saving}
className="btn btn-primary"
role="status"
aria-live="polite"
>
{saving ? 'Saving...' : saved ? 'Saved' : 'Save'}
</button>
</div>
{!auth.isPasskeyUser && (
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 8 }}>
This name is tied to your browser cookie. Register with a passkey to make it permanent.
</p>
)}
</div>
{/* Identity status */}
<div className="card p-5 mb-4">
<div className="card card-static p-5 mb-4">
<h2
className="text-sm font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
className="font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
Identity
</h2>
<div
className="flex items-center gap-3 p-3 rounded-lg mb-3"
style={{ background: 'var(--bg)' }}
className="flex items-center gap-3 p-3 mb-3"
style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
className="w-8 h-8 flex items-center justify-center shrink-0"
style={{
background: auth.isPasskeyUser ? 'rgba(34, 197, 94, 0.15)' : 'var(--accent-subtle)',
background: auth.isPasskeyUser ? 'rgba(34, 197, 94, 0.12)' : 'var(--accent-subtle)',
color: auth.isPasskeyUser ? 'var(--success)' : 'var(--accent)',
borderRadius: 'var(--radius-sm)',
}}
>
{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>
<IconCheck size={16} stroke={2.5} />
) : (
<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>
<IconLock size={16} stroke={2} />
)}
</div>
<div>
<div className="text-sm font-medium" style={{ color: 'var(--text)' }}>
<div className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{auth.isPasskeyUser ? 'Passkey registered' : 'Cookie-based identity'}
</div>
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{auth.isPasskeyUser
? 'Your identity is secured with a passkey'
: 'Your identity is tied to this browser cookie'}
@@ -122,66 +323,278 @@ export default function IdentitySettings() {
{!auth.isPasskeyUser && (
<button onClick={() => setShowPasskey(true)} className="btn btn-primary w-full">
Upgrade to passkey
Save my identity
</button>
)}
</div>
{/* Data */}
<div className="card p-5 mb-4">
{/* Recovery code - cookie-only users */}
{!auth.isPasskeyUser && (
<div className="card card-static p-5 mb-4">
<h2
className="font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
Recovery Code
</h2>
<div
className="flex items-center gap-3 p-3 mb-3"
style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}
>
<div
className="w-8 h-8 flex items-center justify-center shrink-0"
style={{
background: auth.user?.hasRecoveryCode ? 'rgba(34, 197, 94, 0.12)' : 'rgba(234, 179, 8, 0.12)',
color: auth.user?.hasRecoveryCode ? 'var(--success)' : 'var(--warning)',
borderRadius: 'var(--radius-sm)',
}}
>
<IconShieldCheck size={16} stroke={2} />
</div>
<div>
<div className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{auth.user?.hasRecoveryCode ? 'Recovery code active' : 'No recovery code'}
</div>
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{auth.user?.hasRecoveryCode
? `Expires ${new Date(auth.user.recoveryCodeExpiresAt!).toLocaleDateString()}`
: 'If you clear cookies, you lose access to your posts'}
</div>
</div>
</div>
<p className="mb-3" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', lineHeight: 1.5 }}>
{auth.user?.hasRecoveryCode
? 'Your recovery code lets you get back to your identity if cookies are cleared. Codes expire after 90 days - generate a new one before it runs out.'
: 'Generate a recovery phrase to get back to your posts if you clear your cookies or switch browsers. No email needed.'}
</p>
<button
onClick={() => setShowRecovery(true)}
className="btn btn-secondary w-full flex items-center justify-center gap-2"
>
<IconShieldCheck size={16} stroke={2} />
{auth.user?.hasRecoveryCode ? 'Manage recovery code' : 'Generate recovery code'}
</button>
{/* Redeem a recovery code */}
<div style={{ borderTop: '1px solid var(--border)', marginTop: 16, paddingTop: 16 }}>
{showRedeemInput ? (
<div className="fade-in">
{redeemSuccess ? (
<div
className="p-3 flex items-center gap-2"
style={{
background: 'rgba(34, 197, 94, 0.08)',
border: '1px solid rgba(34, 197, 94, 0.25)',
borderRadius: 'var(--radius-md)',
color: 'var(--success)',
fontSize: 'var(--text-sm)',
}}
>
<IconCheck size={16} stroke={2.5} />
Identity recovered
</div>
) : (
<>
<p className="mb-2" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', lineHeight: 1.5 }}>
Enter the 6-word recovery phrase you saved earlier.
</p>
<input
className="input w-full mb-2 font-mono"
placeholder="word-word-word-word-word-word"
value={redeemPhrase}
onChange={(e) => setRedeemPhrase(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRedeem()}
spellCheck={false}
autoComplete="off"
autoFocus
style={{ letterSpacing: '0.02em', fontSize: 'var(--text-sm)' }}
/>
{redeemError && (
<p role="alert" className="mb-2" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{redeemError}</p>
)}
<div className="flex gap-2">
<button
onClick={handleRedeem}
disabled={redeeming || !redeemPhrase.trim()}
className="btn btn-primary flex-1"
style={{ fontSize: 'var(--text-sm)', opacity: redeeming ? 0.6 : 1 }}
>
{redeeming ? 'Recovering...' : 'Recover identity'}
</button>
<button
onClick={() => { setShowRedeemInput(false); setRedeemPhrase(''); setRedeemError('') }}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-sm)' }}
>
Cancel
</button>
</div>
</>
)}
</div>
) : (
<button
onClick={() => setShowRedeemInput(true)}
className="btn btn-ghost w-full flex items-center justify-center gap-2"
style={{ fontSize: 'var(--text-xs)' }}
>
<IconArrowBack size={14} stroke={2} />
I have a recovery code
</button>
)}
</div>
</div>
)}
{/* Passkey management */}
{auth.isPasskeyUser && (
<div className="card card-static p-5 mb-4">
<h2
className="font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
Passkeys
</h2>
<div className="flex flex-col gap-2 mb-3">
{passkeys.map((pk) => (
<div
key={pk.id}
className="flex items-center justify-between p-3"
style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}
>
<div className="flex items-center gap-3">
<div
className="w-8 h-8 flex items-center justify-center"
style={{
background: 'rgba(34, 197, 94, 0.12)',
color: 'var(--success)',
borderRadius: 'var(--radius-sm)',
}}
>
<IconKey size={14} stroke={2} />
</div>
<div>
<div style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{pk.credentialDeviceType === 'multiDevice' ? 'Synced passkey' : 'Device-bound passkey'}
{pk.credentialBackedUp && (
<span className="ml-1" style={{ color: 'var(--success)', fontSize: 'var(--text-xs)' }}>(backed up)</span>
)}
</div>
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Added <time dateTime={pk.createdAt}>{new Date(pk.createdAt).toLocaleDateString()}</time>
</div>
</div>
</div>
{passkeys.length > 1 && (
<button
onClick={async () => {
if (!await confirm('Remove this passkey?')) return
try {
await api.delete(`/me/passkeys/${pk.id}`)
fetchPasskeys()
} catch {}
}}
className="px-2 py-1"
style={{
color: 'var(--error)',
background: 'rgba(239, 68, 68, 0.1)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
>
Remove
</button>
)}
</div>
))}
</div>
<button
onClick={() => setShowPasskey(true)}
className="btn btn-secondary w-full"
style={{ fontSize: 'var(--text-sm)' }}
>
Add another passkey
</button>
</div>
)}
{/* Data export */}
<div className="card card-static p-5 mb-4">
<h2
className="text-sm font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
className="font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
Your Data
</h2>
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
<p className="mb-3" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
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>
<button onClick={handleExport} className="btn btn-secondary inline-flex items-center gap-2">
<IconDownload size={16} stroke={2} />
Export Data
</button>
</div>
{/* Danger zone */}
<div className="card p-5" style={{ borderColor: 'rgba(239, 68, 68, 0.2)' }}>
<div
className="card card-static 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)' }}
className="font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)', fontSize: 'var(--text-sm)' }}
>
Danger Zone
</h2>
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
<p className="mb-3" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
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)' }}
className="btn"
style={{
background: 'rgba(239, 68, 68, 0.12)',
color: 'var(--error)',
fontSize: 'var(--text-sm)',
}}
>
Delete my identity
</button>
</div>
{/* Delete confirmation */}
{/* Delete confirmation modal */}
{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)' }}
className="absolute inset-0 fade-in"
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}
/>
<div
ref={deleteTrapRef}
role="dialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
className="relative w-full max-w-sm mx-4 p-6 fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === 'Escape' && setShowDelete(false)}
>
<h3 className="text-lg font-bold mb-2" style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)' }}>
<h2
id="delete-modal-title"
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)', fontSize: 'var(--text-lg)' }}
>
Delete Identity
</h3>
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
</h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Are you sure? All your posts, votes, and data will be permanently removed. This action cannot be reversed.
</p>
<div className="flex gap-3">
@@ -190,16 +603,30 @@ export default function IdentitySettings() {
</button>
<button
onClick={handleDelete}
disabled={deleting}
disabled={deleting || countdown > 0}
className="btn flex-1"
style={{ background: 'var(--error)', color: 'white', opacity: deleting ? 0.6 : 1 }}
style={{
background: countdown > 0 ? 'rgba(239, 68, 68, 0.3)' : 'var(--error)',
color: 'white',
opacity: deleting ? 0.6 : 1,
}}
>
{deleting ? 'Deleting...' : 'Delete'}
{deleting ? 'Deleting...' : countdown > 0 ? `Delete (${countdown}s)` : 'Delete forever'}
</button>
</div>
</div>
</div>
)}
<PasskeyModal
mode="register"
open={showPasskey}
onClose={() => setShowPasskey(false)}
/>
<RecoveryCodeModal
open={showRecovery}
onClose={() => setShowRecovery(false)}
/>
</div>
)
}

View File

@@ -1,7 +1,10 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import StatusBadge from '../components/StatusBadge'
import EmptyState from '../components/EmptyState'
import { IconFileText } from '@tabler/icons-react'
interface Post {
id: string
@@ -10,12 +13,30 @@ interface Post {
status: string
voteCount: number
commentCount: number
boardSlug: string
boardName: string
viewCount?: number
board?: { slug: string; name: string } | null
createdAt: string
}
function PostSkeleton() {
return (
<div className="flex flex-col gap-2">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="card p-4 flex items-center gap-4" style={{ opacity: 1 - i * 0.15 }}>
<div className="flex-1">
<div className="skeleton h-3 mb-2" style={{ width: '30%' }} />
<div className="skeleton h-4 mb-2" style={{ width: '60%' }} />
<div className="skeleton h-3" style={{ width: '40%' }} />
</div>
<div className="skeleton h-6 w-20 rounded" />
</div>
))}
</div>
)
}
export default function MySubmissions() {
useDocumentTitle('My Posts')
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
@@ -27,61 +48,63 @@ export default function MySubmissions() {
}, [])
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<h1
className="text-2xl font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
className="font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
>
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>
<>
<div className="progress-bar mb-4" />
<PostSkeleton />
</>
) : 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>
<EmptyState
icon={IconFileText}
title="No posts yet"
message="Your submitted posts will appear here"
actionLabel="Browse boards"
onAction={() => { window.location.href = '/' }}
/>
) : (
<div className="flex flex-col gap-2">
{posts.map((post) => (
{posts.map((post, i) => (
<Link
key={post.id}
to={`/b/${post.boardSlug}/post/${post.id}`}
className="card p-4 flex items-center gap-4"
to={`/b/${post.board?.slug}/post/${post.id}`}
className="card card-interactive p-4 flex items-center gap-4 stagger-in"
style={{ '--stagger': i } as React.CSSProperties}
>
<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 style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{post.board?.name}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded capitalize"
className="px-1.5 py-0.5"
style={{
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
background: post.type === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
color: post.type === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
}}
>
{post.type}
{post.type === 'BUG_REPORT' ? 'Bug' : 'Feature'}
</span>
</div>
<h3
className="text-sm font-medium truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
<h2
className="font-medium truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
{post.title}
</h3>
<div className="flex items-center gap-3 mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>
</h2>
<div className="flex items-center gap-3 mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
<span>{post.voteCount} votes</span>
<span>{post.commentCount} comments</span>
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
<time dateTime={post.createdAt}>{new Date(post.createdAt).toLocaleDateString()}</time>
</div>
</div>
<StatusBadge status={post.status} />

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react'
import { api } from '../lib/api'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import { Link } from 'react-router-dom'
interface DataField {
field: string
@@ -9,13 +11,75 @@ interface DataField {
}
interface Manifest {
fields: DataField[]
anonymousUser: DataField[]
passkeyUser: DataField[]
cookieInfo: string
dataLocation: string
thirdParties: string[]
neverStored: string[]
securityHeaders: Record<string, string>
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="card card-static p-5 mb-5">
<h2
className="font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-base)' }}
>
{title}
</h2>
{children}
</div>
)
}
function FieldTable({ fields }: { fields: DataField[] }) {
return (
<div className="flex flex-col gap-3">
{fields.map((f) => (
<div key={f.field} className="p-3" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}>
<div className="flex items-center justify-between mb-1">
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>{f.field}</span>
{f.deletable && (
<span
className="px-1.5 py-0.5"
style={{
background: 'rgba(34, 197, 94, 0.12)',
color: 'var(--success)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
>
deletable
</span>
)}
</div>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>{f.purpose}</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>Retained: {f.retention}</p>
</div>
))}
</div>
)
}
function PrivacySkeleton() {
return (
<div className="flex flex-col gap-5">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="card p-5" style={{ opacity: 1 - i * 0.15 }}>
<div className="skeleton h-5 mb-3" style={{ width: '40%' }} />
<div className="skeleton h-4 mb-2" style={{ width: '90%' }} />
<div className="skeleton h-4 mb-2" style={{ width: '80%' }} />
<div className="skeleton h-4" style={{ width: '60%' }} />
</div>
))}
</div>
)
}
export default function PrivacyPage() {
useDocumentTitle('Privacy')
const [manifest, setManifest] = useState<Manifest | null>(null)
const [loading, setLoading] = useState(true)
@@ -26,137 +90,153 @@ export default function PrivacyPage() {
.finally(() => setLoading(false))
}, [])
const body = (color: string) => ({ color, lineHeight: '1.7', fontSize: 'var(--text-sm)' as const })
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div style={{ maxWidth: 680, margin: '0 auto', padding: '32px 24px' }}>
<h1
className="text-2xl font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
>
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 className="mb-8" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
This page is generated from the application's actual configuration. It cannot drift out of sync with reality.
</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}
<div className="progress-bar mb-4" />
<PrivacySkeleton />
</>
) : (
<>
<Section title="How the cookie works">
<p style={body('var(--text-secondary)')}>
{manifest?.cookieInfo || 'This site uses exactly one cookie. It contains a random identifier used to connect you to your posts. It is a session cookie - it expires when you close your browser. It is not used for tracking, analytics, or advertising. No other cookies are set, ever.'}
</p>
</div>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
The cookie is <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>httpOnly</code> (JavaScript cannot read it), <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>Secure</code> (only sent over HTTPS), and <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>SameSite=Strict</code> (never sent on cross-origin requests). Dark mode preference is stored in localStorage, not as a cookie.
</p>
</Section>
{/* 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)' }}
<Section title="How passkeys work">
<p style={body('var(--text-secondary)')}>
Passkeys use the WebAuthn standard. When you register, your device generates a public/private key pair. The private key stays on your device (or syncs via iCloud Keychain, Google Password Manager, Windows Hello). The server only stores the public key.
</p>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
To log in, your device signs a challenge with the private key. The server verifies it against the stored public key. No email, no password, no personal data. Passkeys are phishing-resistant because they are bound to this domain and cannot be used on any other site.
</p>
</Section>
<Section title="What we store - anonymous cookie users">
{manifest?.anonymousUser ? (
<FieldTable fields={manifest.anonymousUser} />
) : (
<ul className="flex flex-col gap-1" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
<li>- Token hash (SHA-256, irreversible)</li>
<li>- Display name (encrypted at rest)</li>
<li>- Dark mode preference</li>
<li>- Creation timestamp</li>
</ul>
)}
</Section>
<Section title="What we store - passkey users">
{manifest?.passkeyUser ? (
<FieldTable fields={manifest.passkeyUser} />
) : (
<ul className="flex flex-col gap-1" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
<li>- Username (encrypted + blind indexed)</li>
<li>- Display name (encrypted)</li>
<li>- Passkey credential ID (encrypted + blind indexed)</li>
<li>- Passkey public key (encrypted)</li>
<li>- Passkey counter, device type, backup flag</li>
<li>- Passkey transports (encrypted)</li>
<li>- Dark mode preference</li>
<li>- Creation timestamp</li>
</ul>
)}
</Section>
<Section title="What we never store">
<div className="flex flex-wrap gap-2">
{(manifest?.neverStored || [
'Email address', 'IP address', 'Browser fingerprint', 'User-agent string',
'Referrer URL', 'Geolocation', 'Device identifiers', 'Behavioral data',
'Session replays', 'Third-party tracking identifiers',
]).map((item) => (
<span
key={item}
className="px-2 py-1 rounded-full"
style={{ background: 'rgba(239, 68, 68, 0.1)', color: 'var(--error)', fontSize: 'var(--text-xs)' }}
>
<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>
{item}
</span>
))}
</div>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
IP addresses are used for rate limiting in memory only - they never touch the database. Request logs record method, path, and status code, but the IP is stripped before the log line is written.
</p>
</Section>
<Section title="How encryption works">
<p style={body('var(--text-secondary)')}>
Every field that could identify a user is encrypted at rest using AES-256-GCM. This includes display names, usernames, passkey credentials, and push subscription endpoints. The anonymous token is stored as a one-way SHA-256 hash (irreversible, even stronger than encryption).
</p>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
Posts, comments, vote counts, and board metadata are not encrypted because they are public content visible to every visitor. Encrypting them would add latency without a security benefit.
</p>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
If someone gains access to the raw database, they see ciphertext blobs for identity fields and SHA-256 hashes for tokens. They cannot determine who wrote what, reconstruct display names, or extract passkey credentials.
</p>
</Section>
<Section title="Your data, your control">
<p style={body('var(--text-secondary)')}>
<strong style={{ color: 'var(--text)' }}>Export:</strong> Download everything this system holds about you as a JSON file from <Link to="/settings" style={{ color: 'var(--accent)' }}>your settings page</Link>. The file includes your posts, comments, votes, reactions, display name, and the data manifest.
</p>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
<strong style={{ color: 'var(--text)' }}>Delete:</strong> One click deletes your identity. Your votes and reactions are removed, comments are anonymized to "[deleted]", posts become "[deleted by author]", and your user record is purged from the database. Not soft-deleted - actually removed.
</p>
</Section>
<Section title="Security headers in effect">
<div className="flex flex-col gap-2">
{Object.entries(manifest?.securityHeaders || {
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-src 'none'; object-src 'none'",
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
'Cross-Origin-Opener-Policy': 'same-origin',
}).map(([header, value]) => (
<div key={header} className="p-2" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}>
<span className="font-medium block" style={{ color: 'var(--text)', fontSize: 'var(--text-xs)' }}>{header}</span>
<span className="break-all" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>{value}</span>
</div>
))}
</div>
</div>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
No external domains are whitelisted. The client never contacts a third party.
</p>
</Section>
{/* 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>
<Section title="Third-party services">
{manifest?.thirdParties && manifest.thirdParties.length > 0 ? (
<ul className="flex flex-col gap-1">
{manifest.thirdParties.map((tp) => (
<li key={tp} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
- {tp}
</li>
<li key={tp} style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>- {tp}</li>
))}
</ul>
</div>
)}
) : (
<p style={body('var(--text-secondary)')}>
None. No external HTTP requests from the client, ever. No Google Fonts, no CDN scripts, no analytics, no tracking pixels. The only outbound requests the server makes are to external APIs configured by installed plugins (always your own infrastructure) and to push notification endpoints (browser-generated URLs).
</p>
)}
</Section>
</>
) : (
<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,259 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import { useAuth } from '../hooks/useAuth'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import StatusBadge from '../components/StatusBadge'
import EmptyState from '../components/EmptyState'
import { IconUser, IconMessageCircle, IconArrowUp, IconFileText, IconHeart } from '@tabler/icons-react'
import Avatar from '../components/Avatar'
interface ProfileData {
id: string
displayName: string | null
username: string | null
isPasskeyUser: boolean
avatarUrl: string | null
createdAt: string
stats: {
posts: number
comments: number
votesGiven: number
votesReceived: number
}
votedPosts: {
id: string
title: string
status: string
voteCount: number
commentCount: number
board: { slug: string; name: string } | null
votedAt: string
}[]
}
interface Post {
id: string
title: string
type: string
status: string
voteCount: number
commentCount: number
board?: { slug: string; name: string } | null
createdAt: string
}
export default function ProfilePage() {
useDocumentTitle('Profile')
const auth = useAuth()
const [profile, setProfile] = useState<ProfileData | null>(null)
const [posts, setPosts] = useState<Post[]>([])
const [tab, setTab] = useState<'posts' | 'votes'>('posts')
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.get<ProfileData>('/me/profile'),
api.get<{ posts: Post[] }>('/me/posts'),
]).then(([p, myPosts]) => {
setProfile(p)
setPosts(myPosts.posts)
}).catch(() => {}).finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<div className="progress-bar mb-4" />
</div>
)
}
if (!profile) return null
const name = profile.displayName
|| (profile.username ? `@${profile.username}` : `Anonymous #${profile.id.slice(-4)}`)
const stats = [
{ icon: IconFileText, label: 'Posts', value: profile.stats.posts },
{ icon: IconMessageCircle, label: 'Comments', value: profile.stats.comments },
{ icon: IconArrowUp, label: 'Votes given', value: profile.stats.votesGiven },
{ icon: IconHeart, label: 'Votes received', value: profile.stats.votesReceived },
]
return (
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
{/* Header */}
<div className="card p-6 mb-6">
<div className="flex items-center gap-4 mb-5">
<Avatar
userId={profile.id}
name={profile.displayName}
avatarUrl={profile.avatarUrl}
size={56}
/>
<div>
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-2xl)' }}
>
{name}
</h1>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
Joined <time dateTime={profile.createdAt}>{new Date(profile.createdAt).toLocaleDateString()}</time>
{profile.isPasskeyUser && ' - Passkey user'}
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3" style={{ maxWidth: 400 }}>
{stats.map((s) => (
<div
key={s.label}
className="flex items-center gap-3 p-3"
style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}
>
<s.icon size={16} stroke={2} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<div>
<div style={{ color: 'var(--text)', fontSize: 'var(--text-lg)', fontWeight: 700, lineHeight: 1 }}>
{s.value}
</div>
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{s.label}
</div>
</div>
</div>
))}
</div>
</div>
{/* Tabs */}
<div role="tablist" className="flex gap-1 mb-4" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
{(['posts', 'votes'] as const).map((t) => (
<button
key={t}
id={`tab-${t}`}
role="tab"
aria-selected={tab === t}
aria-controls={`tabpanel-${t}`}
onClick={() => setTab(t)}
className="px-4 py-2"
style={{
color: tab === t ? 'var(--accent)' : 'var(--text-tertiary)',
fontWeight: tab === t ? 600 : 400,
fontSize: 'var(--text-sm)',
borderBottom: tab === t ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom: -1,
transition: 'all 0.15s ease',
minHeight: 44,
}}
>
{t === 'posts' ? 'My Posts' : 'Voted On'}
</button>
))}
</div>
{/* Posts tab */}
{tab === 'posts' && (
<div role="tabpanel" id="tabpanel-posts" aria-labelledby="tab-posts">
{posts.length === 0 ? (
<EmptyState
icon={IconFileText}
title="No posts yet"
message="Your submitted posts will appear here"
actionLabel="Browse boards"
onAction={() => { window.location.href = '/' }}
/>
) : (
<div className="flex flex-col gap-2">
{posts.map((post, i) => (
<Link
key={post.id}
to={`/b/${post.board?.slug}/post/${post.id}`}
className="card card-interactive p-4 flex items-center gap-4 stagger-in"
style={{ '--stagger': i } as React.CSSProperties}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{post.board?.name}
</span>
<span
className="px-1.5 py-0.5"
style={{
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
background: post.type === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
color: post.type === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
}}
>
{post.type === 'BUG_REPORT' ? 'Bug' : 'Feature'}
</span>
</div>
<h2
className="font-medium truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
{post.title}
</h2>
<div className="flex items-center gap-3 mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
<span>{post.voteCount} votes</span>
<span>{post.commentCount} comments</span>
<time dateTime={post.createdAt}>{new Date(post.createdAt).toLocaleDateString()}</time>
</div>
</div>
<StatusBadge status={post.status} />
</Link>
))}
</div>
)}
</div>
)}
{/* Votes tab */}
{tab === 'votes' && (
<div role="tabpanel" id="tabpanel-votes" aria-labelledby="tab-votes">
{profile.votedPosts.length === 0 ? (
<EmptyState
icon={IconArrowUp}
title="No votes yet"
message="Posts you vote on will appear here"
/>
) : (
<div className="flex flex-col gap-2">
{profile.votedPosts.map((post, i) => (
<Link
key={post.id}
to={`/b/${post.board?.slug}/post/${post.id}`}
className="card card-interactive p-4 flex items-center gap-4 stagger-in"
style={{ '--stagger': i } as React.CSSProperties}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{post.board?.name}
</span>
</div>
<h2
className="font-medium truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
{post.title}
</h2>
<div className="flex items-center gap-3 mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
<span>{post.voteCount} votes</span>
<span>{post.commentCount} comments</span>
<time dateTime={post.votedAt}>Voted {new Date(post.votedAt).toLocaleDateString()}</time>
</div>
</div>
<StatusBadge status={post.status} />
</Link>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { IconShieldCheck } from '@tabler/icons-react'
import { api } from '../lib/api'
import { solveAltcha } from '../lib/altcha'
import { useAuth } from '../hooks/useAuth'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
// extract and clear hash immediately at module load, before React renders
let extractedPhrase = ''
if (typeof window !== 'undefined' && window.location.pathname === '/recover') {
const hash = window.location.hash.slice(1)
if (hash && /^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/.test(hash)) {
extractedPhrase = hash
window.history.replaceState(null, '', window.location.pathname)
}
}
export default function RecoverPage() {
useDocumentTitle('Recover Identity')
const navigate = useNavigate()
const auth = useAuth()
const [phrase, setPhrase] = useState(extractedPhrase)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const handleRecover = async () => {
const clean = phrase.toLowerCase().trim()
if (!/^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/.test(clean)) {
setError('Enter a valid 6-word recovery phrase separated by dashes')
return
}
setLoading(true)
setError('')
try {
const altcha = await solveAltcha()
await api.post('/auth/recover', { phrase: clean, altcha })
setSuccess(true)
auth.refresh()
setTimeout(() => navigate('/settings'), 1500)
} catch (e: any) {
setError(e?.message || 'Invalid or expired recovery code')
} finally {
setLoading(false)
}
}
return (
<div className="flex items-center justify-center" style={{ minHeight: 'calc(100vh - 120px)', padding: '24px' }}>
<div
className="w-full"
style={{ maxWidth: 420 }}
>
<div
className="w-12 h-12 flex items-center justify-center mb-5"
style={{
background: 'var(--accent-subtle)',
borderRadius: 'var(--radius-md)',
}}
>
<IconShieldCheck size={24} stroke={2} style={{ color: 'var(--accent)' }} />
</div>
<h1
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-2xl)', color: 'var(--text)' }}
>
Recover your identity
</h1>
<p className="mb-6" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
Enter your 6-word recovery phrase to get back to your posts and votes.
</p>
{success ? (
<div
className="p-4"
style={{
background: 'rgba(34, 197, 94, 0.08)',
border: '1px solid rgba(34, 197, 94, 0.25)',
borderRadius: 'var(--radius-md)',
color: 'var(--success)',
fontSize: 'var(--text-sm)',
}}
>
Identity recovered. Redirecting to settings...
</div>
) : (
<>
<input
className="input w-full mb-3 font-mono"
placeholder="word-word-word-word-word-word"
value={phrase}
onChange={(e) => setPhrase(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRecover()}
autoFocus
spellCheck={false}
autoComplete="off"
style={{ letterSpacing: '0.02em' }}
/>
{error && (
<p role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{error}</p>
)}
<button
onClick={handleRecover}
disabled={loading || !phrase.trim()}
className="btn btn-primary w-full"
style={{ opacity: loading ? 0.6 : 1 }}
>
{loading ? 'Recovering...' : 'Recover identity'}
</button>
<p className="mt-4" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', lineHeight: 1.5 }}>
Don't have a recovery code? If your cookies were cleared and you didn't save a recovery phrase, your previous identity can't be restored. You can still create a new one.
</p>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,180 @@
import { useState, useEffect } from 'react'
import { Link, useParams } from 'react-router-dom'
import { IconMessage, IconTriangle } from '@tabler/icons-react'
import StatusBadge from '../components/StatusBadge'
import { api } from '../lib/api'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
interface Tag {
id: string
name: string
color: string
}
interface RoadmapPost {
id: string
title: string
type: string
status: string
category?: string | null
voteCount: number
createdAt: string
board: { slug: string; name: string }
_count: { comments: number }
tags?: Tag[]
}
interface RoadmapData {
columns: {
PLANNED: RoadmapPost[]
IN_PROGRESS: RoadmapPost[]
DONE: RoadmapPost[]
}
}
const COLUMNS = [
{ key: 'PLANNED' as const, label: 'Planned' },
{ key: 'IN_PROGRESS' as const, label: 'In Progress' },
{ key: 'DONE' as const, label: 'Done' },
]
export default function RoadmapPage() {
const { boardSlug } = useParams()
useDocumentTitle(boardSlug ? `${boardSlug} Roadmap` : 'Roadmap')
const [data, setData] = useState<RoadmapData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
const url = boardSlug ? `/b/${boardSlug}/roadmap` : '/roadmap'
api.get<RoadmapData>(url)
.then(setData)
.catch(() => {})
.finally(() => setLoading(false))
}, [boardSlug])
return (
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<div className="mb-6">
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
>
Roadmap
</h1>
<p className="mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
What we're working on and what's coming next
</p>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => (
<div key={i}>
<div className="skeleton h-5 w-24 rounded mb-3" />
{[0, 1, 2].map((j) => (
<div key={j} className="card mb-2" style={{ padding: 16, opacity: 1 - j * 0.2 }}>
<div className="skeleton h-4 w-3/4 rounded mb-2" />
<div className="skeleton h-3 w-1/2 rounded" />
</div>
))}
</div>
))}
</div>
) : data ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5" style={{ alignItems: 'start' }}>
{COLUMNS.map((col) => {
const posts = data.columns[col.key]
return (
<div key={col.key}>
<h2 className="flex items-center gap-2 mb-3" style={{ fontSize: 'var(--text-sm)', fontWeight: 600 }}>
<StatusBadge status={col.key} />
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 400 }}>
{posts.length}
</span>
</h2>
{posts.length === 0 ? (
<div
className="text-center py-6"
style={{
color: 'var(--text-tertiary)',
fontSize: 'var(--text-xs)',
border: '1px dashed var(--border)',
borderRadius: 'var(--radius-lg)',
}}
>
Nothing here yet
</div>
) : (
<div className="flex flex-col gap-2">
{posts.map((post) => (
<Link
key={post.id}
to={`/b/${post.board.slug}/post/${post.id}`}
className="card roadmap-card"
style={{
padding: '14px 16px',
display: 'block',
color: 'var(--text)',
transition: 'border-color var(--duration-fast) ease-out, box-shadow var(--duration-fast) ease-out',
}}
>
<div className="font-medium mb-1.5" style={{ fontSize: 'var(--text-sm)', lineHeight: 1.4 }}>
{post.title}
</div>
<div className="flex items-center gap-3 flex-wrap" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
<span>{post.board.name}</span>
{post.category && (
<>
<span>-</span>
<span>{post.category}</span>
</>
)}
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded"
style={{
background: post.type === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
color: post.type === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
}}
>
{post.type === 'BUG_REPORT' ? 'Bug' : 'Feature'}
</span>
{post.tags?.map((tag) => (
<span
key={tag.id}
className="px-1.5 py-0.5 rounded"
style={{ background: `${tag.color}20`, color: tag.color }}
>
{tag.name}
</span>
))}
</div>
<div className="flex items-center gap-3 mt-2" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
<span className="inline-flex items-center gap-1" style={{ color: 'var(--accent)' }}>
<IconTriangle size={11} stroke={2} />
{post.voteCount}
</span>
{post._count.comments > 0 && (
<span className="inline-flex items-center gap-1">
<IconMessage size={11} stroke={2} />
{post._count.comments}
</span>
)}
</div>
</Link>
))}
</div>
)}
</div>
)
})}
</div>
) : (
<div className="text-center py-12" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
Failed to load roadmap
</div>
)}
</div>
)
}

View File

@@ -1,19 +1,31 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useFocusTrap } from '../../hooks/useFocusTrap'
import Dropdown from '../../components/Dropdown'
import NumberInput from '../../components/NumberInput'
import IconPicker from '../../components/IconPicker'
import BoardIcon from '../../components/BoardIcon'
interface Board {
id: string
slug: string
name: string
description: string
postCount: number
archived: boolean
_count?: { posts: number }
isArchived: boolean
iconName: string | null
iconColor: string | null
voteBudget: number
voteResetSchedule: string
voteBudgetReset: string
rssEnabled: boolean
rssFeedCount: number
staleDays: number
}
export default function AdminBoards() {
useDocumentTitle('Manage Boards')
const [boards, setBoards] = useState<Board[]>([])
const [loading, setLoading] = useState(true)
const [editBoard, setEditBoard] = useState<Board | null>(null)
@@ -22,10 +34,16 @@ export default function AdminBoards() {
name: '',
slug: '',
description: '',
iconName: null as string | null,
iconColor: null as string | null,
voteBudget: 10,
voteResetSchedule: 'monthly',
voteBudgetReset: 'monthly',
rssEnabled: true,
rssFeedCount: 50,
staleDays: 0,
})
const [saving, setSaving] = useState(false)
const boardTrapRef = useFocusTrap(showCreate)
const fetchBoards = async () => {
try {
@@ -39,7 +57,7 @@ export default function AdminBoards() {
useEffect(() => { fetchBoards() }, [])
const resetForm = () => {
setForm({ name: '', slug: '', description: '', voteBudget: 10, voteResetSchedule: 'monthly' })
setForm({ name: '', slug: '', description: '', iconName: null, iconColor: null, voteBudget: 10, voteBudgetReset: 'monthly', rssEnabled: true, rssFeedCount: 50, staleDays: 0 })
setEditBoard(null)
setShowCreate(false)
}
@@ -50,8 +68,13 @@ export default function AdminBoards() {
name: b.name,
slug: b.slug,
description: b.description,
iconName: b.iconName,
iconColor: b.iconColor,
voteBudget: b.voteBudget,
voteResetSchedule: b.voteResetSchedule,
voteBudgetReset: b.voteBudgetReset,
rssEnabled: b.rssEnabled,
rssFeedCount: b.rssFeedCount,
staleDays: b.staleDays ?? 0,
})
setShowCreate(true)
}
@@ -71,9 +94,9 @@ export default function AdminBoards() {
}
}
const handleArchive = async (id: string, archived: boolean) => {
const handleArchive = async (id: string, isArchived: boolean) => {
try {
await api.patch(`/admin/boards/${id}`, { archived: !archived })
await api.put(`/admin/boards/${id}`, { isArchived: !isArchived })
fetchBoards()
} catch {}
}
@@ -81,11 +104,11 @@ export default function AdminBoards() {
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 style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<div className="flex items-center justify-between mb-6">
<h1
className="text-2xl font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Boards
</h1>
@@ -98,11 +121,14 @@ export default function AdminBoards() {
</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="progress-bar mb-4" />
{[0, 1, 2].map((i) => (
<div key={i} className="card p-4 flex items-center gap-4 mb-3" style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton w-10 h-10 rounded-lg" />
<div className="flex-1"><div className="skeleton h-4 mb-2" style={{ width: '40%' }} /><div className="skeleton h-3" style={{ width: '60%' }} /></div>
</div>
))}
</div>
) : (
<div className="flex flex-col gap-3">
@@ -110,43 +136,34 @@ export default function AdminBoards() {
<div
key={board.id}
className="card p-4 flex items-center gap-4"
style={{ opacity: board.archived ? 0.5 : 1 }}
style={{ opacity: board.isArchived ? 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>
<BoardIcon name={board.name} iconName={board.iconName} iconColor={board.iconColor} size={40} />
<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)' }}>
<h2 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
{board.name}
</h3>
{board.archived && (
</h2>
{board.isArchived && (
<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}
/{board.slug} - {board._count?.posts ?? 0} posts - Budget: {board.voteBudget}/{board.voteBudgetReset}
</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)' }}>
<button onClick={() => openEdit(board)} className="btn btn-ghost text-xs px-2" style={{ minHeight: 44, 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)' }}
onClick={() => handleArchive(board.id, board.isArchived)}
className="btn btn-ghost text-xs px-2"
style={{ minHeight: 44, color: board.isArchived ? 'var(--success)' : 'var(--warning)' }}
>
{board.archived ? 'Restore' : 'Archive'}
{board.isArchived ? 'Restore' : 'Archive'}
</button>
</div>
</div>
@@ -169,18 +186,24 @@ export default function AdminBoards() {
>
<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)' }}
ref={boardTrapRef}
role="dialog"
aria-modal="true"
aria-labelledby="board-modal-title"
className="relative w-full max-w-md mx-4 fade-in"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-xl)', boxShadow: 'var(--shadow-xl)', padding: '24px' }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === 'Escape' && resetForm()}
>
<h3 className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
<h2 id="board-modal-title" className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
{editBoard ? 'Edit Board' : 'New Board'}
</h3>
</h2>
<div className="flex flex-col gap-3">
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
<label htmlFor="board-name" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
<input
id="board-name"
className="input"
value={form.name}
onChange={(e) => {
@@ -194,8 +217,9 @@ export default function AdminBoards() {
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
<label htmlFor="board-slug" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
<input
id="board-slug"
className="input"
value={form.slug}
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
@@ -203,8 +227,9 @@ export default function AdminBoards() {
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
<label htmlFor="board-description" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
<textarea
id="board-description"
className="input"
rows={2}
value={form.description}
@@ -213,30 +238,80 @@ export default function AdminBoards() {
style={{ resize: 'vertical' }}
/>
</div>
<div>
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Icon</label>
<IconPicker
value={form.iconName}
color={form.iconColor}
onChangeIcon={(v) => setForm((f) => ({ ...f, iconName: v }))}
onChangeColor={(v) => setForm((f) => ({ ...f, iconColor: v }))}
/>
</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"
<label htmlFor="board-vote-budget" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Vote Budget</label>
<NumberInput
value={form.voteBudget}
onChange={(v) => setForm((f) => ({ ...f, voteBudget: v }))}
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>
<label htmlFor="board-reset-schedule" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Reset Schedule</label>
<Dropdown
value={form.voteBudgetReset}
onChange={(v) => setForm((f) => ({ ...f, voteBudgetReset: v }))}
options={[
{ value: 'weekly', label: 'Weekly' },
{ value: 'biweekly', label: 'Biweekly' },
{ value: 'monthly', label: 'Monthly' },
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'per_release', label: 'Per release' },
{ value: 'never', label: 'Never' },
]}
/>
</div>
</div>
<div className="col-span-2 flex items-center gap-3 mt-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.rssEnabled}
onChange={(e) => setForm((f) => ({ ...f, rssEnabled: e.target.checked }))}
style={{ accentColor: 'var(--admin-accent)' }}
/>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>RSS Feed</span>
</label>
{form.rssEnabled && (
<div className="flex items-center gap-1">
<label className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Items:</label>
<NumberInput
value={form.rssFeedCount}
onChange={(v) => setForm((f) => ({ ...f, rssFeedCount: v }))}
min={1}
max={200}
style={{ width: 100 }}
/>
</div>
)}
</div>
<div>
<label htmlFor="board-stale-days" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>
Stale after (days)
</label>
<div className="flex items-center gap-2">
<NumberInput
value={form.staleDays}
onChange={(v) => setForm((f) => ({ ...f, staleDays: v }))}
min={0}
max={365}
step={7}
style={{ width: 120 }}
/>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{form.staleDays === 0 ? 'Disabled' : `${form.staleDays} days`}
</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,118 @@
import { useState, useEffect } from 'react'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
interface Category {
id: string
name: string
slug: string
}
export default function AdminCategories() {
useDocumentTitle('Categories')
const [categories, setCategories] = useState<Category[]>([])
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const fetch = () => {
api.get<Category[]>('/categories')
.then(setCategories)
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(fetch, [])
const create = async () => {
if (!name.trim() || !slug.trim()) return
setError('')
try {
await api.post('/admin/categories', { name: name.trim(), slug: slug.trim() })
setName('')
setSlug('')
fetch()
} catch {
setError('Failed to create category')
}
}
const remove = async (id: string) => {
try {
await api.delete(`/admin/categories/${id}`)
fetch()
} catch {}
}
return (
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
<h1
className="font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Categories
</h1>
<div className="card p-4 mb-6">
<div className="flex gap-2 mb-2">
<input
className="input flex-1"
placeholder="Category name"
aria-label="Category name"
value={name}
onChange={(e) => {
setName(e.target.value)
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''))
}}
/>
<input
className="input w-40"
placeholder="slug"
aria-label="Category slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
/>
<button onClick={create} className="btn btn-admin">Add</button>
</div>
{error && <p role="alert" className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>}
</div>
{loading ? (
<div>
<div className="progress-bar mb-4" />
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton h-4" style={{ width: '40%' }} />
<div className="skeleton h-4 w-12" />
</div>
))}
</div>
) : categories.length === 0 ? (
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No categories yet</p>
) : (
<div className="flex flex-col gap-1">
{categories.map((cat) => (
<div
key={cat.id}
className="flex items-center justify-between p-3 rounded-lg"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div>
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{cat.name}</span>
<span className="text-xs ml-2" style={{ color: 'var(--text-tertiary)' }}>{cat.slug}</span>
</div>
<button
onClick={() => remove(cat.id)}
className="text-xs px-2 rounded"
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer' }}
>
Delete
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,237 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { IconPlus, IconTrash, IconPencil } from '@tabler/icons-react'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useConfirm } from '../../hooks/useConfirm'
import MarkdownEditor from '../../components/MarkdownEditor'
import Dropdown from '../../components/Dropdown'
interface Board {
id: string
slug: string
name: string
}
interface Entry {
id: string
title: string
body: string
boardId: string | null
board: Board | null
publishedAt: string
}
export default function AdminChangelog() {
useDocumentTitle('Manage Changelog')
const confirm = useConfirm()
const [entries, setEntries] = useState<Entry[]>([])
const [boards, setBoards] = useState<Board[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [boardId, setBoardId] = useState('')
const [error, setError] = useState('')
const [publishAt, setPublishAt] = useState('')
const fetchEntries = () => {
api.get<{ entries: Entry[] }>('/admin/changelog')
.then((r) => setEntries(r.entries))
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(fetchEntries, [])
useEffect(() => {
api.get<Board[]>('/admin/boards')
.then((r) => setBoards(Array.isArray(r) ? r : []))
.catch(() => {})
}, [])
const save = async () => {
if (!title.trim() || !body.trim()) return
setError('')
try {
const payload = {
title: title.trim(),
body: body.trim(),
boardId: boardId || null,
...(publishAt && { publishedAt: new Date(publishAt).toISOString() }),
}
if (editId) {
await api.put(`/admin/changelog/${editId}`, payload)
} else {
await api.post('/admin/changelog', payload)
}
setTitle('')
setBody('')
setBoardId('')
setPublishAt('')
setEditId(null)
setShowForm(false)
fetchEntries()
} catch {
setError('Failed to save entry')
}
}
const remove = async (id: string) => {
if (!await confirm('Delete this changelog entry?')) return
try {
await api.delete(`/admin/changelog/${id}`)
fetchEntries()
} catch {}
}
const startEdit = (entry: Entry) => {
setEditId(entry.id)
setTitle(entry.title)
setBody(entry.body)
setBoardId(entry.boardId || '')
const d = new Date(entry.publishedAt)
setPublishAt(d > new Date() ? d.toISOString().slice(0, 16) : '')
setShowForm(true)
}
return (
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
<div className="flex items-center justify-between mb-6">
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Changelog
</h1>
<div className="flex items-center gap-2">
<button
onClick={() => { setShowForm(!showForm); setEditId(null); setTitle(''); setBody(''); setBoardId(''); setPublishAt('') }}
className="btn btn-admin flex items-center gap-1"
style={{ fontSize: 'var(--text-sm)' }}
>
<IconPlus size={14} stroke={2} />
New entry
</button>
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back</Link>
</div>
</div>
{showForm && (
<div className="card p-4 mb-6">
<div className="mb-3">
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Title</label>
<input
className="input w-full"
placeholder="What changed?"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
/>
</div>
<div className="mb-3">
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Board</label>
<Dropdown
value={boardId}
onChange={setBoardId}
placeholder="All boards (global)"
options={[
{ value: '', label: 'All boards (global)' },
...boards.map((b) => ({ value: b.id, label: b.name })),
]}
/>
</div>
<div className="mb-3">
<label htmlFor="changelog-publish-at" style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, marginBottom: 4 }}>
Publish date
</label>
<input
id="changelog-publish-at"
type="datetime-local"
className="input w-full"
value={publishAt}
onChange={(e) => setPublishAt(e.target.value)}
/>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 4 }}>
Leave empty to publish immediately
</p>
</div>
<div className="mb-3">
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Body</label>
<MarkdownEditor
value={body}
onChange={(v) => setBody(v.slice(0, 10000))}
placeholder="Describe the changes..."
rows={8}
preview
/>
</div>
{error && <p role="alert" className="text-xs mb-2" style={{ color: 'var(--error)' }}>{error}</p>}
<div className="flex gap-2">
<button onClick={save} className="btn btn-admin">{editId ? 'Update' : 'Publish'}</button>
<button onClick={() => { setShowForm(false); setEditId(null) }} className="btn btn-ghost">Cancel</button>
</div>
</div>
)}
{loading ? (
<div>
<div className="progress-bar mb-4" />
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton h-4" style={{ width: '60%' }} />
<div className="skeleton h-4 w-16" />
</div>
))}
</div>
) : entries.length === 0 ? (
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No changelog entries yet</p>
) : (
<div className="flex flex-col gap-1">
{entries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between p-3 rounded-lg"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex-1 min-w-0 mr-2">
<div className="font-medium truncate flex items-center" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{entry.title}
{new Date(entry.publishedAt) > new Date() && (
<span className="px-1.5 py-0.5 rounded font-medium ml-2 shrink-0" style={{ background: 'rgba(96, 165, 250, 0.15)', color: '#93C5FD', fontSize: 'var(--text-xs)', border: '1px solid rgba(96, 165, 250, 0.25)' }}>
Scheduled
</span>
)}
</div>
<div className="text-xs truncate" style={{ color: 'var(--text-tertiary)', marginTop: 2 }}>
<time dateTime={entry.publishedAt}>{new Date(entry.publishedAt).toLocaleDateString()}</time>
{entry.board ? ` - ${entry.board.name}` : ' - Global'}
{' - '}{entry.body.slice(0, 60)}...
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => startEdit(entry)}
className="btn btn-ghost text-xs px-2"
style={{ minHeight: 44, color: 'var(--admin-accent)' }}
aria-label={`Edit ${entry.title}`}
>
<IconPencil size={14} stroke={2} />
</button>
<button
onClick={() => remove(entry.id)}
className="btn btn-ghost text-xs px-2"
style={{ minHeight: 44, color: 'var(--error)' }}
aria-label={`Delete ${entry.title}`}
>
<IconTrash size={14} stroke={2} />
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -1,15 +1,20 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { IconChevronRight, IconFileText, IconLayoutGrid, IconTag, IconTrash } from '@tabler/icons-react'
import type { Icon } from '@tabler/icons-react'
interface Stats {
totalPosts: number
byStatus: Record<string, number>
thisWeek: number
topUnresolved: { id: string; title: string; voteCount: number; boardSlug: string }[]
authMethodRatio?: Record<string, number>
}
export default function AdminDashboard() {
useDocumentTitle('Admin')
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
@@ -29,68 +34,157 @@ export default function AdminDashboard() {
{ 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' },
const navLinks: { to: string; label: string; desc: string; icon: Icon }[] = [
{ to: '/admin/posts', label: 'Manage Posts', desc: 'View, filter, and respond to all posts', icon: IconFileText },
{ to: '/admin/boards', label: 'Manage Boards', desc: 'Create, edit, and archive feedback boards', icon: IconLayoutGrid },
{ to: '/admin/categories', label: 'Categories', desc: 'Add or remove post categories', icon: IconTag },
{ to: '/admin/data-retention', label: 'Data Retention', desc: 'Cleanup schedules and upcoming counts', icon: IconTrash },
]
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 aria-live="polite" aria-busy="true" style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<div className="progress-bar mb-6" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-8">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="card p-4" style={{ opacity: 1 - i * 0.1 }}>
<div className="skeleton h-8 mb-2" style={{ width: '40%' }} />
<div className="skeleton h-3" style={{ width: '60%' }} />
</div>
))}
</div>
</div>
)
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<div className="flex items-center justify-between mb-8">
<h1
className="text-2xl font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Dashboard
</h1>
<Link to="/" className="btn btn-ghost text-sm">
<Link to="/" className="btn btn-ghost" style={{ fontSize: 'var(--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 }}>
{statCards.map((s, i) => (
<div key={s.label} className="card card-static p-4 stagger-in" style={{ '--stagger': i } as React.CSSProperties}>
<div className="font-bold mb-1" style={{ fontFamily: 'var(--font-heading)', color: s.color, fontSize: 'var(--text-xl)' }}>
{s.value}
</div>
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{s.label}
</div>
</div>
))}
</div>
{/* Posts by status bar chart */}
{stats && stats.totalPosts > 0 && (
<div className="card p-5 mb-8">
<h2
className="text-sm font-semibold mb-4"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Posts by status
</h2>
<div className="flex flex-col gap-2">
{[
{ key: 'OPEN', label: 'Open', color: 'var(--warning)' },
{ key: 'UNDER_REVIEW', label: 'Under review', color: 'var(--info)' },
{ key: 'PLANNED', label: 'Planned', color: 'var(--admin-accent)' },
{ key: 'IN_PROGRESS', label: 'In progress', color: 'var(--accent)' },
{ key: 'DONE', label: 'Done', color: 'var(--success)' },
{ key: 'DECLINED', label: 'Declined', color: 'var(--error)' },
].map((s) => {
const count = stats.byStatus[s.key] || 0
const pct = stats.totalPosts > 0 ? (count / stats.totalPosts) * 100 : 0
return (
<div key={s.key} className="flex items-center gap-3">
<span className="text-xs w-24 shrink-0" style={{ color: 'var(--text-secondary)' }}>{s.label}</span>
<div className="flex-1 h-5 rounded-sm overflow-hidden" style={{ background: 'var(--bg)' }}>
<div
className="h-full rounded-sm"
style={{
width: `${pct}%`,
background: s.color,
transition: 'width 500ms ease-out',
minWidth: count > 0 ? 4 : 0,
}}
/>
</div>
<span className="text-xs w-8 text-right font-medium" style={{ color: 'var(--text-tertiary)' }}>{count}</span>
</div>
)
})}
</div>
</div>
)}
{/* Auth method ratio */}
{stats?.authMethodRatio && Object.keys(stats.authMethodRatio).length > 0 && (() => {
const total = Object.values(stats.authMethodRatio!).reduce((a, b) => a + b, 0)
return (
<div className="card p-5 mb-8">
<h2
className="text-sm font-semibold mb-4"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
User identity methods
</h2>
<div className="flex gap-4">
{Object.entries(stats.authMethodRatio!).map(([method, count]) => (
<div key={method} className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{method === 'PASSKEY' ? 'Passkey' : 'Cookie'}
</span>
<span className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
{count} ({total > 0 ? Math.round((count / total) * 100) : 0}%)
</span>
</div>
<div className="h-2 rounded-sm overflow-hidden" style={{ background: 'var(--bg)' }}>
<div
className="h-full rounded-sm"
style={{
width: `${total > 0 ? (count / total) * 100 : 0}%`,
background: method === 'PASSKEY' ? 'var(--success)' : 'var(--accent)',
transition: 'width 500ms ease-out',
}}
/>
</div>
</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}
<div className="flex items-center gap-2">
<link.icon size={16} stroke={2} style={{ color: 'var(--admin-accent)' }} />
<h2
className="text-sm font-semibold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
>
{link.label}
</h2>
</div>
<IconChevronRight
size={16}
stroke={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>
@@ -115,6 +209,8 @@ export default function AdminDashboard() {
style={{ transition: 'background 200ms ease-out' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
onFocus={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
onBlur={(e) => (e.currentTarget.style.background = 'transparent')}
>
<span
className="text-sm font-semibold w-8 text-center"

View File

@@ -0,0 +1,111 @@
import { useState, useEffect } from 'react'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
interface RetentionData {
activityRetentionDays: number
orphanRetentionDays: number
staleActivityEvents: number
orphanedUsers: number
}
export default function AdminDataRetention() {
useDocumentTitle('Data Retention')
const [data, setData] = useState<RetentionData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get<RetentionData>('/admin/data-retention')
.then(setData)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
<div className="progress-bar mb-6" />
{[0, 1, 2, 3].map((i) => (
<div key={i} className="card p-5 mb-4" style={{ opacity: 1 - i * 0.15 }}>
<div className="skeleton h-4 mb-2" style={{ width: '50%' }} />
<div className="skeleton h-3 mb-2" style={{ width: '80%' }} />
<div className="skeleton h-3" style={{ width: '40%' }} />
</div>
))}
</div>
)
}
if (!data) {
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<p className="text-sm" style={{ color: 'var(--error)' }}>Failed to load retention data</p>
</div>
)
}
const items = [
{
label: 'Activity events',
window: `${data.activityRetentionDays} days`,
pending: data.staleActivityEvents,
desc: 'Activity feed entries older than the retention window are pruned daily at 3:00 AM.',
},
{
label: 'Orphaned anonymous users',
window: `${data.orphanRetentionDays} days`,
pending: data.orphanedUsers,
desc: 'Cookie-based users with no posts, comments, or votes after the retention window are pruned daily at 4:00 AM.',
},
{
label: 'Failed push subscriptions',
window: '3 consecutive failures',
pending: null,
desc: 'Push subscriptions that fail delivery 3 times are removed daily at 5:00 AM.',
},
{
label: 'WebAuthn challenges',
window: '60 seconds',
pending: null,
desc: 'Expired registration and login challenges are cleaned every minute.',
},
]
return (
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
<h1
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Data Retention
</h1>
<p className="mb-8" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
Automated cleanup schedules and upcoming counts
</p>
<div className="flex flex-col gap-4">
{items.map((item) => (
<div key={item.label} className="card p-5">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
{item.label}
</h2>
<span
className="text-xs px-2 py-0.5 rounded"
style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)' }}
>
{item.window}
</span>
</div>
<p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{item.desc}</p>
{item.pending !== null && (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span className="font-medium" style={{ color: 'var(--accent)' }}>{item.pending}</span> records scheduled for next cleanup
</p>
)}
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,263 @@
import { useState, useEffect } from 'react'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { IconCode, IconCopy, IconCheck } from '@tabler/icons-react'
import Dropdown from '../../components/Dropdown'
import NumberInput from '../../components/NumberInput'
interface Board {
id: string
slug: string
name: string
}
export default function AdminEmbed() {
useDocumentTitle('Embed')
const [boards, setBoards] = useState<Board[]>([])
const [selectedBoard, setSelectedBoard] = useState('')
const [theme, setTheme] = useState('dark')
const [limit, setLimit] = useState('10')
const [sort, setSort] = useState('top')
const [height, setHeight] = useState(500)
const [mode, setMode] = useState('inline')
const [label, setLabel] = useState('Feedback')
const [position, setPosition] = useState('right')
const [copied, setCopied] = useState(false)
useEffect(() => {
api.get<Board[]>('/admin/boards').then((b) => {
setBoards(b)
if (b.length > 0) setSelectedBoard(b[0].slug)
}).catch(() => {})
}, [])
const origin = window.location.origin
const attrs = [
'data-echoboard',
`data-board="${selectedBoard}"`,
`data-theme="${theme}"`,
`data-limit="${limit}"`,
`data-sort="${sort}"`,
`data-height="${height}"`,
]
if (mode === 'button') {
attrs.push(`data-mode="button"`)
attrs.push(`data-label="${label}"`)
attrs.push(`data-position="${position}"`)
}
attrs.push(`src="${origin}/embed.js"`)
const snippet = `<script ${attrs.join(' ')}><\/script>`
const handleCopy = () => {
navigator.clipboard.writeText(snippet).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="mx-auto px-6 py-10" style={{ maxWidth: 'var(--content-max)' }}>
<div className="flex items-center gap-3 mb-8">
<div
className="flex items-center justify-center"
style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)' }}
>
<IconCode size={20} stroke={2} style={{ color: 'var(--accent)' }} />
</div>
<div>
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-2xl)', fontWeight: 700, color: 'var(--text)' }}>
Embed Widget
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
Add a feedback widget to any website
</p>
</div>
</div>
{/* Settings */}
<div className="card p-6 mb-6">
<h2 className="font-semibold mb-4" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
Configure
</h2>
<div className="grid gap-4" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))' }}>
<div>
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Widget Type</label>
<Dropdown
value={mode}
onChange={setMode}
options={[
{ value: 'inline', label: 'Inline embed' },
{ value: 'button', label: 'Floating button' },
]}
/>
</div>
<div>
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Board</label>
<Dropdown
value={selectedBoard}
onChange={setSelectedBoard}
options={boards.map((b) => ({ value: b.slug, label: b.name }))}
/>
</div>
<div>
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Theme</label>
<Dropdown
value={theme}
onChange={setTheme}
options={[
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
]}
/>
</div>
<div>
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Sort</label>
<Dropdown
value={sort}
onChange={setSort}
options={[
{ value: 'top', label: 'Most voted' },
{ value: 'newest', label: 'Newest' },
]}
/>
</div>
<div>
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Posts</label>
<Dropdown
value={limit}
onChange={setLimit}
options={[
{ value: '5', label: '5 posts' },
{ value: '10', label: '10 posts' },
{ value: '15', label: '15 posts' },
{ value: '20', label: '20 posts' },
]}
/>
</div>
<div>
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Height (px)</label>
<NumberInput
value={height}
onChange={setHeight}
min={200}
max={2000}
step={50}
/>
</div>
{mode === 'button' && (
<>
<div>
<label htmlFor="embed-button-label" className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Button Label</label>
<input
id="embed-button-label"
className="input"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Feedback"
/>
</div>
<div>
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Position</label>
<Dropdown
value={position}
onChange={setPosition}
options={[
{ value: 'right', label: 'Bottom right' },
{ value: 'left', label: 'Bottom left' },
]}
/>
</div>
</>
)}
</div>
</div>
{/* Snippet */}
<div className="card p-6 mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
Embed code
</h2>
<button onClick={handleCopy} className="btn btn-secondary flex items-center gap-2">
{copied ? <IconCheck size={14} stroke={2} /> : <IconCopy size={14} stroke={2} />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<pre
style={{
padding: 16,
borderRadius: 'var(--radius-md)',
background: 'var(--bg)',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
fontSize: 'var(--text-sm)',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{snippet}
</pre>
</div>
{/* Preview */}
<div className="card p-6">
<h2 className="font-semibold mb-3" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
Preview
</h2>
{mode === 'button' ? (
<div
className="relative"
style={{
height: `${Math.min(height, 500)}px`,
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
}}
>
<div className="absolute inset-0 flex items-center justify-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
Simulated page content
</div>
<div
className="absolute"
style={{
bottom: 16,
[position === 'left' ? 'left' : 'right']: 16,
}}
>
<button
style={{
padding: '10px 20px',
border: 'none',
borderRadius: 24,
cursor: 'default',
fontFamily: 'var(--font-body)',
fontSize: 14,
fontWeight: 600,
background: 'var(--accent)',
color: '#161616',
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
}}
>
{label}
</button>
</div>
</div>
) : selectedBoard ? (
<iframe
src={`/embed/${selectedBoard}?theme=${theme}&limit=${limit}&sort=${sort}`}
style={{
width: '100%',
height: `${height}px`,
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
}}
title="Widget preview"
sandbox="allow-scripts allow-same-origin"
/>
) : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { useState } from 'react'
import { IconDownload } from '@tabler/icons-react'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
const types = [
{ value: 'all', label: 'All Data' },
{ value: 'posts', label: 'Posts' },
{ value: 'votes', label: 'Votes' },
{ value: 'comments', label: 'Comments' },
{ value: 'users', label: 'Users' },
]
const formats = [
{ value: 'json', label: 'JSON' },
{ value: 'csv', label: 'CSV' },
]
export default function AdminExport() {
useDocumentTitle('Export')
const [type, setType] = useState('all')
const [format, setFormat] = useState('json')
const [loading, setLoading] = useState(false)
async function doExport() {
setLoading(true)
try {
const res = await fetch(`/api/v1/admin/export?format=${format}&type=${type}`, {
credentials: 'include',
})
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const ext = format === 'csv' ? 'csv' : 'json'
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `echoboard-${type}.${ext}`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch {
// silently fail
} finally {
setLoading(false)
}
}
return (
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<h1
className="font-bold mb-8"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Export Data
</h1>
<div className="card p-6" style={{ maxWidth: 480 }}>
<div className="mb-5">
<label htmlFor="export-type" className="block mb-2 text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
Data type
</label>
<select
id="export-type"
className="input w-full"
value={type}
onChange={(e) => setType(e.target.value)}
>
{types.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div className="mb-6">
<label className="block mb-2 text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
Format
</label>
<div className="flex gap-2">
{formats.map((f) => (
<button
key={f.value}
className="btn btn-ghost flex-1"
style={{
background: format === f.value ? 'var(--admin-subtle)' : undefined,
color: format === f.value ? 'var(--admin-accent)' : 'var(--text-secondary)',
fontWeight: format === f.value ? 600 : 400,
border: format === f.value ? '1px solid var(--admin-accent)' : '1px solid var(--border)',
}}
onClick={() => setFormat(f.value)}
>
{f.label}
</button>
))}
</div>
</div>
<button
className="btn w-full flex items-center justify-center gap-2"
style={{
background: 'var(--admin-accent)',
color: '#fff',
opacity: loading ? 0.6 : 1,
}}
onClick={doExport}
disabled={loading}
>
<IconDownload size={16} stroke={2} />
{loading ? 'Exporting...' : 'Export'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,233 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { api } from '../../lib/api'
import { useBranding } from '../../hooks/useBranding'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
interface InviteInfo {
role: string
invitedBy: string | null
expiresAt: string
}
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
ADMIN: 'Admin',
MODERATOR: 'Moderator',
}
export default function AdminJoin() {
useDocumentTitle('Join Team')
const { token } = useParams<{ token: string }>()
const nav = useNavigate()
const { appName } = useBranding()
const [info, setInfo] = useState<InviteInfo | null>(null)
const [invalid, setInvalid] = useState(false)
const [displayName, setDisplayName] = useState('')
const [teamTitle, setTeamTitle] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
useEffect(() => {
if (!token) { setInvalid(true); return }
api.get<InviteInfo>(`/admin/join/${token}`)
.then(setInfo)
.catch(() => setInvalid(true))
}, [token])
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (!displayName.trim() || !token) return
setLoading(true)
setError('')
try {
const res = await api.post<{ needsSetup?: boolean }>(`/admin/join/${token}`, {
displayName: displayName.trim(),
teamTitle: teamTitle.trim() || undefined,
})
setSuccess(true)
if (res.needsSetup) {
setTimeout(() => nav('/admin'), 4000)
} else {
setTimeout(() => nav('/admin'), 2000)
}
} catch {
setError('Failed to join. The invite may have expired or already been used.')
} finally {
setLoading(false)
}
}
if (invalid) {
return (
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
<div
className="w-full max-w-sm fade-in text-center"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
padding: '28px',
}}
>
<h1
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)', fontSize: 'var(--text-xl)' }}
>
Invalid invite
</h1>
<p role="alert" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.5 }}>
This invite link is invalid, expired, or has already been used.
</p>
</div>
</div>
)
}
if (!info) {
return (
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
<div className="progress-bar" style={{ width: 200 }} />
</div>
)
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
<div
className="w-full max-w-sm fade-in text-center"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
padding: '28px',
}}
>
<h1
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-xl)' }}
>
Welcome to the team!
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.5 }}>
You can register a passkey or save a recovery phrase in your settings to secure your account. Redirecting to admin panel...
</p>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
<div
className="w-full max-w-sm fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
padding: '28px',
}}
>
<div className="text-center mb-6">
<h1
className="font-bold mb-1"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-xl)' }}
>
Join Team
</h1>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{appName} administration
</p>
</div>
<div
className="p-3 mb-5"
style={{
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-sm)',
}}
>
<div className="flex items-center justify-between mb-1">
<span style={{ color: 'var(--text-tertiary)' }}>Role</span>
<span
className="font-medium px-2 py-0.5 rounded"
style={{
color: 'var(--admin-accent)',
background: 'var(--admin-subtle)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
>
{ROLE_LABELS[info.role] ?? info.role}
</span>
</div>
{info.invitedBy && (
<div className="flex items-center justify-between mb-1">
<span style={{ color: 'var(--text-tertiary)' }}>Invited by</span>
<span style={{ color: 'var(--text)' }}>{info.invitedBy}</span>
</div>
)}
<div className="flex items-center justify-between">
<span style={{ color: 'var(--text-tertiary)' }}>Expires</span>
<span style={{ color: 'var(--text)' }}>{new Date(info.expiresAt).toLocaleDateString()}</span>
</div>
</div>
<form onSubmit={submit} className="flex flex-col gap-3">
<div>
<label htmlFor="join-name" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Display name <span style={{ color: 'var(--error)' }}>*</span>
</label>
<input
id="join-name"
className="input"
placeholder="Your name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
autoFocus
/>
</div>
<div>
<label htmlFor="join-title" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Team title (optional)
</label>
<input
id="join-title"
className="input"
placeholder="e.g. Product Manager"
value={teamTitle}
onChange={(e) => setTeamTitle(e.target.value)}
/>
</div>
{error && (
<p role="alert" className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>
)}
<button
type="submit"
disabled={loading || !displayName.trim()}
className="btn w-full mt-1"
style={{
background: 'var(--admin-accent)',
color: '#141420',
opacity: loading || !displayName.trim() ? 0.6 : 1,
}}
>
{loading ? 'Joining...' : 'Join team'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -1,13 +1,17 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useBranding } from '../../hooks/useBranding'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { api } from '../../lib/api'
export default function AdminLogin() {
useDocumentTitle('Admin Login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const nav = useNavigate()
const { appName } = useBranding()
const submit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -28,43 +32,61 @@ export default function AdminLogin() {
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)' }}
className="w-full max-w-sm fade-in"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-xl)',
padding: '28px',
}}
>
<div className="text-center mb-6">
<h1
className="text-xl font-bold mb-1"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
className="font-bold mb-1"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-xl)' }}
>
Admin Login
</h1>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
Echoboard administration
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{appName} 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 }}
/>
<div>
<label htmlFor="admin-email" className="sr-only">Email</label>
<input
id="admin-email"
className="input"
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
aria-invalid={!!error}
aria-describedby={error ? 'admin-login-error' : undefined}
style={{ borderColor: error ? 'var(--error)' : undefined }}
/>
</div>
<div>
<label htmlFor="admin-password" className="sr-only">Password</label>
<input
id="admin-password"
className="input"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
aria-invalid={!!error}
aria-describedby={error ? 'admin-login-error' : undefined}
style={{ borderColor: error ? 'var(--error)' : undefined }}
/>
</div>
{error && (
<p className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>
<p id="admin-login-error" role="alert" className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>
)}
<button

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
interface SiteSettings {
appName: string
logoUrl: string | null
faviconUrl: string | null
accentColor: string
headerFont: string | null
bodyFont: string | null
poweredByVisible: boolean
customCss: string | null
}
const defaults: SiteSettings = {
appName: 'Echoboard',
logoUrl: null,
faviconUrl: null,
accentColor: '#F59E0B',
headerFont: null,
bodyFont: null,
poweredByVisible: true,
customCss: null,
}
export default function AdminSettings() {
useDocumentTitle('Branding')
const [form, setForm] = useState<SiteSettings>(defaults)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
api.get<SiteSettings>('/admin/site-settings')
.then(data => setForm({ ...defaults, ...data }))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const handleSave = async () => {
setSaving(true)
setSaved(false)
try {
await api.put('/admin/site-settings', form)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch {} finally {
setSaving(false)
}
}
return (
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<div className="flex items-center justify-between mb-6">
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Branding
</h1>
<Link to="/admin" className="btn btn-ghost text-sm">Back</Link>
</div>
{loading ? (
<div>
<div className="progress-bar mb-4" />
{[0, 1, 2].map((i) => (
<div key={i} className="card p-4 mb-3" style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton h-4 mb-2" style={{ width: '30%' }} />
<div className="skeleton h-10" />
</div>
))}
</div>
) : (
<div className="card" style={{ padding: 24 }}>
<div className="flex flex-col gap-4">
<div>
<label htmlFor="settings-app-name" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>App name</label>
<input
id="settings-app-name"
className="input"
value={form.appName}
onChange={e => setForm(f => ({ ...f, appName: e.target.value }))}
placeholder="Echoboard"
/>
</div>
<div>
<label htmlFor="settings-logo-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Logo URL</label>
<input
id="settings-logo-url"
className="input"
value={form.logoUrl || ''}
onChange={e => setForm(f => ({ ...f, logoUrl: e.target.value || null }))}
placeholder="https://example.com/logo.svg"
/>
</div>
<div>
<label htmlFor="settings-favicon-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Favicon URL</label>
<input
id="settings-favicon-url"
className="input"
value={form.faviconUrl || ''}
onChange={e => setForm(f => ({ ...f, faviconUrl: e.target.value || null }))}
placeholder="https://example.com/favicon.ico"
/>
</div>
<div>
<label htmlFor="settings-accent-color" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Accent color</label>
<div className="flex items-center gap-3">
<input
id="settings-accent-picker"
type="color"
value={form.accentColor}
onChange={e => setForm(f => ({ ...f, accentColor: e.target.value }))}
aria-label="Accent color picker"
style={{ width: 40, height: 40, padding: 0, border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer' }}
/>
<input
id="settings-accent-color"
className="input"
value={form.accentColor}
onChange={e => setForm(f => ({ ...f, accentColor: e.target.value }))}
placeholder="#F59E0B"
style={{ maxWidth: 140 }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="settings-header-font" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Header font</label>
<input
id="settings-header-font"
className="input"
value={form.headerFont || ''}
onChange={e => setForm(f => ({ ...f, headerFont: e.target.value || null }))}
placeholder="Space Grotesk"
/>
</div>
<div>
<label htmlFor="settings-body-font" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Body font</label>
<input
id="settings-body-font"
className="input"
value={form.bodyFont || ''}
onChange={e => setForm(f => ({ ...f, bodyFont: e.target.value || null }))}
placeholder="Sora"
/>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer mt-1">
<input
type="checkbox"
checked={form.poweredByVisible}
onChange={e => setForm(f => ({ ...f, poweredByVisible: e.target.checked }))}
style={{ accentColor: 'var(--admin-accent)' }}
/>
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Show "Powered by" in embeds
</span>
</label>
<div>
<label htmlFor="settings-custom-css" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Custom CSS</label>
<textarea
id="settings-custom-css"
className="input"
rows={5}
value={form.customCss || ''}
onChange={e => setForm(f => ({ ...f, customCss: e.target.value || null }))}
placeholder=":root { --accent: #3B82F6; }"
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: 'var(--text-xs)' }}
/>
</div>
</div>
<div className="flex items-center gap-3 mt-6">
<button
onClick={handleSave}
disabled={saving}
className="btn btn-admin"
style={{ opacity: saving ? 0.6 : 1 }}
>
{saving ? 'Saving...' : 'Save'}
</button>
{saved && (
<span role="status" className="text-xs fade-in" style={{ color: 'var(--success)' }}>Saved</span>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,495 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { IconPalette, IconGripVertical, IconCheck, IconPlus, IconTrash } from '@tabler/icons-react'
import Dropdown from '../../components/Dropdown'
interface Board {
id: string
slug: string
name: string
}
interface StatusEntry {
status: string
label: string
color: string
position: number
}
const SUGGESTION_COLORS = [
'#F59E0B', '#06B6D4', '#3B82F6', '#EAB308', '#22C55E',
'#EF4444', '#8B5CF6', '#EC4899', '#F97316', '#64748B',
'#14B8A6', '#A855F7', '#E11D48', '#0EA5E9', '#84CC16',
]
function toStatusKey(label: string): string {
return label.trim().toUpperCase().replace(/\s+/g, '_').replace(/[^A-Z0-9_]/g, '')
}
function DropLine() {
return (
<div
style={{
height: 2,
background: 'var(--accent)',
borderRadius: 1,
margin: '0 12px',
position: 'relative',
}}
>
<div style={{ position: 'absolute', left: -4, top: -3, width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
<div style={{ position: 'absolute', right: -4, top: -3, width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
</div>
)
}
export default function AdminStatuses() {
useDocumentTitle('Statuses')
const [boards, setBoards] = useState<Board[]>([])
const [selectedBoardId, setSelectedBoardId] = useState('')
const [statuses, setStatuses] = useState<StatusEntry[]>([])
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
const [usedStatuses, setUsedStatuses] = useState<Record<string, number>>({})
// add form
const [newLabel, setNewLabel] = useState('')
// move modal for deleting in-use statuses
const [moveFrom, setMoveFrom] = useState<StatusEntry | null>(null)
const [moveTo, setMoveTo] = useState('')
// drag state
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [insertAt, setInsertAt] = useState<number | null>(null)
const rowRefs = useRef<(HTMLDivElement | null)[]>([])
// color picker refs
const colorRefs = useRef<(HTMLInputElement | null)[]>([])
useEffect(() => {
api.get<Board[]>('/admin/boards').then((b) => {
setBoards(b)
if (b.length > 0) setSelectedBoardId(b[0].id)
}).catch(() => {})
}, [])
const fetchStatuses = useCallback(() => {
if (!selectedBoardId) return
setError('')
api.get<{ statuses: StatusEntry[] }>(`/admin/boards/${selectedBoardId}/statuses`)
.then((r) => setStatuses(r.statuses))
.catch(() => {})
}, [selectedBoardId])
useEffect(() => {
fetchStatuses()
if (!selectedBoardId) return
api.get<{ posts: { status: string }[] }>(`/admin/posts?boardId=${selectedBoardId}&limit=100`)
.then((r) => {
const counts: Record<string, number> = {}
r.posts.forEach((p) => { counts[p.status] = (counts[p.status] || 0) + 1 })
setUsedStatuses(counts)
})
.catch(() => {})
}, [selectedBoardId, fetchStatuses])
const updateField = (index: number, field: keyof StatusEntry, value: string | number) => {
setStatuses((prev) => prev.map((s, i) => i === index ? { ...s, [field]: value } : s))
setSaved(false)
}
const removeStatus = (index: number) => {
const s = statuses[index]
if (s.status === 'OPEN') return
const count = usedStatuses[s.status] || 0
if (count > 0) {
setMoveFrom(s)
setMoveTo('')
return
}
setStatuses((prev) => prev.filter((_, i) => i !== index).map((st, i) => ({ ...st, position: i })))
setSaved(false)
setError('')
}
const handleMoveThenDelete = async () => {
if (!moveFrom || !moveTo || !selectedBoardId) return
setSaving(true)
try {
await api.post(`/admin/boards/${selectedBoardId}/statuses/move`, {
fromStatus: moveFrom.status,
toStatus: moveTo,
})
setStatuses((prev) => prev.filter((s) => s.status !== moveFrom.status).map((s, i) => ({ ...s, position: i })))
setUsedStatuses((prev) => {
const next = { ...prev }
const moved = next[moveFrom.status] || 0
delete next[moveFrom.status]
next[moveTo] = (next[moveTo] || 0) + moved
return next
})
setMoveFrom(null)
setMoveTo('')
setSaved(false)
} catch {
setError('Failed to move posts')
} finally {
setSaving(false)
}
}
const addStatus = () => {
const label = newLabel.trim()
if (!label) return
const key = toStatusKey(label)
if (!key) return
if (statuses.some((s) => s.status === key)) {
setError(`Status "${key}" already exists`)
return
}
const color = SUGGESTION_COLORS[statuses.length % SUGGESTION_COLORS.length]
setStatuses((prev) => [
...prev,
{ status: key, label, color, position: prev.length },
])
setNewLabel('')
setSaved(false)
setError('')
}
// drag handlers
const onDragStart = useCallback((e: React.DragEvent, idx: number) => {
setDragIdx(idx)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(idx))
if (e.currentTarget instanceof HTMLElement) e.currentTarget.style.opacity = '0.4'
}, [])
const onDragEnd = useCallback((e: React.DragEvent) => {
if (e.currentTarget instanceof HTMLElement) e.currentTarget.style.opacity = '1'
setDragIdx(null)
setInsertAt(null)
}, [])
const onDragOver = useCallback((e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const row = rowRefs.current[idx]
if (!row) return
const rect = row.getBoundingClientRect()
setInsertAt(e.clientY < rect.top + rect.height / 2 ? idx : idx + 1)
}, [])
const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
if (dragIdx === null || insertAt === null) return
setStatuses((prev) => {
const next = [...prev]
const [moved] = next.splice(dragIdx, 1)
next.splice(insertAt > dragIdx ? insertAt - 1 : insertAt, 0, moved)
return next.map((s, i) => ({ ...s, position: i }))
})
setSaved(false)
setDragIdx(null)
setInsertAt(null)
}, [dragIdx, insertAt])
const handleSave = async () => {
setSaving(true)
setError('')
try {
const payload = statuses.map((s, i) => ({
status: s.status,
label: s.label,
color: s.color,
position: i,
}))
await api.put(`/admin/boards/${selectedBoardId}/statuses`, { statuses: payload })
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e: unknown) {
const msg = e && typeof e === 'object' && 'body' in e
? ((e as { body: { error?: string } }).body?.error || 'Save failed')
: 'Save failed'
setError(msg)
} finally {
setSaving(false)
}
}
return (
<div className="mx-auto px-6 py-10" style={{ maxWidth: 'var(--content-max)' }}>
<div className="flex items-center gap-3 mb-8">
<div
className="flex items-center justify-center"
style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)' }}
>
<IconPalette size={20} stroke={2} style={{ color: 'var(--accent)' }} />
</div>
<div>
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-2xl)', fontWeight: 700, color: 'var(--text)' }}>
Custom Statuses
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
Customize status labels, colors, and order per board
</p>
</div>
</div>
<div className="mb-6" style={{ maxWidth: 300 }}>
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Board</label>
<Dropdown
value={selectedBoardId}
onChange={setSelectedBoardId}
options={boards.map((b) => ({ value: b.id, label: b.name }))}
/>
</div>
<div className="card p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
Statuses
</h2>
<button
onClick={handleSave}
disabled={saving}
className="btn btn-primary flex items-center gap-2"
>
{saved ? <IconCheck size={14} stroke={2} /> : null}
{saving ? 'Saving...' : saved ? 'Saved' : 'Save changes'}
</button>
</div>
{error && (
<div role="alert" className="mb-4 p-3 rounded" style={{ background: 'rgba(239,68,68,0.1)', color: 'var(--error)', fontSize: 'var(--text-sm)' }}>
{error}
</div>
)}
<div
className="flex flex-col gap-1"
onDragLeave={() => setInsertAt(null)}
>
{statuses.map((s, i) => {
const showLineBefore = dragIdx !== null && insertAt === i && dragIdx !== i && dragIdx !== i - 1
const showLineAfter = dragIdx !== null && insertAt === statuses.length && i === statuses.length - 1 && dragIdx !== i
const count = usedStatuses[s.status] || 0
return (
<div key={s.status}>
{showLineBefore && <DropLine />}
<div
ref={(el) => { rowRefs.current[i] = el }}
draggable
onDragStart={(e) => onDragStart(e, i)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, i)}
onDrop={onDrop}
className="flex items-center gap-3 p-3 rounded-lg"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', cursor: 'default' }}
>
<div
style={{ cursor: 'grab', color: 'var(--text-tertiary)', display: 'flex', padding: '2px 0' }}
onMouseDown={(e) => { e.currentTarget.style.cursor = 'grabbing' }}
onMouseUp={(e) => { e.currentTarget.style.cursor = 'grab' }}
>
<IconGripVertical size={16} stroke={2} />
</div>
<div className="relative">
<div
role="button"
tabIndex={0}
aria-label={`Pick color for ${s.label || s.status}`}
style={{
width: 44, height: 44,
borderRadius: 'var(--radius-sm)',
background: s.color,
cursor: 'pointer',
border: '2px solid var(--border)',
transition: 'transform 100ms ease-out',
}}
onClick={() => colorRefs.current[i]?.click()}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') colorRefs.current[i]?.click() }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)' }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
onFocus={(e) => { e.currentTarget.style.transform = 'scale(1.1)' }}
onBlur={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
/>
<input
ref={(el) => { colorRefs.current[i] = el }}
type="color"
value={s.color}
onChange={(e) => updateField(i, 'color', e.target.value)}
style={{ position: 'absolute', top: 0, left: 0, width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
/>
</div>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontFamily: 'var(--font-mono)', minWidth: 100 }}>
{s.status}
</span>
<input
className="input flex-1"
value={s.label}
onChange={(e) => updateField(i, 'label', e.target.value)}
style={{ maxWidth: 200 }}
/>
{count > 0 && (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
{count} post{count !== 1 ? 's' : ''}
</span>
)}
<button
type="button"
onClick={() => removeStatus(i)}
disabled={s.status === 'OPEN'}
aria-label={s.status === 'OPEN' ? 'OPEN status is required' : `Remove ${s.label || s.status}`}
className="flex items-center justify-center"
style={{
width: 44, height: 44,
borderRadius: 'var(--radius-sm)',
color: s.status === 'OPEN' ? 'var(--border)' : 'var(--text-tertiary)',
cursor: s.status === 'OPEN' ? 'not-allowed' : 'pointer',
background: 'transparent', border: 'none',
transition: 'color 100ms ease-out, background 100ms ease-out',
flexShrink: 0,
}}
onMouseEnter={(e) => {
if (s.status !== 'OPEN') {
e.currentTarget.style.color = 'var(--error)'
e.currentTarget.style.background = 'var(--surface-hover)'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = s.status === 'OPEN' ? 'var(--border)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
onFocus={(e) => {
if (s.status !== 'OPEN') {
e.currentTarget.style.color = 'var(--error)'
e.currentTarget.style.background = 'var(--surface-hover)'
}
}}
onBlur={(e) => {
e.currentTarget.style.color = s.status === 'OPEN' ? 'var(--border)' : 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
>
<IconTrash size={14} stroke={2} />
</button>
</div>
{showLineAfter && <DropLine />}
</div>
)
})}
</div>
{/* Add new status */}
<div className="flex items-center gap-2 mt-4">
<input
className="input flex-1"
placeholder="New status name..."
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addStatus() }}
style={{ maxWidth: 240 }}
/>
<button
onClick={addStatus}
disabled={!newLabel.trim()}
className="btn btn-admin flex items-center gap-1"
style={{ fontSize: 'var(--text-sm)' }}
>
<IconPlus size={14} stroke={2} />
Add
</button>
{newLabel.trim() && (
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontFamily: 'var(--font-mono)' }}>
{toStatusKey(newLabel)}
</span>
)}
</div>
</div>
{/* Preview */}
<div className="card p-6">
<h2 className="font-semibold mb-4" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
Preview
</h2>
<div className="flex flex-wrap gap-2">
{statuses.map((s) => (
<span
key={s.status}
className="inline-flex items-center gap-1.5 px-2.5 py-1 font-medium"
style={{
background: `${s.color}20`,
color: s.color,
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: s.color }} />
{s.label}
</span>
))}
</div>
</div>
{/* Move posts modal */}
{moveFrom && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={() => setMoveFrom(null)}
>
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
<div
role="dialog"
aria-modal="true"
aria-labelledby="move-posts-title"
className="relative card p-6 w-full fade-in"
style={{ maxWidth: 420 }}
onClick={(e) => e.stopPropagation()}
>
<h2 id="move-posts-title" className="font-bold mb-2" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
Move posts
</h2>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
<strong style={{ color: moveFrom.color }}>{moveFrom.label}</strong> has {usedStatuses[moveFrom.status] || 0} post{(usedStatuses[moveFrom.status] || 0) !== 1 ? 's' : ''}. Move them to another status before removing it.
</p>
<label className="text-xs font-medium block mb-1.5" style={{ color: 'var(--text-tertiary)' }}>
Move to
</label>
<div className="mb-4">
<Dropdown
value={moveTo}
onChange={setMoveTo}
placeholder="Select status..."
options={statuses
.filter((s) => s.status !== moveFrom.status)
.map((s) => ({ value: s.status, label: s.label }))}
/>
</div>
<div className="flex gap-2">
<button onClick={() => setMoveFrom(null)} className="btn btn-ghost flex-1">Cancel</button>
<button
onClick={handleMoveThenDelete}
disabled={!moveTo || saving}
className="btn btn-primary flex-1"
>
{saving ? 'Moving...' : 'Move and remove'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,205 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useConfirm } from '../../hooks/useConfirm'
interface Tag {
id: string
name: string
color: string
_count?: { posts: number }
}
const PRESET_COLORS = [
'#F59E0B', '#EF4444', '#22C55E', '#3B82F6', '#8B5CF6',
'#EC4899', '#06B6D4', '#F97316', '#14B8A6', '#6366F1',
]
export default function AdminTags() {
useDocumentTitle('Tags')
const confirm = useConfirm()
const [tags, setTags] = useState<Tag[]>([])
const [name, setName] = useState('')
const [color, setColor] = useState('#6366F1')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editId, setEditId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const [editColor, setEditColor] = useState('')
const fetchTags = () => {
api.get<{ tags: Tag[] }>('/admin/tags')
.then((r) => setTags(r.tags))
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(fetchTags, [])
const create = async () => {
if (!name.trim()) return
setError('')
try {
await api.post('/admin/tags', { name: name.trim(), color })
setName('')
fetchTags()
} catch {
setError('Failed to create tag')
}
}
const update = async () => {
if (!editId || !editName.trim()) return
try {
await api.put(`/admin/tags/${editId}`, { name: editName.trim(), color: editColor })
setEditId(null)
fetchTags()
} catch {
setError('Failed to update tag')
}
}
const remove = async (id: string) => {
if (!await confirm('Delete this tag? It will be removed from all posts.')) return
try {
await api.delete(`/admin/tags/${id}`)
fetchTags()
} catch {}
}
return (
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
<div className="flex items-center justify-between mb-6">
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Tags
</h1>
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back to dashboard</Link>
</div>
<div className="card p-4 mb-6">
<div className="flex gap-2 items-center mb-2">
<input
className="input flex-1"
placeholder="Tag name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && create()}
maxLength={30}
/>
<button onClick={create} className="btn btn-admin">Add</button>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginRight: 4 }}>Color</span>
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
aria-label={`Color ${c}`}
style={{
width: 44,
height: 44,
background: c,
border: c === color ? '2px solid var(--text)' : '2px solid transparent',
borderRadius: '50%',
cursor: 'pointer',
}}
/>
))}
</div>
{error && <p role="alert" className="text-xs mt-2" style={{ color: 'var(--error)' }}>{error}</p>}
</div>
{loading ? (
<div>
<div className="progress-bar mb-4" />
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton h-4" style={{ width: '40%' }} />
<div className="skeleton h-4 w-12" />
</div>
))}
</div>
) : tags.length === 0 ? (
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No tags yet</p>
) : (
<div className="flex flex-col gap-1">
{tags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between p-3 rounded-lg"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
>
{editId === tag.id ? (
<div className="flex items-center gap-2 flex-1 mr-2">
<input
className="input flex-1"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && update()}
maxLength={30}
autoFocus
/>
<div className="flex items-center gap-1">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setEditColor(c)}
aria-label={`Color ${c}`}
style={{
width: 44,
height: 44,
background: c,
border: c === editColor ? '2px solid var(--text)' : '2px solid transparent',
borderRadius: '50%',
cursor: 'pointer',
}}
/>
))}
</div>
<button onClick={update} className="btn btn-admin text-xs px-3 py-1">Save</button>
<button onClick={() => setEditId(null)} className="btn btn-ghost text-xs px-2 py-1">Cancel</button>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
style={{ background: `${tag.color}20`, color: tag.color }}
>
{tag.name}
</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{tag._count?.posts ?? 0} posts
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => { setEditId(tag.id); setEditName(tag.name); setEditColor(tag.color) }}
className="text-xs px-2 rounded"
style={{ minHeight: 44, color: 'var(--admin-accent)', background: 'none', border: 'none', cursor: 'pointer' }}
>
Edit
</button>
<button
onClick={() => remove(tag.id)}
className="text-xs px-2 rounded"
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer' }}
>
Delete
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,645 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useAdmin } from '../../hooks/useAdmin'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useConfirm } from '../../hooks/useConfirm'
import Dropdown from '../../components/Dropdown'
import { IconCopy, IconCheck, IconTrash, IconKey, IconPlus, IconInfoCircle, IconX, IconCamera } from '@tabler/icons-react'
import Avatar from '../../components/Avatar'
import { useAuth } from '../../hooks/useAuth'
interface TeamMember {
id: string
displayName: string | null
teamTitle: string | null
role: string
invitedBy: string | null
joinedAt: string
}
interface PendingInvite {
id: string
role: string
label: string | null
expiresAt: string
createdBy: string | null
}
interface InviteResult {
url: string
recoveryPhrase: string | null
}
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
ADMIN: 'Admin',
MODERATOR: 'Moderator',
}
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'var(--admin-accent)',
ADMIN: 'var(--accent)',
MODERATOR: 'var(--text-secondary)',
}
function RoleBadge({ role }: { role: string }) {
return (
<span
className="px-2 py-0.5 rounded font-medium"
style={{
fontSize: 'var(--text-xs)',
background: `color-mix(in srgb, ${ROLE_COLORS[role] ?? 'var(--text-tertiary)'} 18%, transparent)`,
color: ROLE_COLORS[role] ?? 'var(--text-tertiary)',
border: `1px solid color-mix(in srgb, ${ROLE_COLORS[role] ?? 'var(--text-tertiary)'} 25%, transparent)`,
borderRadius: 'var(--radius-sm)',
}}
>
{ROLE_LABELS[role] ?? role}
</span>
)
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
onClick={copy}
className="btn btn-ghost inline-flex items-center gap-1"
style={{ fontSize: 'var(--text-xs)', padding: '4px 8px' }}
>
{copied ? <IconCheck size={14} stroke={2} /> : <IconCopy size={14} stroke={2} />}
{copied ? 'Copied' : 'Copy'}
</button>
)
}
export default function AdminTeam() {
useDocumentTitle('Team')
const admin = useAdmin()
const auth = useAuth()
const confirm = useConfirm()
const [members, setMembers] = useState<TeamMember[]>([])
const [invites, setInvites] = useState<PendingInvite[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// invite form
const [showInviteForm, setShowInviteForm] = useState(false)
const [inviteRole, setInviteRole] = useState('MODERATOR')
const [inviteExpiry, setInviteExpiry] = useState('7d')
const [inviteLabel, setInviteLabel] = useState('')
const [inviteRecovery, setInviteRecovery] = useState(false)
const [inviteLoading, setInviteLoading] = useState(false)
const [inviteResult, setInviteResult] = useState<InviteResult | null>(null)
const [myName, setMyName] = useState(admin.displayName || '')
const [myTitle, setMyTitle] = useState(admin.teamTitle || '')
const [profileSaving, setProfileSaving] = useState(false)
const [profileSaved, setProfileSaved] = useState(false)
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const [showPerms, setShowPerms] = useState(false)
const [permsPos, setPermsPos] = useState({ top: 0, left: 0 })
const permsAnchorRef = useRef<HTMLButtonElement>(null)
const openPerms = useCallback(() => {
if (showPerms) { setShowPerms(false); return }
if (permsAnchorRef.current) {
const rect = permsAnchorRef.current.getBoundingClientRect()
const popW = Math.min(400, window.innerWidth - 32)
let left = rect.left
if (left + popW > window.innerWidth - 16) left = window.innerWidth - 16 - popW
if (left < 16) left = 16
setPermsPos({ top: rect.bottom + 6, left })
}
setShowPerms(true)
}, [showPerms])
const fetchMembers = () => {
api.get<{ members: TeamMember[] }>('/admin/team')
.then((r) => setMembers(r.members))
.catch(() => {})
.finally(() => setLoading(false))
}
const fetchInvites = () => {
api.get<{ invites: PendingInvite[] }>('/admin/team/invites')
.then((r) => setInvites(r.invites))
.catch(() => {})
}
useEffect(() => { fetchMembers(); fetchInvites() }, [])
useEffect(() => {
api.get<{ avatarUrl: string | null }>('/me').then((d) => setAvatarUrl(d.avatarUrl ?? null)).catch(() => {})
}, [])
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || file.size > 2 * 1024 * 1024) return
setUploadingAvatar(true)
try {
const form = new FormData()
form.append('file', file)
const res = await fetch('/api/v1/me/avatar', { method: 'POST', body: form, credentials: 'include' })
if (res.ok) {
const data = await res.json()
setAvatarUrl(data.avatarUrl + '?t=' + Date.now())
}
} catch {} finally {
setUploadingAvatar(false)
if (avatarInputRef.current) avatarInputRef.current.value = ''
}
}
const handleAvatarRemove = async () => {
try {
await api.delete('/me/avatar')
setAvatarUrl(null)
} catch {}
}
const removeMember = async (id: string, name: string | null) => {
if (!await confirm(`Remove ${name ?? 'this team member'}? They will lose admin access.`)) return
try {
await api.delete(`/admin/team/${id}`)
fetchMembers()
} catch {
setError('Failed to remove member')
}
}
const revokeInvite = async (id: string) => {
if (!await confirm('Revoke this invite? The link will stop working.')) return
try {
await api.delete(`/admin/team/invites/${id}`)
fetchInvites()
} catch {
setError('Failed to revoke invite')
}
}
const regenRecovery = async (id: string, name: string | null) => {
if (!await confirm(`Regenerate recovery phrase for ${name ?? 'this member'}? The old phrase will stop working.`)) return
try {
const res = await api.post<{ recoveryPhrase: string }>(`/admin/team/${id}/recovery`)
alert(`New recovery phrase:\n\n${res.recoveryPhrase}\n\nSave this - it won't be shown again.`)
} catch {
setError('Failed to regenerate recovery phrase')
}
}
const createInvite = async () => {
setInviteLoading(true)
setError('')
try {
const res = await api.post<InviteResult>('/admin/team/invite', {
role: inviteRole,
expiry: inviteExpiry,
label: inviteLabel.trim() || undefined,
generateRecovery: inviteRecovery,
})
setInviteResult(res)
} catch {
setError('Failed to create invite')
} finally {
setInviteLoading(false)
}
}
const closeInviteForm = () => {
setShowInviteForm(false)
setInviteResult(null)
setInviteLabel('')
setInviteRecovery(false)
if (inviteResult) fetchInvites()
}
const saveProfile = async () => {
if (!myName.trim()) return
setProfileSaving(true)
try {
await api.put('/admin/team/me', { displayName: myName.trim(), teamTitle: myTitle.trim() || undefined })
admin.refresh()
setProfileSaved(true)
setTimeout(() => setProfileSaved(false), 2000)
} catch {
setError('Failed to update profile')
} finally {
setProfileSaving(false)
}
}
const roleOptions = admin.isSuperAdmin
? [
{ value: 'ADMIN', label: 'Admin' },
{ value: 'MODERATOR', label: 'Moderator' },
]
: [{ value: 'MODERATOR', label: 'Moderator' }]
const expiryOptions = [
{ value: '1h', label: '1 hour' },
{ value: '24h', label: '24 hours' },
{ value: '7d', label: '7 days' },
{ value: '30d', label: '30 days' },
]
return (
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
<div className="flex items-center justify-between mb-6">
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Team
</h1>
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back to dashboard</Link>
</div>
{error && (
<div role="alert" className="card p-3 mb-4" style={{ borderColor: 'var(--error)', color: 'var(--error)', fontSize: 'var(--text-sm)' }}>
{error}
</div>
)}
{/* Your profile */}
<div className="card p-5 mb-6" style={{ borderColor: 'rgba(6, 182, 212, 0.15)' }}>
<h2 className="font-semibold mb-3" style={{ color: 'var(--admin-accent)', fontSize: 'var(--text-sm)' }}>
Your profile
</h2>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4 mb-1">
<Avatar userId={auth.user?.id ?? ''} name={myName || null} avatarUrl={avatarUrl} size={56} />
<div className="flex flex-col gap-1.5">
<input
ref={avatarInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarUpload}
className="sr-only"
id="admin-avatar-upload"
/>
<label
htmlFor="admin-avatar-upload"
className="btn btn-secondary inline-flex items-center gap-2"
style={{ cursor: uploadingAvatar ? 'wait' : 'pointer', opacity: uploadingAvatar ? 0.6 : 1, fontSize: 'var(--text-xs)', padding: '6px 12px' }}
>
<IconCamera size={14} stroke={2} />
{uploadingAvatar ? 'Uploading...' : avatarUrl ? 'Change photo' : 'Upload photo'}
</label>
{avatarUrl && (
<button
onClick={handleAvatarRemove}
className="action-btn inline-flex items-center gap-1"
style={{ color: 'var(--error)', fontSize: 'var(--text-xs)', padding: '4px 8px', borderRadius: 'var(--radius-sm)' }}
>
<IconTrash size={12} stroke={2} /> Remove
</button>
)}
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>JPG, PNG or WebP. Max 2MB.</span>
</div>
</div>
<div>
<label htmlFor="my-display-name" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Display name
</label>
<input
id="my-display-name"
className="input w-full"
value={myName}
onChange={(e) => setMyName(e.target.value)}
placeholder="Your name"
maxLength={100}
/>
</div>
<div>
<label htmlFor="my-team-title" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Team title <span style={{ color: 'var(--text-tertiary)' }}>(optional)</span>
</label>
<input
id="my-team-title"
className="input w-full"
value={myTitle}
onChange={(e) => setMyTitle(e.target.value)}
placeholder="e.g. Product Manager, Lead Developer"
maxLength={100}
/>
</div>
<div className="flex items-center gap-3">
<button
onClick={saveProfile}
disabled={profileSaving || !myName.trim()}
className="btn btn-admin"
style={{ fontSize: 'var(--text-sm)', opacity: profileSaving ? 0.6 : 1 }}
>
{profileSaving ? 'Saving...' : profileSaved ? 'Saved' : 'Save profile'}
</button>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{admin.role === 'SUPER_ADMIN' ? 'Super Admin' : admin.role === 'MODERATOR' ? 'Moderator' : 'Admin'}
</span>
</div>
</div>
</div>
{/* Invite section */}
{admin.canInvite && (
<div className="card p-4 mb-6">
{!showInviteForm && !inviteResult && (
<button
onClick={() => setShowInviteForm(true)}
className="btn btn-admin inline-flex items-center gap-2"
>
<IconPlus size={16} stroke={2} />
Invite team member
</button>
)}
{showInviteForm && !inviteResult && (
<div className="flex flex-col gap-3">
<h2 className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
Create invite link
</h2>
<div style={{ position: 'relative' }}>
<div className="flex items-center gap-1.5" style={{ marginBottom: 4 }}>
<label id="invite-role-label" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>Role</label>
<button
ref={permsAnchorRef}
type="button"
onClick={openPerms}
aria-label="View role permissions"
className="action-btn"
style={{ cursor: 'pointer', padding: 2, color: 'var(--text-tertiary)', borderRadius: 'var(--radius-sm)' }}
>
<IconInfoCircle size={14} stroke={2} />
</button>
</div>
{showPerms && (
<>
<div
className="fixed inset-0 z-[99]"
onClick={() => setShowPerms(false)}
/>
<div
className="fade-in fixed z-[100]"
style={{
top: permsPos.top,
left: permsPos.left,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-xl)',
padding: '16px',
width: Math.min(400, window.innerWidth - 32),
}}
onKeyDown={(e) => e.key === 'Escape' && setShowPerms(false)}
>
<div className="flex items-center justify-between mb-3">
<span className="font-medium" style={{ fontSize: 'var(--text-sm)', color: 'var(--text)' }}>Role permissions</span>
<button
onClick={() => setShowPerms(false)}
aria-label="Close"
className="action-btn"
style={{ cursor: 'pointer', color: 'var(--text-tertiary)', padding: 2, borderRadius: 'var(--radius-sm)' }}
>
<IconX size={14} stroke={2} />
</button>
</div>
<table style={{ width: '100%', fontSize: 'var(--text-xs)', color: 'var(--text-secondary)', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={{ textAlign: 'left', padding: '4px 8px 4px 0', color: 'var(--text-tertiary)' }}></th>
<th style={{ textAlign: 'center', padding: '4px 6px', color: 'var(--admin-accent)', fontWeight: 600 }}>Admin</th>
<th style={{ textAlign: 'center', padding: '4px 6px', color: 'var(--text-secondary)', fontWeight: 600 }}>Mod</th>
</tr>
</thead>
<tbody>
{[
['Manage posts, status, pin, merge', true, true],
['Categories, tags, notes', true, true],
['Boards, statuses, templates', true, false],
['Changelog, embed widget', true, false],
['Webhooks, data export', true, false],
['Invite moderators', true, false],
['Branding / site settings', false, false],
].map(([label, adm, mod], i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '5px 8px 5px 0' }}>{label as string}</td>
<td style={{ textAlign: 'center', padding: '5px 6px', color: adm ? 'var(--success)' : 'var(--text-tertiary)' }}>{adm ? 'Yes' : '-'}</td>
<td style={{ textAlign: 'center', padding: '5px 6px', color: mod ? 'var(--success)' : 'var(--text-tertiary)' }}>{mod ? 'Yes' : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<Dropdown
value={inviteRole}
options={roleOptions}
onChange={setInviteRole}
placeholder="Select role"
aria-label="Role"
/>
</div>
<div>
<label id="invite-expiry-label" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>Expires in</label>
<Dropdown
value={inviteExpiry}
options={expiryOptions}
onChange={setInviteExpiry}
placeholder="Select expiry"
aria-label="Expires in"
/>
</div>
<div>
<label htmlFor="invite-label-input" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>Label (optional)</label>
<input
id="invite-label-input"
className="input"
placeholder="e.g. 'For Jane'"
value={inviteLabel}
onChange={(e) => setInviteLabel(e.target.value)}
maxLength={100}
/>
</div>
<label className="flex items-center gap-2" style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={inviteRecovery}
onChange={(e) => setInviteRecovery(e.target.checked)}
/>
Generate recovery phrase
</label>
<div className="flex gap-2">
<button onClick={createInvite} disabled={inviteLoading} className="btn btn-admin" style={{ opacity: inviteLoading ? 0.6 : 1 }}>
{inviteLoading ? 'Creating...' : 'Create invite'}
</button>
<button onClick={closeInviteForm} className="btn btn-ghost">Cancel</button>
</div>
</div>
)}
{inviteResult && (
<div className="flex flex-col gap-3">
<h2 className="font-medium" style={{ color: 'var(--admin-accent)', fontSize: 'var(--text-sm)' }}>
Invite created
</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>
Share this link with your team member. It can only be used once.
</p>
<div
className="p-3 flex items-center gap-2"
style={{
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
fontFamily: 'var(--font-mono, monospace)',
wordBreak: 'break-all',
}}
>
<span className="flex-1" style={{ color: 'var(--text)' }}>{inviteResult.url}</span>
<CopyButton text={inviteResult.url} />
</div>
{inviteResult.recoveryPhrase && (
<div>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Recovery phrase - save this, it won't be shown again:
</p>
<div
className="p-3 flex items-center gap-2"
style={{
background: 'color-mix(in srgb, var(--admin-accent) 6%, var(--bg))',
border: '1px solid color-mix(in srgb, var(--admin-accent) 20%, transparent)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-sm)',
fontFamily: 'var(--font-mono, monospace)',
}}
>
<span className="flex-1" style={{ color: 'var(--text)' }}>{inviteResult.recoveryPhrase}</span>
<CopyButton text={inviteResult.recoveryPhrase} />
</div>
</div>
)}
<button onClick={closeInviteForm} className="btn btn-ghost self-start">Done</button>
</div>
)}
</div>
)}
{/* Team members */}
<h2
className="font-medium mb-3"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
>
Members
</h2>
{loading ? (
<div>
<div className="progress-bar mb-4" />
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton h-4" style={{ width: '40%' }} />
<div className="skeleton h-4 w-12" />
</div>
))}
</div>
) : members.length === 0 ? (
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No team members yet</p>
) : (
<div className="flex flex-col gap-1 mb-8">
{members.map((m) => (
<div
key={m.id}
className="flex items-center justify-between p-3 rounded-lg"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{m.displayName ?? 'Unnamed'}
</span>
<RoleBadge role={m.role} />
</div>
<div className="flex items-center gap-3" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{m.teamTitle && <span>{m.teamTitle}</span>}
{m.invitedBy && <span>Invited by {m.invitedBy}</span>}
<span>Joined {new Date(m.joinedAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center gap-1">
{admin.isSuperAdmin && m.role !== 'SUPER_ADMIN' && (
<>
<button
onClick={() => regenRecovery(m.id, m.displayName)}
className="inline-flex items-center gap-1 px-2 action-btn"
style={{ minHeight: 44, color: 'var(--admin-accent)', cursor: 'pointer', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}
>
<IconKey size={14} stroke={2} /> Recovery
</button>
<button
onClick={() => removeMember(m.id, m.displayName)}
className="inline-flex items-center gap-1 px-2 action-btn"
style={{ minHeight: 44, color: 'var(--error)', cursor: 'pointer', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}
>
<IconTrash size={14} stroke={2} /> Remove
</button>
</>
)}
</div>
</div>
))}
</div>
)}
{/* Pending invites */}
{admin.canInvite && invites.length > 0 && (
<>
<h2
className="font-medium mb-3"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
>
Pending invites
</h2>
<div className="flex flex-col gap-1">
{invites.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between p-3 rounded-lg"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<RoleBadge role={inv.role} />
{inv.label && (
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>{inv.label}</span>
)}
</div>
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{inv.createdBy && <span>Created by {inv.createdBy} - </span>}
Expires {new Date(inv.expiresAt).toLocaleDateString()}
</div>
</div>
<button
onClick={() => revokeInvite(inv.id)}
className="text-xs px-2 action-btn"
style={{ minHeight: 44, color: 'var(--error)', cursor: 'pointer', borderRadius: 'var(--radius-sm)' }}
>
Revoke
</button>
</div>
))}
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,658 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import {
IconTemplate, IconGripVertical, IconCheck, IconPlus, IconTrash,
IconPencil, IconX, IconStar, IconChevronDown, IconChevronUp,
} from '@tabler/icons-react'
import Dropdown from '../../components/Dropdown'
interface Board {
id: string
slug: string
name: string
}
interface TemplateField {
key: string
label: string
type: 'text' | 'textarea' | 'select'
required: boolean
placeholder?: string
options?: string[]
}
interface Template {
id: string
boardId: string
name: string
fields: TemplateField[]
isDefault: boolean
position: number
}
function DropLine() {
return (
<div
style={{
height: 2,
background: 'var(--accent)',
borderRadius: 1,
margin: '0 12px',
position: 'relative',
}}
>
<div style={{ position: 'absolute', left: -4, top: -3, width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
<div style={{ position: 'absolute', right: -4, top: -3, width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
</div>
)
}
function toFieldKey(label: string): string {
return label.trim().toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
}
const emptyField = (): TemplateField => ({
key: '',
label: '',
type: 'textarea',
required: false,
placeholder: '',
})
function FieldEditor({
field,
index,
total,
onChange,
onRemove,
onMoveUp,
onMoveDown,
}: {
field: TemplateField
index: number
total: number
onChange: (f: TemplateField) => void
onRemove: () => void
onMoveUp: () => void
onMoveDown: () => void
}) {
const updateLabel = (label: string) => {
const key = field.key && field.key !== toFieldKey(field.label) ? field.key : toFieldKey(label)
onChange({ ...field, label, key })
}
return (
<div
className="p-4 rounded-lg mb-2"
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
>
<div className="flex items-center justify-between mb-3">
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontFamily: 'var(--font-mono)' }}>
Field {index + 1}{field.key ? ` - ${field.key}` : ''}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={onMoveUp}
disabled={index === 0}
aria-label="Move field up"
className="flex items-center justify-center"
style={{
width: 44, height: 44,
borderRadius: 'var(--radius-sm)',
color: index === 0 ? 'var(--border)' : 'var(--text-tertiary)',
background: 'transparent', border: 'none', cursor: index === 0 ? 'not-allowed' : 'pointer',
}}
>
<IconChevronUp size={14} stroke={2} />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={index === total - 1}
aria-label="Move field down"
className="flex items-center justify-center"
style={{
width: 44, height: 44,
borderRadius: 'var(--radius-sm)',
color: index === total - 1 ? 'var(--border)' : 'var(--text-tertiary)',
background: 'transparent', border: 'none', cursor: index === total - 1 ? 'not-allowed' : 'pointer',
}}
>
<IconChevronDown size={14} stroke={2} />
</button>
<button
type="button"
onClick={onRemove}
aria-label="Remove field"
className="flex items-center justify-center"
style={{
width: 44, height: 44,
borderRadius: 'var(--radius-sm)',
color: 'var(--text-tertiary)',
background: 'transparent', border: 'none', cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--error)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
onFocus={(e) => { e.currentTarget.style.color = 'var(--error)' }}
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
>
<IconTrash size={14} stroke={2} />
</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block mb-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Label</label>
<input
className="input w-full"
value={field.label}
onChange={(e) => updateLabel(e.target.value)}
placeholder="Field label"
/>
</div>
<div>
<label className="block mb-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Type</label>
<select
className="input w-full"
value={field.type}
onChange={(e) => onChange({ ...field, type: e.target.value as TemplateField['type'] })}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
<option value="select">Dropdown</option>
</select>
</div>
<div>
<label className="block mb-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Placeholder</label>
<input
className="input w-full"
value={field.placeholder || ''}
onChange={(e) => onChange({ ...field, placeholder: e.target.value })}
placeholder="Optional hint text"
/>
</div>
<div className="flex items-end gap-3 pb-1">
<label className="flex items-center gap-2 cursor-pointer" style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
<input
type="checkbox"
checked={field.required}
onChange={(e) => onChange({ ...field, required: e.target.checked })}
/>
Required
</label>
</div>
</div>
{field.type === 'select' && (
<div className="mt-3">
<label className="block mb-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Options (comma-separated)
</label>
<input
className="input w-full"
value={(field.options || []).join(', ')}
onChange={(e) => onChange({
...field,
options: e.target.value.split(',').map((s) => s.trim()).filter(Boolean),
})}
placeholder="Option 1, Option 2, Option 3"
/>
</div>
)}
</div>
)
}
export default function AdminTemplates() {
useDocumentTitle('Templates')
const [boards, setBoards] = useState<Board[]>([])
const [selectedBoardId, setSelectedBoardId] = useState('')
const [templates, setTemplates] = useState<Template[]>([])
const [error, setError] = useState('')
// drag state
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [insertAt, setInsertAt] = useState<number | null>(null)
const rowRefs = useRef<(HTMLDivElement | null)[]>([])
// modal state
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<Template | null>(null)
const [modalName, setModalName] = useState('')
const [modalDefault, setModalDefault] = useState(false)
const [modalFields, setModalFields] = useState<TemplateField[]>([])
const [saving, setSaving] = useState(false)
const [modalError, setModalError] = useState('')
useEffect(() => {
api.get<Board[]>('/admin/boards').then((b) => {
setBoards(b)
if (b.length > 0) setSelectedBoardId(b[0].id)
}).catch(() => {})
}, [])
const fetchTemplates = useCallback(() => {
if (!selectedBoardId) return
setError('')
api.get<{ templates: Template[] }>(`/admin/boards/${selectedBoardId}/templates`)
.then((r) => setTemplates(r.templates))
.catch(() => setError('Failed to load templates'))
}, [selectedBoardId])
useEffect(() => { fetchTemplates() }, [fetchTemplates])
const openCreate = () => {
setEditing(null)
setModalName('')
setModalDefault(false)
setModalFields([emptyField()])
setModalError('')
setModalOpen(true)
}
const openEdit = (t: Template) => {
setEditing(t)
setModalName(t.name)
setModalDefault(t.isDefault)
setModalFields([...t.fields])
setModalError('')
setModalOpen(true)
}
const closeModal = () => {
setModalOpen(false)
setEditing(null)
setModalName('')
setModalFields([])
setModalError('')
}
const handleSaveTemplate = async () => {
if (!modalName.trim()) { setModalError('Name is required'); return }
const cleanFields = modalFields.filter((f) => f.label.trim())
if (cleanFields.length === 0) { setModalError('Add at least one field'); return }
for (const f of cleanFields) {
if (!f.key) f.key = toFieldKey(f.label)
if (!f.key) { setModalError(`Invalid label: "${f.label}"`); return }
if (f.type === 'select' && (!f.options || f.options.length === 0)) {
setModalError(`Field "${f.label}" needs options`); return
}
}
// check for duplicate keys
const keys = new Set<string>()
for (const f of cleanFields) {
if (keys.has(f.key)) { setModalError(`Duplicate field key: ${f.key}`); return }
keys.add(f.key)
}
setSaving(true)
setModalError('')
try {
if (editing) {
await api.put(`/admin/templates/${editing.id}`, {
name: modalName.trim(),
fields: cleanFields,
isDefault: modalDefault,
})
} else {
await api.post(`/admin/boards/${selectedBoardId}/templates`, {
name: modalName.trim(),
fields: cleanFields,
isDefault: modalDefault,
})
}
fetchTemplates()
closeModal()
} catch {
setModalError('Failed to save template')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
try {
await api.delete(`/admin/templates/${id}`)
setTemplates((prev) => prev.filter((t) => t.id !== id))
} catch {
setError('Failed to delete template')
}
}
// reorder via drag
const onDragStart = useCallback((e: React.DragEvent, idx: number) => {
setDragIdx(idx)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(idx))
if (e.currentTarget instanceof HTMLElement) e.currentTarget.style.opacity = '0.4'
}, [])
const onDragEnd = useCallback((e: React.DragEvent) => {
if (e.currentTarget instanceof HTMLElement) e.currentTarget.style.opacity = '1'
setDragIdx(null)
setInsertAt(null)
}, [])
const onDragOver = useCallback((e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const row = rowRefs.current[idx]
if (!row) return
const rect = row.getBoundingClientRect()
setInsertAt(e.clientY < rect.top + rect.height / 2 ? idx : idx + 1)
}, [])
const onDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault()
if (dragIdx === null || insertAt === null) return
const reordered = [...templates]
const [moved] = reordered.splice(dragIdx, 1)
reordered.splice(insertAt > dragIdx ? insertAt - 1 : insertAt, 0, moved)
setTemplates(reordered)
setDragIdx(null)
setInsertAt(null)
// persist positions
for (let i = 0; i < reordered.length; i++) {
if (reordered[i].position !== i) {
await api.put(`/admin/templates/${reordered[i].id}`, { position: i }).catch(() => {})
}
}
}, [dragIdx, insertAt, templates])
return (
<div className="mx-auto px-6 py-10" style={{ maxWidth: 'var(--content-max)' }}>
<div className="flex items-center gap-3 mb-8">
<div
className="flex items-center justify-center"
style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)' }}
>
<IconTemplate size={20} stroke={2} style={{ color: 'var(--accent)' }} />
</div>
<div>
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-2xl)', fontWeight: 700, color: 'var(--text)' }}>
Request Templates
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
Create custom form templates for each board
</p>
</div>
</div>
<div className="mb-6" style={{ maxWidth: 300 }}>
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Board</label>
<Dropdown
value={selectedBoardId}
onChange={setSelectedBoardId}
options={boards.map((b) => ({ value: b.id, label: b.name }))}
/>
</div>
<div className="card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
Templates
</h2>
<button
onClick={openCreate}
className="btn btn-primary flex items-center gap-2"
>
<IconPlus size={14} stroke={2} />
New template
</button>
</div>
{error && (
<div role="alert" className="mb-4 p-3 rounded" style={{ background: 'rgba(239,68,68,0.1)', color: 'var(--error)', fontSize: 'var(--text-sm)' }}>
{error}
</div>
)}
{templates.length === 0 && !error && (
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
No templates yet. Create one to customize the submission form.
</p>
)}
<div
className="flex flex-col gap-1"
onDragLeave={() => setInsertAt(null)}
>
{templates.map((t, i) => {
const showLineBefore = dragIdx !== null && insertAt === i && dragIdx !== i && dragIdx !== i - 1
const showLineAfter = dragIdx !== null && insertAt === templates.length && i === templates.length - 1 && dragIdx !== i
return (
<div key={t.id}>
{showLineBefore && <DropLine />}
<div
ref={(el) => { rowRefs.current[i] = el }}
draggable
onDragStart={(e) => onDragStart(e, i)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, i)}
onDrop={onDrop}
className="flex items-center gap-3 p-3 rounded-lg"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', cursor: 'default' }}
>
<div
style={{ cursor: 'grab', color: 'var(--text-tertiary)', display: 'flex', padding: '2px 0' }}
onMouseDown={(e) => { e.currentTarget.style.cursor = 'grabbing' }}
onMouseUp={(e) => { e.currentTarget.style.cursor = 'grab' }}
>
<IconGripVertical size={16} stroke={2} />
</div>
<span className="font-medium flex-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{t.name}
</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{t.fields.length} field{t.fields.length !== 1 ? 's' : ''}
</span>
{t.isDefault && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5"
style={{
background: 'var(--accent-subtle)',
color: 'var(--accent)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
fontWeight: 500,
}}
>
<IconStar size={10} stroke={2} /> Default
</span>
)}
<button
type="button"
onClick={() => openEdit(t)}
aria-label={`Edit ${t.name}`}
className="flex items-center justify-center"
style={{
width: 44, height: 44,
borderRadius: 'var(--radius-sm)',
color: 'var(--text-tertiary)',
background: 'transparent', border: 'none', cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--accent)'
e.currentTarget.style.background = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
onFocus={(e) => {
e.currentTarget.style.color = 'var(--accent)'
e.currentTarget.style.background = 'var(--surface-hover)'
}}
onBlur={(e) => {
e.currentTarget.style.color = 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
>
<IconPencil size={14} stroke={2} />
</button>
<button
type="button"
onClick={() => handleDelete(t.id)}
aria-label={`Delete ${t.name}`}
className="flex items-center justify-center"
style={{
width: 44, height: 44,
borderRadius: 'var(--radius-sm)',
color: 'var(--text-tertiary)',
background: 'transparent', border: 'none', cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--error)'
e.currentTarget.style.background = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
onFocus={(e) => {
e.currentTarget.style.color = 'var(--error)'
e.currentTarget.style.background = 'var(--surface-hover)'
}}
onBlur={(e) => {
e.currentTarget.style.color = 'var(--text-tertiary)'
e.currentTarget.style.background = 'transparent'
}}
>
<IconTrash size={14} stroke={2} />
</button>
</div>
{showLineAfter && <DropLine />}
</div>
)
})}
</div>
</div>
{/* Template editor modal */}
{modalOpen && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={closeModal}
>
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
<div
role="dialog"
aria-modal="true"
aria-labelledby="template-modal-title"
className="relative card p-6 w-full fade-in overflow-y-auto"
style={{ maxWidth: 640, maxHeight: '90vh' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 id="template-modal-title" className="font-bold" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
{editing ? 'Edit template' : 'New template'}
</h2>
<button
onClick={closeModal}
aria-label="Close"
className="flex items-center justify-center"
style={{ width: 44, height: 44, color: 'var(--text-tertiary)', background: 'none', border: 'none', cursor: 'pointer' }}
>
<IconX size={18} stroke={2} />
</button>
</div>
<div className="mb-4">
<label className="block mb-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Template name
</label>
<input
className="input w-full"
value={modalName}
onChange={(e) => setModalName(e.target.value)}
placeholder="e.g. Bug Report, Feature Request"
/>
</div>
<label className="flex items-center gap-2 mb-5 cursor-pointer" style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
<input
type="checkbox"
checked={modalDefault}
onChange={(e) => setModalDefault(e.target.checked)}
/>
Set as default template for this board
</label>
<div className="mb-3">
<h4 className="font-semibold mb-2" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
Fields
</h4>
{modalFields.map((f, i) => (
<FieldEditor
key={i}
field={f}
index={i}
total={modalFields.length}
onChange={(updated) => setModalFields((prev) => prev.map((ff, j) => j === i ? updated : ff))}
onRemove={() => setModalFields((prev) => prev.filter((_, j) => j !== i))}
onMoveUp={() => {
if (i === 0) return
setModalFields((prev) => {
const next = [...prev]
;[next[i - 1], next[i]] = [next[i], next[i - 1]]
return next
})
}}
onMoveDown={() => {
if (i === modalFields.length - 1) return
setModalFields((prev) => {
const next = [...prev]
;[next[i], next[i + 1]] = [next[i + 1], next[i]]
return next
})
}}
/>
))}
<button
type="button"
onClick={() => setModalFields((prev) => [...prev, emptyField()])}
className="btn btn-admin flex items-center gap-1 mt-2"
style={{ fontSize: 'var(--text-sm)' }}
>
<IconPlus size={14} stroke={2} />
Add field
</button>
</div>
{modalError && (
<div className="mb-4 p-3 rounded" style={{ background: 'rgba(239,68,68,0.1)', color: 'var(--error)', fontSize: 'var(--text-sm)' }}>
{modalError}
</div>
)}
<div className="flex gap-2 justify-end">
<button onClick={closeModal} className="btn btn-ghost">Cancel</button>
<button
onClick={handleSaveTemplate}
disabled={saving}
className="btn btn-primary"
>
{saving ? 'Saving...' : editing ? 'Save changes' : 'Create template'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,213 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { IconPlus, IconTrash } from '@tabler/icons-react'
import { api } from '../../lib/api'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useConfirm } from '../../hooks/useConfirm'
interface Webhook {
id: string
url: string
secret: string
events: string[]
active: boolean
createdAt: string
}
const ALL_EVENTS = [
{ value: 'status_changed', label: 'Status changed' },
{ value: 'post_created', label: 'Post created' },
{ value: 'comment_added', label: 'Comment added' },
]
export default function AdminWebhooks() {
useDocumentTitle('Webhooks')
const confirm = useConfirm()
const [webhooks, setWebhooks] = useState<Webhook[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [url, setUrl] = useState('')
const [events, setEvents] = useState<string[]>(['status_changed', 'post_created', 'comment_added'])
const [newSecret, setNewSecret] = useState<string | null>(null)
const [error, setError] = useState('')
const fetchWebhooks = () => {
api.get<{ webhooks: Webhook[] }>('/admin/webhooks')
.then((r) => setWebhooks(r.webhooks))
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(fetchWebhooks, [])
const create = async () => {
if (!url.trim() || events.length === 0) return
setError('')
try {
const res = await api.post<Webhook>('/admin/webhooks', { url: url.trim(), events })
setNewSecret(res.secret)
setUrl('')
setShowForm(false)
fetchWebhooks()
} catch {
setError('Failed to create webhook')
}
}
const toggle = async (id: string, active: boolean) => {
try {
await api.put(`/admin/webhooks/${id}`, { active: !active })
fetchWebhooks()
} catch {}
}
const remove = async (id: string) => {
if (!await confirm('Delete this webhook?')) return
try {
await api.delete(`/admin/webhooks/${id}`)
fetchWebhooks()
} catch {}
}
return (
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
<div className="flex items-center justify-between mb-6">
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Webhooks
</h1>
<div className="flex items-center gap-2">
<button
onClick={() => setShowForm(!showForm)}
className="btn btn-admin flex items-center gap-1"
aria-expanded={showForm}
style={{ fontSize: 'var(--text-sm)' }}
>
<IconPlus size={14} stroke={2} />
Add webhook
</button>
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back</Link>
</div>
</div>
{newSecret && (
<div className="card p-4 mb-4" style={{ border: '1px solid var(--success)', background: 'rgba(34, 197, 94, 0.08)' }}>
<p className="text-xs font-medium mb-1" style={{ color: 'var(--success)' }}>Webhook created - copy the signing secret now (it won't be shown again):</p>
<code
className="block p-2 rounded text-xs"
style={{ background: 'var(--bg)', color: 'var(--text)', wordBreak: 'break-all', fontFamily: 'var(--font-mono)' }}
>
{newSecret}
</code>
<button onClick={() => setNewSecret(null)} className="btn btn-ghost text-xs mt-2">Dismiss</button>
</div>
)}
{showForm && (
<div className="card p-4 mb-6">
<div className="mb-3">
<label htmlFor="webhook-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>URL</label>
<input
id="webhook-url"
className="input w-full"
placeholder="https://example.com/webhook"
value={url}
onChange={(e) => setUrl(e.target.value)}
type="url"
/>
</div>
<div className="mb-3">
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Events</label>
<div className="flex flex-wrap gap-2">
{ALL_EVENTS.map((ev) => {
const active = events.includes(ev.value)
return (
<button
key={ev.value}
type="button"
onClick={() => setEvents((evs) =>
active ? evs.filter((e) => e !== ev.value) : [...evs, ev.value]
)}
className="px-2 py-1 rounded text-xs"
style={{
background: active ? 'var(--admin-subtle)' : 'var(--surface-hover)',
color: active ? 'var(--admin-accent)' : 'var(--text-tertiary)',
border: active ? '1px solid rgba(6, 182, 212, 0.3)' : '1px solid transparent',
cursor: 'pointer',
}}
>
{ev.label}
</button>
)
})}
</div>
</div>
{error && <p role="alert" className="text-xs mb-2" style={{ color: 'var(--error)' }}>{error}</p>}
<div className="flex gap-2">
<button onClick={create} className="btn btn-admin">Create</button>
<button onClick={() => setShowForm(false)} className="btn btn-ghost">Cancel</button>
</div>
</div>
)}
{loading ? (
<div>
<div className="progress-bar mb-4" />
{[0, 1].map((i) => (
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.3 }}>
<div className="skeleton h-4" style={{ width: '50%' }} />
<div className="skeleton h-4 w-16" />
</div>
))}
</div>
) : webhooks.length === 0 ? (
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No webhooks configured</p>
) : (
<div className="flex flex-col gap-1">
{webhooks.map((wh) => (
<div
key={wh.id}
className="flex items-center justify-between p-3 rounded-lg"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', opacity: wh.active ? 1 : 0.5 }}
>
<div className="flex-1 min-w-0 mr-2">
<div className="font-medium truncate" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{wh.url}
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{wh.events.map((ev) => (
<span key={ev} className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}>
{ev.replace(/_/g, ' ')}
</span>
))}
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
Signed
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => toggle(wh.id, wh.active)}
className="text-xs px-2 rounded"
style={{ minHeight: 44, color: wh.active ? 'var(--warning)' : 'var(--success)', background: 'none', border: 'none', cursor: 'pointer' }}
>
{wh.active ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => remove(wh.id)}
className="text-xs px-2 rounded"
aria-label="Delete webhook"
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer' }}
>
<IconTrash size={14} stroke={2} />
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}