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

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

View File

@@ -1,152 +1,165 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import EmptyState from '../components/EmptyState'
import Dropdown from '../components/Dropdown'
import { IconPlus, IconTrendingUp, IconMessageCircle, IconShieldCheck, IconArrowUp, IconActivity } from '@tabler/icons-react'
import type { Icon } from '@tabler/icons-react'
interface Activity {
id: string
type: 'post_created' | 'status_changed' | 'comment_added' | 'admin_response' | 'vote'
postId: string
postTitle: string
boardSlug: string
boardName: string
actorName: string
detail?: string
type: 'post_created' | 'status_changed' | 'comment_created' | 'admin_responded' | 'vote'
board: { slug: string; name: string } | null
post: { id: string; title: string } | null
metadata: Record<string, string> | null
createdAt: string
}
const typeLabels: Record<string, string> = {
post_created: 'created a post',
status_changed: 'changed status',
comment_added: 'commented',
admin_response: 'responded',
comment_created: 'commented',
admin_responded: 'responded',
vote: 'voted on',
}
const typeIcons: Record<string, JSX.Element> = {
post_created: (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
),
status_changed: (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
),
comment_added: (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
),
admin_response: (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
vote: (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
),
const typeIcons: Record<string, Icon> = {
post_created: IconPlus,
status_changed: IconTrendingUp,
comment_created: IconMessageCircle,
admin_responded: IconShieldCheck,
vote: IconArrowUp,
}
function ActivitySkeleton() {
return (
<div className="flex flex-col gap-1">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-start gap-3 p-3" style={{ opacity: 1 - i * 0.15 }}>
<div className="skeleton w-7 h-7 rounded-full shrink-0" />
<div className="flex-1">
<div className="skeleton h-4 mb-2" style={{ width: '70%' }} />
<div className="skeleton h-3" style={{ width: '40%' }} />
</div>
</div>
))}
</div>
)
}
export default function ActivityFeed() {
useDocumentTitle('Activity')
const [activities, setActivities] = useState<Activity[]>([])
const [loading, setLoading] = useState(true)
const [boardFilter, setBoardFilter] = useState('')
const [typeFilter, setTypeFilter] = useState('')
useEffect(() => {
setLoading(true)
const params = new URLSearchParams()
if (boardFilter) params.set('board', boardFilter)
if (typeFilter) params.set('type', typeFilter)
api.get<Activity[]>(`/activity?${params}`)
.then(setActivities)
api.get<{ events: Activity[] }>(`/activity?${params}`)
.then((r) => setActivities(r.events))
.catch(() => {})
.finally(() => setLoading(false))
}, [boardFilter, typeFilter])
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<h1
className="text-2xl font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
className="font-bold mb-6"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
>
Activity
</h1>
{/* Filters */}
<div className="flex gap-3 mb-6">
<select
className="input"
style={{ maxWidth: 200 }}
value={boardFilter}
onChange={(e) => setBoardFilter(e.target.value)}
>
<option value="">All boards</option>
</select>
<select
className="input"
style={{ maxWidth: 200 }}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="">All types</option>
<option value="post_created">Posts</option>
<option value="comment_added">Comments</option>
<option value="status_changed">Status changes</option>
<option value="admin_response">Admin responses</option>
<option value="vote">Votes</option>
</select>
<div style={{ maxWidth: 200, flex: 1 }}>
<Dropdown
value={boardFilter}
onChange={setBoardFilter}
placeholder="All boards"
options={[{ value: '', label: 'All boards' }]}
/>
</div>
<div style={{ maxWidth: 200, flex: 1 }}>
<Dropdown
value={typeFilter}
onChange={setTypeFilter}
placeholder="All types"
options={[
{ value: '', label: 'All types' },
{ value: 'post_created', label: 'Posts' },
{ value: 'comment_created', label: 'Comments' },
{ value: 'status_changed', label: 'Status changes' },
{ value: 'admin_responded', label: 'Admin responses' },
{ value: 'vote', label: 'Votes' },
]}
/>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
<>
<div className="progress-bar mb-4" />
<ActivitySkeleton />
</>
) : activities.length === 0 ? (
<div className="text-center py-12">
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No activity yet</p>
</div>
<EmptyState
icon={IconActivity}
title="No activity yet"
message="Activity from boards you follow will show up here"
/>
) : (
<div className="flex flex-col gap-1">
{activities.map((a) => (
<Link
key={a.id}
to={`/b/${a.boardSlug}/post/${a.postId}`}
className="flex items-start gap-3 p-3 rounded-lg"
style={{ transition: 'background 200ms ease-out' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5"
{activities.filter((a) => a.post && a.board).map((a, i) => {
const Icon = typeIcons[a.type] || IconPlus
const postTitle = a.post!.title || a.metadata?.title || 'Untitled'
return (
<Link
key={a.id}
to={`/b/${a.board!.slug}/post/${a.post!.id}`}
className="flex items-start gap-3 stagger-in"
style={{
background: a.type === 'admin_response' ? 'var(--admin-subtle)' : 'var(--accent-subtle)',
color: a.type === 'admin_response' ? 'var(--admin-accent)' : 'var(--accent)',
}}
'--stagger': i,
padding: '12px',
borderRadius: 'var(--radius-md)',
transition: 'background var(--duration-fast) ease-out',
} as React.CSSProperties}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
onFocus={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
onBlur={(e) => (e.currentTarget.style.background = 'transparent')}
>
{typeIcons[a.type]}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text)' }}>{a.actorName}</span>
{' '}{typeLabels[a.type] || a.type}{' '}
<span style={{ color: 'var(--text)' }}>{a.postTitle}</span>
</p>
{a.detail && (
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-tertiary)' }}>{a.detail}</p>
)}
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
{a.boardName} - {new Date(a.createdAt).toLocaleDateString()}
</p>
</div>
</Link>
))}
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5"
style={{
background: a.type === 'admin_responded' ? 'var(--admin-subtle)' : 'var(--accent-subtle)',
color: a.type === 'admin_responded' ? 'var(--admin-accent)' : 'var(--accent)',
}}
>
<Icon size={14} stroke={2} />
</div>
<div className="flex-1 min-w-0">
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
{typeLabels[a.type] || a.type}{' '}
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{postTitle}</span>
</p>
{a.metadata?.status && (
<p className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
{a.metadata.status.replace(/_/g, ' ').toLowerCase()}
</p>
)}
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 4 }}>
{a.board!.name} - <time dateTime={a.createdAt}>{new Date(a.createdAt).toLocaleDateString()}</time>
</p>
</div>
</Link>
)
})}
</div>
)}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react'
import { api } from '../lib/api'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import { Link } from 'react-router-dom'
interface DataField {
field: string
@@ -9,13 +11,75 @@ interface DataField {
}
interface Manifest {
fields: DataField[]
anonymousUser: DataField[]
passkeyUser: DataField[]
cookieInfo: string
dataLocation: string
thirdParties: string[]
neverStored: string[]
securityHeaders: Record<string, string>
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="card card-static p-5 mb-5">
<h2
className="font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-base)' }}
>
{title}
</h2>
{children}
</div>
)
}
function FieldTable({ fields }: { fields: DataField[] }) {
return (
<div className="flex flex-col gap-3">
{fields.map((f) => (
<div key={f.field} className="p-3" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}>
<div className="flex items-center justify-between mb-1">
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>{f.field}</span>
{f.deletable && (
<span
className="px-1.5 py-0.5"
style={{
background: 'rgba(34, 197, 94, 0.12)',
color: 'var(--success)',
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
}}
>
deletable
</span>
)}
</div>
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>{f.purpose}</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>Retained: {f.retention}</p>
</div>
))}
</div>
)
}
function PrivacySkeleton() {
return (
<div className="flex flex-col gap-5">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="card p-5" style={{ opacity: 1 - i * 0.15 }}>
<div className="skeleton h-5 mb-3" style={{ width: '40%' }} />
<div className="skeleton h-4 mb-2" style={{ width: '90%' }} />
<div className="skeleton h-4 mb-2" style={{ width: '80%' }} />
<div className="skeleton h-4" style={{ width: '60%' }} />
</div>
))}
</div>
)
}
export default function PrivacyPage() {
useDocumentTitle('Privacy')
const [manifest, setManifest] = useState<Manifest | null>(null)
const [loading, setLoading] = useState(true)
@@ -26,137 +90,153 @@ export default function PrivacyPage() {
.finally(() => setLoading(false))
}, [])
const body = (color: string) => ({ color, lineHeight: '1.7', fontSize: 'var(--text-sm)' as const })
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div style={{ maxWidth: 680, margin: '0 auto', padding: '32px 24px' }}>
<h1
className="text-2xl font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
>
Privacy
</h1>
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
Here is exactly what data this Echoboard instance collects and why.
<p className="mb-8" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
This page is generated from the application's actual configuration. It cannot drift out of sync with reality.
</p>
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-6 h-6 border-2 rounded-full"
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
/>
</div>
) : manifest ? (
<>
{/* Quick summary */}
<div className="card p-5 mb-6">
<h2
className="text-base font-semibold mb-3"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
The short version
</h2>
<ul className="flex flex-col gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<li className="flex items-start gap-2">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
No tracking scripts, no analytics, no third-party cookies
</li>
<li className="flex items-start gap-2">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
All data stays on this server - {manifest.dataLocation}
</li>
<li className="flex items-start gap-2">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
No external fonts or resources loaded
</li>
<li className="flex items-start gap-2">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
You can delete everything at any time
</li>
</ul>
</div>
{/* Cookie info */}
<div className="card p-5 mb-6">
<h2
className="text-base font-semibold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Cookies
</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{manifest.cookieInfo}
<div className="progress-bar mb-4" />
<PrivacySkeleton />
</>
) : (
<>
<Section title="How the cookie works">
<p style={body('var(--text-secondary)')}>
{manifest?.cookieInfo || 'This site uses exactly one cookie. It contains a random identifier used to connect you to your posts. It is a session cookie - it expires when you close your browser. It is not used for tracking, analytics, or advertising. No other cookies are set, ever.'}
</p>
</div>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
The cookie is <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>httpOnly</code> (JavaScript cannot read it), <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>Secure</code> (only sent over HTTPS), and <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>SameSite=Strict</code> (never sent on cross-origin requests). Dark mode preference is stored in localStorage, not as a cookie.
</p>
</Section>
{/* Data fields */}
<div className="card p-5 mb-6">
<h2
className="text-base font-semibold mb-4"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
What we store
</h2>
<div className="flex flex-col gap-3">
{manifest.fields.map((f) => (
<div
key={f.field}
className="p-3 rounded-lg"
style={{ background: 'var(--bg)' }}
<Section title="How passkeys work">
<p style={body('var(--text-secondary)')}>
Passkeys use the WebAuthn standard. When you register, your device generates a public/private key pair. The private key stays on your device (or syncs via iCloud Keychain, Google Password Manager, Windows Hello). The server only stores the public key.
</p>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
To log in, your device signs a challenge with the private key. The server verifies it against the stored public key. No email, no password, no personal data. Passkeys are phishing-resistant because they are bound to this domain and cannot be used on any other site.
</p>
</Section>
<Section title="What we store - anonymous cookie users">
{manifest?.anonymousUser ? (
<FieldTable fields={manifest.anonymousUser} />
) : (
<ul className="flex flex-col gap-1" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
<li>- Token hash (SHA-256, irreversible)</li>
<li>- Display name (encrypted at rest)</li>
<li>- Dark mode preference</li>
<li>- Creation timestamp</li>
</ul>
)}
</Section>
<Section title="What we store - passkey users">
{manifest?.passkeyUser ? (
<FieldTable fields={manifest.passkeyUser} />
) : (
<ul className="flex flex-col gap-1" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
<li>- Username (encrypted + blind indexed)</li>
<li>- Display name (encrypted)</li>
<li>- Passkey credential ID (encrypted + blind indexed)</li>
<li>- Passkey public key (encrypted)</li>
<li>- Passkey counter, device type, backup flag</li>
<li>- Passkey transports (encrypted)</li>
<li>- Dark mode preference</li>
<li>- Creation timestamp</li>
</ul>
)}
</Section>
<Section title="What we never store">
<div className="flex flex-wrap gap-2">
{(manifest?.neverStored || [
'Email address', 'IP address', 'Browser fingerprint', 'User-agent string',
'Referrer URL', 'Geolocation', 'Device identifiers', 'Behavioral data',
'Session replays', 'Third-party tracking identifiers',
]).map((item) => (
<span
key={item}
className="px-2 py-1 rounded-full"
style={{ background: 'rgba(239, 68, 68, 0.1)', color: 'var(--error)', fontSize: 'var(--text-xs)' }}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
{f.field}
</span>
{f.deletable && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'rgba(34, 197, 94, 0.15)', color: 'var(--success)' }}>
deletable
</span>
)}
</div>
<p className="text-xs mb-1" style={{ color: 'var(--text-secondary)' }}>
{f.purpose}
</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
Retained: {f.retention}
</p>
{item}
</span>
))}
</div>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
IP addresses are used for rate limiting in memory only - they never touch the database. Request logs record method, path, and status code, but the IP is stripped before the log line is written.
</p>
</Section>
<Section title="How encryption works">
<p style={body('var(--text-secondary)')}>
Every field that could identify a user is encrypted at rest using AES-256-GCM. This includes display names, usernames, passkey credentials, and push subscription endpoints. The anonymous token is stored as a one-way SHA-256 hash (irreversible, even stronger than encryption).
</p>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
Posts, comments, vote counts, and board metadata are not encrypted because they are public content visible to every visitor. Encrypting them would add latency without a security benefit.
</p>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
If someone gains access to the raw database, they see ciphertext blobs for identity fields and SHA-256 hashes for tokens. They cannot determine who wrote what, reconstruct display names, or extract passkey credentials.
</p>
</Section>
<Section title="Your data, your control">
<p style={body('var(--text-secondary)')}>
<strong style={{ color: 'var(--text)' }}>Export:</strong> Download everything this system holds about you as a JSON file from <Link to="/settings" style={{ color: 'var(--accent)' }}>your settings page</Link>. The file includes your posts, comments, votes, reactions, display name, and the data manifest.
</p>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
<strong style={{ color: 'var(--text)' }}>Delete:</strong> One click deletes your identity. Your votes and reactions are removed, comments are anonymized to "[deleted]", posts become "[deleted by author]", and your user record is purged from the database. Not soft-deleted - actually removed.
</p>
</Section>
<Section title="Security headers in effect">
<div className="flex flex-col gap-2">
{Object.entries(manifest?.securityHeaders || {
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-src 'none'; object-src 'none'",
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
'Cross-Origin-Opener-Policy': 'same-origin',
}).map(([header, value]) => (
<div key={header} className="p-2" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}>
<span className="font-medium block" style={{ color: 'var(--text)', fontSize: 'var(--text-xs)' }}>{header}</span>
<span className="break-all" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>{value}</span>
</div>
))}
</div>
</div>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
No external domains are whitelisted. The client never contacts a third party.
</p>
</Section>
{/* Third parties */}
{manifest.thirdParties.length > 0 && (
<div className="card p-5 mb-6">
<h2
className="text-base font-semibold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
Third parties
</h2>
<Section title="Third-party services">
{manifest?.thirdParties && manifest.thirdParties.length > 0 ? (
<ul className="flex flex-col gap-1">
{manifest.thirdParties.map((tp) => (
<li key={tp} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
- {tp}
</li>
<li key={tp} style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>- {tp}</li>
))}
</ul>
</div>
)}
) : (
<p style={body('var(--text-secondary)')}>
None. No external HTTP requests from the client, ever. No Google Fonts, no CDN scripts, no analytics, no tracking pixels. The only outbound requests the server makes are to external APIs configured by installed plugins (always your own infrastructure) and to push notification endpoints (browser-generated URLs).
</p>
)}
</Section>
</>
) : (
<div className="card p-5">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
This Echoboard instance is self-hosted and privacy-focused. No tracking, no external services, no data sharing. Your identity is cookie-based unless you register a passkey. You can delete all your data at any time from the settings page.
</p>
</div>
)}
</div>
)

View File

@@ -0,0 +1,259 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../lib/api'
import { useAuth } from '../hooks/useAuth'
import { useDocumentTitle } from '../hooks/useDocumentTitle'
import StatusBadge from '../components/StatusBadge'
import EmptyState from '../components/EmptyState'
import { IconUser, IconMessageCircle, IconArrowUp, IconFileText, IconHeart } from '@tabler/icons-react'
import Avatar from '../components/Avatar'
interface ProfileData {
id: string
displayName: string | null
username: string | null
isPasskeyUser: boolean
avatarUrl: string | null
createdAt: string
stats: {
posts: number
comments: number
votesGiven: number
votesReceived: number
}
votedPosts: {
id: string
title: string
status: string
voteCount: number
commentCount: number
board: { slug: string; name: string } | null
votedAt: string
}[]
}
interface Post {
id: string
title: string
type: string
status: string
voteCount: number
commentCount: number
board?: { slug: string; name: string } | null
createdAt: string
}
export default function ProfilePage() {
useDocumentTitle('Profile')
const auth = useAuth()
const [profile, setProfile] = useState<ProfileData | null>(null)
const [posts, setPosts] = useState<Post[]>([])
const [tab, setTab] = useState<'posts' | 'votes'>('posts')
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
api.get<ProfileData>('/me/profile'),
api.get<{ posts: Post[] }>('/me/posts'),
]).then(([p, myPosts]) => {
setProfile(p)
setPosts(myPosts.posts)
}).catch(() => {}).finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
<div className="progress-bar mb-4" />
</div>
)
}
if (!profile) return null
const name = profile.displayName
|| (profile.username ? `@${profile.username}` : `Anonymous #${profile.id.slice(-4)}`)
const stats = [
{ icon: IconFileText, label: 'Posts', value: profile.stats.posts },
{ icon: IconMessageCircle, label: 'Comments', value: profile.stats.comments },
{ icon: IconArrowUp, label: 'Votes given', value: profile.stats.votesGiven },
{ icon: IconHeart, label: 'Votes received', value: profile.stats.votesReceived },
]
return (
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
{/* Header */}
<div className="card p-6 mb-6">
<div className="flex items-center gap-4 mb-5">
<Avatar
userId={profile.id}
name={profile.displayName}
avatarUrl={profile.avatarUrl}
size={56}
/>
<div>
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-2xl)' }}
>
{name}
</h1>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
Joined <time dateTime={profile.createdAt}>{new Date(profile.createdAt).toLocaleDateString()}</time>
{profile.isPasskeyUser && ' - Passkey user'}
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3" style={{ maxWidth: 400 }}>
{stats.map((s) => (
<div
key={s.label}
className="flex items-center gap-3 p-3"
style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}
>
<s.icon size={16} stroke={2} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<div>
<div style={{ color: 'var(--text)', fontSize: 'var(--text-lg)', fontWeight: 700, lineHeight: 1 }}>
{s.value}
</div>
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{s.label}
</div>
</div>
</div>
))}
</div>
</div>
{/* Tabs */}
<div role="tablist" className="flex gap-1 mb-4" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
{(['posts', 'votes'] as const).map((t) => (
<button
key={t}
id={`tab-${t}`}
role="tab"
aria-selected={tab === t}
aria-controls={`tabpanel-${t}`}
onClick={() => setTab(t)}
className="px-4 py-2"
style={{
color: tab === t ? 'var(--accent)' : 'var(--text-tertiary)',
fontWeight: tab === t ? 600 : 400,
fontSize: 'var(--text-sm)',
borderBottom: tab === t ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom: -1,
transition: 'all 0.15s ease',
minHeight: 44,
}}
>
{t === 'posts' ? 'My Posts' : 'Voted On'}
</button>
))}
</div>
{/* Posts tab */}
{tab === 'posts' && (
<div role="tabpanel" id="tabpanel-posts" aria-labelledby="tab-posts">
{posts.length === 0 ? (
<EmptyState
icon={IconFileText}
title="No posts yet"
message="Your submitted posts will appear here"
actionLabel="Browse boards"
onAction={() => { window.location.href = '/' }}
/>
) : (
<div className="flex flex-col gap-2">
{posts.map((post, i) => (
<Link
key={post.id}
to={`/b/${post.board?.slug}/post/${post.id}`}
className="card card-interactive p-4 flex items-center gap-4 stagger-in"
style={{ '--stagger': i } as React.CSSProperties}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{post.board?.name}
</span>
<span
className="px-1.5 py-0.5"
style={{
fontSize: 'var(--text-xs)',
borderRadius: 'var(--radius-sm)',
background: post.type === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
color: post.type === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
}}
>
{post.type === 'BUG_REPORT' ? 'Bug' : 'Feature'}
</span>
</div>
<h2
className="font-medium truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
{post.title}
</h2>
<div className="flex items-center gap-3 mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
<span>{post.voteCount} votes</span>
<span>{post.commentCount} comments</span>
<time dateTime={post.createdAt}>{new Date(post.createdAt).toLocaleDateString()}</time>
</div>
</div>
<StatusBadge status={post.status} />
</Link>
))}
</div>
)}
</div>
)}
{/* Votes tab */}
{tab === 'votes' && (
<div role="tabpanel" id="tabpanel-votes" aria-labelledby="tab-votes">
{profile.votedPosts.length === 0 ? (
<EmptyState
icon={IconArrowUp}
title="No votes yet"
message="Posts you vote on will appear here"
/>
) : (
<div className="flex flex-col gap-2">
{profile.votedPosts.map((post, i) => (
<Link
key={post.id}
to={`/b/${post.board?.slug}/post/${post.id}`}
className="card card-interactive p-4 flex items-center gap-4 stagger-in"
style={{ '--stagger': i } as React.CSSProperties}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{post.board?.name}
</span>
</div>
<h2
className="font-medium truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
>
{post.title}
</h2>
<div className="flex items-center gap-3 mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
<span>{post.voteCount} votes</span>
<span>{post.commentCount} comments</span>
<time dateTime={post.votedAt}>Voted {new Date(post.votedAt).toLocaleDateString()}</time>
</div>
</div>
<StatusBadge status={post.status} />
</Link>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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