diff --git a/src/app/admin/moderation/page.tsx b/src/app/admin/moderation/page.tsx index 3cdf82e..74e8f44 100644 --- a/src/app/admin/moderation/page.tsx +++ b/src/app/admin/moderation/page.tsx @@ -7,7 +7,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' import { AdminLayout } from '@/components/admin/admin-layout' import { ErrorAlert } from '@/components/error-alert' import { ModerationReportsTab } from '@/components/admin/moderation/reports-tab' @@ -15,116 +14,28 @@ import { ModerationFirstPostTab } from '@/components/admin/moderation/first-post import { ModerationActionLogTab } from '@/components/admin/moderation/action-log-tab' import { ModerationReportedUsersTab } from '@/components/admin/moderation/reported-users-tab' import { ModerationThresholdsTab } from '@/components/admin/moderation/thresholds-tab' -import { - getModerationReports, - resolveReport, - getFirstPostQueue, - resolveFirstPost, - getModerationLog, - getReportedUsers, - getModerationThresholds, - updateModerationThresholds, -} from '@/lib/api/client' import { cn } from '@/lib/utils' -import type { - ModerationReport, - FirstPostQueueItem, - ModerationLogEntry, - ReportedUser, - ModerationThresholds, - ReportResolution, -} from '@/lib/api/types' -import { useAuth } from '@/hooks/use-auth' - -type TabId = 'reports' | 'first-post' | 'action-log' | 'reported-users' | 'thresholds' - -const TABS: { id: TabId; label: string }[] = [ - { id: 'reports', label: 'Reports' }, - { id: 'first-post', label: 'First Post Queue' }, - { id: 'action-log', label: 'Action Log' }, - { id: 'reported-users', label: 'Reported Users' }, - { id: 'thresholds', label: 'Thresholds' }, -] +import { useModerationData, MODERATION_TABS } from '@/hooks/admin/use-moderation-data' export default function AdminModerationPage() { - const { getAccessToken } = useAuth() - const [activeTab, setActiveTab] = useState('reports') - const [reports, setReports] = useState([]) - const [firstPostQueue, setFirstPostQueue] = useState([]) - const [moderationLog, setModerationLog] = useState([]) - const [reportedUsers, setReportedUsers] = useState([]) - const [thresholds, setThresholds] = useState(null) - const [loading, setLoading] = useState(true) - const [loadError, setLoadError] = useState(null) - const [actionError, setActionError] = useState(null) - - const fetchData = useCallback(async () => { - setLoadError(null) - try { - const [reportsRes, queueRes, logRes, usersRes, thresholdsRes] = await Promise.all([ - getModerationReports(getAccessToken() ?? ''), - getFirstPostQueue(getAccessToken() ?? ''), - getModerationLog(getAccessToken() ?? ''), - getReportedUsers(getAccessToken() ?? ''), - getModerationThresholds(getAccessToken() ?? ''), - ]) - setReports(reportsRes.reports) - setFirstPostQueue(queueRes.items) - setModerationLog(logRes.entries) - setReportedUsers(usersRes.users) - setThresholds(thresholdsRes) - } catch { - setLoadError('Failed to load moderation data. The API may be unreachable.') - } finally { - setLoading(false) - } - }, [getAccessToken]) - - useEffect(() => { - void fetchData() - }, [fetchData]) - - const handleResolveReport = async (id: string, resolution: ReportResolution) => { - setActionError(null) - try { - await resolveReport(id, resolution, getAccessToken() ?? '') - setReports((prev) => prev.filter((r) => r.id !== id)) - } catch { - setActionError('Failed to resolve report. Please try again.') - } - } - - const handleResolveFirstPost = async (id: string, action: 'approved' | 'rejected') => { - setActionError(null) - try { - await resolveFirstPost(id, action, getAccessToken() ?? '') - setFirstPostQueue((prev) => prev.filter((item) => item.id !== id)) - } catch { - setActionError( - `Failed to ${action === 'approved' ? 'approve' : 'reject'} post. Please try again.` - ) - } - } - - const handleBatchResolveFirstPost = async (ids: string[], action: 'approved' | 'rejected') => { - setActionError(null) - try { - await Promise.all(ids.map((id) => resolveFirstPost(id, action, getAccessToken() ?? ''))) - setFirstPostQueue((prev) => prev.filter((item) => !ids.includes(item.id))) - } catch { - setActionError('Failed to process batch action. Some items may not have been updated.') - } - } - - const handleSaveThresholds = async (updated: Partial) => { - setActionError(null) - try { - const result = await updateModerationThresholds(updated, getAccessToken() ?? '') - setThresholds(result) - } catch { - setActionError('Failed to save thresholds. Please try again.') - } - } + const { + activeTab, + setActiveTab, + reports, + firstPostQueue, + moderationLog, + reportedUsers, + thresholds, + loading, + loadError, + actionError, + setActionError, + fetchData, + handleResolveReport, + handleResolveFirstPost, + handleBatchResolveFirstPost, + handleSaveThresholds, + } = useModerationData() return ( @@ -137,7 +48,7 @@ export default function AdminModerationPage() { aria-label="Moderation sections" className="flex gap-1 border-b border-border" > - {TABS.map((tab) => ( + {MODERATION_TABS.map((tab) => ( - - - - + void confirmDisable()} + onCancel={() => setDependencyWarning(null)} + /> )} {settingsPlugin && ( diff --git a/src/app/admin/sybil-detection/page.tsx b/src/app/admin/sybil-detection/page.tsx index ed534d9..0fbc4a5 100644 --- a/src/app/admin/sybil-detection/page.tsx +++ b/src/app/admin/sybil-detection/page.tsx @@ -7,7 +7,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' import { AdminLayout } from '@/components/admin/admin-layout' import { ErrorAlert } from '@/components/error-alert' import { ConfirmDialog } from '@/components/confirm-dialog' @@ -15,121 +14,30 @@ import { TrustGraphStatusCard } from '@/components/admin/sybil/trust-graph-statu import { SybilClusterListView } from '@/components/admin/sybil/cluster-list-view' import { SybilClusterDetailView } from '@/components/admin/sybil/cluster-detail-view' import { BehavioralFlagsSection } from '@/components/admin/sybil/behavioral-flags-section' -import { - getSybilClusters, - getSybilClusterDetail, - updateSybilClusterStatus, - getTrustGraphStatus, - recomputeTrustGraph, - getBehavioralFlags, - updateBehavioralFlag, -} from '@/lib/api/client' -import type { - SybilCluster, - SybilClusterDetail, - SybilClusterStatus, - TrustGraphStatus, - BehavioralFlag, -} from '@/lib/api/types' -import { useAuth } from '@/hooks/use-auth' +import { useSybilData } from '@/hooks/admin/use-sybil-data' export default function AdminSybilDetectionPage() { - const { getAccessToken } = useAuth() - const [clusters, setClusters] = useState([]) - const [graphStatus, setGraphStatus] = useState(null) - const [flags, setFlags] = useState([]) - const [selectedDetail, setSelectedDetail] = useState(null) - const [statusFilter, setStatusFilter] = useState('all') - const [loading, setLoading] = useState(true) - const [loadError, setLoadError] = useState(null) - const [actionError, setActionError] = useState(null) - const [recomputing, setRecomputing] = useState(false) - const [confirmAction, setConfirmAction] = useState<{ - title: string - message: string - onConfirm: () => void - } | null>(null) - - const fetchData = useCallback(async () => { - setLoadError(null) - setLoading(true) - try { - const token = getAccessToken() ?? '' - const [clustersRes, statusRes, flagsRes] = await Promise.all([ - getSybilClusters(token), - getTrustGraphStatus(token), - getBehavioralFlags(token), - ]) - setClusters(clustersRes.clusters) - setGraphStatus(statusRes) - setFlags(flagsRes.flags) - } catch { - setLoadError('Failed to load sybil detection data. The API may be unreachable.') - } finally { - setLoading(false) - } - }, [getAccessToken]) - - useEffect(() => { - void fetchData() - }, [fetchData]) - - const filteredClusters = - statusFilter === 'all' ? clusters : clusters.filter((c) => c.status === statusFilter) - - const handleViewDetail = async (id: number) => { - setActionError(null) - try { - const detail = await getSybilClusterDetail(id, getAccessToken() ?? '') - setSelectedDetail(detail) - } catch { - setActionError('Failed to load cluster details.') - } - } - - const handleClusterAction = (status: SybilClusterStatus) => { - if (!selectedDetail) return - const actionLabel = status === 'banned' ? 'ban' : status === 'dismissed' ? 'dismiss' : status - setConfirmAction({ - title: `${actionLabel.charAt(0).toUpperCase() + actionLabel.slice(1)} cluster`, - message: `Are you sure you want to ${actionLabel} this cluster with ${selectedDetail.memberCount} members?`, - onConfirm: async () => { - setConfirmAction(null) - try { - const updated = await updateSybilClusterStatus( - selectedDetail.id, - status, - getAccessToken() ?? '' - ) - setClusters((prev) => prev.map((c) => (c.id === updated.id ? updated : c))) - setSelectedDetail({ ...selectedDetail, ...updated }) - } catch { - setActionError('Failed to update cluster status.') - } - }, - }) - } - - const handleRecompute = async () => { - setRecomputing(true) - try { - await recomputeTrustGraph(getAccessToken() ?? '') - } catch { - setActionError('Failed to start recomputation.') - } finally { - setRecomputing(false) - } - } - - const handleDismissFlag = async (id: number) => { - setActionError(null) - try { - const updated = await updateBehavioralFlag(id, 'dismissed', getAccessToken() ?? '') - setFlags((prev) => prev.map((f) => (f.id === updated.id ? updated : f))) - } catch { - setActionError('Failed to dismiss flag.') - } - } + const { + clusters, + graphStatus, + flags, + selectedDetail, + setSelectedDetail, + statusFilter, + setStatusFilter, + loading, + loadError, + actionError, + setActionError, + recomputing, + confirmAction, + setConfirmAction, + fetchData, + handleViewDetail, + handleClusterAction, + handleRecompute, + handleDismissFlag, + } = useSybilData() return ( @@ -193,7 +101,7 @@ export default function AdminSybilDetectionPage() { void handleViewDetail(id)} /> diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index d41d7bd..07f6488 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -8,28 +8,13 @@ 'use client' import { useState, useEffect, useCallback } from 'react' -import { Prohibit, WarningCircle } from '@phosphor-icons/react' import { AdminLayout } from '@/components/admin/admin-layout' import { ErrorAlert } from '@/components/error-alert' +import { UserCard } from '@/components/admin/users/user-card' import { getAdminUsers, banUser, unbanUser } from '@/lib/api/client' -import { cn } from '@/lib/utils' import type { AdminUser } from '@/lib/api/types' import { useAuth } from '@/hooks/use-auth' -const ROLE_COLORS: Record = { - admin: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', - moderator: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', - member: 'bg-muted text-muted-foreground', -} - -function formatDate(dateStr: string) { - return new Date(dateStr).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) -} - export default function AdminUsersPage() { const { getAccessToken } = useAuth() const [users, setUsers] = useState([]) @@ -108,78 +93,12 @@ export default function AdminUsersPage() { {!loading && users.length > 0 && (
{users.map((user) => ( -
-
-
-
-

- {user.displayName ?? user.handle} -

- - {user.role} - - {user.isBanned && ( - - - )} -
-

@{user.handle}

-
- {user.topicCount} topics - {user.replyCount} replies - {user.reportCount} reports - Joined {formatDate(user.firstSeenAt)} -
- {user.bannedFromOtherCommunities > 0 && ( -

-

- )} - {user.isBanned && user.banReason && ( -

- Reason: {user.banReason} -

- )} -
-
- {user.isBanned ? ( - - ) : ( - user.role !== 'admin' && ( - - ) - )} -
-
-
+ user={user} + onBan={(did) => void handleBan(did)} + onUnban={(did) => void handleUnban(did)} + /> ))}
)} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 84164a5..f7bb12b 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -8,15 +8,11 @@ 'use client' -import { Suspense, useState, useEffect, useCallback } from 'react' -import { useSearchParams } from 'next/navigation' -import Link from 'next/link' -import { ChatCircle, Article, Heart } from '@phosphor-icons/react' +import { Suspense } from 'react' import { ForumLayout } from '@/components/layout/forum-layout' import { Breadcrumbs } from '@/components/breadcrumbs' import { SearchInput } from '@/components/search-input' -import { searchContent } from '@/lib/api/client' -import type { SearchResult, SearchResponse } from '@/lib/api/types' +import { SearchResults } from '@/components/search-results' export default function SearchPage() { return ( @@ -46,150 +42,3 @@ export default function SearchPage() { ) } - -function SearchResults() { - const searchParams = useSearchParams() - const initialQuery = searchParams.get('q') ?? '' - - const [results, setResults] = useState([]) - const [total, setTotal] = useState(null) - const [loading, setLoading] = useState(false) - const [searched, setSearched] = useState(false) - - const performSearch = useCallback(async (q: string) => { - if (!q) { - setResults([]) - setTotal(null) - setSearched(false) - return - } - - setLoading(true) - try { - const response: SearchResponse = await searchContent({ q }) - setResults(response.results) - setTotal(response.total) - setSearched(true) - } catch { - setResults([]) - setTotal(0) - setSearched(true) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - if (initialQuery) { - void performSearch(initialQuery) - } - }, [initialQuery, performSearch]) - - const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }) - } - - return ( -
- {loading && ( -
-
-
-
- )} - - {!loading && !searched && !initialQuery && ( -

- Enter a search term to find topics and replies. -

- )} - - {!loading && searched && results.length === 0 && ( -

- No results found for “{initialQuery}”. Try a different search term. -

- )} - - {!loading && searched && results.length > 0 && ( -
-

- {total} result{total !== 1 ? 's' : ''} for “{initialQuery}” -

- -
    - {results.map((result) => ( -
  • - -
  • - ))} -
-
- )} -
- ) -} - -interface SearchResultCardProps { - result: SearchResult - formatDate: (dateStr: string) => string -} - -function SearchResultCard({ result, formatDate }: SearchResultCardProps) { - const isTopic = result.type === 'topic' - const href = isTopic ? `/t/${result.category ?? '-'}/${result.rkey}` : `/t/-/${result.rkey}` - - return ( -
-
-
- {isTopic ? ( -
-
-
- - {result.type} - - {result.category && ( - {result.category} - )} -
- - - {isTopic && result.title ? result.title : result.content.slice(0, 100)} - - - {!isTopic && result.rootTitle && ( -

- In topic: {result.rootTitle} -

- )} - -
- {formatDate(result.createdAt)} - - - {isTopic && result.replyCount !== null && ( - - - )} -
-
-
-
- ) -} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 7f8c87d..6e9d304 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -9,7 +9,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' import { ForumLayout } from '@/components/layout/forum-layout' import { Breadcrumbs } from '@/components/breadcrumbs' @@ -21,164 +20,27 @@ import { CommunityOverridesSection } from '@/components/settings/community-overr import { CrossPostingSection } from '@/components/settings/cross-posting-section' import { NotificationsSection } from '@/components/settings/notifications-section' import { cn } from '@/lib/utils' -import { - getPreferences, - updatePreferences, - getCommunityPreferences, - updateCommunityPreference, -} from '@/lib/api/client' -import type { CommunityPreferenceOverride } from '@/lib/api/types' -import { useAuth } from '@/hooks/use-auth' - -type MaturityLevel = 'sfw' | 'sfw-mature' - -interface SettingsValues { - maturityLevel: MaturityLevel - mutedWords: string - crossPostBluesky: boolean - crossPostFrontpage: boolean - notifyReplies: boolean - notifyMentions: boolean - notifyReactions: boolean -} - -interface CommunityOverrideValues { - communityDid: string - communityName: string - maturityLevel: 'inherit' | 'sfw' | 'mature' - mutedWords: string - blockedDids: string -} +import { useSettingsForm } from '@/hooks/use-settings-form' export default function SettingsPage() { - const { getAccessToken, crossPostScopesGranted, requestCrossPostAuth } = useAuth() - const [showCrossPostAuthDialog, setShowCrossPostAuthDialog] = useState(false) - const [values, setValues] = useState({ - maturityLevel: 'sfw', - mutedWords: '', - crossPostBluesky: true, - crossPostFrontpage: false, - notifyReplies: true, - notifyMentions: true, - notifyReactions: false, - }) - const [communityOverrides, setCommunityOverrides] = useState([]) - const [saving, setSaving] = useState(false) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [success, setSuccess] = useState(false) - const [declaredAge, setDeclaredAge] = useState(null) - const [showAgeGate, setShowAgeGate] = useState(false) - - useEffect(() => { - const token = getAccessToken() - if (!token) { - setLoading(false) - return - } - - Promise.all([getPreferences(token), getCommunityPreferences(token)]) - .then(([prefs, communityPrefs]) => { - setValues({ - maturityLevel: prefs.maturityLevel === 'mature' ? 'sfw-mature' : 'sfw', - mutedWords: prefs.mutedWords.join(', '), - crossPostBluesky: prefs.crossPostBluesky, - crossPostFrontpage: prefs.crossPostFrontpage, - notifyReplies: true, - notifyMentions: true, - notifyReactions: false, - }) - setDeclaredAge(prefs.declaredAge) - setCommunityOverrides( - communityPrefs.communities.map( - (c: CommunityPreferenceOverride): CommunityOverrideValues => ({ - communityDid: c.communityDid, - communityName: c.communityName, - maturityLevel: c.maturityLevel, - mutedWords: c.mutedWords.join(', '), - blockedDids: c.blockedDids.join(', '), - }) - ) - ) - }) - .catch(() => setError('Failed to load preferences')) - .finally(() => setLoading(false)) - }, [getAccessToken]) - - const handleCommunityChange = useCallback( - (communityDid: string, field: keyof CommunityOverrideValues, value: string) => { - setCommunityOverrides((prev) => - prev.map((c) => (c.communityDid === communityDid ? { ...c, [field]: value } : c)) - ) - }, - [] - ) - - const handleSave = useCallback( - async (e: React.FormEvent) => { - e.preventDefault() - setSaving(true) - setError(null) - setSuccess(false) - - const token = getAccessToken() - if (!token) { - setError('Not authenticated') - setSaving(false) - return - } - - if (values.maturityLevel === 'sfw-mature' && !declaredAge) { - setShowAgeGate(true) - setSaving(false) - return - } - - try { - const mutedWords = values.mutedWords - .split(',') - .map((w) => w.trim()) - .filter(Boolean) - - await updatePreferences( - { - maturityLevel: values.maturityLevel === 'sfw-mature' ? 'mature' : 'sfw', - mutedWords, - crossPostBluesky: values.crossPostBluesky, - crossPostFrontpage: values.crossPostFrontpage, - }, - token - ) - - await Promise.all( - communityOverrides.map((c) => - updateCommunityPreference( - c.communityDid, - { - maturityLevel: c.maturityLevel, - mutedWords: c.mutedWords - .split(',') - .map((w) => w.trim()) - .filter(Boolean), - blockedDids: c.blockedDids - .split(',') - .map((d) => d.trim()) - .filter(Boolean), - }, - token - ) - ) - ) - - setSuccess(true) - } catch { - setError('Failed to save preferences') - } finally { - setSaving(false) - } - }, - [values, communityOverrides, declaredAge, getAccessToken] - ) + const { + values, + setValues, + communityOverrides, + saving, + loading, + error, + success, + showAgeGate, + showCrossPostAuthDialog, + setShowCrossPostAuthDialog, + crossPostScopesGranted, + handleCommunityChange, + handleSave, + handleAgeConfirm, + handleAgeCancel, + handleCrossPostAuthorize, + } = useSettingsForm() return ( @@ -281,25 +143,11 @@ export default function SettingsPage() { { - setShowCrossPostAuthDialog(false) - void requestCrossPostAuth() - }} + onAuthorize={handleCrossPostAuthorize} onCancel={() => setShowCrossPostAuthDialog(false)} /> - { - setDeclaredAge(age) - setShowAgeGate(false) - void handleSave({ preventDefault: () => {} } as React.FormEvent) - }} - onCancel={() => { - setShowAgeGate(false) - setValues((prev) => ({ ...prev, maturityLevel: 'sfw' })) - }} - /> + ) } diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index 1ab6084..70abbdf 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -9,12 +9,10 @@ 'use client' import { useState, useEffect } from 'react' -import Image from 'next/image' -import { User, CalendarBlank, ChatCircle } from '@phosphor-icons/react' import { ForumLayout } from '@/components/layout/forum-layout' import { Breadcrumbs } from '@/components/breadcrumbs' -import { ReputationBadge } from '@/components/reputation-badge' -import { BlockMuteButton } from '@/components/block-mute-button' +import { ProfileHeader } from '@/components/profile/profile-header' +import { ProfileSkeleton } from '@/components/profile/profile-skeleton' import { getUserProfile } from '@/lib/api/client' import type { UserProfile } from '@/lib/api/types' @@ -85,16 +83,7 @@ export default function UserProfilePage({ params }: UserProfilePageProps) { if (!handle || loading) { return ( -
-
-
-
-
-
-
-
-
-
+ ) } @@ -135,72 +124,17 @@ export default function UserProfilePage({ params }: UserProfilePageProps) {
- {/* Profile header */} -
- {/* Banner */} - {profile.bannerUrl && ( -
- -
- )} - -
-
- {/* Avatar */} - {profile.avatarUrl ? ( - {`${profile.displayName - ) : ( -
-
- )} - -
-

- {profile.displayName ?? handle} -

- {profile.displayName &&

@{handle}

} - - {/* Bio */} - {profile.bio &&

{profile.bio}

} - -
- - - - - -
- - {/* Block/Mute actions */} -
- - -
-
-
-
-
+ {/* Recent activity */}
diff --git a/src/components/admin/plugins/dependency-warning-dialog.tsx b/src/components/admin/plugins/dependency-warning-dialog.tsx new file mode 100644 index 0000000..c4069fa --- /dev/null +++ b/src/components/admin/plugins/dependency-warning-dialog.tsx @@ -0,0 +1,57 @@ +/** + * DependencyWarningDialog - Warns when disabling a plugin that other plugins depend on. + */ + +'use client' + +import { WarningCircle } from '@phosphor-icons/react' + +interface DependencyWarningDialogProps { + pluginName: string + dependents: string[] + onConfirm: () => void + onCancel: () => void +} + +export function DependencyWarningDialog({ + pluginName, + dependents, + onConfirm, + onCancel, +}: DependencyWarningDialogProps) { + return ( +
+
+
+
+

+ Disabling {pluginName} will affect the following plugins that depend on + it: {dependents.join(', ')} +

+
+ + +
+
+
+ ) +} diff --git a/src/components/admin/users/user-card.tsx b/src/components/admin/users/user-card.tsx new file mode 100644 index 0000000..eeaa30f --- /dev/null +++ b/src/components/admin/users/user-card.tsx @@ -0,0 +1,102 @@ +/** + * UserCard - Displays a single user row in the admin user management list. + * @see specs/prd-web.md Section M11 + */ + +'use client' + +import { Prohibit, WarningCircle } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' +import type { AdminUser } from '@/lib/api/types' + +const ROLE_COLORS: Record = { + admin: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', + moderator: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + member: 'bg-muted text-muted-foreground', +} + +function formatDate(dateStr: string) { + return new Date(dateStr).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +interface UserCardProps { + user: AdminUser + onBan: (did: string) => void + onUnban: (did: string) => void +} + +export function UserCard({ user, onBan, onUnban }: UserCardProps) { + return ( +
+
+
+
+

{user.displayName ?? user.handle}

+ + {user.role} + + {user.isBanned && ( + + + )} +
+

@{user.handle}

+
+ {user.topicCount} topics + {user.replyCount} replies + {user.reportCount} reports + Joined {formatDate(user.firstSeenAt)} +
+ {user.bannedFromOtherCommunities > 0 && ( +

+

+ )} + {user.isBanned && user.banReason && ( +

Reason: {user.banReason}

+ )} +
+
+ {user.isBanned ? ( + + ) : ( + user.role !== 'admin' && ( + + ) + )} +
+
+
+ ) +} diff --git a/src/components/profile/profile-header.tsx b/src/components/profile/profile-header.tsx new file mode 100644 index 0000000..3ddaac5 --- /dev/null +++ b/src/components/profile/profile-header.tsx @@ -0,0 +1,102 @@ +/** + * ProfileHeader - Displays user profile card with banner, avatar, bio, stats, and actions. + * @see specs/prd-web.md Section M8 + */ + +'use client' + +import Image from 'next/image' +import { User, CalendarBlank, ChatCircle } from '@phosphor-icons/react' +import { ReputationBadge } from '@/components/reputation-badge' +import { BlockMuteButton } from '@/components/block-mute-button' +import type { UserProfile } from '@/lib/api/types' + +interface ProfileHeaderProps { + profile: UserProfile + handle: string + reputationScore: number + postCount: number + joinDate: string + isBlocked: boolean + isMuted: boolean + onBlockToggle: (blocked: boolean) => void + onMuteToggle: (muted: boolean) => void +} + +export function ProfileHeader({ + profile, + handle, + reputationScore, + postCount, + joinDate, + isBlocked, + isMuted, + onBlockToggle, + onMuteToggle, +}: ProfileHeaderProps) { + return ( +
+ {/* Banner */} + {profile.bannerUrl && ( +
+ +
+ )} + +
+
+ {/* Avatar */} + {profile.avatarUrl ? ( + {`${profile.displayName + ) : ( +
+
+ )} + +
+

{profile.displayName ?? handle}

+ {profile.displayName &&

@{handle}

} + + {/* Bio */} + {profile.bio &&

{profile.bio}

} + +
+ + + + + +
+ + {/* Block/Mute actions */} +
+ + +
+
+
+
+
+ ) +} diff --git a/src/components/profile/profile-skeleton.tsx b/src/components/profile/profile-skeleton.tsx new file mode 100644 index 0000000..37ce29b --- /dev/null +++ b/src/components/profile/profile-skeleton.tsx @@ -0,0 +1,18 @@ +/** + * ProfileSkeleton - Loading skeleton for the user profile page. + */ + +export function ProfileSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/src/components/search-result-card.tsx b/src/components/search-result-card.tsx new file mode 100644 index 0000000..e939742 --- /dev/null +++ b/src/components/search-result-card.tsx @@ -0,0 +1,71 @@ +/** + * SearchResultCard - Renders a single search result with type indicator. + * @see specs/prd-web.md Section M9 + */ + +'use client' + +import Link from 'next/link' +import { ChatCircle, Article, Heart } from '@phosphor-icons/react' +import type { SearchResult } from '@/lib/api/types' + +interface SearchResultCardProps { + result: SearchResult + formatDate: (dateStr: string) => string +} + +export function SearchResultCard({ result, formatDate }: SearchResultCardProps) { + const isTopic = result.type === 'topic' + const href = isTopic ? `/t/${result.category ?? '-'}/${result.rkey}` : `/t/-/${result.rkey}` + + return ( +
+
+
+ {isTopic ? ( +
+
+
+ + {result.type} + + {result.category && ( + {result.category} + )} +
+ + + {isTopic && result.title ? result.title : result.content.slice(0, 100)} + + + {!isTopic && result.rootTitle && ( +

+ In topic: {result.rootTitle} +

+ )} + +
+ {formatDate(result.createdAt)} + + + {isTopic && result.replyCount !== null && ( + + + )} +
+
+
+
+ ) +} diff --git a/src/components/search-results.tsx b/src/components/search-results.tsx new file mode 100644 index 0000000..1fe2be0 --- /dev/null +++ b/src/components/search-results.tsx @@ -0,0 +1,99 @@ +/** + * SearchResults - Fetches and displays search results based on query params. + * Must be wrapped in because it reads useSearchParams. + * @see specs/prd-web.md Section M9 + */ + +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useSearchParams } from 'next/navigation' +import { SearchResultCard } from '@/components/search-result-card' +import { searchContent } from '@/lib/api/client' +import type { SearchResult, SearchResponse } from '@/lib/api/types' + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function SearchResults() { + const searchParams = useSearchParams() + const initialQuery = searchParams.get('q') ?? '' + + const [results, setResults] = useState([]) + const [total, setTotal] = useState(null) + const [loading, setLoading] = useState(false) + const [searched, setSearched] = useState(false) + + const performSearch = useCallback(async (q: string) => { + if (!q) { + setResults([]) + setTotal(null) + setSearched(false) + return + } + + setLoading(true) + try { + const response: SearchResponse = await searchContent({ q }) + setResults(response.results) + setTotal(response.total) + setSearched(true) + } catch { + setResults([]) + setTotal(0) + setSearched(true) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (initialQuery) { + void performSearch(initialQuery) + } + }, [initialQuery, performSearch]) + + return ( +
+ {loading && ( +
+
+
+
+ )} + + {!loading && !searched && !initialQuery && ( +

+ Enter a search term to find topics and replies. +

+ )} + + {!loading && searched && results.length === 0 && ( +

+ No results found for “{initialQuery}”. Try a different search term. +

+ )} + + {!loading && searched && results.length > 0 && ( +
+

+ {total} result{total !== 1 ? 's' : ''} for “{initialQuery}” +

+ +
    + {results.map((result) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ) +} diff --git a/src/hooks/admin/use-moderation-data.ts b/src/hooks/admin/use-moderation-data.ts new file mode 100644 index 0000000..5d7e1ea --- /dev/null +++ b/src/hooks/admin/use-moderation-data.ts @@ -0,0 +1,142 @@ +/** + * Hook for managing moderation page state and API interactions. + * @see specs/prd-web.md Section M11 + */ + +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { + getModerationReports, + resolveReport, + getFirstPostQueue, + resolveFirstPost, + getModerationLog, + getReportedUsers, + getModerationThresholds, + updateModerationThresholds, +} from '@/lib/api/client' +import type { + ModerationReport, + FirstPostQueueItem, + ModerationLogEntry, + ReportedUser, + ModerationThresholds, + ReportResolution, +} from '@/lib/api/types' +import { useAuth } from '@/hooks/use-auth' + +export type ModerationTabId = + | 'reports' + | 'first-post' + | 'action-log' + | 'reported-users' + | 'thresholds' + +export const MODERATION_TABS: { id: ModerationTabId; label: string }[] = [ + { id: 'reports', label: 'Reports' }, + { id: 'first-post', label: 'First Post Queue' }, + { id: 'action-log', label: 'Action Log' }, + { id: 'reported-users', label: 'Reported Users' }, + { id: 'thresholds', label: 'Thresholds' }, +] + +export function useModerationData() { + const { getAccessToken } = useAuth() + const [activeTab, setActiveTab] = useState('reports') + const [reports, setReports] = useState([]) + const [firstPostQueue, setFirstPostQueue] = useState([]) + const [moderationLog, setModerationLog] = useState([]) + const [reportedUsers, setReportedUsers] = useState([]) + const [thresholds, setThresholds] = useState(null) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [actionError, setActionError] = useState(null) + + const fetchData = useCallback(async () => { + setLoadError(null) + try { + const [reportsRes, queueRes, logRes, usersRes, thresholdsRes] = await Promise.all([ + getModerationReports(getAccessToken() ?? ''), + getFirstPostQueue(getAccessToken() ?? ''), + getModerationLog(getAccessToken() ?? ''), + getReportedUsers(getAccessToken() ?? ''), + getModerationThresholds(getAccessToken() ?? ''), + ]) + setReports(reportsRes.reports) + setFirstPostQueue(queueRes.items) + setModerationLog(logRes.entries) + setReportedUsers(usersRes.users) + setThresholds(thresholdsRes) + } catch { + setLoadError('Failed to load moderation data. The API may be unreachable.') + } finally { + setLoading(false) + } + }, [getAccessToken]) + + useEffect(() => { + void fetchData() + }, [fetchData]) + + const handleResolveReport = async (id: string, resolution: ReportResolution) => { + setActionError(null) + try { + await resolveReport(id, resolution, getAccessToken() ?? '') + setReports((prev) => prev.filter((r) => r.id !== id)) + } catch { + setActionError('Failed to resolve report. Please try again.') + } + } + + const handleResolveFirstPost = async (id: string, action: 'approved' | 'rejected') => { + setActionError(null) + try { + await resolveFirstPost(id, action, getAccessToken() ?? '') + setFirstPostQueue((prev) => prev.filter((item) => item.id !== id)) + } catch { + setActionError( + `Failed to ${action === 'approved' ? 'approve' : 'reject'} post. Please try again.` + ) + } + } + + const handleBatchResolveFirstPost = async (ids: string[], action: 'approved' | 'rejected') => { + setActionError(null) + try { + await Promise.all(ids.map((id) => resolveFirstPost(id, action, getAccessToken() ?? ''))) + setFirstPostQueue((prev) => prev.filter((item) => !ids.includes(item.id))) + } catch { + setActionError('Failed to process batch action. Some items may not have been updated.') + } + } + + const handleSaveThresholds = async (updated: Partial) => { + setActionError(null) + try { + const result = await updateModerationThresholds(updated, getAccessToken() ?? '') + setThresholds(result) + } catch { + setActionError('Failed to save thresholds. Please try again.') + } + } + + return { + activeTab, + setActiveTab, + reports, + firstPostQueue, + moderationLog, + reportedUsers, + thresholds, + loading, + loadError, + actionError, + setActionError, + fetchData, + handleResolveReport, + handleResolveFirstPost, + handleBatchResolveFirstPost, + handleSaveThresholds, + } +} diff --git a/src/hooks/admin/use-onboarding-fields.ts b/src/hooks/admin/use-onboarding-fields.ts new file mode 100644 index 0000000..517e04a --- /dev/null +++ b/src/hooks/admin/use-onboarding-fields.ts @@ -0,0 +1,158 @@ +/** + * Hook for managing admin onboarding fields CRUD and reordering. + */ + +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { + getOnboardingFields, + createOnboardingField, + updateOnboardingField, + deleteOnboardingField, + reorderOnboardingFields, +} from '@/lib/api/client' +import type { OnboardingField, CreateOnboardingFieldInput } from '@/lib/api/types' +import { EMPTY_FIELD } from '@/components/admin/onboarding/onboarding-field-form' +import type { EditingField } from '@/components/admin/onboarding/onboarding-field-form' +import { useAuth } from '@/hooks/use-auth' + +export function useOnboardingFields() { + const { getAccessToken } = useAuth() + const [fields, setFields] = useState([]) + const [loading, setLoading] = useState(true) + const [editing, setEditing] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [loadError, setLoadError] = useState(null) + const [actionError, setActionError] = useState(null) + + const fetchFields = useCallback(async () => { + setLoadError(null) + try { + const response = await getOnboardingFields(getAccessToken() ?? '') + setFields(response.fields) + } catch { + setLoadError('Failed to load onboarding fields. The API may be unreachable.') + } finally { + setLoading(false) + } + }, [getAccessToken]) + + useEffect(() => { + void fetchFields() + }, [fetchFields]) + + const handleAdd = () => { + setEditing({ ...EMPTY_FIELD }) + setError(null) + } + + const handleEdit = (field: OnboardingField) => { + setEditing({ + id: field.id, + fieldType: field.fieldType, + label: field.label, + description: field.description ?? '', + isMandatory: field.isMandatory, + config: field.config, + }) + setError(null) + } + + const handleDelete = async (id: string) => { + setActionError(null) + try { + await deleteOnboardingField(id, getAccessToken() ?? '') + void fetchFields() + } catch { + setActionError('Failed to delete field. Please try again.') + } + } + + const handleSave = async () => { + if (!editing) return + if (!editing.label.trim()) { + setError('Label is required') + return + } + + setSaving(true) + setError(null) + try { + if (editing.id) { + await updateOnboardingField( + editing.id, + { + label: editing.label, + description: editing.description || null, + isMandatory: editing.isMandatory, + config: editing.config, + }, + getAccessToken() ?? '' + ) + } else { + const input: CreateOnboardingFieldInput = { + fieldType: editing.fieldType, + label: editing.label, + description: editing.description || undefined, + isMandatory: editing.isMandatory, + sortOrder: fields.length, + config: editing.config ?? undefined, + } + await createOnboardingField(input, getAccessToken() ?? '') + } + setEditing(null) + void fetchFields() + } catch { + setError('Failed to save field') + } finally { + setSaving(false) + } + } + + const handleMoveUp = async (index: number) => { + if (index === 0) return + const newFields = [...fields] + const temp = newFields[index - 1]! + newFields[index - 1] = newFields[index]! + newFields[index] = temp + setFields(newFields) + await reorderOnboardingFields( + newFields.map((f, i) => ({ id: f.id, sortOrder: i })), + getAccessToken() ?? '' + ) + } + + const handleMoveDown = async (index: number) => { + if (index >= fields.length - 1) return + const newFields = [...fields] + const temp = newFields[index + 1]! + newFields[index + 1] = newFields[index]! + newFields[index] = temp + setFields(newFields) + await reorderOnboardingFields( + newFields.map((f, i) => ({ id: f.id, sortOrder: i })), + getAccessToken() ?? '' + ) + } + + return { + fields, + loading, + editing, + setEditing, + saving, + error, + loadError, + actionError, + setActionError, + fetchFields, + handleAdd, + handleEdit, + handleDelete, + handleSave, + handleMoveUp, + handleMoveDown, + } +} diff --git a/src/hooks/admin/use-plugin-management.ts b/src/hooks/admin/use-plugin-management.ts new file mode 100644 index 0000000..64c02d8 --- /dev/null +++ b/src/hooks/admin/use-plugin-management.ts @@ -0,0 +1,120 @@ +/** + * Hook for managing plugin list state and API interactions. + * @see specs/prd-web.md Section M13 + */ + +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { getPlugins, togglePlugin, updatePluginSettings, uninstallPlugin } from '@/lib/api/client' +import type { Plugin } from '@/lib/api/types' +import { useAuth } from '@/hooks/use-auth' + +interface DependencyWarning { + plugin: Plugin + dependents: string[] +} + +export function usePluginManagement() { + const { getAccessToken } = useAuth() + const [plugins, setPlugins] = useState([]) + const [loading, setLoading] = useState(true) + const [settingsPlugin, setSettingsPlugin] = useState(null) + const [dependencyWarning, setDependencyWarning] = useState(null) + const [loadError, setLoadError] = useState(null) + const [actionError, setActionError] = useState(null) + + const fetchPlugins = useCallback(async () => { + setLoadError(null) + try { + const response = await getPlugins(getAccessToken() ?? '') + setPlugins(response.plugins) + } catch { + setLoadError('Failed to load plugins. The API may be unreachable.') + } finally { + setLoading(false) + } + }, [getAccessToken]) + + useEffect(() => { + void fetchPlugins() + }, [fetchPlugins]) + + const findDependentNames = (plugin: Plugin): string[] => { + return plugin.dependents.map((depId) => { + const dep = plugins.find((p) => p.id === depId) + return dep?.displayName ?? depId + }) + } + + const handleToggle = async (plugin: Plugin) => { + if (plugin.enabled && plugin.dependents.length > 0) { + const dependentNames = findDependentNames(plugin) + setDependencyWarning({ plugin, dependents: dependentNames }) + return + } + + setActionError(null) + try { + await togglePlugin(plugin.id, !plugin.enabled, getAccessToken() ?? '') + setPlugins((prev) => + prev.map((p) => (p.id === plugin.id ? { ...p, enabled: !p.enabled } : p)) + ) + } catch { + setActionError(`Failed to ${plugin.enabled ? 'disable' : 'enable'} plugin. Please try again.`) + } + } + + const confirmDisable = async () => { + if (!dependencyWarning) return + setActionError(null) + try { + await togglePlugin(dependencyWarning.plugin.id, false, getAccessToken() ?? '') + setPlugins((prev) => + prev.map((p) => (p.id === dependencyWarning.plugin.id ? { ...p, enabled: false } : p)) + ) + } catch { + setActionError('Failed to disable plugin. Please try again.') + } + setDependencyWarning(null) + } + + const handleSaveSettings = async (settings: Record) => { + if (!settingsPlugin) return + setActionError(null) + try { + await updatePluginSettings(settingsPlugin.id, settings, getAccessToken() ?? '') + setPlugins((prev) => prev.map((p) => (p.id === settingsPlugin.id ? { ...p, settings } : p))) + } catch { + setActionError('Failed to save plugin settings. Please try again.') + } + setSettingsPlugin(null) + } + + const handleUninstall = async (plugin: Plugin) => { + setActionError(null) + try { + await uninstallPlugin(plugin.id, getAccessToken() ?? '') + setPlugins((prev) => prev.filter((p) => p.id !== plugin.id)) + } catch { + setActionError('Failed to uninstall plugin. Please try again.') + } + } + + return { + plugins, + loading, + settingsPlugin, + setSettingsPlugin, + dependencyWarning, + setDependencyWarning, + loadError, + actionError, + setActionError, + fetchPlugins, + handleToggle, + confirmDisable, + handleSaveSettings, + handleUninstall, + } +} diff --git a/src/hooks/admin/use-sybil-data.ts b/src/hooks/admin/use-sybil-data.ts new file mode 100644 index 0000000..0305b5d --- /dev/null +++ b/src/hooks/admin/use-sybil-data.ts @@ -0,0 +1,146 @@ +/** + * Hook for managing sybil detection page state and API interactions. + * @see specs/prd-web.md Section P2.10 + */ + +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { + getSybilClusters, + getSybilClusterDetail, + updateSybilClusterStatus, + getTrustGraphStatus, + recomputeTrustGraph, + getBehavioralFlags, + updateBehavioralFlag, +} from '@/lib/api/client' +import type { + SybilCluster, + SybilClusterDetail, + SybilClusterStatus, + TrustGraphStatus, + BehavioralFlag, +} from '@/lib/api/types' +import { useAuth } from '@/hooks/use-auth' + +export function useSybilData() { + const { getAccessToken } = useAuth() + const [clusters, setClusters] = useState([]) + const [graphStatus, setGraphStatus] = useState(null) + const [flags, setFlags] = useState([]) + const [selectedDetail, setSelectedDetail] = useState(null) + const [statusFilter, setStatusFilter] = useState('all') + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [actionError, setActionError] = useState(null) + const [recomputing, setRecomputing] = useState(false) + const [confirmAction, setConfirmAction] = useState<{ + title: string + message: string + onConfirm: () => void + } | null>(null) + + const fetchData = useCallback(async () => { + setLoadError(null) + setLoading(true) + try { + const token = getAccessToken() ?? '' + const [clustersRes, statusRes, flagsRes] = await Promise.all([ + getSybilClusters(token), + getTrustGraphStatus(token), + getBehavioralFlags(token), + ]) + setClusters(clustersRes.clusters) + setGraphStatus(statusRes) + setFlags(flagsRes.flags) + } catch { + setLoadError('Failed to load sybil detection data. The API may be unreachable.') + } finally { + setLoading(false) + } + }, [getAccessToken]) + + useEffect(() => { + void fetchData() + }, [fetchData]) + + const filteredClusters = + statusFilter === 'all' ? clusters : clusters.filter((c) => c.status === statusFilter) + + const handleViewDetail = async (id: number) => { + setActionError(null) + try { + const detail = await getSybilClusterDetail(id, getAccessToken() ?? '') + setSelectedDetail(detail) + } catch { + setActionError('Failed to load cluster details.') + } + } + + const handleClusterAction = (status: SybilClusterStatus) => { + if (!selectedDetail) return + const actionLabel = status === 'banned' ? 'ban' : status === 'dismissed' ? 'dismiss' : status + setConfirmAction({ + title: `${actionLabel.charAt(0).toUpperCase() + actionLabel.slice(1)} cluster`, + message: `Are you sure you want to ${actionLabel} this cluster with ${selectedDetail.memberCount} members?`, + onConfirm: async () => { + setConfirmAction(null) + try { + const updated = await updateSybilClusterStatus( + selectedDetail.id, + status, + getAccessToken() ?? '' + ) + setClusters((prev) => prev.map((c) => (c.id === updated.id ? updated : c))) + setSelectedDetail({ ...selectedDetail, ...updated }) + } catch { + setActionError('Failed to update cluster status.') + } + }, + }) + } + + const handleRecompute = async () => { + setRecomputing(true) + try { + await recomputeTrustGraph(getAccessToken() ?? '') + } catch { + setActionError('Failed to start recomputation.') + } finally { + setRecomputing(false) + } + } + + const handleDismissFlag = async (id: number) => { + setActionError(null) + try { + const updated = await updateBehavioralFlag(id, 'dismissed', getAccessToken() ?? '') + setFlags((prev) => prev.map((f) => (f.id === updated.id ? updated : f))) + } catch { + setActionError('Failed to dismiss flag.') + } + } + + return { + clusters: filteredClusters, + graphStatus, + flags, + selectedDetail, + setSelectedDetail, + statusFilter, + setStatusFilter, + loading, + loadError, + actionError, + setActionError, + recomputing, + confirmAction, + setConfirmAction, + fetchData, + handleViewDetail, + handleClusterAction, + handleRecompute, + handleDismissFlag, + } +} diff --git a/src/hooks/use-settings-form.ts b/src/hooks/use-settings-form.ts new file mode 100644 index 0000000..a708ecd --- /dev/null +++ b/src/hooks/use-settings-form.ts @@ -0,0 +1,207 @@ +/** + * Hook for managing user settings form state and API interactions. + * @see specs/prd-web.md Section M8 (Settings page) + */ + +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { + getPreferences, + updatePreferences, + getCommunityPreferences, + updateCommunityPreference, +} from '@/lib/api/client' +import type { CommunityPreferenceOverride } from '@/lib/api/types' +import { useAuth } from '@/hooks/use-auth' + +export type MaturityLevel = 'sfw' | 'sfw-mature' + +export interface SettingsValues { + maturityLevel: MaturityLevel + mutedWords: string + crossPostBluesky: boolean + crossPostFrontpage: boolean + notifyReplies: boolean + notifyMentions: boolean + notifyReactions: boolean +} + +export interface CommunityOverrideValues { + communityDid: string + communityName: string + maturityLevel: 'inherit' | 'sfw' | 'mature' + mutedWords: string + blockedDids: string +} + +const INITIAL_VALUES: SettingsValues = { + maturityLevel: 'sfw', + mutedWords: '', + crossPostBluesky: true, + crossPostFrontpage: false, + notifyReplies: true, + notifyMentions: true, + notifyReactions: false, +} + +export function useSettingsForm() { + const { getAccessToken, crossPostScopesGranted, requestCrossPostAuth } = useAuth() + const [values, setValues] = useState(INITIAL_VALUES) + const [communityOverrides, setCommunityOverrides] = useState([]) + const [saving, setSaving] = useState(false) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [declaredAge, setDeclaredAge] = useState(null) + const [showAgeGate, setShowAgeGate] = useState(false) + const [showCrossPostAuthDialog, setShowCrossPostAuthDialog] = useState(false) + + useEffect(() => { + const token = getAccessToken() + if (!token) { + setLoading(false) + return + } + + Promise.all([getPreferences(token), getCommunityPreferences(token)]) + .then(([prefs, communityPrefs]) => { + setValues({ + maturityLevel: prefs.maturityLevel === 'mature' ? 'sfw-mature' : 'sfw', + mutedWords: prefs.mutedWords.join(', '), + crossPostBluesky: prefs.crossPostBluesky, + crossPostFrontpage: prefs.crossPostFrontpage, + notifyReplies: true, + notifyMentions: true, + notifyReactions: false, + }) + setDeclaredAge(prefs.declaredAge) + setCommunityOverrides( + communityPrefs.communities.map( + (c: CommunityPreferenceOverride): CommunityOverrideValues => ({ + communityDid: c.communityDid, + communityName: c.communityName, + maturityLevel: c.maturityLevel, + mutedWords: c.mutedWords.join(', '), + blockedDids: c.blockedDids.join(', '), + }) + ) + ) + }) + .catch(() => setError('Failed to load preferences')) + .finally(() => setLoading(false)) + }, [getAccessToken]) + + const handleCommunityChange = useCallback( + (communityDid: string, field: keyof CommunityOverrideValues, value: string) => { + setCommunityOverrides((prev) => + prev.map((c) => (c.communityDid === communityDid ? { ...c, [field]: value } : c)) + ) + }, + [] + ) + + const handleSave = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + setSaving(true) + setError(null) + setSuccess(false) + + const token = getAccessToken() + if (!token) { + setError('Not authenticated') + setSaving(false) + return + } + + if (values.maturityLevel === 'sfw-mature' && !declaredAge) { + setShowAgeGate(true) + setSaving(false) + return + } + + try { + const mutedWords = values.mutedWords + .split(',') + .map((w) => w.trim()) + .filter(Boolean) + + await updatePreferences( + { + maturityLevel: values.maturityLevel === 'sfw-mature' ? 'mature' : 'sfw', + mutedWords, + crossPostBluesky: values.crossPostBluesky, + crossPostFrontpage: values.crossPostFrontpage, + }, + token + ) + + await Promise.all( + communityOverrides.map((c) => + updateCommunityPreference( + c.communityDid, + { + maturityLevel: c.maturityLevel, + mutedWords: c.mutedWords + .split(',') + .map((w) => w.trim()) + .filter(Boolean), + blockedDids: c.blockedDids + .split(',') + .map((d) => d.trim()) + .filter(Boolean), + }, + token + ) + ) + ) + + setSuccess(true) + } catch { + setError('Failed to save preferences') + } finally { + setSaving(false) + } + }, + [values, communityOverrides, declaredAge, getAccessToken] + ) + + const handleAgeConfirm = useCallback( + (age: number) => { + setDeclaredAge(age) + setShowAgeGate(false) + void handleSave({ preventDefault: () => {} } as React.FormEvent) + }, + [handleSave] + ) + + const handleAgeCancel = useCallback(() => { + setShowAgeGate(false) + setValues((prev) => ({ ...prev, maturityLevel: 'sfw' })) + }, []) + + const handleCrossPostAuthorize = useCallback(() => { + setShowCrossPostAuthDialog(false) + void requestCrossPostAuth() + }, [requestCrossPostAuth]) + + return { + values, + setValues, + communityOverrides, + saving, + loading, + error, + success, + showAgeGate, + showCrossPostAuthDialog, + setShowCrossPostAuthDialog, + crossPostScopesGranted, + handleCommunityChange, + handleSave, + handleAgeConfirm, + handleAgeCancel, + handleCrossPostAuthorize, + } +}