security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -1,152 +1,165 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import Dropdown from '../components/Dropdown'
|
||||
import { IconPlus, IconTrendingUp, IconMessageCircle, IconShieldCheck, IconArrowUp, IconActivity } from '@tabler/icons-react'
|
||||
import type { Icon } from '@tabler/icons-react'
|
||||
|
||||
interface Activity {
|
||||
id: string
|
||||
type: 'post_created' | 'status_changed' | 'comment_added' | 'admin_response' | 'vote'
|
||||
postId: string
|
||||
postTitle: string
|
||||
boardSlug: string
|
||||
boardName: string
|
||||
actorName: string
|
||||
detail?: string
|
||||
type: 'post_created' | 'status_changed' | 'comment_created' | 'admin_responded' | 'vote'
|
||||
board: { slug: string; name: string } | null
|
||||
post: { id: string; title: string } | null
|
||||
metadata: Record<string, string> | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
post_created: 'created a post',
|
||||
status_changed: 'changed status',
|
||||
comment_added: 'commented',
|
||||
admin_response: 'responded',
|
||||
comment_created: 'commented',
|
||||
admin_responded: 'responded',
|
||||
vote: 'voted on',
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, JSX.Element> = {
|
||||
post_created: (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
status_changed: (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
),
|
||||
comment_added: (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
),
|
||||
admin_response: (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
vote: (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
),
|
||||
const typeIcons: Record<string, Icon> = {
|
||||
post_created: IconPlus,
|
||||
status_changed: IconTrendingUp,
|
||||
comment_created: IconMessageCircle,
|
||||
admin_responded: IconShieldCheck,
|
||||
vote: IconArrowUp,
|
||||
}
|
||||
|
||||
function ActivitySkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3" style={{ opacity: 1 - i * 0.15 }}>
|
||||
<div className="skeleton w-7 h-7 rounded-full shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="skeleton h-4 mb-2" style={{ width: '70%' }} />
|
||||
<div className="skeleton h-3" style={{ width: '40%' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ActivityFeed() {
|
||||
useDocumentTitle('Activity')
|
||||
const [activities, setActivities] = useState<Activity[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [boardFilter, setBoardFilter] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
if (boardFilter) params.set('board', boardFilter)
|
||||
if (typeFilter) params.set('type', typeFilter)
|
||||
|
||||
api.get<Activity[]>(`/activity?${params}`)
|
||||
.then(setActivities)
|
||||
api.get<{ events: Activity[] }>(`/activity?${params}`)
|
||||
.then((r) => setActivities(r.events))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [boardFilter, typeFilter])
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1
|
||||
className="text-2xl font-bold mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
className="font-bold mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Activity
|
||||
</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<select
|
||||
className="input"
|
||||
style={{ maxWidth: 200 }}
|
||||
value={boardFilter}
|
||||
onChange={(e) => setBoardFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All boards</option>
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
style={{ maxWidth: 200 }}
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All types</option>
|
||||
<option value="post_created">Posts</option>
|
||||
<option value="comment_added">Comments</option>
|
||||
<option value="status_changed">Status changes</option>
|
||||
<option value="admin_response">Admin responses</option>
|
||||
<option value="vote">Votes</option>
|
||||
</select>
|
||||
<div style={{ maxWidth: 200, flex: 1 }}>
|
||||
<Dropdown
|
||||
value={boardFilter}
|
||||
onChange={setBoardFilter}
|
||||
placeholder="All boards"
|
||||
options={[{ value: '', label: 'All boards' }]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ maxWidth: 200, flex: 1 }}>
|
||||
<Dropdown
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
placeholder="All types"
|
||||
options={[
|
||||
{ value: '', label: 'All types' },
|
||||
{ value: 'post_created', label: 'Posts' },
|
||||
{ value: 'comment_created', label: 'Comments' },
|
||||
{ value: 'status_changed', label: 'Status changes' },
|
||||
{ value: 'admin_responded', label: 'Admin responses' },
|
||||
{ value: 'vote', label: 'Votes' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="progress-bar mb-4" />
|
||||
<ActivitySkeleton />
|
||||
</>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No activity yet</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={IconActivity}
|
||||
title="No activity yet"
|
||||
message="Activity from boards you follow will show up here"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{activities.map((a) => (
|
||||
<Link
|
||||
key={a.id}
|
||||
to={`/b/${a.boardSlug}/post/${a.postId}`}
|
||||
className="flex items-start gap-3 p-3 rounded-lg"
|
||||
style={{ transition: 'background 200ms ease-out' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5"
|
||||
{activities.filter((a) => a.post && a.board).map((a, i) => {
|
||||
const Icon = typeIcons[a.type] || IconPlus
|
||||
const postTitle = a.post!.title || a.metadata?.title || 'Untitled'
|
||||
return (
|
||||
<Link
|
||||
key={a.id}
|
||||
to={`/b/${a.board!.slug}/post/${a.post!.id}`}
|
||||
className="flex items-start gap-3 stagger-in"
|
||||
style={{
|
||||
background: a.type === 'admin_response' ? 'var(--admin-subtle)' : 'var(--accent-subtle)',
|
||||
color: a.type === 'admin_response' ? 'var(--admin-accent)' : 'var(--accent)',
|
||||
}}
|
||||
'--stagger': i,
|
||||
padding: '12px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
transition: 'background var(--duration-fast) ease-out',
|
||||
} as React.CSSProperties}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
onFocus={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
|
||||
onBlur={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
{typeIcons[a.type]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text)' }}>{a.actorName}</span>
|
||||
{' '}{typeLabels[a.type] || a.type}{' '}
|
||||
<span style={{ color: 'var(--text)' }}>{a.postTitle}</span>
|
||||
</p>
|
||||
{a.detail && (
|
||||
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-tertiary)' }}>{a.detail}</p>
|
||||
)}
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{a.boardName} - {new Date(a.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5"
|
||||
style={{
|
||||
background: a.type === 'admin_responded' ? 'var(--admin-subtle)' : 'var(--accent-subtle)',
|
||||
color: a.type === 'admin_responded' ? 'var(--admin-accent)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
<Icon size={14} stroke={2} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
{typeLabels[a.type] || a.type}{' '}
|
||||
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{postTitle}</span>
|
||||
</p>
|
||||
{a.metadata?.status && (
|
||||
<p className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
|
||||
{a.metadata.status.replace(/_/g, ' ').toLowerCase()}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 4 }}>
|
||||
{a.board!.name} - <time dateTime={a.createdAt}>{new Date(a.createdAt).toLocaleDateString()}</time>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,46 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import PostCard from '../components/PostCard'
|
||||
import PostForm from '../components/PostForm'
|
||||
import VoteBudget from '../components/VoteBudget'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import PluginSlot from '../components/PluginSlot'
|
||||
import { IconExternalLink, IconRss, IconX, IconFilter, IconChevronDown, IconPlus, IconBell, IconBellOff } from '@tabler/icons-react'
|
||||
import BoardIcon from '../components/BoardIcon'
|
||||
import Dropdown from '../components/Dropdown'
|
||||
import { solveAltcha } from '../lib/altcha'
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
title: string
|
||||
excerpt?: string
|
||||
type: 'feature' | 'bug' | 'general'
|
||||
description?: Record<string, string>
|
||||
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
|
||||
status: string
|
||||
category?: string | null
|
||||
voteCount: number
|
||||
commentCount: number
|
||||
authorName: string
|
||||
viewCount?: number
|
||||
isPinned?: boolean
|
||||
isStale?: boolean
|
||||
author?: { id: string; displayName: string; avatarUrl?: string | null } | null
|
||||
createdAt: string
|
||||
boardSlug: string
|
||||
hasVoted?: boolean
|
||||
voted?: boolean
|
||||
}
|
||||
|
||||
interface PostsResponse {
|
||||
posts: Post[]
|
||||
total: number
|
||||
page: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
interface BoardStatusConfig {
|
||||
status: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface Board {
|
||||
@@ -25,170 +48,538 @@ interface Board {
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
externalUrl: string | null
|
||||
iconName: string | null
|
||||
iconColor: string | null
|
||||
statuses?: BoardStatusConfig[]
|
||||
}
|
||||
|
||||
interface Budget {
|
||||
used: number
|
||||
total: number
|
||||
resetsAt?: string
|
||||
remaining: number
|
||||
resetSchedule: string
|
||||
nextReset: string
|
||||
}
|
||||
|
||||
type SortOption = 'newest' | 'top' | 'trending'
|
||||
type StatusFilter = 'all' | 'OPEN' | 'PLANNED' | 'IN_PROGRESS' | 'DONE' | 'DECLINED'
|
||||
function PostCardSkeleton({ index }: { index: number }) {
|
||||
return (
|
||||
<div
|
||||
className="card card-static flex gap-0 overflow-hidden"
|
||||
style={{ animationDelay: `${index * 60}ms` }}
|
||||
>
|
||||
<div className="hidden md:flex flex-col items-center justify-center px-4 py-5 shrink-0" style={{ width: 56, borderRight: '1px solid var(--border)' }}>
|
||||
<div className="skeleton" style={{ width: 16, height: 16, borderRadius: 4, marginBottom: 6 }} />
|
||||
<div className="skeleton" style={{ width: 20, height: 14, borderRadius: 4 }} />
|
||||
</div>
|
||||
<div className="flex-1 py-4 px-5">
|
||||
<div className="flex gap-2 mb-2">
|
||||
<div className="skeleton" style={{ width: 60, height: 18, borderRadius: 6 }} />
|
||||
<div className="skeleton" style={{ width: 50, height: 18, borderRadius: 6 }} />
|
||||
</div>
|
||||
<div className="skeleton skeleton-title" style={{ width: '70%' }} />
|
||||
<div className="skeleton skeleton-text" style={{ width: '90%' }} />
|
||||
</div>
|
||||
<div className="hidden md:flex flex-col items-end justify-center px-5 py-4 shrink-0 gap-2">
|
||||
<div className="skeleton" style={{ width: 70, height: 20, borderRadius: 6 }} />
|
||||
<div className="skeleton" style={{ width: 40, height: 14, borderRadius: 4 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BoardFeed() {
|
||||
const { boardSlug } = useParams<{ boardSlug: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [board, setBoard] = useState<Board | null>(null)
|
||||
useDocumentTitle(board?.name)
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [budget, setBudget] = useState<Budget>({ used: 0, total: 10 })
|
||||
const [pagination, setPagination] = useState({ page: 1, pages: 1, total: 0 })
|
||||
const [budget, setBudget] = useState<Budget | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sort, setSort] = useState<SortOption>('newest')
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
const [subLoading, setSubLoading] = useState(false)
|
||||
|
||||
const sort = searchParams.get('sort') ?? 'newest'
|
||||
const statusFilter = searchParams.get('status') ?? ''
|
||||
const typeFilter = searchParams.get('type') ?? ''
|
||||
const categoryFilter = searchParams.get('category') ?? ''
|
||||
const page = parseInt(searchParams.get('page') ?? '1', 10)
|
||||
|
||||
const activeFilters: { key: string; label: string }[] = []
|
||||
if (statusFilter) activeFilters.push({ key: 'status', label: statusFilter.replace('_', ' ').toLowerCase() })
|
||||
if (typeFilter) activeFilters.push({ key: 'type', label: typeFilter === 'BUG_REPORT' ? 'Bug reports' : 'Feature requests' })
|
||||
if (categoryFilter) activeFilters.push({ key: 'category', label: categoryFilter })
|
||||
if (sort !== 'newest') activeFilters.push({ key: 'sort', label: sort })
|
||||
|
||||
const setFilter = (key: string, value: string | null) => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (value) next.set(key, value)
|
||||
else next.delete(key)
|
||||
next.delete('page')
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
const dismissFilter = (key: string) => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete(key)
|
||||
next.delete('page')
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
const setPage = (p: number) => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (p <= 1) next.delete('page')
|
||||
else next.set('page', String(p))
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ id: string; name: string; slug: string }[]>('/categories')
|
||||
.then(setCategories)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardSlug) return
|
||||
api.get<{ subscribed: boolean }>(`/boards/${boardSlug}/subscription`)
|
||||
.then((r) => setSubscribed(r.subscribed))
|
||||
.catch(() => {})
|
||||
}, [boardSlug])
|
||||
|
||||
const toggleSubscription = async () => {
|
||||
if (subLoading || !boardSlug) return
|
||||
setSubLoading(true)
|
||||
try {
|
||||
if (subscribed) {
|
||||
await api.delete(`/boards/${boardSlug}/subscribe`)
|
||||
setSubscribed(false)
|
||||
} else {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
alert('Push notifications are not supported in this browser')
|
||||
return
|
||||
}
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') return
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const vapid = await api.get<{ publicKey: string }>('/push/vapid')
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: vapid.publicKey,
|
||||
})
|
||||
const json = sub.toJSON()
|
||||
await api.post(`/boards/${boardSlug}/subscribe`, {
|
||||
endpoint: json.endpoint,
|
||||
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
||||
})
|
||||
setSubscribed(true)
|
||||
}
|
||||
} catch {} finally {
|
||||
setSubLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPosts = useCallback(async () => {
|
||||
if (!boardSlug) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ sort })
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter)
|
||||
if (search) params.set('q', search)
|
||||
const params = new URLSearchParams()
|
||||
if (sort !== 'newest') params.set('sort', sort)
|
||||
if (statusFilter) params.set('status', statusFilter)
|
||||
if (typeFilter) params.set('type', typeFilter)
|
||||
if (categoryFilter) params.set('category', categoryFilter)
|
||||
if (search) params.set('search', search)
|
||||
if (page > 1) params.set('page', String(page))
|
||||
|
||||
const [b, p, bud] = await Promise.all([
|
||||
const [b, res, bud] = await Promise.all([
|
||||
api.get<Board>(`/boards/${boardSlug}`),
|
||||
api.get<Post[]>(`/boards/${boardSlug}/posts?${params}`),
|
||||
api.get<Budget>(`/boards/${boardSlug}/budget`).catch(() => ({ used: 0, total: 10 })),
|
||||
api.get<PostsResponse>(`/boards/${boardSlug}/posts?${params}`),
|
||||
api.get<Budget>(`/boards/${boardSlug}/budget`).catch(() => null),
|
||||
])
|
||||
setBoard(b)
|
||||
setPosts(p)
|
||||
setBudget(bud as Budget)
|
||||
setPosts(res.posts.map((p) => ({ ...p, boardSlug: boardSlug! })))
|
||||
setPagination({ page: res.page, pages: res.pages, total: res.total })
|
||||
setBudget(bud)
|
||||
} catch {
|
||||
setPosts([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [boardSlug, sort, statusFilter, search])
|
||||
}, [boardSlug, sort, statusFilter, typeFilter, categoryFilter, search, page])
|
||||
|
||||
useEffect(() => { fetchPosts() }, [fetchPosts])
|
||||
|
||||
const refreshBudget = () => {
|
||||
if (boardSlug) {
|
||||
api.get<Budget>(`/boards/${boardSlug}/budget`).then(setBudget).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const [importancePostId, setImportancePostId] = useState<string | null>(null)
|
||||
|
||||
const handleVote = async (postId: string) => {
|
||||
setPosts((prev) => prev.map((p) =>
|
||||
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount + 1 } : p
|
||||
))
|
||||
if (budget) setBudget({ ...budget, remaining: budget.remaining - 1 })
|
||||
try {
|
||||
await api.post(`/posts/${postId}/vote`)
|
||||
fetchPosts()
|
||||
const altcha = await solveAltcha('light')
|
||||
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
|
||||
refreshBudget()
|
||||
setImportancePostId(postId)
|
||||
} catch {
|
||||
setPosts((prev) => prev.map((p) =>
|
||||
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount - 1 } : p
|
||||
))
|
||||
refreshBudget()
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportance = async (postId: string, importance: string) => {
|
||||
setImportancePostId(null)
|
||||
try {
|
||||
await api.put(`/boards/${boardSlug}/posts/${postId}/vote/importance`, { importance })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const sortOptions: { value: SortOption; label: string }[] = [
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
{ value: 'top', label: 'Top Voted' },
|
||||
{ value: 'trending', label: 'Trending' },
|
||||
]
|
||||
|
||||
const statuses: { value: StatusFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'OPEN', label: 'Open' },
|
||||
{ value: 'PLANNED', label: 'Planned' },
|
||||
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||
{ value: 'DONE', label: 'Done' },
|
||||
{ value: 'DECLINED', label: 'Declined' },
|
||||
]
|
||||
const handleUnvote = async (postId: string) => {
|
||||
setPosts((prev) => prev.map((p) =>
|
||||
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount - 1 } : p
|
||||
))
|
||||
if (budget) setBudget({ ...budget, remaining: budget.remaining + 1 })
|
||||
try {
|
||||
await api.delete(`/boards/${boardSlug}/posts/${postId}/vote`)
|
||||
refreshBudget()
|
||||
} catch {
|
||||
setPosts((prev) => prev.map((p) =>
|
||||
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount + 1 } : p
|
||||
))
|
||||
refreshBudget()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<div className="mx-auto px-6 py-10" style={{ maxWidth: 'var(--content-max)' }}>
|
||||
{/* Breadcrumb */}
|
||||
{board && (
|
||||
<nav aria-label="Breadcrumb" className="mb-4">
|
||||
<ol className="flex items-center gap-1.5" style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 'var(--text-sm)', color: 'var(--text-tertiary)' }}>
|
||||
<li>
|
||||
<Link to="/" style={{ color: 'var(--text-tertiary)', transition: 'color var(--duration-fast) ease-out' }} onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)' }} onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }} onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)' }} onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}>Home</Link>
|
||||
</li>
|
||||
<li aria-hidden="true" style={{ color: 'var(--text-tertiary)' }}>/</li>
|
||||
<li>
|
||||
<span aria-current="page" style={{ color: 'var(--text-secondary)' }}>{board.name}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
{board && (
|
||||
<div className="mb-6">
|
||||
<h1
|
||||
className="text-2xl font-bold mb-1"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
{board.name}
|
||||
</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<BoardIcon name={board.name} iconName={board.iconName} iconColor={board.iconColor} size={40} />
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
fontSize: 'var(--text-2xl)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{board.name}
|
||||
</h1>
|
||||
{board.externalUrl && /^https?:\/\//i.test(board.externalUrl) && (
|
||||
<a
|
||||
href={board.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-full action-btn"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--text-tertiary)',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<IconExternalLink size={12} stroke={2} />
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`/api/v1/boards/${boardSlug}/feed.rss`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-full action-btn"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--text-tertiary)',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
title="RSS feed"
|
||||
>
|
||||
<IconRss size={12} stroke={2} />
|
||||
RSS
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleSubscription}
|
||||
disabled={subLoading}
|
||||
className="flex items-center gap-1 px-2.5 rounded-full action-btn"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
minHeight: 36,
|
||||
color: subscribed ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
background: subscribed ? 'var(--accent-subtle)' : 'var(--surface)',
|
||||
border: subscribed ? '1px solid var(--border-accent)' : '1px solid var(--border)',
|
||||
cursor: subLoading ? 'wait' : 'pointer',
|
||||
opacity: subLoading ? 0.6 : 1,
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
title={subscribed ? 'Unsubscribe from new posts' : 'Get notified of new posts'}
|
||||
aria-label={subscribed ? 'Unsubscribe from board notifications' : 'Subscribe to board notifications'}
|
||||
>
|
||||
{subscribed ? <IconBell size={12} stroke={2} /> : <IconBellOff size={12} stroke={2} />}
|
||||
{subscribed ? 'Subscribed' : 'Subscribe'}
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
{board.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Budget */}
|
||||
<div className="mb-4">
|
||||
<VoteBudget used={budget.used} total={budget.total} resetsAt={budget.resetsAt} />
|
||||
</div>
|
||||
<PluginSlot name="board-feed-top" />
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
{/* Budget */}
|
||||
{budget && (
|
||||
<div className="mb-5">
|
||||
<VoteBudget used={budget.total - budget.remaining} total={budget.total} resetsAt={budget.nextReset} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar: search + filters + new post */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search posts..."
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label="Search posts"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{sortOptions.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => setSort(o.value)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-medium"
|
||||
<button
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
className="btn btn-secondary"
|
||||
aria-label="Filters"
|
||||
aria-expanded={filtersOpen}
|
||||
style={{
|
||||
color: filtersOpen || activeFilters.length > 0 ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
borderColor: filtersOpen ? 'var(--border-accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<IconFilter size={15} stroke={2} />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
{activeFilters.length > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-full font-bold"
|
||||
style={{
|
||||
background: sort === o.value ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: sort === o.value ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
transition: 'all 200ms ease-out',
|
||||
width: 18,
|
||||
height: 18,
|
||||
background: 'var(--accent)',
|
||||
color: '#161616',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
{o.label}
|
||||
{activeFilters.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFormOpen(!formOpen)}
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
aria-expanded={formOpen}
|
||||
>
|
||||
<IconPlus size={15} stroke={2.5} />
|
||||
<span className="hidden sm:inline">Feedback</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar - horizontal, in content area */}
|
||||
{filtersOpen && (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-3 mb-5 p-4 rounded-xl slide-down"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<Dropdown
|
||||
value={typeFilter}
|
||||
onChange={(v) => setFilter('type', v || null)}
|
||||
placeholder="All types"
|
||||
options={[
|
||||
{ value: '', label: 'All types' },
|
||||
{ value: 'FEATURE_REQUEST', label: 'Feature requests' },
|
||||
{ value: 'BUG_REPORT', label: 'Bug reports' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<Dropdown
|
||||
value={statusFilter}
|
||||
onChange={(v) => setFilter('status', v || null)}
|
||||
placeholder="All statuses"
|
||||
options={[
|
||||
{ value: '', label: 'All statuses' },
|
||||
...(board?.statuses || []).map((s) => ({ value: s.status, label: s.label })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{categories.length > 0 && (
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<Dropdown
|
||||
value={categoryFilter}
|
||||
onChange={(v) => setFilter('category', v || null)}
|
||||
placeholder="All categories"
|
||||
options={[
|
||||
{ value: '', label: 'All categories' },
|
||||
...categories.map((c) => ({ value: c.slug, label: c.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ minWidth: 140 }}>
|
||||
<Dropdown
|
||||
value={sort}
|
||||
onChange={(v) => setFilter('sort', v === 'newest' ? null : v)}
|
||||
options={[
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
{ value: 'oldest', label: 'Oldest' },
|
||||
{ value: 'top', label: 'Most voted' },
|
||||
{ value: 'updated', label: 'Recently updated' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active filter pills */}
|
||||
{activeFilters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-5">
|
||||
{activeFilters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => dismissFilter(f.key)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full action-btn"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
background: 'var(--accent-subtle)',
|
||||
color: 'var(--accent)',
|
||||
border: '1px solid var(--border-accent)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
<IconX size={12} stroke={3} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<div className="flex gap-1 mb-6 overflow-x-auto pb-1">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => setStatusFilter(s.value)}
|
||||
className="px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
style={{
|
||||
background: statusFilter === s.value ? 'var(--surface-hover)' : 'transparent',
|
||||
color: statusFilter === s.value ? 'var(--text)' : 'var(--text-tertiary)',
|
||||
border: `1px solid ${statusFilter === s.value ? 'var(--border-hover)' : 'var(--border)'}`,
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Post form */}
|
||||
{boardSlug && (
|
||||
<div className="mb-4">
|
||||
<PostForm boardSlug={boardSlug} onSubmit={fetchPosts} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Posts */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
{/* Post form with expand animation */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: formOpen ? '1fr' : '0fr',
|
||||
opacity: formOpen ? 1 : 0,
|
||||
transition: `grid-template-rows var(--duration-slow) var(--ease-out), opacity var(--duration-normal) ease-out`,
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
{boardSlug && (
|
||||
<div className="mb-6">
|
||||
<PostForm boardSlug={boardSlug} boardId={board?.id} onSubmit={() => { setFormOpen(false); fetchPosts() }} onCancel={() => setFormOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<EmptyState
|
||||
onAction={() => setShowForm(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} onVote={handleVote} />
|
||||
</div>
|
||||
|
||||
{/* Loading skeletons */}
|
||||
<div aria-live="polite" aria-busy={loading}>
|
||||
{loading && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Progress bar */}
|
||||
<div className="progress-bar mb-2" />
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<PostCardSkeleton key={i} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && posts.length === 0 && (
|
||||
<EmptyState onAction={() => setFormOpen(true)} />
|
||||
)}
|
||||
|
||||
{/* Posts */}
|
||||
{!loading && posts.length > 0 && (
|
||||
<>
|
||||
{posts.some(p => p.isPinned) && (
|
||||
<div
|
||||
className="mb-6 pb-6"
|
||||
style={{ borderBottom: '1px solid var(--border)' }}
|
||||
>
|
||||
<div
|
||||
className="font-medium uppercase tracking-wider mb-3 px-1"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em' }}
|
||||
>
|
||||
Pinned
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{posts.filter(p => p.isPinned).map((post, i) => (
|
||||
<PostCard key={post.id} post={post} onVote={handleVote} onUnvote={handleUnvote} onImportance={handleImportance} showImportancePopup={importancePostId === post.id} budgetDepleted={budget ? budget.remaining <= 0 : false} customStatuses={board?.statuses} index={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{posts.filter(p => !p.isPinned).map((post, i) => (
|
||||
<PostCard key={post.id} post={post} onVote={handleVote} onUnvote={handleUnvote} onImportance={handleImportance} showImportancePopup={importancePostId === post.id} budgetDepleted={budget ? budget.remaining <= 0 : false} index={i} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 mt-10">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="btn btn-secondary"
|
||||
style={{ opacity: page <= 1 ? 0.4 : 1 }}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
{page} / {pagination.pages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= pagination.pages}
|
||||
className="btn btn-secondary"
|
||||
style={{ opacity: page >= pagination.pages ? 0.4 : 1 }}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,57 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import PluginSlot from '../components/PluginSlot'
|
||||
import { IconExternalLink, IconChevronRight, IconMessages, IconCircleDot, IconClock, IconArchive } from '@tabler/icons-react'
|
||||
import BoardIcon from '../components/BoardIcon'
|
||||
|
||||
interface Board {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
externalUrl: string | null
|
||||
iconName: string | null
|
||||
iconColor: string | null
|
||||
postCount: number
|
||||
openCount: number
|
||||
lastActivity: string | null
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
function timeAgo(date: string) {
|
||||
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
return `${Math.floor(days / 30)}mo ago`
|
||||
}
|
||||
|
||||
function BoardCardSkeleton({ index }: { index: number }) {
|
||||
return (
|
||||
<div
|
||||
className="card card-static p-6"
|
||||
style={{ animationDelay: `${index * 80}ms` }}
|
||||
>
|
||||
<div className="skeleton skeleton-title" style={{ width: '50%' }} />
|
||||
<div className="skeleton skeleton-text" style={{ width: '90%' }} />
|
||||
<div className="skeleton skeleton-text" style={{ width: '70%' }} />
|
||||
<div className="flex gap-4 mt-4">
|
||||
<div className="skeleton" style={{ width: 70, height: 14 }} />
|
||||
<div className="skeleton" style={{ width: 60, height: 14 }} />
|
||||
<div className="skeleton" style={{ width: 50, height: 14 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BoardIndex() {
|
||||
useDocumentTitle('Boards')
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
@@ -27,110 +66,170 @@ export default function BoardIndex() {
|
||||
const active = boards.filter((b) => !b.archived)
|
||||
const archived = boards.filter((b) => b.archived)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<div className="mx-auto px-6 py-10" style={{ maxWidth: 'var(--content-max)' }}>
|
||||
{/* Hero */}
|
||||
<div className="mb-10">
|
||||
<h1
|
||||
className="text-3xl font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
fontSize: 'var(--text-3xl)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text)',
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
Feedback Boards
|
||||
Feedback
|
||||
</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Choose a board to browse or submit feedback
|
||||
<p
|
||||
className="mt-3"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 'var(--text-lg)',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
Browse boards and share what matters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{active.map((board, i) => (
|
||||
<Link
|
||||
key={board.id}
|
||||
to={`/b/${board.slug}`}
|
||||
className="card p-5 block group"
|
||||
style={{
|
||||
animation: `fadeIn 200ms ease-out ${i * 80}ms both`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-lg font-bold"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
background: 'var(--accent-subtle)',
|
||||
color: 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{board.name.charAt(0)}
|
||||
</div>
|
||||
<svg
|
||||
width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
style={{ color: 'var(--text-tertiary)', transition: 'transform 200ms ease-out' }}
|
||||
className="group-hover:translate-x-0.5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2
|
||||
className="text-base font-semibold mb-1"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
{board.name}
|
||||
</h2>
|
||||
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{board.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span>{board.postCount} posts</span>
|
||||
<span>{board.openCount} open</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<PluginSlot name="board-index-top" />
|
||||
|
||||
{archived.length > 0 && (
|
||||
<div className="mt-10">
|
||||
{/* Loading skeletons */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<BoardCardSkeleton key={i} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Board grid */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{active.map((board, i) => (
|
||||
<Link
|
||||
key={board.id}
|
||||
to={`/b/${board.slug}`}
|
||||
className="card card-interactive p-6 block stagger-in"
|
||||
style={{ '--stagger': i } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<BoardIcon name={board.name} iconName={board.iconName} iconColor={board.iconColor} size={36} />
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
fontSize: 'var(--text-xl)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text)',
|
||||
transition: 'color var(--duration-normal) ease-out',
|
||||
}}
|
||||
>
|
||||
{board.name}
|
||||
</h2>
|
||||
{board.externalUrl && (
|
||||
<IconExternalLink size={14} stroke={2} style={{ color: 'var(--text-tertiary)' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{board.description && (
|
||||
<p
|
||||
className="mb-4 line-clamp-2"
|
||||
style={{
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{board.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-5" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<IconMessages size={13} stroke={2} />
|
||||
{board.postCount} posts
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5" style={{ color: 'var(--accent)' }}>
|
||||
<IconCircleDot size={13} stroke={2} />
|
||||
{board.openCount} open
|
||||
</span>
|
||||
{board.lastActivity && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<IconClock size={13} stroke={2} />
|
||||
<time dateTime={board.lastActivity}>{timeAgo(board.lastActivity)}</time>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && active.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 fade-in">
|
||||
<div
|
||||
className="w-20 h-20 rounded-full flex items-center justify-center mb-6"
|
||||
style={{ background: 'var(--accent-subtle)' }}
|
||||
>
|
||||
<IconMessages size={36} stroke={1.5} style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
<h2
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-lg)' }}
|
||||
>
|
||||
No boards yet
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
Boards will appear here once an admin creates them
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Archived boards */}
|
||||
{!loading && archived.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<button
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
className="flex items-center gap-2 text-sm mb-4"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
aria-expanded={showArchived}
|
||||
className="flex items-center gap-2 mb-4"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
<svg
|
||||
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
stroke={2}
|
||||
style={{
|
||||
transition: 'transform 200ms ease-out',
|
||||
transition: 'transform var(--duration-normal) var(--ease-out)',
|
||||
transform: showArchived ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Archived boards ({archived.length})
|
||||
/>
|
||||
<IconArchive size={14} stroke={2} />
|
||||
Archived ({archived.length})
|
||||
</button>
|
||||
|
||||
{showArchived && (
|
||||
<div className="grid gap-3 md:grid-cols-2 fade-in">
|
||||
{archived.map((board) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{archived.map((board, i) => (
|
||||
<Link
|
||||
key={board.id}
|
||||
to={`/b/${board.slug}`}
|
||||
className="card p-4 block opacity-60"
|
||||
className="card card-interactive p-5 block stagger-in"
|
||||
style={{ '--stagger': i, opacity: 0.6 } as React.CSSProperties}
|
||||
>
|
||||
<h3 className="text-sm font-medium mb-1" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
<h2
|
||||
className="font-semibold mb-1"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--text)',
|
||||
fontSize: 'var(--text-base)',
|
||||
}}
|
||||
>
|
||||
{board.name}
|
||||
</h3>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
</h2>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{board.postCount} posts - archived
|
||||
</p>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
99
packages/web/src/pages/ChangelogPage.tsx
Normal file
99
packages/web/src/pages/ChangelogPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import Markdown from '../components/Markdown'
|
||||
|
||||
interface Entry {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
publishedAt: string
|
||||
}
|
||||
|
||||
export default function ChangelogPage() {
|
||||
const { boardSlug } = useParams()
|
||||
useDocumentTitle(boardSlug ? `${boardSlug} Changelog` : 'Changelog')
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const url = boardSlug ? `/b/${boardSlug}/changelog` : '/changelog'
|
||||
api.get<{ entries: Entry[] }>(url)
|
||||
.then((r) => setEntries(r.entries))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [boardSlug])
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 680, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="mb-8">
|
||||
<h1
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Changelog
|
||||
</h1>
|
||||
<p className="mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
Latest updates and improvements
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} style={{ opacity: 1 - i * 0.2 }}>
|
||||
<div className="skeleton h-3 w-24 rounded mb-2" />
|
||||
<div className="skeleton h-5 w-2/3 rounded mb-3" />
|
||||
<div className="skeleton h-16 w-full rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="text-center py-12" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
No changelog entries yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative" style={{ paddingLeft: 24 }}>
|
||||
{/* Timeline line */}
|
||||
<div
|
||||
className="absolute top-2 bottom-2"
|
||||
style={{ left: 5, width: 2, background: 'var(--border)', borderRadius: 1 }}
|
||||
/>
|
||||
{entries.map((entry, i) => (
|
||||
<div key={entry.id} className="relative mb-8" style={{ paddingBottom: i === entries.length - 1 ? 0 : undefined }}>
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: -22,
|
||||
top: 6,
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: i === 0 ? 'var(--accent)' : 'var(--surface-hover)',
|
||||
border: `2px solid ${i === 0 ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
<time
|
||||
dateTime={entry.publishedAt}
|
||||
className="block mb-1 font-medium"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
{new Date(entry.publishedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</time>
|
||||
<h2
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-lg)' }}
|
||||
>
|
||||
{entry.title}
|
||||
</h2>
|
||||
<Markdown>{entry.body}</Markdown>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
packages/web/src/pages/EmbedBoard.tsx
Normal file
194
packages/web/src/pages/EmbedBoard.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useBranding } from '../hooks/useBranding'
|
||||
|
||||
interface EmbedPost {
|
||||
id: string
|
||||
title: string
|
||||
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
|
||||
status: string
|
||||
voteCount: number
|
||||
isPinned: boolean
|
||||
commentCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface EmbedData {
|
||||
board: { name: string; slug: string }
|
||||
posts: EmbedPost[]
|
||||
}
|
||||
|
||||
const STATUS_DARK: Record<string, { bg: string; color: string; label: string }> = {
|
||||
OPEN: { bg: 'rgba(245,158,11,0.12)', color: '#F59E0B', label: 'Open' },
|
||||
UNDER_REVIEW: { bg: 'rgba(8,196,228,0.12)', color: '#08C4E4', label: 'Under Review' },
|
||||
PLANNED: { bg: 'rgba(109,181,252,0.12)', color: '#6DB5FC', label: 'Planned' },
|
||||
IN_PROGRESS: { bg: 'rgba(234,179,8,0.12)', color: '#EAB308', label: 'In Progress' },
|
||||
DONE: { bg: 'rgba(34,197,94,0.12)', color: '#22C55E', label: 'Done' },
|
||||
DECLINED: { bg: 'rgba(249,138,138,0.12)', color: '#F98A8A', label: 'Declined' },
|
||||
}
|
||||
const STATUS_LIGHT: Record<string, { bg: string; color: string; label: string }> = {
|
||||
OPEN: { bg: 'rgba(112,73,9,0.12)', color: '#704909', label: 'Open' },
|
||||
UNDER_REVIEW: { bg: 'rgba(10,92,115,0.12)', color: '#0A5C73', label: 'Under Review' },
|
||||
PLANNED: { bg: 'rgba(26,64,176,0.12)', color: '#1A40B0', label: 'Planned' },
|
||||
IN_PROGRESS: { bg: 'rgba(116,67,12,0.12)', color: '#74430C', label: 'In Progress' },
|
||||
DONE: { bg: 'rgba(22,101,52,0.12)', color: '#166534', label: 'Done' },
|
||||
DECLINED: { bg: 'rgba(153,25,25,0.12)', color: '#991919', label: 'Declined' },
|
||||
}
|
||||
|
||||
function timeAgo(date: string): string {
|
||||
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
|
||||
if (s < 60) return 'just now'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
const d = Math.floor(h / 24)
|
||||
if (d < 30) return `${d}d ago`
|
||||
return `${Math.floor(d / 30)}mo ago`
|
||||
}
|
||||
|
||||
export default function EmbedBoard() {
|
||||
const { boardSlug } = useParams<{ boardSlug: string }>()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [data, setData] = useState<EmbedData | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const { appName, poweredByVisible } = useBranding()
|
||||
|
||||
const theme = searchParams.get('theme') || 'dark'
|
||||
const limit = searchParams.get('limit') || '10'
|
||||
const sort = searchParams.get('sort') || 'top'
|
||||
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardSlug) return
|
||||
const params = new URLSearchParams({ limit, sort })
|
||||
fetch(`/api/v1/embed/${boardSlug}/posts?${params}`)
|
||||
.then((r) => { if (!r.ok) throw new Error(); return r.json() })
|
||||
.then(setData)
|
||||
.catch(() => setError(true))
|
||||
}, [boardSlug, limit, sort])
|
||||
|
||||
const colors = isDark
|
||||
? { bg: '#161616', surface: '#1e1e1e', border: 'rgba(255,255,255,0.08)', text: '#f0f0f0', textSec: 'rgba(240,240,240,0.72)', textTer: 'rgba(240,240,240,0.71)', accent: '#F59E0B' }
|
||||
: { bg: '#f7f8fa', surface: '#ffffff', border: 'rgba(0,0,0,0.08)', text: '#1a1a1a', textSec: '#4a4a4a', textTer: '#545454', accent: '#704909' }
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 24, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', color: colors.textSec, fontSize: 14, background: colors.bg, minHeight: '100vh' }}>
|
||||
Board not found
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{ padding: 24, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', color: colors.textTer, fontSize: 14, background: colors.bg, minHeight: '100vh' }}>
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// build the base URL for linking back to the main app
|
||||
const origin = window.location.origin
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', background: colors.bg, color: colors.text, minHeight: '100vh', padding: '16px 20px' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<a
|
||||
href={`${origin}/b/${data.board.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: colors.text, textDecoration: 'none', fontWeight: 600, fontSize: 16 }}
|
||||
>
|
||||
{data.board.name}
|
||||
</a>
|
||||
<a
|
||||
href={`${origin}/b/${data.board.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: colors.accent, textDecoration: 'none', fontSize: 12, fontWeight: 500 }}
|
||||
>
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Posts */}
|
||||
{data.posts.length === 0 && (
|
||||
<div style={{ color: colors.textTer, fontSize: 13, padding: '20px 0', textAlign: 'center' }}>
|
||||
No posts yet
|
||||
</div>
|
||||
)}
|
||||
{data.posts.map((post) => {
|
||||
const statusMap = isDark ? STATUS_DARK : STATUS_LIGHT
|
||||
const sc = statusMap[post.status] || { bg: colors.surface, color: colors.textSec, label: post.status }
|
||||
return (
|
||||
<a
|
||||
key={post.id}
|
||||
href={`${origin}/b/${data.board.slug}/post/${post.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
marginBottom: 4,
|
||||
borderRadius: 8,
|
||||
background: colors.surface,
|
||||
border: `1px solid ${colors.border}`,
|
||||
textDecoration: 'none',
|
||||
color: colors.text,
|
||||
transition: 'border-color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = colors.accent }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = colors.border }}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = colors.accent }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = colors.border }}
|
||||
>
|
||||
{/* Vote count */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 36 }}>
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" style={{ marginBottom: 2 }}>
|
||||
<path d="M6 0L12 8H0Z" fill={colors.accent} />
|
||||
</svg>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: colors.accent }}>{post.voteCount}</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{post.isPinned && <span style={{ color: colors.accent, marginRight: 4, fontSize: 12, fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.04em' }}>Pinned</span>}
|
||||
{post.title}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, padding: '1px 6px', borderRadius: 4, background: sc.bg, color: sc.color, fontWeight: 500 }}>
|
||||
{sc.label}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: colors.textTer }}>
|
||||
{post.commentCount} comment{post.commentCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<time dateTime={post.createdAt} style={{ fontSize: 12, color: colors.textTer }}>
|
||||
{timeAgo(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Footer */}
|
||||
{poweredByVisible && (
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<a
|
||||
href={`${origin}/b/${data.board.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: colors.textTer, textDecoration: 'none', fontSize: 12 }}
|
||||
>
|
||||
Powered by {appName}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { api } from '../lib/api'
|
||||
import { solveAltcha } from '../lib/altcha'
|
||||
import { IconCheck, IconLock, IconKey, IconDownload, IconCamera, IconTrash, IconShieldCheck, IconArrowBack } from '@tabler/icons-react'
|
||||
import PasskeyModal from '../components/PasskeyModal'
|
||||
import RecoveryCodeModal from '../components/RecoveryCodeModal'
|
||||
import Avatar from '../components/Avatar'
|
||||
|
||||
interface PasskeyInfo {
|
||||
id: string
|
||||
credentialDeviceType: string
|
||||
credentialBackedUp: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function IdentitySettings() {
|
||||
useDocumentTitle('Settings')
|
||||
const confirm = useConfirm()
|
||||
const auth = useAuth()
|
||||
const [name, setName] = useState(auth.displayName)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -10,12 +27,89 @@ export default function IdentitySettings() {
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showPasskey, setShowPasskey] = useState(false)
|
||||
const [showRecovery, setShowRecovery] = useState(false)
|
||||
const [showRedeemInput, setShowRedeemInput] = useState(false)
|
||||
const [redeemPhrase, setRedeemPhrase] = useState('')
|
||||
const [redeeming, setRedeeming] = useState(false)
|
||||
const [redeemError, setRedeemError] = useState('')
|
||||
const [redeemSuccess, setRedeemSuccess] = useState(false)
|
||||
const [passkeys, setPasskeys] = useState<PasskeyInfo[]>([])
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null)
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval>>()
|
||||
const deleteTrapRef = useFocusTrap(showDelete)
|
||||
|
||||
useEffect(() => {
|
||||
if (showDelete) {
|
||||
setCountdown(10)
|
||||
countdownRef.current = setInterval(() => {
|
||||
setCountdown((c) => {
|
||||
if (c <= 1) {
|
||||
clearInterval(countdownRef.current)
|
||||
return 0
|
||||
}
|
||||
return c - 1
|
||||
})
|
||||
}, 1000)
|
||||
} else {
|
||||
clearInterval(countdownRef.current)
|
||||
setCountdown(0)
|
||||
}
|
||||
return () => clearInterval(countdownRef.current)
|
||||
}, [showDelete])
|
||||
|
||||
const fetchPasskeys = () => {
|
||||
if (auth.isPasskeyUser) {
|
||||
api.get<PasskeyInfo[]>('/me/passkeys').then(setPasskeys).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchPasskeys() }, [auth.isPasskeyUser])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ avatarUrl: string | null }>('/me').then((data) => {
|
||||
setAvatarUrl(data.avatarUrl ?? null)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (file.size > 2 * 1024 * 1024) return
|
||||
setUploadingAvatar(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await fetch('/api/v1/me/avatar', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAvatarUrl(data.avatarUrl + '?t=' + Date.now())
|
||||
}
|
||||
} catch {} finally {
|
||||
setUploadingAvatar(false)
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await api.delete('/me/avatar')
|
||||
setAvatarUrl(null)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const saveName = async () => {
|
||||
if (!name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await auth.updateProfile({ displayName: name })
|
||||
const altcha = await solveAltcha('light')
|
||||
await auth.updateProfile({ displayName: name, altcha })
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch {} finally {
|
||||
@@ -23,6 +117,31 @@ export default function IdentitySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRedeem = async () => {
|
||||
const clean = redeemPhrase.toLowerCase().trim()
|
||||
if (!/^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/.test(clean)) {
|
||||
setRedeemError('Enter a valid 6-word recovery phrase separated by dashes')
|
||||
return
|
||||
}
|
||||
setRedeeming(true)
|
||||
setRedeemError('')
|
||||
try {
|
||||
const altcha = await solveAltcha()
|
||||
await api.post('/auth/recover', { phrase: clean, altcha })
|
||||
setRedeemSuccess(true)
|
||||
auth.refresh()
|
||||
setTimeout(() => {
|
||||
setShowRedeemInput(false)
|
||||
setRedeemPhrase('')
|
||||
setRedeemSuccess(false)
|
||||
}, 2000)
|
||||
} catch (e: any) {
|
||||
setRedeemError(e?.message || 'Invalid or expired recovery code')
|
||||
} finally {
|
||||
setRedeeming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const data = await api.get<unknown>('/me/export')
|
||||
@@ -39,7 +158,8 @@ export default function IdentitySettings() {
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await auth.deleteIdentity()
|
||||
const altcha = await solveAltcha()
|
||||
await auth.deleteIdentity(altcha)
|
||||
window.location.href = '/'
|
||||
} catch {} finally {
|
||||
setDeleting(false)
|
||||
@@ -47,72 +167,153 @@ export default function IdentitySettings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto px-4 py-8">
|
||||
<div style={{ maxWidth: 520, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1
|
||||
className="text-2xl font-bold mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
className="font-bold mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
{/* Display name */}
|
||||
<div className="card p-5 mb-4">
|
||||
{/* Avatar */}
|
||||
<div className="card card-static p-5 mb-4">
|
||||
<h2
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
className="font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
Avatar
|
||||
</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
userId={auth.user?.id ?? ''}
|
||||
name={auth.displayName || null}
|
||||
avatarUrl={avatarUrl}
|
||||
size={64}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
{auth.isPasskeyUser ? (
|
||||
<>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleAvatarUpload}
|
||||
className="sr-only"
|
||||
id="avatar-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="avatar-upload"
|
||||
className="btn btn-secondary inline-flex items-center gap-2"
|
||||
style={{ cursor: uploadingAvatar ? 'wait' : 'pointer', opacity: uploadingAvatar ? 0.6 : 1 }}
|
||||
>
|
||||
<IconCamera size={16} stroke={2} />
|
||||
{uploadingAvatar ? 'Uploading...' : avatarUrl ? 'Change photo' : 'Upload photo'}
|
||||
</label>
|
||||
{avatarUrl && (
|
||||
<button
|
||||
onClick={handleAvatarRemove}
|
||||
className="btn inline-flex items-center gap-2"
|
||||
style={{
|
||||
color: 'var(--error)',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
<IconTrash size={14} stroke={2} />
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
<button
|
||||
onClick={() => setShowPasskey(true)}
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
fontSize: 'inherit',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '2px',
|
||||
}}
|
||||
>
|
||||
Save your identity
|
||||
</button>
|
||||
{' '}to upload a custom avatar
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 12 }}>
|
||||
JPG, PNG or WebP. Max 2MB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div className="card card-static p-5 mb-4">
|
||||
<h2
|
||||
className="font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
Display Name
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-label="Display name"
|
||||
/>
|
||||
<button
|
||||
onClick={saveName}
|
||||
disabled={saving}
|
||||
className="btn btn-primary"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{saving ? 'Saving...' : saved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{!auth.isPasskeyUser && (
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 8 }}>
|
||||
This name is tied to your browser cookie. Register with a passkey to make it permanent.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Identity status */}
|
||||
<div className="card p-5 mb-4">
|
||||
<div className="card card-static p-5 mb-4">
|
||||
<h2
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
className="font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
Identity
|
||||
</h2>
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 rounded-lg mb-3"
|
||||
style={{ background: 'var(--bg)' }}
|
||||
className="flex items-center gap-3 p-3 mb-3"
|
||||
style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center"
|
||||
className="w-8 h-8 flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
background: auth.isPasskeyUser ? 'rgba(34, 197, 94, 0.15)' : 'var(--accent-subtle)',
|
||||
background: auth.isPasskeyUser ? 'rgba(34, 197, 94, 0.12)' : 'var(--accent-subtle)',
|
||||
color: auth.isPasskeyUser ? 'var(--success)' : 'var(--accent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{auth.isPasskeyUser ? (
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<IconCheck size={16} stroke={2.5} />
|
||||
) : (
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<IconLock size={16} stroke={2} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||
<div className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{auth.isPasskeyUser ? 'Passkey registered' : 'Cookie-based identity'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{auth.isPasskeyUser
|
||||
? 'Your identity is secured with a passkey'
|
||||
: 'Your identity is tied to this browser cookie'}
|
||||
@@ -122,66 +323,278 @@ export default function IdentitySettings() {
|
||||
|
||||
{!auth.isPasskeyUser && (
|
||||
<button onClick={() => setShowPasskey(true)} className="btn btn-primary w-full">
|
||||
Upgrade to passkey
|
||||
Save my identity
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Data */}
|
||||
<div className="card p-5 mb-4">
|
||||
{/* Recovery code - cookie-only users */}
|
||||
{!auth.isPasskeyUser && (
|
||||
<div className="card card-static p-5 mb-4">
|
||||
<h2
|
||||
className="font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
Recovery Code
|
||||
</h2>
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 mb-3"
|
||||
style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
background: auth.user?.hasRecoveryCode ? 'rgba(34, 197, 94, 0.12)' : 'rgba(234, 179, 8, 0.12)',
|
||||
color: auth.user?.hasRecoveryCode ? 'var(--success)' : 'var(--warning)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
<IconShieldCheck size={16} stroke={2} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{auth.user?.hasRecoveryCode ? 'Recovery code active' : 'No recovery code'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{auth.user?.hasRecoveryCode
|
||||
? `Expires ${new Date(auth.user.recoveryCodeExpiresAt!).toLocaleDateString()}`
|
||||
: 'If you clear cookies, you lose access to your posts'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-3" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', lineHeight: 1.5 }}>
|
||||
{auth.user?.hasRecoveryCode
|
||||
? 'Your recovery code lets you get back to your identity if cookies are cleared. Codes expire after 90 days - generate a new one before it runs out.'
|
||||
: 'Generate a recovery phrase to get back to your posts if you clear your cookies or switch browsers. No email needed.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowRecovery(true)}
|
||||
className="btn btn-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<IconShieldCheck size={16} stroke={2} />
|
||||
{auth.user?.hasRecoveryCode ? 'Manage recovery code' : 'Generate recovery code'}
|
||||
</button>
|
||||
|
||||
{/* Redeem a recovery code */}
|
||||
<div style={{ borderTop: '1px solid var(--border)', marginTop: 16, paddingTop: 16 }}>
|
||||
{showRedeemInput ? (
|
||||
<div className="fade-in">
|
||||
{redeemSuccess ? (
|
||||
<div
|
||||
className="p-3 flex items-center gap-2"
|
||||
style={{
|
||||
background: 'rgba(34, 197, 94, 0.08)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.25)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--success)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
}}
|
||||
>
|
||||
<IconCheck size={16} stroke={2.5} />
|
||||
Identity recovered
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', lineHeight: 1.5 }}>
|
||||
Enter the 6-word recovery phrase you saved earlier.
|
||||
</p>
|
||||
<input
|
||||
className="input w-full mb-2 font-mono"
|
||||
placeholder="word-word-word-word-word-word"
|
||||
value={redeemPhrase}
|
||||
onChange={(e) => setRedeemPhrase(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRedeem()}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
style={{ letterSpacing: '0.02em', fontSize: 'var(--text-sm)' }}
|
||||
/>
|
||||
{redeemError && (
|
||||
<p role="alert" className="mb-2" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{redeemError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRedeem}
|
||||
disabled={redeeming || !redeemPhrase.trim()}
|
||||
className="btn btn-primary flex-1"
|
||||
style={{ fontSize: 'var(--text-sm)', opacity: redeeming ? 0.6 : 1 }}
|
||||
>
|
||||
{redeeming ? 'Recovering...' : 'Recover identity'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowRedeemInput(false); setRedeemPhrase(''); setRedeemError('') }}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowRedeemInput(true)}
|
||||
className="btn btn-ghost w-full flex items-center justify-center gap-2"
|
||||
style={{ fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
<IconArrowBack size={14} stroke={2} />
|
||||
I have a recovery code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Passkey management */}
|
||||
{auth.isPasskeyUser && (
|
||||
<div className="card card-static p-5 mb-4">
|
||||
<h2
|
||||
className="font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
Passkeys
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
{passkeys.map((pk) => (
|
||||
<div
|
||||
key={pk.id}
|
||||
className="flex items-center justify-between p-3"
|
||||
style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-8 h-8 flex items-center justify-center"
|
||||
style={{
|
||||
background: 'rgba(34, 197, 94, 0.12)',
|
||||
color: 'var(--success)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
<IconKey size={14} stroke={2} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{pk.credentialDeviceType === 'multiDevice' ? 'Synced passkey' : 'Device-bound passkey'}
|
||||
{pk.credentialBackedUp && (
|
||||
<span className="ml-1" style={{ color: 'var(--success)', fontSize: 'var(--text-xs)' }}>(backed up)</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
Added <time dateTime={pk.createdAt}>{new Date(pk.createdAt).toLocaleDateString()}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{passkeys.length > 1 && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!await confirm('Remove this passkey?')) return
|
||||
try {
|
||||
await api.delete(`/me/passkeys/${pk.id}`)
|
||||
fetchPasskeys()
|
||||
} catch {}
|
||||
}}
|
||||
className="px-2 py-1"
|
||||
style={{
|
||||
color: 'var(--error)',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPasskey(true)}
|
||||
className="btn btn-secondary w-full"
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
Add another passkey
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data export */}
|
||||
<div className="card card-static p-5 mb-4">
|
||||
<h2
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
className="font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
Your Data
|
||||
</h2>
|
||||
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
<p className="mb-3" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
Export all your data in JSON format.
|
||||
</p>
|
||||
<button onClick={handleExport} className="btn btn-secondary">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<button onClick={handleExport} className="btn btn-secondary inline-flex items-center gap-2">
|
||||
<IconDownload size={16} stroke={2} />
|
||||
Export Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="card p-5" style={{ borderColor: 'rgba(239, 68, 68, 0.2)' }}>
|
||||
<div
|
||||
className="card card-static p-5"
|
||||
style={{ borderColor: 'rgba(239, 68, 68, 0.2)' }}
|
||||
>
|
||||
<h2
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)' }}
|
||||
className="font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
Danger Zone
|
||||
</h2>
|
||||
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
<p className="mb-3" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
This will permanently delete your identity and all associated data. This cannot be undone.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="btn text-sm"
|
||||
style={{ background: 'rgba(239, 68, 68, 0.15)', color: 'var(--error)' }}
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.12)',
|
||||
color: 'var(--error)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
}}
|
||||
>
|
||||
Delete my identity
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{/* Delete confirmation modal */}
|
||||
{showDelete && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
onClick={() => setShowDelete(false)}
|
||||
>
|
||||
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 p-6 rounded-xl shadow-2xl slide-up"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
className="absolute inset-0 fade-in"
|
||||
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}
|
||||
/>
|
||||
<div
|
||||
ref={deleteTrapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-modal-title"
|
||||
className="relative w-full max-w-sm mx-4 p-6 fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.key === 'Escape' && setShowDelete(false)}
|
||||
>
|
||||
<h3 className="text-lg font-bold mb-2" style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)' }}>
|
||||
<h2
|
||||
id="delete-modal-title"
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)', fontSize: 'var(--text-lg)' }}
|
||||
>
|
||||
Delete Identity
|
||||
</h3>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
</h2>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
Are you sure? All your posts, votes, and data will be permanently removed. This action cannot be reversed.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
@@ -190,16 +603,30 @@ export default function IdentitySettings() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
disabled={deleting || countdown > 0}
|
||||
className="btn flex-1"
|
||||
style={{ background: 'var(--error)', color: 'white', opacity: deleting ? 0.6 : 1 }}
|
||||
style={{
|
||||
background: countdown > 0 ? 'rgba(239, 68, 68, 0.3)' : 'var(--error)',
|
||||
color: 'white',
|
||||
opacity: deleting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
{deleting ? 'Deleting...' : countdown > 0 ? `Delete (${countdown}s)` : 'Delete forever'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PasskeyModal
|
||||
mode="register"
|
||||
open={showPasskey}
|
||||
onClose={() => setShowPasskey(false)}
|
||||
/>
|
||||
<RecoveryCodeModal
|
||||
open={showRecovery}
|
||||
onClose={() => setShowRecovery(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import { IconFileText } from '@tabler/icons-react'
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
@@ -10,12 +13,30 @@ interface Post {
|
||||
status: string
|
||||
voteCount: number
|
||||
commentCount: number
|
||||
boardSlug: string
|
||||
boardName: string
|
||||
viewCount?: number
|
||||
board?: { slug: string; name: string } | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function PostSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="card p-4 flex items-center gap-4" style={{ opacity: 1 - i * 0.15 }}>
|
||||
<div className="flex-1">
|
||||
<div className="skeleton h-3 mb-2" style={{ width: '30%' }} />
|
||||
<div className="skeleton h-4 mb-2" style={{ width: '60%' }} />
|
||||
<div className="skeleton h-3" style={{ width: '40%' }} />
|
||||
</div>
|
||||
<div className="skeleton h-6 w-20 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MySubmissions() {
|
||||
useDocumentTitle('My Posts')
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -27,61 +48,63 @@ export default function MySubmissions() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1
|
||||
className="text-2xl font-bold mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
className="font-bold mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
My Posts
|
||||
</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="progress-bar mb-4" />
|
||||
<PostSkeleton />
|
||||
</>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sm mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
You have not submitted any posts yet
|
||||
</p>
|
||||
<Link to="/" className="btn btn-primary">Browse boards</Link>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={IconFileText}
|
||||
title="No posts yet"
|
||||
message="Your submitted posts will appear here"
|
||||
actionLabel="Browse boards"
|
||||
onAction={() => { window.location.href = '/' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{posts.map((post) => (
|
||||
{posts.map((post, i) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/b/${post.boardSlug}/post/${post.id}`}
|
||||
className="card p-4 flex items-center gap-4"
|
||||
to={`/b/${post.board?.slug}/post/${post.id}`}
|
||||
className="card card-interactive p-4 flex items-center gap-4 stagger-in"
|
||||
style={{ '--stagger': i } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{post.boardName}
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{post.board?.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded capitalize"
|
||||
className="px-1.5 py-0.5"
|
||||
style={{
|
||||
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
|
||||
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: post.type === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
|
||||
color: post.type === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{post.type}
|
||||
{post.type === 'BUG_REPORT' ? 'Bug' : 'Feature'}
|
||||
</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
<h2
|
||||
className="font-medium truncate"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
</h2>
|
||||
<div className="flex items-center gap-3 mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
<span>{post.voteCount} votes</span>
|
||||
<span>{post.commentCount} comments</span>
|
||||
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
|
||||
<time dateTime={post.createdAt}>{new Date(post.createdAt).toLocaleDateString()}</time>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={post.status} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface DataField {
|
||||
field: string
|
||||
@@ -9,13 +11,75 @@ interface DataField {
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
fields: DataField[]
|
||||
anonymousUser: DataField[]
|
||||
passkeyUser: DataField[]
|
||||
cookieInfo: string
|
||||
dataLocation: string
|
||||
thirdParties: string[]
|
||||
neverStored: string[]
|
||||
securityHeaders: Record<string, string>
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card card-static p-5 mb-5">
|
||||
<h2
|
||||
className="font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-base)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTable({ fields }: { fields: DataField[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{fields.map((f) => (
|
||||
<div key={f.field} className="p-3" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>{f.field}</span>
|
||||
{f.deletable && (
|
||||
<span
|
||||
className="px-1.5 py-0.5"
|
||||
style={{
|
||||
background: 'rgba(34, 197, 94, 0.12)',
|
||||
color: 'var(--success)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
deletable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>{f.purpose}</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>Retained: {f.retention}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivacySkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="card p-5" style={{ opacity: 1 - i * 0.15 }}>
|
||||
<div className="skeleton h-5 mb-3" style={{ width: '40%' }} />
|
||||
<div className="skeleton h-4 mb-2" style={{ width: '90%' }} />
|
||||
<div className="skeleton h-4 mb-2" style={{ width: '80%' }} />
|
||||
<div className="skeleton h-4" style={{ width: '60%' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
useDocumentTitle('Privacy')
|
||||
const [manifest, setManifest] = useState<Manifest | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -26,137 +90,153 @@ export default function PrivacyPage() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const body = (color: string) => ({ color, lineHeight: '1.7', fontSize: 'var(--text-sm)' as const })
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<div style={{ maxWidth: 680, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1
|
||||
className="text-2xl font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Privacy
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
Here is exactly what data this Echoboard instance collects and why.
|
||||
<p className="mb-8" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
This page is generated from the application's actual configuration. It cannot drift out of sync with reality.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
) : manifest ? (
|
||||
<>
|
||||
{/* Quick summary */}
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
The short version
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
No tracking scripts, no analytics, no third-party cookies
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
All data stays on this server - {manifest.dataLocation}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
No external fonts or resources loaded
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
You can delete everything at any time
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Cookie info */}
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Cookies
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{manifest.cookieInfo}
|
||||
<div className="progress-bar mb-4" />
|
||||
<PrivacySkeleton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Section title="How the cookie works">
|
||||
<p style={body('var(--text-secondary)')}>
|
||||
{manifest?.cookieInfo || 'This site uses exactly one cookie. It contains a random identifier used to connect you to your posts. It is a session cookie - it expires when you close your browser. It is not used for tracking, analytics, or advertising. No other cookies are set, ever.'}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
The cookie is <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>httpOnly</code> (JavaScript cannot read it), <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>Secure</code> (only sent over HTTPS), and <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>SameSite=Strict</code> (never sent on cross-origin requests). Dark mode preference is stored in localStorage, not as a cookie.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Data fields */}
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-4"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
What we store
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{manifest.fields.map((f) => (
|
||||
<div
|
||||
key={f.field}
|
||||
className="p-3 rounded-lg"
|
||||
style={{ background: 'var(--bg)' }}
|
||||
<Section title="How passkeys work">
|
||||
<p style={body('var(--text-secondary)')}>
|
||||
Passkeys use the WebAuthn standard. When you register, your device generates a public/private key pair. The private key stays on your device (or syncs via iCloud Keychain, Google Password Manager, Windows Hello). The server only stores the public key.
|
||||
</p>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
To log in, your device signs a challenge with the private key. The server verifies it against the stored public key. No email, no password, no personal data. Passkeys are phishing-resistant because they are bound to this domain and cannot be used on any other site.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="What we store - anonymous cookie users">
|
||||
{manifest?.anonymousUser ? (
|
||||
<FieldTable fields={manifest.anonymousUser} />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
<li>- Token hash (SHA-256, irreversible)</li>
|
||||
<li>- Display name (encrypted at rest)</li>
|
||||
<li>- Dark mode preference</li>
|
||||
<li>- Creation timestamp</li>
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="What we store - passkey users">
|
||||
{manifest?.passkeyUser ? (
|
||||
<FieldTable fields={manifest.passkeyUser} />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
<li>- Username (encrypted + blind indexed)</li>
|
||||
<li>- Display name (encrypted)</li>
|
||||
<li>- Passkey credential ID (encrypted + blind indexed)</li>
|
||||
<li>- Passkey public key (encrypted)</li>
|
||||
<li>- Passkey counter, device type, backup flag</li>
|
||||
<li>- Passkey transports (encrypted)</li>
|
||||
<li>- Dark mode preference</li>
|
||||
<li>- Creation timestamp</li>
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="What we never store">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(manifest?.neverStored || [
|
||||
'Email address', 'IP address', 'Browser fingerprint', 'User-agent string',
|
||||
'Referrer URL', 'Geolocation', 'Device identifiers', 'Behavioral data',
|
||||
'Session replays', 'Third-party tracking identifiers',
|
||||
]).map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="px-2 py-1 rounded-full"
|
||||
style={{ background: 'rgba(239, 68, 68, 0.1)', color: 'var(--error)', fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||
{f.field}
|
||||
</span>
|
||||
{f.deletable && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'rgba(34, 197, 94, 0.15)', color: 'var(--success)' }}>
|
||||
deletable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{f.purpose}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Retained: {f.retention}
|
||||
</p>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
IP addresses are used for rate limiting in memory only - they never touch the database. Request logs record method, path, and status code, but the IP is stripped before the log line is written.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="How encryption works">
|
||||
<p style={body('var(--text-secondary)')}>
|
||||
Every field that could identify a user is encrypted at rest using AES-256-GCM. This includes display names, usernames, passkey credentials, and push subscription endpoints. The anonymous token is stored as a one-way SHA-256 hash (irreversible, even stronger than encryption).
|
||||
</p>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
Posts, comments, vote counts, and board metadata are not encrypted because they are public content visible to every visitor. Encrypting them would add latency without a security benefit.
|
||||
</p>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
If someone gains access to the raw database, they see ciphertext blobs for identity fields and SHA-256 hashes for tokens. They cannot determine who wrote what, reconstruct display names, or extract passkey credentials.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Your data, your control">
|
||||
<p style={body('var(--text-secondary)')}>
|
||||
<strong style={{ color: 'var(--text)' }}>Export:</strong> Download everything this system holds about you as a JSON file from <Link to="/settings" style={{ color: 'var(--accent)' }}>your settings page</Link>. The file includes your posts, comments, votes, reactions, display name, and the data manifest.
|
||||
</p>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
<strong style={{ color: 'var(--text)' }}>Delete:</strong> One click deletes your identity. Your votes and reactions are removed, comments are anonymized to "[deleted]", posts become "[deleted by author]", and your user record is purged from the database. Not soft-deleted - actually removed.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Security headers in effect">
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(manifest?.securityHeaders || {
|
||||
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-src 'none'; object-src 'none'",
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
|
||||
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
}).map(([header, value]) => (
|
||||
<div key={header} className="p-2" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}>
|
||||
<span className="font-medium block" style={{ color: 'var(--text)', fontSize: 'var(--text-xs)' }}>{header}</span>
|
||||
<span className="break-all" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
No external domains are whitelisted. The client never contacts a third party.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Third parties */}
|
||||
{manifest.thirdParties.length > 0 && (
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Third parties
|
||||
</h2>
|
||||
<Section title="Third-party services">
|
||||
{manifest?.thirdParties && manifest.thirdParties.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{manifest.thirdParties.map((tp) => (
|
||||
<li key={tp} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
- {tp}
|
||||
</li>
|
||||
<li key={tp} style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>- {tp}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<p style={body('var(--text-secondary)')}>
|
||||
None. No external HTTP requests from the client, ever. No Google Fonts, no CDN scripts, no analytics, no tracking pixels. The only outbound requests the server makes are to external APIs configured by installed plugins (always your own infrastructure) and to push notification endpoints (browser-generated URLs).
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
) : (
|
||||
<div className="card p-5">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
This Echoboard instance is self-hosted and privacy-focused. No tracking, no external services, no data sharing. Your identity is cookie-based unless you register a passkey. You can delete all your data at any time from the settings page.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
259
packages/web/src/pages/ProfilePage.tsx
Normal file
259
packages/web/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import { IconUser, IconMessageCircle, IconArrowUp, IconFileText, IconHeart } from '@tabler/icons-react'
|
||||
import Avatar from '../components/Avatar'
|
||||
|
||||
interface ProfileData {
|
||||
id: string
|
||||
displayName: string | null
|
||||
username: string | null
|
||||
isPasskeyUser: boolean
|
||||
avatarUrl: string | null
|
||||
createdAt: string
|
||||
stats: {
|
||||
posts: number
|
||||
comments: number
|
||||
votesGiven: number
|
||||
votesReceived: number
|
||||
}
|
||||
votedPosts: {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
voteCount: number
|
||||
commentCount: number
|
||||
board: { slug: string; name: string } | null
|
||||
votedAt: string
|
||||
}[]
|
||||
}
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
status: string
|
||||
voteCount: number
|
||||
commentCount: number
|
||||
board?: { slug: string; name: string } | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
useDocumentTitle('Profile')
|
||||
const auth = useAuth()
|
||||
const [profile, setProfile] = useState<ProfileData | null>(null)
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [tab, setTab] = useState<'posts' | 'votes'>('posts')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get<ProfileData>('/me/profile'),
|
||||
api.get<{ posts: Post[] }>('/me/posts'),
|
||||
]).then(([p, myPosts]) => {
|
||||
setProfile(p)
|
||||
setPosts(myPosts.posts)
|
||||
}).catch(() => {}).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="progress-bar mb-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!profile) return null
|
||||
|
||||
const name = profile.displayName
|
||||
|| (profile.username ? `@${profile.username}` : `Anonymous #${profile.id.slice(-4)}`)
|
||||
|
||||
const stats = [
|
||||
{ icon: IconFileText, label: 'Posts', value: profile.stats.posts },
|
||||
{ icon: IconMessageCircle, label: 'Comments', value: profile.stats.comments },
|
||||
{ icon: IconArrowUp, label: 'Votes given', value: profile.stats.votesGiven },
|
||||
{ icon: IconHeart, label: 'Votes received', value: profile.stats.votesReceived },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
{/* Header */}
|
||||
<div className="card p-6 mb-6">
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
<Avatar
|
||||
userId={profile.id}
|
||||
name={profile.displayName}
|
||||
avatarUrl={profile.avatarUrl}
|
||||
size={56}
|
||||
/>
|
||||
<div>
|
||||
<h1
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-2xl)' }}
|
||||
>
|
||||
{name}
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
|
||||
Joined <time dateTime={profile.createdAt}>{new Date(profile.createdAt).toLocaleDateString()}</time>
|
||||
{profile.isPasskeyUser && ' - Passkey user'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3" style={{ maxWidth: 400 }}>
|
||||
{stats.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="flex items-center gap-3 p-3"
|
||||
style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}
|
||||
>
|
||||
<s.icon size={16} stroke={2} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ color: 'var(--text)', fontSize: 'var(--text-lg)', fontWeight: 700, lineHeight: 1 }}>
|
||||
{s.value}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div role="tablist" className="flex gap-1 mb-4" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
|
||||
{(['posts', 'votes'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
id={`tab-${t}`}
|
||||
role="tab"
|
||||
aria-selected={tab === t}
|
||||
aria-controls={`tabpanel-${t}`}
|
||||
onClick={() => setTab(t)}
|
||||
className="px-4 py-2"
|
||||
style={{
|
||||
color: tab === t ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
fontWeight: tab === t ? 600 : 400,
|
||||
fontSize: 'var(--text-sm)',
|
||||
borderBottom: tab === t ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
marginBottom: -1,
|
||||
transition: 'all 0.15s ease',
|
||||
minHeight: 44,
|
||||
}}
|
||||
>
|
||||
{t === 'posts' ? 'My Posts' : 'Voted On'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Posts tab */}
|
||||
{tab === 'posts' && (
|
||||
<div role="tabpanel" id="tabpanel-posts" aria-labelledby="tab-posts">
|
||||
{posts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={IconFileText}
|
||||
title="No posts yet"
|
||||
message="Your submitted posts will appear here"
|
||||
actionLabel="Browse boards"
|
||||
onAction={() => { window.location.href = '/' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{posts.map((post, i) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/b/${post.board?.slug}/post/${post.id}`}
|
||||
className="card card-interactive p-4 flex items-center gap-4 stagger-in"
|
||||
style={{ '--stagger': i } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{post.board?.name}
|
||||
</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: post.type === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
|
||||
color: post.type === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{post.type === 'BUG_REPORT' ? 'Bug' : 'Feature'}
|
||||
</span>
|
||||
</div>
|
||||
<h2
|
||||
className="font-medium truncate"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
{post.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3 mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
<span>{post.voteCount} votes</span>
|
||||
<span>{post.commentCount} comments</span>
|
||||
<time dateTime={post.createdAt}>{new Date(post.createdAt).toLocaleDateString()}</time>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={post.status} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Votes tab */}
|
||||
{tab === 'votes' && (
|
||||
<div role="tabpanel" id="tabpanel-votes" aria-labelledby="tab-votes">
|
||||
{profile.votedPosts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={IconArrowUp}
|
||||
title="No votes yet"
|
||||
message="Posts you vote on will appear here"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{profile.votedPosts.map((post, i) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/b/${post.board?.slug}/post/${post.id}`}
|
||||
className="card card-interactive p-4 flex items-center gap-4 stagger-in"
|
||||
style={{ '--stagger': i } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{post.board?.name}
|
||||
</span>
|
||||
</div>
|
||||
<h2
|
||||
className="font-medium truncate"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
{post.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3 mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
<span>{post.voteCount} votes</span>
|
||||
<span>{post.commentCount} comments</span>
|
||||
<time dateTime={post.votedAt}>Voted {new Date(post.votedAt).toLocaleDateString()}</time>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={post.status} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
packages/web/src/pages/RecoverPage.tsx
Normal file
124
packages/web/src/pages/RecoverPage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { IconShieldCheck } from '@tabler/icons-react'
|
||||
import { api } from '../lib/api'
|
||||
import { solveAltcha } from '../lib/altcha'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
|
||||
// extract and clear hash immediately at module load, before React renders
|
||||
let extractedPhrase = ''
|
||||
if (typeof window !== 'undefined' && window.location.pathname === '/recover') {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (hash && /^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/.test(hash)) {
|
||||
extractedPhrase = hash
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
}
|
||||
|
||||
export default function RecoverPage() {
|
||||
useDocumentTitle('Recover Identity')
|
||||
const navigate = useNavigate()
|
||||
const auth = useAuth()
|
||||
const [phrase, setPhrase] = useState(extractedPhrase)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleRecover = async () => {
|
||||
const clean = phrase.toLowerCase().trim()
|
||||
if (!/^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/.test(clean)) {
|
||||
setError('Enter a valid 6-word recovery phrase separated by dashes')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const altcha = await solveAltcha()
|
||||
await api.post('/auth/recover', { phrase: clean, altcha })
|
||||
setSuccess(true)
|
||||
auth.refresh()
|
||||
setTimeout(() => navigate('/settings'), 1500)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Invalid or expired recovery code')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ minHeight: 'calc(100vh - 120px)', padding: '24px' }}>
|
||||
<div
|
||||
className="w-full"
|
||||
style={{ maxWidth: 420 }}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 flex items-center justify-center mb-5"
|
||||
style={{
|
||||
background: 'var(--accent-subtle)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}
|
||||
>
|
||||
<IconShieldCheck size={24} stroke={2} style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-2xl)', color: 'var(--text)' }}
|
||||
>
|
||||
Recover your identity
|
||||
</h1>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
Enter your 6-word recovery phrase to get back to your posts and votes.
|
||||
</p>
|
||||
|
||||
{success ? (
|
||||
<div
|
||||
className="p-4"
|
||||
style={{
|
||||
background: 'rgba(34, 197, 94, 0.08)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.25)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--success)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
}}
|
||||
>
|
||||
Identity recovered. Redirecting to settings...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="input w-full mb-3 font-mono"
|
||||
placeholder="word-word-word-word-word-word"
|
||||
value={phrase}
|
||||
onChange={(e) => setPhrase(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRecover()}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
style={{ letterSpacing: '0.02em' }}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleRecover}
|
||||
disabled={loading || !phrase.trim()}
|
||||
className="btn btn-primary w-full"
|
||||
style={{ opacity: loading ? 0.6 : 1 }}
|
||||
>
|
||||
{loading ? 'Recovering...' : 'Recover identity'}
|
||||
</button>
|
||||
|
||||
<p className="mt-4" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', lineHeight: 1.5 }}>
|
||||
Don't have a recovery code? If your cookies were cleared and you didn't save a recovery phrase, your previous identity can't be restored. You can still create a new one.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
180
packages/web/src/pages/RoadmapPage.tsx
Normal file
180
packages/web/src/pages/RoadmapPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { IconMessage, IconTriangle } from '@tabler/icons-react'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import { api } from '../lib/api'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
|
||||
interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface RoadmapPost {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
status: string
|
||||
category?: string | null
|
||||
voteCount: number
|
||||
createdAt: string
|
||||
board: { slug: string; name: string }
|
||||
_count: { comments: number }
|
||||
tags?: Tag[]
|
||||
}
|
||||
|
||||
interface RoadmapData {
|
||||
columns: {
|
||||
PLANNED: RoadmapPost[]
|
||||
IN_PROGRESS: RoadmapPost[]
|
||||
DONE: RoadmapPost[]
|
||||
}
|
||||
}
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'PLANNED' as const, label: 'Planned' },
|
||||
{ key: 'IN_PROGRESS' as const, label: 'In Progress' },
|
||||
{ key: 'DONE' as const, label: 'Done' },
|
||||
]
|
||||
|
||||
export default function RoadmapPage() {
|
||||
const { boardSlug } = useParams()
|
||||
useDocumentTitle(boardSlug ? `${boardSlug} Roadmap` : 'Roadmap')
|
||||
const [data, setData] = useState<RoadmapData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const url = boardSlug ? `/b/${boardSlug}/roadmap` : '/roadmap'
|
||||
api.get<RoadmapData>(url)
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [boardSlug])
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="mb-6">
|
||||
<h1
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Roadmap
|
||||
</h1>
|
||||
<p className="mt-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
What we're working on and what's coming next
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i}>
|
||||
<div className="skeleton h-5 w-24 rounded mb-3" />
|
||||
{[0, 1, 2].map((j) => (
|
||||
<div key={j} className="card mb-2" style={{ padding: 16, opacity: 1 - j * 0.2 }}>
|
||||
<div className="skeleton h-4 w-3/4 rounded mb-2" />
|
||||
<div className="skeleton h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5" style={{ alignItems: 'start' }}>
|
||||
{COLUMNS.map((col) => {
|
||||
const posts = data.columns[col.key]
|
||||
return (
|
||||
<div key={col.key}>
|
||||
<h2 className="flex items-center gap-2 mb-3" style={{ fontSize: 'var(--text-sm)', fontWeight: 600 }}>
|
||||
<StatusBadge status={col.key} />
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 400 }}>
|
||||
{posts.length}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div
|
||||
className="text-center py-6"
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
border: '1px dashed var(--border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
}}
|
||||
>
|
||||
Nothing here yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/b/${post.board.slug}/post/${post.id}`}
|
||||
className="card roadmap-card"
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
display: 'block',
|
||||
color: 'var(--text)',
|
||||
transition: 'border-color var(--duration-fast) ease-out, box-shadow var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
<div className="font-medium mb-1.5" style={{ fontSize: 'var(--text-sm)', lineHeight: 1.4 }}>
|
||||
{post.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
|
||||
<span>{post.board.name}</span>
|
||||
{post.category && (
|
||||
<>
|
||||
<span>-</span>
|
||||
<span>{post.category}</span>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: post.type === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
|
||||
color: post.type === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{post.type === 'BUG_REPORT' ? 'Bug' : 'Feature'}
|
||||
</span>
|
||||
{post.tags?.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="px-1.5 py-0.5 rounded"
|
||||
style={{ background: `${tag.color}20`, color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
|
||||
<span className="inline-flex items-center gap-1" style={{ color: 'var(--accent)' }}>
|
||||
<IconTriangle size={11} stroke={2} />
|
||||
{post.voteCount}
|
||||
</span>
|
||||
{post._count.comments > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<IconMessage size={11} stroke={2} />
|
||||
{post._count.comments}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
Failed to load roadmap
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,31 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useFocusTrap } from '../../hooks/useFocusTrap'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
import NumberInput from '../../components/NumberInput'
|
||||
import IconPicker from '../../components/IconPicker'
|
||||
import BoardIcon from '../../components/BoardIcon'
|
||||
|
||||
interface Board {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
postCount: number
|
||||
archived: boolean
|
||||
_count?: { posts: number }
|
||||
isArchived: boolean
|
||||
iconName: string | null
|
||||
iconColor: string | null
|
||||
voteBudget: number
|
||||
voteResetSchedule: string
|
||||
voteBudgetReset: string
|
||||
rssEnabled: boolean
|
||||
rssFeedCount: number
|
||||
staleDays: number
|
||||
}
|
||||
|
||||
export default function AdminBoards() {
|
||||
useDocumentTitle('Manage Boards')
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editBoard, setEditBoard] = useState<Board | null>(null)
|
||||
@@ -22,10 +34,16 @@ export default function AdminBoards() {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
iconName: null as string | null,
|
||||
iconColor: null as string | null,
|
||||
voteBudget: 10,
|
||||
voteResetSchedule: 'monthly',
|
||||
voteBudgetReset: 'monthly',
|
||||
rssEnabled: true,
|
||||
rssFeedCount: 50,
|
||||
staleDays: 0,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const boardTrapRef = useFocusTrap(showCreate)
|
||||
|
||||
const fetchBoards = async () => {
|
||||
try {
|
||||
@@ -39,7 +57,7 @@ export default function AdminBoards() {
|
||||
useEffect(() => { fetchBoards() }, [])
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ name: '', slug: '', description: '', voteBudget: 10, voteResetSchedule: 'monthly' })
|
||||
setForm({ name: '', slug: '', description: '', iconName: null, iconColor: null, voteBudget: 10, voteBudgetReset: 'monthly', rssEnabled: true, rssFeedCount: 50, staleDays: 0 })
|
||||
setEditBoard(null)
|
||||
setShowCreate(false)
|
||||
}
|
||||
@@ -50,8 +68,13 @@ export default function AdminBoards() {
|
||||
name: b.name,
|
||||
slug: b.slug,
|
||||
description: b.description,
|
||||
iconName: b.iconName,
|
||||
iconColor: b.iconColor,
|
||||
voteBudget: b.voteBudget,
|
||||
voteResetSchedule: b.voteResetSchedule,
|
||||
voteBudgetReset: b.voteBudgetReset,
|
||||
rssEnabled: b.rssEnabled,
|
||||
rssFeedCount: b.rssFeedCount,
|
||||
staleDays: b.staleDays ?? 0,
|
||||
})
|
||||
setShowCreate(true)
|
||||
}
|
||||
@@ -71,9 +94,9 @@ export default function AdminBoards() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id: string, archived: boolean) => {
|
||||
const handleArchive = async (id: string, isArchived: boolean) => {
|
||||
try {
|
||||
await api.patch(`/admin/boards/${id}`, { archived: !archived })
|
||||
await api.put(`/admin/boards/${id}`, { isArchived: !isArchived })
|
||||
fetchBoards()
|
||||
} catch {}
|
||||
}
|
||||
@@ -81,11 +104,11 @@ export default function AdminBoards() {
|
||||
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="text-2xl font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Boards
|
||||
</h1>
|
||||
@@ -98,11 +121,14 @@ export default function AdminBoards() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="card p-4 flex items-center gap-4 mb-3" style={{ opacity: 1 - i * 0.2 }}>
|
||||
<div className="skeleton w-10 h-10 rounded-lg" />
|
||||
<div className="flex-1"><div className="skeleton h-4 mb-2" style={{ width: '40%' }} /><div className="skeleton h-3" style={{ width: '60%' }} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -110,43 +136,34 @@ export default function AdminBoards() {
|
||||
<div
|
||||
key={board.id}
|
||||
className="card p-4 flex items-center gap-4"
|
||||
style={{ opacity: board.archived ? 0.5 : 1 }}
|
||||
style={{ opacity: board.isArchived ? 0.5 : 1 }}
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-sm font-bold shrink-0"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
background: 'var(--admin-subtle)',
|
||||
color: 'var(--admin-accent)',
|
||||
}}
|
||||
>
|
||||
{board.name.charAt(0)}
|
||||
</div>
|
||||
<BoardIcon name={board.name} iconName={board.iconName} iconColor={board.iconColor} size={40} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
<h2 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
{board.name}
|
||||
</h3>
|
||||
{board.archived && (
|
||||
</h2>
|
||||
{board.isArchived && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}>
|
||||
archived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||
/{board.slug} - {board.postCount} posts - Budget: {board.voteBudget}/{board.voteResetSchedule}
|
||||
/{board.slug} - {board._count?.posts ?? 0} posts - Budget: {board.voteBudget}/{board.voteBudgetReset}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button onClick={() => openEdit(board)} className="btn btn-ghost text-xs px-2 py-1" style={{ color: 'var(--admin-accent)' }}>
|
||||
<button onClick={() => openEdit(board)} className="btn btn-ghost text-xs px-2" style={{ minHeight: 44, color: 'var(--admin-accent)' }}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleArchive(board.id, board.archived)}
|
||||
className="btn btn-ghost text-xs px-2 py-1"
|
||||
style={{ color: board.archived ? 'var(--success)' : 'var(--warning)' }}
|
||||
onClick={() => handleArchive(board.id, board.isArchived)}
|
||||
className="btn btn-ghost text-xs px-2"
|
||||
style={{ minHeight: 44, color: board.isArchived ? 'var(--success)' : 'var(--warning)' }}
|
||||
>
|
||||
{board.archived ? 'Restore' : 'Archive'}
|
||||
{board.isArchived ? 'Restore' : 'Archive'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,18 +186,24 @@ export default function AdminBoards() {
|
||||
>
|
||||
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||
<div
|
||||
className="relative w-full max-w-md mx-4 p-6 rounded-xl shadow-2xl slide-up"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
ref={boardTrapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="board-modal-title"
|
||||
className="relative w-full max-w-md mx-4 fade-in"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-xl)', boxShadow: 'var(--shadow-xl)', padding: '24px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.key === 'Escape' && resetForm()}
|
||||
>
|
||||
<h3 className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
|
||||
<h2 id="board-modal-title" className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
|
||||
{editBoard ? 'Edit Board' : 'New Board'}
|
||||
</h3>
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
|
||||
<label htmlFor="board-name" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
|
||||
<input
|
||||
id="board-name"
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
@@ -194,8 +217,9 @@ export default function AdminBoards() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
|
||||
<label htmlFor="board-slug" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
|
||||
<input
|
||||
id="board-slug"
|
||||
className="input"
|
||||
value={form.slug}
|
||||
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
||||
@@ -203,8 +227,9 @@ export default function AdminBoards() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
|
||||
<label htmlFor="board-description" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
|
||||
<textarea
|
||||
id="board-description"
|
||||
className="input"
|
||||
rows={2}
|
||||
value={form.description}
|
||||
@@ -213,30 +238,80 @@ export default function AdminBoards() {
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Icon</label>
|
||||
<IconPicker
|
||||
value={form.iconName}
|
||||
color={form.iconColor}
|
||||
onChangeIcon={(v) => setForm((f) => ({ ...f, iconName: v }))}
|
||||
onChangeColor={(v) => setForm((f) => ({ ...f, iconColor: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Vote Budget</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
<label htmlFor="board-vote-budget" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Vote Budget</label>
|
||||
<NumberInput
|
||||
value={form.voteBudget}
|
||||
onChange={(v) => setForm((f) => ({ ...f, voteBudget: v }))}
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.voteBudget}
|
||||
onChange={(e) => setForm((f) => ({ ...f, voteBudget: parseInt(e.target.value) || 10 }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Reset Schedule</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.voteResetSchedule}
|
||||
onChange={(e) => setForm((f) => ({ ...f, voteResetSchedule: e.target.value }))}
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="never">Never</option>
|
||||
</select>
|
||||
<label htmlFor="board-reset-schedule" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Reset Schedule</label>
|
||||
<Dropdown
|
||||
value={form.voteBudgetReset}
|
||||
onChange={(v) => setForm((f) => ({ ...f, voteBudgetReset: v }))}
|
||||
options={[
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'biweekly', label: 'Biweekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'quarterly', label: 'Quarterly' },
|
||||
{ value: 'per_release', label: 'Per release' },
|
||||
{ value: 'never', label: 'Never' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-3 mt-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.rssEnabled}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rssEnabled: e.target.checked }))}
|
||||
style={{ accentColor: 'var(--admin-accent)' }}
|
||||
/>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>RSS Feed</span>
|
||||
</label>
|
||||
{form.rssEnabled && (
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Items:</label>
|
||||
<NumberInput
|
||||
value={form.rssFeedCount}
|
||||
onChange={(v) => setForm((f) => ({ ...f, rssFeedCount: v }))}
|
||||
min={1}
|
||||
max={200}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="board-stale-days" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Stale after (days)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
value={form.staleDays}
|
||||
onChange={(v) => setForm((f) => ({ ...f, staleDays: v }))}
|
||||
min={0}
|
||||
max={365}
|
||||
step={7}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{form.staleDays === 0 ? 'Disabled' : `${form.staleDays} days`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
118
packages/web/src/pages/admin/AdminCategories.tsx
Normal file
118
packages/web/src/pages/admin/AdminCategories.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
|
||||
interface Category {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export default function AdminCategories() {
|
||||
useDocumentTitle('Categories')
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetch = () => {
|
||||
api.get<Category[]>('/categories')
|
||||
.then(setCategories)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(fetch, [])
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim() || !slug.trim()) return
|
||||
setError('')
|
||||
try {
|
||||
await api.post('/admin/categories', { name: name.trim(), slug: slug.trim() })
|
||||
setName('')
|
||||
setSlug('')
|
||||
fetch()
|
||||
} catch {
|
||||
setError('Failed to create category')
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/admin/categories/${id}`)
|
||||
fetch()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1
|
||||
className="font-bold mb-6"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Categories
|
||||
</h1>
|
||||
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Category name"
|
||||
aria-label="Category name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''))
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="input w-40"
|
||||
placeholder="slug"
|
||||
aria-label="Category slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
<button onClick={create} className="btn btn-admin">Add</button>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
|
||||
<div className="skeleton h-4" style={{ width: '40%' }} />
|
||||
<div className="skeleton h-4 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No categories yet</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{cat.name}</span>
|
||||
<span className="text-xs ml-2" style={{ color: 'var(--text-tertiary)' }}>{cat.slug}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => remove(cat.id)}
|
||||
className="text-xs px-2 rounded"
|
||||
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
packages/web/src/pages/admin/AdminChangelog.tsx
Normal file
237
packages/web/src/pages/admin/AdminChangelog.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { IconPlus, IconTrash, IconPencil } from '@tabler/icons-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import MarkdownEditor from '../../components/MarkdownEditor'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
|
||||
interface Board {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
boardId: string | null
|
||||
board: Board | null
|
||||
publishedAt: string
|
||||
}
|
||||
|
||||
export default function AdminChangelog() {
|
||||
useDocumentTitle('Manage Changelog')
|
||||
const confirm = useConfirm()
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [boardId, setBoardId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [publishAt, setPublishAt] = useState('')
|
||||
|
||||
const fetchEntries = () => {
|
||||
api.get<{ entries: Entry[] }>('/admin/changelog')
|
||||
.then((r) => setEntries(r.entries))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(fetchEntries, [])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Board[]>('/admin/boards')
|
||||
.then((r) => setBoards(Array.isArray(r) ? r : []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const save = async () => {
|
||||
if (!title.trim() || !body.trim()) return
|
||||
setError('')
|
||||
try {
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
body: body.trim(),
|
||||
boardId: boardId || null,
|
||||
...(publishAt && { publishedAt: new Date(publishAt).toISOString() }),
|
||||
}
|
||||
if (editId) {
|
||||
await api.put(`/admin/changelog/${editId}`, payload)
|
||||
} else {
|
||||
await api.post('/admin/changelog', payload)
|
||||
}
|
||||
setTitle('')
|
||||
setBody('')
|
||||
setBoardId('')
|
||||
setPublishAt('')
|
||||
setEditId(null)
|
||||
setShowForm(false)
|
||||
fetchEntries()
|
||||
} catch {
|
||||
setError('Failed to save entry')
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: string) => {
|
||||
if (!await confirm('Delete this changelog entry?')) return
|
||||
try {
|
||||
await api.delete(`/admin/changelog/${id}`)
|
||||
fetchEntries()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const startEdit = (entry: Entry) => {
|
||||
setEditId(entry.id)
|
||||
setTitle(entry.title)
|
||||
setBody(entry.body)
|
||||
setBoardId(entry.boardId || '')
|
||||
const d = new Date(entry.publishedAt)
|
||||
setPublishAt(d > new Date() ? d.toISOString().slice(0, 16) : '')
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Changelog
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { setShowForm(!showForm); setEditId(null); setTitle(''); setBody(''); setBoardId(''); setPublishAt('') }}
|
||||
className="btn btn-admin flex items-center gap-1"
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
<IconPlus size={14} stroke={2} />
|
||||
New entry
|
||||
</button>
|
||||
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="mb-3">
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Title</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
placeholder="What changed?"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Board</label>
|
||||
<Dropdown
|
||||
value={boardId}
|
||||
onChange={setBoardId}
|
||||
placeholder="All boards (global)"
|
||||
options={[
|
||||
{ value: '', label: 'All boards (global)' },
|
||||
...boards.map((b) => ({ value: b.id, label: b.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="changelog-publish-at" style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, marginBottom: 4 }}>
|
||||
Publish date
|
||||
</label>
|
||||
<input
|
||||
id="changelog-publish-at"
|
||||
type="datetime-local"
|
||||
className="input w-full"
|
||||
value={publishAt}
|
||||
onChange={(e) => setPublishAt(e.target.value)}
|
||||
/>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 4 }}>
|
||||
Leave empty to publish immediately
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Body</label>
|
||||
<MarkdownEditor
|
||||
value={body}
|
||||
onChange={(v) => setBody(v.slice(0, 10000))}
|
||||
placeholder="Describe the changes..."
|
||||
rows={8}
|
||||
preview
|
||||
/>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-xs mb-2" style={{ color: 'var(--error)' }}>{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={save} className="btn btn-admin">{editId ? 'Update' : 'Publish'}</button>
|
||||
<button onClick={() => { setShowForm(false); setEditId(null) }} className="btn btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
|
||||
<div className="skeleton h-4" style={{ width: '60%' }} />
|
||||
<div className="skeleton h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No changelog entries yet</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-2">
|
||||
<div className="font-medium truncate flex items-center" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{entry.title}
|
||||
{new Date(entry.publishedAt) > new Date() && (
|
||||
<span className="px-1.5 py-0.5 rounded font-medium ml-2 shrink-0" style={{ background: 'rgba(96, 165, 250, 0.15)', color: '#93C5FD', fontSize: 'var(--text-xs)', border: '1px solid rgba(96, 165, 250, 0.25)' }}>
|
||||
Scheduled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs truncate" style={{ color: 'var(--text-tertiary)', marginTop: 2 }}>
|
||||
<time dateTime={entry.publishedAt}>{new Date(entry.publishedAt).toLocaleDateString()}</time>
|
||||
{entry.board ? ` - ${entry.board.name}` : ' - Global'}
|
||||
{' - '}{entry.body.slice(0, 60)}...
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(entry)}
|
||||
className="btn btn-ghost text-xs px-2"
|
||||
style={{ minHeight: 44, color: 'var(--admin-accent)' }}
|
||||
aria-label={`Edit ${entry.title}`}
|
||||
>
|
||||
<IconPencil size={14} stroke={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(entry.id)}
|
||||
className="btn btn-ghost text-xs px-2"
|
||||
style={{ minHeight: 44, color: 'var(--error)' }}
|
||||
aria-label={`Delete ${entry.title}`}
|
||||
>
|
||||
<IconTrash size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { IconChevronRight, IconFileText, IconLayoutGrid, IconTag, IconTrash } from '@tabler/icons-react'
|
||||
import type { Icon } from '@tabler/icons-react'
|
||||
|
||||
interface Stats {
|
||||
totalPosts: number
|
||||
byStatus: Record<string, number>
|
||||
thisWeek: number
|
||||
topUnresolved: { id: string; title: string; voteCount: number; boardSlug: string }[]
|
||||
authMethodRatio?: Record<string, number>
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
useDocumentTitle('Admin')
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -29,68 +34,157 @@ export default function AdminDashboard() {
|
||||
{ label: 'Declined', value: stats.byStatus['DECLINED'] || 0, color: 'var(--error)' },
|
||||
] : []
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/admin/posts', label: 'Manage Posts', desc: 'View, filter, and respond to all posts' },
|
||||
{ to: '/admin/boards', label: 'Manage Boards', desc: 'Create, edit, and archive feedback boards' },
|
||||
const navLinks: { to: string; label: string; desc: string; icon: Icon }[] = [
|
||||
{ to: '/admin/posts', label: 'Manage Posts', desc: 'View, filter, and respond to all posts', icon: IconFileText },
|
||||
{ to: '/admin/boards', label: 'Manage Boards', desc: 'Create, edit, and archive feedback boards', icon: IconLayoutGrid },
|
||||
{ to: '/admin/categories', label: 'Categories', desc: 'Add or remove post categories', icon: IconTag },
|
||||
{ to: '/admin/data-retention', label: 'Data Retention', desc: 'Cleanup schedules and upcoming counts', icon: IconTrash },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
<div aria-live="polite" aria-busy="true" style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="progress-bar mb-6" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-8">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="card p-4" style={{ opacity: 1 - i * 0.1 }}>
|
||||
<div className="skeleton h-8 mb-2" style={{ width: '40%' }} />
|
||||
<div className="skeleton h-3" style={{ width: '60%' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1
|
||||
className="text-2xl font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Dashboard
|
||||
</h1>
|
||||
<Link to="/" className="btn btn-ghost text-sm">
|
||||
<Link to="/" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
View public site
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-8">
|
||||
{statCards.map((s) => (
|
||||
<div key={s.label} className="card p-4 fade-in">
|
||||
<div className="text-2xl font-bold mb-1" style={{ fontFamily: 'var(--font-heading)', color: s.color }}>
|
||||
{statCards.map((s, i) => (
|
||||
<div key={s.label} className="card card-static p-4 stagger-in" style={{ '--stagger': i } as React.CSSProperties}>
|
||||
<div className="font-bold mb-1" style={{ fontFamily: 'var(--font-heading)', color: s.color, fontSize: 'var(--text-xl)' }}>
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Posts by status bar chart */}
|
||||
{stats && stats.totalPosts > 0 && (
|
||||
<div className="card p-5 mb-8">
|
||||
<h2
|
||||
className="text-sm font-semibold mb-4"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Posts by status
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ key: 'OPEN', label: 'Open', color: 'var(--warning)' },
|
||||
{ key: 'UNDER_REVIEW', label: 'Under review', color: 'var(--info)' },
|
||||
{ key: 'PLANNED', label: 'Planned', color: 'var(--admin-accent)' },
|
||||
{ key: 'IN_PROGRESS', label: 'In progress', color: 'var(--accent)' },
|
||||
{ key: 'DONE', label: 'Done', color: 'var(--success)' },
|
||||
{ key: 'DECLINED', label: 'Declined', color: 'var(--error)' },
|
||||
].map((s) => {
|
||||
const count = stats.byStatus[s.key] || 0
|
||||
const pct = stats.totalPosts > 0 ? (count / stats.totalPosts) * 100 : 0
|
||||
return (
|
||||
<div key={s.key} className="flex items-center gap-3">
|
||||
<span className="text-xs w-24 shrink-0" style={{ color: 'var(--text-secondary)' }}>{s.label}</span>
|
||||
<div className="flex-1 h-5 rounded-sm overflow-hidden" style={{ background: 'var(--bg)' }}>
|
||||
<div
|
||||
className="h-full rounded-sm"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
background: s.color,
|
||||
transition: 'width 500ms ease-out',
|
||||
minWidth: count > 0 ? 4 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs w-8 text-right font-medium" style={{ color: 'var(--text-tertiary)' }}>{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auth method ratio */}
|
||||
{stats?.authMethodRatio && Object.keys(stats.authMethodRatio).length > 0 && (() => {
|
||||
const total = Object.values(stats.authMethodRatio!).reduce((a, b) => a + b, 0)
|
||||
return (
|
||||
<div className="card p-5 mb-8">
|
||||
<h2
|
||||
className="text-sm font-semibold mb-4"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
User identity methods
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
{Object.entries(stats.authMethodRatio!).map(([method, count]) => (
|
||||
<div key={method} className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{method === 'PASSKEY' ? 'Passkey' : 'Cookie'}
|
||||
</span>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{count} ({total > 0 ? Math.round((count / total) * 100) : 0}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-sm overflow-hidden" style={{ background: 'var(--bg)' }}>
|
||||
<div
|
||||
className="h-full rounded-sm"
|
||||
style={{
|
||||
width: `${total > 0 ? (count / total) * 100 : 0}%`,
|
||||
background: method === 'PASSKEY' ? 'var(--success)' : 'var(--accent)',
|
||||
transition: 'width 500ms ease-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Nav links */}
|
||||
<div className="grid md:grid-cols-2 gap-3 mb-8">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="card p-5 block group">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3
|
||||
className="text-sm font-semibold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
>
|
||||
{link.label}
|
||||
</h3>
|
||||
<svg
|
||||
width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
<div className="flex items-center gap-2">
|
||||
<link.icon size={16} stroke={2} style={{ color: 'var(--admin-accent)' }} />
|
||||
<h2
|
||||
className="text-sm font-semibold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
>
|
||||
{link.label}
|
||||
</h2>
|
||||
</div>
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
stroke={2}
|
||||
style={{ color: 'var(--text-tertiary)', transition: 'transform 200ms ease-out' }}
|
||||
className="group-hover:translate-x-0.5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{link.desc}</p>
|
||||
</Link>
|
||||
@@ -115,6 +209,8 @@ export default function AdminDashboard() {
|
||||
style={{ transition: 'background 200ms ease-out' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
onFocus={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
|
||||
onBlur={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-semibold w-8 text-center"
|
||||
|
||||
111
packages/web/src/pages/admin/AdminDataRetention.tsx
Normal file
111
packages/web/src/pages/admin/AdminDataRetention.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
|
||||
interface RetentionData {
|
||||
activityRetentionDays: number
|
||||
orphanRetentionDays: number
|
||||
staleActivityEvents: number
|
||||
orphanedUsers: number
|
||||
}
|
||||
|
||||
export default function AdminDataRetention() {
|
||||
useDocumentTitle('Data Retention')
|
||||
const [data, setData] = useState<RetentionData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<RetentionData>('/admin/data-retention')
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="progress-bar mb-6" />
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="card p-5 mb-4" style={{ opacity: 1 - i * 0.15 }}>
|
||||
<div className="skeleton h-4 mb-2" style={{ width: '50%' }} />
|
||||
<div className="skeleton h-3 mb-2" style={{ width: '80%' }} />
|
||||
<div className="skeleton h-3" style={{ width: '40%' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<p className="text-sm" style={{ color: 'var(--error)' }}>Failed to load retention data</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: 'Activity events',
|
||||
window: `${data.activityRetentionDays} days`,
|
||||
pending: data.staleActivityEvents,
|
||||
desc: 'Activity feed entries older than the retention window are pruned daily at 3:00 AM.',
|
||||
},
|
||||
{
|
||||
label: 'Orphaned anonymous users',
|
||||
window: `${data.orphanRetentionDays} days`,
|
||||
pending: data.orphanedUsers,
|
||||
desc: 'Cookie-based users with no posts, comments, or votes after the retention window are pruned daily at 4:00 AM.',
|
||||
},
|
||||
{
|
||||
label: 'Failed push subscriptions',
|
||||
window: '3 consecutive failures',
|
||||
pending: null,
|
||||
desc: 'Push subscriptions that fail delivery 3 times are removed daily at 5:00 AM.',
|
||||
},
|
||||
{
|
||||
label: 'WebAuthn challenges',
|
||||
window: '60 seconds',
|
||||
pending: null,
|
||||
desc: 'Expired registration and login challenges are cleaned every minute.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Data Retention
|
||||
</h1>
|
||||
<p className="mb-8" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
Automated cleanup schedules and upcoming counts
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="card p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-sm font-semibold" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
{item.label}
|
||||
</h2>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)' }}
|
||||
>
|
||||
{item.window}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{item.desc}</p>
|
||||
{item.pending !== null && (
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className="font-medium" style={{ color: 'var(--accent)' }}>{item.pending}</span> records scheduled for next cleanup
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
263
packages/web/src/pages/admin/AdminEmbed.tsx
Normal file
263
packages/web/src/pages/admin/AdminEmbed.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { IconCode, IconCopy, IconCheck } from '@tabler/icons-react'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
import NumberInput from '../../components/NumberInput'
|
||||
|
||||
interface Board {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function AdminEmbed() {
|
||||
useDocumentTitle('Embed')
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [selectedBoard, setSelectedBoard] = useState('')
|
||||
const [theme, setTheme] = useState('dark')
|
||||
const [limit, setLimit] = useState('10')
|
||||
const [sort, setSort] = useState('top')
|
||||
const [height, setHeight] = useState(500)
|
||||
const [mode, setMode] = useState('inline')
|
||||
const [label, setLabel] = useState('Feedback')
|
||||
const [position, setPosition] = useState('right')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Board[]>('/admin/boards').then((b) => {
|
||||
setBoards(b)
|
||||
if (b.length > 0) setSelectedBoard(b[0].slug)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const origin = window.location.origin
|
||||
const attrs = [
|
||||
'data-echoboard',
|
||||
`data-board="${selectedBoard}"`,
|
||||
`data-theme="${theme}"`,
|
||||
`data-limit="${limit}"`,
|
||||
`data-sort="${sort}"`,
|
||||
`data-height="${height}"`,
|
||||
]
|
||||
if (mode === 'button') {
|
||||
attrs.push(`data-mode="button"`)
|
||||
attrs.push(`data-label="${label}"`)
|
||||
attrs.push(`data-position="${position}"`)
|
||||
}
|
||||
attrs.push(`src="${origin}/embed.js"`)
|
||||
const snippet = `<script ${attrs.join(' ')}><\/script>`
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(snippet).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto px-6 py-10" style={{ maxWidth: 'var(--content-max)' }}>
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)' }}
|
||||
>
|
||||
<IconCode size={20} stroke={2} style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-2xl)', fontWeight: 700, color: 'var(--text)' }}>
|
||||
Embed Widget
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
Add a feedback widget to any website
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="font-semibold mb-4" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
|
||||
Configure
|
||||
</h2>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))' }}>
|
||||
<div>
|
||||
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Widget Type</label>
|
||||
<Dropdown
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: 'inline', label: 'Inline embed' },
|
||||
{ value: 'button', label: 'Floating button' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Board</label>
|
||||
<Dropdown
|
||||
value={selectedBoard}
|
||||
onChange={setSelectedBoard}
|
||||
options={boards.map((b) => ({ value: b.slug, label: b.name }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Theme</label>
|
||||
<Dropdown
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Sort</label>
|
||||
<Dropdown
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={[
|
||||
{ value: 'top', label: 'Most voted' },
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Posts</label>
|
||||
<Dropdown
|
||||
value={limit}
|
||||
onChange={setLimit}
|
||||
options={[
|
||||
{ value: '5', label: '5 posts' },
|
||||
{ value: '10', label: '10 posts' },
|
||||
{ value: '15', label: '15 posts' },
|
||||
{ value: '20', label: '20 posts' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Height (px)</label>
|
||||
<NumberInput
|
||||
value={height}
|
||||
onChange={setHeight}
|
||||
min={200}
|
||||
max={2000}
|
||||
step={50}
|
||||
/>
|
||||
</div>
|
||||
{mode === 'button' && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="embed-button-label" className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Button Label</label>
|
||||
<input
|
||||
id="embed-button-label"
|
||||
className="input"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Feedback"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Position</label>
|
||||
<Dropdown
|
||||
value={position}
|
||||
onChange={setPosition}
|
||||
options={[
|
||||
{ value: 'right', label: 'Bottom right' },
|
||||
{ value: 'left', label: 'Bottom left' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snippet */}
|
||||
<div className="card p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
|
||||
Embed code
|
||||
</h2>
|
||||
<button onClick={handleCopy} className="btn btn-secondary flex items-center gap-2">
|
||||
{copied ? <IconCheck size={14} stroke={2} /> : <IconCopy size={14} stroke={2} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{snippet}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="card p-6">
|
||||
<h2 className="font-semibold mb-3" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
|
||||
Preview
|
||||
</h2>
|
||||
{mode === 'button' ? (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: `${Math.min(height, 500)}px`,
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
Simulated page content
|
||||
</div>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
bottom: 16,
|
||||
[position === 'left' ? 'left' : 'right']: 16,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderRadius: 24,
|
||||
cursor: 'default',
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
background: 'var(--accent)',
|
||||
color: '#161616',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedBoard ? (
|
||||
<iframe
|
||||
src={`/embed/${selectedBoard}?theme=${theme}&limit=${limit}&sort=${sort}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: `${height}px`,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}
|
||||
title="Widget preview"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
packages/web/src/pages/admin/AdminExport.tsx
Normal file
114
packages/web/src/pages/admin/AdminExport.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react'
|
||||
import { IconDownload } from '@tabler/icons-react'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
|
||||
const types = [
|
||||
{ value: 'all', label: 'All Data' },
|
||||
{ value: 'posts', label: 'Posts' },
|
||||
{ value: 'votes', label: 'Votes' },
|
||||
{ value: 'comments', label: 'Comments' },
|
||||
{ value: 'users', label: 'Users' },
|
||||
]
|
||||
|
||||
const formats = [
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'csv', label: 'CSV' },
|
||||
]
|
||||
|
||||
export default function AdminExport() {
|
||||
useDocumentTitle('Export')
|
||||
const [type, setType] = useState('all')
|
||||
const [format, setFormat] = useState('json')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function doExport() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/v1/admin/export?format=${format}&type=${type}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Export failed')
|
||||
|
||||
const blob = await res.blob()
|
||||
const ext = format === 'csv' ? 'csv' : 'json'
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `echoboard-${type}.${ext}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1
|
||||
className="font-bold mb-8"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Export Data
|
||||
</h1>
|
||||
|
||||
<div className="card p-6" style={{ maxWidth: 480 }}>
|
||||
<div className="mb-5">
|
||||
<label htmlFor="export-type" className="block mb-2 text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
Data type
|
||||
</label>
|
||||
<select
|
||||
id="export-type"
|
||||
className="input w-full"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
{types.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block mb-2 text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
Format
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{formats.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
className="btn btn-ghost flex-1"
|
||||
style={{
|
||||
background: format === f.value ? 'var(--admin-subtle)' : undefined,
|
||||
color: format === f.value ? 'var(--admin-accent)' : 'var(--text-secondary)',
|
||||
fontWeight: format === f.value ? 600 : 400,
|
||||
border: format === f.value ? '1px solid var(--admin-accent)' : '1px solid var(--border)',
|
||||
}}
|
||||
onClick={() => setFormat(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn w-full flex items-center justify-center gap-2"
|
||||
style={{
|
||||
background: 'var(--admin-accent)',
|
||||
color: '#fff',
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}
|
||||
onClick={doExport}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconDownload size={16} stroke={2} />
|
||||
{loading ? 'Exporting...' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
233
packages/web/src/pages/admin/AdminJoin.tsx
Normal file
233
packages/web/src/pages/admin/AdminJoin.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useBranding } from '../../hooks/useBranding'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
|
||||
interface InviteInfo {
|
||||
role: string
|
||||
invitedBy: string | null
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
ADMIN: 'Admin',
|
||||
MODERATOR: 'Moderator',
|
||||
}
|
||||
|
||||
export default function AdminJoin() {
|
||||
useDocumentTitle('Join Team')
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const nav = useNavigate()
|
||||
const { appName } = useBranding()
|
||||
|
||||
const [info, setInfo] = useState<InviteInfo | null>(null)
|
||||
const [invalid, setInvalid] = useState(false)
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [teamTitle, setTeamTitle] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { setInvalid(true); return }
|
||||
api.get<InviteInfo>(`/admin/join/${token}`)
|
||||
.then(setInfo)
|
||||
.catch(() => setInvalid(true))
|
||||
}, [token])
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!displayName.trim() || !token) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const res = await api.post<{ needsSetup?: boolean }>(`/admin/join/${token}`, {
|
||||
displayName: displayName.trim(),
|
||||
teamTitle: teamTitle.trim() || undefined,
|
||||
})
|
||||
setSuccess(true)
|
||||
if (res.needsSetup) {
|
||||
setTimeout(() => nav('/admin'), 4000)
|
||||
} else {
|
||||
setTimeout(() => nav('/admin'), 2000)
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to join. The invite may have expired or already been used.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (invalid) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
|
||||
<div
|
||||
className="w-full max-w-sm fade-in text-center"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
padding: '28px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)', fontSize: 'var(--text-xl)' }}
|
||||
>
|
||||
Invalid invite
|
||||
</h1>
|
||||
<p role="alert" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.5 }}>
|
||||
This invite link is invalid, expired, or has already been used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
|
||||
<div className="progress-bar" style={{ width: 200 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
|
||||
<div
|
||||
className="w-full max-w-sm fade-in text-center"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
padding: '28px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-xl)' }}
|
||||
>
|
||||
Welcome to the team!
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.5 }}>
|
||||
You can register a passkey or save a recovery phrase in your settings to secure your account. Redirecting to admin panel...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
|
||||
<div
|
||||
className="w-full max-w-sm fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
padding: '28px',
|
||||
}}
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<h1
|
||||
className="font-bold mb-1"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-xl)' }}
|
||||
>
|
||||
Join Team
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{appName} administration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-3 mb-5"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>Role</span>
|
||||
<span
|
||||
className="font-medium px-2 py-0.5 rounded"
|
||||
style={{
|
||||
color: 'var(--admin-accent)',
|
||||
background: 'var(--admin-subtle)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{ROLE_LABELS[info.role] ?? info.role}
|
||||
</span>
|
||||
</div>
|
||||
{info.invitedBy && (
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>Invited by</span>
|
||||
<span style={{ color: 'var(--text)' }}>{info.invitedBy}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>Expires</span>
|
||||
<span style={{ color: 'var(--text)' }}>{new Date(info.expiresAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label htmlFor="join-name" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||
Display name <span style={{ color: 'var(--error)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="join-name"
|
||||
className="input"
|
||||
placeholder="Your name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="join-title" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||
Team title (optional)
|
||||
</label>
|
||||
<input
|
||||
id="join-title"
|
||||
className="input"
|
||||
placeholder="e.g. Product Manager"
|
||||
value={teamTitle}
|
||||
onChange={(e) => setTeamTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !displayName.trim()}
|
||||
className="btn w-full mt-1"
|
||||
style={{
|
||||
background: 'var(--admin-accent)',
|
||||
color: '#141420',
|
||||
opacity: loading || !displayName.trim() ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? 'Joining...' : 'Join team'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useBranding } from '../../hooks/useBranding'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { api } from '../../lib/api'
|
||||
|
||||
export default function AdminLogin() {
|
||||
useDocumentTitle('Admin Login')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const nav = useNavigate()
|
||||
const { appName } = useBranding()
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -28,43 +32,61 @@ export default function AdminLogin() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
|
||||
<div
|
||||
className="w-full max-w-sm p-6 rounded-xl shadow-xl fade-in"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
className="w-full max-w-sm fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
padding: '28px',
|
||||
}}
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<h1
|
||||
className="text-xl font-bold mb-1"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||
className="font-bold mb-1"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-xl)' }}
|
||||
>
|
||||
Admin Login
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Echoboard administration
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{appName} administration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
style={{ borderColor: error ? 'var(--error)' : undefined }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
style={{ borderColor: error ? 'var(--error)' : undefined }}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="admin-email" className="sr-only">Email</label>
|
||||
<input
|
||||
id="admin-email"
|
||||
className="input"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? 'admin-login-error' : undefined}
|
||||
style={{ borderColor: error ? 'var(--error)' : undefined }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="admin-password" className="sr-only">Password</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? 'admin-login-error' : undefined}
|
||||
style={{ borderColor: error ? 'var(--error)' : undefined }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>
|
||||
<p id="admin-login-error" role="alert" className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
200
packages/web/src/pages/admin/AdminSettings.tsx
Normal file
200
packages/web/src/pages/admin/AdminSettings.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
|
||||
interface SiteSettings {
|
||||
appName: string
|
||||
logoUrl: string | null
|
||||
faviconUrl: string | null
|
||||
accentColor: string
|
||||
headerFont: string | null
|
||||
bodyFont: string | null
|
||||
poweredByVisible: boolean
|
||||
customCss: string | null
|
||||
}
|
||||
|
||||
const defaults: SiteSettings = {
|
||||
appName: 'Echoboard',
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
accentColor: '#F59E0B',
|
||||
headerFont: null,
|
||||
bodyFont: null,
|
||||
poweredByVisible: true,
|
||||
customCss: null,
|
||||
}
|
||||
|
||||
export default function AdminSettings() {
|
||||
useDocumentTitle('Branding')
|
||||
const [form, setForm] = useState<SiteSettings>(defaults)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<SiteSettings>('/admin/site-settings')
|
||||
.then(data => setForm({ ...defaults, ...data }))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
try {
|
||||
await api.put('/admin/site-settings', form)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch {} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Branding
|
||||
</h1>
|
||||
<Link to="/admin" className="btn btn-ghost text-sm">Back</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="card p-4 mb-3" style={{ opacity: 1 - i * 0.2 }}>
|
||||
<div className="skeleton h-4 mb-2" style={{ width: '30%' }} />
|
||||
<div className="skeleton h-10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="settings-app-name" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>App name</label>
|
||||
<input
|
||||
id="settings-app-name"
|
||||
className="input"
|
||||
value={form.appName}
|
||||
onChange={e => setForm(f => ({ ...f, appName: e.target.value }))}
|
||||
placeholder="Echoboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="settings-logo-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Logo URL</label>
|
||||
<input
|
||||
id="settings-logo-url"
|
||||
className="input"
|
||||
value={form.logoUrl || ''}
|
||||
onChange={e => setForm(f => ({ ...f, logoUrl: e.target.value || null }))}
|
||||
placeholder="https://example.com/logo.svg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="settings-favicon-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Favicon URL</label>
|
||||
<input
|
||||
id="settings-favicon-url"
|
||||
className="input"
|
||||
value={form.faviconUrl || ''}
|
||||
onChange={e => setForm(f => ({ ...f, faviconUrl: e.target.value || null }))}
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="settings-accent-color" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Accent color</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="settings-accent-picker"
|
||||
type="color"
|
||||
value={form.accentColor}
|
||||
onChange={e => setForm(f => ({ ...f, accentColor: e.target.value }))}
|
||||
aria-label="Accent color picker"
|
||||
style={{ width: 40, height: 40, padding: 0, border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer' }}
|
||||
/>
|
||||
<input
|
||||
id="settings-accent-color"
|
||||
className="input"
|
||||
value={form.accentColor}
|
||||
onChange={e => setForm(f => ({ ...f, accentColor: e.target.value }))}
|
||||
placeholder="#F59E0B"
|
||||
style={{ maxWidth: 140 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="settings-header-font" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Header font</label>
|
||||
<input
|
||||
id="settings-header-font"
|
||||
className="input"
|
||||
value={form.headerFont || ''}
|
||||
onChange={e => setForm(f => ({ ...f, headerFont: e.target.value || null }))}
|
||||
placeholder="Space Grotesk"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="settings-body-font" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Body font</label>
|
||||
<input
|
||||
id="settings-body-font"
|
||||
className="input"
|
||||
value={form.bodyFont || ''}
|
||||
onChange={e => setForm(f => ({ ...f, bodyFont: e.target.value || null }))}
|
||||
placeholder="Sora"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.poweredByVisible}
|
||||
onChange={e => setForm(f => ({ ...f, poweredByVisible: e.target.checked }))}
|
||||
style={{ accentColor: 'var(--admin-accent)' }}
|
||||
/>
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
Show "Powered by" in embeds
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label htmlFor="settings-custom-css" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Custom CSS</label>
|
||||
<textarea
|
||||
id="settings-custom-css"
|
||||
className="input"
|
||||
rows={5}
|
||||
value={form.customCss || ''}
|
||||
onChange={e => setForm(f => ({ ...f, customCss: e.target.value || null }))}
|
||||
placeholder=":root { --accent: #3B82F6; }"
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: 'var(--text-xs)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-6">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn btn-admin"
|
||||
style={{ opacity: saving ? 0.6 : 1 }}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{saved && (
|
||||
<span role="status" className="text-xs fade-in" style={{ color: 'var(--success)' }}>Saved</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
495
packages/web/src/pages/admin/AdminStatuses.tsx
Normal file
495
packages/web/src/pages/admin/AdminStatuses.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { IconPalette, IconGripVertical, IconCheck, IconPlus, IconTrash } from '@tabler/icons-react'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
|
||||
interface Board {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface StatusEntry {
|
||||
status: string
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
}
|
||||
|
||||
const SUGGESTION_COLORS = [
|
||||
'#F59E0B', '#06B6D4', '#3B82F6', '#EAB308', '#22C55E',
|
||||
'#EF4444', '#8B5CF6', '#EC4899', '#F97316', '#64748B',
|
||||
'#14B8A6', '#A855F7', '#E11D48', '#0EA5E9', '#84CC16',
|
||||
]
|
||||
|
||||
function toStatusKey(label: string): string {
|
||||
return label.trim().toUpperCase().replace(/\s+/g, '_').replace(/[^A-Z0-9_]/g, '')
|
||||
}
|
||||
|
||||
function DropLine() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 2,
|
||||
background: 'var(--accent)',
|
||||
borderRadius: 1,
|
||||
margin: '0 12px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', left: -4, top: -3, width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
|
||||
<div style={{ position: 'absolute', right: -4, top: -3, width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminStatuses() {
|
||||
useDocumentTitle('Statuses')
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [selectedBoardId, setSelectedBoardId] = useState('')
|
||||
const [statuses, setStatuses] = useState<StatusEntry[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [usedStatuses, setUsedStatuses] = useState<Record<string, number>>({})
|
||||
|
||||
// add form
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
|
||||
// move modal for deleting in-use statuses
|
||||
const [moveFrom, setMoveFrom] = useState<StatusEntry | null>(null)
|
||||
const [moveTo, setMoveTo] = useState('')
|
||||
|
||||
// drag state
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [insertAt, setInsertAt] = useState<number | null>(null)
|
||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
// color picker refs
|
||||
const colorRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Board[]>('/admin/boards').then((b) => {
|
||||
setBoards(b)
|
||||
if (b.length > 0) setSelectedBoardId(b[0].id)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fetchStatuses = useCallback(() => {
|
||||
if (!selectedBoardId) return
|
||||
setError('')
|
||||
api.get<{ statuses: StatusEntry[] }>(`/admin/boards/${selectedBoardId}/statuses`)
|
||||
.then((r) => setStatuses(r.statuses))
|
||||
.catch(() => {})
|
||||
}, [selectedBoardId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatuses()
|
||||
if (!selectedBoardId) return
|
||||
api.get<{ posts: { status: string }[] }>(`/admin/posts?boardId=${selectedBoardId}&limit=100`)
|
||||
.then((r) => {
|
||||
const counts: Record<string, number> = {}
|
||||
r.posts.forEach((p) => { counts[p.status] = (counts[p.status] || 0) + 1 })
|
||||
setUsedStatuses(counts)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [selectedBoardId, fetchStatuses])
|
||||
|
||||
const updateField = (index: number, field: keyof StatusEntry, value: string | number) => {
|
||||
setStatuses((prev) => prev.map((s, i) => i === index ? { ...s, [field]: value } : s))
|
||||
setSaved(false)
|
||||
}
|
||||
|
||||
const removeStatus = (index: number) => {
|
||||
const s = statuses[index]
|
||||
if (s.status === 'OPEN') return
|
||||
const count = usedStatuses[s.status] || 0
|
||||
if (count > 0) {
|
||||
setMoveFrom(s)
|
||||
setMoveTo('')
|
||||
return
|
||||
}
|
||||
setStatuses((prev) => prev.filter((_, i) => i !== index).map((st, i) => ({ ...st, position: i })))
|
||||
setSaved(false)
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleMoveThenDelete = async () => {
|
||||
if (!moveFrom || !moveTo || !selectedBoardId) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.post(`/admin/boards/${selectedBoardId}/statuses/move`, {
|
||||
fromStatus: moveFrom.status,
|
||||
toStatus: moveTo,
|
||||
})
|
||||
setStatuses((prev) => prev.filter((s) => s.status !== moveFrom.status).map((s, i) => ({ ...s, position: i })))
|
||||
setUsedStatuses((prev) => {
|
||||
const next = { ...prev }
|
||||
const moved = next[moveFrom.status] || 0
|
||||
delete next[moveFrom.status]
|
||||
next[moveTo] = (next[moveTo] || 0) + moved
|
||||
return next
|
||||
})
|
||||
setMoveFrom(null)
|
||||
setMoveTo('')
|
||||
setSaved(false)
|
||||
} catch {
|
||||
setError('Failed to move posts')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addStatus = () => {
|
||||
const label = newLabel.trim()
|
||||
if (!label) return
|
||||
const key = toStatusKey(label)
|
||||
if (!key) return
|
||||
if (statuses.some((s) => s.status === key)) {
|
||||
setError(`Status "${key}" already exists`)
|
||||
return
|
||||
}
|
||||
const color = SUGGESTION_COLORS[statuses.length % SUGGESTION_COLORS.length]
|
||||
setStatuses((prev) => [
|
||||
...prev,
|
||||
{ status: key, label, color, position: prev.length },
|
||||
])
|
||||
setNewLabel('')
|
||||
setSaved(false)
|
||||
setError('')
|
||||
}
|
||||
|
||||
// drag handlers
|
||||
const onDragStart = useCallback((e: React.DragEvent, idx: number) => {
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', String(idx))
|
||||
if (e.currentTarget instanceof HTMLElement) e.currentTarget.style.opacity = '0.4'
|
||||
}, [])
|
||||
|
||||
const onDragEnd = useCallback((e: React.DragEvent) => {
|
||||
if (e.currentTarget instanceof HTMLElement) e.currentTarget.style.opacity = '1'
|
||||
setDragIdx(null)
|
||||
setInsertAt(null)
|
||||
}, [])
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const row = rowRefs.current[idx]
|
||||
if (!row) return
|
||||
const rect = row.getBoundingClientRect()
|
||||
setInsertAt(e.clientY < rect.top + rect.height / 2 ? idx : idx + 1)
|
||||
}, [])
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (dragIdx === null || insertAt === null) return
|
||||
setStatuses((prev) => {
|
||||
const next = [...prev]
|
||||
const [moved] = next.splice(dragIdx, 1)
|
||||
next.splice(insertAt > dragIdx ? insertAt - 1 : insertAt, 0, moved)
|
||||
return next.map((s, i) => ({ ...s, position: i }))
|
||||
})
|
||||
setSaved(false)
|
||||
setDragIdx(null)
|
||||
setInsertAt(null)
|
||||
}, [dragIdx, insertAt])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const payload = statuses.map((s, i) => ({
|
||||
status: s.status,
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
position: i,
|
||||
}))
|
||||
await api.put(`/admin/boards/${selectedBoardId}/statuses`, { statuses: payload })
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch (e: unknown) {
|
||||
const msg = e && typeof e === 'object' && 'body' in e
|
||||
? ((e as { body: { error?: string } }).body?.error || 'Save failed')
|
||||
: 'Save failed'
|
||||
setError(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto px-6 py-10" style={{ maxWidth: 'var(--content-max)' }}>
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)' }}
|
||||
>
|
||||
<IconPalette size={20} stroke={2} style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-2xl)', fontWeight: 700, color: 'var(--text)' }}>
|
||||
Custom Statuses
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
Customize status labels, colors, and order per board
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6" style={{ maxWidth: 300 }}>
|
||||
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Board</label>
|
||||
<Dropdown
|
||||
value={selectedBoardId}
|
||||
onChange={setSelectedBoardId}
|
||||
options={boards.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
|
||||
Statuses
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
{saved ? <IconCheck size={14} stroke={2} /> : null}
|
||||
{saving ? 'Saving...' : saved ? 'Saved' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="mb-4 p-3 rounded" style={{ background: 'rgba(239,68,68,0.1)', color: 'var(--error)', fontSize: 'var(--text-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex flex-col gap-1"
|
||||
onDragLeave={() => setInsertAt(null)}
|
||||
>
|
||||
{statuses.map((s, i) => {
|
||||
const showLineBefore = dragIdx !== null && insertAt === i && dragIdx !== i && dragIdx !== i - 1
|
||||
const showLineAfter = dragIdx !== null && insertAt === statuses.length && i === statuses.length - 1 && dragIdx !== i
|
||||
const count = usedStatuses[s.status] || 0
|
||||
|
||||
return (
|
||||
<div key={s.status}>
|
||||
{showLineBefore && <DropLine />}
|
||||
<div
|
||||
ref={(el) => { rowRefs.current[i] = el }}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, i)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => onDragOver(e, i)}
|
||||
onDrop={onDrop}
|
||||
className="flex items-center gap-3 p-3 rounded-lg"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)', cursor: 'default' }}
|
||||
>
|
||||
<div
|
||||
style={{ cursor: 'grab', color: 'var(--text-tertiary)', display: 'flex', padding: '2px 0' }}
|
||||
onMouseDown={(e) => { e.currentTarget.style.cursor = 'grabbing' }}
|
||||
onMouseUp={(e) => { e.currentTarget.style.cursor = 'grab' }}
|
||||
>
|
||||
<IconGripVertical size={16} stroke={2} />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Pick color for ${s.label || s.status}`}
|
||||
style={{
|
||||
width: 44, height: 44,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: s.color,
|
||||
cursor: 'pointer',
|
||||
border: '2px solid var(--border)',
|
||||
transition: 'transform 100ms ease-out',
|
||||
}}
|
||||
onClick={() => colorRefs.current[i]?.click()}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') colorRefs.current[i]?.click() }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.transform = 'scale(1.1)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
/>
|
||||
<input
|
||||
ref={(el) => { colorRefs.current[i] = el }}
|
||||
type="color"
|
||||
value={s.color}
|
||||
onChange={(e) => updateField(i, 'color', e.target.value)}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontFamily: 'var(--font-mono)', minWidth: 100 }}>
|
||||
{s.status}
|
||||
</span>
|
||||
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={s.label}
|
||||
onChange={(e) => updateField(i, 'label', e.target.value)}
|
||||
style={{ maxWidth: 200 }}
|
||||
/>
|
||||
|
||||
{count > 0 && (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
||||
{count} post{count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStatus(i)}
|
||||
disabled={s.status === 'OPEN'}
|
||||
aria-label={s.status === 'OPEN' ? 'OPEN status is required' : `Remove ${s.label || s.status}`}
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 44, height: 44,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: s.status === 'OPEN' ? 'var(--border)' : 'var(--text-tertiary)',
|
||||
cursor: s.status === 'OPEN' ? 'not-allowed' : 'pointer',
|
||||
background: 'transparent', border: 'none',
|
||||
transition: 'color 100ms ease-out, background 100ms ease-out',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (s.status !== 'OPEN') {
|
||||
e.currentTarget.style.color = 'var(--error)'
|
||||
e.currentTarget.style.background = 'var(--surface-hover)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = s.status === 'OPEN' ? 'var(--border)' : 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
if (s.status !== 'OPEN') {
|
||||
e.currentTarget.style.color = 'var(--error)'
|
||||
e.currentTarget.style.background = 'var(--surface-hover)'
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.color = s.status === 'OPEN' ? 'var(--border)' : 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<IconTrash size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
{showLineAfter && <DropLine />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add new status */}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="New status name..."
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') addStatus() }}
|
||||
style={{ maxWidth: 240 }}
|
||||
/>
|
||||
<button
|
||||
onClick={addStatus}
|
||||
disabled={!newLabel.trim()}
|
||||
className="btn btn-admin flex items-center gap-1"
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
<IconPlus size={14} stroke={2} />
|
||||
Add
|
||||
</button>
|
||||
{newLabel.trim() && (
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontFamily: 'var(--font-mono)' }}>
|
||||
{toStatusKey(newLabel)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="card p-6">
|
||||
<h2 className="font-semibold mb-4" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
|
||||
Preview
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statuses.map((s) => (
|
||||
<span
|
||||
key={s.status}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 font-medium"
|
||||
style={{
|
||||
background: `${s.color}20`,
|
||||
color: s.color,
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Move posts modal */}
|
||||
{moveFrom && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
onClick={() => setMoveFrom(null)}
|
||||
>
|
||||
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="move-posts-title"
|
||||
className="relative card p-6 w-full fade-in"
|
||||
style={{ maxWidth: 420 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="move-posts-title" className="font-bold mb-2" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
|
||||
Move posts
|
||||
</h2>
|
||||
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
<strong style={{ color: moveFrom.color }}>{moveFrom.label}</strong> has {usedStatuses[moveFrom.status] || 0} post{(usedStatuses[moveFrom.status] || 0) !== 1 ? 's' : ''}. Move them to another status before removing it.
|
||||
</p>
|
||||
|
||||
<label className="text-xs font-medium block mb-1.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Move to
|
||||
</label>
|
||||
<div className="mb-4">
|
||||
<Dropdown
|
||||
value={moveTo}
|
||||
onChange={setMoveTo}
|
||||
placeholder="Select status..."
|
||||
options={statuses
|
||||
.filter((s) => s.status !== moveFrom.status)
|
||||
.map((s) => ({ value: s.status, label: s.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setMoveFrom(null)} className="btn btn-ghost flex-1">Cancel</button>
|
||||
<button
|
||||
onClick={handleMoveThenDelete}
|
||||
disabled={!moveTo || saving}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
{saving ? 'Moving...' : 'Move and remove'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
packages/web/src/pages/admin/AdminTags.tsx
Normal file
205
packages/web/src/pages/admin/AdminTags.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
|
||||
interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
_count?: { posts: number }
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#F59E0B', '#EF4444', '#22C55E', '#3B82F6', '#8B5CF6',
|
||||
'#EC4899', '#06B6D4', '#F97316', '#14B8A6', '#6366F1',
|
||||
]
|
||||
|
||||
export default function AdminTags() {
|
||||
useDocumentTitle('Tags')
|
||||
const confirm = useConfirm()
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [color, setColor] = useState('#6366F1')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [editId, setEditId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editColor, setEditColor] = useState('')
|
||||
|
||||
const fetchTags = () => {
|
||||
api.get<{ tags: Tag[] }>('/admin/tags')
|
||||
.then((r) => setTags(r.tags))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(fetchTags, [])
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim()) return
|
||||
setError('')
|
||||
try {
|
||||
await api.post('/admin/tags', { name: name.trim(), color })
|
||||
setName('')
|
||||
fetchTags()
|
||||
} catch {
|
||||
setError('Failed to create tag')
|
||||
}
|
||||
}
|
||||
|
||||
const update = async () => {
|
||||
if (!editId || !editName.trim()) return
|
||||
try {
|
||||
await api.put(`/admin/tags/${editId}`, { name: editName.trim(), color: editColor })
|
||||
setEditId(null)
|
||||
fetchTags()
|
||||
} catch {
|
||||
setError('Failed to update tag')
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: string) => {
|
||||
if (!await confirm('Delete this tag? It will be removed from all posts.')) return
|
||||
try {
|
||||
await api.delete(`/admin/tags/${id}`)
|
||||
fetchTags()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Tags
|
||||
</h1>
|
||||
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back to dashboard</Link>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="flex gap-2 items-center mb-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Tag name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && create()}
|
||||
maxLength={30}
|
||||
/>
|
||||
<button onClick={create} className="btn btn-admin">Add</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginRight: 4 }}>Color</span>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-label={`Color ${c}`}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
background: c,
|
||||
border: c === color ? '2px solid var(--text)' : '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && <p role="alert" className="text-xs mt-2" style={{ color: 'var(--error)' }}>{error}</p>}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
|
||||
<div className="skeleton h-4" style={{ width: '40%' }} />
|
||||
<div className="skeleton h-4 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : tags.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No tags yet</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
{editId === tag.id ? (
|
||||
<div className="flex items-center gap-2 flex-1 mr-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && update()}
|
||||
maxLength={30}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setEditColor(c)}
|
||||
aria-label={`Color ${c}`}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
background: c,
|
||||
border: c === editColor ? '2px solid var(--text)' : '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={update} className="btn btn-admin text-xs px-3 py-1">Save</button>
|
||||
<button onClick={() => setEditId(null)} className="btn btn-ghost text-xs px-2 py-1">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{ background: `${tag.color}20`, color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{tag._count?.posts ?? 0} posts
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { setEditId(tag.id); setEditName(tag.name); setEditColor(tag.color) }}
|
||||
className="text-xs px-2 rounded"
|
||||
style={{ minHeight: 44, color: 'var(--admin-accent)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(tag.id)}
|
||||
className="text-xs px-2 rounded"
|
||||
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
645
packages/web/src/pages/admin/AdminTeam.tsx
Normal file
645
packages/web/src/pages/admin/AdminTeam.tsx
Normal file
@@ -0,0 +1,645 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../../lib/api'
|
||||
import { useAdmin } from '../../hooks/useAdmin'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
import { IconCopy, IconCheck, IconTrash, IconKey, IconPlus, IconInfoCircle, IconX, IconCamera } from '@tabler/icons-react'
|
||||
import Avatar from '../../components/Avatar'
|
||||
import { useAuth } from '../../hooks/useAuth'
|
||||
|
||||
interface TeamMember {
|
||||
id: string
|
||||
displayName: string | null
|
||||
teamTitle: string | null
|
||||
role: string
|
||||
invitedBy: string | null
|
||||
joinedAt: string
|
||||
}
|
||||
|
||||
interface PendingInvite {
|
||||
id: string
|
||||
role: string
|
||||
label: string | null
|
||||
expiresAt: string
|
||||
createdBy: string | null
|
||||
}
|
||||
|
||||
interface InviteResult {
|
||||
url: string
|
||||
recoveryPhrase: string | null
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
ADMIN: 'Admin',
|
||||
MODERATOR: 'Moderator',
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
SUPER_ADMIN: 'var(--admin-accent)',
|
||||
ADMIN: 'var(--accent)',
|
||||
MODERATOR: 'var(--text-secondary)',
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded font-medium"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
background: `color-mix(in srgb, ${ROLE_COLORS[role] ?? 'var(--text-tertiary)'} 18%, transparent)`,
|
||||
color: ROLE_COLORS[role] ?? 'var(--text-tertiary)',
|
||||
border: `1px solid color-mix(in srgb, ${ROLE_COLORS[role] ?? 'var(--text-tertiary)'} 25%, transparent)`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{ROLE_LABELS[role] ?? role}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={copy}
|
||||
className="btn btn-ghost inline-flex items-center gap-1"
|
||||
style={{ fontSize: 'var(--text-xs)', padding: '4px 8px' }}
|
||||
>
|
||||
{copied ? <IconCheck size={14} stroke={2} /> : <IconCopy size={14} stroke={2} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminTeam() {
|
||||
useDocumentTitle('Team')
|
||||
const admin = useAdmin()
|
||||
const auth = useAuth()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const [members, setMembers] = useState<TeamMember[]>([])
|
||||
const [invites, setInvites] = useState<PendingInvite[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// invite form
|
||||
const [showInviteForm, setShowInviteForm] = useState(false)
|
||||
const [inviteRole, setInviteRole] = useState('MODERATOR')
|
||||
const [inviteExpiry, setInviteExpiry] = useState('7d')
|
||||
const [inviteLabel, setInviteLabel] = useState('')
|
||||
const [inviteRecovery, setInviteRecovery] = useState(false)
|
||||
const [inviteLoading, setInviteLoading] = useState(false)
|
||||
const [inviteResult, setInviteResult] = useState<InviteResult | null>(null)
|
||||
const [myName, setMyName] = useState(admin.displayName || '')
|
||||
const [myTitle, setMyTitle] = useState(admin.teamTitle || '')
|
||||
const [profileSaving, setProfileSaving] = useState(false)
|
||||
const [profileSaved, setProfileSaved] = useState(false)
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null)
|
||||
const [showPerms, setShowPerms] = useState(false)
|
||||
const [permsPos, setPermsPos] = useState({ top: 0, left: 0 })
|
||||
const permsAnchorRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const openPerms = useCallback(() => {
|
||||
if (showPerms) { setShowPerms(false); return }
|
||||
if (permsAnchorRef.current) {
|
||||
const rect = permsAnchorRef.current.getBoundingClientRect()
|
||||
const popW = Math.min(400, window.innerWidth - 32)
|
||||
let left = rect.left
|
||||
if (left + popW > window.innerWidth - 16) left = window.innerWidth - 16 - popW
|
||||
if (left < 16) left = 16
|
||||
setPermsPos({ top: rect.bottom + 6, left })
|
||||
}
|
||||
setShowPerms(true)
|
||||
}, [showPerms])
|
||||
|
||||
const fetchMembers = () => {
|
||||
api.get<{ members: TeamMember[] }>('/admin/team')
|
||||
.then((r) => setMembers(r.members))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
const fetchInvites = () => {
|
||||
api.get<{ invites: PendingInvite[] }>('/admin/team/invites')
|
||||
.then((r) => setInvites(r.invites))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => { fetchMembers(); fetchInvites() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ avatarUrl: string | null }>('/me').then((d) => setAvatarUrl(d.avatarUrl ?? null)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || file.size > 2 * 1024 * 1024) return
|
||||
setUploadingAvatar(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await fetch('/api/v1/me/avatar', { method: 'POST', body: form, credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAvatarUrl(data.avatarUrl + '?t=' + Date.now())
|
||||
}
|
||||
} catch {} finally {
|
||||
setUploadingAvatar(false)
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await api.delete('/me/avatar')
|
||||
setAvatarUrl(null)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const removeMember = async (id: string, name: string | null) => {
|
||||
if (!await confirm(`Remove ${name ?? 'this team member'}? They will lose admin access.`)) return
|
||||
try {
|
||||
await api.delete(`/admin/team/${id}`)
|
||||
fetchMembers()
|
||||
} catch {
|
||||
setError('Failed to remove member')
|
||||
}
|
||||
}
|
||||
|
||||
const revokeInvite = async (id: string) => {
|
||||
if (!await confirm('Revoke this invite? The link will stop working.')) return
|
||||
try {
|
||||
await api.delete(`/admin/team/invites/${id}`)
|
||||
fetchInvites()
|
||||
} catch {
|
||||
setError('Failed to revoke invite')
|
||||
}
|
||||
}
|
||||
|
||||
const regenRecovery = async (id: string, name: string | null) => {
|
||||
if (!await confirm(`Regenerate recovery phrase for ${name ?? 'this member'}? The old phrase will stop working.`)) return
|
||||
try {
|
||||
const res = await api.post<{ recoveryPhrase: string }>(`/admin/team/${id}/recovery`)
|
||||
alert(`New recovery phrase:\n\n${res.recoveryPhrase}\n\nSave this - it won't be shown again.`)
|
||||
} catch {
|
||||
setError('Failed to regenerate recovery phrase')
|
||||
}
|
||||
}
|
||||
|
||||
const createInvite = async () => {
|
||||
setInviteLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post<InviteResult>('/admin/team/invite', {
|
||||
role: inviteRole,
|
||||
expiry: inviteExpiry,
|
||||
label: inviteLabel.trim() || undefined,
|
||||
generateRecovery: inviteRecovery,
|
||||
})
|
||||
setInviteResult(res)
|
||||
} catch {
|
||||
setError('Failed to create invite')
|
||||
} finally {
|
||||
setInviteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closeInviteForm = () => {
|
||||
setShowInviteForm(false)
|
||||
setInviteResult(null)
|
||||
setInviteLabel('')
|
||||
setInviteRecovery(false)
|
||||
if (inviteResult) fetchInvites()
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!myName.trim()) return
|
||||
setProfileSaving(true)
|
||||
try {
|
||||
await api.put('/admin/team/me', { displayName: myName.trim(), teamTitle: myTitle.trim() || undefined })
|
||||
admin.refresh()
|
||||
setProfileSaved(true)
|
||||
setTimeout(() => setProfileSaved(false), 2000)
|
||||
} catch {
|
||||
setError('Failed to update profile')
|
||||
} finally {
|
||||
setProfileSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const roleOptions = admin.isSuperAdmin
|
||||
? [
|
||||
{ value: 'ADMIN', label: 'Admin' },
|
||||
{ value: 'MODERATOR', label: 'Moderator' },
|
||||
]
|
||||
: [{ value: 'MODERATOR', label: 'Moderator' }]
|
||||
|
||||
const expiryOptions = [
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '24h', label: '24 hours' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '30d', label: '30 days' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Team
|
||||
</h1>
|
||||
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back to dashboard</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="card p-3 mb-4" style={{ borderColor: 'var(--error)', color: 'var(--error)', fontSize: 'var(--text-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Your profile */}
|
||||
<div className="card p-5 mb-6" style={{ borderColor: 'rgba(6, 182, 212, 0.15)' }}>
|
||||
<h2 className="font-semibold mb-3" style={{ color: 'var(--admin-accent)', fontSize: 'var(--text-sm)' }}>
|
||||
Your profile
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4 mb-1">
|
||||
<Avatar userId={auth.user?.id ?? ''} name={myName || null} avatarUrl={avatarUrl} size={56} />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleAvatarUpload}
|
||||
className="sr-only"
|
||||
id="admin-avatar-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="admin-avatar-upload"
|
||||
className="btn btn-secondary inline-flex items-center gap-2"
|
||||
style={{ cursor: uploadingAvatar ? 'wait' : 'pointer', opacity: uploadingAvatar ? 0.6 : 1, fontSize: 'var(--text-xs)', padding: '6px 12px' }}
|
||||
>
|
||||
<IconCamera size={14} stroke={2} />
|
||||
{uploadingAvatar ? 'Uploading...' : avatarUrl ? 'Change photo' : 'Upload photo'}
|
||||
</label>
|
||||
{avatarUrl && (
|
||||
<button
|
||||
onClick={handleAvatarRemove}
|
||||
className="action-btn inline-flex items-center gap-1"
|
||||
style={{ color: 'var(--error)', fontSize: 'var(--text-xs)', padding: '4px 8px', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconTrash size={12} stroke={2} /> Remove
|
||||
</button>
|
||||
)}
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>JPG, PNG or WebP. Max 2MB.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="my-display-name" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
id="my-display-name"
|
||||
className="input w-full"
|
||||
value={myName}
|
||||
onChange={(e) => setMyName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="my-team-title" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||
Team title <span style={{ color: 'var(--text-tertiary)' }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="my-team-title"
|
||||
className="input w-full"
|
||||
value={myTitle}
|
||||
onChange={(e) => setMyTitle(e.target.value)}
|
||||
placeholder="e.g. Product Manager, Lead Developer"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={profileSaving || !myName.trim()}
|
||||
className="btn btn-admin"
|
||||
style={{ fontSize: 'var(--text-sm)', opacity: profileSaving ? 0.6 : 1 }}
|
||||
>
|
||||
{profileSaving ? 'Saving...' : profileSaved ? 'Saved' : 'Save profile'}
|
||||
</button>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{admin.role === 'SUPER_ADMIN' ? 'Super Admin' : admin.role === 'MODERATOR' ? 'Moderator' : 'Admin'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite section */}
|
||||
{admin.canInvite && (
|
||||
<div className="card p-4 mb-6">
|
||||
{!showInviteForm && !inviteResult && (
|
||||
<button
|
||||
onClick={() => setShowInviteForm(true)}
|
||||
className="btn btn-admin inline-flex items-center gap-2"
|
||||
>
|
||||
<IconPlus size={16} stroke={2} />
|
||||
Invite team member
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showInviteForm && !inviteResult && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
Create invite link
|
||||
</h2>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="flex items-center gap-1.5" style={{ marginBottom: 4 }}>
|
||||
<label id="invite-role-label" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>Role</label>
|
||||
<button
|
||||
ref={permsAnchorRef}
|
||||
type="button"
|
||||
onClick={openPerms}
|
||||
aria-label="View role permissions"
|
||||
className="action-btn"
|
||||
style={{ cursor: 'pointer', padding: 2, color: 'var(--text-tertiary)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconInfoCircle size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
{showPerms && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[99]"
|
||||
onClick={() => setShowPerms(false)}
|
||||
/>
|
||||
<div
|
||||
className="fade-in fixed z-[100]"
|
||||
style={{
|
||||
top: permsPos.top,
|
||||
left: permsPos.left,
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
padding: '16px',
|
||||
width: Math.min(400, window.innerWidth - 32),
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Escape' && setShowPerms(false)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-medium" style={{ fontSize: 'var(--text-sm)', color: 'var(--text)' }}>Role permissions</span>
|
||||
<button
|
||||
onClick={() => setShowPerms(false)}
|
||||
aria-label="Close"
|
||||
className="action-btn"
|
||||
style={{ cursor: 'pointer', color: 'var(--text-tertiary)', padding: 2, borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconX size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
<table style={{ width: '100%', fontSize: 'var(--text-xs)', color: 'var(--text-secondary)', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '4px 8px 4px 0', color: 'var(--text-tertiary)' }}></th>
|
||||
<th style={{ textAlign: 'center', padding: '4px 6px', color: 'var(--admin-accent)', fontWeight: 600 }}>Admin</th>
|
||||
<th style={{ textAlign: 'center', padding: '4px 6px', color: 'var(--text-secondary)', fontWeight: 600 }}>Mod</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
['Manage posts, status, pin, merge', true, true],
|
||||
['Categories, tags, notes', true, true],
|
||||
['Boards, statuses, templates', true, false],
|
||||
['Changelog, embed widget', true, false],
|
||||
['Webhooks, data export', true, false],
|
||||
['Invite moderators', true, false],
|
||||
['Branding / site settings', false, false],
|
||||
].map(([label, adm, mod], i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '5px 8px 5px 0' }}>{label as string}</td>
|
||||
<td style={{ textAlign: 'center', padding: '5px 6px', color: adm ? 'var(--success)' : 'var(--text-tertiary)' }}>{adm ? 'Yes' : '-'}</td>
|
||||
<td style={{ textAlign: 'center', padding: '5px 6px', color: mod ? 'var(--success)' : 'var(--text-tertiary)' }}>{mod ? 'Yes' : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Dropdown
|
||||
value={inviteRole}
|
||||
options={roleOptions}
|
||||
onChange={setInviteRole}
|
||||
placeholder="Select role"
|
||||
aria-label="Role"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label id="invite-expiry-label" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>Expires in</label>
|
||||
<Dropdown
|
||||
value={inviteExpiry}
|
||||
options={expiryOptions}
|
||||
onChange={setInviteExpiry}
|
||||
placeholder="Select expiry"
|
||||
aria-label="Expires in"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="invite-label-input" style={{ display: 'block', color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>Label (optional)</label>
|
||||
<input
|
||||
id="invite-label-input"
|
||||
className="input"
|
||||
placeholder="e.g. 'For Jane'"
|
||||
value={inviteLabel}
|
||||
onChange={(e) => setInviteLabel(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2" style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteRecovery}
|
||||
onChange={(e) => setInviteRecovery(e.target.checked)}
|
||||
/>
|
||||
Generate recovery phrase
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={createInvite} disabled={inviteLoading} className="btn btn-admin" style={{ opacity: inviteLoading ? 0.6 : 1 }}>
|
||||
{inviteLoading ? 'Creating...' : 'Create invite'}
|
||||
</button>
|
||||
<button onClick={closeInviteForm} className="btn btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inviteResult && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-medium" style={{ color: 'var(--admin-accent)', fontSize: 'var(--text-sm)' }}>
|
||||
Invite created
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>
|
||||
Share this link with your team member. It can only be used once.
|
||||
</p>
|
||||
<div
|
||||
className="p-3 flex items-center gap-2"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
<span className="flex-1" style={{ color: 'var(--text)' }}>{inviteResult.url}</span>
|
||||
<CopyButton text={inviteResult.url} />
|
||||
</div>
|
||||
{inviteResult.recoveryPhrase && (
|
||||
<div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||
Recovery phrase - save this, it won't be shown again:
|
||||
</p>
|
||||
<div
|
||||
className="p-3 flex items-center gap-2"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--admin-accent) 6%, var(--bg))',
|
||||
border: '1px solid color-mix(in srgb, var(--admin-accent) 20%, transparent)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
>
|
||||
<span className="flex-1" style={{ color: 'var(--text)' }}>{inviteResult.recoveryPhrase}</span>
|
||||
<CopyButton text={inviteResult.recoveryPhrase} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={closeInviteForm} className="btn btn-ghost self-start">Done</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team members */}
|
||||
<h2
|
||||
className="font-medium mb-3"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
|
||||
>
|
||||
Members
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
|
||||
<div className="skeleton h-4" style={{ width: '40%' }} />
|
||||
<div className="skeleton h-4 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No team members yet</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 mb-8">
|
||||
{members.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{m.displayName ?? 'Unnamed'}
|
||||
</span>
|
||||
<RoleBadge role={m.role} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{m.teamTitle && <span>{m.teamTitle}</span>}
|
||||
{m.invitedBy && <span>Invited by {m.invitedBy}</span>}
|
||||
<span>Joined {new Date(m.joinedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{admin.isSuperAdmin && m.role !== 'SUPER_ADMIN' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => regenRecovery(m.id, m.displayName)}
|
||||
className="inline-flex items-center gap-1 px-2 action-btn"
|
||||
style={{ minHeight: 44, color: 'var(--admin-accent)', cursor: 'pointer', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconKey size={14} stroke={2} /> Recovery
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeMember(m.id, m.displayName)}
|
||||
className="inline-flex items-center gap-1 px-2 action-btn"
|
||||
style={{ minHeight: 44, color: 'var(--error)', cursor: 'pointer', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconTrash size={14} stroke={2} /> Remove
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending invites */}
|
||||
{admin.canInvite && invites.length > 0 && (
|
||||
<>
|
||||
<h2
|
||||
className="font-medium mb-3"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
|
||||
>
|
||||
Pending invites
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{invites.map((inv) => (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleBadge role={inv.role} />
|
||||
{inv.label && (
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>{inv.label}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{inv.createdBy && <span>Created by {inv.createdBy} - </span>}
|
||||
Expires {new Date(inv.expiresAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => revokeInvite(inv.id)}
|
||||
className="text-xs px-2 action-btn"
|
||||
style={{ minHeight: 44, color: 'var(--error)', cursor: 'pointer', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
658
packages/web/src/pages/admin/AdminTemplates.tsx
Normal file
658
packages/web/src/pages/admin/AdminTemplates.tsx
Normal file
@@ -0,0 +1,658 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import {
|
||||
IconTemplate, IconGripVertical, IconCheck, IconPlus, IconTrash,
|
||||
IconPencil, IconX, IconStar, IconChevronDown, IconChevronUp,
|
||||
} from '@tabler/icons-react'
|
||||
import Dropdown from '../../components/Dropdown'
|
||||
|
||||
interface Board {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TemplateField {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'textarea' | 'select'
|
||||
required: boolean
|
||||
placeholder?: string
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
boardId: string
|
||||
name: string
|
||||
fields: TemplateField[]
|
||||
isDefault: boolean
|
||||
position: number
|
||||
}
|
||||
|
||||
function DropLine() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 2,
|
||||
background: 'var(--accent)',
|
||||
borderRadius: 1,
|
||||
margin: '0 12px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', left: -4, top: -3, width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
|
||||
<div style={{ position: 'absolute', right: -4, top: -3, width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function toFieldKey(label: string): string {
|
||||
return label.trim().toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
|
||||
}
|
||||
|
||||
const emptyField = (): TemplateField => ({
|
||||
key: '',
|
||||
label: '',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
placeholder: '',
|
||||
})
|
||||
|
||||
function FieldEditor({
|
||||
field,
|
||||
index,
|
||||
total,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: {
|
||||
field: TemplateField
|
||||
index: number
|
||||
total: number
|
||||
onChange: (f: TemplateField) => void
|
||||
onRemove: () => void
|
||||
onMoveUp: () => void
|
||||
onMoveDown: () => void
|
||||
}) {
|
||||
const updateLabel = (label: string) => {
|
||||
const key = field.key && field.key !== toFieldKey(field.label) ? field.key : toFieldKey(label)
|
||||
onChange({ ...field, label, key })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-lg mb-2"
|
||||
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontFamily: 'var(--font-mono)' }}>
|
||||
Field {index + 1}{field.key ? ` - ${field.key}` : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={index === 0}
|
||||
aria-label="Move field up"
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 44, height: 44,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: index === 0 ? 'var(--border)' : 'var(--text-tertiary)',
|
||||
background: 'transparent', border: 'none', cursor: index === 0 ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<IconChevronUp size={14} stroke={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={index === total - 1}
|
||||
aria-label="Move field down"
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 44, height: 44,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: index === total - 1 ? 'var(--border)' : 'var(--text-tertiary)',
|
||||
background: 'transparent', border: 'none', cursor: index === total - 1 ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<IconChevronDown size={14} stroke={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove field"
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 44, height: 44,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-tertiary)',
|
||||
background: 'transparent', border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--error)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.color = 'var(--error)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
|
||||
>
|
||||
<IconTrash size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block mb-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Label</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
value={field.label}
|
||||
onChange={(e) => updateLabel(e.target.value)}
|
||||
placeholder="Field label"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Type</label>
|
||||
<select
|
||||
className="input w-full"
|
||||
value={field.type}
|
||||
onChange={(e) => onChange({ ...field, type: e.target.value as TemplateField['type'] })}
|
||||
>
|
||||
<option value="text">Short text</option>
|
||||
<option value="textarea">Long text</option>
|
||||
<option value="select">Dropdown</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Placeholder</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
value={field.placeholder || ''}
|
||||
onChange={(e) => onChange({ ...field, placeholder: e.target.value })}
|
||||
placeholder="Optional hint text"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 pb-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer" style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => onChange({ ...field, required: e.target.checked })}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{field.type === 'select' && (
|
||||
<div className="mt-3">
|
||||
<label className="block mb-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
Options (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
value={(field.options || []).join(', ')}
|
||||
onChange={(e) => onChange({
|
||||
...field,
|
||||
options: e.target.value.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
})}
|
||||
placeholder="Option 1, Option 2, Option 3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminTemplates() {
|
||||
useDocumentTitle('Templates')
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [selectedBoardId, setSelectedBoardId] = useState('')
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// drag state
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [insertAt, setInsertAt] = useState<number | null>(null)
|
||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
// modal state
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Template | null>(null)
|
||||
const [modalName, setModalName] = useState('')
|
||||
const [modalDefault, setModalDefault] = useState(false)
|
||||
const [modalFields, setModalFields] = useState<TemplateField[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [modalError, setModalError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Board[]>('/admin/boards').then((b) => {
|
||||
setBoards(b)
|
||||
if (b.length > 0) setSelectedBoardId(b[0].id)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const fetchTemplates = useCallback(() => {
|
||||
if (!selectedBoardId) return
|
||||
setError('')
|
||||
api.get<{ templates: Template[] }>(`/admin/boards/${selectedBoardId}/templates`)
|
||||
.then((r) => setTemplates(r.templates))
|
||||
.catch(() => setError('Failed to load templates'))
|
||||
}, [selectedBoardId])
|
||||
|
||||
useEffect(() => { fetchTemplates() }, [fetchTemplates])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setModalName('')
|
||||
setModalDefault(false)
|
||||
setModalFields([emptyField()])
|
||||
setModalError('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (t: Template) => {
|
||||
setEditing(t)
|
||||
setModalName(t.name)
|
||||
setModalDefault(t.isDefault)
|
||||
setModalFields([...t.fields])
|
||||
setModalError('')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false)
|
||||
setEditing(null)
|
||||
setModalName('')
|
||||
setModalFields([])
|
||||
setModalError('')
|
||||
}
|
||||
|
||||
const handleSaveTemplate = async () => {
|
||||
if (!modalName.trim()) { setModalError('Name is required'); return }
|
||||
const cleanFields = modalFields.filter((f) => f.label.trim())
|
||||
if (cleanFields.length === 0) { setModalError('Add at least one field'); return }
|
||||
|
||||
for (const f of cleanFields) {
|
||||
if (!f.key) f.key = toFieldKey(f.label)
|
||||
if (!f.key) { setModalError(`Invalid label: "${f.label}"`); return }
|
||||
if (f.type === 'select' && (!f.options || f.options.length === 0)) {
|
||||
setModalError(`Field "${f.label}" needs options`); return
|
||||
}
|
||||
}
|
||||
|
||||
// check for duplicate keys
|
||||
const keys = new Set<string>()
|
||||
for (const f of cleanFields) {
|
||||
if (keys.has(f.key)) { setModalError(`Duplicate field key: ${f.key}`); return }
|
||||
keys.add(f.key)
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setModalError('')
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await api.put(`/admin/templates/${editing.id}`, {
|
||||
name: modalName.trim(),
|
||||
fields: cleanFields,
|
||||
isDefault: modalDefault,
|
||||
})
|
||||
} else {
|
||||
await api.post(`/admin/boards/${selectedBoardId}/templates`, {
|
||||
name: modalName.trim(),
|
||||
fields: cleanFields,
|
||||
isDefault: modalDefault,
|
||||
})
|
||||
}
|
||||
fetchTemplates()
|
||||
closeModal()
|
||||
} catch {
|
||||
setModalError('Failed to save template')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/admin/templates/${id}`)
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
} catch {
|
||||
setError('Failed to delete template')
|
||||
}
|
||||
}
|
||||
|
||||
// reorder via drag
|
||||
const onDragStart = useCallback((e: React.DragEvent, idx: number) => {
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', String(idx))
|
||||
if (e.currentTarget instanceof HTMLElement) e.currentTarget.style.opacity = '0.4'
|
||||
}, [])
|
||||
|
||||
const onDragEnd = useCallback((e: React.DragEvent) => {
|
||||
if (e.currentTarget instanceof HTMLElement) e.currentTarget.style.opacity = '1'
|
||||
setDragIdx(null)
|
||||
setInsertAt(null)
|
||||
}, [])
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
const row = rowRefs.current[idx]
|
||||
if (!row) return
|
||||
const rect = row.getBoundingClientRect()
|
||||
setInsertAt(e.clientY < rect.top + rect.height / 2 ? idx : idx + 1)
|
||||
}, [])
|
||||
|
||||
const onDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (dragIdx === null || insertAt === null) return
|
||||
const reordered = [...templates]
|
||||
const [moved] = reordered.splice(dragIdx, 1)
|
||||
reordered.splice(insertAt > dragIdx ? insertAt - 1 : insertAt, 0, moved)
|
||||
|
||||
setTemplates(reordered)
|
||||
setDragIdx(null)
|
||||
setInsertAt(null)
|
||||
|
||||
// persist positions
|
||||
for (let i = 0; i < reordered.length; i++) {
|
||||
if (reordered[i].position !== i) {
|
||||
await api.put(`/admin/templates/${reordered[i].id}`, { position: i }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [dragIdx, insertAt, templates])
|
||||
|
||||
return (
|
||||
<div className="mx-auto px-6 py-10" style={{ maxWidth: 'var(--content-max)' }}>
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)' }}
|
||||
>
|
||||
<IconTemplate size={20} stroke={2} style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-2xl)', fontWeight: 700, color: 'var(--text)' }}>
|
||||
Request Templates
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
Create custom form templates for each board
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6" style={{ maxWidth: 300 }}>
|
||||
<label className="block mb-1.5" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>Board</label>
|
||||
<Dropdown
|
||||
value={selectedBoardId}
|
||||
onChange={setSelectedBoardId}
|
||||
options={boards.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
|
||||
Templates
|
||||
</h2>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<IconPlus size={14} stroke={2} />
|
||||
New template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="mb-4 p-3 rounded" style={{ background: 'rgba(239,68,68,0.1)', color: 'var(--error)', fontSize: 'var(--text-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templates.length === 0 && !error && (
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
No templates yet. Create one to customize the submission form.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex flex-col gap-1"
|
||||
onDragLeave={() => setInsertAt(null)}
|
||||
>
|
||||
{templates.map((t, i) => {
|
||||
const showLineBefore = dragIdx !== null && insertAt === i && dragIdx !== i && dragIdx !== i - 1
|
||||
const showLineAfter = dragIdx !== null && insertAt === templates.length && i === templates.length - 1 && dragIdx !== i
|
||||
|
||||
return (
|
||||
<div key={t.id}>
|
||||
{showLineBefore && <DropLine />}
|
||||
<div
|
||||
ref={(el) => { rowRefs.current[i] = el }}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, i)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => onDragOver(e, i)}
|
||||
onDrop={onDrop}
|
||||
className="flex items-center gap-3 p-3 rounded-lg"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)', cursor: 'default' }}
|
||||
>
|
||||
<div
|
||||
style={{ cursor: 'grab', color: 'var(--text-tertiary)', display: 'flex', padding: '2px 0' }}
|
||||
onMouseDown={(e) => { e.currentTarget.style.cursor = 'grabbing' }}
|
||||
onMouseUp={(e) => { e.currentTarget.style.cursor = 'grab' }}
|
||||
>
|
||||
<IconGripVertical size={16} stroke={2} />
|
||||
</div>
|
||||
|
||||
<span className="font-medium flex-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{t.name}
|
||||
</span>
|
||||
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{t.fields.length} field{t.fields.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
|
||||
{t.isDefault && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5"
|
||||
style={{
|
||||
background: 'var(--accent-subtle)',
|
||||
color: 'var(--accent)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<IconStar size={10} stroke={2} /> Default
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(t)}
|
||||
aria-label={`Edit ${t.name}`}
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 44, height: 44,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-tertiary)',
|
||||
background: 'transparent', border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent)'
|
||||
e.currentTarget.style.background = 'var(--surface-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent)'
|
||||
e.currentTarget.style.background = 'var(--surface-hover)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<IconPencil size={14} stroke={2} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(t.id)}
|
||||
aria-label={`Delete ${t.name}`}
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 44, height: 44,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-tertiary)',
|
||||
background: 'transparent', border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--error)'
|
||||
e.currentTarget.style.background = 'var(--surface-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.color = 'var(--error)'
|
||||
e.currentTarget.style.background = 'var(--surface-hover)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.color = 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<IconTrash size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
{showLineAfter && <DropLine />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template editor modal */}
|
||||
{modalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
onClick={closeModal}
|
||||
>
|
||||
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="template-modal-title"
|
||||
className="relative card p-6 w-full fade-in overflow-y-auto"
|
||||
style={{ maxWidth: 640, maxHeight: '90vh' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 id="template-modal-title" className="font-bold" style={{ color: 'var(--text)', fontSize: 'var(--text-lg)' }}>
|
||||
{editing ? 'Edit template' : 'New template'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
aria-label="Close"
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: 44, height: 44, color: 'var(--text-tertiary)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<IconX size={18} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block mb-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
Template name
|
||||
</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
value={modalName}
|
||||
onChange={(e) => setModalName(e.target.value)}
|
||||
placeholder="e.g. Bug Report, Feature Request"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 mb-5 cursor-pointer" style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modalDefault}
|
||||
onChange={(e) => setModalDefault(e.target.checked)}
|
||||
/>
|
||||
Set as default template for this board
|
||||
</label>
|
||||
|
||||
<div className="mb-3">
|
||||
<h4 className="font-semibold mb-2" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
Fields
|
||||
</h4>
|
||||
{modalFields.map((f, i) => (
|
||||
<FieldEditor
|
||||
key={i}
|
||||
field={f}
|
||||
index={i}
|
||||
total={modalFields.length}
|
||||
onChange={(updated) => setModalFields((prev) => prev.map((ff, j) => j === i ? updated : ff))}
|
||||
onRemove={() => setModalFields((prev) => prev.filter((_, j) => j !== i))}
|
||||
onMoveUp={() => {
|
||||
if (i === 0) return
|
||||
setModalFields((prev) => {
|
||||
const next = [...prev]
|
||||
;[next[i - 1], next[i]] = [next[i], next[i - 1]]
|
||||
return next
|
||||
})
|
||||
}}
|
||||
onMoveDown={() => {
|
||||
if (i === modalFields.length - 1) return
|
||||
setModalFields((prev) => {
|
||||
const next = [...prev]
|
||||
;[next[i], next[i + 1]] = [next[i + 1], next[i]]
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalFields((prev) => [...prev, emptyField()])}
|
||||
className="btn btn-admin flex items-center gap-1 mt-2"
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
<IconPlus size={14} stroke={2} />
|
||||
Add field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{modalError && (
|
||||
<div className="mb-4 p-3 rounded" style={{ background: 'rgba(239,68,68,0.1)', color: 'var(--error)', fontSize: 'var(--text-sm)' }}>
|
||||
{modalError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={closeModal} className="btn btn-ghost">Cancel</button>
|
||||
<button
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={saving}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{saving ? 'Saving...' : editing ? 'Save changes' : 'Create template'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
packages/web/src/pages/admin/AdminWebhooks.tsx
Normal file
213
packages/web/src/pages/admin/AdminWebhooks.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||
import { useConfirm } from '../../hooks/useConfirm'
|
||||
|
||||
interface Webhook {
|
||||
id: string
|
||||
url: string
|
||||
secret: string
|
||||
events: string[]
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const ALL_EVENTS = [
|
||||
{ value: 'status_changed', label: 'Status changed' },
|
||||
{ value: 'post_created', label: 'Post created' },
|
||||
{ value: 'comment_added', label: 'Comment added' },
|
||||
]
|
||||
|
||||
export default function AdminWebhooks() {
|
||||
useDocumentTitle('Webhooks')
|
||||
const confirm = useConfirm()
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [events, setEvents] = useState<string[]>(['status_changed', 'post_created', 'comment_added'])
|
||||
const [newSecret, setNewSecret] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetchWebhooks = () => {
|
||||
api.get<{ webhooks: Webhook[] }>('/admin/webhooks')
|
||||
.then((r) => setWebhooks(r.webhooks))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(fetchWebhooks, [])
|
||||
|
||||
const create = async () => {
|
||||
if (!url.trim() || events.length === 0) return
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post<Webhook>('/admin/webhooks', { url: url.trim(), events })
|
||||
setNewSecret(res.secret)
|
||||
setUrl('')
|
||||
setShowForm(false)
|
||||
fetchWebhooks()
|
||||
} catch {
|
||||
setError('Failed to create webhook')
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (id: string, active: boolean) => {
|
||||
try {
|
||||
await api.put(`/admin/webhooks/${id}`, { active: !active })
|
||||
fetchWebhooks()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const remove = async (id: string) => {
|
||||
if (!await confirm('Delete this webhook?')) return
|
||||
try {
|
||||
await api.delete(`/admin/webhooks/${id}`)
|
||||
fetchWebhooks()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Webhooks
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="btn btn-admin flex items-center gap-1"
|
||||
aria-expanded={showForm}
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
<IconPlus size={14} stroke={2} />
|
||||
Add webhook
|
||||
</button>
|
||||
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newSecret && (
|
||||
<div className="card p-4 mb-4" style={{ border: '1px solid var(--success)', background: 'rgba(34, 197, 94, 0.08)' }}>
|
||||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--success)' }}>Webhook created - copy the signing secret now (it won't be shown again):</p>
|
||||
<code
|
||||
className="block p-2 rounded text-xs"
|
||||
style={{ background: 'var(--bg)', color: 'var(--text)', wordBreak: 'break-all', fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{newSecret}
|
||||
</code>
|
||||
<button onClick={() => setNewSecret(null)} className="btn btn-ghost text-xs mt-2">Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="mb-3">
|
||||
<label htmlFor="webhook-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>URL</label>
|
||||
<input
|
||||
id="webhook-url"
|
||||
className="input w-full"
|
||||
placeholder="https://example.com/webhook"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Events</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ALL_EVENTS.map((ev) => {
|
||||
const active = events.includes(ev.value)
|
||||
return (
|
||||
<button
|
||||
key={ev.value}
|
||||
type="button"
|
||||
onClick={() => setEvents((evs) =>
|
||||
active ? evs.filter((e) => e !== ev.value) : [...evs, ev.value]
|
||||
)}
|
||||
className="px-2 py-1 rounded text-xs"
|
||||
style={{
|
||||
background: active ? 'var(--admin-subtle)' : 'var(--surface-hover)',
|
||||
color: active ? 'var(--admin-accent)' : 'var(--text-tertiary)',
|
||||
border: active ? '1px solid rgba(6, 182, 212, 0.3)' : '1px solid transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{ev.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-xs mb-2" style={{ color: 'var(--error)' }}>{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={create} className="btn btn-admin">Create</button>
|
||||
<button onClick={() => setShowForm(false)} className="btn btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div>
|
||||
<div className="progress-bar mb-4" />
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.3 }}>
|
||||
<div className="skeleton h-4" style={{ width: '50%' }} />
|
||||
<div className="skeleton h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No webhooks configured</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{webhooks.map((wh) => (
|
||||
<div
|
||||
key={wh.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)', opacity: wh.active ? 1 : 0.5 }}
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-2">
|
||||
<div className="font-medium truncate" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{wh.url}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{wh.events.map((ev) => (
|
||||
<span key={ev} className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}>
|
||||
{ev.replace(/_/g, ' ')}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Signed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => toggle(wh.id, wh.active)}
|
||||
className="text-xs px-2 rounded"
|
||||
style={{ minHeight: 44, color: wh.active ? 'var(--warning)' : 'var(--success)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{wh.active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(wh.id)}
|
||||
className="text-xs px-2 rounded"
|
||||
aria-label="Delete webhook"
|
||||
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<IconTrash size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user