Barazo default frontend barazo.forum
2
fork

Configure Feed

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

refactor(web): extract hooks and sub-components from oversized pages (#77)

Extract state/logic into custom hooks and UI into sub-components to bring
all page files under the ~150-line component limit.

Pages refactored:
- settings/page.tsx (305 -> 157): useSettingsForm hook
- admin/moderation (235 -> 146): useModerationData hook
- admin/sybil-detection (228 -> 136): useSybilData hook
- u/[handle] (215 -> 149): ProfileHeader, ProfileSkeleton components
- admin/onboarding (214 -> 101): useOnboardingFields hook
- search (195 -> 44): SearchResults, SearchResultCard components
- admin/plugins (195 -> 92): DependencyWarningDialog, usePluginManagement
- admin/users (189 -> 108): UserCard component

Closes #23

authored by

Guido X Jansen and committed by
GitHub
b3eff4c9 3e10a811

+1352 -977
+20 -109
src/app/admin/moderation/page.tsx
··· 7 7 8 8 'use client' 9 9 10 - import { useState, useEffect, useCallback } from 'react' 11 10 import { AdminLayout } from '@/components/admin/admin-layout' 12 11 import { ErrorAlert } from '@/components/error-alert' 13 12 import { ModerationReportsTab } from '@/components/admin/moderation/reports-tab' ··· 15 14 import { ModerationActionLogTab } from '@/components/admin/moderation/action-log-tab' 16 15 import { ModerationReportedUsersTab } from '@/components/admin/moderation/reported-users-tab' 17 16 import { ModerationThresholdsTab } from '@/components/admin/moderation/thresholds-tab' 18 - import { 19 - getModerationReports, 20 - resolveReport, 21 - getFirstPostQueue, 22 - resolveFirstPost, 23 - getModerationLog, 24 - getReportedUsers, 25 - getModerationThresholds, 26 - updateModerationThresholds, 27 - } from '@/lib/api/client' 28 17 import { cn } from '@/lib/utils' 29 - import type { 30 - ModerationReport, 31 - FirstPostQueueItem, 32 - ModerationLogEntry, 33 - ReportedUser, 34 - ModerationThresholds, 35 - ReportResolution, 36 - } from '@/lib/api/types' 37 - import { useAuth } from '@/hooks/use-auth' 38 - 39 - type TabId = 'reports' | 'first-post' | 'action-log' | 'reported-users' | 'thresholds' 40 - 41 - const TABS: { id: TabId; label: string }[] = [ 42 - { id: 'reports', label: 'Reports' }, 43 - { id: 'first-post', label: 'First Post Queue' }, 44 - { id: 'action-log', label: 'Action Log' }, 45 - { id: 'reported-users', label: 'Reported Users' }, 46 - { id: 'thresholds', label: 'Thresholds' }, 47 - ] 18 + import { useModerationData, MODERATION_TABS } from '@/hooks/admin/use-moderation-data' 48 19 49 20 export default function AdminModerationPage() { 50 - const { getAccessToken } = useAuth() 51 - const [activeTab, setActiveTab] = useState<TabId>('reports') 52 - const [reports, setReports] = useState<ModerationReport[]>([]) 53 - const [firstPostQueue, setFirstPostQueue] = useState<FirstPostQueueItem[]>([]) 54 - const [moderationLog, setModerationLog] = useState<ModerationLogEntry[]>([]) 55 - const [reportedUsers, setReportedUsers] = useState<ReportedUser[]>([]) 56 - const [thresholds, setThresholds] = useState<ModerationThresholds | null>(null) 57 - const [loading, setLoading] = useState(true) 58 - const [loadError, setLoadError] = useState<string | null>(null) 59 - const [actionError, setActionError] = useState<string | null>(null) 60 - 61 - const fetchData = useCallback(async () => { 62 - setLoadError(null) 63 - try { 64 - const [reportsRes, queueRes, logRes, usersRes, thresholdsRes] = await Promise.all([ 65 - getModerationReports(getAccessToken() ?? ''), 66 - getFirstPostQueue(getAccessToken() ?? ''), 67 - getModerationLog(getAccessToken() ?? ''), 68 - getReportedUsers(getAccessToken() ?? ''), 69 - getModerationThresholds(getAccessToken() ?? ''), 70 - ]) 71 - setReports(reportsRes.reports) 72 - setFirstPostQueue(queueRes.items) 73 - setModerationLog(logRes.entries) 74 - setReportedUsers(usersRes.users) 75 - setThresholds(thresholdsRes) 76 - } catch { 77 - setLoadError('Failed to load moderation data. The API may be unreachable.') 78 - } finally { 79 - setLoading(false) 80 - } 81 - }, [getAccessToken]) 82 - 83 - useEffect(() => { 84 - void fetchData() 85 - }, [fetchData]) 86 - 87 - const handleResolveReport = async (id: string, resolution: ReportResolution) => { 88 - setActionError(null) 89 - try { 90 - await resolveReport(id, resolution, getAccessToken() ?? '') 91 - setReports((prev) => prev.filter((r) => r.id !== id)) 92 - } catch { 93 - setActionError('Failed to resolve report. Please try again.') 94 - } 95 - } 96 - 97 - const handleResolveFirstPost = async (id: string, action: 'approved' | 'rejected') => { 98 - setActionError(null) 99 - try { 100 - await resolveFirstPost(id, action, getAccessToken() ?? '') 101 - setFirstPostQueue((prev) => prev.filter((item) => item.id !== id)) 102 - } catch { 103 - setActionError( 104 - `Failed to ${action === 'approved' ? 'approve' : 'reject'} post. Please try again.` 105 - ) 106 - } 107 - } 108 - 109 - const handleBatchResolveFirstPost = async (ids: string[], action: 'approved' | 'rejected') => { 110 - setActionError(null) 111 - try { 112 - await Promise.all(ids.map((id) => resolveFirstPost(id, action, getAccessToken() ?? ''))) 113 - setFirstPostQueue((prev) => prev.filter((item) => !ids.includes(item.id))) 114 - } catch { 115 - setActionError('Failed to process batch action. Some items may not have been updated.') 116 - } 117 - } 118 - 119 - const handleSaveThresholds = async (updated: Partial<ModerationThresholds>) => { 120 - setActionError(null) 121 - try { 122 - const result = await updateModerationThresholds(updated, getAccessToken() ?? '') 123 - setThresholds(result) 124 - } catch { 125 - setActionError('Failed to save thresholds. Please try again.') 126 - } 127 - } 21 + const { 22 + activeTab, 23 + setActiveTab, 24 + reports, 25 + firstPostQueue, 26 + moderationLog, 27 + reportedUsers, 28 + thresholds, 29 + loading, 30 + loadError, 31 + actionError, 32 + setActionError, 33 + fetchData, 34 + handleResolveReport, 35 + handleResolveFirstPost, 36 + handleBatchResolveFirstPost, 37 + handleSaveThresholds, 38 + } = useModerationData() 128 39 129 40 return ( 130 41 <AdminLayout> ··· 137 48 aria-label="Moderation sections" 138 49 className="flex gap-1 border-b border-border" 139 50 > 140 - {TABS.map((tab) => ( 51 + {MODERATION_TABS.map((tab) => ( 141 52 <button 142 53 key={tab.id} 143 54 type="button"
+20 -133
src/app/admin/onboarding/page.tsx
··· 6 6 7 7 'use client' 8 8 9 - import { useState, useEffect, useCallback } from 'react' 10 9 import { Plus } from '@phosphor-icons/react' 11 10 import { AdminLayout } from '@/components/admin/admin-layout' 12 11 import { ErrorAlert } from '@/components/error-alert' 13 - import { 14 - OnboardingFieldForm, 15 - EMPTY_FIELD, 16 - } from '@/components/admin/onboarding/onboarding-field-form' 12 + import { OnboardingFieldForm } from '@/components/admin/onboarding/onboarding-field-form' 17 13 import { OnboardingFieldItem } from '@/components/admin/onboarding/onboarding-field-item' 18 - import { 19 - getOnboardingFields, 20 - createOnboardingField, 21 - updateOnboardingField, 22 - deleteOnboardingField, 23 - reorderOnboardingFields, 24 - } from '@/lib/api/client' 25 - import type { OnboardingField, CreateOnboardingFieldInput } from '@/lib/api/types' 26 - import type { EditingField } from '@/components/admin/onboarding/onboarding-field-form' 27 - import { useAuth } from '@/hooks/use-auth' 14 + import { useOnboardingFields } from '@/hooks/admin/use-onboarding-fields' 28 15 29 16 export default function AdminOnboardingPage() { 30 - const { getAccessToken } = useAuth() 31 - const [fields, setFields] = useState<OnboardingField[]>([]) 32 - const [loading, setLoading] = useState(true) 33 - const [editing, setEditing] = useState<EditingField | null>(null) 34 - const [saving, setSaving] = useState(false) 35 - const [error, setError] = useState<string | null>(null) 36 - const [loadError, setLoadError] = useState<string | null>(null) 37 - const [actionError, setActionError] = useState<string | null>(null) 38 - 39 - const fetchFields = useCallback(async () => { 40 - setLoadError(null) 41 - try { 42 - const response = await getOnboardingFields(getAccessToken() ?? '') 43 - setFields(response.fields) 44 - } catch { 45 - setLoadError('Failed to load onboarding fields. The API may be unreachable.') 46 - } finally { 47 - setLoading(false) 48 - } 49 - }, [getAccessToken]) 50 - 51 - useEffect(() => { 52 - void fetchFields() 53 - }, [fetchFields]) 54 - 55 - const handleAdd = () => { 56 - setEditing({ ...EMPTY_FIELD }) 57 - setError(null) 58 - } 59 - 60 - const handleEdit = (field: OnboardingField) => { 61 - setEditing({ 62 - id: field.id, 63 - fieldType: field.fieldType, 64 - label: field.label, 65 - description: field.description ?? '', 66 - isMandatory: field.isMandatory, 67 - config: field.config, 68 - }) 69 - setError(null) 70 - } 71 - 72 - const handleDelete = async (id: string) => { 73 - setActionError(null) 74 - try { 75 - await deleteOnboardingField(id, getAccessToken() ?? '') 76 - void fetchFields() 77 - } catch { 78 - setActionError('Failed to delete field. Please try again.') 79 - } 80 - } 81 - 82 - const handleSave = async () => { 83 - if (!editing) return 84 - if (!editing.label.trim()) { 85 - setError('Label is required') 86 - return 87 - } 88 - 89 - setSaving(true) 90 - setError(null) 91 - try { 92 - if (editing.id) { 93 - await updateOnboardingField( 94 - editing.id, 95 - { 96 - label: editing.label, 97 - description: editing.description || null, 98 - isMandatory: editing.isMandatory, 99 - config: editing.config, 100 - }, 101 - getAccessToken() ?? '' 102 - ) 103 - } else { 104 - const input: CreateOnboardingFieldInput = { 105 - fieldType: editing.fieldType, 106 - label: editing.label, 107 - description: editing.description || undefined, 108 - isMandatory: editing.isMandatory, 109 - sortOrder: fields.length, 110 - config: editing.config ?? undefined, 111 - } 112 - await createOnboardingField(input, getAccessToken() ?? '') 113 - } 114 - setEditing(null) 115 - void fetchFields() 116 - } catch { 117 - setError('Failed to save field') 118 - } finally { 119 - setSaving(false) 120 - } 121 - } 122 - 123 - const handleMoveUp = async (index: number) => { 124 - if (index === 0) return 125 - const newFields = [...fields] 126 - const temp = newFields[index - 1]! 127 - newFields[index - 1] = newFields[index]! 128 - newFields[index] = temp 129 - setFields(newFields) 130 - await reorderOnboardingFields( 131 - newFields.map((f, i) => ({ id: f.id, sortOrder: i })), 132 - getAccessToken() ?? '' 133 - ) 134 - } 135 - 136 - const handleMoveDown = async (index: number) => { 137 - if (index >= fields.length - 1) return 138 - const newFields = [...fields] 139 - const temp = newFields[index + 1]! 140 - newFields[index + 1] = newFields[index]! 141 - newFields[index] = temp 142 - setFields(newFields) 143 - await reorderOnboardingFields( 144 - newFields.map((f, i) => ({ id: f.id, sortOrder: i })), 145 - getAccessToken() ?? '' 146 - ) 147 - } 17 + const { 18 + fields, 19 + loading, 20 + editing, 21 + setEditing, 22 + saving, 23 + error, 24 + loadError, 25 + actionError, 26 + setActionError, 27 + fetchFields, 28 + handleAdd, 29 + handleEdit, 30 + handleDelete, 31 + handleSave, 32 + handleMoveUp, 33 + handleMoveDown, 34 + } = useOnboardingFields() 148 35 149 36 return ( 150 37 <AdminLayout>
+24 -127
src/app/admin/plugins/page.tsx
··· 8 8 9 9 'use client' 10 10 11 - import { useState, useEffect, useCallback } from 'react' 12 - import { WarningCircle } from '@phosphor-icons/react' 13 11 import { AdminLayout } from '@/components/admin/admin-layout' 14 12 import { ErrorAlert } from '@/components/error-alert' 15 13 import { PluginCard } from '@/components/admin/plugins/plugin-card' 16 14 import { PluginSettingsModal } from '@/components/admin/plugins/plugin-settings-modal' 17 - import { getPlugins, togglePlugin, updatePluginSettings, uninstallPlugin } from '@/lib/api/client' 18 - import type { Plugin } from '@/lib/api/types' 19 - import { useAuth } from '@/hooks/use-auth' 15 + import { DependencyWarningDialog } from '@/components/admin/plugins/dependency-warning-dialog' 16 + import { usePluginManagement } from '@/hooks/admin/use-plugin-management' 20 17 21 18 export default function AdminPluginsPage() { 22 - const { getAccessToken } = useAuth() 23 - const [plugins, setPlugins] = useState<Plugin[]>([]) 24 - const [loading, setLoading] = useState(true) 25 - const [settingsPlugin, setSettingsPlugin] = useState<Plugin | null>(null) 26 - const [dependencyWarning, setDependencyWarning] = useState<{ 27 - plugin: Plugin 28 - dependents: string[] 29 - } | null>(null) 30 - const [loadError, setLoadError] = useState<string | null>(null) 31 - const [actionError, setActionError] = useState<string | null>(null) 32 - 33 - const fetchPlugins = useCallback(async () => { 34 - setLoadError(null) 35 - try { 36 - const response = await getPlugins(getAccessToken() ?? '') 37 - setPlugins(response.plugins) 38 - } catch { 39 - setLoadError('Failed to load plugins. The API may be unreachable.') 40 - } finally { 41 - setLoading(false) 42 - } 43 - }, [getAccessToken]) 44 - 45 - useEffect(() => { 46 - void fetchPlugins() 47 - }, [fetchPlugins]) 48 - 49 - const findDependentNames = (plugin: Plugin): string[] => { 50 - return plugin.dependents.map((depId) => { 51 - const dep = plugins.find((p) => p.id === depId) 52 - return dep?.displayName ?? depId 53 - }) 54 - } 55 - 56 - const handleToggle = async (plugin: Plugin) => { 57 - if (plugin.enabled && plugin.dependents.length > 0) { 58 - const dependentNames = findDependentNames(plugin) 59 - setDependencyWarning({ plugin, dependents: dependentNames }) 60 - return 61 - } 62 - 63 - setActionError(null) 64 - try { 65 - await togglePlugin(plugin.id, !plugin.enabled, getAccessToken() ?? '') 66 - setPlugins((prev) => 67 - prev.map((p) => (p.id === plugin.id ? { ...p, enabled: !p.enabled } : p)) 68 - ) 69 - } catch { 70 - setActionError(`Failed to ${plugin.enabled ? 'disable' : 'enable'} plugin. Please try again.`) 71 - } 72 - } 73 - 74 - const confirmDisable = async () => { 75 - if (!dependencyWarning) return 76 - setActionError(null) 77 - try { 78 - await togglePlugin(dependencyWarning.plugin.id, false, getAccessToken() ?? '') 79 - setPlugins((prev) => 80 - prev.map((p) => (p.id === dependencyWarning.plugin.id ? { ...p, enabled: false } : p)) 81 - ) 82 - } catch { 83 - setActionError('Failed to disable plugin. Please try again.') 84 - } 85 - setDependencyWarning(null) 86 - } 87 - 88 - const handleSaveSettings = async (settings: Record<string, boolean | string | number>) => { 89 - if (!settingsPlugin) return 90 - setActionError(null) 91 - try { 92 - await updatePluginSettings(settingsPlugin.id, settings, getAccessToken() ?? '') 93 - setPlugins((prev) => prev.map((p) => (p.id === settingsPlugin.id ? { ...p, settings } : p))) 94 - } catch { 95 - setActionError('Failed to save plugin settings. Please try again.') 96 - } 97 - setSettingsPlugin(null) 98 - } 99 - 100 - const handleUninstall = async (plugin: Plugin) => { 101 - setActionError(null) 102 - try { 103 - await uninstallPlugin(plugin.id, getAccessToken() ?? '') 104 - setPlugins((prev) => prev.filter((p) => p.id !== plugin.id)) 105 - } catch { 106 - setActionError('Failed to uninstall plugin. Please try again.') 107 - } 108 - } 19 + const { 20 + plugins, 21 + loading, 22 + settingsPlugin, 23 + setSettingsPlugin, 24 + dependencyWarning, 25 + setDependencyWarning, 26 + loadError, 27 + actionError, 28 + setActionError, 29 + fetchPlugins, 30 + handleToggle, 31 + confirmDisable, 32 + handleSaveSettings, 33 + handleUninstall, 34 + } = usePluginManagement() 109 35 110 36 return ( 111 37 <AdminLayout> ··· 144 70 </div> 145 71 )} 146 72 147 - {/* Dependency Warning Dialog */} 148 73 {dependencyWarning && ( 149 - <div 150 - className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 151 - role="alertdialog" 152 - aria-modal="true" 153 - aria-label="Dependency warning" 154 - > 155 - <div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg"> 156 - <div className="mb-3 flex items-center gap-2 text-destructive"> 157 - <WarningCircle size={20} aria-hidden="true" /> 158 - <h2 className="font-semibold">Dependency Warning</h2> 159 - </div> 160 - <p className="text-sm text-muted-foreground"> 161 - Disabling <strong>{dependencyWarning.plugin.displayName}</strong> will affect the 162 - following plugins that depend on it:{' '} 163 - <strong>{dependencyWarning.dependents.join(', ')}</strong> 164 - </p> 165 - <div className="mt-4 flex justify-end gap-2"> 166 - <button 167 - type="button" 168 - onClick={() => setDependencyWarning(null)} 169 - className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 170 - > 171 - Cancel 172 - </button> 173 - <button 174 - type="button" 175 - onClick={() => void confirmDisable()} 176 - className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 177 - > 178 - Disable Anyway 179 - </button> 180 - </div> 181 - </div> 182 - </div> 74 + <DependencyWarningDialog 75 + pluginName={dependencyWarning.plugin.displayName} 76 + dependents={dependencyWarning.dependents} 77 + onConfirm={() => void confirmDisable()} 78 + onCancel={() => setDependencyWarning(null)} 79 + /> 183 80 )} 184 81 185 82 {settingsPlugin && (
+23 -115
src/app/admin/sybil-detection/page.tsx
··· 7 7 8 8 'use client' 9 9 10 - import { useState, useEffect, useCallback } from 'react' 11 10 import { AdminLayout } from '@/components/admin/admin-layout' 12 11 import { ErrorAlert } from '@/components/error-alert' 13 12 import { ConfirmDialog } from '@/components/confirm-dialog' ··· 15 14 import { SybilClusterListView } from '@/components/admin/sybil/cluster-list-view' 16 15 import { SybilClusterDetailView } from '@/components/admin/sybil/cluster-detail-view' 17 16 import { BehavioralFlagsSection } from '@/components/admin/sybil/behavioral-flags-section' 18 - import { 19 - getSybilClusters, 20 - getSybilClusterDetail, 21 - updateSybilClusterStatus, 22 - getTrustGraphStatus, 23 - recomputeTrustGraph, 24 - getBehavioralFlags, 25 - updateBehavioralFlag, 26 - } from '@/lib/api/client' 27 - import type { 28 - SybilCluster, 29 - SybilClusterDetail, 30 - SybilClusterStatus, 31 - TrustGraphStatus, 32 - BehavioralFlag, 33 - } from '@/lib/api/types' 34 - import { useAuth } from '@/hooks/use-auth' 17 + import { useSybilData } from '@/hooks/admin/use-sybil-data' 35 18 36 19 export default function AdminSybilDetectionPage() { 37 - const { getAccessToken } = useAuth() 38 - const [clusters, setClusters] = useState<SybilCluster[]>([]) 39 - const [graphStatus, setGraphStatus] = useState<TrustGraphStatus | null>(null) 40 - const [flags, setFlags] = useState<BehavioralFlag[]>([]) 41 - const [selectedDetail, setSelectedDetail] = useState<SybilClusterDetail | null>(null) 42 - const [statusFilter, setStatusFilter] = useState<string>('all') 43 - const [loading, setLoading] = useState(true) 44 - const [loadError, setLoadError] = useState<string | null>(null) 45 - const [actionError, setActionError] = useState<string | null>(null) 46 - const [recomputing, setRecomputing] = useState(false) 47 - const [confirmAction, setConfirmAction] = useState<{ 48 - title: string 49 - message: string 50 - onConfirm: () => void 51 - } | null>(null) 52 - 53 - const fetchData = useCallback(async () => { 54 - setLoadError(null) 55 - setLoading(true) 56 - try { 57 - const token = getAccessToken() ?? '' 58 - const [clustersRes, statusRes, flagsRes] = await Promise.all([ 59 - getSybilClusters(token), 60 - getTrustGraphStatus(token), 61 - getBehavioralFlags(token), 62 - ]) 63 - setClusters(clustersRes.clusters) 64 - setGraphStatus(statusRes) 65 - setFlags(flagsRes.flags) 66 - } catch { 67 - setLoadError('Failed to load sybil detection data. The API may be unreachable.') 68 - } finally { 69 - setLoading(false) 70 - } 71 - }, [getAccessToken]) 72 - 73 - useEffect(() => { 74 - void fetchData() 75 - }, [fetchData]) 76 - 77 - const filteredClusters = 78 - statusFilter === 'all' ? clusters : clusters.filter((c) => c.status === statusFilter) 79 - 80 - const handleViewDetail = async (id: number) => { 81 - setActionError(null) 82 - try { 83 - const detail = await getSybilClusterDetail(id, getAccessToken() ?? '') 84 - setSelectedDetail(detail) 85 - } catch { 86 - setActionError('Failed to load cluster details.') 87 - } 88 - } 89 - 90 - const handleClusterAction = (status: SybilClusterStatus) => { 91 - if (!selectedDetail) return 92 - const actionLabel = status === 'banned' ? 'ban' : status === 'dismissed' ? 'dismiss' : status 93 - setConfirmAction({ 94 - title: `${actionLabel.charAt(0).toUpperCase() + actionLabel.slice(1)} cluster`, 95 - message: `Are you sure you want to ${actionLabel} this cluster with ${selectedDetail.memberCount} members?`, 96 - onConfirm: async () => { 97 - setConfirmAction(null) 98 - try { 99 - const updated = await updateSybilClusterStatus( 100 - selectedDetail.id, 101 - status, 102 - getAccessToken() ?? '' 103 - ) 104 - setClusters((prev) => prev.map((c) => (c.id === updated.id ? updated : c))) 105 - setSelectedDetail({ ...selectedDetail, ...updated }) 106 - } catch { 107 - setActionError('Failed to update cluster status.') 108 - } 109 - }, 110 - }) 111 - } 112 - 113 - const handleRecompute = async () => { 114 - setRecomputing(true) 115 - try { 116 - await recomputeTrustGraph(getAccessToken() ?? '') 117 - } catch { 118 - setActionError('Failed to start recomputation.') 119 - } finally { 120 - setRecomputing(false) 121 - } 122 - } 123 - 124 - const handleDismissFlag = async (id: number) => { 125 - setActionError(null) 126 - try { 127 - const updated = await updateBehavioralFlag(id, 'dismissed', getAccessToken() ?? '') 128 - setFlags((prev) => prev.map((f) => (f.id === updated.id ? updated : f))) 129 - } catch { 130 - setActionError('Failed to dismiss flag.') 131 - } 132 - } 20 + const { 21 + clusters, 22 + graphStatus, 23 + flags, 24 + selectedDetail, 25 + setSelectedDetail, 26 + statusFilter, 27 + setStatusFilter, 28 + loading, 29 + loadError, 30 + actionError, 31 + setActionError, 32 + recomputing, 33 + confirmAction, 34 + setConfirmAction, 35 + fetchData, 36 + handleViewDetail, 37 + handleClusterAction, 38 + handleRecompute, 39 + handleDismissFlag, 40 + } = useSybilData() 133 41 134 42 return ( 135 43 <AdminLayout> ··· 193 101 </div> 194 102 </div> 195 103 <SybilClusterListView 196 - clusters={filteredClusters} 104 + clusters={clusters} 197 105 onViewDetail={(id) => void handleViewDetail(id)} 198 106 /> 199 107 </div>
+6 -87
src/app/admin/users/page.tsx
··· 8 8 'use client' 9 9 10 10 import { useState, useEffect, useCallback } from 'react' 11 - import { Prohibit, WarningCircle } from '@phosphor-icons/react' 12 11 import { AdminLayout } from '@/components/admin/admin-layout' 13 12 import { ErrorAlert } from '@/components/error-alert' 13 + import { UserCard } from '@/components/admin/users/user-card' 14 14 import { getAdminUsers, banUser, unbanUser } from '@/lib/api/client' 15 - import { cn } from '@/lib/utils' 16 15 import type { AdminUser } from '@/lib/api/types' 17 16 import { useAuth } from '@/hooks/use-auth' 18 - 19 - const ROLE_COLORS: Record<string, string> = { 20 - admin: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', 21 - moderator: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', 22 - member: 'bg-muted text-muted-foreground', 23 - } 24 - 25 - function formatDate(dateStr: string) { 26 - return new Date(dateStr).toLocaleDateString('en-US', { 27 - month: 'short', 28 - day: 'numeric', 29 - year: 'numeric', 30 - }) 31 - } 32 17 33 18 export default function AdminUsersPage() { 34 19 const { getAccessToken } = useAuth() ··· 108 93 {!loading && users.length > 0 && ( 109 94 <div className="space-y-2"> 110 95 {users.map((user) => ( 111 - <article 96 + <UserCard 112 97 key={user.did} 113 - className={cn( 114 - 'rounded-lg border border-border bg-card p-4', 115 - user.isBanned && 'border-l-4 border-l-destructive opacity-75' 116 - )} 117 - > 118 - <div className="flex items-start justify-between gap-4"> 119 - <div className="min-w-0 flex-1"> 120 - <div className="flex items-center gap-2"> 121 - <p className="text-sm font-medium text-foreground"> 122 - {user.displayName ?? user.handle} 123 - </p> 124 - <span 125 - className={cn( 126 - 'rounded-full px-2 py-0.5 text-xs font-medium', 127 - ROLE_COLORS[user.role] ?? ROLE_COLORS.member 128 - )} 129 - > 130 - {user.role} 131 - </span> 132 - {user.isBanned && ( 133 - <span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive"> 134 - <Prohibit size={10} aria-hidden="true" /> 135 - Banned 136 - </span> 137 - )} 138 - </div> 139 - <p className="text-xs text-muted-foreground">@{user.handle}</p> 140 - <div className="mt-1 flex gap-4 text-xs text-muted-foreground"> 141 - <span>{user.topicCount} topics</span> 142 - <span>{user.replyCount} replies</span> 143 - <span>{user.reportCount} reports</span> 144 - <span>Joined {formatDate(user.firstSeenAt)}</span> 145 - </div> 146 - {user.bannedFromOtherCommunities > 0 && ( 147 - <p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-destructive"> 148 - <WarningCircle size={12} aria-hidden="true" /> 149 - Banned from {user.bannedFromOtherCommunities} other communities 150 - </p> 151 - )} 152 - {user.isBanned && user.banReason && ( 153 - <p className="mt-1 text-xs text-muted-foreground italic"> 154 - Reason: {user.banReason} 155 - </p> 156 - )} 157 - </div> 158 - <div className="shrink-0"> 159 - {user.isBanned ? ( 160 - <button 161 - type="button" 162 - onClick={() => void handleUnban(user.did)} 163 - className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted" 164 - aria-label={`Unban ${user.displayName ?? user.handle}`} 165 - > 166 - Unban 167 - </button> 168 - ) : ( 169 - user.role !== 'admin' && ( 170 - <button 171 - type="button" 172 - onClick={() => void handleBan(user.did)} 173 - className="rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 174 - aria-label={`Ban ${user.displayName ?? user.handle}`} 175 - > 176 - Ban 177 - </button> 178 - ) 179 - )} 180 - </div> 181 - </div> 182 - </article> 98 + user={user} 99 + onBan={(did) => void handleBan(did)} 100 + onUnban={(did) => void handleUnban(did)} 101 + /> 183 102 ))} 184 103 </div> 185 104 )}
+2 -153
src/app/search/page.tsx
··· 8 8 9 9 'use client' 10 10 11 - import { Suspense, useState, useEffect, useCallback } from 'react' 12 - import { useSearchParams } from 'next/navigation' 13 - import Link from 'next/link' 14 - import { ChatCircle, Article, Heart } from '@phosphor-icons/react' 11 + import { Suspense } from 'react' 15 12 import { ForumLayout } from '@/components/layout/forum-layout' 16 13 import { Breadcrumbs } from '@/components/breadcrumbs' 17 14 import { SearchInput } from '@/components/search-input' 18 - import { searchContent } from '@/lib/api/client' 19 - import type { SearchResult, SearchResponse } from '@/lib/api/types' 15 + import { SearchResults } from '@/components/search-results' 20 16 21 17 export default function SearchPage() { 22 18 return ( ··· 46 42 </ForumLayout> 47 43 ) 48 44 } 49 - 50 - function SearchResults() { 51 - const searchParams = useSearchParams() 52 - const initialQuery = searchParams.get('q') ?? '' 53 - 54 - const [results, setResults] = useState<SearchResult[]>([]) 55 - const [total, setTotal] = useState<number | null>(null) 56 - const [loading, setLoading] = useState(false) 57 - const [searched, setSearched] = useState(false) 58 - 59 - const performSearch = useCallback(async (q: string) => { 60 - if (!q) { 61 - setResults([]) 62 - setTotal(null) 63 - setSearched(false) 64 - return 65 - } 66 - 67 - setLoading(true) 68 - try { 69 - const response: SearchResponse = await searchContent({ q }) 70 - setResults(response.results) 71 - setTotal(response.total) 72 - setSearched(true) 73 - } catch { 74 - setResults([]) 75 - setTotal(0) 76 - setSearched(true) 77 - } finally { 78 - setLoading(false) 79 - } 80 - }, []) 81 - 82 - useEffect(() => { 83 - if (initialQuery) { 84 - void performSearch(initialQuery) 85 - } 86 - }, [initialQuery, performSearch]) 87 - 88 - const formatDate = (dateStr: string) => { 89 - return new Date(dateStr).toLocaleDateString('en-US', { 90 - year: 'numeric', 91 - month: 'short', 92 - day: 'numeric', 93 - }) 94 - } 95 - 96 - return ( 97 - <div aria-live="polite"> 98 - {loading && ( 99 - <div className="animate-pulse space-y-4 py-4"> 100 - <div className="h-16 rounded bg-muted" /> 101 - <div className="h-16 rounded bg-muted" /> 102 - </div> 103 - )} 104 - 105 - {!loading && !searched && !initialQuery && ( 106 - <p className="py-8 text-center text-muted-foreground"> 107 - Enter a search term to find topics and replies. 108 - </p> 109 - )} 110 - 111 - {!loading && searched && results.length === 0 && ( 112 - <p className="py-8 text-center text-muted-foreground"> 113 - No results found for &ldquo;{initialQuery}&rdquo;. Try a different search term. 114 - </p> 115 - )} 116 - 117 - {!loading && searched && results.length > 0 && ( 118 - <div className="space-y-4"> 119 - <p className="text-sm text-muted-foreground"> 120 - {total} result{total !== 1 ? 's' : ''} for &ldquo;{initialQuery}&rdquo; 121 - </p> 122 - 123 - <ul className="space-y-3"> 124 - {results.map((result) => ( 125 - <li key={result.uri}> 126 - <SearchResultCard result={result} formatDate={formatDate} /> 127 - </li> 128 - ))} 129 - </ul> 130 - </div> 131 - )} 132 - </div> 133 - ) 134 - } 135 - 136 - interface SearchResultCardProps { 137 - result: SearchResult 138 - formatDate: (dateStr: string) => string 139 - } 140 - 141 - function SearchResultCard({ result, formatDate }: SearchResultCardProps) { 142 - const isTopic = result.type === 'topic' 143 - const href = isTopic ? `/t/${result.category ?? '-'}/${result.rkey}` : `/t/-/${result.rkey}` 144 - 145 - return ( 146 - <article className="rounded-lg border border-border bg-card p-4 transition-colors hover:bg-card-hover"> 147 - <div className="flex items-start gap-3"> 148 - <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted"> 149 - {isTopic ? ( 150 - <Article size={16} className="text-muted-foreground" aria-hidden="true" /> 151 - ) : ( 152 - <ChatCircle size={16} className="text-muted-foreground" aria-hidden="true" /> 153 - )} 154 - </div> 155 - <div className="min-w-0 flex-1"> 156 - <div className="flex items-center gap-2"> 157 - <span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground capitalize"> 158 - {result.type} 159 - </span> 160 - {result.category && ( 161 - <span className="text-xs text-muted-foreground">{result.category}</span> 162 - )} 163 - </div> 164 - 165 - <Link 166 - href={href} 167 - className="mt-1 block font-medium text-foreground hover:text-primary hover:underline" 168 - > 169 - {isTopic && result.title ? result.title : result.content.slice(0, 100)} 170 - </Link> 171 - 172 - {!isTopic && result.rootTitle && ( 173 - <p className="mt-1 text-xs text-muted-foreground"> 174 - In topic: <span className="font-medium">{result.rootTitle}</span> 175 - </p> 176 - )} 177 - 178 - <div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground"> 179 - <span>{formatDate(result.createdAt)}</span> 180 - <span className="flex items-center gap-1"> 181 - <Heart size={12} aria-hidden="true" /> 182 - {result.reactionCount} 183 - </span> 184 - {isTopic && result.replyCount !== null && ( 185 - <span className="flex items-center gap-1"> 186 - <ChatCircle size={12} aria-hidden="true" /> 187 - {result.replyCount} 188 - </span> 189 - )} 190 - </div> 191 - </div> 192 - </div> 193 - </article> 194 - ) 195 - }
+21 -173
src/app/settings/page.tsx
··· 9 9 10 10 'use client' 11 11 12 - import { useState, useEffect, useCallback } from 'react' 13 12 import Link from 'next/link' 14 13 import { ForumLayout } from '@/components/layout/forum-layout' 15 14 import { Breadcrumbs } from '@/components/breadcrumbs' ··· 21 20 import { CrossPostingSection } from '@/components/settings/cross-posting-section' 22 21 import { NotificationsSection } from '@/components/settings/notifications-section' 23 22 import { cn } from '@/lib/utils' 24 - import { 25 - getPreferences, 26 - updatePreferences, 27 - getCommunityPreferences, 28 - updateCommunityPreference, 29 - } from '@/lib/api/client' 30 - import type { CommunityPreferenceOverride } from '@/lib/api/types' 31 - import { useAuth } from '@/hooks/use-auth' 32 - 33 - type MaturityLevel = 'sfw' | 'sfw-mature' 34 - 35 - interface SettingsValues { 36 - maturityLevel: MaturityLevel 37 - mutedWords: string 38 - crossPostBluesky: boolean 39 - crossPostFrontpage: boolean 40 - notifyReplies: boolean 41 - notifyMentions: boolean 42 - notifyReactions: boolean 43 - } 44 - 45 - interface CommunityOverrideValues { 46 - communityDid: string 47 - communityName: string 48 - maturityLevel: 'inherit' | 'sfw' | 'mature' 49 - mutedWords: string 50 - blockedDids: string 51 - } 23 + import { useSettingsForm } from '@/hooks/use-settings-form' 52 24 53 25 export default function SettingsPage() { 54 - const { getAccessToken, crossPostScopesGranted, requestCrossPostAuth } = useAuth() 55 - const [showCrossPostAuthDialog, setShowCrossPostAuthDialog] = useState(false) 56 - const [values, setValues] = useState<SettingsValues>({ 57 - maturityLevel: 'sfw', 58 - mutedWords: '', 59 - crossPostBluesky: true, 60 - crossPostFrontpage: false, 61 - notifyReplies: true, 62 - notifyMentions: true, 63 - notifyReactions: false, 64 - }) 65 - const [communityOverrides, setCommunityOverrides] = useState<CommunityOverrideValues[]>([]) 66 - const [saving, setSaving] = useState(false) 67 - const [loading, setLoading] = useState(true) 68 - const [error, setError] = useState<string | null>(null) 69 - const [success, setSuccess] = useState(false) 70 - const [declaredAge, setDeclaredAge] = useState<number | null>(null) 71 - const [showAgeGate, setShowAgeGate] = useState(false) 72 - 73 - useEffect(() => { 74 - const token = getAccessToken() 75 - if (!token) { 76 - setLoading(false) 77 - return 78 - } 79 - 80 - Promise.all([getPreferences(token), getCommunityPreferences(token)]) 81 - .then(([prefs, communityPrefs]) => { 82 - setValues({ 83 - maturityLevel: prefs.maturityLevel === 'mature' ? 'sfw-mature' : 'sfw', 84 - mutedWords: prefs.mutedWords.join(', '), 85 - crossPostBluesky: prefs.crossPostBluesky, 86 - crossPostFrontpage: prefs.crossPostFrontpage, 87 - notifyReplies: true, 88 - notifyMentions: true, 89 - notifyReactions: false, 90 - }) 91 - setDeclaredAge(prefs.declaredAge) 92 - setCommunityOverrides( 93 - communityPrefs.communities.map( 94 - (c: CommunityPreferenceOverride): CommunityOverrideValues => ({ 95 - communityDid: c.communityDid, 96 - communityName: c.communityName, 97 - maturityLevel: c.maturityLevel, 98 - mutedWords: c.mutedWords.join(', '), 99 - blockedDids: c.blockedDids.join(', '), 100 - }) 101 - ) 102 - ) 103 - }) 104 - .catch(() => setError('Failed to load preferences')) 105 - .finally(() => setLoading(false)) 106 - }, [getAccessToken]) 107 - 108 - const handleCommunityChange = useCallback( 109 - (communityDid: string, field: keyof CommunityOverrideValues, value: string) => { 110 - setCommunityOverrides((prev) => 111 - prev.map((c) => (c.communityDid === communityDid ? { ...c, [field]: value } : c)) 112 - ) 113 - }, 114 - [] 115 - ) 116 - 117 - const handleSave = useCallback( 118 - async (e: React.FormEvent) => { 119 - e.preventDefault() 120 - setSaving(true) 121 - setError(null) 122 - setSuccess(false) 123 - 124 - const token = getAccessToken() 125 - if (!token) { 126 - setError('Not authenticated') 127 - setSaving(false) 128 - return 129 - } 130 - 131 - if (values.maturityLevel === 'sfw-mature' && !declaredAge) { 132 - setShowAgeGate(true) 133 - setSaving(false) 134 - return 135 - } 136 - 137 - try { 138 - const mutedWords = values.mutedWords 139 - .split(',') 140 - .map((w) => w.trim()) 141 - .filter(Boolean) 142 - 143 - await updatePreferences( 144 - { 145 - maturityLevel: values.maturityLevel === 'sfw-mature' ? 'mature' : 'sfw', 146 - mutedWords, 147 - crossPostBluesky: values.crossPostBluesky, 148 - crossPostFrontpage: values.crossPostFrontpage, 149 - }, 150 - token 151 - ) 152 - 153 - await Promise.all( 154 - communityOverrides.map((c) => 155 - updateCommunityPreference( 156 - c.communityDid, 157 - { 158 - maturityLevel: c.maturityLevel, 159 - mutedWords: c.mutedWords 160 - .split(',') 161 - .map((w) => w.trim()) 162 - .filter(Boolean), 163 - blockedDids: c.blockedDids 164 - .split(',') 165 - .map((d) => d.trim()) 166 - .filter(Boolean), 167 - }, 168 - token 169 - ) 170 - ) 171 - ) 172 - 173 - setSuccess(true) 174 - } catch { 175 - setError('Failed to save preferences') 176 - } finally { 177 - setSaving(false) 178 - } 179 - }, 180 - [values, communityOverrides, declaredAge, getAccessToken] 181 - ) 26 + const { 27 + values, 28 + setValues, 29 + communityOverrides, 30 + saving, 31 + loading, 32 + error, 33 + success, 34 + showAgeGate, 35 + showCrossPostAuthDialog, 36 + setShowCrossPostAuthDialog, 37 + crossPostScopesGranted, 38 + handleCommunityChange, 39 + handleSave, 40 + handleAgeConfirm, 41 + handleAgeCancel, 42 + handleCrossPostAuthorize, 43 + } = useSettingsForm() 182 44 183 45 return ( 184 46 <ForumLayout> ··· 281 143 282 144 <CrossPostAuthDialog 283 145 open={showCrossPostAuthDialog} 284 - onAuthorize={() => { 285 - setShowCrossPostAuthDialog(false) 286 - void requestCrossPostAuth() 287 - }} 146 + onAuthorize={handleCrossPostAuthorize} 288 147 onCancel={() => setShowCrossPostAuthDialog(false)} 289 148 /> 290 149 291 - <AgeGateDialog 292 - open={showAgeGate} 293 - onConfirm={(age) => { 294 - setDeclaredAge(age) 295 - setShowAgeGate(false) 296 - void handleSave({ preventDefault: () => {} } as React.FormEvent) 297 - }} 298 - onCancel={() => { 299 - setShowAgeGate(false) 300 - setValues((prev) => ({ ...prev, maturityLevel: 'sfw' })) 301 - }} 302 - /> 150 + <AgeGateDialog open={showAgeGate} onConfirm={handleAgeConfirm} onCancel={handleAgeCancel} /> 303 151 </ForumLayout> 304 152 ) 305 153 }
+14 -80
src/app/u/[handle]/page.tsx
··· 9 9 'use client' 10 10 11 11 import { useState, useEffect } from 'react' 12 - import Image from 'next/image' 13 - import { User, CalendarBlank, ChatCircle } from '@phosphor-icons/react' 14 12 import { ForumLayout } from '@/components/layout/forum-layout' 15 13 import { Breadcrumbs } from '@/components/breadcrumbs' 16 - import { ReputationBadge } from '@/components/reputation-badge' 17 - import { BlockMuteButton } from '@/components/block-mute-button' 14 + import { ProfileHeader } from '@/components/profile/profile-header' 15 + import { ProfileSkeleton } from '@/components/profile/profile-skeleton' 18 16 import { getUserProfile } from '@/lib/api/client' 19 17 import type { UserProfile } from '@/lib/api/types' 20 18 ··· 85 83 if (!handle || loading) { 86 84 return ( 87 85 <ForumLayout> 88 - <div className="animate-pulse space-y-4 py-8"> 89 - <div className="h-48 rounded-t-lg bg-muted" /> 90 - <div className="flex items-start gap-4 p-6"> 91 - <div className="h-16 w-16 rounded-full bg-muted" /> 92 - <div className="space-y-2"> 93 - <div className="h-6 w-32 rounded bg-muted" /> 94 - <div className="h-4 w-48 rounded bg-muted" /> 95 - </div> 96 - </div> 97 - </div> 86 + <ProfileSkeleton /> 98 87 </ForumLayout> 99 88 ) 100 89 } ··· 135 124 <div className="space-y-6"> 136 125 <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: handle }]} /> 137 126 138 - {/* Profile header */} 139 - <div className="overflow-hidden rounded-lg border border-border bg-card"> 140 - {/* Banner */} 141 - {profile.bannerUrl && ( 142 - <div className="relative h-48 overflow-hidden"> 143 - <Image src={profile.bannerUrl} alt="" fill className="object-cover" /> 144 - </div> 145 - )} 146 - 147 - <div className="p-6"> 148 - <div className="flex items-start gap-4"> 149 - {/* Avatar */} 150 - {profile.avatarUrl ? ( 151 - <Image 152 - src={profile.avatarUrl} 153 - alt={`${profile.displayName ?? profile.handle}'s avatar`} 154 - width={64} 155 - height={64} 156 - className="rounded-full object-cover" 157 - /> 158 - ) : ( 159 - <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"> 160 - <User size={32} className="text-muted-foreground" aria-hidden="true" /> 161 - </div> 162 - )} 163 - 164 - <div className="min-w-0 flex-1"> 165 - <h1 className="text-2xl font-bold text-foreground"> 166 - {profile.displayName ?? handle} 167 - </h1> 168 - {profile.displayName && <p className="text-lg text-muted-foreground">@{handle}</p>} 169 - 170 - {/* Bio */} 171 - {profile.bio && <p className="mt-2 text-sm text-muted-foreground">{profile.bio}</p>} 172 - 173 - <div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> 174 - <ReputationBadge score={reputationScore} /> 175 - <span className="flex items-center gap-1"> 176 - <ChatCircle size={16} aria-hidden="true" /> 177 - {postCount} {postCount === 1 ? 'post' : 'posts'} 178 - </span> 179 - <span className="flex items-center gap-1"> 180 - <CalendarBlank size={16} aria-hidden="true" /> 181 - Joined {joinDate} 182 - </span> 183 - </div> 184 - 185 - {/* Block/Mute actions */} 186 - <div className="mt-3 flex gap-2"> 187 - <BlockMuteButton 188 - targetDid={profile.did} 189 - action="block" 190 - isActive={isBlocked} 191 - onToggle={setIsBlocked} 192 - /> 193 - <BlockMuteButton 194 - targetDid={profile.did} 195 - action="mute" 196 - isActive={isMuted} 197 - onToggle={setIsMuted} 198 - /> 199 - </div> 200 - </div> 201 - </div> 202 - </div> 203 - </div> 127 + <ProfileHeader 128 + profile={profile} 129 + handle={handle} 130 + reputationScore={reputationScore} 131 + postCount={postCount} 132 + joinDate={joinDate} 133 + isBlocked={isBlocked} 134 + isMuted={isMuted} 135 + onBlockToggle={setIsBlocked} 136 + onMuteToggle={setIsMuted} 137 + /> 204 138 205 139 {/* Recent activity */} 206 140 <section>
+57
src/components/admin/plugins/dependency-warning-dialog.tsx
··· 1 + /** 2 + * DependencyWarningDialog - Warns when disabling a plugin that other plugins depend on. 3 + */ 4 + 5 + 'use client' 6 + 7 + import { WarningCircle } from '@phosphor-icons/react' 8 + 9 + interface DependencyWarningDialogProps { 10 + pluginName: string 11 + dependents: string[] 12 + onConfirm: () => void 13 + onCancel: () => void 14 + } 15 + 16 + export function DependencyWarningDialog({ 17 + pluginName, 18 + dependents, 19 + onConfirm, 20 + onCancel, 21 + }: DependencyWarningDialogProps) { 22 + return ( 23 + <div 24 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 25 + role="alertdialog" 26 + aria-modal="true" 27 + aria-label="Dependency warning" 28 + > 29 + <div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg"> 30 + <div className="mb-3 flex items-center gap-2 text-destructive"> 31 + <WarningCircle size={20} aria-hidden="true" /> 32 + <h2 className="font-semibold">Dependency Warning</h2> 33 + </div> 34 + <p className="text-sm text-muted-foreground"> 35 + Disabling <strong>{pluginName}</strong> will affect the following plugins that depend on 36 + it: <strong>{dependents.join(', ')}</strong> 37 + </p> 38 + <div className="mt-4 flex justify-end gap-2"> 39 + <button 40 + type="button" 41 + onClick={onCancel} 42 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 43 + > 44 + Cancel 45 + </button> 46 + <button 47 + type="button" 48 + onClick={onConfirm} 49 + className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 50 + > 51 + Disable Anyway 52 + </button> 53 + </div> 54 + </div> 55 + </div> 56 + ) 57 + }
+102
src/components/admin/users/user-card.tsx
··· 1 + /** 2 + * UserCard - Displays a single user row in the admin user management list. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + 'use client' 7 + 8 + import { Prohibit, WarningCircle } from '@phosphor-icons/react' 9 + import { cn } from '@/lib/utils' 10 + import type { AdminUser } from '@/lib/api/types' 11 + 12 + const ROLE_COLORS: Record<string, string> = { 13 + admin: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', 14 + moderator: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', 15 + member: 'bg-muted text-muted-foreground', 16 + } 17 + 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 + interface UserCardProps { 27 + user: AdminUser 28 + onBan: (did: string) => void 29 + onUnban: (did: string) => void 30 + } 31 + 32 + export function UserCard({ user, onBan, onUnban }: UserCardProps) { 33 + return ( 34 + <article 35 + className={cn( 36 + 'rounded-lg border border-border bg-card p-4', 37 + user.isBanned && 'border-l-4 border-l-destructive opacity-75' 38 + )} 39 + > 40 + <div className="flex items-start justify-between gap-4"> 41 + <div className="min-w-0 flex-1"> 42 + <div className="flex items-center gap-2"> 43 + <p className="text-sm font-medium text-foreground">{user.displayName ?? user.handle}</p> 44 + <span 45 + className={cn( 46 + 'rounded-full px-2 py-0.5 text-xs font-medium', 47 + ROLE_COLORS[user.role] ?? ROLE_COLORS.member 48 + )} 49 + > 50 + {user.role} 51 + </span> 52 + {user.isBanned && ( 53 + <span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive"> 54 + <Prohibit size={10} aria-hidden="true" /> 55 + Banned 56 + </span> 57 + )} 58 + </div> 59 + <p className="text-xs text-muted-foreground">@{user.handle}</p> 60 + <div className="mt-1 flex gap-4 text-xs text-muted-foreground"> 61 + <span>{user.topicCount} topics</span> 62 + <span>{user.replyCount} replies</span> 63 + <span>{user.reportCount} reports</span> 64 + <span>Joined {formatDate(user.firstSeenAt)}</span> 65 + </div> 66 + {user.bannedFromOtherCommunities > 0 && ( 67 + <p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-destructive"> 68 + <WarningCircle size={12} aria-hidden="true" /> 69 + Banned from {user.bannedFromOtherCommunities} other communities 70 + </p> 71 + )} 72 + {user.isBanned && user.banReason && ( 73 + <p className="mt-1 text-xs text-muted-foreground italic">Reason: {user.banReason}</p> 74 + )} 75 + </div> 76 + <div className="shrink-0"> 77 + {user.isBanned ? ( 78 + <button 79 + type="button" 80 + onClick={() => onUnban(user.did)} 81 + className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted" 82 + aria-label={`Unban ${user.displayName ?? user.handle}`} 83 + > 84 + Unban 85 + </button> 86 + ) : ( 87 + user.role !== 'admin' && ( 88 + <button 89 + type="button" 90 + onClick={() => onBan(user.did)} 91 + className="rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 92 + aria-label={`Ban ${user.displayName ?? user.handle}`} 93 + > 94 + Ban 95 + </button> 96 + ) 97 + )} 98 + </div> 99 + </div> 100 + </article> 101 + ) 102 + }
+102
src/components/profile/profile-header.tsx
··· 1 + /** 2 + * ProfileHeader - Displays user profile card with banner, avatar, bio, stats, and actions. 3 + * @see specs/prd-web.md Section M8 4 + */ 5 + 6 + 'use client' 7 + 8 + import Image from 'next/image' 9 + import { User, CalendarBlank, ChatCircle } from '@phosphor-icons/react' 10 + import { ReputationBadge } from '@/components/reputation-badge' 11 + import { BlockMuteButton } from '@/components/block-mute-button' 12 + import type { UserProfile } from '@/lib/api/types' 13 + 14 + interface ProfileHeaderProps { 15 + profile: UserProfile 16 + handle: string 17 + reputationScore: number 18 + postCount: number 19 + joinDate: string 20 + isBlocked: boolean 21 + isMuted: boolean 22 + onBlockToggle: (blocked: boolean) => void 23 + onMuteToggle: (muted: boolean) => void 24 + } 25 + 26 + export function ProfileHeader({ 27 + profile, 28 + handle, 29 + reputationScore, 30 + postCount, 31 + joinDate, 32 + isBlocked, 33 + isMuted, 34 + onBlockToggle, 35 + onMuteToggle, 36 + }: ProfileHeaderProps) { 37 + return ( 38 + <div className="overflow-hidden rounded-lg border border-border bg-card"> 39 + {/* Banner */} 40 + {profile.bannerUrl && ( 41 + <div className="relative h-48 overflow-hidden"> 42 + <Image src={profile.bannerUrl} alt="" fill className="object-cover" /> 43 + </div> 44 + )} 45 + 46 + <div className="p-6"> 47 + <div className="flex items-start gap-4"> 48 + {/* Avatar */} 49 + {profile.avatarUrl ? ( 50 + <Image 51 + src={profile.avatarUrl} 52 + alt={`${profile.displayName ?? profile.handle}'s avatar`} 53 + width={64} 54 + height={64} 55 + className="rounded-full object-cover" 56 + /> 57 + ) : ( 58 + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"> 59 + <User size={32} className="text-muted-foreground" aria-hidden="true" /> 60 + </div> 61 + )} 62 + 63 + <div className="min-w-0 flex-1"> 64 + <h1 className="text-2xl font-bold text-foreground">{profile.displayName ?? handle}</h1> 65 + {profile.displayName && <p className="text-lg text-muted-foreground">@{handle}</p>} 66 + 67 + {/* Bio */} 68 + {profile.bio && <p className="mt-2 text-sm text-muted-foreground">{profile.bio}</p>} 69 + 70 + <div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> 71 + <ReputationBadge score={reputationScore} /> 72 + <span className="flex items-center gap-1"> 73 + <ChatCircle size={16} aria-hidden="true" /> 74 + {postCount} {postCount === 1 ? 'post' : 'posts'} 75 + </span> 76 + <span className="flex items-center gap-1"> 77 + <CalendarBlank size={16} aria-hidden="true" /> 78 + Joined {joinDate} 79 + </span> 80 + </div> 81 + 82 + {/* Block/Mute actions */} 83 + <div className="mt-3 flex gap-2"> 84 + <BlockMuteButton 85 + targetDid={profile.did} 86 + action="block" 87 + isActive={isBlocked} 88 + onToggle={onBlockToggle} 89 + /> 90 + <BlockMuteButton 91 + targetDid={profile.did} 92 + action="mute" 93 + isActive={isMuted} 94 + onToggle={onMuteToggle} 95 + /> 96 + </div> 97 + </div> 98 + </div> 99 + </div> 100 + </div> 101 + ) 102 + }
+18
src/components/profile/profile-skeleton.tsx
··· 1 + /** 2 + * ProfileSkeleton - Loading skeleton for the user profile page. 3 + */ 4 + 5 + export function ProfileSkeleton() { 6 + return ( 7 + <div className="animate-pulse space-y-4 py-8"> 8 + <div className="h-48 rounded-t-lg bg-muted" /> 9 + <div className="flex items-start gap-4 p-6"> 10 + <div className="h-16 w-16 rounded-full bg-muted" /> 11 + <div className="space-y-2"> 12 + <div className="h-6 w-32 rounded bg-muted" /> 13 + <div className="h-4 w-48 rounded bg-muted" /> 14 + </div> 15 + </div> 16 + </div> 17 + ) 18 + }
+71
src/components/search-result-card.tsx
··· 1 + /** 2 + * SearchResultCard - Renders a single search result with type indicator. 3 + * @see specs/prd-web.md Section M9 4 + */ 5 + 6 + 'use client' 7 + 8 + import Link from 'next/link' 9 + import { ChatCircle, Article, Heart } from '@phosphor-icons/react' 10 + import type { SearchResult } from '@/lib/api/types' 11 + 12 + interface SearchResultCardProps { 13 + result: SearchResult 14 + formatDate: (dateStr: string) => string 15 + } 16 + 17 + export function SearchResultCard({ result, formatDate }: SearchResultCardProps) { 18 + const isTopic = result.type === 'topic' 19 + const href = isTopic ? `/t/${result.category ?? '-'}/${result.rkey}` : `/t/-/${result.rkey}` 20 + 21 + return ( 22 + <article className="rounded-lg border border-border bg-card p-4 transition-colors hover:bg-card-hover"> 23 + <div className="flex items-start gap-3"> 24 + <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted"> 25 + {isTopic ? ( 26 + <Article size={16} className="text-muted-foreground" aria-hidden="true" /> 27 + ) : ( 28 + <ChatCircle size={16} className="text-muted-foreground" aria-hidden="true" /> 29 + )} 30 + </div> 31 + <div className="min-w-0 flex-1"> 32 + <div className="flex items-center gap-2"> 33 + <span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground capitalize"> 34 + {result.type} 35 + </span> 36 + {result.category && ( 37 + <span className="text-xs text-muted-foreground">{result.category}</span> 38 + )} 39 + </div> 40 + 41 + <Link 42 + href={href} 43 + className="mt-1 block font-medium text-foreground hover:text-primary hover:underline" 44 + > 45 + {isTopic && result.title ? result.title : result.content.slice(0, 100)} 46 + </Link> 47 + 48 + {!isTopic && result.rootTitle && ( 49 + <p className="mt-1 text-xs text-muted-foreground"> 50 + In topic: <span className="font-medium">{result.rootTitle}</span> 51 + </p> 52 + )} 53 + 54 + <div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground"> 55 + <span>{formatDate(result.createdAt)}</span> 56 + <span className="flex items-center gap-1"> 57 + <Heart size={12} aria-hidden="true" /> 58 + {result.reactionCount} 59 + </span> 60 + {isTopic && result.replyCount !== null && ( 61 + <span className="flex items-center gap-1"> 62 + <ChatCircle size={12} aria-hidden="true" /> 63 + {result.replyCount} 64 + </span> 65 + )} 66 + </div> 67 + </div> 68 + </div> 69 + </article> 70 + ) 71 + }
+99
src/components/search-results.tsx
··· 1 + /** 2 + * SearchResults - Fetches and displays search results based on query params. 3 + * Must be wrapped in <Suspense> because it reads useSearchParams. 4 + * @see specs/prd-web.md Section M9 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState, useEffect, useCallback } from 'react' 10 + import { useSearchParams } from 'next/navigation' 11 + import { SearchResultCard } from '@/components/search-result-card' 12 + import { searchContent } from '@/lib/api/client' 13 + import type { SearchResult, SearchResponse } from '@/lib/api/types' 14 + 15 + function formatDate(dateStr: string): string { 16 + return new Date(dateStr).toLocaleDateString('en-US', { 17 + year: 'numeric', 18 + month: 'short', 19 + day: 'numeric', 20 + }) 21 + } 22 + 23 + export function SearchResults() { 24 + const searchParams = useSearchParams() 25 + const initialQuery = searchParams.get('q') ?? '' 26 + 27 + const [results, setResults] = useState<SearchResult[]>([]) 28 + const [total, setTotal] = useState<number | null>(null) 29 + const [loading, setLoading] = useState(false) 30 + const [searched, setSearched] = useState(false) 31 + 32 + const performSearch = useCallback(async (q: string) => { 33 + if (!q) { 34 + setResults([]) 35 + setTotal(null) 36 + setSearched(false) 37 + return 38 + } 39 + 40 + setLoading(true) 41 + try { 42 + const response: SearchResponse = await searchContent({ q }) 43 + setResults(response.results) 44 + setTotal(response.total) 45 + setSearched(true) 46 + } catch { 47 + setResults([]) 48 + setTotal(0) 49 + setSearched(true) 50 + } finally { 51 + setLoading(false) 52 + } 53 + }, []) 54 + 55 + useEffect(() => { 56 + if (initialQuery) { 57 + void performSearch(initialQuery) 58 + } 59 + }, [initialQuery, performSearch]) 60 + 61 + return ( 62 + <div aria-live="polite"> 63 + {loading && ( 64 + <div className="animate-pulse space-y-4 py-4"> 65 + <div className="h-16 rounded bg-muted" /> 66 + <div className="h-16 rounded bg-muted" /> 67 + </div> 68 + )} 69 + 70 + {!loading && !searched && !initialQuery && ( 71 + <p className="py-8 text-center text-muted-foreground"> 72 + Enter a search term to find topics and replies. 73 + </p> 74 + )} 75 + 76 + {!loading && searched && results.length === 0 && ( 77 + <p className="py-8 text-center text-muted-foreground"> 78 + No results found for &ldquo;{initialQuery}&rdquo;. Try a different search term. 79 + </p> 80 + )} 81 + 82 + {!loading && searched && results.length > 0 && ( 83 + <div className="space-y-4"> 84 + <p className="text-sm text-muted-foreground"> 85 + {total} result{total !== 1 ? 's' : ''} for &ldquo;{initialQuery}&rdquo; 86 + </p> 87 + 88 + <ul className="space-y-3"> 89 + {results.map((result) => ( 90 + <li key={result.uri}> 91 + <SearchResultCard result={result} formatDate={formatDate} /> 92 + </li> 93 + ))} 94 + </ul> 95 + </div> 96 + )} 97 + </div> 98 + ) 99 + }
+142
src/hooks/admin/use-moderation-data.ts
··· 1 + /** 2 + * Hook for managing moderation page state and API interactions. 3 + * @see specs/prd-web.md Section M11 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useEffect, useCallback } from 'react' 9 + import { 10 + getModerationReports, 11 + resolveReport, 12 + getFirstPostQueue, 13 + resolveFirstPost, 14 + getModerationLog, 15 + getReportedUsers, 16 + getModerationThresholds, 17 + updateModerationThresholds, 18 + } from '@/lib/api/client' 19 + import type { 20 + ModerationReport, 21 + FirstPostQueueItem, 22 + ModerationLogEntry, 23 + ReportedUser, 24 + ModerationThresholds, 25 + ReportResolution, 26 + } from '@/lib/api/types' 27 + import { useAuth } from '@/hooks/use-auth' 28 + 29 + export type ModerationTabId = 30 + | 'reports' 31 + | 'first-post' 32 + | 'action-log' 33 + | 'reported-users' 34 + | 'thresholds' 35 + 36 + export const MODERATION_TABS: { id: ModerationTabId; label: string }[] = [ 37 + { id: 'reports', label: 'Reports' }, 38 + { id: 'first-post', label: 'First Post Queue' }, 39 + { id: 'action-log', label: 'Action Log' }, 40 + { id: 'reported-users', label: 'Reported Users' }, 41 + { id: 'thresholds', label: 'Thresholds' }, 42 + ] 43 + 44 + export function useModerationData() { 45 + const { getAccessToken } = useAuth() 46 + const [activeTab, setActiveTab] = useState<ModerationTabId>('reports') 47 + const [reports, setReports] = useState<ModerationReport[]>([]) 48 + const [firstPostQueue, setFirstPostQueue] = useState<FirstPostQueueItem[]>([]) 49 + const [moderationLog, setModerationLog] = useState<ModerationLogEntry[]>([]) 50 + const [reportedUsers, setReportedUsers] = useState<ReportedUser[]>([]) 51 + const [thresholds, setThresholds] = useState<ModerationThresholds | null>(null) 52 + const [loading, setLoading] = useState(true) 53 + const [loadError, setLoadError] = useState<string | null>(null) 54 + const [actionError, setActionError] = useState<string | null>(null) 55 + 56 + const fetchData = useCallback(async () => { 57 + setLoadError(null) 58 + try { 59 + const [reportsRes, queueRes, logRes, usersRes, thresholdsRes] = await Promise.all([ 60 + getModerationReports(getAccessToken() ?? ''), 61 + getFirstPostQueue(getAccessToken() ?? ''), 62 + getModerationLog(getAccessToken() ?? ''), 63 + getReportedUsers(getAccessToken() ?? ''), 64 + getModerationThresholds(getAccessToken() ?? ''), 65 + ]) 66 + setReports(reportsRes.reports) 67 + setFirstPostQueue(queueRes.items) 68 + setModerationLog(logRes.entries) 69 + setReportedUsers(usersRes.users) 70 + setThresholds(thresholdsRes) 71 + } catch { 72 + setLoadError('Failed to load moderation data. The API may be unreachable.') 73 + } finally { 74 + setLoading(false) 75 + } 76 + }, [getAccessToken]) 77 + 78 + useEffect(() => { 79 + void fetchData() 80 + }, [fetchData]) 81 + 82 + const handleResolveReport = async (id: string, resolution: ReportResolution) => { 83 + setActionError(null) 84 + try { 85 + await resolveReport(id, resolution, getAccessToken() ?? '') 86 + setReports((prev) => prev.filter((r) => r.id !== id)) 87 + } catch { 88 + setActionError('Failed to resolve report. Please try again.') 89 + } 90 + } 91 + 92 + const handleResolveFirstPost = async (id: string, action: 'approved' | 'rejected') => { 93 + setActionError(null) 94 + try { 95 + await resolveFirstPost(id, action, getAccessToken() ?? '') 96 + setFirstPostQueue((prev) => prev.filter((item) => item.id !== id)) 97 + } catch { 98 + setActionError( 99 + `Failed to ${action === 'approved' ? 'approve' : 'reject'} post. Please try again.` 100 + ) 101 + } 102 + } 103 + 104 + const handleBatchResolveFirstPost = async (ids: string[], action: 'approved' | 'rejected') => { 105 + setActionError(null) 106 + try { 107 + await Promise.all(ids.map((id) => resolveFirstPost(id, action, getAccessToken() ?? ''))) 108 + setFirstPostQueue((prev) => prev.filter((item) => !ids.includes(item.id))) 109 + } catch { 110 + setActionError('Failed to process batch action. Some items may not have been updated.') 111 + } 112 + } 113 + 114 + const handleSaveThresholds = async (updated: Partial<ModerationThresholds>) => { 115 + setActionError(null) 116 + try { 117 + const result = await updateModerationThresholds(updated, getAccessToken() ?? '') 118 + setThresholds(result) 119 + } catch { 120 + setActionError('Failed to save thresholds. Please try again.') 121 + } 122 + } 123 + 124 + return { 125 + activeTab, 126 + setActiveTab, 127 + reports, 128 + firstPostQueue, 129 + moderationLog, 130 + reportedUsers, 131 + thresholds, 132 + loading, 133 + loadError, 134 + actionError, 135 + setActionError, 136 + fetchData, 137 + handleResolveReport, 138 + handleResolveFirstPost, 139 + handleBatchResolveFirstPost, 140 + handleSaveThresholds, 141 + } 142 + }
+158
src/hooks/admin/use-onboarding-fields.ts
··· 1 + /** 2 + * Hook for managing admin onboarding fields CRUD and reordering. 3 + */ 4 + 5 + 'use client' 6 + 7 + import { useState, useEffect, useCallback } from 'react' 8 + import { 9 + getOnboardingFields, 10 + createOnboardingField, 11 + updateOnboardingField, 12 + deleteOnboardingField, 13 + reorderOnboardingFields, 14 + } from '@/lib/api/client' 15 + import type { OnboardingField, CreateOnboardingFieldInput } from '@/lib/api/types' 16 + import { EMPTY_FIELD } from '@/components/admin/onboarding/onboarding-field-form' 17 + import type { EditingField } from '@/components/admin/onboarding/onboarding-field-form' 18 + import { useAuth } from '@/hooks/use-auth' 19 + 20 + export function useOnboardingFields() { 21 + const { getAccessToken } = useAuth() 22 + const [fields, setFields] = useState<OnboardingField[]>([]) 23 + const [loading, setLoading] = useState(true) 24 + const [editing, setEditing] = useState<EditingField | null>(null) 25 + const [saving, setSaving] = useState(false) 26 + const [error, setError] = useState<string | null>(null) 27 + const [loadError, setLoadError] = useState<string | null>(null) 28 + const [actionError, setActionError] = useState<string | null>(null) 29 + 30 + const fetchFields = useCallback(async () => { 31 + setLoadError(null) 32 + try { 33 + const response = await getOnboardingFields(getAccessToken() ?? '') 34 + setFields(response.fields) 35 + } catch { 36 + setLoadError('Failed to load onboarding fields. The API may be unreachable.') 37 + } finally { 38 + setLoading(false) 39 + } 40 + }, [getAccessToken]) 41 + 42 + useEffect(() => { 43 + void fetchFields() 44 + }, [fetchFields]) 45 + 46 + const handleAdd = () => { 47 + setEditing({ ...EMPTY_FIELD }) 48 + setError(null) 49 + } 50 + 51 + const handleEdit = (field: OnboardingField) => { 52 + setEditing({ 53 + id: field.id, 54 + fieldType: field.fieldType, 55 + label: field.label, 56 + description: field.description ?? '', 57 + isMandatory: field.isMandatory, 58 + config: field.config, 59 + }) 60 + setError(null) 61 + } 62 + 63 + const handleDelete = async (id: string) => { 64 + setActionError(null) 65 + try { 66 + await deleteOnboardingField(id, getAccessToken() ?? '') 67 + void fetchFields() 68 + } catch { 69 + setActionError('Failed to delete field. Please try again.') 70 + } 71 + } 72 + 73 + const handleSave = async () => { 74 + if (!editing) return 75 + if (!editing.label.trim()) { 76 + setError('Label is required') 77 + return 78 + } 79 + 80 + setSaving(true) 81 + setError(null) 82 + try { 83 + if (editing.id) { 84 + await updateOnboardingField( 85 + editing.id, 86 + { 87 + label: editing.label, 88 + description: editing.description || null, 89 + isMandatory: editing.isMandatory, 90 + config: editing.config, 91 + }, 92 + getAccessToken() ?? '' 93 + ) 94 + } else { 95 + const input: CreateOnboardingFieldInput = { 96 + fieldType: editing.fieldType, 97 + label: editing.label, 98 + description: editing.description || undefined, 99 + isMandatory: editing.isMandatory, 100 + sortOrder: fields.length, 101 + config: editing.config ?? undefined, 102 + } 103 + await createOnboardingField(input, getAccessToken() ?? '') 104 + } 105 + setEditing(null) 106 + void fetchFields() 107 + } catch { 108 + setError('Failed to save field') 109 + } finally { 110 + setSaving(false) 111 + } 112 + } 113 + 114 + const handleMoveUp = async (index: number) => { 115 + if (index === 0) return 116 + const newFields = [...fields] 117 + const temp = newFields[index - 1]! 118 + newFields[index - 1] = newFields[index]! 119 + newFields[index] = temp 120 + setFields(newFields) 121 + await reorderOnboardingFields( 122 + newFields.map((f, i) => ({ id: f.id, sortOrder: i })), 123 + getAccessToken() ?? '' 124 + ) 125 + } 126 + 127 + const handleMoveDown = async (index: number) => { 128 + if (index >= fields.length - 1) return 129 + const newFields = [...fields] 130 + const temp = newFields[index + 1]! 131 + newFields[index + 1] = newFields[index]! 132 + newFields[index] = temp 133 + setFields(newFields) 134 + await reorderOnboardingFields( 135 + newFields.map((f, i) => ({ id: f.id, sortOrder: i })), 136 + getAccessToken() ?? '' 137 + ) 138 + } 139 + 140 + return { 141 + fields, 142 + loading, 143 + editing, 144 + setEditing, 145 + saving, 146 + error, 147 + loadError, 148 + actionError, 149 + setActionError, 150 + fetchFields, 151 + handleAdd, 152 + handleEdit, 153 + handleDelete, 154 + handleSave, 155 + handleMoveUp, 156 + handleMoveDown, 157 + } 158 + }
+120
src/hooks/admin/use-plugin-management.ts
··· 1 + /** 2 + * Hook for managing plugin list state and API interactions. 3 + * @see specs/prd-web.md Section M13 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useEffect, useCallback } from 'react' 9 + import { getPlugins, togglePlugin, updatePluginSettings, uninstallPlugin } from '@/lib/api/client' 10 + import type { Plugin } from '@/lib/api/types' 11 + import { useAuth } from '@/hooks/use-auth' 12 + 13 + interface DependencyWarning { 14 + plugin: Plugin 15 + dependents: string[] 16 + } 17 + 18 + export function usePluginManagement() { 19 + const { getAccessToken } = useAuth() 20 + const [plugins, setPlugins] = useState<Plugin[]>([]) 21 + const [loading, setLoading] = useState(true) 22 + const [settingsPlugin, setSettingsPlugin] = useState<Plugin | null>(null) 23 + const [dependencyWarning, setDependencyWarning] = useState<DependencyWarning | null>(null) 24 + const [loadError, setLoadError] = useState<string | null>(null) 25 + const [actionError, setActionError] = useState<string | null>(null) 26 + 27 + const fetchPlugins = useCallback(async () => { 28 + setLoadError(null) 29 + try { 30 + const response = await getPlugins(getAccessToken() ?? '') 31 + setPlugins(response.plugins) 32 + } catch { 33 + setLoadError('Failed to load plugins. The API may be unreachable.') 34 + } finally { 35 + setLoading(false) 36 + } 37 + }, [getAccessToken]) 38 + 39 + useEffect(() => { 40 + void fetchPlugins() 41 + }, [fetchPlugins]) 42 + 43 + const findDependentNames = (plugin: Plugin): string[] => { 44 + return plugin.dependents.map((depId) => { 45 + const dep = plugins.find((p) => p.id === depId) 46 + return dep?.displayName ?? depId 47 + }) 48 + } 49 + 50 + const handleToggle = async (plugin: Plugin) => { 51 + if (plugin.enabled && plugin.dependents.length > 0) { 52 + const dependentNames = findDependentNames(plugin) 53 + setDependencyWarning({ plugin, dependents: dependentNames }) 54 + return 55 + } 56 + 57 + setActionError(null) 58 + try { 59 + await togglePlugin(plugin.id, !plugin.enabled, getAccessToken() ?? '') 60 + setPlugins((prev) => 61 + prev.map((p) => (p.id === plugin.id ? { ...p, enabled: !p.enabled } : p)) 62 + ) 63 + } catch { 64 + setActionError(`Failed to ${plugin.enabled ? 'disable' : 'enable'} plugin. Please try again.`) 65 + } 66 + } 67 + 68 + const confirmDisable = async () => { 69 + if (!dependencyWarning) return 70 + setActionError(null) 71 + try { 72 + await togglePlugin(dependencyWarning.plugin.id, false, getAccessToken() ?? '') 73 + setPlugins((prev) => 74 + prev.map((p) => (p.id === dependencyWarning.plugin.id ? { ...p, enabled: false } : p)) 75 + ) 76 + } catch { 77 + setActionError('Failed to disable plugin. Please try again.') 78 + } 79 + setDependencyWarning(null) 80 + } 81 + 82 + const handleSaveSettings = async (settings: Record<string, boolean | string | number>) => { 83 + if (!settingsPlugin) return 84 + setActionError(null) 85 + try { 86 + await updatePluginSettings(settingsPlugin.id, settings, getAccessToken() ?? '') 87 + setPlugins((prev) => prev.map((p) => (p.id === settingsPlugin.id ? { ...p, settings } : p))) 88 + } catch { 89 + setActionError('Failed to save plugin settings. Please try again.') 90 + } 91 + setSettingsPlugin(null) 92 + } 93 + 94 + const handleUninstall = async (plugin: Plugin) => { 95 + setActionError(null) 96 + try { 97 + await uninstallPlugin(plugin.id, getAccessToken() ?? '') 98 + setPlugins((prev) => prev.filter((p) => p.id !== plugin.id)) 99 + } catch { 100 + setActionError('Failed to uninstall plugin. Please try again.') 101 + } 102 + } 103 + 104 + return { 105 + plugins, 106 + loading, 107 + settingsPlugin, 108 + setSettingsPlugin, 109 + dependencyWarning, 110 + setDependencyWarning, 111 + loadError, 112 + actionError, 113 + setActionError, 114 + fetchPlugins, 115 + handleToggle, 116 + confirmDisable, 117 + handleSaveSettings, 118 + handleUninstall, 119 + } 120 + }
+146
src/hooks/admin/use-sybil-data.ts
··· 1 + /** 2 + * Hook for managing sybil detection page state and API interactions. 3 + * @see specs/prd-web.md Section P2.10 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useEffect, useCallback } from 'react' 9 + import { 10 + getSybilClusters, 11 + getSybilClusterDetail, 12 + updateSybilClusterStatus, 13 + getTrustGraphStatus, 14 + recomputeTrustGraph, 15 + getBehavioralFlags, 16 + updateBehavioralFlag, 17 + } from '@/lib/api/client' 18 + import type { 19 + SybilCluster, 20 + SybilClusterDetail, 21 + SybilClusterStatus, 22 + TrustGraphStatus, 23 + BehavioralFlag, 24 + } from '@/lib/api/types' 25 + import { useAuth } from '@/hooks/use-auth' 26 + 27 + export function useSybilData() { 28 + const { getAccessToken } = useAuth() 29 + const [clusters, setClusters] = useState<SybilCluster[]>([]) 30 + const [graphStatus, setGraphStatus] = useState<TrustGraphStatus | null>(null) 31 + const [flags, setFlags] = useState<BehavioralFlag[]>([]) 32 + const [selectedDetail, setSelectedDetail] = useState<SybilClusterDetail | null>(null) 33 + const [statusFilter, setStatusFilter] = useState<string>('all') 34 + const [loading, setLoading] = useState(true) 35 + const [loadError, setLoadError] = useState<string | null>(null) 36 + const [actionError, setActionError] = useState<string | null>(null) 37 + const [recomputing, setRecomputing] = useState(false) 38 + const [confirmAction, setConfirmAction] = useState<{ 39 + title: string 40 + message: string 41 + onConfirm: () => void 42 + } | null>(null) 43 + 44 + const fetchData = useCallback(async () => { 45 + setLoadError(null) 46 + setLoading(true) 47 + try { 48 + const token = getAccessToken() ?? '' 49 + const [clustersRes, statusRes, flagsRes] = await Promise.all([ 50 + getSybilClusters(token), 51 + getTrustGraphStatus(token), 52 + getBehavioralFlags(token), 53 + ]) 54 + setClusters(clustersRes.clusters) 55 + setGraphStatus(statusRes) 56 + setFlags(flagsRes.flags) 57 + } catch { 58 + setLoadError('Failed to load sybil detection data. The API may be unreachable.') 59 + } finally { 60 + setLoading(false) 61 + } 62 + }, [getAccessToken]) 63 + 64 + useEffect(() => { 65 + void fetchData() 66 + }, [fetchData]) 67 + 68 + const filteredClusters = 69 + statusFilter === 'all' ? clusters : clusters.filter((c) => c.status === statusFilter) 70 + 71 + const handleViewDetail = async (id: number) => { 72 + setActionError(null) 73 + try { 74 + const detail = await getSybilClusterDetail(id, getAccessToken() ?? '') 75 + setSelectedDetail(detail) 76 + } catch { 77 + setActionError('Failed to load cluster details.') 78 + } 79 + } 80 + 81 + const handleClusterAction = (status: SybilClusterStatus) => { 82 + if (!selectedDetail) return 83 + const actionLabel = status === 'banned' ? 'ban' : status === 'dismissed' ? 'dismiss' : status 84 + setConfirmAction({ 85 + title: `${actionLabel.charAt(0).toUpperCase() + actionLabel.slice(1)} cluster`, 86 + message: `Are you sure you want to ${actionLabel} this cluster with ${selectedDetail.memberCount} members?`, 87 + onConfirm: async () => { 88 + setConfirmAction(null) 89 + try { 90 + const updated = await updateSybilClusterStatus( 91 + selectedDetail.id, 92 + status, 93 + getAccessToken() ?? '' 94 + ) 95 + setClusters((prev) => prev.map((c) => (c.id === updated.id ? updated : c))) 96 + setSelectedDetail({ ...selectedDetail, ...updated }) 97 + } catch { 98 + setActionError('Failed to update cluster status.') 99 + } 100 + }, 101 + }) 102 + } 103 + 104 + const handleRecompute = async () => { 105 + setRecomputing(true) 106 + try { 107 + await recomputeTrustGraph(getAccessToken() ?? '') 108 + } catch { 109 + setActionError('Failed to start recomputation.') 110 + } finally { 111 + setRecomputing(false) 112 + } 113 + } 114 + 115 + const handleDismissFlag = async (id: number) => { 116 + setActionError(null) 117 + try { 118 + const updated = await updateBehavioralFlag(id, 'dismissed', getAccessToken() ?? '') 119 + setFlags((prev) => prev.map((f) => (f.id === updated.id ? updated : f))) 120 + } catch { 121 + setActionError('Failed to dismiss flag.') 122 + } 123 + } 124 + 125 + return { 126 + clusters: filteredClusters, 127 + graphStatus, 128 + flags, 129 + selectedDetail, 130 + setSelectedDetail, 131 + statusFilter, 132 + setStatusFilter, 133 + loading, 134 + loadError, 135 + actionError, 136 + setActionError, 137 + recomputing, 138 + confirmAction, 139 + setConfirmAction, 140 + fetchData, 141 + handleViewDetail, 142 + handleClusterAction, 143 + handleRecompute, 144 + handleDismissFlag, 145 + } 146 + }
+207
src/hooks/use-settings-form.ts
··· 1 + /** 2 + * Hook for managing user settings form state and API interactions. 3 + * @see specs/prd-web.md Section M8 (Settings page) 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useEffect, useCallback } from 'react' 9 + import { 10 + getPreferences, 11 + updatePreferences, 12 + getCommunityPreferences, 13 + updateCommunityPreference, 14 + } from '@/lib/api/client' 15 + import type { CommunityPreferenceOverride } from '@/lib/api/types' 16 + import { useAuth } from '@/hooks/use-auth' 17 + 18 + export type MaturityLevel = 'sfw' | 'sfw-mature' 19 + 20 + export interface SettingsValues { 21 + maturityLevel: MaturityLevel 22 + mutedWords: string 23 + crossPostBluesky: boolean 24 + crossPostFrontpage: boolean 25 + notifyReplies: boolean 26 + notifyMentions: boolean 27 + notifyReactions: boolean 28 + } 29 + 30 + export interface CommunityOverrideValues { 31 + communityDid: string 32 + communityName: string 33 + maturityLevel: 'inherit' | 'sfw' | 'mature' 34 + mutedWords: string 35 + blockedDids: string 36 + } 37 + 38 + const INITIAL_VALUES: SettingsValues = { 39 + maturityLevel: 'sfw', 40 + mutedWords: '', 41 + crossPostBluesky: true, 42 + crossPostFrontpage: false, 43 + notifyReplies: true, 44 + notifyMentions: true, 45 + notifyReactions: false, 46 + } 47 + 48 + export function useSettingsForm() { 49 + const { getAccessToken, crossPostScopesGranted, requestCrossPostAuth } = useAuth() 50 + const [values, setValues] = useState<SettingsValues>(INITIAL_VALUES) 51 + const [communityOverrides, setCommunityOverrides] = useState<CommunityOverrideValues[]>([]) 52 + const [saving, setSaving] = useState(false) 53 + const [loading, setLoading] = useState(true) 54 + const [error, setError] = useState<string | null>(null) 55 + const [success, setSuccess] = useState(false) 56 + const [declaredAge, setDeclaredAge] = useState<number | null>(null) 57 + const [showAgeGate, setShowAgeGate] = useState(false) 58 + const [showCrossPostAuthDialog, setShowCrossPostAuthDialog] = useState(false) 59 + 60 + useEffect(() => { 61 + const token = getAccessToken() 62 + if (!token) { 63 + setLoading(false) 64 + return 65 + } 66 + 67 + Promise.all([getPreferences(token), getCommunityPreferences(token)]) 68 + .then(([prefs, communityPrefs]) => { 69 + setValues({ 70 + maturityLevel: prefs.maturityLevel === 'mature' ? 'sfw-mature' : 'sfw', 71 + mutedWords: prefs.mutedWords.join(', '), 72 + crossPostBluesky: prefs.crossPostBluesky, 73 + crossPostFrontpage: prefs.crossPostFrontpage, 74 + notifyReplies: true, 75 + notifyMentions: true, 76 + notifyReactions: false, 77 + }) 78 + setDeclaredAge(prefs.declaredAge) 79 + setCommunityOverrides( 80 + communityPrefs.communities.map( 81 + (c: CommunityPreferenceOverride): CommunityOverrideValues => ({ 82 + communityDid: c.communityDid, 83 + communityName: c.communityName, 84 + maturityLevel: c.maturityLevel, 85 + mutedWords: c.mutedWords.join(', '), 86 + blockedDids: c.blockedDids.join(', '), 87 + }) 88 + ) 89 + ) 90 + }) 91 + .catch(() => setError('Failed to load preferences')) 92 + .finally(() => setLoading(false)) 93 + }, [getAccessToken]) 94 + 95 + const handleCommunityChange = useCallback( 96 + (communityDid: string, field: keyof CommunityOverrideValues, value: string) => { 97 + setCommunityOverrides((prev) => 98 + prev.map((c) => (c.communityDid === communityDid ? { ...c, [field]: value } : c)) 99 + ) 100 + }, 101 + [] 102 + ) 103 + 104 + const handleSave = useCallback( 105 + async (e: React.FormEvent) => { 106 + e.preventDefault() 107 + setSaving(true) 108 + setError(null) 109 + setSuccess(false) 110 + 111 + const token = getAccessToken() 112 + if (!token) { 113 + setError('Not authenticated') 114 + setSaving(false) 115 + return 116 + } 117 + 118 + if (values.maturityLevel === 'sfw-mature' && !declaredAge) { 119 + setShowAgeGate(true) 120 + setSaving(false) 121 + return 122 + } 123 + 124 + try { 125 + const mutedWords = values.mutedWords 126 + .split(',') 127 + .map((w) => w.trim()) 128 + .filter(Boolean) 129 + 130 + await updatePreferences( 131 + { 132 + maturityLevel: values.maturityLevel === 'sfw-mature' ? 'mature' : 'sfw', 133 + mutedWords, 134 + crossPostBluesky: values.crossPostBluesky, 135 + crossPostFrontpage: values.crossPostFrontpage, 136 + }, 137 + token 138 + ) 139 + 140 + await Promise.all( 141 + communityOverrides.map((c) => 142 + updateCommunityPreference( 143 + c.communityDid, 144 + { 145 + maturityLevel: c.maturityLevel, 146 + mutedWords: c.mutedWords 147 + .split(',') 148 + .map((w) => w.trim()) 149 + .filter(Boolean), 150 + blockedDids: c.blockedDids 151 + .split(',') 152 + .map((d) => d.trim()) 153 + .filter(Boolean), 154 + }, 155 + token 156 + ) 157 + ) 158 + ) 159 + 160 + setSuccess(true) 161 + } catch { 162 + setError('Failed to save preferences') 163 + } finally { 164 + setSaving(false) 165 + } 166 + }, 167 + [values, communityOverrides, declaredAge, getAccessToken] 168 + ) 169 + 170 + const handleAgeConfirm = useCallback( 171 + (age: number) => { 172 + setDeclaredAge(age) 173 + setShowAgeGate(false) 174 + void handleSave({ preventDefault: () => {} } as React.FormEvent) 175 + }, 176 + [handleSave] 177 + ) 178 + 179 + const handleAgeCancel = useCallback(() => { 180 + setShowAgeGate(false) 181 + setValues((prev) => ({ ...prev, maturityLevel: 'sfw' })) 182 + }, []) 183 + 184 + const handleCrossPostAuthorize = useCallback(() => { 185 + setShowCrossPostAuthDialog(false) 186 + void requestCrossPostAuth() 187 + }, [requestCrossPostAuth]) 188 + 189 + return { 190 + values, 191 + setValues, 192 + communityOverrides, 193 + saving, 194 + loading, 195 + error, 196 + success, 197 + showAgeGate, 198 + showCrossPostAuthDialog, 199 + setShowCrossPostAuthDialog, 200 + crossPostScopesGranted, 201 + handleCommunityChange, 202 + handleSave, 203 + handleAgeConfirm, 204 + handleAgeCancel, 205 + handleCrossPostAuthorize, 206 + } 207 + }