security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
144
packages/web/src/components/AdminSidebar.tsx
Normal file
144
packages/web/src/components/AdminSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
packages/web/src/components/Avatar.tsx
Normal file
72
packages/web/src/components/Avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
packages/web/src/components/BoardIcon.tsx
Normal file
50
packages/web/src/components/BoardIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)' }}>↑↓</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>
|
||||
|
||||
253
packages/web/src/components/Dropdown.tsx
Normal file
253
packages/web/src/components/Dropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
157
packages/web/src/components/EditHistoryModal.tsx
Normal file
157
packages/web/src/components/EditHistoryModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
196
packages/web/src/components/FileUpload.tsx
Normal file
196
packages/web/src/components/FileUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
283
packages/web/src/components/IconPicker.tsx
Normal file
283
packages/web/src/components/IconPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
36
packages/web/src/components/Markdown.tsx
Normal file
36
packages/web/src/components/Markdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
499
packages/web/src/components/MarkdownEditor.tsx
Normal file
499
packages/web/src/components/MarkdownEditor.tsx
Normal 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: '' } },
|
||||
{ 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>
|
||||
)
|
||||
}
|
||||
164
packages/web/src/components/MentionInput.tsx
Normal file
164
packages/web/src/components/MentionInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
138
packages/web/src/components/NumberInput.tsx
Normal file
138
packages/web/src/components/NumberInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
51
packages/web/src/components/PluginSlot.tsx
Normal file
51
packages/web/src/components/PluginSlot.tsx
Normal 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,
|
||||
}) }} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
191
packages/web/src/components/RecoveryCodeModal.tsx
Normal file
191
packages/web/src/components/RecoveryCodeModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
73
packages/web/src/hooks/useAdmin.ts
Normal file
73
packages/web/src/hooks/useAdmin.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}, [])
|
||||
|
||||
|
||||
97
packages/web/src/hooks/useBranding.tsx
Normal file
97
packages/web/src/hooks/useBranding.tsx
Normal 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)
|
||||
}
|
||||
99
packages/web/src/hooks/useConfirm.tsx
Normal file
99
packages/web/src/hooks/useConfirm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
packages/web/src/hooks/useDocumentTitle.ts
Normal file
11
packages/web/src/hooks/useDocumentTitle.ts
Normal 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])
|
||||
}
|
||||
48
packages/web/src/hooks/useFocusTrap.ts
Normal file
48
packages/web/src/hooks/useFocusTrap.ts
Normal 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
|
||||
}
|
||||
@@ -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
129
packages/web/src/i18n/en.ts
Normal 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
|
||||
34
packages/web/src/i18n/index.ts
Normal file
34
packages/web/src/i18n/index.ts
Normal 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)
|
||||
}
|
||||
5
packages/web/src/i18n/locales.ts
Normal file
5
packages/web/src/i18n/locales.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const locales = [
|
||||
{ code: 'en', name: 'English' },
|
||||
] as const
|
||||
|
||||
export type LocaleCode = (typeof locales)[number]['code']
|
||||
1
packages/web/src/i18n/types.ts
Normal file
1
packages/web/src/i18n/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Translations = Record<string, string>
|
||||
34
packages/web/src/lib/altcha.ts
Normal file
34
packages/web/src/lib/altcha.ts
Normal 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')
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
99
packages/web/src/pages/ChangelogPage.tsx
Normal file
99
packages/web/src/pages/ChangelogPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
194
packages/web/src/pages/EmbedBoard.tsx
Normal file
194
packages/web/src/pages/EmbedBoard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
259
packages/web/src/pages/ProfilePage.tsx
Normal file
259
packages/web/src/pages/ProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
packages/web/src/pages/RecoverPage.tsx
Normal file
124
packages/web/src/pages/RecoverPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
180
packages/web/src/pages/RoadmapPage.tsx
Normal file
180
packages/web/src/pages/RoadmapPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
118
packages/web/src/pages/admin/AdminCategories.tsx
Normal file
118
packages/web/src/pages/admin/AdminCategories.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
237
packages/web/src/pages/admin/AdminChangelog.tsx
Normal file
237
packages/web/src/pages/admin/AdminChangelog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
111
packages/web/src/pages/admin/AdminDataRetention.tsx
Normal file
111
packages/web/src/pages/admin/AdminDataRetention.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
263
packages/web/src/pages/admin/AdminEmbed.tsx
Normal file
263
packages/web/src/pages/admin/AdminEmbed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
packages/web/src/pages/admin/AdminExport.tsx
Normal file
114
packages/web/src/pages/admin/AdminExport.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
233
packages/web/src/pages/admin/AdminJoin.tsx
Normal file
233
packages/web/src/pages/admin/AdminJoin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
200
packages/web/src/pages/admin/AdminSettings.tsx
Normal file
200
packages/web/src/pages/admin/AdminSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
495
packages/web/src/pages/admin/AdminStatuses.tsx
Normal file
495
packages/web/src/pages/admin/AdminStatuses.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
packages/web/src/pages/admin/AdminTags.tsx
Normal file
205
packages/web/src/pages/admin/AdminTags.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
645
packages/web/src/pages/admin/AdminTeam.tsx
Normal file
645
packages/web/src/pages/admin/AdminTeam.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
658
packages/web/src/pages/admin/AdminTemplates.tsx
Normal file
658
packages/web/src/pages/admin/AdminTemplates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
213
packages/web/src/pages/admin/AdminWebhooks.tsx
Normal file
213
packages/web/src/pages/admin/AdminWebhooks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user