Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(web): split 16 oversized components into focused modules (#74)

* fix(test): stabilize auth mocks to prevent infinite re-render loops

The useAuth mock created new function references on every call, causing
components with getAccessToken in useCallback/useEffect deps to enter
infinite re-render loops. Extract mock object to a stable reference.

Also add maxWorkers (cpuCount/2) and testTimeout (10s) to vitest config
to reduce CPU contention and give axe-core tests adequate time.

* refactor(web): split 16 oversized components into ~38 focused modules

Enforce ~150-line limit for non-page TSX files by extracting cohesive
sub-components. All 606 tests pass, typecheck clean, zero regressions.

Priority 1 (400+ lines): admin/moderation, admin/sybil-detection,
admin/settings, settings, community-profile-settings, admin/plugins.

Priority 2 (250-400 lines): admin/onboarding, admin/trust-seeds,
admin/categories, onboarding-modal, topic-form, settings/reports.

Priority 3 (150-250 lines): report-dialog, markdown-editor,
community-profile-settings, search-input.

Also adds formatDateShort to shared format utilities and extracts
useCommunityProfile hook.

authored by

Guido X Jansen and committed by
GitHub
3e10a811 209b626f

+4022 -3490
+11 -170
src/app/admin/categories/page.tsx
··· 8 8 'use client' 9 9 10 10 import { useState, useEffect, useCallback } from 'react' 11 - import { PencilSimple, Plus, TrashSimple } from '@phosphor-icons/react' 11 + import { Plus } from '@phosphor-icons/react' 12 12 import { AdminLayout } from '@/components/admin/admin-layout' 13 13 import { ErrorAlert } from '@/components/error-alert' 14 + import { CategoryRow } from '@/components/admin/categories/category-row' 15 + import { CategoryForm } from '@/components/admin/categories/category-form' 16 + import type { EditingCategory } from '@/components/admin/categories/category-form' 14 17 import { getCategories, createCategory, updateCategory, deleteCategory } from '@/lib/api/client' 15 - import { cn } from '@/lib/utils' 16 - import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types' 18 + import type { CategoryTreeNode } from '@/lib/api/types' 17 19 import { useAuth } from '@/hooks/use-auth' 18 20 19 - const MATURITY_LABELS: Record<MaturityRating, string> = { 20 - safe: 'Safe', 21 - mature: 'Mature', 22 - adult: 'Adult', 23 - } 24 - 25 - const MATURITY_COLORS: Record<MaturityRating, string> = { 26 - safe: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 27 - mature: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', 28 - adult: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', 29 - } 30 - 31 - interface EditingCategory { 32 - id: string | null 33 - name: string 34 - slug: string 35 - description: string 36 - parentId: string | null 37 - maturityRating: MaturityRating 38 - } 39 - 40 - function CategoryRow({ 41 - category, 42 - depth, 43 - onEdit, 44 - onDelete, 45 - }: { 46 - category: CategoryTreeNode 47 - depth: number 48 - onEdit: (cat: CategoryTreeNode) => void 49 - onDelete: (id: string) => void 50 - }) { 51 - return ( 52 - <> 53 - <div 54 - data-depth={depth} 55 - className={cn( 56 - 'flex items-center justify-between rounded-md border border-border bg-card p-3', 57 - depth > 0 && 'ml-6' 58 - )} 59 - > 60 - <div className="flex items-center gap-3"> 61 - <div> 62 - <p className="text-sm font-medium text-foreground">{category.name}</p> 63 - {category.description && ( 64 - <p className="text-xs text-muted-foreground">{category.description}</p> 65 - )} 66 - </div> 67 - </div> 68 - <div className="flex items-center gap-2"> 69 - <span 70 - className={cn( 71 - 'rounded-full px-2 py-0.5 text-xs font-medium', 72 - MATURITY_COLORS[category.maturityRating] 73 - )} 74 - > 75 - {MATURITY_LABELS[category.maturityRating]} 76 - </span> 77 - <button 78 - type="button" 79 - onClick={() => onEdit(category)} 80 - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 81 - aria-label={`Edit ${category.name}`} 82 - > 83 - <PencilSimple size={16} aria-hidden="true" /> 84 - </button> 85 - <button 86 - type="button" 87 - onClick={() => onDelete(category.id)} 88 - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" 89 - aria-label={`Delete ${category.name}`} 90 - > 91 - <TrashSimple size={16} aria-hidden="true" /> 92 - </button> 93 - </div> 94 - </div> 95 - {category.children.map((child) => ( 96 - <CategoryRow 97 - key={child.id} 98 - category={child} 99 - depth={depth + 1} 100 - onEdit={onEdit} 101 - onDelete={onDelete} 102 - /> 103 - ))} 104 - </> 105 - ) 106 - } 107 - 108 21 export default function AdminCategoriesPage() { 109 22 const { getAccessToken } = useAuth() 110 23 const [categories, setCategories] = useState<CategoryTreeNode[]>([]) ··· 214 127 215 128 {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 216 129 217 - {/* Edit form */} 218 130 {editing && ( 219 - <div className="rounded-lg border border-border bg-card p-4"> 220 - <h2 className="mb-4 text-lg font-semibold text-foreground"> 221 - {editing.id ? 'Edit Category' : 'New Category'} 222 - </h2> 223 - <div className="space-y-4"> 224 - <div> 225 - <label htmlFor="cat-name" className="block text-sm font-medium text-foreground"> 226 - Category Name 227 - </label> 228 - <input 229 - id="cat-name" 230 - type="text" 231 - value={editing.name} 232 - onChange={(e) => setEditing({ ...editing, name: e.target.value })} 233 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 234 - /> 235 - </div> 236 - <div> 237 - <label htmlFor="cat-slug" className="block text-sm font-medium text-foreground"> 238 - Slug 239 - </label> 240 - <input 241 - id="cat-slug" 242 - type="text" 243 - value={editing.slug} 244 - onChange={(e) => setEditing({ ...editing, slug: e.target.value })} 245 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 246 - /> 247 - </div> 248 - <div> 249 - <label htmlFor="cat-desc" className="block text-sm font-medium text-foreground"> 250 - Description 251 - </label> 252 - <textarea 253 - id="cat-desc" 254 - value={editing.description} 255 - onChange={(e) => setEditing({ ...editing, description: e.target.value })} 256 - rows={2} 257 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 258 - /> 259 - </div> 260 - <div> 261 - <label htmlFor="cat-maturity" className="block text-sm font-medium text-foreground"> 262 - Maturity Rating 263 - </label> 264 - <select 265 - id="cat-maturity" 266 - value={editing.maturityRating} 267 - onChange={(e) => 268 - setEditing({ ...editing, maturityRating: e.target.value as MaturityRating }) 269 - } 270 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 271 - > 272 - <option value="safe">Safe</option> 273 - <option value="mature">Mature</option> 274 - <option value="adult">Adult</option> 275 - </select> 276 - </div> 277 - <div className="flex gap-2"> 278 - <button 279 - type="button" 280 - onClick={() => void handleSave()} 281 - className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 282 - > 283 - Save 284 - </button> 285 - <button 286 - type="button" 287 - onClick={() => setEditing(null)} 288 - className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 289 - > 290 - Cancel 291 - </button> 292 - </div> 293 - </div> 294 - </div> 131 + <CategoryForm 132 + editing={editing} 133 + onChange={setEditing} 134 + onSave={() => void handleSave()} 135 + onCancel={() => setEditing(null)} 136 + /> 295 137 )} 296 138 297 - {/* Category list */} 298 139 {loadError && ( 299 140 <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchCategories()} /> 300 141 )}
+12 -458
src/app/admin/moderation/page.tsx
··· 8 8 'use client' 9 9 10 10 import { useState, useEffect, useCallback } from 'react' 11 - import { WarningCircle, ShieldCheck, Clock, Prohibit } from '@phosphor-icons/react' 12 11 import { AdminLayout } from '@/components/admin/admin-layout' 13 12 import { ErrorAlert } from '@/components/error-alert' 13 + import { ModerationReportsTab } from '@/components/admin/moderation/reports-tab' 14 + import { ModerationFirstPostTab } from '@/components/admin/moderation/first-post-tab' 15 + import { ModerationActionLogTab } from '@/components/admin/moderation/action-log-tab' 16 + import { ModerationReportedUsersTab } from '@/components/admin/moderation/reported-users-tab' 17 + import { ModerationThresholdsTab } from '@/components/admin/moderation/thresholds-tab' 14 18 import { 15 19 getModerationReports, 16 20 resolveReport, ··· 42 46 { id: 'thresholds', label: 'Thresholds' }, 43 47 ] 44 48 45 - const RESOLUTION_ACTIONS: { value: ReportResolution; label: string }[] = [ 46 - { value: 'dismissed', label: 'Dismiss' }, 47 - { value: 'warned', label: 'Warn' }, 48 - { value: 'labeled', label: 'Label' }, 49 - { value: 'removed', label: 'Remove' }, 50 - { value: 'banned', label: 'Ban' }, 51 - ] 52 - 53 - const ACTION_TYPE_LABELS: Record<string, string> = { 54 - lock: 'Locked', 55 - unlock: 'Unlocked', 56 - pin: 'Pinned', 57 - unpin: 'Unpinned', 58 - delete: 'Deleted', 59 - ban: 'Banned', 60 - unban: 'Unbanned', 61 - warn: 'Warned', 62 - label: 'Labeled', 63 - approve: 'Approved', 64 - reject: 'Rejected', 65 - } 66 - 67 - function formatDate(dateStr: string) { 68 - return new Date(dateStr).toLocaleDateString('en-US', { 69 - month: 'short', 70 - day: 'numeric', 71 - hour: 'numeric', 72 - minute: '2-digit', 73 - }) 74 - } 75 - 76 - // --- Report Queue Tab --- 77 - function ReportsTab({ 78 - reports, 79 - onResolve, 80 - }: { 81 - reports: ModerationReport[] 82 - onResolve: (id: string, resolution: ReportResolution) => void 83 - }) { 84 - // Sort: potentially illegal first, then by date (newest first) 85 - const sorted = [...reports].sort((a, b) => { 86 - if (a.potentiallyIllegal !== b.potentiallyIllegal) { 87 - return a.potentiallyIllegal ? -1 : 1 88 - } 89 - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 90 - }) 91 - 92 - return ( 93 - <div className="space-y-3"> 94 - {sorted.map((report) => ( 95 - <article 96 - key={report.id} 97 - className={cn( 98 - 'rounded-lg border border-border bg-card p-4', 99 - report.potentiallyIllegal && 'border-l-4 border-l-destructive' 100 - )} 101 - > 102 - <div className="flex items-start justify-between gap-3"> 103 - <div className="min-w-0 flex-1"> 104 - {report.potentiallyIllegal && ( 105 - <span className="mb-1 inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive"> 106 - <WarningCircle size={12} aria-hidden="true" /> 107 - Potentially illegal 108 - </span> 109 - )} 110 - <p className="text-sm font-medium text-foreground"> 111 - <span className="capitalize">{report.reasonType}</span> 112 - {' -- reported by '} 113 - <span className="text-muted-foreground">{report.reporterHandle}</span> 114 - </p> 115 - <p className="mt-1 text-sm text-muted-foreground">{report.targetContent}</p> 116 - {report.reason && ( 117 - <p className="mt-1 text-xs text-muted-foreground italic"> 118 - &ldquo;{report.reason}&rdquo; 119 - </p> 120 - )} 121 - <p className="mt-1 text-xs text-muted-foreground"> 122 - Target: {report.targetAuthorHandle} &middot; {formatDate(report.createdAt)} 123 - </p> 124 - </div> 125 - <div className="flex shrink-0 gap-1"> 126 - {RESOLUTION_ACTIONS.map((action) => ( 127 - <button 128 - key={action.value} 129 - type="button" 130 - onClick={() => onResolve(report.id, action.value)} 131 - className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 132 - > 133 - {action.label} 134 - </button> 135 - ))} 136 - </div> 137 - </div> 138 - </article> 139 - ))} 140 - {sorted.length === 0 && ( 141 - <p className="py-8 text-center text-muted-foreground">No pending reports.</p> 142 - )} 143 - </div> 144 - ) 145 - } 146 - 147 - // --- First Post Queue Tab --- 148 - function FirstPostTab({ 149 - items, 150 - onResolve, 151 - onBatchResolve, 152 - }: { 153 - items: FirstPostQueueItem[] 154 - onResolve: (id: string, action: 'approved' | 'rejected') => void 155 - onBatchResolve: (ids: string[], action: 'approved' | 'rejected') => void 156 - }) { 157 - const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) 158 - 159 - const allSelected = items.length > 0 && selectedIds.size === items.length 160 - 161 - const toggleSelectAll = () => { 162 - if (allSelected) { 163 - setSelectedIds(new Set()) 164 - } else { 165 - setSelectedIds(new Set(items.map((item) => item.id))) 166 - } 167 - } 168 - 169 - const toggleItem = (id: string) => { 170 - setSelectedIds((prev) => { 171 - const next = new Set(prev) 172 - if (next.has(id)) { 173 - next.delete(id) 174 - } else { 175 - next.add(id) 176 - } 177 - return next 178 - }) 179 - } 180 - 181 - const handleBatchAction = (action: 'approved' | 'rejected') => { 182 - const ids = Array.from(selectedIds) 183 - onBatchResolve(ids, action) 184 - setSelectedIds(new Set()) 185 - } 186 - 187 - return ( 188 - <div className="space-y-3"> 189 - {items.length > 0 && ( 190 - <div className="flex items-center justify-between"> 191 - <label className="flex items-center gap-2 text-sm text-muted-foreground"> 192 - <input 193 - type="checkbox" 194 - checked={allSelected} 195 - onChange={toggleSelectAll} 196 - className="rounded border-border" 197 - aria-label="Select all" 198 - /> 199 - Select all ({items.length}) 200 - </label> 201 - {selectedIds.size > 0 && ( 202 - <div className="flex gap-2"> 203 - <button 204 - type="button" 205 - onClick={() => handleBatchAction('approved')} 206 - className="rounded-md bg-green-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-green-700" 207 - > 208 - Approve selected ({selectedIds.size}) 209 - </button> 210 - <button 211 - type="button" 212 - onClick={() => handleBatchAction('rejected')} 213 - className="rounded-md bg-destructive px-3 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 214 - > 215 - Reject selected ({selectedIds.size}) 216 - </button> 217 - </div> 218 - )} 219 - </div> 220 - )} 221 - {items.map((item) => ( 222 - <article key={item.id} className="rounded-lg border border-border bg-card p-4"> 223 - <div className="flex items-start gap-3"> 224 - <input 225 - type="checkbox" 226 - checked={selectedIds.has(item.id)} 227 - onChange={() => toggleItem(item.id)} 228 - className="mt-1 rounded border-border" 229 - aria-label={`Select post by ${item.authorHandle}`} 230 - /> 231 - <div className="min-w-0 flex-1"> 232 - <p className="text-sm font-medium text-foreground">{item.authorHandle}</p> 233 - <div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground"> 234 - <span className="inline-flex items-center gap-1"> 235 - <Clock size={12} aria-hidden="true" /> 236 - New account, {item.accountAge} old 237 - </span> 238 - {item.crossCommunityCount > 0 && ( 239 - <span className="inline-flex items-center gap-1"> 240 - <ShieldCheck size={12} aria-hidden="true" /> 241 - Active in {item.crossCommunityCount} other communities 242 - </span> 243 - )} 244 - {item.bannedFromOtherCommunities > 0 && ( 245 - <span className="inline-flex items-center gap-1 font-medium text-destructive"> 246 - <Prohibit size={12} aria-hidden="true" /> 247 - Banned from {item.bannedFromOtherCommunities} other{' '} 248 - {item.bannedFromOtherCommunities === 1 ? 'community' : 'communities'} 249 - </span> 250 - )} 251 - </div> 252 - {item.title && ( 253 - <p className="mt-2 text-sm font-medium text-foreground">{item.title}</p> 254 - )} 255 - <p className="mt-1 text-sm text-muted-foreground">{item.content}</p> 256 - <p className="mt-1 text-xs text-muted-foreground"> 257 - {item.contentType === 'topic' ? 'Topic' : 'Reply'} &middot;{' '} 258 - {formatDate(item.createdAt)} 259 - </p> 260 - </div> 261 - <div className="flex shrink-0 gap-2"> 262 - <button 263 - type="button" 264 - onClick={() => onResolve(item.id, 'approved')} 265 - className="rounded-md bg-green-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-green-700" 266 - > 267 - Approve 268 - </button> 269 - <button 270 - type="button" 271 - onClick={() => onResolve(item.id, 'rejected')} 272 - className="rounded-md bg-destructive px-3 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 273 - > 274 - Reject 275 - </button> 276 - </div> 277 - </div> 278 - </article> 279 - ))} 280 - {items.length === 0 && ( 281 - <p className="py-8 text-center text-muted-foreground">No posts awaiting approval.</p> 282 - )} 283 - </div> 284 - ) 285 - } 286 - 287 - // --- Action Log Tab --- 288 - function ActionLogTab({ entries }: { entries: ModerationLogEntry[] }) { 289 - return ( 290 - <div className="space-y-2"> 291 - {entries.map((entry) => ( 292 - <div key={entry.id} className="rounded-md border border-border bg-card p-3"> 293 - <div className="flex items-center justify-between"> 294 - <p className="text-sm text-foreground"> 295 - <span className="font-medium">{entry.moderatorHandle}</span>{' '} 296 - <span className="text-muted-foreground"> 297 - {ACTION_TYPE_LABELS[entry.actionType] ?? entry.actionType} 298 - </span> 299 - {entry.targetHandle && ( 300 - <span className="text-muted-foreground"> {entry.targetHandle}</span> 301 - )} 302 - </p> 303 - <span className="text-xs text-muted-foreground">{formatDate(entry.createdAt)}</span> 304 - </div> 305 - {entry.reason && ( 306 - <p className="mt-1 text-xs text-muted-foreground italic">{entry.reason}</p> 307 - )} 308 - </div> 309 - ))} 310 - {entries.length === 0 && ( 311 - <p className="py-8 text-center text-muted-foreground">No moderation actions recorded.</p> 312 - )} 313 - </div> 314 - ) 315 - } 316 - 317 - // --- Reported Users Tab --- 318 - function ReportedUsersTab({ users }: { users: ReportedUser[] }) { 319 - return ( 320 - <div className="space-y-2"> 321 - {users.map((user) => ( 322 - <div key={user.did} className="rounded-md border border-border bg-card p-3"> 323 - <div className="flex items-center justify-between"> 324 - <div> 325 - <p className="text-sm font-medium text-foreground">{user.handle}</p> 326 - <p className="text-xs text-muted-foreground"> 327 - {user.reportCount} reports &middot; Latest: {formatDate(user.latestReportAt)} 328 - </p> 329 - {user.bannedFromOtherCommunities > 0 && ( 330 - <p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-destructive"> 331 - <Prohibit size={12} aria-hidden="true" /> 332 - Banned from {user.bannedFromOtherCommunities} other communities 333 - </p> 334 - )} 335 - </div> 336 - </div> 337 - </div> 338 - ))} 339 - {users.length === 0 && ( 340 - <p className="py-8 text-center text-muted-foreground">No reported users.</p> 341 - )} 342 - </div> 343 - ) 344 - } 345 - 346 - // --- Thresholds Tab --- 347 - function ThresholdsTab({ 348 - thresholds, 349 - onSave, 350 - }: { 351 - thresholds: ModerationThresholds 352 - onSave: (updated: Partial<ModerationThresholds>) => void 353 - }) { 354 - const [values, setValues] = useState(thresholds) 355 - 356 - return ( 357 - <div className="max-w-lg space-y-4"> 358 - <div> 359 - <label htmlFor="threshold-autoblock" className="block text-sm font-medium text-foreground"> 360 - Auto-block report count 361 - </label> 362 - <input 363 - id="threshold-autoblock" 364 - type="number" 365 - min={1} 366 - value={values.autoBlockReportCount} 367 - onChange={(e) => 368 - setValues({ ...values, autoBlockReportCount: parseInt(e.target.value, 10) || 1 }) 369 - } 370 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 371 - /> 372 - </div> 373 - <div> 374 - <label htmlFor="threshold-warn" className="block text-sm font-medium text-foreground"> 375 - Warn threshold 376 - </label> 377 - <input 378 - id="threshold-warn" 379 - type="number" 380 - min={1} 381 - value={values.warnThreshold} 382 - onChange={(e) => 383 - setValues({ ...values, warnThreshold: parseInt(e.target.value, 10) || 1 }) 384 - } 385 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 386 - /> 387 - </div> 388 - <div> 389 - <label htmlFor="threshold-fpq" className="block text-sm font-medium text-foreground"> 390 - First-post queue count (0 to disable) 391 - </label> 392 - <input 393 - id="threshold-fpq" 394 - type="number" 395 - min={0} 396 - value={values.firstPostQueueCount} 397 - onChange={(e) => 398 - setValues({ ...values, firstPostQueueCount: parseInt(e.target.value, 10) || 0 }) 399 - } 400 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 401 - /> 402 - </div> 403 - <div> 404 - <label htmlFor="threshold-ratelimit" className="block text-sm font-medium text-foreground"> 405 - New account rate limit (writes/min) 406 - </label> 407 - <input 408 - id="threshold-ratelimit" 409 - type="number" 410 - min={1} 411 - value={values.newAccountRateLimit} 412 - onChange={(e) => 413 - setValues({ ...values, newAccountRateLimit: parseInt(e.target.value, 10) || 1 }) 414 - } 415 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 416 - /> 417 - </div> 418 - <fieldset className="space-y-3"> 419 - <legend className="text-sm font-medium text-foreground">Anti-spam settings</legend> 420 - <div className="flex items-center gap-2"> 421 - <input 422 - id="threshold-linkhold" 423 - type="checkbox" 424 - checked={values.linkPostingHold} 425 - onChange={(e) => setValues({ ...values, linkPostingHold: e.target.checked })} 426 - className="rounded border-border" 427 - /> 428 - <label htmlFor="threshold-linkhold" className="text-sm text-foreground"> 429 - Hold posts with links from new accounts for review 430 - </label> 431 - </div> 432 - <div className="flex items-center gap-2"> 433 - <input 434 - id="threshold-topicdelay" 435 - type="checkbox" 436 - checked={values.topicCreationDelay} 437 - onChange={(e) => setValues({ ...values, topicCreationDelay: e.target.checked })} 438 - className="rounded border-border" 439 - /> 440 - <label htmlFor="threshold-topicdelay" className="text-sm text-foreground"> 441 - Delay topic creation for new accounts 442 - </label> 443 - </div> 444 - </fieldset> 445 - <div className="flex gap-4"> 446 - <div> 447 - <label 448 - htmlFor="threshold-burstcount" 449 - className="block text-sm font-medium text-foreground" 450 - > 451 - Burst detection: posts 452 - </label> 453 - <input 454 - id="threshold-burstcount" 455 - type="number" 456 - min={1} 457 - value={values.burstDetectionPostCount} 458 - onChange={(e) => 459 - setValues({ 460 - ...values, 461 - burstDetectionPostCount: parseInt(e.target.value, 10) || 1, 462 - }) 463 - } 464 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 465 - /> 466 - </div> 467 - <div> 468 - <label htmlFor="threshold-burstmin" className="block text-sm font-medium text-foreground"> 469 - in minutes 470 - </label> 471 - <input 472 - id="threshold-burstmin" 473 - type="number" 474 - min={1} 475 - value={values.burstDetectionMinutes} 476 - onChange={(e) => 477 - setValues({ 478 - ...values, 479 - burstDetectionMinutes: parseInt(e.target.value, 10) || 1, 480 - }) 481 - } 482 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 483 - /> 484 - </div> 485 - </div> 486 - <button 487 - type="button" 488 - onClick={() => onSave(values)} 489 - className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 490 - > 491 - Save Thresholds 492 - </button> 493 - </div> 494 - ) 495 - } 496 - 497 49 export default function AdminModerationPage() { 498 50 const { getAccessToken } = useAuth() 499 51 const [activeTab, setActiveTab] = useState<TabId>('reports') ··· 624 176 hidden={activeTab !== 'reports'} 625 177 > 626 178 {activeTab === 'reports' && ( 627 - <ReportsTab 179 + <ModerationReportsTab 628 180 reports={reports} 629 181 onResolve={(id, res) => void handleResolveReport(id, res)} 630 182 /> ··· 637 189 hidden={activeTab !== 'first-post'} 638 190 > 639 191 {activeTab === 'first-post' && ( 640 - <FirstPostTab 192 + <ModerationFirstPostTab 641 193 items={firstPostQueue} 642 194 onResolve={(id, action) => void handleResolveFirstPost(id, action)} 643 195 onBatchResolve={(ids, action) => void handleBatchResolveFirstPost(ids, action)} ··· 650 202 aria-labelledby="tab-action-log" 651 203 hidden={activeTab !== 'action-log'} 652 204 > 653 - {activeTab === 'action-log' && <ActionLogTab entries={moderationLog} />} 205 + {activeTab === 'action-log' && <ModerationActionLogTab entries={moderationLog} />} 654 206 </div> 655 207 <div 656 208 role="tabpanel" ··· 658 210 aria-labelledby="tab-reported-users" 659 211 hidden={activeTab !== 'reported-users'} 660 212 > 661 - {activeTab === 'reported-users' && <ReportedUsersTab users={reportedUsers} />} 213 + {activeTab === 'reported-users' && ( 214 + <ModerationReportedUsersTab users={reportedUsers} /> 215 + )} 662 216 </div> 663 217 <div 664 218 role="tabpanel" ··· 667 221 hidden={activeTab !== 'thresholds'} 668 222 > 669 223 {activeTab === 'thresholds' && thresholds && ( 670 - <ThresholdsTab 224 + <ModerationThresholdsTab 671 225 thresholds={thresholds} 672 226 onSave={(updated) => void handleSaveThresholds(updated)} 673 227 />
+25 -188
src/app/admin/onboarding/page.tsx
··· 7 7 'use client' 8 8 9 9 import { useState, useEffect, useCallback } from 'react' 10 - import { Plus, PencilSimple, TrashSimple, ArrowUp, ArrowDown } from '@phosphor-icons/react' 10 + import { Plus } from '@phosphor-icons/react' 11 11 import { AdminLayout } from '@/components/admin/admin-layout' 12 12 import { ErrorAlert } from '@/components/error-alert' 13 13 import { 14 + OnboardingFieldForm, 15 + EMPTY_FIELD, 16 + } from '@/components/admin/onboarding/onboarding-field-form' 17 + import { OnboardingFieldItem } from '@/components/admin/onboarding/onboarding-field-item' 18 + import { 14 19 getOnboardingFields, 15 20 createOnboardingField, 16 21 updateOnboardingField, 17 22 deleteOnboardingField, 18 23 reorderOnboardingFields, 19 24 } from '@/lib/api/client' 20 - import { cn } from '@/lib/utils' 21 - import type { 22 - OnboardingField, 23 - OnboardingFieldType, 24 - CreateOnboardingFieldInput, 25 - } from '@/lib/api/types' 25 + import type { OnboardingField, CreateOnboardingFieldInput } from '@/lib/api/types' 26 + import type { EditingField } from '@/components/admin/onboarding/onboarding-field-form' 26 27 import { useAuth } from '@/hooks/use-auth' 27 - 28 - const FIELD_TYPE_LABELS: Record<OnboardingFieldType, string> = { 29 - age_confirmation: 'Age Confirmation', 30 - tos_acceptance: 'ToS Acceptance', 31 - newsletter_email: 'Newsletter Email', 32 - custom_text: 'Text Input', 33 - custom_select: 'Dropdown Select', 34 - custom_checkbox: 'Checkbox', 35 - } 36 - 37 - interface EditingField { 38 - id: string | null 39 - fieldType: OnboardingFieldType 40 - label: string 41 - description: string 42 - isMandatory: boolean 43 - config: Record<string, unknown> | null 44 - } 45 - 46 - const EMPTY_FIELD: EditingField = { 47 - id: null, 48 - fieldType: 'custom_text', 49 - label: '', 50 - description: '', 51 - isMandatory: true, 52 - config: null, 53 - } 54 28 55 29 export default function AdminOnboardingPage() { 56 30 const { getAccessToken } = useAuth() ··· 194 168 195 169 {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 196 170 197 - {/* Edit/Create form */} 198 171 {editing && ( 199 - <div className="rounded-lg border border-border bg-card p-4"> 200 - <h2 className="mb-4 text-lg font-semibold text-foreground"> 201 - {editing.id ? 'Edit Field' : 'New Onboarding Field'} 202 - </h2> 203 - <div className="space-y-4"> 204 - {!editing.id && ( 205 - <div> 206 - <label htmlFor="field-type" className="block text-sm font-medium text-foreground"> 207 - Field Type 208 - </label> 209 - <select 210 - id="field-type" 211 - value={editing.fieldType} 212 - onChange={(e) => 213 - setEditing({ ...editing, fieldType: e.target.value as OnboardingFieldType }) 214 - } 215 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 216 - > 217 - {Object.entries(FIELD_TYPE_LABELS).map(([value, label]) => ( 218 - <option key={value} value={value}> 219 - {label} 220 - </option> 221 - ))} 222 - </select> 223 - </div> 224 - )} 225 - <div> 226 - <label htmlFor="field-label" className="block text-sm font-medium text-foreground"> 227 - Label 228 - </label> 229 - <input 230 - id="field-label" 231 - type="text" 232 - value={editing.label} 233 - onChange={(e) => setEditing({ ...editing, label: e.target.value })} 234 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 235 - placeholder="e.g., Accept our community rules" 236 - /> 237 - </div> 238 - <div> 239 - <label 240 - htmlFor="field-description" 241 - className="block text-sm font-medium text-foreground" 242 - > 243 - Description (optional) 244 - </label> 245 - <textarea 246 - id="field-description" 247 - value={editing.description} 248 - onChange={(e) => setEditing({ ...editing, description: e.target.value })} 249 - rows={2} 250 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 251 - placeholder="Additional context or instructions for users" 252 - /> 253 - </div> 254 - <div className="flex items-center gap-2"> 255 - <input 256 - id="field-mandatory" 257 - type="checkbox" 258 - checked={editing.isMandatory} 259 - onChange={(e) => setEditing({ ...editing, isMandatory: e.target.checked })} 260 - className="h-4 w-4 rounded border-border" 261 - /> 262 - <label htmlFor="field-mandatory" className="text-sm text-foreground"> 263 - Required (users must complete this field before posting) 264 - </label> 265 - </div> 266 - {error && ( 267 - <p role="alert" className="text-sm text-destructive"> 268 - {error} 269 - </p> 270 - )} 271 - <div className="flex gap-2"> 272 - <button 273 - type="button" 274 - onClick={() => void handleSave()} 275 - disabled={saving} 276 - className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 277 - > 278 - {saving ? 'Saving...' : 'Save'} 279 - </button> 280 - <button 281 - type="button" 282 - onClick={() => setEditing(null)} 283 - className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 284 - > 285 - Cancel 286 - </button> 287 - </div> 288 - </div> 289 - </div> 172 + <OnboardingFieldForm 173 + editing={editing} 174 + saving={saving} 175 + error={error} 176 + onChange={setEditing} 177 + onSave={() => void handleSave()} 178 + onCancel={() => setEditing(null)} 179 + /> 290 180 )} 291 181 292 - {/* Field list */} 293 182 {loadError && ( 294 183 <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchFields()} /> 295 184 )} ··· 306 195 {!loading && fields.length > 0 && ( 307 196 <div className="space-y-2"> 308 197 {fields.map((field, index) => ( 309 - <div 198 + <OnboardingFieldItem 310 199 key={field.id} 311 - className="flex items-center justify-between rounded-md border border-border bg-card p-3" 312 - > 313 - <div className="min-w-0 flex-1"> 314 - <div className="flex items-center gap-2"> 315 - <p className="text-sm font-medium text-foreground">{field.label}</p> 316 - <span 317 - className={cn( 318 - 'rounded-full px-2 py-0.5 text-xs font-medium', 319 - 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' 320 - )} 321 - > 322 - {FIELD_TYPE_LABELS[field.fieldType]} 323 - </span> 324 - {field.isMandatory && ( 325 - <span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400"> 326 - Required 327 - </span> 328 - )} 329 - </div> 330 - {field.description && ( 331 - <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 332 - )} 333 - </div> 334 - <div className="flex items-center gap-1"> 335 - <button 336 - type="button" 337 - onClick={() => void handleMoveUp(index)} 338 - disabled={index === 0} 339 - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 340 - aria-label={`Move ${field.label} up`} 341 - > 342 - <ArrowUp size={16} aria-hidden="true" /> 343 - </button> 344 - <button 345 - type="button" 346 - onClick={() => void handleMoveDown(index)} 347 - disabled={index === fields.length - 1} 348 - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 349 - aria-label={`Move ${field.label} down`} 350 - > 351 - <ArrowDown size={16} aria-hidden="true" /> 352 - </button> 353 - <button 354 - type="button" 355 - onClick={() => handleEdit(field)} 356 - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 357 - aria-label={`Edit ${field.label}`} 358 - > 359 - <PencilSimple size={16} aria-hidden="true" /> 360 - </button> 361 - <button 362 - type="button" 363 - onClick={() => void handleDelete(field.id)} 364 - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" 365 - aria-label={`Delete ${field.label}`} 366 - > 367 - <TrashSimple size={16} aria-hidden="true" /> 368 - </button> 369 - </div> 370 - </div> 200 + field={field} 201 + index={index} 202 + totalCount={fields.length} 203 + onMoveUp={(i) => void handleMoveUp(i)} 204 + onMoveDown={(i) => void handleMoveDown(i)} 205 + onEdit={handleEdit} 206 + onDelete={(id) => void handleDelete(id)} 207 + /> 371 208 ))} 372 209 </div> 373 210 )}
+11 -257
src/app/admin/plugins/page.tsx
··· 9 9 'use client' 10 10 11 11 import { useState, useEffect, useCallback } from 'react' 12 - import { Gear, Trash, WarningCircle, X } from '@phosphor-icons/react' 12 + import { WarningCircle } from '@phosphor-icons/react' 13 13 import { AdminLayout } from '@/components/admin/admin-layout' 14 14 import { ErrorAlert } from '@/components/error-alert' 15 + import { PluginCard } from '@/components/admin/plugins/plugin-card' 16 + import { PluginSettingsModal } from '@/components/admin/plugins/plugin-settings-modal' 15 17 import { getPlugins, togglePlugin, updatePluginSettings, uninstallPlugin } from '@/lib/api/client' 16 - import { cn } from '@/lib/utils' 17 - import type { Plugin, PluginSettingsSchema } from '@/lib/api/types' 18 + import type { Plugin } from '@/lib/api/types' 18 19 import { useAuth } from '@/hooks/use-auth' 19 20 20 - const SOURCE_STYLES: Record<string, string> = { 21 - core: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', 22 - official: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', 23 - community: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 24 - experimental: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', 25 - } 26 - 27 - const SOURCE_LABELS: Record<string, string> = { 28 - core: 'Core', 29 - official: 'Official', 30 - community: 'Community', 31 - experimental: 'Experimental', 32 - } 33 - 34 - function PluginSettingsModal({ 35 - plugin, 36 - onClose, 37 - onSave, 38 - }: { 39 - plugin: Plugin 40 - onClose: () => void 41 - onSave: (settings: Record<string, boolean | string | number>) => void 42 - }) { 43 - const [values, setValues] = useState<Record<string, boolean | string | number>>(() => ({ 44 - ...plugin.settings, 45 - })) 46 - 47 - const handleChange = (key: string, value: boolean | string | number) => { 48 - setValues((prev) => ({ ...prev, [key]: value })) 49 - } 50 - 51 - const handleSubmit = (e: React.FormEvent) => { 52 - e.preventDefault() 53 - onSave(values) 54 - } 55 - 56 - return ( 57 - <div 58 - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 59 - role="dialog" 60 - aria-modal="true" 61 - aria-label={`${plugin.displayName} settings`} 62 - > 63 - <div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 64 - <div className="mb-4 flex items-center justify-between"> 65 - <h2 className="text-lg font-semibold text-foreground">{plugin.displayName} Settings</h2> 66 - <button 67 - type="button" 68 - onClick={onClose} 69 - className="rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground" 70 - aria-label="Close settings" 71 - > 72 - <X size={18} aria-hidden="true" /> 73 - </button> 74 - </div> 75 - 76 - <form onSubmit={handleSubmit} className="space-y-4"> 77 - {Object.entries(plugin.settingsSchema).map(([key, schema]) => ( 78 - <SettingsField 79 - key={key} 80 - fieldKey={key} 81 - schema={schema} 82 - value={values[key] ?? schema.default} 83 - onChange={(val) => handleChange(key, val)} 84 - /> 85 - ))} 86 - 87 - <div className="flex justify-end gap-2 pt-2"> 88 - <button 89 - type="button" 90 - onClick={onClose} 91 - className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 92 - > 93 - Cancel 94 - </button> 95 - <button 96 - type="submit" 97 - className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 98 - > 99 - Save 100 - </button> 101 - </div> 102 - </form> 103 - </div> 104 - </div> 105 - ) 106 - } 107 - 108 - function SettingsField({ 109 - fieldKey, 110 - schema, 111 - value, 112 - onChange, 113 - }: { 114 - fieldKey: string 115 - schema: PluginSettingsSchema[string] 116 - value: boolean | string | number 117 - onChange: (value: boolean | string | number) => void 118 - }) { 119 - if (schema.type === 'boolean') { 120 - return ( 121 - <label className="flex items-center justify-between gap-3"> 122 - <div> 123 - <span className="text-sm font-medium text-foreground">{schema.label}</span> 124 - {schema.description && ( 125 - <p className="text-xs text-muted-foreground">{schema.description}</p> 126 - )} 127 - </div> 128 - <input 129 - type="checkbox" 130 - checked={value as boolean} 131 - onChange={(e) => onChange(e.target.checked)} 132 - className="h-4 w-4 rounded border-border" 133 - /> 134 - </label> 135 - ) 136 - } 137 - 138 - if (schema.type === 'select') { 139 - return ( 140 - <label className="block"> 141 - <span className="text-sm font-medium text-foreground">{schema.label}</span> 142 - {schema.description && ( 143 - <p className="text-xs text-muted-foreground">{schema.description}</p> 144 - )} 145 - <select 146 - value={value as string} 147 - onChange={(e) => onChange(e.target.value)} 148 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground" 149 - > 150 - {schema.options?.map((opt) => ( 151 - <option key={opt} value={opt}> 152 - {opt || '(none)'} 153 - </option> 154 - ))} 155 - </select> 156 - </label> 157 - ) 158 - } 159 - 160 - if (schema.type === 'number') { 161 - return ( 162 - <label className="block"> 163 - <span className="text-sm font-medium text-foreground">{schema.label}</span> 164 - {schema.description && ( 165 - <p className="text-xs text-muted-foreground">{schema.description}</p> 166 - )} 167 - <input 168 - type="number" 169 - value={value as number} 170 - onChange={(e) => onChange(Number(e.target.value))} 171 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground" 172 - name={fieldKey} 173 - /> 174 - </label> 175 - ) 176 - } 177 - 178 - // string 179 - return ( 180 - <label className="block"> 181 - <span className="text-sm font-medium text-foreground">{schema.label}</span> 182 - {schema.description && <p className="text-xs text-muted-foreground">{schema.description}</p>} 183 - <input 184 - type="text" 185 - value={value as string} 186 - onChange={(e) => onChange(e.target.value)} 187 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground" 188 - name={fieldKey} 189 - /> 190 - </label> 191 - ) 192 - } 193 - 194 21 export default function AdminPluginsPage() { 195 22 const { getAccessToken } = useAuth() 196 23 const [plugins, setPlugins] = useState<Plugin[]>([]) ··· 305 132 {!loading && plugins.length > 0 && ( 306 133 <div className="space-y-3"> 307 134 {plugins.map((plugin) => ( 308 - <article 135 + <PluginCard 309 136 key={plugin.id} 310 - className={cn( 311 - 'rounded-lg border border-border bg-card p-4', 312 - !plugin.enabled && 'opacity-60' 313 - )} 314 - > 315 - <div className="flex items-start justify-between gap-4"> 316 - <div className="min-w-0 flex-1"> 317 - <div className="flex items-center gap-2"> 318 - <h2 className="text-sm font-semibold text-foreground"> 319 - {plugin.displayName} 320 - </h2> 321 - <span className="text-xs text-muted-foreground">v{plugin.version}</span> 322 - <span 323 - className={cn( 324 - 'rounded-full px-2 py-0.5 text-xs font-medium', 325 - SOURCE_STYLES[plugin.source] ?? SOURCE_STYLES.community 326 - )} 327 - > 328 - {SOURCE_LABELS[plugin.source] ?? plugin.source} 329 - </span> 330 - </div> 331 - <p className="mt-1 text-xs text-muted-foreground">{plugin.description}</p> 332 - {plugin.dependencies.length > 0 && ( 333 - <p className="mt-1 text-xs text-muted-foreground"> 334 - Depends on:{' '} 335 - {plugin.dependencies 336 - .map((depId) => { 337 - const dep = plugins.find((p) => p.id === depId) 338 - return dep?.displayName ?? depId 339 - }) 340 - .join(', ')} 341 - </p> 342 - )} 343 - </div> 344 - 345 - <div className="flex shrink-0 items-center gap-2"> 346 - {Object.keys(plugin.settingsSchema).length > 0 && ( 347 - <button 348 - type="button" 349 - onClick={() => setSettingsPlugin(plugin)} 350 - className="rounded-md border border-border p-1.5 text-muted-foreground transition-colors hover:text-foreground" 351 - aria-label={`${plugin.displayName} settings`} 352 - > 353 - <Gear size={16} aria-hidden="true" /> 354 - </button> 355 - )} 356 - {plugin.source !== 'core' && ( 357 - <button 358 - type="button" 359 - onClick={() => void handleUninstall(plugin)} 360 - className="rounded-md border border-border p-1.5 text-muted-foreground transition-colors hover:text-destructive" 361 - aria-label={`Uninstall ${plugin.displayName}`} 362 - > 363 - <Trash size={16} aria-hidden="true" /> 364 - </button> 365 - )} 366 - <button 367 - type="button" 368 - role="switch" 369 - aria-checked={plugin.enabled} 370 - aria-label={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} 371 - onClick={() => void handleToggle(plugin)} 372 - className={cn( 373 - 'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors', 374 - plugin.enabled ? 'bg-primary' : 'bg-muted' 375 - )} 376 - > 377 - <span 378 - aria-hidden="true" 379 - className={cn( 380 - 'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition-transform', 381 - plugin.enabled ? 'translate-x-5' : 'translate-x-0' 382 - )} 383 - /> 384 - </button> 385 - </div> 386 - </div> 387 - </article> 137 + plugin={plugin} 138 + allPlugins={plugins} 139 + onOpenSettings={setSettingsPlugin} 140 + onToggle={(p) => void handleToggle(p)} 141 + onUninstall={(p) => void handleUninstall(p)} 142 + /> 388 143 ))} 389 144 </div> 390 145 )} ··· 427 182 </div> 428 183 )} 429 184 430 - {/* Settings Modal */} 431 185 {settingsPlugin && ( 432 186 <PluginSettingsModal 433 187 plugin={settingsPlugin}
+12 -448
src/app/admin/settings/page.tsx
··· 7 7 8 8 'use client' 9 9 10 - import { useState, useEffect, useCallback, useRef } from 'react' 10 + import { useState, useEffect, useCallback } from 'react' 11 11 import { AdminLayout } from '@/components/admin/admin-layout' 12 12 import { ErrorAlert } from '@/components/error-alert' 13 + import { CommunitySettingsForm } from '@/components/admin/settings/community-settings-form' 14 + import { PdsTrustSection } from '@/components/admin/settings/pds-trust-section' 13 15 import { 14 16 getCommunitySettings, 15 17 updateCommunitySettings, 16 18 getPdsTrustFactors, 17 19 updatePdsTrustFactor, 18 20 } from '@/lib/api/client' 19 - import { cn } from '@/lib/utils' 20 - import type { CommunitySettings, MaturityRating, PdsTrustFactor } from '@/lib/api/types' 21 + import type { CommunitySettings, PdsTrustFactor } from '@/lib/api/types' 21 22 import { useAuth } from '@/hooks/use-auth' 22 23 23 - // --- Confirm Dialog --- 24 - function ConfirmDialog({ 25 - open, 26 - title, 27 - message, 28 - onConfirm, 29 - onCancel, 30 - }: { 31 - open: boolean 32 - title: string 33 - message: string 34 - onConfirm: () => void 35 - onCancel: () => void 36 - }) { 37 - const confirmRef = useRef<HTMLButtonElement>(null) 38 - 39 - useEffect(() => { 40 - if (open) { 41 - confirmRef.current?.focus() 42 - } 43 - }, [open]) 44 - 45 - useEffect(() => { 46 - if (!open) return 47 - const handleKey = (e: KeyboardEvent) => { 48 - if (e.key === 'Escape') onCancel() 49 - } 50 - document.addEventListener('keydown', handleKey) 51 - return () => document.removeEventListener('keydown', handleKey) 52 - }, [open, onCancel]) 53 - 54 - if (!open) return null 55 - 56 - return ( 57 - <div 58 - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 59 - role="dialog" 60 - aria-modal="true" 61 - aria-labelledby="confirm-dialog-title" 62 - aria-describedby="confirm-dialog-message" 63 - > 64 - <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 65 - <h3 id="confirm-dialog-title" className="text-lg font-semibold text-foreground"> 66 - {title} 67 - </h3> 68 - <p id="confirm-dialog-message" className="mt-2 text-sm text-muted-foreground"> 69 - {message} 70 - </p> 71 - <div className="mt-4 flex justify-end gap-2"> 72 - <button 73 - type="button" 74 - onClick={onCancel} 75 - className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 76 - > 77 - Cancel 78 - </button> 79 - <button 80 - ref={confirmRef} 81 - type="button" 82 - onClick={onConfirm} 83 - className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 84 - > 85 - Confirm 86 - </button> 87 - </div> 88 - </div> 89 - </div> 90 - ) 91 - } 92 - 93 - // --- Add/Edit PDS Override Dialog --- 94 - function PdsOverrideDialog({ 95 - open, 96 - mode, 97 - initialHostname, 98 - initialTrustFactor, 99 - onClose, 100 - onSubmit, 101 - }: { 102 - open: boolean 103 - mode: 'add' | 'edit' 104 - initialHostname: string 105 - initialTrustFactor: number 106 - onClose: () => void 107 - onSubmit: (hostname: string, trustFactor: number) => void 108 - }) { 109 - const [hostname, setHostname] = useState(initialHostname) 110 - const [trustFactor, setTrustFactor] = useState(initialTrustFactor) 111 - const hostnameRef = useRef<HTMLInputElement>(null) 112 - 113 - useEffect(() => { 114 - if (open && mode === 'add') { 115 - hostnameRef.current?.focus() 116 - } 117 - }, [open, mode]) 118 - 119 - useEffect(() => { 120 - if (!open) return 121 - const handleKey = (e: KeyboardEvent) => { 122 - if (e.key === 'Escape') onClose() 123 - } 124 - document.addEventListener('keydown', handleKey) 125 - return () => document.removeEventListener('keydown', handleKey) 126 - }, [open, onClose]) 127 - 128 - if (!open) return null 129 - 130 - const handleSubmit = (e: React.FormEvent) => { 131 - e.preventDefault() 132 - if (mode === 'add' && !hostname.trim()) return 133 - onSubmit(hostname.trim(), trustFactor) 134 - } 135 - 136 - return ( 137 - <div 138 - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 139 - role="dialog" 140 - aria-modal="true" 141 - aria-label={mode === 'add' ? 'Add PDS trust override' : 'Edit PDS trust factor'} 142 - > 143 - <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 144 - <h3 className="text-lg font-semibold text-foreground"> 145 - {mode === 'add' ? 'Add PDS Override' : 'Edit Trust Factor'} 146 - </h3> 147 - <form onSubmit={handleSubmit} className="mt-4 space-y-4"> 148 - <div> 149 - <label htmlFor="pds-hostname" className="block text-sm font-medium text-foreground"> 150 - PDS Hostname 151 - </label> 152 - <input 153 - ref={hostnameRef} 154 - id="pds-hostname" 155 - type="text" 156 - value={hostname} 157 - onChange={(e) => setHostname(e.target.value)} 158 - disabled={mode === 'edit'} 159 - placeholder="my-pds.example.org" 160 - className={cn( 161 - 'mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 162 - mode === 'edit' && 'cursor-not-allowed opacity-60' 163 - )} 164 - required 165 - /> 166 - </div> 167 - <div> 168 - <label htmlFor="pds-trust-factor" className="block text-sm font-medium text-foreground"> 169 - Trust Factor: {trustFactor.toFixed(1)} 170 - </label> 171 - <input 172 - id="pds-trust-factor" 173 - type="range" 174 - min="0" 175 - max="1" 176 - step="0.1" 177 - value={trustFactor} 178 - onChange={(e) => setTrustFactor(parseFloat(e.target.value))} 179 - className="mt-1 w-full" 180 - /> 181 - <div className="mt-1 flex justify-between text-xs text-muted-foreground"> 182 - <span>0.0 (untrusted)</span> 183 - <span>1.0 (fully trusted)</span> 184 - </div> 185 - </div> 186 - <div className="flex justify-end gap-2"> 187 - <button 188 - type="button" 189 - onClick={onClose} 190 - className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 191 - > 192 - Cancel 193 - </button> 194 - <button 195 - type="submit" 196 - className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 197 - > 198 - {mode === 'add' ? 'Add' : 'Save'} 199 - </button> 200 - </div> 201 - </form> 202 - </div> 203 - </div> 204 - ) 205 - } 206 - 207 - // --- PDS Provider Trust Section --- 208 - function PdsTrustSection({ 209 - providers, 210 - onUpdate, 211 - onRemove, 212 - }: { 213 - providers: PdsTrustFactor[] 214 - onUpdate: (pdsHost: string, trustFactor: number) => void 215 - onRemove: (pdsHost: string) => void 216 - }) { 217 - const [dialogOpen, setDialogOpen] = useState(false) 218 - const [dialogKey, setDialogKey] = useState(0) 219 - const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add') 220 - const [editHostname, setEditHostname] = useState('') 221 - const [editTrustFactor, setEditTrustFactor] = useState(1.0) 222 - const [confirmRemove, setConfirmRemove] = useState<string | null>(null) 223 - 224 - const handleAdd = () => { 225 - setDialogMode('add') 226 - setEditHostname('') 227 - setEditTrustFactor(1.0) 228 - setDialogKey((k) => k + 1) 229 - setDialogOpen(true) 230 - } 231 - 232 - const handleEdit = (provider: PdsTrustFactor) => { 233 - setDialogMode('edit') 234 - setEditHostname(provider.pdsHost) 235 - setEditTrustFactor(provider.trustFactor) 236 - setDialogKey((k) => k + 1) 237 - setDialogOpen(true) 238 - } 239 - 240 - const handleDialogSubmit = (hostname: string, trustFactor: number) => { 241 - onUpdate(hostname, trustFactor) 242 - setDialogOpen(false) 243 - } 244 - 245 - return ( 246 - <div className="space-y-4"> 247 - <div className="flex items-center justify-between"> 248 - <div> 249 - <h2 className="text-lg font-semibold text-foreground">PDS Provider Trust</h2> 250 - <p className="mt-0.5 text-sm text-muted-foreground"> 251 - Accounts from providers with higher trust factors earn reputation faster. Override the 252 - default if you trust a specific self-hosted PDS provider. 253 - </p> 254 - </div> 255 - <button 256 - type="button" 257 - onClick={handleAdd} 258 - aria-label="Add override" 259 - className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 260 - > 261 - Add Override 262 - </button> 263 - </div> 264 - 265 - <div className="space-y-2"> 266 - {providers.map((provider) => ( 267 - <div 268 - key={provider.pdsHost} 269 - className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3" 270 - > 271 - <div className="min-w-0 flex-1"> 272 - <div className="flex items-center gap-2"> 273 - <span className="text-sm font-medium text-foreground">{provider.pdsHost}</span> 274 - {provider.isDefault && ( 275 - <span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"> 276 - Default 277 - </span> 278 - )} 279 - </div> 280 - <p className="mt-0.5 text-xs text-muted-foreground"> 281 - Trust factor: {provider.trustFactor.toFixed(1)} 282 - </p> 283 - </div> 284 - {!provider.isDefault && ( 285 - <div className="flex shrink-0 gap-2"> 286 - <button 287 - type="button" 288 - onClick={() => handleEdit(provider)} 289 - aria-label="Edit" 290 - className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 291 - > 292 - Edit 293 - </button> 294 - <button 295 - type="button" 296 - onClick={() => setConfirmRemove(provider.pdsHost)} 297 - aria-label="Remove" 298 - className="rounded-md border border-border px-3 py-1.5 text-sm text-destructive transition-colors hover:bg-destructive/10" 299 - > 300 - Remove 301 - </button> 302 - </div> 303 - )} 304 - </div> 305 - ))} 306 - {providers.length === 0 && ( 307 - <p className="py-4 text-center text-muted-foreground">No PDS providers configured.</p> 308 - )} 309 - </div> 310 - 311 - <PdsOverrideDialog 312 - key={dialogKey} 313 - open={dialogOpen} 314 - mode={dialogMode} 315 - initialHostname={editHostname} 316 - initialTrustFactor={editTrustFactor} 317 - onClose={() => setDialogOpen(false)} 318 - onSubmit={handleDialogSubmit} 319 - /> 320 - 321 - <ConfirmDialog 322 - open={confirmRemove !== null} 323 - title="Remove PDS override" 324 - message={`Are you sure you want to remove the trust override for ${confirmRemove ?? ''}? The default trust factor will apply.`} 325 - onConfirm={() => { 326 - if (confirmRemove) onRemove(confirmRemove) 327 - setConfirmRemove(null) 328 - }} 329 - onCancel={() => setConfirmRemove(null)} 330 - /> 331 - </div> 332 - ) 333 - } 334 - 335 24 export default function AdminSettingsPage() { 336 25 const { getAccessToken } = useAuth() 337 26 const [settings, setSettings] = useState<CommunitySettings | null>(null) ··· 404 93 405 94 const handlePdsRemove = async (pdsHost: string) => { 406 95 setPdsError(null) 407 - // Removing an override reverts to default -- for the UI we just remove it from the list 408 96 setPdsProviders((prev) => prev.filter((p) => p.pdsHost !== pdsHost)) 409 97 } 410 98 ··· 420 108 )} 421 109 422 110 {settings && ( 423 - <div className="max-w-lg space-y-6"> 424 - <div> 425 - <label htmlFor="settings-name" className="block text-sm font-medium text-foreground"> 426 - Community Name 427 - </label> 428 - <input 429 - id="settings-name" 430 - type="text" 431 - value={settings.communityName} 432 - onChange={(e) => setSettings({ ...settings, communityName: e.target.value })} 433 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 434 - /> 435 - </div> 436 - 437 - <div> 438 - <label htmlFor="settings-desc" className="block text-sm font-medium text-foreground"> 439 - Description 440 - </label> 441 - <textarea 442 - id="settings-desc" 443 - value={settings.communityDescription ?? ''} 444 - onChange={(e) => 445 - setSettings({ ...settings, communityDescription: e.target.value || null }) 446 - } 447 - rows={3} 448 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 449 - /> 450 - </div> 451 - 452 - <div> 453 - <label 454 - htmlFor="settings-maturity" 455 - className="block text-sm font-medium text-foreground" 456 - > 457 - Community Maturity Rating 458 - </label> 459 - <select 460 - id="settings-maturity" 461 - value={settings.maturityRating} 462 - onChange={(e) => 463 - setSettings({ ...settings, maturityRating: e.target.value as MaturityRating }) 464 - } 465 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 466 - > 467 - <option value="safe">Safe (default)</option> 468 - <option value="mature">Mature</option> 469 - <option value="adult">Adult</option> 470 - </select> 471 - <p className="mt-1 text-xs text-muted-foreground"> 472 - Changing to Mature or Adult affects global aggregator visibility. 473 - </p> 474 - </div> 475 - 476 - <div> 477 - <label 478 - htmlFor="settings-reactions" 479 - className="block text-sm font-medium text-foreground" 480 - > 481 - Reaction Set 482 - </label> 483 - <input 484 - id="settings-reactions" 485 - type="text" 486 - value={settings.reactionSet.join(', ')} 487 - onChange={(e) => 488 - setSettings({ 489 - ...settings, 490 - reactionSet: e.target.value 491 - .split(',') 492 - .map((s) => s.trim()) 493 - .filter(Boolean), 494 - }) 495 - } 496 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 497 - /> 498 - <p className="mt-1 text-xs text-muted-foreground"> 499 - Comma-separated list of reaction types available in your community. 500 - </p> 501 - </div> 502 - 503 - <fieldset className="space-y-4"> 504 - <legend className="text-sm font-medium text-foreground">Branding</legend> 505 - <div> 506 - <label 507 - htmlFor="settings-primary-color" 508 - className="block text-sm text-muted-foreground" 509 - > 510 - Primary Color 511 - </label> 512 - <input 513 - id="settings-primary-color" 514 - type="text" 515 - value={settings.primaryColor ?? ''} 516 - onChange={(e) => 517 - setSettings({ ...settings, primaryColor: e.target.value || null }) 518 - } 519 - placeholder="#31748f" 520 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 521 - /> 522 - </div> 523 - <div> 524 - <label 525 - htmlFor="settings-accent-color" 526 - className="block text-sm text-muted-foreground" 527 - > 528 - Accent Color 529 - </label> 530 - <input 531 - id="settings-accent-color" 532 - type="text" 533 - value={settings.accentColor ?? ''} 534 - onChange={(e) => 535 - setSettings({ ...settings, accentColor: e.target.value || null }) 536 - } 537 - placeholder="#c4a7e7" 538 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 539 - /> 540 - </div> 541 - </fieldset> 542 - 543 - {saveError && <ErrorAlert message={saveError} onDismiss={() => setSaveError(null)} />} 544 - 545 - <button 546 - type="button" 547 - onClick={() => void handleSave()} 548 - disabled={saving} 549 - className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 550 - > 551 - {saving ? 'Saving...' : 'Save Settings'} 552 - </button> 553 - </div> 111 + <CommunitySettingsForm 112 + settings={settings} 113 + onChange={setSettings} 114 + onSave={() => void handleSave()} 115 + saving={saving} 116 + saveError={saveError} 117 + onDismissError={() => setSaveError(null)} 118 + /> 554 119 )} 555 120 556 - {/* PDS Provider Trust section */} 557 121 {!loading && !loadError && ( 558 122 <> 559 123 <hr className="border-border" />
+11 -379
src/app/admin/sybil-detection/page.tsx
··· 7 7 8 8 'use client' 9 9 10 - import { useState, useEffect, useCallback, useRef } from 'react' 11 - import { ArrowLeft } from '@phosphor-icons/react' 10 + import { useState, useEffect, useCallback } from 'react' 12 11 import { AdminLayout } from '@/components/admin/admin-layout' 13 12 import { ErrorAlert } from '@/components/error-alert' 13 + import { ConfirmDialog } from '@/components/confirm-dialog' 14 + import { TrustGraphStatusCard } from '@/components/admin/sybil/trust-graph-status-card' 15 + import { SybilClusterListView } from '@/components/admin/sybil/cluster-list-view' 16 + import { SybilClusterDetailView } from '@/components/admin/sybil/cluster-detail-view' 17 + import { BehavioralFlagsSection } from '@/components/admin/sybil/behavioral-flags-section' 14 18 import { 15 19 getSybilClusters, 16 20 getSybilClusterDetail, ··· 20 24 getBehavioralFlags, 21 25 updateBehavioralFlag, 22 26 } from '@/lib/api/client' 23 - import { cn } from '@/lib/utils' 24 27 import type { 25 28 SybilCluster, 26 29 SybilClusterDetail, ··· 30 33 } from '@/lib/api/types' 31 34 import { useAuth } from '@/hooks/use-auth' 32 35 33 - function formatNumber(n: number): string { 34 - return n.toLocaleString('en-US') 35 - } 36 - 37 - function formatRelativeTime(dateStr: string): string { 38 - const diff = Date.now() - new Date(dateStr).getTime() 39 - const minutes = Math.floor(diff / 60_000) 40 - if (minutes < 60) return `${minutes}m ago` 41 - const hours = Math.floor(minutes / 60) 42 - if (hours < 24) return `${hours}h ago` 43 - const days = Math.floor(hours / 24) 44 - return `${days}d ago` 45 - } 46 - 47 - // --- Confirm Dialog --- 48 - function ConfirmDialog({ 49 - open, 50 - title, 51 - message, 52 - onConfirm, 53 - onCancel, 54 - }: { 55 - open: boolean 56 - title: string 57 - message: string 58 - onConfirm: () => void 59 - onCancel: () => void 60 - }) { 61 - const confirmRef = useRef<HTMLButtonElement>(null) 62 - 63 - useEffect(() => { 64 - if (open) { 65 - confirmRef.current?.focus() 66 - } 67 - }, [open]) 68 - 69 - useEffect(() => { 70 - if (!open) return 71 - const handleKey = (e: KeyboardEvent) => { 72 - if (e.key === 'Escape') onCancel() 73 - } 74 - document.addEventListener('keydown', handleKey) 75 - return () => document.removeEventListener('keydown', handleKey) 76 - }, [open, onCancel]) 77 - 78 - if (!open) return null 79 - 80 - return ( 81 - <div 82 - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 83 - role="dialog" 84 - aria-modal="true" 85 - aria-labelledby="confirm-dialog-title" 86 - aria-describedby="confirm-dialog-message" 87 - > 88 - <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 89 - <h3 id="confirm-dialog-title" className="text-lg font-semibold text-foreground"> 90 - {title} 91 - </h3> 92 - <p id="confirm-dialog-message" className="mt-2 text-sm text-muted-foreground"> 93 - {message} 94 - </p> 95 - <div className="mt-4 flex justify-end gap-2"> 96 - <button 97 - type="button" 98 - onClick={onCancel} 99 - className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 100 - > 101 - Cancel 102 - </button> 103 - <button 104 - ref={confirmRef} 105 - type="button" 106 - onClick={onConfirm} 107 - className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 108 - > 109 - Confirm 110 - </button> 111 - </div> 112 - </div> 113 - </div> 114 - ) 115 - } 116 - 117 - // --- Trust Graph Status Card --- 118 - function TrustGraphStatusCard({ 119 - status, 120 - onRecompute, 121 - recomputing, 122 - }: { 123 - status: TrustGraphStatus 124 - onRecompute: () => void 125 - recomputing: boolean 126 - }) { 127 - return ( 128 - <div className="rounded-lg border border-border bg-card p-4"> 129 - <div className="flex items-center justify-between"> 130 - <div className="space-y-1"> 131 - <p className="text-sm text-muted-foreground"> 132 - {status.lastComputedAt 133 - ? `Last computed: ${formatRelativeTime(status.lastComputedAt)}` 134 - : 'Never computed'} 135 - {' | '} 136 - <span>{formatNumber(status.totalNodes)} nodes</span> 137 - {' | '} 138 - <span>{formatNumber(status.totalEdges)} edges</span> 139 - {' | '} 140 - <span>{status.clustersFlagged} clusters flagged</span> 141 - </p> 142 - </div> 143 - <button 144 - type="button" 145 - onClick={onRecompute} 146 - disabled={recomputing} 147 - aria-label="Recompute now" 148 - className={cn( 149 - 'rounded-md px-4 py-2 text-sm font-medium transition-colors', 150 - recomputing 151 - ? 'cursor-not-allowed bg-muted text-muted-foreground' 152 - : 'bg-primary text-primary-foreground hover:bg-primary/90' 153 - )} 154 - > 155 - {recomputing ? 'Recomputing...' : 'Recompute Now'} 156 - </button> 157 - </div> 158 - </div> 159 - ) 160 - } 161 - 162 - // --- Suspicion Ratio Bar --- 163 - function SuspicionBar({ ratio }: { ratio: number }) { 164 - const percent = Math.round(ratio * 100) 165 - return ( 166 - <div className="flex items-center gap-2"> 167 - <div className="h-2 w-20 overflow-hidden rounded-full bg-muted" aria-hidden="true"> 168 - <div 169 - className={cn( 170 - 'h-full rounded-full', 171 - percent >= 70 ? 'bg-destructive' : percent >= 40 ? 'bg-yellow-500' : 'bg-green-500' 172 - )} 173 - style={{ width: `${percent}%` }} 174 - /> 175 - </div> 176 - <span className="text-xs text-muted-foreground">{percent}%</span> 177 - </div> 178 - ) 179 - } 180 - 181 - // --- Status Badge --- 182 - function StatusBadge({ status }: { status: string }) { 183 - const colors: Record<string, string> = { 184 - flagged: 'bg-destructive/10 text-destructive', 185 - monitoring: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400', 186 - dismissed: 'bg-muted text-muted-foreground', 187 - banned: 'bg-destructive text-destructive-foreground', 188 - pending: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400', 189 - action_taken: 'bg-green-500/10 text-green-700 dark:text-green-400', 190 - } 191 - return ( 192 - <span 193 - className={cn( 194 - 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium', 195 - colors[status] ?? 'bg-muted text-muted-foreground' 196 - )} 197 - > 198 - {status} 199 - </span> 200 - ) 201 - } 202 - 203 - // --- Cluster List View --- 204 - function ClusterListView({ 205 - clusters, 206 - onViewDetail, 207 - }: { 208 - clusters: SybilCluster[] 209 - onViewDetail: (id: number) => void 210 - }) { 211 - if (clusters.length === 0) { 212 - return <p className="py-8 text-center text-muted-foreground">No clusters found.</p> 213 - } 214 - 215 - return ( 216 - <div className="space-y-2"> 217 - {clusters.map((cluster) => ( 218 - <article key={cluster.id} className="rounded-lg border border-border bg-card p-4"> 219 - <div className="flex items-center justify-between gap-4"> 220 - <div className="min-w-0 flex-1"> 221 - <div className="flex flex-wrap items-center gap-3"> 222 - <span className="text-sm font-medium text-foreground"> 223 - {cluster.memberCount} members 224 - </span> 225 - <StatusBadge status={cluster.status} /> 226 - <SuspicionBar ratio={cluster.suspicionRatio} /> 227 - </div> 228 - <p className="mt-1 text-xs text-muted-foreground"> 229 - {cluster.internalEdgeCount} internal / {cluster.externalEdgeCount} external 230 - connections &middot; Detected {formatRelativeTime(cluster.detectedAt)} 231 - </p> 232 - </div> 233 - <button 234 - type="button" 235 - onClick={() => onViewDetail(cluster.id)} 236 - aria-label="View details" 237 - className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 238 - > 239 - View details 240 - </button> 241 - </div> 242 - </article> 243 - ))} 244 - </div> 245 - ) 246 - } 247 - 248 - // --- Cluster Detail View --- 249 - function ClusterDetailView({ 250 - detail, 251 - onBack, 252 - onAction, 253 - }: { 254 - detail: SybilClusterDetail 255 - onBack: () => void 256 - onAction: (status: SybilClusterStatus) => void 257 - }) { 258 - return ( 259 - <div className="space-y-4"> 260 - <button 261 - type="button" 262 - onClick={onBack} 263 - className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground" 264 - aria-label="Back to cluster list" 265 - > 266 - <ArrowLeft size={14} aria-hidden="true" /> 267 - Back to clusters 268 - </button> 269 - 270 - <div className="rounded-lg border border-border bg-card p-4"> 271 - <div className="flex items-center justify-between"> 272 - <div> 273 - <h3 className="text-lg font-semibold text-foreground">Cluster #{detail.id}</h3> 274 - <div className="mt-1 flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> 275 - <span>{detail.memberCount} members</span> 276 - <StatusBadge status={detail.status} /> 277 - <SuspicionBar ratio={detail.suspicionRatio} /> 278 - </div> 279 - </div> 280 - <div className="flex gap-2"> 281 - {detail.status !== 'monitoring' && ( 282 - <button 283 - type="button" 284 - onClick={() => onAction('monitoring')} 285 - className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 286 - > 287 - Monitor 288 - </button> 289 - )} 290 - {detail.status !== 'dismissed' && ( 291 - <button 292 - type="button" 293 - onClick={() => onAction('dismissed')} 294 - aria-label="Dismiss cluster" 295 - className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 296 - > 297 - Dismiss 298 - </button> 299 - )} 300 - {detail.status !== 'banned' && ( 301 - <button 302 - type="button" 303 - onClick={() => onAction('banned')} 304 - aria-label="Ban cluster" 305 - className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 306 - > 307 - Ban 308 - </button> 309 - )} 310 - </div> 311 - </div> 312 - </div> 313 - 314 - {/* Member table */} 315 - <div className="overflow-x-auto"> 316 - <table className="w-full text-sm"> 317 - <thead> 318 - <tr className="border-b border-border text-left"> 319 - <th className="pb-2 pr-4 font-medium text-muted-foreground">Handle</th> 320 - <th className="pb-2 pr-4 font-medium text-muted-foreground">Role</th> 321 - <th className="pb-2 pr-4 font-medium text-muted-foreground">Trust</th> 322 - <th className="pb-2 pr-4 font-medium text-muted-foreground">Reputation</th> 323 - <th className="pb-2 pr-4 font-medium text-muted-foreground">Account Age</th> 324 - <th className="pb-2 font-medium text-muted-foreground">Communities</th> 325 - </tr> 326 - </thead> 327 - <tbody> 328 - {detail.members.map((member) => ( 329 - <tr key={member.did} className="border-b border-border last:border-0"> 330 - <td className="py-2 pr-4"> 331 - <div> 332 - <p className="font-medium text-foreground">{member.handle}</p> 333 - <p className="text-xs text-muted-foreground">{member.displayName}</p> 334 - </div> 335 - </td> 336 - <td className="py-2 pr-4"> 337 - <StatusBadge status={member.roleInCluster} /> 338 - </td> 339 - <td className="py-2 pr-4 text-muted-foreground"> 340 - {(member.trustScore * 100).toFixed(0)}% 341 - </td> 342 - <td className="py-2 pr-4 text-muted-foreground"> 343 - {(member.reputationScore * 100).toFixed(0)}% 344 - </td> 345 - <td className="py-2 pr-4 text-muted-foreground">{member.accountAge}</td> 346 - <td className="py-2 text-muted-foreground">{member.communitiesActiveIn}</td> 347 - </tr> 348 - ))} 349 - </tbody> 350 - </table> 351 - </div> 352 - </div> 353 - ) 354 - } 355 - 356 - // --- Behavioral Flags Section --- 357 - function BehavioralFlagsSection({ 358 - flags, 359 - onDismiss, 360 - }: { 361 - flags: BehavioralFlag[] 362 - onDismiss: (id: number) => void 363 - }) { 364 - return ( 365 - <div className="space-y-3"> 366 - <h2 className="text-lg font-semibold text-foreground">Behavioral Flags</h2> 367 - {flags.length === 0 && ( 368 - <p className="py-4 text-center text-muted-foreground">No behavioral flags.</p> 369 - )} 370 - {flags.map((flag) => ( 371 - <article key={flag.id} className="rounded-lg border border-border bg-card p-4"> 372 - <div className="flex items-start justify-between gap-3"> 373 - <div className="min-w-0 flex-1"> 374 - <div className="flex items-center gap-2"> 375 - <span className="text-sm font-medium text-foreground">{flag.flagType}</span> 376 - <StatusBadge status={flag.status} /> 377 - </div> 378 - <p className="mt-1 text-sm text-muted-foreground">{flag.details}</p> 379 - <p className="mt-1 text-xs text-muted-foreground"> 380 - {flag.affectedDids.length} affected accounts &middot; Detected{' '} 381 - {formatRelativeTime(flag.detectedAt)} 382 - </p> 383 - </div> 384 - {flag.status === 'pending' && ( 385 - <button 386 - type="button" 387 - onClick={() => onDismiss(flag.id)} 388 - aria-label="Dismiss flag" 389 - className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 390 - > 391 - Dismiss 392 - </button> 393 - )} 394 - </div> 395 - </article> 396 - ))} 397 - </div> 398 - ) 399 - } 400 - 401 - // --- Main Page --- 402 36 export default function AdminSybilDetectionPage() { 403 37 const { getAccessToken } = useAuth() 404 38 const [clusters, setClusters] = useState<SybilCluster[]>([]) ··· 518 152 519 153 {!loading && !loadError && ( 520 154 <> 521 - {/* Trust Graph Status */} 522 155 {graphStatus && ( 523 156 <TrustGraphStatusCard 524 157 status={graphStatus} ··· 527 160 /> 528 161 )} 529 162 530 - {/* Cluster List or Detail */} 531 163 {selectedDetail ? ( 532 - <ClusterDetailView 164 + <SybilClusterDetailView 533 165 detail={selectedDetail} 534 166 onBack={() => setSelectedDetail(null)} 535 167 onAction={handleClusterAction} ··· 560 192 </select> 561 193 </div> 562 194 </div> 563 - <ClusterListView 195 + <SybilClusterListView 564 196 clusters={filteredClusters} 565 197 onViewDetail={(id) => void handleViewDetail(id)} 566 198 /> 567 199 </div> 568 200 )} 569 201 570 - {/* Behavioral Flags */} 571 202 {!selectedDetail && ( 572 203 <BehavioralFlagsSection 573 204 flags={flags} ··· 577 208 </> 578 209 )} 579 210 580 - {/* Confirm Dialog */} 581 211 <ConfirmDialog 582 212 open={confirmAction !== null} 583 213 title={confirmAction?.title ?? ''} 584 - message={confirmAction?.message ?? ''} 214 + description={confirmAction?.message ?? ''} 215 + confirmLabel="Confirm" 216 + variant="destructive" 585 217 onConfirm={() => confirmAction?.onConfirm()} 586 218 onCancel={() => setConfirmAction(null)} 587 219 />
+9 -230
src/app/admin/trust-seeds/page.tsx
··· 7 7 8 8 'use client' 9 9 10 - import { useState, useEffect, useCallback, useRef } from 'react' 10 + import { useState, useEffect, useCallback } from 'react' 11 11 import { AdminLayout } from '@/components/admin/admin-layout' 12 12 import { ErrorAlert } from '@/components/error-alert' 13 + import { ConfirmDialog } from '@/components/confirm-dialog' 14 + import { AddSeedDialog } from '@/components/admin/trust-seeds/add-seed-dialog' 15 + import { TrustSeedCard } from '@/components/admin/trust-seeds/trust-seed-card' 13 16 import { getTrustSeeds, createTrustSeed, deleteTrustSeed } from '@/lib/api/client' 14 - import { cn } from '@/lib/utils' 15 17 import type { TrustSeed } from '@/lib/api/types' 16 18 import { useAuth } from '@/hooks/use-auth' 17 19 18 - function formatDate(dateStr: string) { 19 - return new Date(dateStr).toLocaleDateString('en-US', { 20 - month: 'short', 21 - day: 'numeric', 22 - year: 'numeric', 23 - }) 24 - } 25 - 26 - // --- Confirm Dialog --- 27 - function ConfirmDialog({ 28 - open, 29 - title, 30 - message, 31 - onConfirm, 32 - onCancel, 33 - }: { 34 - open: boolean 35 - title: string 36 - message: string 37 - onConfirm: () => void 38 - onCancel: () => void 39 - }) { 40 - const confirmRef = useRef<HTMLButtonElement>(null) 41 - 42 - useEffect(() => { 43 - if (open) { 44 - confirmRef.current?.focus() 45 - } 46 - }, [open]) 47 - 48 - useEffect(() => { 49 - if (!open) return 50 - const handleKey = (e: KeyboardEvent) => { 51 - if (e.key === 'Escape') onCancel() 52 - } 53 - document.addEventListener('keydown', handleKey) 54 - return () => document.removeEventListener('keydown', handleKey) 55 - }, [open, onCancel]) 56 - 57 - if (!open) return null 58 - 59 - return ( 60 - <div 61 - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 62 - role="dialog" 63 - aria-modal="true" 64 - aria-labelledby="confirm-dialog-title" 65 - aria-describedby="confirm-dialog-message" 66 - > 67 - <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 68 - <h3 id="confirm-dialog-title" className="text-lg font-semibold text-foreground"> 69 - {title} 70 - </h3> 71 - <p id="confirm-dialog-message" className="mt-2 text-sm text-muted-foreground"> 72 - {message} 73 - </p> 74 - <div className="mt-4 flex justify-end gap-2"> 75 - <button 76 - type="button" 77 - onClick={onCancel} 78 - className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 79 - > 80 - Cancel 81 - </button> 82 - <button 83 - ref={confirmRef} 84 - type="button" 85 - onClick={onConfirm} 86 - className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 87 - > 88 - Confirm 89 - </button> 90 - </div> 91 - </div> 92 - </div> 93 - ) 94 - } 95 - 96 - // --- Add Seed Dialog --- 97 - function AddSeedDialog({ 98 - open, 99 - onClose, 100 - onSubmit, 101 - }: { 102 - open: boolean 103 - onClose: () => void 104 - onSubmit: (data: { handle: string; communityId?: string; reason?: string }) => void 105 - }) { 106 - const [handle, setHandle] = useState('') 107 - const [reason, setReason] = useState('') 108 - const handleRef = useRef<HTMLInputElement>(null) 109 - 110 - useEffect(() => { 111 - if (open) { 112 - handleRef.current?.focus() 113 - } 114 - }, [open]) 115 - 116 - useEffect(() => { 117 - if (!open) return 118 - const handleKey = (e: KeyboardEvent) => { 119 - if (e.key === 'Escape') onClose() 120 - } 121 - document.addEventListener('keydown', handleKey) 122 - return () => document.removeEventListener('keydown', handleKey) 123 - }, [open, onClose]) 124 - 125 - if (!open) return null 126 - 127 - const handleSubmit = (e: React.FormEvent) => { 128 - e.preventDefault() 129 - if (!handle.trim()) return 130 - onSubmit({ 131 - handle: handle.trim(), 132 - reason: reason.trim() || undefined, 133 - }) 134 - } 135 - 136 - return ( 137 - <div 138 - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 139 - role="dialog" 140 - aria-modal="true" 141 - aria-label="Add trust seed" 142 - > 143 - <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 144 - <h3 className="text-lg font-semibold text-foreground">Add Trust Seed</h3> 145 - <form onSubmit={handleSubmit} className="mt-4 space-y-4"> 146 - <div> 147 - <label htmlFor="seed-handle" className="block text-sm font-medium text-foreground"> 148 - Handle 149 - </label> 150 - <input 151 - ref={handleRef} 152 - id="seed-handle" 153 - type="text" 154 - value={handle} 155 - onChange={(e) => setHandle(e.target.value)} 156 - placeholder="user.bsky.social" 157 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground" 158 - required 159 - /> 160 - </div> 161 - <div> 162 - <label htmlFor="seed-reason" className="block text-sm font-medium text-foreground"> 163 - Reason 164 - </label> 165 - <input 166 - id="seed-reason" 167 - type="text" 168 - value={reason} 169 - onChange={(e) => setReason(e.target.value)} 170 - placeholder="Optional: why this account is trusted" 171 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground" 172 - /> 173 - </div> 174 - <div className="flex justify-end gap-2"> 175 - <button 176 - type="button" 177 - onClick={onClose} 178 - className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 179 - > 180 - Cancel 181 - </button> 182 - <button 183 - type="submit" 184 - className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 185 - > 186 - Add 187 - </button> 188 - </div> 189 - </form> 190 - </div> 191 - </div> 192 - ) 193 - } 194 - 195 - // --- Type Badge --- 196 - function TypeBadge({ implicit }: { implicit: boolean }) { 197 - return ( 198 - <span 199 - className={cn( 200 - 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium', 201 - implicit ? 'bg-muted text-muted-foreground' : 'bg-primary/10 text-primary' 202 - )} 203 - > 204 - {implicit ? 'Automatic' : 'Manual'} 205 - </span> 206 - ) 207 - } 208 - 209 - // --- Main Page --- 210 20 export default function AdminTrustSeedsPage() { 211 21 const { getAccessToken } = useAuth() 212 22 const [seeds, setSeeds] = useState<TrustSeed[]>([]) ··· 217 27 const [addDialogKey, setAddDialogKey] = useState(0) 218 28 const [confirmAction, setConfirmAction] = useState<{ 219 29 title: string 220 - message: string 30 + description: string 221 31 onConfirm: () => void 222 32 } | null>(null) 223 33 ··· 252 62 const handleRemoveSeed = (seed: TrustSeed) => { 253 63 setConfirmAction({ 254 64 title: 'Remove trust seed', 255 - message: `Are you sure you want to remove ${seed.handle} as a trust seed?`, 65 + description: `Are you sure you want to remove ${seed.handle} as a trust seed?`, 256 66 onConfirm: async () => { 257 67 setConfirmAction(null) 258 68 setActionError(null) ··· 304 114 305 115 <div className="space-y-2"> 306 116 {seeds.map((seed) => ( 307 - <article key={seed.id} className="rounded-lg border border-border bg-card p-4"> 308 - <div className="flex items-center justify-between gap-4"> 309 - <div className="min-w-0 flex-1"> 310 - <div className="flex flex-wrap items-center gap-2"> 311 - <span className="text-sm font-medium text-foreground">{seed.handle}</span> 312 - <TypeBadge implicit={seed.implicit} /> 313 - {seed.communityId ? ( 314 - <span className="text-xs text-muted-foreground">Scoped</span> 315 - ) : ( 316 - <span className="text-xs text-muted-foreground">Global</span> 317 - )} 318 - </div> 319 - <p className="mt-0.5 text-xs text-muted-foreground"> 320 - {seed.displayName} 321 - {seed.reason && <> &middot; {seed.reason}</>} 322 - {' &middot; Added '} 323 - {formatDate(seed.createdAt)} 324 - </p> 325 - </div> 326 - {!seed.implicit && ( 327 - <button 328 - type="button" 329 - onClick={() => handleRemoveSeed(seed)} 330 - aria-label="Remove" 331 - className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-destructive transition-colors hover:bg-destructive/10" 332 - > 333 - Remove 334 - </button> 335 - )} 336 - </div> 337 - </article> 117 + <TrustSeedCard key={seed.id} seed={seed} onRemove={handleRemoveSeed} /> 338 118 ))} 339 119 {seeds.length === 0 && ( 340 120 <p className="py-8 text-center text-muted-foreground">No trust seeds configured.</p> ··· 343 123 </> 344 124 )} 345 125 346 - {/* Add Seed Dialog */} 347 126 <AddSeedDialog 348 127 key={addDialogKey} 349 128 open={addDialogOpen} ··· 351 130 onSubmit={(data) => void handleAddSeed(data)} 352 131 /> 353 132 354 - {/* Confirm Dialog */} 355 133 <ConfirmDialog 356 134 open={confirmAction !== null} 357 135 title={confirmAction?.title ?? ''} 358 - message={confirmAction?.message ?? ''} 136 + description={confirmAction?.description ?? ''} 137 + variant="destructive" 359 138 onConfirm={() => confirmAction?.onConfirm()} 360 139 onCancel={() => setConfirmAction(null)} 361 140 />
+30 -256
src/app/settings/page.tsx
··· 16 16 import { AgeGateDialog } from '@/components/age-gate-dialog' 17 17 import { CrossPostAuthDialog } from '@/components/crosspost-auth-dialog' 18 18 import { CommunityProfileSettings } from '@/components/community-profile-settings' 19 + import { ContentSafetySection } from '@/components/settings/content-safety-section' 20 + import { CommunityOverridesSection } from '@/components/settings/community-overrides-section' 21 + import { CrossPostingSection } from '@/components/settings/cross-posting-section' 22 + import { NotificationsSection } from '@/components/settings/notifications-section' 19 23 import { cn } from '@/lib/utils' 20 24 import { 21 25 getPreferences, ··· 66 70 const [declaredAge, setDeclaredAge] = useState<number | null>(null) 67 71 const [showAgeGate, setShowAgeGate] = useState(false) 68 72 69 - // Load preferences on mount 70 73 useEffect(() => { 71 74 const token = getAccessToken() 72 75 if (!token) { ··· 125 128 return 126 129 } 127 130 128 - // If switching to mature and no age declaration, show age gate 129 131 if (values.maturityLevel === 'sfw-mature' && !declaredAge) { 130 132 setShowAgeGate(true) 131 133 setSaving(false) ··· 138 140 .map((w) => w.trim()) 139 141 .filter(Boolean) 140 142 141 - // Save global preferences 142 143 await updatePreferences( 143 144 { 144 145 maturityLevel: values.maturityLevel === 'sfw-mature' ? 'mature' : 'sfw', ··· 149 150 token 150 151 ) 151 152 152 - // Save per-community overrides 153 153 await Promise.all( 154 154 communityOverrides.map((c) => 155 155 updateCommunityPreference( ··· 213 213 </p> 214 214 )} 215 215 216 - {/* Community Profile (separate save, independent section) */} 217 216 <CommunityProfileSettings /> 218 217 219 - {/* Content Safety */} 220 - <fieldset className="space-y-4 rounded-lg border border-border p-4"> 221 - <legend className="px-2 text-sm font-semibold text-foreground">Content Safety</legend> 222 - 223 - <div className="space-y-1"> 224 - <label 225 - htmlFor="maturity-level" 226 - className="block text-sm font-medium text-foreground" 227 - > 228 - Maturity level 229 - </label> 230 - <select 231 - id="maturity-level" 232 - value={values.maturityLevel} 233 - onChange={(e) => 234 - setValues({ ...values, maturityLevel: e.target.value as MaturityLevel }) 235 - } 236 - className={cn( 237 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground', 238 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 239 - )} 240 - > 241 - <option value="sfw">SFW only</option> 242 - <option value="sfw-mature">SFW + Mature</option> 243 - </select> 244 - <p className="text-xs text-muted-foreground"> 245 - Controls which content you can see. Mature content requires age confirmation. 246 - </p> 247 - </div> 248 - 249 - <div className="space-y-1"> 250 - <label htmlFor="muted-words" className="block text-sm font-medium text-foreground"> 251 - Muted words 252 - </label> 253 - <textarea 254 - id="muted-words" 255 - value={values.mutedWords} 256 - onChange={(e) => setValues({ ...values, mutedWords: e.target.value })} 257 - placeholder="Enter words separated by commas" 258 - rows={3} 259 - className={cn( 260 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 261 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 262 - )} 263 - /> 264 - <p className="text-xs text-muted-foreground"> 265 - Posts containing these words will be collapsed. Comma-separated. 266 - </p> 267 - </div> 268 - </fieldset> 269 - 270 - {/* Per-Community Overrides */} 271 - <fieldset className="space-y-4 rounded-lg border border-border p-4"> 272 - <legend className="px-2 text-sm font-semibold text-foreground"> 273 - Per-Community Overrides 274 - </legend> 275 - 276 - {communityOverrides.length === 0 ? ( 277 - <p className="text-sm text-muted-foreground"> 278 - No community memberships found. Join a community to configure per-community 279 - settings. 280 - </p> 281 - ) : ( 282 - <div className="space-y-3"> 283 - {communityOverrides.map((community) => ( 284 - <details 285 - key={community.communityDid} 286 - className="rounded-md border border-border" 287 - > 288 - <summary className="cursor-pointer px-3 py-2 text-sm font-medium text-foreground hover:bg-muted/50"> 289 - {community.communityName} 290 - </summary> 291 - 292 - <div className="space-y-3 border-t border-border px-3 py-3"> 293 - <div className="space-y-1"> 294 - <label 295 - htmlFor={`maturity-${community.communityDid}`} 296 - className="block text-xs font-medium text-foreground" 297 - > 298 - Maturity override 299 - </label> 300 - <select 301 - id={`maturity-${community.communityDid}`} 302 - value={community.maturityLevel} 303 - onChange={(e) => 304 - handleCommunityChange( 305 - community.communityDid, 306 - 'maturityLevel', 307 - e.target.value 308 - ) 309 - } 310 - className={cn( 311 - 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground', 312 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 313 - )} 314 - > 315 - <option value="inherit">Inherit global setting</option> 316 - <option value="sfw">SFW only</option> 317 - <option value="mature">SFW + Mature</option> 318 - </select> 319 - </div> 320 - 321 - <div className="space-y-1"> 322 - <label 323 - htmlFor={`muted-words-${community.communityDid}`} 324 - className="block text-xs font-medium text-foreground" 325 - > 326 - Community muted words 327 - </label> 328 - <textarea 329 - id={`muted-words-${community.communityDid}`} 330 - value={community.mutedWords} 331 - onChange={(e) => 332 - handleCommunityChange( 333 - community.communityDid, 334 - 'mutedWords', 335 - e.target.value 336 - ) 337 - } 338 - placeholder="Additional muted words for this community" 339 - rows={2} 340 - className={cn( 341 - 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground', 342 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 343 - )} 344 - /> 345 - <p className="text-xs text-muted-foreground"> 346 - These are in addition to your global muted words. Comma-separated. 347 - </p> 348 - </div> 218 + <ContentSafetySection 219 + maturityLevel={values.maturityLevel} 220 + mutedWords={values.mutedWords} 221 + onMaturityChange={(level) => setValues({ ...values, maturityLevel: level })} 222 + onMutedWordsChange={(words) => setValues({ ...values, mutedWords: words })} 223 + /> 349 224 350 - <div className="space-y-1"> 351 - <label 352 - htmlFor={`blocked-${community.communityDid}`} 353 - className="block text-xs font-medium text-foreground" 354 - > 355 - Community blocked users 356 - </label> 357 - <textarea 358 - id={`blocked-${community.communityDid}`} 359 - value={community.blockedDids} 360 - onChange={(e) => 361 - handleCommunityChange( 362 - community.communityDid, 363 - 'blockedDids', 364 - e.target.value 365 - ) 366 - } 367 - placeholder="DIDs of users to block in this community" 368 - rows={2} 369 - className={cn( 370 - 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground', 371 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 372 - )} 373 - /> 374 - <p className="text-xs text-muted-foreground"> 375 - Block specific users only in this community. Comma-separated DIDs. 376 - </p> 377 - </div> 378 - </div> 379 - </details> 380 - ))} 381 - </div> 382 - )} 383 - </fieldset> 225 + <CommunityOverridesSection 226 + overrides={communityOverrides} 227 + onChange={handleCommunityChange} 228 + /> 384 229 385 - {/* Cross-Posting */} 386 - <fieldset className="space-y-4 rounded-lg border border-border p-4"> 387 - <legend className="px-2 text-sm font-semibold text-foreground">Cross-Posting</legend> 388 - {crossPostScopesGranted ? ( 389 - <div className="space-y-3"> 390 - <p className="text-xs text-muted-foreground"> 391 - Cross-posting authorized. You can share topics on Bluesky and Frontpage. 392 - </p> 393 - <label className="flex items-center gap-2"> 394 - <input 395 - type="checkbox" 396 - checked={values.crossPostBluesky} 397 - onChange={(e) => setValues({ ...values, crossPostBluesky: e.target.checked })} 398 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 399 - /> 400 - <span className="text-sm text-foreground"> 401 - Share new topics on Bluesky by default 402 - </span> 403 - </label> 404 - <label className="flex items-center gap-2"> 405 - <input 406 - type="checkbox" 407 - checked={values.crossPostFrontpage} 408 - onChange={(e) => 409 - setValues({ ...values, crossPostFrontpage: e.target.checked }) 410 - } 411 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 412 - /> 413 - <span className="text-sm text-foreground"> 414 - Share new topics on Frontpage by default 415 - </span> 416 - </label> 417 - </div> 418 - ) : ( 419 - <div className="space-y-3"> 420 - <p className="text-sm text-muted-foreground"> 421 - To share topics on Bluesky and Frontpage, Barazo needs permission to create 422 - posts on your behalf. 423 - </p> 424 - <button 425 - type="button" 426 - onClick={() => setShowCrossPostAuthDialog(true)} 427 - className={cn( 428 - 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 429 - 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' 430 - )} 431 - > 432 - Authorize cross-posting 433 - </button> 434 - </div> 435 - )} 436 - </fieldset> 230 + <CrossPostingSection 231 + authorized={crossPostScopesGranted} 232 + crossPostBluesky={values.crossPostBluesky} 233 + crossPostFrontpage={values.crossPostFrontpage} 234 + onBlueskyChange={(v) => setValues({ ...values, crossPostBluesky: v })} 235 + onFrontpageChange={(v) => setValues({ ...values, crossPostFrontpage: v })} 236 + onAuthorize={() => setShowCrossPostAuthDialog(true)} 237 + /> 437 238 438 - {/* Notifications */} 439 - <fieldset className="space-y-4 rounded-lg border border-border p-4"> 440 - <legend className="px-2 text-sm font-semibold text-foreground">Notifications</legend> 441 - <div className="space-y-3"> 442 - <label className="flex items-center gap-2"> 443 - <input 444 - type="checkbox" 445 - checked={values.notifyReplies} 446 - onChange={(e) => setValues({ ...values, notifyReplies: e.target.checked })} 447 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 448 - /> 449 - <span className="text-sm text-foreground">Replies to my posts</span> 450 - </label> 451 - <label className="flex items-center gap-2"> 452 - <input 453 - type="checkbox" 454 - checked={values.notifyMentions} 455 - onChange={(e) => setValues({ ...values, notifyMentions: e.target.checked })} 456 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 457 - /> 458 - <span className="text-sm text-foreground">Mentions of my handle</span> 459 - </label> 460 - <label className="flex items-center gap-2"> 461 - <input 462 - type="checkbox" 463 - checked={values.notifyReactions} 464 - onChange={(e) => setValues({ ...values, notifyReactions: e.target.checked })} 465 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 466 - /> 467 - <span className="text-sm text-foreground">Reactions on my posts</span> 468 - </label> 469 - </div> 470 - </fieldset> 239 + <NotificationsSection 240 + notifyReplies={values.notifyReplies} 241 + notifyMentions={values.notifyMentions} 242 + notifyReactions={values.notifyReactions} 243 + onRepliesChange={(v) => setValues({ ...values, notifyReplies: v })} 244 + onMentionsChange={(v) => setValues({ ...values, notifyMentions: v })} 245 + onReactionsChange={(v) => setValues({ ...values, notifyReactions: v })} 246 + /> 471 247 472 248 {/* My Reports link */} 473 249 <div className="rounded-lg border border-border p-4"> ··· 486 262 </p> 487 263 </div> 488 264 489 - {/* Save */} 490 265 <div className="flex justify-end"> 491 266 <button 492 267 type="submit" ··· 518 293 onConfirm={(age) => { 519 294 setDeclaredAge(age) 520 295 setShowAgeGate(false) 521 - // Re-trigger save now that age is declared 522 296 void handleSave({ preventDefault: () => {} } as React.FormEvent) 523 297 }} 524 298 onCancel={() => {
+18 -146
src/app/settings/reports/page.tsx
··· 13 13 import { ForumLayout } from '@/components/layout/forum-layout' 14 14 import { Breadcrumbs } from '@/components/breadcrumbs' 15 15 import { ErrorAlert } from '@/components/error-alert' 16 - import { cn } from '@/lib/utils' 16 + import { ReportCard } from '@/components/settings/report-card' 17 17 import { getMyReports, submitAppeal } from '@/lib/api/client' 18 18 import type { MyReport } from '@/lib/api/types' 19 19 import { useAuth } from '@/hooks/use-auth' 20 20 21 - function statusLabel(report: MyReport): string { 22 - if (report.appealStatus === 'pending') return 'Appeal pending' 23 - if (report.status === 'resolved' && report.resolutionType) { 24 - return report.resolutionType.charAt(0).toUpperCase() + report.resolutionType.slice(1) 25 - } 26 - return 'Pending' 27 - } 28 - 29 - function statusColor(report: MyReport): string { 30 - if (report.appealStatus === 'pending') 31 - return 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400' 32 - if (report.status === 'pending') return 'bg-blue-500/10 text-blue-700 dark:text-blue-400' 33 - if (report.resolutionType === 'dismissed') return 'bg-muted text-muted-foreground' 34 - return 'bg-green-500/10 text-green-700 dark:text-green-400' 35 - } 36 - 37 - function canAppeal(report: MyReport): boolean { 38 - return ( 39 - report.status === 'resolved' && 40 - report.resolutionType === 'dismissed' && 41 - report.appealStatus === 'none' 42 - ) 43 - } 44 - 45 21 export default function MyReportsPage() { 46 22 const { getAccessToken } = useAuth() 47 23 const [reports, setReports] = useState<MyReport[]>([]) ··· 78 54 const handleAppealCancel = useCallback(() => { 79 55 setAppealingId(null) 80 56 setAppealReason('') 57 + setAppealError('') 58 + }, []) 59 + 60 + const handleAppealReasonChange = useCallback((reason: string) => { 61 + setAppealReason(reason) 81 62 setAppealError('') 82 63 }, []) 83 64 ··· 114 95 [appealingId, appealReason, getAccessToken] 115 96 ) 116 97 117 - const formatDate = (iso: string) => { 118 - return new Date(iso).toLocaleDateString('en-US', { 119 - year: 'numeric', 120 - month: 'short', 121 - day: 'numeric', 122 - }) 123 - } 124 - 125 98 return ( 126 99 <ForumLayout> 127 100 <div className="space-y-6"> ··· 149 122 ) : ( 150 123 <div className="max-w-2xl space-y-4"> 151 124 {reports.map((report) => ( 152 - <div 125 + <ReportCard 153 126 key={report.id} 154 - className="rounded-lg border border-border bg-card p-4 space-y-3" 155 - > 156 - {/* Report header */} 157 - <div className="flex items-start justify-between gap-3"> 158 - <div className="space-y-1"> 159 - <p className="text-sm font-medium text-foreground"> 160 - {report.reasonType.charAt(0).toUpperCase() + report.reasonType.slice(1)} 161 - </p> 162 - {report.description && ( 163 - <p className="text-sm text-muted-foreground">{report.description}</p> 164 - )} 165 - </div> 166 - <span 167 - className={cn( 168 - 'shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium', 169 - statusColor(report) 170 - )} 171 - > 172 - {statusLabel(report)} 173 - </span> 174 - </div> 175 - 176 - {/* Metadata */} 177 - <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> 178 - <span>Reported: {formatDate(report.createdAt)}</span> 179 - {report.resolvedAt && <span>Resolved: {formatDate(report.resolvedAt)}</span>} 180 - </div> 181 - 182 - {/* Appeal success message */} 183 - {appealSuccess === report.id && ( 184 - <p 185 - className="rounded-md bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400" 186 - role="status" 187 - > 188 - Appeal submitted. A moderator will re-review your report. 189 - </p> 190 - )} 191 - 192 - {/* Appeal button */} 193 - {canAppeal(report) && appealingId !== report.id && appealSuccess !== report.id && ( 194 - <button 195 - type="button" 196 - onClick={() => handleAppealOpen(report.id)} 197 - className={cn( 198 - 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors', 199 - 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 200 - )} 201 - > 202 - Appeal 203 - </button> 204 - )} 205 - 206 - {/* Appeal form */} 207 - {appealingId === report.id && ( 208 - <form onSubmit={handleAppealSubmit} className="space-y-3" noValidate> 209 - <div className="space-y-1"> 210 - <label 211 - htmlFor={`appeal-reason-${report.id}`} 212 - className="block text-sm font-medium text-foreground" 213 - > 214 - Reason for appeal 215 - </label> 216 - <textarea 217 - id={`appeal-reason-${report.id}`} 218 - value={appealReason} 219 - onChange={(e) => { 220 - setAppealReason(e.target.value) 221 - setAppealError('') 222 - }} 223 - placeholder="Explain why you believe this report should be reconsidered" 224 - rows={3} 225 - disabled={appealSubmitting} 226 - className={cn( 227 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 228 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 229 - 'disabled:cursor-not-allowed disabled:opacity-50' 230 - )} 231 - /> 232 - {appealError && ( 233 - <p className="text-sm text-destructive" role="alert"> 234 - {appealError} 235 - </p> 236 - )} 237 - </div> 238 - <div className="flex gap-3"> 239 - <button 240 - type="submit" 241 - disabled={appealSubmitting} 242 - className={cn( 243 - 'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors', 244 - 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 245 - 'disabled:cursor-not-allowed disabled:opacity-50' 246 - )} 247 - > 248 - {appealSubmitting ? 'Submitting...' : 'Submit appeal'} 249 - </button> 250 - <button 251 - type="button" 252 - onClick={handleAppealCancel} 253 - disabled={appealSubmitting} 254 - className={cn( 255 - 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors', 256 - 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 257 - 'disabled:cursor-not-allowed disabled:opacity-50' 258 - )} 259 - > 260 - Cancel 261 - </button> 262 - </div> 263 - </form> 264 - )} 265 - </div> 127 + report={report} 128 + appealingId={appealingId} 129 + appealReason={appealReason} 130 + appealError={appealError} 131 + appealSubmitting={appealSubmitting} 132 + appealSuccess={appealSuccess} 133 + onAppealOpen={handleAppealOpen} 134 + onAppealCancel={handleAppealCancel} 135 + onAppealReasonChange={handleAppealReasonChange} 136 + onAppealSubmit={handleAppealSubmit} 137 + /> 266 138 ))} 267 139 </div> 268 140 )}
+103
src/components/admin/categories/category-form.tsx
··· 1 + /** 2 + * CategoryForm - Edit/create form for a category with maturity rating. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + import type { MaturityRating } from '@/lib/api/types' 7 + 8 + export interface EditingCategory { 9 + id: string | null 10 + name: string 11 + slug: string 12 + description: string 13 + parentId: string | null 14 + maturityRating: MaturityRating 15 + } 16 + 17 + interface CategoryFormProps { 18 + editing: EditingCategory 19 + onChange: (cat: EditingCategory) => void 20 + onSave: () => void 21 + onCancel: () => void 22 + } 23 + 24 + export function CategoryForm({ editing, onChange, onSave, onCancel }: CategoryFormProps) { 25 + return ( 26 + <div className="rounded-lg border border-border bg-card p-4"> 27 + <h2 className="mb-4 text-lg font-semibold text-foreground"> 28 + {editing.id ? 'Edit Category' : 'New Category'} 29 + </h2> 30 + <div className="space-y-4"> 31 + <div> 32 + <label htmlFor="cat-name" className="block text-sm font-medium text-foreground"> 33 + Category Name 34 + </label> 35 + <input 36 + id="cat-name" 37 + type="text" 38 + value={editing.name} 39 + onChange={(e) => onChange({ ...editing, name: e.target.value })} 40 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 41 + /> 42 + </div> 43 + <div> 44 + <label htmlFor="cat-slug" className="block text-sm font-medium text-foreground"> 45 + Slug 46 + </label> 47 + <input 48 + id="cat-slug" 49 + type="text" 50 + value={editing.slug} 51 + onChange={(e) => onChange({ ...editing, slug: e.target.value })} 52 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 53 + /> 54 + </div> 55 + <div> 56 + <label htmlFor="cat-desc" className="block text-sm font-medium text-foreground"> 57 + Description 58 + </label> 59 + <textarea 60 + id="cat-desc" 61 + value={editing.description} 62 + onChange={(e) => onChange({ ...editing, description: e.target.value })} 63 + rows={2} 64 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 65 + /> 66 + </div> 67 + <div> 68 + <label htmlFor="cat-maturity" className="block text-sm font-medium text-foreground"> 69 + Maturity Rating 70 + </label> 71 + <select 72 + id="cat-maturity" 73 + value={editing.maturityRating} 74 + onChange={(e) => 75 + onChange({ ...editing, maturityRating: e.target.value as MaturityRating }) 76 + } 77 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 78 + > 79 + <option value="safe">Safe</option> 80 + <option value="mature">Mature</option> 81 + <option value="adult">Adult</option> 82 + </select> 83 + </div> 84 + <div className="flex gap-2"> 85 + <button 86 + type="button" 87 + onClick={onSave} 88 + className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 89 + > 90 + Save 91 + </button> 92 + <button 93 + type="button" 94 + onClick={onCancel} 95 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 96 + > 97 + Cancel 98 + </button> 99 + </div> 100 + </div> 101 + </div> 102 + ) 103 + }
+85
src/components/admin/categories/category-row.tsx
··· 1 + /** 2 + * CategoryRow - Recursive tree row for a single category with edit/delete controls. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + import { PencilSimple, TrashSimple } from '@phosphor-icons/react' 7 + import { cn } from '@/lib/utils' 8 + import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types' 9 + 10 + const MATURITY_LABELS: Record<MaturityRating, string> = { 11 + safe: 'Safe', 12 + mature: 'Mature', 13 + adult: 'Adult', 14 + } 15 + 16 + const MATURITY_COLORS: Record<MaturityRating, string> = { 17 + safe: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 18 + mature: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', 19 + adult: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', 20 + } 21 + 22 + interface CategoryRowProps { 23 + category: CategoryTreeNode 24 + depth: number 25 + onEdit: (cat: CategoryTreeNode) => void 26 + onDelete: (id: string) => void 27 + } 28 + 29 + export function CategoryRow({ category, depth, onEdit, onDelete }: CategoryRowProps) { 30 + return ( 31 + <> 32 + <div 33 + data-depth={depth} 34 + className={cn( 35 + 'flex items-center justify-between rounded-md border border-border bg-card p-3', 36 + depth > 0 && 'ml-6' 37 + )} 38 + > 39 + <div className="flex items-center gap-3"> 40 + <div> 41 + <p className="text-sm font-medium text-foreground">{category.name}</p> 42 + {category.description && ( 43 + <p className="text-xs text-muted-foreground">{category.description}</p> 44 + )} 45 + </div> 46 + </div> 47 + <div className="flex items-center gap-2"> 48 + <span 49 + className={cn( 50 + 'rounded-full px-2 py-0.5 text-xs font-medium', 51 + MATURITY_COLORS[category.maturityRating] 52 + )} 53 + > 54 + {MATURITY_LABELS[category.maturityRating]} 55 + </span> 56 + <button 57 + type="button" 58 + onClick={() => onEdit(category)} 59 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 60 + aria-label={`Edit ${category.name}`} 61 + > 62 + <PencilSimple size={16} aria-hidden="true" /> 63 + </button> 64 + <button 65 + type="button" 66 + onClick={() => onDelete(category.id)} 67 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" 68 + aria-label={`Delete ${category.name}`} 69 + > 70 + <TrashSimple size={16} aria-hidden="true" /> 71 + </button> 72 + </div> 73 + </div> 74 + {category.children.map((child) => ( 75 + <CategoryRow 76 + key={child.id} 77 + category={child} 78 + depth={depth + 1} 79 + onEdit={onEdit} 80 + onDelete={onDelete} 81 + /> 82 + ))} 83 + </> 84 + ) 85 + }
+54
src/components/admin/moderation/action-log-tab.tsx
··· 1 + /** 2 + * ModerationActionLogTab - Read-only list of moderation action log entries. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + import { formatDate } from '@/lib/format' 7 + import type { ModerationLogEntry } from '@/lib/api/types' 8 + 9 + const ACTION_TYPE_LABELS: Record<string, string> = { 10 + lock: 'Locked', 11 + unlock: 'Unlocked', 12 + pin: 'Pinned', 13 + unpin: 'Unpinned', 14 + delete: 'Deleted', 15 + ban: 'Banned', 16 + unban: 'Unbanned', 17 + warn: 'Warned', 18 + label: 'Labeled', 19 + approve: 'Approved', 20 + reject: 'Rejected', 21 + } 22 + 23 + interface ModerationActionLogTabProps { 24 + entries: ModerationLogEntry[] 25 + } 26 + 27 + export function ModerationActionLogTab({ entries }: ModerationActionLogTabProps) { 28 + return ( 29 + <div className="space-y-2"> 30 + {entries.map((entry) => ( 31 + <div key={entry.id} className="rounded-md border border-border bg-card p-3"> 32 + <div className="flex items-center justify-between"> 33 + <p className="text-sm text-foreground"> 34 + <span className="font-medium">{entry.moderatorHandle}</span>{' '} 35 + <span className="text-muted-foreground"> 36 + {ACTION_TYPE_LABELS[entry.actionType] ?? entry.actionType} 37 + </span> 38 + {entry.targetHandle && ( 39 + <span className="text-muted-foreground"> {entry.targetHandle}</span> 40 + )} 41 + </p> 42 + <span className="text-xs text-muted-foreground">{formatDate(entry.createdAt)}</span> 43 + </div> 44 + {entry.reason && ( 45 + <p className="mt-1 text-xs text-muted-foreground italic">{entry.reason}</p> 46 + )} 47 + </div> 48 + ))} 49 + {entries.length === 0 && ( 50 + <p className="py-8 text-center text-muted-foreground">No moderation actions recorded.</p> 51 + )} 52 + </div> 53 + ) 54 + }
+153
src/components/admin/moderation/first-post-tab.tsx
··· 1 + /** 2 + * ModerationFirstPostTab - First-post approval queue with batch actions. 3 + * Shows account age, cross-community activity, and ban warnings. 4 + * @see specs/prd-web.md Section M11 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState } from 'react' 10 + import { ShieldCheck, Clock, Prohibit } from '@phosphor-icons/react' 11 + import { formatDate } from '@/lib/format' 12 + import type { FirstPostQueueItem } from '@/lib/api/types' 13 + 14 + interface ModerationFirstPostTabProps { 15 + items: FirstPostQueueItem[] 16 + onResolve: (id: string, action: 'approved' | 'rejected') => void 17 + onBatchResolve: (ids: string[], action: 'approved' | 'rejected') => void 18 + } 19 + 20 + export function ModerationFirstPostTab({ 21 + items, 22 + onResolve, 23 + onBatchResolve, 24 + }: ModerationFirstPostTabProps) { 25 + const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) 26 + 27 + const allSelected = items.length > 0 && selectedIds.size === items.length 28 + 29 + const toggleSelectAll = () => { 30 + if (allSelected) { 31 + setSelectedIds(new Set()) 32 + } else { 33 + setSelectedIds(new Set(items.map((item) => item.id))) 34 + } 35 + } 36 + 37 + const toggleItem = (id: string) => { 38 + setSelectedIds((prev) => { 39 + const next = new Set(prev) 40 + if (next.has(id)) { 41 + next.delete(id) 42 + } else { 43 + next.add(id) 44 + } 45 + return next 46 + }) 47 + } 48 + 49 + const handleBatchAction = (action: 'approved' | 'rejected') => { 50 + const ids = Array.from(selectedIds) 51 + onBatchResolve(ids, action) 52 + setSelectedIds(new Set()) 53 + } 54 + 55 + return ( 56 + <div className="space-y-3"> 57 + {items.length > 0 && ( 58 + <div className="flex items-center justify-between"> 59 + <label className="flex items-center gap-2 text-sm text-muted-foreground"> 60 + <input 61 + type="checkbox" 62 + checked={allSelected} 63 + onChange={toggleSelectAll} 64 + className="rounded border-border" 65 + aria-label="Select all" 66 + /> 67 + Select all ({items.length}) 68 + </label> 69 + {selectedIds.size > 0 && ( 70 + <div className="flex gap-2"> 71 + <button 72 + type="button" 73 + onClick={() => handleBatchAction('approved')} 74 + className="rounded-md bg-green-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-green-700" 75 + > 76 + Approve selected ({selectedIds.size}) 77 + </button> 78 + <button 79 + type="button" 80 + onClick={() => handleBatchAction('rejected')} 81 + className="rounded-md bg-destructive px-3 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 82 + > 83 + Reject selected ({selectedIds.size}) 84 + </button> 85 + </div> 86 + )} 87 + </div> 88 + )} 89 + {items.map((item) => ( 90 + <article key={item.id} className="rounded-lg border border-border bg-card p-4"> 91 + <div className="flex items-start gap-3"> 92 + <input 93 + type="checkbox" 94 + checked={selectedIds.has(item.id)} 95 + onChange={() => toggleItem(item.id)} 96 + className="mt-1 rounded border-border" 97 + aria-label={`Select post by ${item.authorHandle}`} 98 + /> 99 + <div className="min-w-0 flex-1"> 100 + <p className="text-sm font-medium text-foreground">{item.authorHandle}</p> 101 + <div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground"> 102 + <span className="inline-flex items-center gap-1"> 103 + <Clock size={12} aria-hidden="true" /> 104 + New account, {item.accountAge} old 105 + </span> 106 + {item.crossCommunityCount > 0 && ( 107 + <span className="inline-flex items-center gap-1"> 108 + <ShieldCheck size={12} aria-hidden="true" /> 109 + Active in {item.crossCommunityCount} other communities 110 + </span> 111 + )} 112 + {item.bannedFromOtherCommunities > 0 && ( 113 + <span className="inline-flex items-center gap-1 font-medium text-destructive"> 114 + <Prohibit size={12} aria-hidden="true" /> 115 + Banned from {item.bannedFromOtherCommunities} other{' '} 116 + {item.bannedFromOtherCommunities === 1 ? 'community' : 'communities'} 117 + </span> 118 + )} 119 + </div> 120 + {item.title && ( 121 + <p className="mt-2 text-sm font-medium text-foreground">{item.title}</p> 122 + )} 123 + <p className="mt-1 text-sm text-muted-foreground">{item.content}</p> 124 + <p className="mt-1 text-xs text-muted-foreground"> 125 + {item.contentType === 'topic' ? 'Topic' : 'Reply'} &middot;{' '} 126 + {formatDate(item.createdAt)} 127 + </p> 128 + </div> 129 + <div className="flex shrink-0 gap-2"> 130 + <button 131 + type="button" 132 + onClick={() => onResolve(item.id, 'approved')} 133 + className="rounded-md bg-green-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-green-700" 134 + > 135 + Approve 136 + </button> 137 + <button 138 + type="button" 139 + onClick={() => onResolve(item.id, 'rejected')} 140 + className="rounded-md bg-destructive px-3 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 141 + > 142 + Reject 143 + </button> 144 + </div> 145 + </div> 146 + </article> 147 + ))} 148 + {items.length === 0 && ( 149 + <p className="py-8 text-center text-muted-foreground">No posts awaiting approval.</p> 150 + )} 151 + </div> 152 + ) 153 + }
+40
src/components/admin/moderation/reported-users-tab.tsx
··· 1 + /** 2 + * ModerationReportedUsersTab - Users sorted by report count. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + import { Prohibit } from '@phosphor-icons/react' 7 + import { formatDate } from '@/lib/format' 8 + import type { ReportedUser } from '@/lib/api/types' 9 + 10 + interface ModerationReportedUsersTabProps { 11 + users: ReportedUser[] 12 + } 13 + 14 + export function ModerationReportedUsersTab({ users }: ModerationReportedUsersTabProps) { 15 + return ( 16 + <div className="space-y-2"> 17 + {users.map((user) => ( 18 + <div key={user.did} className="rounded-md border border-border bg-card p-3"> 19 + <div className="flex items-center justify-between"> 20 + <div> 21 + <p className="text-sm font-medium text-foreground">{user.handle}</p> 22 + <p className="text-xs text-muted-foreground"> 23 + {user.reportCount} reports &middot; Latest: {formatDate(user.latestReportAt)} 24 + </p> 25 + {user.bannedFromOtherCommunities > 0 && ( 26 + <p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-destructive"> 27 + <Prohibit size={12} aria-hidden="true" /> 28 + Banned from {user.bannedFromOtherCommunities} other communities 29 + </p> 30 + )} 31 + </div> 32 + </div> 33 + </div> 34 + ))} 35 + {users.length === 0 && ( 36 + <p className="py-8 text-center text-muted-foreground">No reported users.</p> 37 + )} 38 + </div> 39 + ) 40 + }
+88
src/components/admin/moderation/reports-tab.tsx
··· 1 + /** 2 + * ModerationReportsTab - Report queue with resolution actions. 3 + * Sorts potentially illegal reports first, then by newest. 4 + * @see specs/prd-web.md Section M11 5 + */ 6 + 7 + 'use client' 8 + 9 + import { WarningCircle } from '@phosphor-icons/react' 10 + import { cn } from '@/lib/utils' 11 + import { formatDate } from '@/lib/format' 12 + import type { ModerationReport, ReportResolution } from '@/lib/api/types' 13 + 14 + const RESOLUTION_ACTIONS: { value: ReportResolution; label: string }[] = [ 15 + { value: 'dismissed', label: 'Dismiss' }, 16 + { value: 'warned', label: 'Warn' }, 17 + { value: 'labeled', label: 'Label' }, 18 + { value: 'removed', label: 'Remove' }, 19 + { value: 'banned', label: 'Ban' }, 20 + ] 21 + 22 + interface ModerationReportsTabProps { 23 + reports: ModerationReport[] 24 + onResolve: (id: string, resolution: ReportResolution) => void 25 + } 26 + 27 + export function ModerationReportsTab({ reports, onResolve }: ModerationReportsTabProps) { 28 + const sorted = [...reports].sort((a, b) => { 29 + if (a.potentiallyIllegal !== b.potentiallyIllegal) { 30 + return a.potentiallyIllegal ? -1 : 1 31 + } 32 + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 33 + }) 34 + 35 + return ( 36 + <div className="space-y-3"> 37 + {sorted.map((report) => ( 38 + <article 39 + key={report.id} 40 + className={cn( 41 + 'rounded-lg border border-border bg-card p-4', 42 + report.potentiallyIllegal && 'border-l-4 border-l-destructive' 43 + )} 44 + > 45 + <div className="flex items-start justify-between gap-3"> 46 + <div className="min-w-0 flex-1"> 47 + {report.potentiallyIllegal && ( 48 + <span className="mb-1 inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive"> 49 + <WarningCircle size={12} aria-hidden="true" /> 50 + Potentially illegal 51 + </span> 52 + )} 53 + <p className="text-sm font-medium text-foreground"> 54 + <span className="capitalize">{report.reasonType}</span> 55 + {' -- reported by '} 56 + <span className="text-muted-foreground">{report.reporterHandle}</span> 57 + </p> 58 + <p className="mt-1 text-sm text-muted-foreground">{report.targetContent}</p> 59 + {report.reason && ( 60 + <p className="mt-1 text-xs text-muted-foreground italic"> 61 + &ldquo;{report.reason}&rdquo; 62 + </p> 63 + )} 64 + <p className="mt-1 text-xs text-muted-foreground"> 65 + Target: {report.targetAuthorHandle} &middot; {formatDate(report.createdAt)} 66 + </p> 67 + </div> 68 + <div className="flex shrink-0 gap-1"> 69 + {RESOLUTION_ACTIONS.map((action) => ( 70 + <button 71 + key={action.value} 72 + type="button" 73 + onClick={() => onResolve(report.id, action.value)} 74 + className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 75 + > 76 + {action.label} 77 + </button> 78 + ))} 79 + </div> 80 + </div> 81 + </article> 82 + ))} 83 + {sorted.length === 0 && ( 84 + <p className="py-8 text-center text-muted-foreground">No pending reports.</p> 85 + )} 86 + </div> 87 + ) 88 + }
+159
src/components/admin/moderation/thresholds-tab.tsx
··· 1 + /** 2 + * ModerationThresholdsTab - Form for editing moderation threshold settings. 3 + * Auto-block, warn, rate limits, anti-spam, burst detection. 4 + * @see specs/prd-web.md Section M11 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState } from 'react' 10 + import type { ModerationThresholds } from '@/lib/api/types' 11 + 12 + interface ModerationThresholdsTabProps { 13 + thresholds: ModerationThresholds 14 + onSave: (updated: Partial<ModerationThresholds>) => void 15 + } 16 + 17 + export function ModerationThresholdsTab({ thresholds, onSave }: ModerationThresholdsTabProps) { 18 + const [values, setValues] = useState(thresholds) 19 + 20 + return ( 21 + <div className="max-w-lg space-y-4"> 22 + <div> 23 + <label htmlFor="threshold-autoblock" className="block text-sm font-medium text-foreground"> 24 + Auto-block report count 25 + </label> 26 + <input 27 + id="threshold-autoblock" 28 + type="number" 29 + min={1} 30 + value={values.autoBlockReportCount} 31 + onChange={(e) => 32 + setValues({ ...values, autoBlockReportCount: parseInt(e.target.value, 10) || 1 }) 33 + } 34 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 35 + /> 36 + </div> 37 + <div> 38 + <label htmlFor="threshold-warn" className="block text-sm font-medium text-foreground"> 39 + Warn threshold 40 + </label> 41 + <input 42 + id="threshold-warn" 43 + type="number" 44 + min={1} 45 + value={values.warnThreshold} 46 + onChange={(e) => 47 + setValues({ ...values, warnThreshold: parseInt(e.target.value, 10) || 1 }) 48 + } 49 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 50 + /> 51 + </div> 52 + <div> 53 + <label htmlFor="threshold-fpq" className="block text-sm font-medium text-foreground"> 54 + First-post queue count (0 to disable) 55 + </label> 56 + <input 57 + id="threshold-fpq" 58 + type="number" 59 + min={0} 60 + value={values.firstPostQueueCount} 61 + onChange={(e) => 62 + setValues({ ...values, firstPostQueueCount: parseInt(e.target.value, 10) || 0 }) 63 + } 64 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 65 + /> 66 + </div> 67 + <div> 68 + <label htmlFor="threshold-ratelimit" className="block text-sm font-medium text-foreground"> 69 + New account rate limit (writes/min) 70 + </label> 71 + <input 72 + id="threshold-ratelimit" 73 + type="number" 74 + min={1} 75 + value={values.newAccountRateLimit} 76 + onChange={(e) => 77 + setValues({ ...values, newAccountRateLimit: parseInt(e.target.value, 10) || 1 }) 78 + } 79 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 80 + /> 81 + </div> 82 + <fieldset className="space-y-3"> 83 + <legend className="text-sm font-medium text-foreground">Anti-spam settings</legend> 84 + <div className="flex items-center gap-2"> 85 + <input 86 + id="threshold-linkhold" 87 + type="checkbox" 88 + checked={values.linkPostingHold} 89 + onChange={(e) => setValues({ ...values, linkPostingHold: e.target.checked })} 90 + className="rounded border-border" 91 + /> 92 + <label htmlFor="threshold-linkhold" className="text-sm text-foreground"> 93 + Hold posts with links from new accounts for review 94 + </label> 95 + </div> 96 + <div className="flex items-center gap-2"> 97 + <input 98 + id="threshold-topicdelay" 99 + type="checkbox" 100 + checked={values.topicCreationDelay} 101 + onChange={(e) => setValues({ ...values, topicCreationDelay: e.target.checked })} 102 + className="rounded border-border" 103 + /> 104 + <label htmlFor="threshold-topicdelay" className="text-sm text-foreground"> 105 + Delay topic creation for new accounts 106 + </label> 107 + </div> 108 + </fieldset> 109 + <div className="flex gap-4"> 110 + <div> 111 + <label 112 + htmlFor="threshold-burstcount" 113 + className="block text-sm font-medium text-foreground" 114 + > 115 + Burst detection: posts 116 + </label> 117 + <input 118 + id="threshold-burstcount" 119 + type="number" 120 + min={1} 121 + value={values.burstDetectionPostCount} 122 + onChange={(e) => 123 + setValues({ 124 + ...values, 125 + burstDetectionPostCount: parseInt(e.target.value, 10) || 1, 126 + }) 127 + } 128 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 129 + /> 130 + </div> 131 + <div> 132 + <label htmlFor="threshold-burstmin" className="block text-sm font-medium text-foreground"> 133 + in minutes 134 + </label> 135 + <input 136 + id="threshold-burstmin" 137 + type="number" 138 + min={1} 139 + value={values.burstDetectionMinutes} 140 + onChange={(e) => 141 + setValues({ 142 + ...values, 143 + burstDetectionMinutes: parseInt(e.target.value, 10) || 1, 144 + }) 145 + } 146 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 147 + /> 148 + </div> 149 + </div> 150 + <button 151 + type="button" 152 + onClick={() => onSave(values)} 153 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 154 + > 155 + Save Thresholds 156 + </button> 157 + </div> 158 + ) 159 + }
+142
src/components/admin/onboarding/onboarding-field-form.tsx
··· 1 + /** 2 + * OnboardingFieldForm - Edit/create form for an onboarding field. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + import type { OnboardingFieldType } from '@/lib/api/types' 7 + 8 + const FIELD_TYPE_LABELS: Record<OnboardingFieldType, string> = { 9 + age_confirmation: 'Age Confirmation', 10 + tos_acceptance: 'ToS Acceptance', 11 + newsletter_email: 'Newsletter Email', 12 + custom_text: 'Text Input', 13 + custom_select: 'Dropdown Select', 14 + custom_checkbox: 'Checkbox', 15 + } 16 + 17 + export interface EditingField { 18 + id: string | null 19 + fieldType: OnboardingFieldType 20 + label: string 21 + description: string 22 + isMandatory: boolean 23 + config: Record<string, unknown> | null 24 + } 25 + 26 + export const EMPTY_FIELD: EditingField = { 27 + id: null, 28 + fieldType: 'custom_text', 29 + label: '', 30 + description: '', 31 + isMandatory: true, 32 + config: null, 33 + } 34 + 35 + interface OnboardingFieldFormProps { 36 + editing: EditingField 37 + saving: boolean 38 + error: string | null 39 + onChange: (field: EditingField) => void 40 + onSave: () => void 41 + onCancel: () => void 42 + } 43 + 44 + export function OnboardingFieldForm({ 45 + editing, 46 + saving, 47 + error, 48 + onChange, 49 + onSave, 50 + onCancel, 51 + }: OnboardingFieldFormProps) { 52 + return ( 53 + <div className="rounded-lg border border-border bg-card p-4"> 54 + <h2 className="mb-4 text-lg font-semibold text-foreground"> 55 + {editing.id ? 'Edit Field' : 'New Onboarding Field'} 56 + </h2> 57 + <div className="space-y-4"> 58 + {!editing.id && ( 59 + <div> 60 + <label htmlFor="field-type" className="block text-sm font-medium text-foreground"> 61 + Field Type 62 + </label> 63 + <select 64 + id="field-type" 65 + value={editing.fieldType} 66 + onChange={(e) => 67 + onChange({ ...editing, fieldType: e.target.value as OnboardingFieldType }) 68 + } 69 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 70 + > 71 + {Object.entries(FIELD_TYPE_LABELS).map(([value, label]) => ( 72 + <option key={value} value={value}> 73 + {label} 74 + </option> 75 + ))} 76 + </select> 77 + </div> 78 + )} 79 + <div> 80 + <label htmlFor="field-label" className="block text-sm font-medium text-foreground"> 81 + Label 82 + </label> 83 + <input 84 + id="field-label" 85 + type="text" 86 + value={editing.label} 87 + onChange={(e) => onChange({ ...editing, label: e.target.value })} 88 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 89 + placeholder="e.g., Accept our community rules" 90 + /> 91 + </div> 92 + <div> 93 + <label htmlFor="field-description" className="block text-sm font-medium text-foreground"> 94 + Description (optional) 95 + </label> 96 + <textarea 97 + id="field-description" 98 + value={editing.description} 99 + onChange={(e) => onChange({ ...editing, description: e.target.value })} 100 + rows={2} 101 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 102 + placeholder="Additional context or instructions for users" 103 + /> 104 + </div> 105 + <div className="flex items-center gap-2"> 106 + <input 107 + id="field-mandatory" 108 + type="checkbox" 109 + checked={editing.isMandatory} 110 + onChange={(e) => onChange({ ...editing, isMandatory: e.target.checked })} 111 + className="h-4 w-4 rounded border-border" 112 + /> 113 + <label htmlFor="field-mandatory" className="text-sm text-foreground"> 114 + Required (users must complete this field before posting) 115 + </label> 116 + </div> 117 + {error && ( 118 + <p role="alert" className="text-sm text-destructive"> 119 + {error} 120 + </p> 121 + )} 122 + <div className="flex gap-2"> 123 + <button 124 + type="button" 125 + onClick={onSave} 126 + disabled={saving} 127 + className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 128 + > 129 + {saving ? 'Saving...' : 'Save'} 130 + </button> 131 + <button 132 + type="button" 133 + onClick={onCancel} 134 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 135 + > 136 + Cancel 137 + </button> 138 + </div> 139 + </div> 140 + </div> 141 + ) 142 + }
+99
src/components/admin/onboarding/onboarding-field-item.tsx
··· 1 + /** 2 + * OnboardingFieldItem - Single onboarding field row with reorder/edit/delete controls. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + import { PencilSimple, TrashSimple, ArrowUp, ArrowDown } from '@phosphor-icons/react' 7 + import { cn } from '@/lib/utils' 8 + import type { OnboardingField, OnboardingFieldType } from '@/lib/api/types' 9 + 10 + const FIELD_TYPE_LABELS: Record<OnboardingFieldType, string> = { 11 + age_confirmation: 'Age Confirmation', 12 + tos_acceptance: 'ToS Acceptance', 13 + newsletter_email: 'Newsletter Email', 14 + custom_text: 'Text Input', 15 + custom_select: 'Dropdown Select', 16 + custom_checkbox: 'Checkbox', 17 + } 18 + 19 + interface OnboardingFieldItemProps { 20 + field: OnboardingField 21 + index: number 22 + totalCount: number 23 + onMoveUp: (index: number) => void 24 + onMoveDown: (index: number) => void 25 + onEdit: (field: OnboardingField) => void 26 + onDelete: (id: string) => void 27 + } 28 + 29 + export function OnboardingFieldItem({ 30 + field, 31 + index, 32 + totalCount, 33 + onMoveUp, 34 + onMoveDown, 35 + onEdit, 36 + onDelete, 37 + }: OnboardingFieldItemProps) { 38 + return ( 39 + <div className="flex items-center justify-between rounded-md border border-border bg-card p-3"> 40 + <div className="min-w-0 flex-1"> 41 + <div className="flex items-center gap-2"> 42 + <p className="text-sm font-medium text-foreground">{field.label}</p> 43 + <span 44 + className={cn( 45 + 'rounded-full px-2 py-0.5 text-xs font-medium', 46 + 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' 47 + )} 48 + > 49 + {FIELD_TYPE_LABELS[field.fieldType]} 50 + </span> 51 + {field.isMandatory && ( 52 + <span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400"> 53 + Required 54 + </span> 55 + )} 56 + </div> 57 + {field.description && ( 58 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 59 + )} 60 + </div> 61 + <div className="flex items-center gap-1"> 62 + <button 63 + type="button" 64 + onClick={() => onMoveUp(index)} 65 + disabled={index === 0} 66 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 67 + aria-label={`Move ${field.label} up`} 68 + > 69 + <ArrowUp size={16} aria-hidden="true" /> 70 + </button> 71 + <button 72 + type="button" 73 + onClick={() => onMoveDown(index)} 74 + disabled={index === totalCount - 1} 75 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 76 + aria-label={`Move ${field.label} down`} 77 + > 78 + <ArrowDown size={16} aria-hidden="true" /> 79 + </button> 80 + <button 81 + type="button" 82 + onClick={() => onEdit(field)} 83 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 84 + aria-label={`Edit ${field.label}`} 85 + > 86 + <PencilSimple size={16} aria-hidden="true" /> 87 + </button> 88 + <button 89 + type="button" 90 + onClick={() => onDelete(field.id)} 91 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" 92 + aria-label={`Delete ${field.label}`} 93 + > 94 + <TrashSimple size={16} aria-hidden="true" /> 95 + </button> 96 + </div> 97 + </div> 98 + ) 99 + }
+115
src/components/admin/plugins/plugin-card.tsx
··· 1 + /** 2 + * PluginCard - Card display for a single plugin with controls. 3 + * @see specs/prd-web.md Section M13 4 + */ 5 + 6 + import { Gear, Trash } from '@phosphor-icons/react' 7 + import { cn } from '@/lib/utils' 8 + import type { Plugin } from '@/lib/api/types' 9 + 10 + const SOURCE_STYLES: Record<string, string> = { 11 + core: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', 12 + official: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', 13 + community: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 14 + experimental: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', 15 + } 16 + 17 + const SOURCE_LABELS: Record<string, string> = { 18 + core: 'Core', 19 + official: 'Official', 20 + community: 'Community', 21 + experimental: 'Experimental', 22 + } 23 + 24 + interface PluginCardProps { 25 + plugin: Plugin 26 + allPlugins: Plugin[] 27 + onOpenSettings: (plugin: Plugin) => void 28 + onToggle: (plugin: Plugin) => void 29 + onUninstall: (plugin: Plugin) => void 30 + } 31 + 32 + export function PluginCard({ 33 + plugin, 34 + allPlugins, 35 + onOpenSettings, 36 + onToggle, 37 + onUninstall, 38 + }: PluginCardProps) { 39 + return ( 40 + <article 41 + className={cn('rounded-lg border border-border bg-card p-4', !plugin.enabled && 'opacity-60')} 42 + > 43 + <div className="flex items-start justify-between gap-4"> 44 + <div className="min-w-0 flex-1"> 45 + <div className="flex items-center gap-2"> 46 + <h2 className="text-sm font-semibold text-foreground">{plugin.displayName}</h2> 47 + <span className="text-xs text-muted-foreground">v{plugin.version}</span> 48 + <span 49 + className={cn( 50 + 'rounded-full px-2 py-0.5 text-xs font-medium', 51 + SOURCE_STYLES[plugin.source] ?? SOURCE_STYLES.community 52 + )} 53 + > 54 + {SOURCE_LABELS[plugin.source] ?? plugin.source} 55 + </span> 56 + </div> 57 + <p className="mt-1 text-xs text-muted-foreground">{plugin.description}</p> 58 + {plugin.dependencies.length > 0 && ( 59 + <p className="mt-1 text-xs text-muted-foreground"> 60 + Depends on:{' '} 61 + {plugin.dependencies 62 + .map((depId) => { 63 + const dep = allPlugins.find((p) => p.id === depId) 64 + return dep?.displayName ?? depId 65 + }) 66 + .join(', ')} 67 + </p> 68 + )} 69 + </div> 70 + 71 + <div className="flex shrink-0 items-center gap-2"> 72 + {Object.keys(plugin.settingsSchema).length > 0 && ( 73 + <button 74 + type="button" 75 + onClick={() => onOpenSettings(plugin)} 76 + className="rounded-md border border-border p-1.5 text-muted-foreground transition-colors hover:text-foreground" 77 + aria-label={`${plugin.displayName} settings`} 78 + > 79 + <Gear size={16} aria-hidden="true" /> 80 + </button> 81 + )} 82 + {plugin.source !== 'core' && ( 83 + <button 84 + type="button" 85 + onClick={() => onUninstall(plugin)} 86 + className="rounded-md border border-border p-1.5 text-muted-foreground transition-colors hover:text-destructive" 87 + aria-label={`Uninstall ${plugin.displayName}`} 88 + > 89 + <Trash size={16} aria-hidden="true" /> 90 + </button> 91 + )} 92 + <button 93 + type="button" 94 + role="switch" 95 + aria-checked={plugin.enabled} 96 + aria-label={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} 97 + onClick={() => onToggle(plugin)} 98 + className={cn( 99 + 'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors', 100 + plugin.enabled ? 'bg-primary' : 'bg-muted' 101 + )} 102 + > 103 + <span 104 + aria-hidden="true" 105 + className={cn( 106 + 'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition-transform', 107 + plugin.enabled ? 'translate-x-5' : 'translate-x-0' 108 + )} 109 + /> 110 + </button> 111 + </div> 112 + </div> 113 + </article> 114 + ) 115 + }
+84
src/components/admin/plugins/plugin-settings-modal.tsx
··· 1 + /** 2 + * PluginSettingsModal - Modal dialog for editing a plugin's settings. 3 + * Dynamically renders fields from the plugin's settingsSchema. 4 + * @see specs/prd-web.md Section M13 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState } from 'react' 10 + import { X } from '@phosphor-icons/react' 11 + import { SettingsField } from '@/components/admin/plugins/settings-field' 12 + import type { Plugin } from '@/lib/api/types' 13 + 14 + interface PluginSettingsModalProps { 15 + plugin: Plugin 16 + onClose: () => void 17 + onSave: (settings: Record<string, boolean | string | number>) => void 18 + } 19 + 20 + export function PluginSettingsModal({ plugin, onClose, onSave }: PluginSettingsModalProps) { 21 + const [values, setValues] = useState<Record<string, boolean | string | number>>(() => ({ 22 + ...plugin.settings, 23 + })) 24 + 25 + const handleChange = (key: string, value: boolean | string | number) => { 26 + setValues((prev) => ({ ...prev, [key]: value })) 27 + } 28 + 29 + const handleSubmit = (e: React.FormEvent) => { 30 + e.preventDefault() 31 + onSave(values) 32 + } 33 + 34 + return ( 35 + <div 36 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 37 + role="dialog" 38 + aria-modal="true" 39 + aria-label={`${plugin.displayName} settings`} 40 + > 41 + <div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 42 + <div className="mb-4 flex items-center justify-between"> 43 + <h2 className="text-lg font-semibold text-foreground">{plugin.displayName} Settings</h2> 44 + <button 45 + type="button" 46 + onClick={onClose} 47 + className="rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground" 48 + aria-label="Close settings" 49 + > 50 + <X size={18} aria-hidden="true" /> 51 + </button> 52 + </div> 53 + 54 + <form onSubmit={handleSubmit} className="space-y-4"> 55 + {Object.entries(plugin.settingsSchema).map(([key, schema]) => ( 56 + <SettingsField 57 + key={key} 58 + fieldKey={key} 59 + schema={schema} 60 + value={values[key] ?? schema.default} 61 + onChange={(val) => handleChange(key, val)} 62 + /> 63 + ))} 64 + 65 + <div className="flex justify-end gap-2 pt-2"> 66 + <button 67 + type="button" 68 + onClick={onClose} 69 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 70 + > 71 + Cancel 72 + </button> 73 + <button 74 + type="submit" 75 + className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 76 + > 77 + Save 78 + </button> 79 + </div> 80 + </form> 81 + </div> 82 + </div> 83 + ) 84 + }
+89
src/components/admin/plugins/settings-field.tsx
··· 1 + /** 2 + * SettingsField - Dynamic form field renderer for plugin settings schemas. 3 + * Handles boolean, select, number, and string types. 4 + * @see specs/prd-web.md Section M13 5 + */ 6 + 7 + import type { PluginSettingsSchema } from '@/lib/api/types' 8 + 9 + interface SettingsFieldProps { 10 + fieldKey: string 11 + schema: PluginSettingsSchema[string] 12 + value: boolean | string | number 13 + onChange: (value: boolean | string | number) => void 14 + } 15 + 16 + export function SettingsField({ fieldKey, schema, value, onChange }: SettingsFieldProps) { 17 + if (schema.type === 'boolean') { 18 + return ( 19 + <label className="flex items-center justify-between gap-3"> 20 + <div> 21 + <span className="text-sm font-medium text-foreground">{schema.label}</span> 22 + {schema.description && ( 23 + <p className="text-xs text-muted-foreground">{schema.description}</p> 24 + )} 25 + </div> 26 + <input 27 + type="checkbox" 28 + checked={value as boolean} 29 + onChange={(e) => onChange(e.target.checked)} 30 + className="h-4 w-4 rounded border-border" 31 + /> 32 + </label> 33 + ) 34 + } 35 + 36 + if (schema.type === 'select') { 37 + return ( 38 + <label className="block"> 39 + <span className="text-sm font-medium text-foreground">{schema.label}</span> 40 + {schema.description && ( 41 + <p className="text-xs text-muted-foreground">{schema.description}</p> 42 + )} 43 + <select 44 + value={value as string} 45 + onChange={(e) => onChange(e.target.value)} 46 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground" 47 + > 48 + {schema.options?.map((opt) => ( 49 + <option key={opt} value={opt}> 50 + {opt || '(none)'} 51 + </option> 52 + ))} 53 + </select> 54 + </label> 55 + ) 56 + } 57 + 58 + if (schema.type === 'number') { 59 + return ( 60 + <label className="block"> 61 + <span className="text-sm font-medium text-foreground">{schema.label}</span> 62 + {schema.description && ( 63 + <p className="text-xs text-muted-foreground">{schema.description}</p> 64 + )} 65 + <input 66 + type="number" 67 + value={value as number} 68 + onChange={(e) => onChange(Number(e.target.value))} 69 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground" 70 + name={fieldKey} 71 + /> 72 + </label> 73 + ) 74 + } 75 + 76 + return ( 77 + <label className="block"> 78 + <span className="text-sm font-medium text-foreground">{schema.label}</span> 79 + {schema.description && <p className="text-xs text-muted-foreground">{schema.description}</p>} 80 + <input 81 + type="text" 82 + value={value as string} 83 + onChange={(e) => onChange(e.target.value)} 84 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground" 85 + name={fieldKey} 86 + /> 87 + </label> 88 + ) 89 + }
+143
src/components/admin/settings/community-settings-form.tsx
··· 1 + /** 2 + * CommunitySettingsForm - Form for community name, description, maturity, reactions, branding. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + 'use client' 7 + 8 + import { ErrorAlert } from '@/components/error-alert' 9 + import type { CommunitySettings, MaturityRating } from '@/lib/api/types' 10 + 11 + interface CommunitySettingsFormProps { 12 + settings: CommunitySettings 13 + onChange: (updated: CommunitySettings) => void 14 + onSave: () => void 15 + saving: boolean 16 + saveError: string | null 17 + onDismissError: () => void 18 + } 19 + 20 + export function CommunitySettingsForm({ 21 + settings, 22 + onChange, 23 + onSave, 24 + saving, 25 + saveError, 26 + onDismissError, 27 + }: CommunitySettingsFormProps) { 28 + return ( 29 + <div className="max-w-lg space-y-6"> 30 + <div> 31 + <label htmlFor="settings-name" className="block text-sm font-medium text-foreground"> 32 + Community Name 33 + </label> 34 + <input 35 + id="settings-name" 36 + type="text" 37 + value={settings.communityName} 38 + onChange={(e) => onChange({ ...settings, communityName: e.target.value })} 39 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 40 + /> 41 + </div> 42 + 43 + <div> 44 + <label htmlFor="settings-desc" className="block text-sm font-medium text-foreground"> 45 + Description 46 + </label> 47 + <textarea 48 + id="settings-desc" 49 + value={settings.communityDescription ?? ''} 50 + onChange={(e) => onChange({ ...settings, communityDescription: e.target.value || null })} 51 + rows={3} 52 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 53 + /> 54 + </div> 55 + 56 + <div> 57 + <label htmlFor="settings-maturity" className="block text-sm font-medium text-foreground"> 58 + Community Maturity Rating 59 + </label> 60 + <select 61 + id="settings-maturity" 62 + value={settings.maturityRating} 63 + onChange={(e) => 64 + onChange({ ...settings, maturityRating: e.target.value as MaturityRating }) 65 + } 66 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 67 + > 68 + <option value="safe">Safe (default)</option> 69 + <option value="mature">Mature</option> 70 + <option value="adult">Adult</option> 71 + </select> 72 + <p className="mt-1 text-xs text-muted-foreground"> 73 + Changing to Mature or Adult affects global aggregator visibility. 74 + </p> 75 + </div> 76 + 77 + <div> 78 + <label htmlFor="settings-reactions" className="block text-sm font-medium text-foreground"> 79 + Reaction Set 80 + </label> 81 + <input 82 + id="settings-reactions" 83 + type="text" 84 + value={settings.reactionSet.join(', ')} 85 + onChange={(e) => 86 + onChange({ 87 + ...settings, 88 + reactionSet: e.target.value 89 + .split(',') 90 + .map((s) => s.trim()) 91 + .filter(Boolean), 92 + }) 93 + } 94 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 95 + /> 96 + <p className="mt-1 text-xs text-muted-foreground"> 97 + Comma-separated list of reaction types available in your community. 98 + </p> 99 + </div> 100 + 101 + <fieldset className="space-y-4"> 102 + <legend className="text-sm font-medium text-foreground">Branding</legend> 103 + <div> 104 + <label htmlFor="settings-primary-color" className="block text-sm text-muted-foreground"> 105 + Primary Color 106 + </label> 107 + <input 108 + id="settings-primary-color" 109 + type="text" 110 + value={settings.primaryColor ?? ''} 111 + onChange={(e) => onChange({ ...settings, primaryColor: e.target.value || null })} 112 + placeholder="#31748f" 113 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 114 + /> 115 + </div> 116 + <div> 117 + <label htmlFor="settings-accent-color" className="block text-sm text-muted-foreground"> 118 + Accent Color 119 + </label> 120 + <input 121 + id="settings-accent-color" 122 + type="text" 123 + value={settings.accentColor ?? ''} 124 + onChange={(e) => onChange({ ...settings, accentColor: e.target.value || null })} 125 + placeholder="#c4a7e7" 126 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 127 + /> 128 + </div> 129 + </fieldset> 130 + 131 + {saveError && <ErrorAlert message={saveError} onDismiss={onDismissError} />} 132 + 133 + <button 134 + type="button" 135 + onClick={onSave} 136 + disabled={saving} 137 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 138 + > 139 + {saving ? 'Saving...' : 'Save Settings'} 140 + </button> 141 + </div> 142 + ) 143 + }
+124
src/components/admin/settings/pds-override-dialog.tsx
··· 1 + /** 2 + * PdsOverrideDialog - Form dialog for adding/editing PDS trust factor overrides. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useEffect, useRef } from 'react' 9 + import { cn } from '@/lib/utils' 10 + 11 + interface PdsOverrideDialogProps { 12 + open: boolean 13 + mode: 'add' | 'edit' 14 + initialHostname: string 15 + initialTrustFactor: number 16 + onClose: () => void 17 + onSubmit: (hostname: string, trustFactor: number) => void 18 + } 19 + 20 + export function PdsOverrideDialog({ 21 + open, 22 + mode, 23 + initialHostname, 24 + initialTrustFactor, 25 + onClose, 26 + onSubmit, 27 + }: PdsOverrideDialogProps) { 28 + const [hostname, setHostname] = useState(initialHostname) 29 + const [trustFactor, setTrustFactor] = useState(initialTrustFactor) 30 + const hostnameRef = useRef<HTMLInputElement>(null) 31 + 32 + useEffect(() => { 33 + if (open && mode === 'add') { 34 + hostnameRef.current?.focus() 35 + } 36 + }, [open, mode]) 37 + 38 + useEffect(() => { 39 + if (!open) return 40 + const handleKey = (e: KeyboardEvent) => { 41 + if (e.key === 'Escape') onClose() 42 + } 43 + document.addEventListener('keydown', handleKey) 44 + return () => document.removeEventListener('keydown', handleKey) 45 + }, [open, onClose]) 46 + 47 + if (!open) return null 48 + 49 + const handleSubmit = (e: React.FormEvent) => { 50 + e.preventDefault() 51 + if (mode === 'add' && !hostname.trim()) return 52 + onSubmit(hostname.trim(), trustFactor) 53 + } 54 + 55 + return ( 56 + <div 57 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 58 + role="dialog" 59 + aria-modal="true" 60 + aria-label={mode === 'add' ? 'Add PDS trust override' : 'Edit PDS trust factor'} 61 + > 62 + <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 63 + <h3 className="text-lg font-semibold text-foreground"> 64 + {mode === 'add' ? 'Add PDS Override' : 'Edit Trust Factor'} 65 + </h3> 66 + <form onSubmit={handleSubmit} className="mt-4 space-y-4"> 67 + <div> 68 + <label htmlFor="pds-hostname" className="block text-sm font-medium text-foreground"> 69 + PDS Hostname 70 + </label> 71 + <input 72 + ref={hostnameRef} 73 + id="pds-hostname" 74 + type="text" 75 + value={hostname} 76 + onChange={(e) => setHostname(e.target.value)} 77 + disabled={mode === 'edit'} 78 + placeholder="my-pds.example.org" 79 + className={cn( 80 + 'mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 81 + mode === 'edit' && 'cursor-not-allowed opacity-60' 82 + )} 83 + required 84 + /> 85 + </div> 86 + <div> 87 + <label htmlFor="pds-trust-factor" className="block text-sm font-medium text-foreground"> 88 + Trust Factor: {trustFactor.toFixed(1)} 89 + </label> 90 + <input 91 + id="pds-trust-factor" 92 + type="range" 93 + min="0" 94 + max="1" 95 + step="0.1" 96 + value={trustFactor} 97 + onChange={(e) => setTrustFactor(parseFloat(e.target.value))} 98 + className="mt-1 w-full" 99 + /> 100 + <div className="mt-1 flex justify-between text-xs text-muted-foreground"> 101 + <span>0.0 (untrusted)</span> 102 + <span>1.0 (fully trusted)</span> 103 + </div> 104 + </div> 105 + <div className="flex justify-end gap-2"> 106 + <button 107 + type="button" 108 + onClick={onClose} 109 + className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 110 + > 111 + Cancel 112 + </button> 113 + <button 114 + type="submit" 115 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 116 + > 117 + {mode === 'add' ? 'Add' : 'Save'} 118 + </button> 119 + </div> 120 + </form> 121 + </div> 122 + </div> 123 + ) 124 + }
+138
src/components/admin/settings/pds-trust-section.tsx
··· 1 + /** 2 + * PdsTrustSection - PDS Provider Trust management with add/edit/remove. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState } from 'react' 9 + import { ConfirmDialog } from '@/components/confirm-dialog' 10 + import { PdsOverrideDialog } from '@/components/admin/settings/pds-override-dialog' 11 + import type { PdsTrustFactor } from '@/lib/api/types' 12 + 13 + interface PdsTrustSectionProps { 14 + providers: PdsTrustFactor[] 15 + onUpdate: (pdsHost: string, trustFactor: number) => void 16 + onRemove: (pdsHost: string) => void 17 + } 18 + 19 + export function PdsTrustSection({ providers, onUpdate, onRemove }: PdsTrustSectionProps) { 20 + const [dialogOpen, setDialogOpen] = useState(false) 21 + const [dialogKey, setDialogKey] = useState(0) 22 + const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add') 23 + const [editHostname, setEditHostname] = useState('') 24 + const [editTrustFactor, setEditTrustFactor] = useState(1.0) 25 + const [confirmRemove, setConfirmRemove] = useState<string | null>(null) 26 + 27 + const handleAdd = () => { 28 + setDialogMode('add') 29 + setEditHostname('') 30 + setEditTrustFactor(1.0) 31 + setDialogKey((k) => k + 1) 32 + setDialogOpen(true) 33 + } 34 + 35 + const handleEdit = (provider: PdsTrustFactor) => { 36 + setDialogMode('edit') 37 + setEditHostname(provider.pdsHost) 38 + setEditTrustFactor(provider.trustFactor) 39 + setDialogKey((k) => k + 1) 40 + setDialogOpen(true) 41 + } 42 + 43 + const handleDialogSubmit = (hostname: string, trustFactor: number) => { 44 + onUpdate(hostname, trustFactor) 45 + setDialogOpen(false) 46 + } 47 + 48 + return ( 49 + <div className="space-y-4"> 50 + <div className="flex items-center justify-between"> 51 + <div> 52 + <h2 className="text-lg font-semibold text-foreground">PDS Provider Trust</h2> 53 + <p className="mt-0.5 text-sm text-muted-foreground"> 54 + Accounts from providers with higher trust factors earn reputation faster. Override the 55 + default if you trust a specific self-hosted PDS provider. 56 + </p> 57 + </div> 58 + <button 59 + type="button" 60 + onClick={handleAdd} 61 + aria-label="Add override" 62 + className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 63 + > 64 + Add Override 65 + </button> 66 + </div> 67 + 68 + <div className="space-y-2"> 69 + {providers.map((provider) => ( 70 + <div 71 + key={provider.pdsHost} 72 + className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3" 73 + > 74 + <div className="min-w-0 flex-1"> 75 + <div className="flex items-center gap-2"> 76 + <span className="text-sm font-medium text-foreground">{provider.pdsHost}</span> 77 + {provider.isDefault && ( 78 + <span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"> 79 + Default 80 + </span> 81 + )} 82 + </div> 83 + <p className="mt-0.5 text-xs text-muted-foreground"> 84 + Trust factor: {provider.trustFactor.toFixed(1)} 85 + </p> 86 + </div> 87 + {!provider.isDefault && ( 88 + <div className="flex shrink-0 gap-2"> 89 + <button 90 + type="button" 91 + onClick={() => handleEdit(provider)} 92 + aria-label="Edit" 93 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 94 + > 95 + Edit 96 + </button> 97 + <button 98 + type="button" 99 + onClick={() => setConfirmRemove(provider.pdsHost)} 100 + aria-label="Remove" 101 + className="rounded-md border border-border px-3 py-1.5 text-sm text-destructive transition-colors hover:bg-destructive/10" 102 + > 103 + Remove 104 + </button> 105 + </div> 106 + )} 107 + </div> 108 + ))} 109 + {providers.length === 0 && ( 110 + <p className="py-4 text-center text-muted-foreground">No PDS providers configured.</p> 111 + )} 112 + </div> 113 + 114 + <PdsOverrideDialog 115 + key={dialogKey} 116 + open={dialogOpen} 117 + mode={dialogMode} 118 + initialHostname={editHostname} 119 + initialTrustFactor={editTrustFactor} 120 + onClose={() => setDialogOpen(false)} 121 + onSubmit={handleDialogSubmit} 122 + /> 123 + 124 + <ConfirmDialog 125 + open={confirmRemove !== null} 126 + title="Remove PDS override" 127 + description={`Are you sure you want to remove the trust override for ${confirmRemove ?? ''}? The default trust factor will apply.`} 128 + confirmLabel="Confirm" 129 + variant="destructive" 130 + onConfirm={() => { 131 + if (confirmRemove) onRemove(confirmRemove) 132 + setConfirmRemove(null) 133 + }} 134 + onCancel={() => setConfirmRemove(null)} 135 + /> 136 + </div> 137 + ) 138 + }
+51
src/components/admin/sybil/behavioral-flags-section.tsx
··· 1 + /** 2 + * BehavioralFlagsSection - List of behavioral flags with dismiss capability. 3 + * @see specs/prd-web.md Section P2.10 4 + */ 5 + 6 + import { formatRelativeTime } from '@/lib/format' 7 + import { StatusBadge } from '@/components/admin/sybil/status-badge' 8 + import type { BehavioralFlag } from '@/lib/api/types' 9 + 10 + interface BehavioralFlagsSectionProps { 11 + flags: BehavioralFlag[] 12 + onDismiss: (id: number) => void 13 + } 14 + 15 + export function BehavioralFlagsSection({ flags, onDismiss }: BehavioralFlagsSectionProps) { 16 + return ( 17 + <div className="space-y-3"> 18 + <h2 className="text-lg font-semibold text-foreground">Behavioral Flags</h2> 19 + {flags.length === 0 && ( 20 + <p className="py-4 text-center text-muted-foreground">No behavioral flags.</p> 21 + )} 22 + {flags.map((flag) => ( 23 + <article key={flag.id} className="rounded-lg border border-border bg-card p-4"> 24 + <div className="flex items-start justify-between gap-3"> 25 + <div className="min-w-0 flex-1"> 26 + <div className="flex items-center gap-2"> 27 + <span className="text-sm font-medium text-foreground">{flag.flagType}</span> 28 + <StatusBadge status={flag.status} /> 29 + </div> 30 + <p className="mt-1 text-sm text-muted-foreground">{flag.details}</p> 31 + <p className="mt-1 text-xs text-muted-foreground"> 32 + {flag.affectedDids.length} affected accounts &middot; Detected{' '} 33 + {formatRelativeTime(flag.detectedAt)} 34 + </p> 35 + </div> 36 + {flag.status === 'pending' && ( 37 + <button 38 + type="button" 39 + onClick={() => onDismiss(flag.id)} 40 + aria-label="Dismiss flag" 41 + className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 42 + > 43 + Dismiss 44 + </button> 45 + )} 46 + </div> 47 + </article> 48 + ))} 49 + </div> 50 + ) 51 + }
+115
src/components/admin/sybil/cluster-detail-view.tsx
··· 1 + /** 2 + * SybilClusterDetailView - Detail view for a single sybil cluster. 3 + * Shows member table and action buttons (Monitor/Dismiss/Ban). 4 + * @see specs/prd-web.md Section P2.10 5 + */ 6 + 7 + import { ArrowLeft } from '@phosphor-icons/react' 8 + import { StatusBadge } from '@/components/admin/sybil/status-badge' 9 + import { SuspicionBar } from '@/components/admin/sybil/suspicion-bar' 10 + import type { SybilClusterDetail, SybilClusterStatus } from '@/lib/api/types' 11 + 12 + interface SybilClusterDetailViewProps { 13 + detail: SybilClusterDetail 14 + onBack: () => void 15 + onAction: (status: SybilClusterStatus) => void 16 + } 17 + 18 + export function SybilClusterDetailView({ detail, onBack, onAction }: SybilClusterDetailViewProps) { 19 + return ( 20 + <div className="space-y-4"> 21 + <button 22 + type="button" 23 + onClick={onBack} 24 + className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground" 25 + aria-label="Back to cluster list" 26 + > 27 + <ArrowLeft size={14} aria-hidden="true" /> 28 + Back to clusters 29 + </button> 30 + 31 + <div className="rounded-lg border border-border bg-card p-4"> 32 + <div className="flex items-center justify-between"> 33 + <div> 34 + <h3 className="text-lg font-semibold text-foreground">Cluster #{detail.id}</h3> 35 + <div className="mt-1 flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> 36 + <span>{detail.memberCount} members</span> 37 + <StatusBadge status={detail.status} /> 38 + <SuspicionBar ratio={detail.suspicionRatio} /> 39 + </div> 40 + </div> 41 + <div className="flex gap-2"> 42 + {detail.status !== 'monitoring' && ( 43 + <button 44 + type="button" 45 + onClick={() => onAction('monitoring')} 46 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 47 + > 48 + Monitor 49 + </button> 50 + )} 51 + {detail.status !== 'dismissed' && ( 52 + <button 53 + type="button" 54 + onClick={() => onAction('dismissed')} 55 + aria-label="Dismiss cluster" 56 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 57 + > 58 + Dismiss 59 + </button> 60 + )} 61 + {detail.status !== 'banned' && ( 62 + <button 63 + type="button" 64 + onClick={() => onAction('banned')} 65 + aria-label="Ban cluster" 66 + className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 67 + > 68 + Ban 69 + </button> 70 + )} 71 + </div> 72 + </div> 73 + </div> 74 + 75 + {/* Member table */} 76 + <div className="overflow-x-auto"> 77 + <table className="w-full text-sm"> 78 + <thead> 79 + <tr className="border-b border-border text-left"> 80 + <th className="pb-2 pr-4 font-medium text-muted-foreground">Handle</th> 81 + <th className="pb-2 pr-4 font-medium text-muted-foreground">Role</th> 82 + <th className="pb-2 pr-4 font-medium text-muted-foreground">Trust</th> 83 + <th className="pb-2 pr-4 font-medium text-muted-foreground">Reputation</th> 84 + <th className="pb-2 pr-4 font-medium text-muted-foreground">Account Age</th> 85 + <th className="pb-2 font-medium text-muted-foreground">Communities</th> 86 + </tr> 87 + </thead> 88 + <tbody> 89 + {detail.members.map((member) => ( 90 + <tr key={member.did} className="border-b border-border last:border-0"> 91 + <td className="py-2 pr-4"> 92 + <div> 93 + <p className="font-medium text-foreground">{member.handle}</p> 94 + <p className="text-xs text-muted-foreground">{member.displayName}</p> 95 + </div> 96 + </td> 97 + <td className="py-2 pr-4"> 98 + <StatusBadge status={member.roleInCluster} /> 99 + </td> 100 + <td className="py-2 pr-4 text-muted-foreground"> 101 + {(member.trustScore * 100).toFixed(0)}% 102 + </td> 103 + <td className="py-2 pr-4 text-muted-foreground"> 104 + {(member.reputationScore * 100).toFixed(0)}% 105 + </td> 106 + <td className="py-2 pr-4 text-muted-foreground">{member.accountAge}</td> 107 + <td className="py-2 text-muted-foreground">{member.communitiesActiveIn}</td> 108 + </tr> 109 + ))} 110 + </tbody> 111 + </table> 112 + </div> 113 + </div> 114 + ) 115 + }
+52
src/components/admin/sybil/cluster-list-view.tsx
··· 1 + /** 2 + * SybilClusterListView - List of sybil clusters with summary info. 3 + * @see specs/prd-web.md Section P2.10 4 + */ 5 + 6 + import { formatRelativeTime } from '@/lib/format' 7 + import { StatusBadge } from '@/components/admin/sybil/status-badge' 8 + import { SuspicionBar } from '@/components/admin/sybil/suspicion-bar' 9 + import type { SybilCluster } from '@/lib/api/types' 10 + 11 + interface SybilClusterListViewProps { 12 + clusters: SybilCluster[] 13 + onViewDetail: (id: number) => void 14 + } 15 + 16 + export function SybilClusterListView({ clusters, onViewDetail }: SybilClusterListViewProps) { 17 + if (clusters.length === 0) { 18 + return <p className="py-8 text-center text-muted-foreground">No clusters found.</p> 19 + } 20 + 21 + return ( 22 + <div className="space-y-2"> 23 + {clusters.map((cluster) => ( 24 + <article key={cluster.id} className="rounded-lg border border-border bg-card p-4"> 25 + <div className="flex items-center justify-between gap-4"> 26 + <div className="min-w-0 flex-1"> 27 + <div className="flex flex-wrap items-center gap-3"> 28 + <span className="text-sm font-medium text-foreground"> 29 + {cluster.memberCount} members 30 + </span> 31 + <StatusBadge status={cluster.status} /> 32 + <SuspicionBar ratio={cluster.suspicionRatio} /> 33 + </div> 34 + <p className="mt-1 text-xs text-muted-foreground"> 35 + {cluster.internalEdgeCount} internal / {cluster.externalEdgeCount} external 36 + connections &middot; Detected {formatRelativeTime(cluster.detectedAt)} 37 + </p> 38 + </div> 39 + <button 40 + type="button" 41 + onClick={() => onViewDetail(cluster.id)} 42 + aria-label="View details" 43 + className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 44 + > 45 + View details 46 + </button> 47 + </div> 48 + </article> 49 + ))} 50 + </div> 51 + ) 52 + }
+32
src/components/admin/sybil/status-badge.tsx
··· 1 + /** 2 + * StatusBadge - Colored badge for status strings used across admin sybil views. 3 + * @see specs/prd-web.md Section P2.10 4 + */ 5 + 6 + import { cn } from '@/lib/utils' 7 + 8 + const STATUS_COLORS: Record<string, string> = { 9 + flagged: 'bg-destructive/10 text-destructive', 10 + monitoring: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400', 11 + dismissed: 'bg-muted text-muted-foreground', 12 + banned: 'bg-destructive text-destructive-foreground', 13 + pending: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400', 14 + action_taken: 'bg-green-500/10 text-green-700 dark:text-green-400', 15 + } 16 + 17 + interface StatusBadgeProps { 18 + status: string 19 + } 20 + 21 + export function StatusBadge({ status }: StatusBadgeProps) { 22 + return ( 23 + <span 24 + className={cn( 25 + 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium', 26 + STATUS_COLORS[status] ?? 'bg-muted text-muted-foreground' 27 + )} 28 + > 29 + {status} 30 + </span> 31 + ) 32 + }
+28
src/components/admin/sybil/suspicion-bar.tsx
··· 1 + /** 2 + * SuspicionBar - Visual progress bar showing suspicion ratio with color coding. 3 + * @see specs/prd-web.md Section P2.10 4 + */ 5 + 6 + import { cn } from '@/lib/utils' 7 + 8 + interface SuspicionBarProps { 9 + ratio: number 10 + } 11 + 12 + export function SuspicionBar({ ratio }: SuspicionBarProps) { 13 + const percent = Math.round(ratio * 100) 14 + return ( 15 + <div className="flex items-center gap-2"> 16 + <div className="h-2 w-20 overflow-hidden rounded-full bg-muted" aria-hidden="true"> 17 + <div 18 + className={cn( 19 + 'h-full rounded-full', 20 + percent >= 70 ? 'bg-destructive' : percent >= 40 ? 'bg-yellow-500' : 'bg-green-500' 21 + )} 22 + style={{ width: `${percent}%` }} 23 + /> 24 + </div> 25 + <span className="text-xs text-muted-foreground">{percent}%</span> 26 + </div> 27 + ) 28 + }
+58
src/components/admin/sybil/trust-graph-status-card.tsx
··· 1 + /** 2 + * TrustGraphStatusCard - Displays trust graph computation status and recompute button. 3 + * @see specs/prd-web.md Section P2.10 4 + */ 5 + 6 + import { formatRelativeTime, formatNumber } from '@/lib/format' 7 + import { cn } from '@/lib/utils' 8 + 9 + interface TrustGraphStatusCardProps { 10 + status: { 11 + lastComputedAt: string | null 12 + totalNodes: number 13 + totalEdges: number 14 + clustersFlagged: number 15 + } 16 + onRecompute: () => void 17 + recomputing: boolean 18 + } 19 + 20 + export function TrustGraphStatusCard({ 21 + status, 22 + onRecompute, 23 + recomputing, 24 + }: TrustGraphStatusCardProps) { 25 + return ( 26 + <div className="rounded-lg border border-border bg-card p-4"> 27 + <div className="flex items-center justify-between"> 28 + <div className="space-y-1"> 29 + <p className="text-sm text-muted-foreground"> 30 + {status.lastComputedAt 31 + ? `Last computed: ${formatRelativeTime(status.lastComputedAt)}` 32 + : 'Never computed'} 33 + {' | '} 34 + <span>{formatNumber(status.totalNodes)} nodes</span> 35 + {' | '} 36 + <span>{formatNumber(status.totalEdges)} edges</span> 37 + {' | '} 38 + <span>{status.clustersFlagged} clusters flagged</span> 39 + </p> 40 + </div> 41 + <button 42 + type="button" 43 + onClick={onRecompute} 44 + disabled={recomputing} 45 + aria-label="Recompute now" 46 + className={cn( 47 + 'rounded-md px-4 py-2 text-sm font-medium transition-colors', 48 + recomputing 49 + ? 'cursor-not-allowed bg-muted text-muted-foreground' 50 + : 'bg-primary text-primary-foreground hover:bg-primary/90' 51 + )} 52 + > 53 + {recomputing ? 'Recomputing...' : 'Recompute Now'} 54 + </button> 55 + </div> 56 + </div> 57 + ) 58 + }
+104
src/components/admin/trust-seeds/add-seed-dialog.tsx
··· 1 + /** 2 + * AddSeedDialog - Modal dialog for adding a new trust seed. 3 + * @see specs/prd-web.md Section P2.10 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useEffect, useRef } from 'react' 9 + 10 + interface AddSeedDialogProps { 11 + open: boolean 12 + onClose: () => void 13 + onSubmit: (data: { handle: string; communityId?: string; reason?: string }) => void 14 + } 15 + 16 + export function AddSeedDialog({ open, onClose, onSubmit }: AddSeedDialogProps) { 17 + const [handle, setHandle] = useState('') 18 + const [reason, setReason] = useState('') 19 + const handleRef = useRef<HTMLInputElement>(null) 20 + 21 + useEffect(() => { 22 + if (open) { 23 + handleRef.current?.focus() 24 + } 25 + }, [open]) 26 + 27 + useEffect(() => { 28 + if (!open) return 29 + const handleKey = (e: KeyboardEvent) => { 30 + if (e.key === 'Escape') onClose() 31 + } 32 + document.addEventListener('keydown', handleKey) 33 + return () => document.removeEventListener('keydown', handleKey) 34 + }, [open, onClose]) 35 + 36 + if (!open) return null 37 + 38 + const handleSubmit = (e: React.FormEvent) => { 39 + e.preventDefault() 40 + if (!handle.trim()) return 41 + onSubmit({ 42 + handle: handle.trim(), 43 + reason: reason.trim() || undefined, 44 + }) 45 + } 46 + 47 + return ( 48 + <div 49 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 50 + role="dialog" 51 + aria-modal="true" 52 + aria-label="Add trust seed" 53 + > 54 + <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 55 + <h3 className="text-lg font-semibold text-foreground">Add Trust Seed</h3> 56 + <form onSubmit={handleSubmit} className="mt-4 space-y-4"> 57 + <div> 58 + <label htmlFor="seed-handle" className="block text-sm font-medium text-foreground"> 59 + Handle 60 + </label> 61 + <input 62 + ref={handleRef} 63 + id="seed-handle" 64 + type="text" 65 + value={handle} 66 + onChange={(e) => setHandle(e.target.value)} 67 + placeholder="user.bsky.social" 68 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground" 69 + required 70 + /> 71 + </div> 72 + <div> 73 + <label htmlFor="seed-reason" className="block text-sm font-medium text-foreground"> 74 + Reason 75 + </label> 76 + <input 77 + id="seed-reason" 78 + type="text" 79 + value={reason} 80 + onChange={(e) => setReason(e.target.value)} 81 + placeholder="Optional: why this account is trusted" 82 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground" 83 + /> 84 + </div> 85 + <div className="flex justify-end gap-2"> 86 + <button 87 + type="button" 88 + onClick={onClose} 89 + className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 90 + > 91 + Cancel 92 + </button> 93 + <button 94 + type="submit" 95 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 96 + > 97 + Add 98 + </button> 99 + </div> 100 + </form> 101 + </div> 102 + </div> 103 + ) 104 + }
+62
src/components/admin/trust-seeds/trust-seed-card.tsx
··· 1 + /** 2 + * TrustSeedCard - Card display for a single trust seed with remove control. 3 + * @see specs/prd-web.md Section P2.10 4 + */ 5 + 6 + import { cn } from '@/lib/utils' 7 + import { formatDateShort } from '@/lib/format' 8 + import type { TrustSeed } from '@/lib/api/types' 9 + 10 + function TypeBadge({ implicit }: { implicit: boolean }) { 11 + return ( 12 + <span 13 + className={cn( 14 + 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium', 15 + implicit ? 'bg-muted text-muted-foreground' : 'bg-primary/10 text-primary' 16 + )} 17 + > 18 + {implicit ? 'Automatic' : 'Manual'} 19 + </span> 20 + ) 21 + } 22 + 23 + interface TrustSeedCardProps { 24 + seed: TrustSeed 25 + onRemove: (seed: TrustSeed) => void 26 + } 27 + 28 + export function TrustSeedCard({ seed, onRemove }: TrustSeedCardProps) { 29 + return ( 30 + <article className="rounded-lg border border-border bg-card p-4"> 31 + <div className="flex items-center justify-between gap-4"> 32 + <div className="min-w-0 flex-1"> 33 + <div className="flex flex-wrap items-center gap-2"> 34 + <span className="text-sm font-medium text-foreground">{seed.handle}</span> 35 + <TypeBadge implicit={seed.implicit} /> 36 + {seed.communityId ? ( 37 + <span className="text-xs text-muted-foreground">Scoped</span> 38 + ) : ( 39 + <span className="text-xs text-muted-foreground">Global</span> 40 + )} 41 + </div> 42 + <p className="mt-0.5 text-xs text-muted-foreground"> 43 + {seed.displayName} 44 + {seed.reason && <> &middot; {seed.reason}</>} 45 + {' &middot; Added '} 46 + {formatDateShort(seed.createdAt)} 47 + </p> 48 + </div> 49 + {!seed.implicit && ( 50 + <button 51 + type="button" 52 + onClick={() => onRemove(seed)} 53 + aria-label="Remove" 54 + className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-destructive transition-colors hover:bg-destructive/10" 55 + > 56 + Remove 57 + </button> 58 + )} 59 + </div> 60 + </article> 61 + ) 62 + }
+77
src/components/community-profile-form-fields.tsx
··· 1 + /** 2 + * CommunityProfileFormFields - Display name and bio inputs for community profile. 3 + * @see specs/prd-web.md Section M8 (Settings / Community Profile) 4 + */ 5 + 6 + import { cn } from '@/lib/utils' 7 + 8 + const DISPLAY_NAME_MAX = 256 9 + const BIO_MAX = 2048 10 + 11 + interface CommunityProfileFormFieldsProps { 12 + displayName: string 13 + bio: string 14 + placeholderDisplayName?: string 15 + placeholderBio?: string 16 + onDisplayNameChange: (value: string) => void 17 + onBioChange: (value: string) => void 18 + } 19 + 20 + export function CommunityProfileFormFields({ 21 + displayName, 22 + bio, 23 + placeholderDisplayName, 24 + placeholderBio, 25 + onDisplayNameChange, 26 + onBioChange, 27 + }: CommunityProfileFormFieldsProps) { 28 + return ( 29 + <> 30 + <div className="space-y-1"> 31 + <label 32 + htmlFor="community-display-name" 33 + className="block text-sm font-medium text-foreground" 34 + > 35 + Display name 36 + </label> 37 + <input 38 + id="community-display-name" 39 + type="text" 40 + value={displayName} 41 + onChange={(e) => onDisplayNameChange(e.target.value)} 42 + placeholder={placeholderDisplayName ?? 'Display name'} 43 + maxLength={DISPLAY_NAME_MAX} 44 + className={cn( 45 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 46 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 47 + )} 48 + /> 49 + <p className="text-xs text-muted-foreground"> 50 + {displayName.length}/{DISPLAY_NAME_MAX} characters. Leave empty to use your AT Protocol 51 + display name. 52 + </p> 53 + </div> 54 + 55 + <div className="space-y-1"> 56 + <label htmlFor="community-bio" className="block text-sm font-medium text-foreground"> 57 + Bio 58 + </label> 59 + <textarea 60 + id="community-bio" 61 + value={bio} 62 + onChange={(e) => onBioChange(e.target.value)} 63 + placeholder={placeholderBio ?? 'Tell the community about yourself'} 64 + maxLength={BIO_MAX} 65 + rows={4} 66 + className={cn( 67 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 68 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 69 + )} 70 + /> 71 + <p className="text-xs text-muted-foreground"> 72 + {bio.length}/{BIO_MAX} characters. Leave empty to use your AT Protocol bio. 73 + </p> 74 + </div> 75 + </> 76 + ) 77 + }
+51 -330
src/components/community-profile-settings.tsx
··· 1 1 /** 2 - * CommunityProfileSettings - Section for customizing per-community profile. 3 - * Displays source AT Protocol profile as reference, override form for 4 - * display name / bio / avatar / banner, and reset functionality. 2 + * CommunityProfileSettings - Per-community profile customization section. 5 3 * @see specs/prd-web.md Section M8 (Settings / Community Profile) 6 4 */ 7 5 8 6 'use client' 9 7 10 - import { useState, useEffect, useCallback } from 'react' 11 8 import { cn } from '@/lib/utils' 12 9 import { ImageUpload } from '@/components/image-upload' 13 10 import { ConfirmDialog } from '@/components/confirm-dialog' 11 + import { SourceProfilePreview } from '@/components/source-profile-preview' 12 + import { CommunityProfileFormFields } from '@/components/community-profile-form-fields' 14 13 import { useAuth } from '@/hooks/use-auth' 15 - import { 16 - getPublicSettings, 17 - getCommunityProfile, 18 - updateCommunityProfile, 19 - resetCommunityProfile, 20 - uploadCommunityAvatar, 21 - uploadCommunityBanner, 22 - } from '@/lib/api/client' 23 - import type { CommunityProfile } from '@/lib/api/types' 14 + import { useCommunityProfile } from '@/hooks/use-community-profile' 24 15 import { ArrowCounterClockwise } from '@phosphor-icons/react' 25 16 26 - const DISPLAY_NAME_MAX = 256 27 - const BIO_MAX = 2048 28 - 29 17 export function CommunityProfileSettings() { 30 - const { getAccessToken, isAuthenticated } = useAuth() 31 - 32 - const [communityDid, setCommunityDid] = useState<string | null>(null) 33 - const [profile, setProfile] = useState<CommunityProfile | null>(null) 34 - const [loading, setLoading] = useState(true) 35 - const [saving, setSaving] = useState(false) 36 - const [error, setError] = useState<string | null>(null) 37 - const [success, setSuccess] = useState(false) 38 - const [showResetConfirm, setShowResetConfirm] = useState(false) 39 - 40 - // Form state for text overrides 41 - const [displayName, setDisplayName] = useState('') 42 - const [bio, setBio] = useState('') 43 - 44 - // Load community DID from public settings, then load profile 45 - useEffect(() => { 46 - if (!isAuthenticated) { 47 - setLoading(false) 48 - return 49 - } 50 - 51 - const token = getAccessToken() 52 - if (!token) { 53 - setLoading(false) 54 - return 55 - } 56 - 57 - let cancelled = false 58 - 59 - async function loadProfile() { 60 - try { 61 - const publicSettings = await getPublicSettings() 62 - const did = publicSettings.communityDid 63 - if (!did) { 64 - // Community not initialized yet 65 - if (!cancelled) { 66 - setLoading(false) 67 - } 68 - return 69 - } 70 - 71 - if (!cancelled) { 72 - setCommunityDid(did) 73 - } 74 - 75 - const currentToken = token 76 - if (!currentToken) return 77 - 78 - const communityProfile = await getCommunityProfile(did, currentToken) 79 - if (!cancelled) { 80 - setProfile(communityProfile) 81 - // Initialize form with current override values (empty string means "use source") 82 - setDisplayName(communityProfile.hasOverride ? (communityProfile.displayName ?? '') : '') 83 - setBio(communityProfile.hasOverride ? (communityProfile.bio ?? '') : '') 84 - } 85 - } catch { 86 - if (!cancelled) { 87 - setError('Failed to load community profile.') 88 - } 89 - } finally { 90 - if (!cancelled) { 91 - setLoading(false) 92 - } 93 - } 94 - } 95 - 96 - void loadProfile() 97 - return () => { 98 - cancelled = true 99 - } 100 - }, [getAccessToken, isAuthenticated]) 101 - 102 - const handleSave = useCallback( 103 - async (e: React.FormEvent) => { 104 - e.preventDefault() 105 - if (!communityDid) return 106 - 107 - const token = getAccessToken() 108 - if (!token) { 109 - setError('Not authenticated') 110 - return 111 - } 112 - 113 - setSaving(true) 114 - setError(null) 115 - setSuccess(false) 116 - 117 - try { 118 - await updateCommunityProfile( 119 - communityDid, 120 - { 121 - displayName: displayName.trim() || null, 122 - bio: bio.trim() || null, 123 - }, 124 - token 125 - ) 126 - 127 - // Reload profile to get fresh state 128 - const updatedProfile = await getCommunityProfile(communityDid, token) 129 - setProfile(updatedProfile) 130 - setSuccess(true) 131 - } catch { 132 - setError('Failed to save community profile.') 133 - } finally { 134 - setSaving(false) 135 - } 136 - }, 137 - [communityDid, displayName, bio, getAccessToken] 138 - ) 139 - 140 - const handleReset = useCallback(async () => { 141 - if (!communityDid) return 142 - 143 - const token = getAccessToken() 144 - if (!token) { 145 - setError('Not authenticated') 146 - return 147 - } 148 - 149 - setShowResetConfirm(false) 150 - setSaving(true) 151 - setError(null) 152 - setSuccess(false) 153 - 154 - try { 155 - await resetCommunityProfile(communityDid, token) 156 - 157 - // Reload profile after reset 158 - const updatedProfile = await getCommunityProfile(communityDid, token) 159 - setProfile(updatedProfile) 160 - setDisplayName('') 161 - setBio('') 162 - setSuccess(true) 163 - } catch { 164 - setError('Failed to reset community profile.') 165 - } finally { 166 - setSaving(false) 167 - } 168 - }, [communityDid, getAccessToken]) 169 - 170 - const handleAvatarUpload = useCallback( 171 - async (file: File): Promise<{ url: string }> => { 172 - if (!communityDid) throw new Error('No community DID') 173 - const token = getAccessToken() 174 - if (!token) throw new Error('Not authenticated') 175 - 176 - const result = await uploadCommunityAvatar(communityDid, file, token) 177 - 178 - // Reload profile to reflect new avatar 179 - const updatedProfile = await getCommunityProfile(communityDid, token) 180 - setProfile(updatedProfile) 181 - 182 - return result 183 - }, 184 - [communityDid, getAccessToken] 185 - ) 186 - 187 - const handleBannerUpload = useCallback( 188 - async (file: File): Promise<{ url: string }> => { 189 - if (!communityDid) throw new Error('No community DID') 190 - const token = getAccessToken() 191 - if (!token) throw new Error('Not authenticated') 192 - 193 - const result = await uploadCommunityBanner(communityDid, file, token) 194 - 195 - // Reload profile to reflect new banner 196 - const updatedProfile = await getCommunityProfile(communityDid, token) 197 - setProfile(updatedProfile) 198 - 199 - return result 200 - }, 201 - [communityDid, getAccessToken] 202 - ) 203 - 204 - const handleAvatarRemove = useCallback(async () => { 205 - if (!communityDid) return 206 - const token = getAccessToken() 207 - if (!token) return 208 - 209 - try { 210 - // Update profile with null avatar by saving without avatar override 211 - // The API interprets a PUT without avatar fields as keeping current state, 212 - // so we reset the full profile and re-save text fields 213 - await updateCommunityProfile( 214 - communityDid, 215 - { 216 - displayName: displayName.trim() || null, 217 - bio: bio.trim() || null, 218 - }, 219 - token 220 - ) 221 - const updatedProfile = await getCommunityProfile(communityDid, token) 222 - setProfile(updatedProfile) 223 - } catch { 224 - setError('Failed to remove avatar.') 225 - } 226 - }, [communityDid, displayName, bio, getAccessToken]) 227 - 228 - const handleBannerRemove = useCallback(async () => { 229 - if (!communityDid) return 230 - const token = getAccessToken() 231 - if (!token) return 232 - 233 - try { 234 - await updateCommunityProfile( 235 - communityDid, 236 - { 237 - displayName: displayName.trim() || null, 238 - bio: bio.trim() || null, 239 - }, 240 - token 241 - ) 242 - const updatedProfile = await getCommunityProfile(communityDid, token) 243 - setProfile(updatedProfile) 244 - } catch { 245 - setError('Failed to remove banner.') 246 - } 247 - }, [communityDid, displayName, bio, getAccessToken]) 18 + const { isAuthenticated } = useAuth() 19 + const { 20 + communityDid, 21 + profile, 22 + loading, 23 + saving, 24 + error, 25 + success, 26 + displayName, 27 + bio, 28 + setDisplayName, 29 + setBio, 30 + handleSave, 31 + handleReset, 32 + handleAvatarUpload, 33 + handleBannerUpload, 34 + handleAvatarRemove, 35 + handleBannerRemove, 36 + showResetConfirm, 37 + setShowResetConfirm, 38 + } = useCommunityProfile() 248 39 249 40 if (!isAuthenticated) return null 250 41 251 - if (loading) { 42 + if (loading || !communityDid) { 252 43 return ( 253 44 <fieldset className="space-y-4 rounded-lg border border-border p-4"> 254 45 <legend className="px-2 text-sm font-semibold text-foreground">Community Profile</legend> 255 - <div className="animate-pulse space-y-3"> 256 - <div className="h-24 w-24 rounded-full bg-muted" /> 257 - <div className="h-10 rounded-md bg-muted" /> 258 - <div className="h-20 rounded-md bg-muted" /> 259 - </div> 260 - </fieldset> 261 - ) 262 - } 263 - 264 - if (!communityDid) { 265 - return ( 266 - <fieldset className="space-y-4 rounded-lg border border-border p-4"> 267 - <legend className="px-2 text-sm font-semibold text-foreground">Community Profile</legend> 268 - <p className="text-sm text-muted-foreground"> 269 - Community has not been initialized yet. Profile customization will be available once the 270 - community is set up. 271 - </p> 46 + {loading ? ( 47 + <div className="animate-pulse space-y-3"> 48 + <div className="h-24 w-24 rounded-full bg-muted" /> 49 + <div className="h-10 rounded-md bg-muted" /> 50 + <div className="h-20 rounded-md bg-muted" /> 51 + </div> 52 + ) : ( 53 + <p className="text-sm text-muted-foreground"> 54 + Community has not been initialized yet. Profile customization will be available once the 55 + community is set up. 56 + </p> 57 + )} 272 58 </fieldset> 273 59 ) 274 60 } ··· 301 87 </p> 302 88 )} 303 89 304 - {/* Source profile preview (read-only) */} 305 90 {profile?.source && ( 306 - <div className="space-y-2 rounded-md border border-dashed border-border bg-muted/30 p-3"> 307 - <p className="text-xs font-medium text-muted-foreground"> 308 - Your AT Protocol profile (source) 309 - </p> 310 - <div className="flex items-center gap-3"> 311 - {profile.source.avatarUrl ? ( 312 - // eslint-disable-next-line @next/next/no-img-element 313 - <img 314 - src={profile.source.avatarUrl} 315 - alt="Source avatar" 316 - className="h-10 w-10 rounded-full object-cover" 317 - /> 318 - ) : ( 319 - <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground"> 320 - <span className="text-xs">--</span> 321 - </div> 322 - )} 323 - <div className="min-w-0"> 324 - <p className="truncate text-sm text-muted-foreground"> 325 - {profile.source.displayName || '(no display name)'} 326 - </p> 327 - <p className="truncate text-xs text-muted-foreground/70"> 328 - {profile.source.bio || '(no bio)'} 329 - </p> 330 - </div> 331 - </div> 332 - </div> 91 + <SourceProfilePreview 92 + avatarUrl={profile.source.avatarUrl} 93 + displayName={profile.source.displayName} 94 + bio={profile.source.bio} 95 + /> 333 96 )} 334 97 335 - {/* Avatar upload */} 336 98 <ImageUpload 337 99 currentUrl={profile?.avatarUrl ?? null} 338 100 onUpload={handleAvatarUpload} ··· 341 103 aspectRatio="1/1" 342 104 /> 343 105 344 - {/* Banner upload */} 345 106 <ImageUpload 346 107 currentUrl={profile?.bannerUrl ?? null} 347 108 onUpload={handleBannerUpload} ··· 350 111 aspectRatio="3/1" 351 112 /> 352 113 353 - {/* Display name */} 354 - <div className="space-y-1"> 355 - <label 356 - htmlFor="community-display-name" 357 - className="block text-sm font-medium text-foreground" 358 - > 359 - Display name 360 - </label> 361 - <input 362 - id="community-display-name" 363 - type="text" 364 - value={displayName} 365 - onChange={(e) => setDisplayName(e.target.value)} 366 - placeholder={profile?.source.displayName ?? 'Display name'} 367 - maxLength={DISPLAY_NAME_MAX} 368 - className={cn( 369 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 370 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 371 - )} 372 - /> 373 - <p className="text-xs text-muted-foreground"> 374 - {displayName.length}/{DISPLAY_NAME_MAX} characters. Leave empty to use your AT Protocol 375 - display name. 376 - </p> 377 - </div> 114 + <CommunityProfileFormFields 115 + displayName={displayName} 116 + bio={bio} 117 + placeholderDisplayName={profile?.source.displayName ?? undefined} 118 + placeholderBio={profile?.source.bio ?? undefined} 119 + onDisplayNameChange={setDisplayName} 120 + onBioChange={setBio} 121 + /> 378 122 379 - {/* Bio */} 380 - <div className="space-y-1"> 381 - <label htmlFor="community-bio" className="block text-sm font-medium text-foreground"> 382 - Bio 383 - </label> 384 - <textarea 385 - id="community-bio" 386 - value={bio} 387 - onChange={(e) => setBio(e.target.value)} 388 - placeholder={profile?.source.bio ?? 'Tell the community about yourself'} 389 - maxLength={BIO_MAX} 390 - rows={4} 391 - className={cn( 392 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 393 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 394 - )} 395 - /> 396 - <p className="text-xs text-muted-foreground"> 397 - {bio.length}/{BIO_MAX} characters. Leave empty to use your AT Protocol bio. 398 - </p> 399 - </div> 400 - 401 - {/* Action buttons */} 402 123 <div className="flex items-center justify-between gap-3"> 403 124 <button 404 125 type="button"
+2 -86
src/components/markdown-editor.tsx
··· 8 8 'use client' 9 9 10 10 import { useRef, useState, useCallback } from 'react' 11 - import { TextB, TextItalic, Link as LinkIcon, Code, Quotes, List } from '@phosphor-icons/react' 12 11 import { cn } from '@/lib/utils' 13 - 14 - interface ToolbarAction { 15 - label: string 16 - icon: typeof TextB 17 - apply: (value: string, start: number, end: number) => { result: string; cursor: number } 18 - } 19 - 20 - const TOOLBAR_ACTIONS: ToolbarAction[] = [ 21 - { 22 - label: 'Bold', 23 - icon: TextB, 24 - apply: (value, start, end) => { 25 - const selected = value.slice(start, end) 26 - const replacement = selected ? `**${selected}**` : '**text**' 27 - return { 28 - result: value.slice(0, start) + replacement + value.slice(end), 29 - cursor: selected ? start + replacement.length : start + 2, 30 - } 31 - }, 32 - }, 33 - { 34 - label: 'Italic', 35 - icon: TextItalic, 36 - apply: (value, start, end) => { 37 - const selected = value.slice(start, end) 38 - const replacement = selected ? `*${selected}*` : '*text*' 39 - return { 40 - result: value.slice(0, start) + replacement + value.slice(end), 41 - cursor: selected ? start + replacement.length : start + 1, 42 - } 43 - }, 44 - }, 45 - { 46 - label: 'Link', 47 - icon: LinkIcon, 48 - apply: (value, start, end) => { 49 - const selected = value.slice(start, end) 50 - const replacement = selected ? `[${selected}](url)` : '[text](url)' 51 - return { 52 - result: value.slice(0, start) + replacement + value.slice(end), 53 - cursor: selected ? start + selected.length + 3 : start + 1, 54 - } 55 - }, 56 - }, 57 - { 58 - label: 'Code', 59 - icon: Code, 60 - apply: (value, start, end) => { 61 - const selected = value.slice(start, end) 62 - const replacement = selected ? `\`${selected}\`` : '`code`' 63 - return { 64 - result: value.slice(0, start) + replacement + value.slice(end), 65 - cursor: selected ? start + replacement.length : start + 1, 66 - } 67 - }, 68 - }, 69 - { 70 - label: 'Quote', 71 - icon: Quotes, 72 - apply: (value, start, end) => { 73 - const selected = value.slice(start, end) 74 - const replacement = `> ${selected || 'quote'}` 75 - return { 76 - result: value.slice(0, start) + replacement + value.slice(end), 77 - cursor: start + replacement.length, 78 - } 79 - }, 80 - }, 81 - { 82 - label: 'List', 83 - icon: List, 84 - apply: (value, start, end) => { 85 - const selected = value.slice(start, end) 86 - const replacement = `- ${selected || 'item'}` 87 - return { 88 - result: value.slice(0, start) + replacement + value.slice(end), 89 - cursor: start + replacement.length, 90 - } 91 - }, 92 - }, 93 - ] 12 + import { TOOLBAR_ACTIONS } from '@/components/markdown-toolbar-actions' 13 + import type { ToolbarAction } from '@/components/markdown-toolbar-actions' 94 14 95 15 interface MarkdownEditorProps { 96 16 value: string ··· 126 46 127 47 onChange(result) 128 48 129 - // Restore focus and cursor position after React re-render 130 49 requestAnimationFrame(() => { 131 50 textarea.focus() 132 51 textarea.setSelectionRange(cursor, cursor) ··· 172 91 {label} 173 92 </label> 174 93 175 - {/* Toolbar */} 176 94 <div 177 95 ref={toolbarRef} 178 96 role="toolbar" ··· 199 117 })} 200 118 </div> 201 119 202 - {/* Textarea */} 203 120 <textarea 204 121 ref={textareaRef} 205 122 id={id} ··· 216 133 )} 217 134 /> 218 135 219 - {/* Error message */} 220 136 {error && ( 221 137 <p id={errorId} className="text-sm text-destructive" role="alert"> 222 138 {error}
+88
src/components/markdown-toolbar-actions.ts
··· 1 + /** 2 + * Markdown toolbar actions configuration. 3 + * Each action defines how to wrap/insert markdown formatting. 4 + * @see specs/prd-web.md Section 4 (Editor Components) 5 + */ 6 + 7 + import { TextB, TextItalic, Link as LinkIcon, Code, Quotes, List } from '@phosphor-icons/react' 8 + 9 + export interface ToolbarAction { 10 + label: string 11 + icon: typeof TextB 12 + apply: (value: string, start: number, end: number) => { result: string; cursor: number } 13 + } 14 + 15 + export const TOOLBAR_ACTIONS: ToolbarAction[] = [ 16 + { 17 + label: 'Bold', 18 + icon: TextB, 19 + apply: (value, start, end) => { 20 + const selected = value.slice(start, end) 21 + const replacement = selected ? `**${selected}**` : '**text**' 22 + return { 23 + result: value.slice(0, start) + replacement + value.slice(end), 24 + cursor: selected ? start + replacement.length : start + 2, 25 + } 26 + }, 27 + }, 28 + { 29 + label: 'Italic', 30 + icon: TextItalic, 31 + apply: (value, start, end) => { 32 + const selected = value.slice(start, end) 33 + const replacement = selected ? `*${selected}*` : '*text*' 34 + return { 35 + result: value.slice(0, start) + replacement + value.slice(end), 36 + cursor: selected ? start + replacement.length : start + 1, 37 + } 38 + }, 39 + }, 40 + { 41 + label: 'Link', 42 + icon: LinkIcon, 43 + apply: (value, start, end) => { 44 + const selected = value.slice(start, end) 45 + const replacement = selected ? `[${selected}](url)` : '[text](url)' 46 + return { 47 + result: value.slice(0, start) + replacement + value.slice(end), 48 + cursor: selected ? start + selected.length + 3 : start + 1, 49 + } 50 + }, 51 + }, 52 + { 53 + label: 'Code', 54 + icon: Code, 55 + apply: (value, start, end) => { 56 + const selected = value.slice(start, end) 57 + const replacement = selected ? `\`${selected}\`` : '`code`' 58 + return { 59 + result: value.slice(0, start) + replacement + value.slice(end), 60 + cursor: selected ? start + replacement.length : start + 1, 61 + } 62 + }, 63 + }, 64 + { 65 + label: 'Quote', 66 + icon: Quotes, 67 + apply: (value, start, end) => { 68 + const selected = value.slice(start, end) 69 + const replacement = `> ${selected || 'quote'}` 70 + return { 71 + result: value.slice(0, start) + replacement + value.slice(end), 72 + cursor: start + replacement.length, 73 + } 74 + }, 75 + }, 76 + { 77 + label: 'List', 78 + icon: List, 79 + apply: (value, start, end) => { 80 + const selected = value.slice(start, end) 81 + const replacement = `- ${selected || 'item'}` 82 + return { 83 + result: value.slice(0, start) + replacement + value.slice(end), 84 + cursor: start + replacement.length, 85 + } 86 + }, 87 + }, 88 + ]
+175
src/components/onboarding-field-input.tsx
··· 1 + /** 2 + * OnboardingFieldInput - Dynamic field renderer for onboarding modal. 3 + * Renders the appropriate input based on field type configuration. 4 + */ 5 + 6 + import type { OnboardingField } from '@/lib/api/types' 7 + 8 + const AGE_OPTIONS = [ 9 + { value: 0, label: 'Rather not say' }, 10 + { value: 13, label: '13+' }, 11 + { value: 14, label: '14+' }, 12 + { value: 15, label: '15+' }, 13 + { value: 16, label: '16+' }, 14 + { value: 18, label: '18+' }, 15 + ] as const 16 + 17 + const INPUT_CLASS = 18 + 'mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground' 19 + 20 + function FieldLabel({ 21 + htmlFor, 22 + label, 23 + required, 24 + description, 25 + block = true, 26 + }: { 27 + htmlFor: string 28 + label: string 29 + required: boolean 30 + description?: string | null 31 + block?: boolean 32 + }) { 33 + return ( 34 + <> 35 + <label 36 + htmlFor={htmlFor} 37 + className={`${block ? 'block ' : ''}text-sm font-medium text-foreground`} 38 + > 39 + {label} 40 + {required && <span className="ml-1 text-destructive">*</span>} 41 + </label> 42 + {description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>} 43 + </> 44 + ) 45 + } 46 + 47 + interface OnboardingFieldInputProps { 48 + field: OnboardingField 49 + value: unknown 50 + onChange: (value: unknown) => void 51 + } 52 + 53 + export function OnboardingFieldInput({ field, value, onChange }: OnboardingFieldInputProps) { 54 + const labelId = `onboarding-${field.id}` 55 + const required = field.isMandatory 56 + 57 + switch (field.fieldType) { 58 + case 'age_confirmation': 59 + return ( 60 + <div> 61 + <FieldLabel 62 + htmlFor={labelId} 63 + label={field.label} 64 + required={required} 65 + description={field.description} 66 + /> 67 + <select 68 + id={labelId} 69 + value={value !== undefined ? String(value) : ''} 70 + onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} 71 + className={INPUT_CLASS} 72 + > 73 + <option value="">Select age bracket...</option> 74 + {AGE_OPTIONS.map((opt) => ( 75 + <option key={opt.value} value={opt.value}> 76 + {opt.label} 77 + </option> 78 + ))} 79 + </select> 80 + </div> 81 + ) 82 + 83 + case 'tos_acceptance': 84 + case 'custom_checkbox': 85 + return ( 86 + <div className="flex items-start gap-2"> 87 + <input 88 + id={labelId} 89 + type="checkbox" 90 + checked={value === true} 91 + onChange={(e) => onChange(e.target.checked)} 92 + className="mt-1 h-4 w-4 rounded border-border" 93 + /> 94 + <div> 95 + <FieldLabel 96 + htmlFor={labelId} 97 + label={field.label} 98 + required={required} 99 + description={field.description} 100 + block={false} 101 + /> 102 + </div> 103 + </div> 104 + ) 105 + 106 + case 'newsletter_email': 107 + return ( 108 + <div> 109 + <FieldLabel 110 + htmlFor={labelId} 111 + label={field.label} 112 + required={required} 113 + description={field.description} 114 + /> 115 + <input 116 + id={labelId} 117 + type="email" 118 + value={typeof value === 'string' ? value : ''} 119 + onChange={(e) => onChange(e.target.value || undefined)} 120 + placeholder="your@email.com" 121 + className={INPUT_CLASS} 122 + /> 123 + </div> 124 + ) 125 + 126 + case 'custom_text': 127 + return ( 128 + <div> 129 + <FieldLabel 130 + htmlFor={labelId} 131 + label={field.label} 132 + required={required} 133 + description={field.description} 134 + /> 135 + <textarea 136 + id={labelId} 137 + value={typeof value === 'string' ? value : ''} 138 + onChange={(e) => onChange(e.target.value || undefined)} 139 + rows={3} 140 + className={INPUT_CLASS} 141 + /> 142 + </div> 143 + ) 144 + 145 + case 'custom_select': { 146 + const options = (field.config?.options ?? []) as string[] 147 + return ( 148 + <div> 149 + <FieldLabel 150 + htmlFor={labelId} 151 + label={field.label} 152 + required={required} 153 + description={field.description} 154 + /> 155 + <select 156 + id={labelId} 157 + value={typeof value === 'string' ? value : ''} 158 + onChange={(e) => onChange(e.target.value || undefined)} 159 + className={INPUT_CLASS} 160 + > 161 + <option value="">Select...</option> 162 + {options.map((opt) => ( 163 + <option key={opt} value={opt}> 164 + {opt} 165 + </option> 166 + ))} 167 + </select> 168 + </div> 169 + ) 170 + } 171 + 172 + default: 173 + return null 174 + } 175 + }
+1 -168
src/components/onboarding-modal.tsx
··· 8 8 9 9 import { useState } from 'react' 10 10 import { cn } from '@/lib/utils' 11 + import { OnboardingFieldInput } from '@/components/onboarding-field-input' 11 12 import type { OnboardingField } from '@/lib/api/types' 12 13 13 14 interface OnboardingModalProps { ··· 16 17 onSubmit: (responses: Array<{ fieldId: string; response: unknown }>) => Promise<boolean> 17 18 onCancel: () => void 18 19 } 19 - 20 - /** Valid age bracket options for age_confirmation fields */ 21 - const AGE_OPTIONS = [ 22 - { value: 0, label: 'Rather not say' }, 23 - { value: 13, label: '13+' }, 24 - { value: 14, label: '14+' }, 25 - { value: 15, label: '15+' }, 26 - { value: 16, label: '16+' }, 27 - { value: 18, label: '18+' }, 28 - ] as const 29 20 30 21 export function OnboardingModal({ open, fields, onSubmit, onCancel }: OnboardingModalProps) { 31 22 const [responses, setResponses] = useState<Record<string, unknown>>({}) ··· 121 112 </div> 122 113 ) 123 114 } 124 - 125 - /** Render the appropriate input for a field type */ 126 - function OnboardingFieldInput({ 127 - field, 128 - value, 129 - onChange, 130 - }: { 131 - field: OnboardingField 132 - value: unknown 133 - onChange: (value: unknown) => void 134 - }) { 135 - const labelId = `onboarding-${field.id}` 136 - const required = field.isMandatory 137 - 138 - switch (field.fieldType) { 139 - case 'age_confirmation': 140 - return ( 141 - <div> 142 - <label htmlFor={labelId} className="block text-sm font-medium text-foreground"> 143 - {field.label} 144 - {required && <span className="ml-1 text-destructive">*</span>} 145 - </label> 146 - {field.description && ( 147 - <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 148 - )} 149 - <select 150 - id={labelId} 151 - value={value !== undefined ? String(value) : ''} 152 - onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} 153 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 154 - > 155 - <option value="">Select age bracket...</option> 156 - {AGE_OPTIONS.map((opt) => ( 157 - <option key={opt.value} value={opt.value}> 158 - {opt.label} 159 - </option> 160 - ))} 161 - </select> 162 - </div> 163 - ) 164 - 165 - case 'tos_acceptance': 166 - return ( 167 - <div className="flex items-start gap-2"> 168 - <input 169 - id={labelId} 170 - type="checkbox" 171 - checked={value === true} 172 - onChange={(e) => onChange(e.target.checked)} 173 - className="mt-1 h-4 w-4 rounded border-border" 174 - /> 175 - <div> 176 - <label htmlFor={labelId} className="text-sm font-medium text-foreground"> 177 - {field.label} 178 - {required && <span className="ml-1 text-destructive">*</span>} 179 - </label> 180 - {field.description && ( 181 - <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 182 - )} 183 - </div> 184 - </div> 185 - ) 186 - 187 - case 'newsletter_email': 188 - return ( 189 - <div> 190 - <label htmlFor={labelId} className="block text-sm font-medium text-foreground"> 191 - {field.label} 192 - {required && <span className="ml-1 text-destructive">*</span>} 193 - </label> 194 - {field.description && ( 195 - <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 196 - )} 197 - <input 198 - id={labelId} 199 - type="email" 200 - value={typeof value === 'string' ? value : ''} 201 - onChange={(e) => onChange(e.target.value || undefined)} 202 - placeholder="your@email.com" 203 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 204 - /> 205 - </div> 206 - ) 207 - 208 - case 'custom_text': 209 - return ( 210 - <div> 211 - <label htmlFor={labelId} className="block text-sm font-medium text-foreground"> 212 - {field.label} 213 - {required && <span className="ml-1 text-destructive">*</span>} 214 - </label> 215 - {field.description && ( 216 - <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 217 - )} 218 - <textarea 219 - id={labelId} 220 - value={typeof value === 'string' ? value : ''} 221 - onChange={(e) => onChange(e.target.value || undefined)} 222 - rows={3} 223 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 224 - /> 225 - </div> 226 - ) 227 - 228 - case 'custom_select': { 229 - const options = (field.config?.options ?? []) as string[] 230 - return ( 231 - <div> 232 - <label htmlFor={labelId} className="block text-sm font-medium text-foreground"> 233 - {field.label} 234 - {required && <span className="ml-1 text-destructive">*</span>} 235 - </label> 236 - {field.description && ( 237 - <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 238 - )} 239 - <select 240 - id={labelId} 241 - value={typeof value === 'string' ? value : ''} 242 - onChange={(e) => onChange(e.target.value || undefined)} 243 - className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 244 - > 245 - <option value="">Select...</option> 246 - {options.map((opt) => ( 247 - <option key={opt} value={opt}> 248 - {opt} 249 - </option> 250 - ))} 251 - </select> 252 - </div> 253 - ) 254 - } 255 - 256 - case 'custom_checkbox': 257 - return ( 258 - <div className="flex items-start gap-2"> 259 - <input 260 - id={labelId} 261 - type="checkbox" 262 - checked={value === true} 263 - onChange={(e) => onChange(e.target.checked)} 264 - className="mt-1 h-4 w-4 rounded border-border" 265 - /> 266 - <div> 267 - <label htmlFor={labelId} className="text-sm font-medium text-foreground"> 268 - {field.label} 269 - {required && <span className="ml-1 text-destructive">*</span>} 270 - </label> 271 - {field.description && ( 272 - <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 273 - )} 274 - </div> 275 - </div> 276 - ) 277 - 278 - default: 279 - return null 280 - } 281 - }
+15 -102
src/components/report-dialog.tsx
··· 2 2 * ReportDialog - Report content with AT Protocol reason categories. 3 3 * Button + accessible dialog with reason selection, optional details, 4 4 * and success/error acknowledgment after submission. 5 - * Follows com.atproto.moderation.defs reason types. 6 5 * @see specs/prd-web.md Section M7 (Report button) 7 - * @see decisions/content-moderation.md 8 6 */ 9 7 10 8 'use client' ··· 12 10 import { useState, useEffect, useRef, useCallback } from 'react' 13 11 import { Flag, CheckCircle } from '@phosphor-icons/react' 14 12 import { cn } from '@/lib/utils' 13 + import { ReportFormContent } from '@/components/report-form-content' 15 14 16 15 export interface ReportSubmission { 17 16 subjectUri: string ··· 25 24 disabled?: boolean 26 25 className?: string 27 26 } 28 - 29 - const REPORT_REASONS = [ 30 - { value: 'spam', label: 'Spam' }, 31 - { value: 'sexual', label: 'Sexual content' }, 32 - { value: 'harassment', label: 'Harassment' }, 33 - { value: 'violation', label: 'Rule violation' }, 34 - { value: 'misleading', label: 'Misleading' }, 35 - { value: 'other', label: 'Other' }, 36 - ] as const 37 27 38 28 export function ReportDialog({ 39 29 subjectUri, ··· 60 50 61 51 const handleKeyDown = useCallback( 62 52 (e: KeyboardEvent) => { 63 - if (e.key === 'Escape') { 64 - handleClose() 65 - } 53 + if (e.key === 'Escape') handleClose() 66 54 }, 67 55 [handleClose] 68 56 ) 69 57 70 58 useEffect(() => { 71 - if (open) { 72 - document.addEventListener('keydown', handleKeyDown) 73 - } 74 - return () => { 75 - document.removeEventListener('keydown', handleKeyDown) 76 - } 59 + if (open) document.addEventListener('keydown', handleKeyDown) 60 + return () => document.removeEventListener('keydown', handleKeyDown) 77 61 }, [open, handleKeyDown]) 78 62 79 63 const handleSubmit = async (e: React.FormEvent) => { ··· 148 132 </button> 149 133 </div> 150 134 ) : ( 151 - <> 152 - <h2 id="report-dialog-title" className="text-lg font-semibold text-foreground"> 153 - Report Content 154 - </h2> 155 - 156 - <form onSubmit={handleSubmit} className="mt-4 space-y-4" noValidate> 157 - <fieldset disabled={submitting}> 158 - <legend className="text-sm font-medium text-foreground">Reason</legend> 159 - <div className="mt-2 space-y-2"> 160 - {REPORT_REASONS.map((r) => ( 161 - <label key={r.value} className="flex items-center gap-2"> 162 - <input 163 - type="radio" 164 - name="report-reason" 165 - value={r.value} 166 - checked={reason === r.value} 167 - onChange={() => { 168 - setReason(r.value) 169 - setError('') 170 - }} 171 - className="h-4 w-4 border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 172 - /> 173 - <span className="text-sm text-foreground">{r.label}</span> 174 - </label> 175 - ))} 176 - </div> 177 - {error && ( 178 - <p className="mt-1 text-sm text-destructive" role="alert"> 179 - {error} 180 - </p> 181 - )} 182 - </fieldset> 183 - 184 - <div className="space-y-1"> 185 - <label 186 - htmlFor="report-details" 187 - className="block text-sm font-medium text-foreground" 188 - > 189 - Additional details 190 - </label> 191 - <textarea 192 - id="report-details" 193 - value={details} 194 - onChange={(e) => setDetails(e.target.value)} 195 - placeholder="Optional: provide more context" 196 - rows={3} 197 - disabled={submitting} 198 - className={cn( 199 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 200 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 201 - 'disabled:cursor-not-allowed disabled:opacity-50' 202 - )} 203 - /> 204 - </div> 205 - 206 - <div className="flex justify-end gap-3"> 207 - <button 208 - type="button" 209 - onClick={handleClose} 210 - disabled={submitting} 211 - className={cn( 212 - 'rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors', 213 - 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 214 - 'disabled:cursor-not-allowed disabled:opacity-50' 215 - )} 216 - > 217 - Cancel 218 - </button> 219 - <button 220 - type="submit" 221 - disabled={submitting} 222 - className={cn( 223 - 'rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors', 224 - 'hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 225 - 'disabled:cursor-not-allowed disabled:opacity-50' 226 - )} 227 - > 228 - {submitting ? 'Submitting...' : 'Submit Report'} 229 - </button> 230 - </div> 231 - </form> 232 - </> 135 + <ReportFormContent 136 + reason={reason} 137 + details={details} 138 + error={error} 139 + submitting={submitting} 140 + onReasonChange={setReason} 141 + onDetailsChange={setDetails} 142 + onErrorClear={() => setError('')} 143 + onSubmit={handleSubmit} 144 + onClose={handleClose} 145 + /> 233 146 )} 234 147 </div> 235 148 </div>
+121
src/components/report-form-content.tsx
··· 1 + /** 2 + * ReportFormContent - Report form with reason radio buttons and details textarea. 3 + * @see specs/prd-web.md Section M7 (Report button) 4 + */ 5 + 6 + import { cn } from '@/lib/utils' 7 + 8 + const REPORT_REASONS = [ 9 + { value: 'spam', label: 'Spam' }, 10 + { value: 'sexual', label: 'Sexual content' }, 11 + { value: 'harassment', label: 'Harassment' }, 12 + { value: 'violation', label: 'Rule violation' }, 13 + { value: 'misleading', label: 'Misleading' }, 14 + { value: 'other', label: 'Other' }, 15 + ] as const 16 + 17 + interface ReportFormContentProps { 18 + reason: string 19 + details: string 20 + error: string 21 + submitting: boolean 22 + onReasonChange: (reason: string) => void 23 + onDetailsChange: (details: string) => void 24 + onErrorClear: () => void 25 + onSubmit: (e: React.FormEvent) => void 26 + onClose: () => void 27 + } 28 + 29 + export function ReportFormContent({ 30 + reason, 31 + details, 32 + error, 33 + submitting, 34 + onReasonChange, 35 + onDetailsChange, 36 + onErrorClear, 37 + onSubmit, 38 + onClose, 39 + }: ReportFormContentProps) { 40 + return ( 41 + <> 42 + <h2 id="report-dialog-title" className="text-lg font-semibold text-foreground"> 43 + Report Content 44 + </h2> 45 + 46 + <form onSubmit={onSubmit} className="mt-4 space-y-4" noValidate> 47 + <fieldset disabled={submitting}> 48 + <legend className="text-sm font-medium text-foreground">Reason</legend> 49 + <div className="mt-2 space-y-2"> 50 + {REPORT_REASONS.map((r) => ( 51 + <label key={r.value} className="flex items-center gap-2"> 52 + <input 53 + type="radio" 54 + name="report-reason" 55 + value={r.value} 56 + checked={reason === r.value} 57 + onChange={() => { 58 + onReasonChange(r.value) 59 + onErrorClear() 60 + }} 61 + className="h-4 w-4 border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 62 + /> 63 + <span className="text-sm text-foreground">{r.label}</span> 64 + </label> 65 + ))} 66 + </div> 67 + {error && ( 68 + <p className="mt-1 text-sm text-destructive" role="alert"> 69 + {error} 70 + </p> 71 + )} 72 + </fieldset> 73 + 74 + <div className="space-y-1"> 75 + <label htmlFor="report-details" className="block text-sm font-medium text-foreground"> 76 + Additional details 77 + </label> 78 + <textarea 79 + id="report-details" 80 + value={details} 81 + onChange={(e) => onDetailsChange(e.target.value)} 82 + placeholder="Optional: provide more context" 83 + rows={3} 84 + disabled={submitting} 85 + className={cn( 86 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 87 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 88 + 'disabled:cursor-not-allowed disabled:opacity-50' 89 + )} 90 + /> 91 + </div> 92 + 93 + <div className="flex justify-end gap-3"> 94 + <button 95 + type="button" 96 + onClick={onClose} 97 + disabled={submitting} 98 + className={cn( 99 + 'rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors', 100 + 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 101 + 'disabled:cursor-not-allowed disabled:opacity-50' 102 + )} 103 + > 104 + Cancel 105 + </button> 106 + <button 107 + type="submit" 108 + disabled={submitting} 109 + className={cn( 110 + 'rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors', 111 + 'hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 112 + 'disabled:cursor-not-allowed disabled:opacity-50' 113 + )} 114 + > 115 + {submitting ? 'Submitting...' : 'Submit Report'} 116 + </button> 117 + </div> 118 + </form> 119 + </> 120 + ) 121 + }
+13 -42
src/components/search-input.tsx
··· 1 1 /** 2 - * SearchInput - WAI-ARIA Combobox pattern with typeahead suggestions. 3 - * Result count announced via role="status". 2 + * SearchInput - WAI-ARIA Combobox with typeahead suggestions. 4 3 * @see specs/prd-web.md Section M9 (Search) 5 4 */ 6 5 ··· 10 9 import { useRouter } from 'next/navigation' 11 10 import { MagnifyingGlass, X } from '@phosphor-icons/react' 12 11 import { cn } from '@/lib/utils' 12 + import { SearchSuggestionList } from '@/components/search-suggestion-list' 13 13 14 14 export interface SearchSuggestion { 15 15 type: 'topic' | 'reply' ··· 57 57 (value: string) => { 58 58 setQuery(value) 59 59 setActiveIndex(-1) 60 - 61 60 if (debounceRef.current) clearTimeout(debounceRef.current) 62 - 63 61 if (value.length > 0) { 64 62 debounceRef.current = setTimeout(() => { 65 63 setIsOpen(true) ··· 89 87 break 90 88 case 'ArrowDown': 91 89 e.preventDefault() 92 - if (showListbox) { 93 - setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1)) 94 - } 90 + if (showListbox) setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1)) 95 91 break 96 92 case 'ArrowUp': 97 93 e.preventDefault() 98 - if (showListbox) { 99 - setActiveIndex((prev) => Math.max(prev - 1, 0)) 100 - } 94 + if (showListbox) setActiveIndex((prev) => Math.max(prev - 1, 0)) 101 95 break 102 96 } 103 97 }, ··· 132 126 if (hasQuery && hasSuggestions) setIsOpen(true) 133 127 }} 134 128 onBlur={() => { 135 - // Delay to allow click on suggestion 136 129 setTimeout(() => setIsOpen(false), 150) 137 130 }} 138 131 placeholder={placeholder} ··· 156 149 </div> 157 150 158 151 {showListbox && ( 159 - <div 152 + <SearchSuggestionList 160 153 id={listboxId} 161 - role="listbox" 162 - aria-label="Search suggestions" 163 - className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-auto rounded-md border border-border bg-card py-1 shadow-lg" 164 - > 165 - {suggestions.map((suggestion, index) => ( 166 - <div 167 - key={suggestion.rkey} 168 - id={`${id}-option-${index}`} 169 - role="option" 170 - tabIndex={-1} 171 - aria-selected={index === activeIndex} 172 - className={cn( 173 - 'cursor-pointer px-3 py-2 text-sm', 174 - index === activeIndex 175 - ? 'bg-primary/10 text-foreground' 176 - : 'text-foreground hover:bg-muted' 177 - )} 178 - onMouseDown={(e) => { 179 - e.preventDefault() 180 - router.push(`/t/-/${suggestion.rkey}`) 181 - setIsOpen(false) 182 - }} 183 - > 184 - <span className="font-medium">{suggestion.title}</span> 185 - <span className="ml-2 text-xs text-muted-foreground capitalize"> 186 - {suggestion.type} 187 - </span> 188 - </div> 189 - ))} 190 - </div> 154 + baseId={id} 155 + suggestions={suggestions} 156 + activeIndex={activeIndex} 157 + onSelect={(rkey) => { 158 + router.push(`/t/-/${rkey}`) 159 + setIsOpen(false) 160 + }} 161 + /> 191 162 )} 192 163 193 164 <div id={statusId} role="status" aria-live="polite" className="sr-only">
+55
src/components/search-suggestion-list.tsx
··· 1 + /** 2 + * SearchSuggestionList - Listbox of search suggestions with keyboard navigation. 3 + * @see specs/prd-web.md Section M9 (Search) 4 + */ 5 + 6 + import { cn } from '@/lib/utils' 7 + import type { SearchSuggestion } from '@/components/search-input' 8 + 9 + interface SearchSuggestionListProps { 10 + id: string 11 + baseId: string 12 + suggestions: SearchSuggestion[] 13 + activeIndex: number 14 + onSelect: (rkey: string) => void 15 + } 16 + 17 + export function SearchSuggestionList({ 18 + id, 19 + baseId, 20 + suggestions, 21 + activeIndex, 22 + onSelect, 23 + }: SearchSuggestionListProps) { 24 + return ( 25 + <div 26 + id={id} 27 + role="listbox" 28 + aria-label="Search suggestions" 29 + className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-auto rounded-md border border-border bg-card py-1 shadow-lg" 30 + > 31 + {suggestions.map((suggestion, index) => ( 32 + <div 33 + key={suggestion.rkey} 34 + id={`${baseId}-option-${index}`} 35 + role="option" 36 + tabIndex={-1} 37 + aria-selected={index === activeIndex} 38 + className={cn( 39 + 'cursor-pointer px-3 py-2 text-sm', 40 + index === activeIndex 41 + ? 'bg-primary/10 text-foreground' 42 + : 'text-foreground hover:bg-muted' 43 + )} 44 + onMouseDown={(e) => { 45 + e.preventDefault() 46 + onSelect(suggestion.rkey) 47 + }} 48 + > 49 + <span className="font-medium">{suggestion.title}</span> 50 + <span className="ml-2 text-xs text-muted-foreground capitalize">{suggestion.type}</span> 51 + </div> 52 + ))} 53 + </div> 54 + ) 55 + }
+121
src/components/settings/community-overrides-section.tsx
··· 1 + /** 2 + * CommunityOverridesSection - Per-community maturity, muted words, blocked users. 3 + * @see specs/prd-web.md Section M8 4 + */ 5 + 6 + 'use client' 7 + 8 + import { cn } from '@/lib/utils' 9 + 10 + interface CommunityOverrideValues { 11 + communityDid: string 12 + communityName: string 13 + maturityLevel: 'inherit' | 'sfw' | 'mature' 14 + mutedWords: string 15 + blockedDids: string 16 + } 17 + 18 + interface CommunityOverridesSectionProps { 19 + overrides: CommunityOverrideValues[] 20 + onChange: (communityDid: string, field: keyof CommunityOverrideValues, value: string) => void 21 + } 22 + 23 + export function CommunityOverridesSection({ overrides, onChange }: CommunityOverridesSectionProps) { 24 + return ( 25 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 26 + <legend className="px-2 text-sm font-semibold text-foreground"> 27 + Per-Community Overrides 28 + </legend> 29 + 30 + {overrides.length === 0 ? ( 31 + <p className="text-sm text-muted-foreground"> 32 + No community memberships found. Join a community to configure per-community settings. 33 + </p> 34 + ) : ( 35 + <div className="space-y-3"> 36 + {overrides.map((community) => ( 37 + <details key={community.communityDid} className="rounded-md border border-border"> 38 + <summary className="cursor-pointer px-3 py-2 text-sm font-medium text-foreground hover:bg-muted/50"> 39 + {community.communityName} 40 + </summary> 41 + 42 + <div className="space-y-3 border-t border-border px-3 py-3"> 43 + <div className="space-y-1"> 44 + <label 45 + htmlFor={`maturity-${community.communityDid}`} 46 + className="block text-xs font-medium text-foreground" 47 + > 48 + Maturity override 49 + </label> 50 + <select 51 + id={`maturity-${community.communityDid}`} 52 + value={community.maturityLevel} 53 + onChange={(e) => 54 + onChange(community.communityDid, 'maturityLevel', e.target.value) 55 + } 56 + className={cn( 57 + 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground', 58 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 59 + )} 60 + > 61 + <option value="inherit">Inherit global setting</option> 62 + <option value="sfw">SFW only</option> 63 + <option value="mature">SFW + Mature</option> 64 + </select> 65 + </div> 66 + 67 + <div className="space-y-1"> 68 + <label 69 + htmlFor={`muted-words-${community.communityDid}`} 70 + className="block text-xs font-medium text-foreground" 71 + > 72 + Community muted words 73 + </label> 74 + <textarea 75 + id={`muted-words-${community.communityDid}`} 76 + value={community.mutedWords} 77 + onChange={(e) => onChange(community.communityDid, 'mutedWords', e.target.value)} 78 + placeholder="Additional muted words for this community" 79 + rows={2} 80 + className={cn( 81 + 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground', 82 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 83 + )} 84 + /> 85 + <p className="text-xs text-muted-foreground"> 86 + These are in addition to your global muted words. Comma-separated. 87 + </p> 88 + </div> 89 + 90 + <div className="space-y-1"> 91 + <label 92 + htmlFor={`blocked-${community.communityDid}`} 93 + className="block text-xs font-medium text-foreground" 94 + > 95 + Community blocked users 96 + </label> 97 + <textarea 98 + id={`blocked-${community.communityDid}`} 99 + value={community.blockedDids} 100 + onChange={(e) => 101 + onChange(community.communityDid, 'blockedDids', e.target.value) 102 + } 103 + placeholder="DIDs of users to block in this community" 104 + rows={2} 105 + className={cn( 106 + 'block w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground', 107 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 108 + )} 109 + /> 110 + <p className="text-xs text-muted-foreground"> 111 + Block specific users only in this community. Comma-separated DIDs. 112 + </p> 113 + </div> 114 + </div> 115 + </details> 116 + ))} 117 + </div> 118 + )} 119 + </fieldset> 120 + ) 121 + }
+71
src/components/settings/content-safety-section.tsx
··· 1 + /** 2 + * ContentSafetySection - Maturity level selector and muted words. 3 + * @see specs/prd-web.md Section M8 4 + */ 5 + 6 + 'use client' 7 + 8 + import { cn } from '@/lib/utils' 9 + 10 + type MaturityLevel = 'sfw' | 'sfw-mature' 11 + 12 + interface ContentSafetySectionProps { 13 + maturityLevel: MaturityLevel 14 + mutedWords: string 15 + onMaturityChange: (level: MaturityLevel) => void 16 + onMutedWordsChange: (words: string) => void 17 + } 18 + 19 + export function ContentSafetySection({ 20 + maturityLevel, 21 + mutedWords, 22 + onMaturityChange, 23 + onMutedWordsChange, 24 + }: ContentSafetySectionProps) { 25 + return ( 26 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 27 + <legend className="px-2 text-sm font-semibold text-foreground">Content Safety</legend> 28 + 29 + <div className="space-y-1"> 30 + <label htmlFor="maturity-level" className="block text-sm font-medium text-foreground"> 31 + Maturity level 32 + </label> 33 + <select 34 + id="maturity-level" 35 + value={maturityLevel} 36 + onChange={(e) => onMaturityChange(e.target.value as MaturityLevel)} 37 + className={cn( 38 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground', 39 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 40 + )} 41 + > 42 + <option value="sfw">SFW only</option> 43 + <option value="sfw-mature">SFW + Mature</option> 44 + </select> 45 + <p className="text-xs text-muted-foreground"> 46 + Controls which content you can see. Mature content requires age confirmation. 47 + </p> 48 + </div> 49 + 50 + <div className="space-y-1"> 51 + <label htmlFor="muted-words" className="block text-sm font-medium text-foreground"> 52 + Muted words 53 + </label> 54 + <textarea 55 + id="muted-words" 56 + value={mutedWords} 57 + onChange={(e) => onMutedWordsChange(e.target.value)} 58 + placeholder="Enter words separated by commas" 59 + rows={3} 60 + className={cn( 61 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 62 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 63 + )} 64 + /> 65 + <p className="text-xs text-muted-foreground"> 66 + Posts containing these words will be collapsed. Comma-separated. 67 + </p> 68 + </div> 69 + </fieldset> 70 + ) 71 + }
+76
src/components/settings/cross-posting-section.tsx
··· 1 + /** 2 + * CrossPostingSection - Cross-posting authorization and toggle controls. 3 + * @see specs/prd-web.md Section M8 4 + */ 5 + 6 + 'use client' 7 + 8 + import { cn } from '@/lib/utils' 9 + 10 + interface CrossPostingSectionProps { 11 + authorized: boolean 12 + crossPostBluesky: boolean 13 + crossPostFrontpage: boolean 14 + onBlueskyChange: (enabled: boolean) => void 15 + onFrontpageChange: (enabled: boolean) => void 16 + onAuthorize: () => void 17 + } 18 + 19 + export function CrossPostingSection({ 20 + authorized, 21 + crossPostBluesky, 22 + crossPostFrontpage, 23 + onBlueskyChange, 24 + onFrontpageChange, 25 + onAuthorize, 26 + }: CrossPostingSectionProps) { 27 + return ( 28 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 29 + <legend className="px-2 text-sm font-semibold text-foreground">Cross-Posting</legend> 30 + {authorized ? ( 31 + <div className="space-y-3"> 32 + <p className="text-xs text-muted-foreground"> 33 + Cross-posting authorized. You can share topics on Bluesky and Frontpage. 34 + </p> 35 + <label className="flex items-center gap-2"> 36 + <input 37 + type="checkbox" 38 + checked={crossPostBluesky} 39 + onChange={(e) => onBlueskyChange(e.target.checked)} 40 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 41 + /> 42 + <span className="text-sm text-foreground">Share new topics on Bluesky by default</span> 43 + </label> 44 + <label className="flex items-center gap-2"> 45 + <input 46 + type="checkbox" 47 + checked={crossPostFrontpage} 48 + onChange={(e) => onFrontpageChange(e.target.checked)} 49 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 50 + /> 51 + <span className="text-sm text-foreground"> 52 + Share new topics on Frontpage by default 53 + </span> 54 + </label> 55 + </div> 56 + ) : ( 57 + <div className="space-y-3"> 58 + <p className="text-sm text-muted-foreground"> 59 + To share topics on Bluesky and Frontpage, Barazo needs permission to create posts on 60 + your behalf. 61 + </p> 62 + <button 63 + type="button" 64 + onClick={onAuthorize} 65 + className={cn( 66 + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 67 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' 68 + )} 69 + > 70 + Authorize cross-posting 71 + </button> 72 + </div> 73 + )} 74 + </fieldset> 75 + ) 76 + }
+59
src/components/settings/notifications-section.tsx
··· 1 + /** 2 + * NotificationsSection - Notification preference toggles. 3 + * @see specs/prd-web.md Section M8 4 + */ 5 + 6 + 'use client' 7 + 8 + interface NotificationsSectionProps { 9 + notifyReplies: boolean 10 + notifyMentions: boolean 11 + notifyReactions: boolean 12 + onRepliesChange: (enabled: boolean) => void 13 + onMentionsChange: (enabled: boolean) => void 14 + onReactionsChange: (enabled: boolean) => void 15 + } 16 + 17 + export function NotificationsSection({ 18 + notifyReplies, 19 + notifyMentions, 20 + notifyReactions, 21 + onRepliesChange, 22 + onMentionsChange, 23 + onReactionsChange, 24 + }: NotificationsSectionProps) { 25 + return ( 26 + <fieldset className="space-y-4 rounded-lg border border-border p-4"> 27 + <legend className="px-2 text-sm font-semibold text-foreground">Notifications</legend> 28 + <div className="space-y-3"> 29 + <label className="flex items-center gap-2"> 30 + <input 31 + type="checkbox" 32 + checked={notifyReplies} 33 + onChange={(e) => onRepliesChange(e.target.checked)} 34 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 35 + /> 36 + <span className="text-sm text-foreground">Replies to my posts</span> 37 + </label> 38 + <label className="flex items-center gap-2"> 39 + <input 40 + type="checkbox" 41 + checked={notifyMentions} 42 + onChange={(e) => onMentionsChange(e.target.checked)} 43 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 44 + /> 45 + <span className="text-sm text-foreground">Mentions of my handle</span> 46 + </label> 47 + <label className="flex items-center gap-2"> 48 + <input 49 + type="checkbox" 50 + checked={notifyReactions} 51 + onChange={(e) => onReactionsChange(e.target.checked)} 52 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 53 + /> 54 + <span className="text-sm text-foreground">Reactions on my posts</span> 55 + </label> 56 + </div> 57 + </fieldset> 58 + ) 59 + }
+82
src/components/settings/report-appeal-form.tsx
··· 1 + /** 2 + * ReportAppealForm - Inline appeal form for a dismissed report. 3 + * @see specs/prd-web.md Section M7 (Appeals process) 4 + */ 5 + 6 + import { cn } from '@/lib/utils' 7 + 8 + interface ReportAppealFormProps { 9 + reportId: number 10 + appealReason: string 11 + appealError: string 12 + appealSubmitting: boolean 13 + onAppealReasonChange: (reason: string) => void 14 + onAppealSubmit: (e: React.FormEvent) => void 15 + onAppealCancel: () => void 16 + } 17 + 18 + export function ReportAppealForm({ 19 + reportId, 20 + appealReason, 21 + appealError, 22 + appealSubmitting, 23 + onAppealReasonChange, 24 + onAppealSubmit, 25 + onAppealCancel, 26 + }: ReportAppealFormProps) { 27 + return ( 28 + <form onSubmit={onAppealSubmit} className="space-y-3" noValidate> 29 + <div className="space-y-1"> 30 + <label 31 + htmlFor={`appeal-reason-${reportId}`} 32 + className="block text-sm font-medium text-foreground" 33 + > 34 + Reason for appeal 35 + </label> 36 + <textarea 37 + id={`appeal-reason-${reportId}`} 38 + value={appealReason} 39 + onChange={(e) => onAppealReasonChange(e.target.value)} 40 + placeholder="Explain why you believe this report should be reconsidered" 41 + rows={3} 42 + disabled={appealSubmitting} 43 + className={cn( 44 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 45 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 46 + 'disabled:cursor-not-allowed disabled:opacity-50' 47 + )} 48 + /> 49 + {appealError && ( 50 + <p className="text-sm text-destructive" role="alert"> 51 + {appealError} 52 + </p> 53 + )} 54 + </div> 55 + <div className="flex gap-3"> 56 + <button 57 + type="submit" 58 + disabled={appealSubmitting} 59 + className={cn( 60 + 'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors', 61 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 62 + 'disabled:cursor-not-allowed disabled:opacity-50' 63 + )} 64 + > 65 + {appealSubmitting ? 'Submitting...' : 'Submit appeal'} 66 + </button> 67 + <button 68 + type="button" 69 + onClick={onAppealCancel} 70 + disabled={appealSubmitting} 71 + className={cn( 72 + 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors', 73 + 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 74 + 'disabled:cursor-not-allowed disabled:opacity-50' 75 + )} 76 + > 77 + Cancel 78 + </button> 79 + </div> 80 + </form> 81 + ) 82 + }
+121
src/components/settings/report-card.tsx
··· 1 + /** 2 + * ReportCard - Single report display with status badge, metadata, and appeal form. 3 + * @see specs/prd-web.md Section M7 (Appeals process) 4 + */ 5 + 6 + import { cn } from '@/lib/utils' 7 + import { formatDateShort } from '@/lib/format' 8 + import { ReportAppealForm } from '@/components/settings/report-appeal-form' 9 + import type { MyReport } from '@/lib/api/types' 10 + 11 + function statusLabel(report: MyReport): string { 12 + if (report.appealStatus === 'pending') return 'Appeal pending' 13 + if (report.status === 'resolved' && report.resolutionType) { 14 + return report.resolutionType.charAt(0).toUpperCase() + report.resolutionType.slice(1) 15 + } 16 + return 'Pending' 17 + } 18 + 19 + function statusColor(report: MyReport): string { 20 + if (report.appealStatus === 'pending') 21 + return 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400' 22 + if (report.status === 'pending') return 'bg-blue-500/10 text-blue-700 dark:text-blue-400' 23 + if (report.resolutionType === 'dismissed') return 'bg-muted text-muted-foreground' 24 + return 'bg-green-500/10 text-green-700 dark:text-green-400' 25 + } 26 + 27 + function canAppeal(report: MyReport): boolean { 28 + return ( 29 + report.status === 'resolved' && 30 + report.resolutionType === 'dismissed' && 31 + report.appealStatus === 'none' 32 + ) 33 + } 34 + 35 + interface ReportCardProps { 36 + report: MyReport 37 + appealingId: number | null 38 + appealReason: string 39 + appealError: string 40 + appealSubmitting: boolean 41 + appealSuccess: number | null 42 + onAppealOpen: (reportId: number) => void 43 + onAppealCancel: () => void 44 + onAppealReasonChange: (reason: string) => void 45 + onAppealSubmit: (e: React.FormEvent) => void 46 + } 47 + 48 + export function ReportCard({ 49 + report, 50 + appealingId, 51 + appealReason, 52 + appealError, 53 + appealSubmitting, 54 + appealSuccess, 55 + onAppealOpen, 56 + onAppealCancel, 57 + onAppealReasonChange, 58 + onAppealSubmit, 59 + }: ReportCardProps) { 60 + return ( 61 + <div className="rounded-lg border border-border bg-card p-4 space-y-3"> 62 + <div className="flex items-start justify-between gap-3"> 63 + <div className="space-y-1"> 64 + <p className="text-sm font-medium text-foreground"> 65 + {report.reasonType.charAt(0).toUpperCase() + report.reasonType.slice(1)} 66 + </p> 67 + {report.description && ( 68 + <p className="text-sm text-muted-foreground">{report.description}</p> 69 + )} 70 + </div> 71 + <span 72 + className={cn( 73 + 'shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium', 74 + statusColor(report) 75 + )} 76 + > 77 + {statusLabel(report)} 78 + </span> 79 + </div> 80 + 81 + <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> 82 + <span>Reported: {formatDateShort(report.createdAt)}</span> 83 + {report.resolvedAt && <span>Resolved: {formatDateShort(report.resolvedAt)}</span>} 84 + </div> 85 + 86 + {appealSuccess === report.id && ( 87 + <p 88 + className="rounded-md bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400" 89 + role="status" 90 + > 91 + Appeal submitted. A moderator will re-review your report. 92 + </p> 93 + )} 94 + 95 + {canAppeal(report) && appealingId !== report.id && appealSuccess !== report.id && ( 96 + <button 97 + type="button" 98 + onClick={() => onAppealOpen(report.id)} 99 + className={cn( 100 + 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors', 101 + 'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 102 + )} 103 + > 104 + Appeal 105 + </button> 106 + )} 107 + 108 + {appealingId === report.id && ( 109 + <ReportAppealForm 110 + reportId={report.id} 111 + appealReason={appealReason} 112 + appealError={appealError} 113 + appealSubmitting={appealSubmitting} 114 + onAppealReasonChange={onAppealReasonChange} 115 + onAppealSubmit={onAppealSubmit} 116 + onAppealCancel={onAppealCancel} 117 + /> 118 + )} 119 + </div> 120 + ) 121 + }
+38
src/components/source-profile-preview.tsx
··· 1 + /** 2 + * SourceProfilePreview - Read-only display of the user's AT Protocol source profile. 3 + * @see specs/prd-web.md Section M8 (Settings / Community Profile) 4 + */ 5 + 6 + interface SourceProfilePreviewProps { 7 + avatarUrl?: string | null 8 + displayName?: string | null 9 + bio?: string | null 10 + } 11 + 12 + export function SourceProfilePreview({ avatarUrl, displayName, bio }: SourceProfilePreviewProps) { 13 + return ( 14 + <div className="space-y-2 rounded-md border border-dashed border-border bg-muted/30 p-3"> 15 + <p className="text-xs font-medium text-muted-foreground">Your AT Protocol profile (source)</p> 16 + <div className="flex items-center gap-3"> 17 + {avatarUrl ? ( 18 + // eslint-disable-next-line @next/next/no-img-element 19 + <img 20 + src={avatarUrl} 21 + alt="Source avatar" 22 + className="h-10 w-10 rounded-full object-cover" 23 + /> 24 + ) : ( 25 + <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground"> 26 + <span className="text-xs">--</span> 27 + </div> 28 + )} 29 + <div className="min-w-0"> 30 + <p className="truncate text-sm text-muted-foreground"> 31 + {displayName || '(no display name)'} 32 + </p> 33 + <p className="truncate text-xs text-muted-foreground/70">{bio || '(no bio)'}</p> 34 + </div> 35 + </div> 36 + </div> 37 + ) 38 + }
+84
src/components/topic-content-editor.tsx
··· 1 + /** 2 + * TopicContentEditor - Write/Preview tabs with markdown editor. 3 + * @see specs/prd-web.md Section 4 (Editor Components) 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState } from 'react' 9 + import { cn } from '@/lib/utils' 10 + import { MarkdownEditor } from '@/components/markdown-editor' 11 + import { MarkdownPreview } from '@/components/markdown-preview' 12 + 13 + interface TopicContentEditorProps { 14 + content: string 15 + onChange: (content: string) => void 16 + error?: string 17 + } 18 + 19 + export function TopicContentEditor({ content, onChange, error }: TopicContentEditorProps) { 20 + const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write') 21 + 22 + return ( 23 + <div className="space-y-1"> 24 + <div role="tablist" aria-label="Editor mode" className="flex gap-1 border-b border-border"> 25 + <button 26 + type="button" 27 + role="tab" 28 + id="tab-write" 29 + aria-selected={activeTab === 'write'} 30 + aria-controls="tabpanel-write" 31 + onClick={() => setActiveTab('write')} 32 + className={cn( 33 + 'px-3 py-1.5 text-sm font-medium transition-colors', 34 + activeTab === 'write' 35 + ? 'border-b-2 border-primary text-foreground' 36 + : 'text-muted-foreground hover:text-foreground' 37 + )} 38 + > 39 + Write 40 + </button> 41 + <button 42 + type="button" 43 + role="tab" 44 + id="tab-preview" 45 + aria-selected={activeTab === 'preview'} 46 + aria-controls="tabpanel-preview" 47 + onClick={() => setActiveTab('preview')} 48 + className={cn( 49 + 'px-3 py-1.5 text-sm font-medium transition-colors', 50 + activeTab === 'preview' 51 + ? 'border-b-2 border-primary text-foreground' 52 + : 'text-muted-foreground hover:text-foreground' 53 + )} 54 + > 55 + Preview 56 + </button> 57 + </div> 58 + 59 + <div 60 + id="tabpanel-write" 61 + role="tabpanel" 62 + aria-labelledby="tab-write" 63 + hidden={activeTab !== 'write'} 64 + > 65 + <MarkdownEditor 66 + value={content} 67 + onChange={onChange} 68 + id="topic-content" 69 + label="Content" 70 + error={error} 71 + /> 72 + </div> 73 + 74 + <div 75 + id="tabpanel-preview" 76 + role="tabpanel" 77 + aria-labelledby="tab-preview" 78 + hidden={activeTab !== 'preview'} 79 + > 80 + <MarkdownPreview content={content} /> 81 + </div> 82 + </div> 83 + ) 84 + }
+70
src/components/topic-cross-post-section.tsx
··· 1 + /** 2 + * TopicCrossPostSection - Cross-posting options for topic creation. 3 + * Shows authorized checkboxes or authorization prompt. 4 + * @see specs/prd-web.md Section 4 (Editor Components) 5 + */ 6 + 7 + import { cn } from '@/lib/utils' 8 + 9 + interface TopicCrossPostSectionProps { 10 + crossPostScopesGranted: boolean 11 + crossPostBluesky: boolean 12 + crossPostFrontpage: boolean 13 + onCrossPostBlueskyChange: (checked: boolean) => void 14 + onCrossPostFrontpageChange: (checked: boolean) => void 15 + onAuthorizeClick: () => void 16 + } 17 + 18 + export function TopicCrossPostSection({ 19 + crossPostScopesGranted, 20 + crossPostBluesky, 21 + crossPostFrontpage, 22 + onCrossPostBlueskyChange, 23 + onCrossPostFrontpageChange, 24 + onAuthorizeClick, 25 + }: TopicCrossPostSectionProps) { 26 + return ( 27 + <fieldset className="space-y-3"> 28 + <legend className="text-sm font-medium text-foreground">Cross-post</legend> 29 + {crossPostScopesGranted ? ( 30 + <div className="flex flex-col gap-2"> 31 + <label className="flex items-center gap-2"> 32 + <input 33 + type="checkbox" 34 + checked={crossPostBluesky} 35 + onChange={(e) => onCrossPostBlueskyChange(e.target.checked)} 36 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 37 + /> 38 + <span className="text-sm text-foreground">Share on Bluesky</span> 39 + </label> 40 + <label className="flex items-center gap-2"> 41 + <input 42 + type="checkbox" 43 + checked={crossPostFrontpage} 44 + onChange={(e) => onCrossPostFrontpageChange(e.target.checked)} 45 + className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 46 + /> 47 + <span className="text-sm text-foreground">Share on Frontpage</span> 48 + </label> 49 + </div> 50 + ) : ( 51 + <div className="space-y-2"> 52 + <p className="text-sm text-muted-foreground"> 53 + Cross-posting requires additional permissions. 54 + </p> 55 + <button 56 + type="button" 57 + onClick={onAuthorizeClick} 58 + className={cn( 59 + 'text-sm font-medium text-primary transition-colors', 60 + 'hover:text-primary-hover underline underline-offset-4', 61 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 62 + )} 63 + > 64 + Authorize cross-posting 65 + </button> 66 + </div> 67 + )} 68 + </fieldset> 69 + ) 70 + }
+39
src/components/topic-form-validation.ts
··· 1 + /** 2 + * TopicForm validation logic and types. 3 + * @see specs/prd-web.md Section 4 (Editor Components) 4 + */ 5 + 6 + export interface TopicFormValues { 7 + title: string 8 + content: string 9 + category: string 10 + tags?: string[] 11 + crossPostBluesky?: boolean 12 + crossPostFrontpage?: boolean 13 + } 14 + 15 + export interface FormErrors { 16 + title?: string 17 + content?: string 18 + category?: string 19 + } 20 + 21 + export function validateTopicForm(values: TopicFormValues): FormErrors { 22 + const errors: FormErrors = {} 23 + if (!values.title.trim()) { 24 + errors.title = 'Title is required' 25 + } else if (values.title.trim().length < 3) { 26 + errors.title = 'Title must be at least 3 characters' 27 + } else if (values.title.trim().length > 200) { 28 + errors.title = 'Title must be at most 200 characters' 29 + } 30 + if (!values.content.trim()) { 31 + errors.content = 'Content is required' 32 + } else if (values.content.trim().length < 10) { 33 + errors.content = 'Content must be at least 10 characters' 34 + } 35 + if (!values.category) { 36 + errors.category = 'Category is required' 37 + } 38 + return errors 39 + }
+27 -230
src/components/topic-form.tsx
··· 10 10 import { useState, useCallback } from 'react' 11 11 import type { CreateTopicInput } from '@/lib/api/types' 12 12 import { cn } from '@/lib/utils' 13 - import { MarkdownEditor } from './markdown-editor' 14 - import { MarkdownPreview } from './markdown-preview' 15 - import { CrossPostAuthDialog } from './crosspost-auth-dialog' 13 + import { TopicMetaFields } from '@/components/topic-meta-fields' 14 + import { TopicContentEditor } from '@/components/topic-content-editor' 15 + import { TopicCrossPostSection } from '@/components/topic-cross-post-section' 16 + import { CrossPostAuthDialog } from '@/components/crosspost-auth-dialog' 17 + import { validateTopicForm } from '@/components/topic-form-validation' 18 + import type { TopicFormValues, FormErrors } from '@/components/topic-form-validation' 16 19 import { useAuth } from '@/hooks/use-auth' 17 20 18 - interface TopicFormValues { 19 - title: string 20 - content: string 21 - category: string 22 - tags?: string[] 23 - crossPostBluesky?: boolean 24 - crossPostFrontpage?: boolean 25 - } 26 - 27 - interface FormErrors { 28 - title?: string 29 - content?: string 30 - category?: string 31 - } 32 - 33 21 interface TopicFormProps { 34 22 onSubmit: (values: CreateTopicInput) => void | Promise<void> 35 23 initialValues?: Partial<TopicFormValues> ··· 48 36 { slug: 'meta', name: 'Meta' }, 49 37 ] 50 38 51 - function validate(values: TopicFormValues): FormErrors { 52 - const errors: FormErrors = {} 53 - 54 - if (!values.title.trim()) { 55 - errors.title = 'Title is required' 56 - } else if (values.title.trim().length < 3) { 57 - errors.title = 'Title must be at least 3 characters' 58 - } else if (values.title.trim().length > 200) { 59 - errors.title = 'Title must be at most 200 characters' 60 - } 61 - 62 - if (!values.content.trim()) { 63 - errors.content = 'Content is required' 64 - } else if (values.content.trim().length < 10) { 65 - errors.content = 'Content must be at least 10 characters' 66 - } 67 - 68 - if (!values.category) { 69 - errors.category = 'Category is required' 70 - } 71 - 72 - return errors 73 - } 74 - 75 39 export function TopicForm({ 76 40 onSubmit, 77 41 initialValues, ··· 89 53 initialValues?.crossPostFrontpage ?? false 90 54 ) 91 55 const [errors, setErrors] = useState<FormErrors>({}) 92 - const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write') 93 56 const [showCrossPostAuthDialog, setShowCrossPostAuthDialog] = useState(false) 94 57 const { crossPostScopesGranted, requestCrossPostAuth } = useAuth() 95 58 96 59 const handleSubmit = useCallback( 97 60 (e: React.FormEvent) => { 98 61 e.preventDefault() 99 - 100 62 const values: TopicFormValues = { 101 63 title, 102 64 content, ··· 108 70 crossPostBluesky, 109 71 crossPostFrontpage, 110 72 } 111 - 112 - const validationErrors = validate(values) 73 + const validationErrors = validateTopicForm(values) 113 74 setErrors(validationErrors) 114 - 115 - if (Object.keys(validationErrors).length > 0) { 116 - return 117 - } 118 - 75 + if (Object.keys(validationErrors).length > 0) return 119 76 onSubmit({ 120 77 title: values.title.trim(), 121 78 content: values.content.trim(), ··· 135 92 noValidate 136 93 aria-label={mode === 'create' ? 'Create new topic' : 'Edit topic'} 137 94 > 138 - {/* Title */} 139 - <div className="space-y-1"> 140 - <label htmlFor="topic-title" className="block text-sm font-medium text-foreground"> 141 - Title 142 - </label> 143 - <input 144 - id="topic-title" 145 - type="text" 146 - value={title} 147 - onChange={(e) => setTitle(e.target.value)} 148 - placeholder="Enter a descriptive title" 149 - aria-invalid={errors.title ? 'true' : undefined} 150 - aria-describedby={errors.title ? 'topic-title-error' : undefined} 151 - className={cn( 152 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 153 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 154 - errors.title && 'border-destructive' 155 - )} 156 - /> 157 - {errors.title && ( 158 - <p id="topic-title-error" className="text-sm text-destructive" role="alert"> 159 - {errors.title} 160 - </p> 161 - )} 162 - </div> 95 + <TopicMetaFields 96 + title={title} 97 + category={category} 98 + tagInput={tagInput} 99 + categories={categories} 100 + errors={errors} 101 + onTitleChange={setTitle} 102 + onCategoryChange={setCategory} 103 + onTagInputChange={setTagInput} 104 + /> 163 105 164 - {/* Category */} 165 - <div className="space-y-1"> 166 - <label htmlFor="topic-category" className="block text-sm font-medium text-foreground"> 167 - Category 168 - </label> 169 - <select 170 - id="topic-category" 171 - value={category} 172 - onChange={(e) => setCategory(e.target.value)} 173 - aria-invalid={errors.category ? 'true' : undefined} 174 - aria-describedby={errors.category ? 'topic-category-error' : undefined} 175 - className={cn( 176 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground', 177 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 178 - errors.category && 'border-destructive' 179 - )} 180 - > 181 - <option value="">Select a category</option> 182 - {categories.map((cat) => ( 183 - <option key={cat.slug} value={cat.slug}> 184 - {cat.name} 185 - </option> 186 - ))} 187 - </select> 188 - {errors.category && ( 189 - <p id="topic-category-error" className="text-sm text-destructive" role="alert"> 190 - {errors.category} 191 - </p> 192 - )} 193 - </div> 106 + <TopicContentEditor content={content} onChange={setContent} error={errors.content} /> 194 107 195 - {/* Tags */} 196 - <div className="space-y-1"> 197 - <label htmlFor="topic-tags" className="block text-sm font-medium text-foreground"> 198 - Tags 199 - </label> 200 - <input 201 - id="topic-tags" 202 - type="text" 203 - value={tagInput} 204 - onChange={(e) => setTagInput(e.target.value)} 205 - placeholder="Comma-separated tags (e.g., discussion, help)" 206 - className={cn( 207 - 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 208 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 209 - )} 108 + {mode === 'create' && ( 109 + <TopicCrossPostSection 110 + crossPostScopesGranted={crossPostScopesGranted} 111 + crossPostBluesky={crossPostBluesky} 112 + crossPostFrontpage={crossPostFrontpage} 113 + onCrossPostBlueskyChange={setCrossPostBluesky} 114 + onCrossPostFrontpageChange={setCrossPostFrontpage} 115 + onAuthorizeClick={() => setShowCrossPostAuthDialog(true)} 210 116 /> 211 - </div> 212 - 213 - {/* Content - Write/Preview tabs */} 214 - <div className="space-y-1"> 215 - <div role="tablist" aria-label="Editor mode" className="flex gap-1 border-b border-border"> 216 - <button 217 - type="button" 218 - role="tab" 219 - id="tab-write" 220 - aria-selected={activeTab === 'write'} 221 - aria-controls="tabpanel-write" 222 - onClick={() => setActiveTab('write')} 223 - className={cn( 224 - 'px-3 py-1.5 text-sm font-medium transition-colors', 225 - activeTab === 'write' 226 - ? 'border-b-2 border-primary text-foreground' 227 - : 'text-muted-foreground hover:text-foreground' 228 - )} 229 - > 230 - Write 231 - </button> 232 - <button 233 - type="button" 234 - role="tab" 235 - id="tab-preview" 236 - aria-selected={activeTab === 'preview'} 237 - aria-controls="tabpanel-preview" 238 - onClick={() => setActiveTab('preview')} 239 - className={cn( 240 - 'px-3 py-1.5 text-sm font-medium transition-colors', 241 - activeTab === 'preview' 242 - ? 'border-b-2 border-primary text-foreground' 243 - : 'text-muted-foreground hover:text-foreground' 244 - )} 245 - > 246 - Preview 247 - </button> 248 - </div> 249 - 250 - <div 251 - id="tabpanel-write" 252 - role="tabpanel" 253 - aria-labelledby="tab-write" 254 - hidden={activeTab !== 'write'} 255 - > 256 - <MarkdownEditor 257 - value={content} 258 - onChange={setContent} 259 - id="topic-content" 260 - label="Content" 261 - error={errors.content} 262 - /> 263 - </div> 264 - 265 - <div 266 - id="tabpanel-preview" 267 - role="tabpanel" 268 - aria-labelledby="tab-preview" 269 - hidden={activeTab !== 'preview'} 270 - > 271 - <MarkdownPreview content={content} /> 272 - </div> 273 - </div> 274 - 275 - {/* Cross-post options */} 276 - {mode === 'create' && ( 277 - <fieldset className="space-y-3"> 278 - <legend className="text-sm font-medium text-foreground">Cross-post</legend> 279 - {crossPostScopesGranted ? ( 280 - <div className="flex flex-col gap-2"> 281 - <label className="flex items-center gap-2"> 282 - <input 283 - type="checkbox" 284 - checked={crossPostBluesky} 285 - onChange={(e) => setCrossPostBluesky(e.target.checked)} 286 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 287 - /> 288 - <span className="text-sm text-foreground">Share on Bluesky</span> 289 - </label> 290 - <label className="flex items-center gap-2"> 291 - <input 292 - type="checkbox" 293 - checked={crossPostFrontpage} 294 - onChange={(e) => setCrossPostFrontpage(e.target.checked)} 295 - className="h-4 w-4 rounded border-border text-primary focus-visible:ring-2 focus-visible:ring-ring" 296 - /> 297 - <span className="text-sm text-foreground">Share on Frontpage</span> 298 - </label> 299 - </div> 300 - ) : ( 301 - <div className="space-y-2"> 302 - <p className="text-sm text-muted-foreground"> 303 - Cross-posting requires additional permissions. 304 - </p> 305 - <button 306 - type="button" 307 - onClick={() => setShowCrossPostAuthDialog(true)} 308 - className={cn( 309 - 'text-sm font-medium text-primary transition-colors', 310 - 'hover:text-primary-hover underline underline-offset-4', 311 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 312 - )} 313 - > 314 - Authorize cross-posting 315 - </button> 316 - </div> 317 - )} 318 - </fieldset> 319 117 )} 320 118 321 119 <CrossPostAuthDialog ··· 327 125 onCancel={() => setShowCrossPostAuthDialog(false)} 328 126 /> 329 127 330 - {/* Submit */} 331 128 <div className="flex justify-end"> 332 129 <button 333 130 type="submit"
+104
src/components/topic-meta-fields.tsx
··· 1 + /** 2 + * TopicMetaFields - Title, category, and tags inputs for topic form. 3 + * @see specs/prd-web.md Section 4 (Editor Components) 4 + */ 5 + 6 + import { cn } from '@/lib/utils' 7 + 8 + interface TopicMetaFieldsProps { 9 + title: string 10 + category: string 11 + tagInput: string 12 + categories: Array<{ slug: string; name: string }> 13 + errors: { title?: string; category?: string } 14 + onTitleChange: (title: string) => void 15 + onCategoryChange: (category: string) => void 16 + onTagInputChange: (tags: string) => void 17 + } 18 + 19 + export function TopicMetaFields({ 20 + title, 21 + category, 22 + tagInput, 23 + categories, 24 + errors, 25 + onTitleChange, 26 + onCategoryChange, 27 + onTagInputChange, 28 + }: TopicMetaFieldsProps) { 29 + return ( 30 + <> 31 + <div className="space-y-1"> 32 + <label htmlFor="topic-title" className="block text-sm font-medium text-foreground"> 33 + Title 34 + </label> 35 + <input 36 + id="topic-title" 37 + type="text" 38 + value={title} 39 + onChange={(e) => onTitleChange(e.target.value)} 40 + placeholder="Enter a descriptive title" 41 + aria-invalid={errors.title ? 'true' : undefined} 42 + aria-describedby={errors.title ? 'topic-title-error' : undefined} 43 + className={cn( 44 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 45 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 46 + errors.title && 'border-destructive' 47 + )} 48 + /> 49 + {errors.title && ( 50 + <p id="topic-title-error" className="text-sm text-destructive" role="alert"> 51 + {errors.title} 52 + </p> 53 + )} 54 + </div> 55 + 56 + <div className="space-y-1"> 57 + <label htmlFor="topic-category" className="block text-sm font-medium text-foreground"> 58 + Category 59 + </label> 60 + <select 61 + id="topic-category" 62 + value={category} 63 + onChange={(e) => onCategoryChange(e.target.value)} 64 + aria-invalid={errors.category ? 'true' : undefined} 65 + aria-describedby={errors.category ? 'topic-category-error' : undefined} 66 + className={cn( 67 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground', 68 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 69 + errors.category && 'border-destructive' 70 + )} 71 + > 72 + <option value="">Select a category</option> 73 + {categories.map((cat) => ( 74 + <option key={cat.slug} value={cat.slug}> 75 + {cat.name} 76 + </option> 77 + ))} 78 + </select> 79 + {errors.category && ( 80 + <p id="topic-category-error" className="text-sm text-destructive" role="alert"> 81 + {errors.category} 82 + </p> 83 + )} 84 + </div> 85 + 86 + <div className="space-y-1"> 87 + <label htmlFor="topic-tags" className="block text-sm font-medium text-foreground"> 88 + Tags 89 + </label> 90 + <input 91 + id="topic-tags" 92 + type="text" 93 + value={tagInput} 94 + onChange={(e) => onTagInputChange(e.target.value)} 95 + placeholder="Comma-separated tags (e.g., discussion, help)" 96 + className={cn( 97 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 98 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 99 + )} 100 + /> 101 + </div> 102 + </> 103 + ) 104 + }
+245
src/hooks/use-community-profile.ts
··· 1 + /** 2 + * useCommunityProfile - Manages community profile data loading and mutations. 3 + * Handles loading community DID, profile data, save, reset, and image upload/remove. 4 + * @see specs/prd-web.md Section M8 (Settings / Community Profile) 5 + */ 6 + 7 + import { useState, useEffect, useCallback } from 'react' 8 + import { useAuth } from '@/hooks/use-auth' 9 + import { 10 + getPublicSettings, 11 + getCommunityProfile, 12 + updateCommunityProfile, 13 + resetCommunityProfile, 14 + uploadCommunityAvatar, 15 + uploadCommunityBanner, 16 + } from '@/lib/api/client' 17 + import type { CommunityProfile } from '@/lib/api/types' 18 + 19 + interface UseCommunityProfileReturn { 20 + communityDid: string | null 21 + profile: CommunityProfile | null 22 + loading: boolean 23 + saving: boolean 24 + error: string | null 25 + success: boolean 26 + displayName: string 27 + bio: string 28 + setDisplayName: (v: string) => void 29 + setBio: (v: string) => void 30 + handleSave: (e: React.FormEvent) => void 31 + handleReset: () => void 32 + handleAvatarUpload: (file: File) => Promise<{ url: string }> 33 + handleBannerUpload: (file: File) => Promise<{ url: string }> 34 + handleAvatarRemove: () => void 35 + handleBannerRemove: () => void 36 + setShowResetConfirm: (show: boolean) => void 37 + showResetConfirm: boolean 38 + } 39 + 40 + export function useCommunityProfile(): UseCommunityProfileReturn { 41 + const { getAccessToken, isAuthenticated } = useAuth() 42 + 43 + const [communityDid, setCommunityDid] = useState<string | null>(null) 44 + const [profile, setProfile] = useState<CommunityProfile | null>(null) 45 + const [loading, setLoading] = useState(true) 46 + const [saving, setSaving] = useState(false) 47 + const [error, setError] = useState<string | null>(null) 48 + const [success, setSuccess] = useState(false) 49 + const [showResetConfirm, setShowResetConfirm] = useState(false) 50 + const [displayName, setDisplayName] = useState('') 51 + const [bio, setBio] = useState('') 52 + 53 + useEffect(() => { 54 + if (!isAuthenticated) { 55 + setLoading(false) 56 + return 57 + } 58 + 59 + const token = getAccessToken() 60 + if (!token) { 61 + setLoading(false) 62 + return 63 + } 64 + 65 + let cancelled = false 66 + 67 + async function loadProfile() { 68 + try { 69 + const publicSettings = await getPublicSettings() 70 + const did = publicSettings.communityDid 71 + if (!did) { 72 + if (!cancelled) setLoading(false) 73 + return 74 + } 75 + 76 + if (!cancelled) setCommunityDid(did) 77 + 78 + const currentToken = token 79 + if (!currentToken) return 80 + 81 + const communityProfile = await getCommunityProfile(did, currentToken) 82 + if (!cancelled) { 83 + setProfile(communityProfile) 84 + setDisplayName(communityProfile.hasOverride ? (communityProfile.displayName ?? '') : '') 85 + setBio(communityProfile.hasOverride ? (communityProfile.bio ?? '') : '') 86 + } 87 + } catch { 88 + if (!cancelled) setError('Failed to load community profile.') 89 + } finally { 90 + if (!cancelled) setLoading(false) 91 + } 92 + } 93 + 94 + void loadProfile() 95 + return () => { 96 + cancelled = true 97 + } 98 + }, [getAccessToken, isAuthenticated]) 99 + 100 + const handleSave = useCallback( 101 + async (e: React.FormEvent) => { 102 + e.preventDefault() 103 + if (!communityDid) return 104 + 105 + const token = getAccessToken() 106 + if (!token) { 107 + setError('Not authenticated') 108 + return 109 + } 110 + 111 + setSaving(true) 112 + setError(null) 113 + setSuccess(false) 114 + 115 + try { 116 + await updateCommunityProfile( 117 + communityDid, 118 + { displayName: displayName.trim() || null, bio: bio.trim() || null }, 119 + token 120 + ) 121 + const updatedProfile = await getCommunityProfile(communityDid, token) 122 + setProfile(updatedProfile) 123 + setSuccess(true) 124 + } catch { 125 + setError('Failed to save community profile.') 126 + } finally { 127 + setSaving(false) 128 + } 129 + }, 130 + [communityDid, displayName, bio, getAccessToken] 131 + ) 132 + 133 + const handleReset = useCallback(async () => { 134 + if (!communityDid) return 135 + 136 + const token = getAccessToken() 137 + if (!token) { 138 + setError('Not authenticated') 139 + return 140 + } 141 + 142 + setShowResetConfirm(false) 143 + setSaving(true) 144 + setError(null) 145 + setSuccess(false) 146 + 147 + try { 148 + await resetCommunityProfile(communityDid, token) 149 + const updatedProfile = await getCommunityProfile(communityDid, token) 150 + setProfile(updatedProfile) 151 + setDisplayName('') 152 + setBio('') 153 + setSuccess(true) 154 + } catch { 155 + setError('Failed to reset community profile.') 156 + } finally { 157 + setSaving(false) 158 + } 159 + }, [communityDid, getAccessToken]) 160 + 161 + const handleAvatarUpload = useCallback( 162 + async (file: File): Promise<{ url: string }> => { 163 + if (!communityDid) throw new Error('No community DID') 164 + const token = getAccessToken() 165 + if (!token) throw new Error('Not authenticated') 166 + 167 + const result = await uploadCommunityAvatar(communityDid, file, token) 168 + const updatedProfile = await getCommunityProfile(communityDid, token) 169 + setProfile(updatedProfile) 170 + return result 171 + }, 172 + [communityDid, getAccessToken] 173 + ) 174 + 175 + const handleBannerUpload = useCallback( 176 + async (file: File): Promise<{ url: string }> => { 177 + if (!communityDid) throw new Error('No community DID') 178 + const token = getAccessToken() 179 + if (!token) throw new Error('Not authenticated') 180 + 181 + const result = await uploadCommunityBanner(communityDid, file, token) 182 + const updatedProfile = await getCommunityProfile(communityDid, token) 183 + setProfile(updatedProfile) 184 + return result 185 + }, 186 + [communityDid, getAccessToken] 187 + ) 188 + 189 + const handleAvatarRemove = useCallback(async () => { 190 + if (!communityDid) return 191 + const token = getAccessToken() 192 + if (!token) return 193 + 194 + try { 195 + await updateCommunityProfile( 196 + communityDid, 197 + { displayName: displayName.trim() || null, bio: bio.trim() || null }, 198 + token 199 + ) 200 + const updatedProfile = await getCommunityProfile(communityDid, token) 201 + setProfile(updatedProfile) 202 + } catch { 203 + setError('Failed to remove avatar.') 204 + } 205 + }, [communityDid, displayName, bio, getAccessToken]) 206 + 207 + const handleBannerRemove = useCallback(async () => { 208 + if (!communityDid) return 209 + const token = getAccessToken() 210 + if (!token) return 211 + 212 + try { 213 + await updateCommunityProfile( 214 + communityDid, 215 + { displayName: displayName.trim() || null, bio: bio.trim() || null }, 216 + token 217 + ) 218 + const updatedProfile = await getCommunityProfile(communityDid, token) 219 + setProfile(updatedProfile) 220 + } catch { 221 + setError('Failed to remove banner.') 222 + } 223 + }, [communityDid, displayName, bio, getAccessToken]) 224 + 225 + return { 226 + communityDid, 227 + profile, 228 + loading, 229 + saving, 230 + error, 231 + success, 232 + displayName, 233 + bio, 234 + setDisplayName, 235 + setBio, 236 + handleSave, 237 + handleReset, 238 + handleAvatarUpload, 239 + handleBannerUpload, 240 + handleAvatarRemove, 241 + handleBannerRemove, 242 + showResetConfirm, 243 + setShowResetConfirm, 244 + } 245 + }
+30
src/lib/format.ts
··· 30 30 } 31 31 32 32 /** 33 + * Formats an ISO date string as a short date/time (e.g., "Jan 15, 3:42 PM"). 34 + */ 35 + export function formatDate(dateStr: string): string { 36 + return new Date(dateStr).toLocaleDateString('en-US', { 37 + month: 'short', 38 + day: 'numeric', 39 + hour: 'numeric', 40 + minute: '2-digit', 41 + }) 42 + } 43 + 44 + /** 45 + * Formats an ISO date string as a short date without time (e.g., "Jan 15, 2026"). 46 + */ 47 + export function formatDateShort(dateStr: string): string { 48 + return new Date(dateStr).toLocaleDateString('en-US', { 49 + month: 'short', 50 + day: 'numeric', 51 + year: 'numeric', 52 + }) 53 + } 54 + 55 + /** 56 + * Formats a number with locale-aware separators (e.g., 1,234). 57 + */ 58 + export function formatNumber(n: number): string { 59 + return n.toLocaleString('en-US') 60 + } 61 + 62 + /** 33 63 * Formats a number with compact notation (e.g., 1.2k, 3.4M). 34 64 */ 35 65 export function formatCompactNumber(n: number): string {