Barazo default frontend barazo.forum
2
fork

Configure Feed

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

fix(ui): replace silent error handling with visible error states (#29)

Replace all 26 instances of silent error handling (catch { // Silently handle })
across admin and frontend pages with proper error display using a new ErrorAlert
component. Pages now show error messages when the API is unreachable instead of
empty content.

Also fixes .env.example to use correct API port (3100 instead of 3000).

authored by

Guido X Jansen and committed by
GitHub
d05561e5 978847ef

+240 -45
+18
.env.example
··· 1 + # Barazo Web Environment Configuration 2 + # Copy this file to .env.local and fill in your values 3 + 4 + # API Configuration 5 + NEXT_PUBLIC_API_URL=http://localhost:3100 6 + 7 + # Application 8 + NEXT_PUBLIC_APP_NAME=Barazo 9 + NEXT_PUBLIC_APP_URL=http://localhost:3001 10 + 11 + # OAuth / AT Protocol 12 + NEXT_PUBLIC_OAUTH_CLIENT_ID=barazo-web 13 + 14 + # Optional: Analytics (Phase 3+) 15 + # NEXT_PUBLIC_UMAMI_WEBSITE_ID= 16 + 17 + # Optional: Sentry/GlitchTip (Phase 1+) 18 + # SENTRY_DSN=
+1
.gitignore
··· 33 33 34 34 # env files (can opt-in for committing if needed) 35 35 .env* 36 + !.env.example 36 37 37 38 # vercel 38 39 .vercel
+14 -3
src/app/admin/categories/page.tsx
··· 10 10 import { useState, useEffect, useCallback } from 'react' 11 11 import { PencilSimple, Plus, TrashSimple } from '@phosphor-icons/react' 12 12 import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { ErrorAlert } from '@/components/error-alert' 13 14 import { getCategories, createCategory, updateCategory, deleteCategory } from '@/lib/api/client' 14 15 import { cn } from '@/lib/utils' 15 16 import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types' ··· 109 110 const [categories, setCategories] = useState<CategoryTreeNode[]>([]) 110 111 const [loading, setLoading] = useState(true) 111 112 const [editing, setEditing] = useState<EditingCategory | null>(null) 113 + const [loadError, setLoadError] = useState<string | null>(null) 114 + const [actionError, setActionError] = useState<string | null>(null) 112 115 113 116 const fetchCategories = useCallback(async () => { 117 + setLoadError(null) 114 118 try { 115 119 const response = await getCategories() 116 120 setCategories(response.categories) 117 121 } catch { 118 - // Silently handle 122 + setLoadError('Failed to load categories. The API may be unreachable.') 119 123 } finally { 120 124 setLoading(false) 121 125 } ··· 148 152 } 149 153 150 154 const handleDelete = async (id: string) => { 155 + setActionError(null) 151 156 try { 152 157 await deleteCategory(id, getAccessToken() ?? '') 153 158 void fetchCategories() 154 159 } catch { 155 - // Silently handle 160 + setActionError('Failed to delete category. Please try again.') 156 161 } 157 162 } 158 163 ··· 188 193 setEditing(null) 189 194 void fetchCategories() 190 195 } catch { 191 - // Silently handle 196 + setActionError('Failed to save category. Please try again.') 192 197 } 193 198 } 194 199 ··· 206 211 Add Category 207 212 </button> 208 213 </div> 214 + 215 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 209 216 210 217 {/* Edit form */} 211 218 {editing && ( ··· 288 295 )} 289 296 290 297 {/* Category list */} 298 + {loadError && ( 299 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchCategories()} /> 300 + )} 301 + 291 302 {loading && <p className="text-sm text-muted-foreground">Loading categories...</p>} 292 303 293 304 {!loading && categories.length === 0 && (
+6 -1
src/app/admin/content-ratings/page.tsx
··· 9 9 10 10 import { useState, useEffect, useCallback } from 'react' 11 11 import { AdminLayout } from '@/components/admin/admin-layout' 12 + import { ErrorAlert } from '@/components/error-alert' 12 13 import { getCategories, getCommunitySettings } from '@/lib/api/client' 13 14 import { cn } from '@/lib/utils' 14 15 import type { CategoryTreeNode, CommunitySettings, MaturityRating } from '@/lib/api/types' ··· 54 55 const [categories, setCategories] = useState<CategoryTreeNode[]>([]) 55 56 const [communitySettings, setCommunitySettings] = useState<CommunitySettings | null>(null) 56 57 const [loading, setLoading] = useState(true) 58 + const [error, setError] = useState<string | null>(null) 57 59 58 60 const fetchData = useCallback(async () => { 61 + setError(null) 59 62 try { 60 63 const [catRes, settingsRes] = await Promise.all([getCategories(), getCommunitySettings()]) 61 64 setCategories(catRes.categories) 62 65 setCommunitySettings(settingsRes) 63 66 } catch { 64 - // Silently handle 67 + setError('Failed to load content ratings. The API may be unreachable.') 65 68 } finally { 66 69 setLoading(false) 67 70 } ··· 99 102 )} 100 103 </dl> 101 104 </div> 105 + 106 + {error && <ErrorAlert message={error} variant="page" onRetry={() => void fetchData()} />} 102 107 103 108 {loading && <p className="text-sm text-muted-foreground">Loading...</p>} 104 109
+21 -5
src/app/admin/moderation/page.tsx
··· 10 10 import { useState, useEffect, useCallback } from 'react' 11 11 import { WarningCircle, ShieldCheck, Clock, Prohibit } from '@phosphor-icons/react' 12 12 import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { ErrorAlert } from '@/components/error-alert' 13 14 import { 14 15 getModerationReports, 15 16 resolveReport, ··· 502 503 const [reportedUsers, setReportedUsers] = useState<ReportedUser[]>([]) 503 504 const [thresholds, setThresholds] = useState<ModerationThresholds | null>(null) 504 505 const [loading, setLoading] = useState(true) 506 + const [loadError, setLoadError] = useState<string | null>(null) 507 + const [actionError, setActionError] = useState<string | null>(null) 505 508 506 509 const fetchData = useCallback(async () => { 510 + setLoadError(null) 507 511 try { 508 512 const [reportsRes, queueRes, logRes, usersRes, thresholdsRes] = await Promise.all([ 509 513 getModerationReports(getAccessToken() ?? ''), ··· 518 522 setReportedUsers(usersRes.users) 519 523 setThresholds(thresholdsRes) 520 524 } catch { 521 - // Silently handle 525 + setLoadError('Failed to load moderation data. The API may be unreachable.') 522 526 } finally { 523 527 setLoading(false) 524 528 } ··· 529 533 }, [fetchData]) 530 534 531 535 const handleResolveReport = async (id: string, resolution: ReportResolution) => { 536 + setActionError(null) 532 537 try { 533 538 await resolveReport(id, resolution, getAccessToken() ?? '') 534 539 setReports((prev) => prev.filter((r) => r.id !== id)) 535 540 } catch { 536 - // Silently handle 541 + setActionError('Failed to resolve report. Please try again.') 537 542 } 538 543 } 539 544 540 545 const handleResolveFirstPost = async (id: string, action: 'approved' | 'rejected') => { 546 + setActionError(null) 541 547 try { 542 548 await resolveFirstPost(id, action, getAccessToken() ?? '') 543 549 setFirstPostQueue((prev) => prev.filter((item) => item.id !== id)) 544 550 } catch { 545 - // Silently handle 551 + setActionError( 552 + `Failed to ${action === 'approved' ? 'approve' : 'reject'} post. Please try again.` 553 + ) 546 554 } 547 555 } 548 556 549 557 const handleBatchResolveFirstPost = async (ids: string[], action: 'approved' | 'rejected') => { 558 + setActionError(null) 550 559 try { 551 560 await Promise.all(ids.map((id) => resolveFirstPost(id, action, getAccessToken() ?? ''))) 552 561 setFirstPostQueue((prev) => prev.filter((item) => !ids.includes(item.id))) 553 562 } catch { 554 - // Silently handle 563 + setActionError('Failed to process batch action. Some items may not have been updated.') 555 564 } 556 565 } 557 566 558 567 const handleSaveThresholds = async (updated: Partial<ModerationThresholds>) => { 568 + setActionError(null) 559 569 try { 560 570 const result = await updateModerationThresholds(updated, getAccessToken() ?? '') 561 571 setThresholds(result) 562 572 } catch { 563 - // Silently handle 573 + setActionError('Failed to save thresholds. Please try again.') 564 574 } 565 575 } 566 576 ··· 595 605 </button> 596 606 ))} 597 607 </div> 608 + 609 + {loadError && ( 610 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchData()} /> 611 + )} 612 + 613 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 598 614 599 615 {loading && <p className="text-sm text-muted-foreground">Loading...</p>} 600 616
+13 -2
src/app/admin/onboarding/page.tsx
··· 9 9 import { useState, useEffect, useCallback } from 'react' 10 10 import { Plus, PencilSimple, TrashSimple, ArrowUp, ArrowDown } from '@phosphor-icons/react' 11 11 import { AdminLayout } from '@/components/admin/admin-layout' 12 + import { ErrorAlert } from '@/components/error-alert' 12 13 import { 13 14 getOnboardingFields, 14 15 createOnboardingField, ··· 58 59 const [editing, setEditing] = useState<EditingField | null>(null) 59 60 const [saving, setSaving] = useState(false) 60 61 const [error, setError] = useState<string | null>(null) 62 + const [loadError, setLoadError] = useState<string | null>(null) 63 + const [actionError, setActionError] = useState<string | null>(null) 61 64 62 65 const fetchFields = useCallback(async () => { 66 + setLoadError(null) 63 67 try { 64 68 const response = await getOnboardingFields(getAccessToken() ?? '') 65 69 setFields(response.fields) 66 70 } catch { 67 - // Silently handle 71 + setLoadError('Failed to load onboarding fields. The API may be unreachable.') 68 72 } finally { 69 73 setLoading(false) 70 74 } ··· 92 96 } 93 97 94 98 const handleDelete = async (id: string) => { 99 + setActionError(null) 95 100 try { 96 101 await deleteOnboardingField(id, getAccessToken() ?? '') 97 102 void fetchFields() 98 103 } catch { 99 - // Silently handle 104 + setActionError('Failed to delete field. Please try again.') 100 105 } 101 106 } 102 107 ··· 186 191 Add Field 187 192 </button> 188 193 </div> 194 + 195 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 189 196 190 197 {/* Edit/Create form */} 191 198 {editing && ( ··· 283 290 )} 284 291 285 292 {/* Field list */} 293 + {loadError && ( 294 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchFields()} /> 295 + )} 296 + 286 297 {loading && <p className="text-sm text-muted-foreground">Loading onboarding fields...</p>} 287 298 288 299 {!loading && fields.length === 0 && !editing && (
+6 -1
src/app/admin/page.tsx
··· 10 10 import { useState, useEffect, useCallback } from 'react' 11 11 import { ChatCircle, Users, FolderSimple, WarningCircle, TrendUp } from '@phosphor-icons/react' 12 12 import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { ErrorAlert } from '@/components/error-alert' 13 14 import { getCommunityStats } from '@/lib/api/client' 14 15 import type { CommunityStats } from '@/lib/api/types' 15 16 import { useAuth } from '@/hooks/use-auth' ··· 45 46 const { getAccessToken } = useAuth() 46 47 const [stats, setStats] = useState<CommunityStats | null>(null) 47 48 const [loading, setLoading] = useState(true) 49 + const [error, setError] = useState<string | null>(null) 48 50 49 51 const fetchStats = useCallback(async () => { 52 + setError(null) 50 53 try { 51 54 const data = await getCommunityStats(getAccessToken() ?? '') 52 55 setStats(data) 53 56 } catch { 54 - // Silently handle 57 + setError('Failed to load dashboard statistics. The API may be unreachable.') 55 58 } finally { 56 59 setLoading(false) 57 60 } ··· 67 70 <h1 className="text-2xl font-bold text-foreground">Dashboard</h1> 68 71 69 72 {loading && <p className="text-sm text-muted-foreground">Loading statistics...</p>} 73 + 74 + {error && <ErrorAlert message={error} variant="page" onRetry={() => void fetchStats()} />} 70 75 71 76 {stats && ( 72 77 <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
+19 -5
src/app/admin/plugins/page.tsx
··· 11 11 import { useState, useEffect, useCallback } from 'react' 12 12 import { Gear, Trash, WarningCircle, X } from '@phosphor-icons/react' 13 13 import { AdminLayout } from '@/components/admin/admin-layout' 14 + import { ErrorAlert } from '@/components/error-alert' 14 15 import { getPlugins, togglePlugin, updatePluginSettings, uninstallPlugin } from '@/lib/api/client' 15 16 import { cn } from '@/lib/utils' 16 17 import type { Plugin, PluginSettingsSchema } from '@/lib/api/types' ··· 199 200 plugin: Plugin 200 201 dependents: string[] 201 202 } | null>(null) 203 + const [loadError, setLoadError] = useState<string | null>(null) 204 + const [actionError, setActionError] = useState<string | null>(null) 202 205 203 206 const fetchPlugins = useCallback(async () => { 207 + setLoadError(null) 204 208 try { 205 209 const response = await getPlugins(getAccessToken() ?? '') 206 210 setPlugins(response.plugins) 207 211 } catch { 208 - // Silently handle 212 + setLoadError('Failed to load plugins. The API may be unreachable.') 209 213 } finally { 210 214 setLoading(false) 211 215 } ··· 229 233 return 230 234 } 231 235 236 + setActionError(null) 232 237 try { 233 238 await togglePlugin(plugin.id, !plugin.enabled, getAccessToken() ?? '') 234 239 setPlugins((prev) => 235 240 prev.map((p) => (p.id === plugin.id ? { ...p, enabled: !p.enabled } : p)) 236 241 ) 237 242 } catch { 238 - // Silently handle 243 + setActionError(`Failed to ${plugin.enabled ? 'disable' : 'enable'} plugin. Please try again.`) 239 244 } 240 245 } 241 246 242 247 const confirmDisable = async () => { 243 248 if (!dependencyWarning) return 249 + setActionError(null) 244 250 try { 245 251 await togglePlugin(dependencyWarning.plugin.id, false, getAccessToken() ?? '') 246 252 setPlugins((prev) => 247 253 prev.map((p) => (p.id === dependencyWarning.plugin.id ? { ...p, enabled: false } : p)) 248 254 ) 249 255 } catch { 250 - // Silently handle 256 + setActionError('Failed to disable plugin. Please try again.') 251 257 } 252 258 setDependencyWarning(null) 253 259 } 254 260 255 261 const handleSaveSettings = async (settings: Record<string, boolean | string | number>) => { 256 262 if (!settingsPlugin) return 263 + setActionError(null) 257 264 try { 258 265 await updatePluginSettings(settingsPlugin.id, settings, getAccessToken() ?? '') 259 266 setPlugins((prev) => prev.map((p) => (p.id === settingsPlugin.id ? { ...p, settings } : p))) 260 267 } catch { 261 - // Silently handle 268 + setActionError('Failed to save plugin settings. Please try again.') 262 269 } 263 270 setSettingsPlugin(null) 264 271 } 265 272 266 273 const handleUninstall = async (plugin: Plugin) => { 274 + setActionError(null) 267 275 try { 268 276 await uninstallPlugin(plugin.id, getAccessToken() ?? '') 269 277 setPlugins((prev) => prev.filter((p) => p.id !== plugin.id)) 270 278 } catch { 271 - // Silently handle 279 + setActionError('Failed to uninstall plugin. Please try again.') 272 280 } 273 281 } 274 282 ··· 281 289 {plugins.filter((p) => p.enabled).length} of {plugins.length} enabled 282 290 </p> 283 291 </div> 292 + 293 + {loadError && ( 294 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchPlugins()} /> 295 + )} 296 + 297 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 284 298 285 299 {loading && <p className="text-sm text-muted-foreground">Loading plugins...</p>} 286 300
+13 -2
src/app/admin/settings/page.tsx
··· 9 9 10 10 import { useState, useEffect, useCallback } from 'react' 11 11 import { AdminLayout } from '@/components/admin/admin-layout' 12 + import { ErrorAlert } from '@/components/error-alert' 12 13 import { getCommunitySettings, updateCommunitySettings } from '@/lib/api/client' 13 14 import type { CommunitySettings, MaturityRating } from '@/lib/api/types' 14 15 import { useAuth } from '@/hooks/use-auth' ··· 18 19 const [settings, setSettings] = useState<CommunitySettings | null>(null) 19 20 const [loading, setLoading] = useState(true) 20 21 const [saving, setSaving] = useState(false) 22 + const [loadError, setLoadError] = useState<string | null>(null) 23 + const [saveError, setSaveError] = useState<string | null>(null) 21 24 22 25 const fetchSettings = useCallback(async () => { 26 + setLoadError(null) 23 27 try { 24 28 const data = await getCommunitySettings() 25 29 setSettings(data) 26 30 } catch { 27 - // Silently handle 31 + setLoadError('Failed to load community settings. The API may be unreachable.') 28 32 } finally { 29 33 setLoading(false) 30 34 } ··· 37 41 const handleSave = async () => { 38 42 if (!settings) return 39 43 setSaving(true) 44 + setSaveError(null) 40 45 try { 41 46 const updated = await updateCommunitySettings( 42 47 { ··· 51 56 ) 52 57 setSettings(updated) 53 58 } catch { 54 - // Silently handle 59 + setSaveError('Failed to save settings. Please try again.') 55 60 } finally { 56 61 setSaving(false) 57 62 } ··· 63 68 <h1 className="text-2xl font-bold text-foreground">Community Settings</h1> 64 69 65 70 {loading && <p className="text-sm text-muted-foreground">Loading settings...</p>} 71 + 72 + {loadError && ( 73 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchSettings()} /> 74 + )} 66 75 67 76 {settings && ( 68 77 <div className="max-w-lg space-y-6"> ··· 184 193 /> 185 194 </div> 186 195 </fieldset> 196 + 197 + {saveError && <ErrorAlert message={saveError} onDismiss={() => setSaveError(null)} />} 187 198 188 199 <button 189 200 type="button"
+15 -3
src/app/admin/users/page.tsx
··· 10 10 import { useState, useEffect, useCallback } from 'react' 11 11 import { Prohibit, WarningCircle } from '@phosphor-icons/react' 12 12 import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { ErrorAlert } from '@/components/error-alert' 13 14 import { getAdminUsers, banUser, unbanUser } from '@/lib/api/client' 14 15 import { cn } from '@/lib/utils' 15 16 import type { AdminUser } from '@/lib/api/types' ··· 33 34 const { getAccessToken } = useAuth() 34 35 const [users, setUsers] = useState<AdminUser[]>([]) 35 36 const [loading, setLoading] = useState(true) 37 + const [loadError, setLoadError] = useState<string | null>(null) 38 + const [actionError, setActionError] = useState<string | null>(null) 36 39 37 40 const fetchUsers = useCallback(async () => { 41 + setLoadError(null) 38 42 try { 39 43 const response = await getAdminUsers(getAccessToken() ?? '') 40 44 setUsers(response.users) 41 45 } catch { 42 - // Silently handle 46 + setLoadError('Failed to load users. The API may be unreachable.') 43 47 } finally { 44 48 setLoading(false) 45 49 } ··· 50 54 }, [fetchUsers]) 51 55 52 56 const handleBan = async (did: string) => { 57 + setActionError(null) 53 58 try { 54 59 await banUser(did, 'Banned by admin', getAccessToken() ?? '') 55 60 setUsers((prev) => ··· 65 70 ) 66 71 ) 67 72 } catch { 68 - // Silently handle 73 + setActionError('Failed to ban user. Please try again.') 69 74 } 70 75 } 71 76 72 77 const handleUnban = async (did: string) => { 78 + setActionError(null) 73 79 try { 74 80 await unbanUser(did, getAccessToken() ?? '') 75 81 setUsers((prev) => ··· 78 84 ) 79 85 ) 80 86 } catch { 81 - // Silently handle 87 + setActionError('Failed to unban user. Please try again.') 82 88 } 83 89 } 84 90 ··· 86 92 <AdminLayout> 87 93 <div className="space-y-6"> 88 94 <h1 className="text-2xl font-bold text-foreground">User Management</h1> 95 + 96 + {loadError && ( 97 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchUsers()} /> 98 + )} 99 + 100 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 89 101 90 102 {loading && <p className="text-sm text-muted-foreground">Loading users...</p>} 91 103
+17 -2
src/app/notifications/page.tsx
··· 12 12 import { ChatCircle, Heart, At, ShieldCheck, CheckCircle } from '@phosphor-icons/react' 13 13 import { ForumLayout } from '@/components/layout/forum-layout' 14 14 import { Breadcrumbs } from '@/components/breadcrumbs' 15 + import { ErrorAlert } from '@/components/error-alert' 15 16 import { getNotifications, markNotificationsRead } from '@/lib/api/client' 16 17 import { cn } from '@/lib/utils' 17 18 import type { Notification, NotificationType } from '@/lib/api/types' ··· 28 29 const { getAccessToken } = useAuth() 29 30 const [notifications, setNotifications] = useState<Notification[]>([]) 30 31 const [loading, setLoading] = useState(true) 32 + const [loadError, setLoadError] = useState<string | null>(null) 33 + const [actionError, setActionError] = useState<string | null>(null) 31 34 32 35 const fetchNotifications = useCallback(async () => { 36 + setLoadError(null) 33 37 try { 34 38 const response = await getNotifications(getAccessToken() ?? '') 35 39 setNotifications(response.notifications) 36 40 } catch { 37 - // Silently handle - notifications are non-critical 41 + setLoadError('Failed to load notifications. The API may be unreachable.') 38 42 } finally { 39 43 setLoading(false) 40 44 } ··· 48 52 const unreadIds = notifications.filter((n) => !n.read).map((n) => n.id) 49 53 if (unreadIds.length === 0) return 50 54 55 + setActionError(null) 51 56 try { 52 57 await markNotificationsRead(getAccessToken() ?? '', unreadIds) 53 58 setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) 54 59 } catch { 55 - // Silently handle 60 + setActionError('Failed to mark notifications as read. Please try again.') 56 61 } 57 62 }, [notifications, getAccessToken]) 58 63 ··· 85 90 </button> 86 91 )} 87 92 </div> 93 + 94 + {loadError && ( 95 + <ErrorAlert 96 + message={loadError} 97 + variant="page" 98 + onRetry={() => void fetchNotifications()} 99 + /> 100 + )} 101 + 102 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 88 103 89 104 {loading && ( 90 105 <div className="animate-pulse space-y-3">
+30 -19
src/components/block-mute-button.tsx
··· 7 7 'use client' 8 8 9 9 import { useState } from 'react' 10 - import { Prohibit, SpeakerSimpleSlash } from '@phosphor-icons/react' 10 + import { Prohibit, SpeakerSimpleSlash, WarningCircle } from '@phosphor-icons/react' 11 11 import { cn } from '@/lib/utils' 12 12 import { blockUser, unblockUser, muteUser, unmuteUser } from '@/lib/api/client' 13 13 import { useAuth } from '@/hooks/use-auth' ··· 29 29 }: BlockMuteButtonProps) { 30 30 const { getAccessToken } = useAuth() 31 31 const [loading, setLoading] = useState(false) 32 + const [error, setError] = useState(false) 32 33 33 34 const handleClick = async () => { 34 35 setLoading(true) 36 + setError(false) 35 37 36 38 const token = getAccessToken() 37 39 if (!token) { ··· 55 57 } 56 58 onToggle(!isActive) 57 59 } catch { 58 - // Silently fail - the UI state won't change 60 + setError(true) 59 61 } finally { 60 62 setLoading(false) 61 63 } ··· 65 67 const label = action === 'block' ? (isActive ? 'Unblock' : 'Block') : isActive ? 'Unmute' : 'Mute' 66 68 67 69 return ( 68 - <button 69 - type="button" 70 - onClick={handleClick} 71 - disabled={loading} 72 - className={cn( 73 - 'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors', 74 - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 75 - 'disabled:cursor-not-allowed disabled:opacity-50', 76 - isActive 77 - ? 'bg-destructive/10 text-destructive hover:bg-destructive/20' 78 - : 'bg-muted text-muted-foreground hover:bg-muted/80', 79 - className 70 + <div className="inline-flex flex-col items-start gap-1"> 71 + <button 72 + type="button" 73 + onClick={handleClick} 74 + disabled={loading} 75 + className={cn( 76 + 'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors', 77 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 78 + 'disabled:cursor-not-allowed disabled:opacity-50', 79 + isActive 80 + ? 'bg-destructive/10 text-destructive hover:bg-destructive/20' 81 + : 'bg-muted text-muted-foreground hover:bg-muted/80', 82 + error && 'ring-1 ring-destructive', 83 + className 84 + )} 85 + aria-label={`${label} this user`} 86 + > 87 + <Icon size={14} weight={isActive ? 'fill' : 'regular'} aria-hidden="true" /> 88 + {loading ? `${label.slice(0, -1)}ing...` : label} 89 + </button> 90 + {error && ( 91 + <span role="alert" className="inline-flex items-center gap-1 text-xs text-destructive"> 92 + <WarningCircle size={12} aria-hidden="true" /> 93 + Action failed 94 + </span> 80 95 )} 81 - aria-label={`${label} this user`} 82 - > 83 - <Icon size={14} weight={isActive ? 'fill' : 'regular'} aria-hidden="true" /> 84 - {loading ? `${label.slice(0, -1)}ing...` : label} 85 - </button> 96 + </div> 86 97 ) 87 98 }
+63
src/components/error-alert.tsx
··· 1 + /** 2 + * Reusable error alert component for displaying API errors visibly. 3 + * Used to replace silent error handling across admin and frontend pages. 4 + */ 5 + 6 + 'use client' 7 + 8 + import { WarningCircle, ArrowClockwise, X } from '@phosphor-icons/react' 9 + 10 + interface ErrorAlertProps { 11 + /** Error message to display */ 12 + message: string 13 + /** Optional retry callback. Shows a retry button when provided. */ 14 + onRetry?: () => void 15 + /** Optional dismiss callback. Shows a dismiss button when provided. */ 16 + onDismiss?: () => void 17 + /** Visual variant: 'page' fills the content area, 'inline' sits alongside content */ 18 + variant?: 'page' | 'inline' 19 + } 20 + 21 + export function ErrorAlert({ message, onRetry, onDismiss, variant = 'inline' }: ErrorAlertProps) { 22 + if (variant === 'page') { 23 + return ( 24 + <div 25 + role="alert" 26 + className="flex flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-6 py-12 text-center" 27 + > 28 + <WarningCircle size={32} className="text-destructive" aria-hidden="true" /> 29 + <p className="text-sm text-destructive">{message}</p> 30 + {onRetry && ( 31 + <button 32 + type="button" 33 + onClick={onRetry} 34 + className="mt-1 inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 35 + > 36 + <ArrowClockwise size={14} aria-hidden="true" /> 37 + Retry 38 + </button> 39 + )} 40 + </div> 41 + ) 42 + } 43 + 44 + return ( 45 + <div 46 + role="alert" 47 + className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2" 48 + > 49 + <WarningCircle size={16} className="shrink-0 text-destructive" aria-hidden="true" /> 50 + <p className="flex-1 text-sm text-destructive">{message}</p> 51 + {onDismiss && ( 52 + <button 53 + type="button" 54 + onClick={onDismiss} 55 + className="shrink-0 rounded-md p-1 text-destructive/60 transition-colors hover:text-destructive" 56 + aria-label="Dismiss error" 57 + > 58 + <X size={14} aria-hidden="true" /> 59 + </button> 60 + )} 61 + </div> 62 + ) 63 + }
+4 -2
src/hooks/use-onboarding.ts
··· 46 46 const result = await getOnboardingStatus(token) 47 47 setStatus(result) 48 48 } catch { 49 - // If fetch fails, assume no onboarding required 50 - setStatus({ complete: true, fields: [], responses: {}, missingFields: [] }) 49 + // Set status to null so callers can detect the error state. 50 + // The UI will still function -- onboarding won't block posting 51 + // if we can't determine onboarding status. 52 + setStatus(null) 51 53 } finally { 52 54 setLoading(false) 53 55 }