diff --git a/src/components/dashboard-frame.tsx b/src/components/dashboard-frame.tsx index a39941f..fd0bde7 100644 --- a/src/components/dashboard-frame.tsx +++ b/src/components/dashboard-frame.tsx @@ -33,7 +33,8 @@ export function DashboardFrame({ initialSession }: { initialSession: AdminSessio if (result.error) throw new Error(result.error.message) await router.invalidate() await router.navigate({ to: "/login", replace: true }) - } catch { + } catch (error) { + console.error(error) toast.error("Could not sign out. Please try again.") setLoggingOut(false) } diff --git a/src/components/dashboard-sidebar.tsx b/src/components/dashboard-sidebar.tsx index 4173632..da2d8be 100644 --- a/src/components/dashboard-sidebar.tsx +++ b/src/components/dashboard-sidebar.tsx @@ -56,7 +56,8 @@ export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: Dashb try { const stored = window.localStorage.getItem(categoryStorageKey) if (stored) setCategoryState(JSON.parse(stored)) - } catch { + } catch (error) { + console.error(error) // Ignore malformed or unavailable local storage and use route-based defaults. } }, []) @@ -66,7 +67,8 @@ export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: Dashb const next = { ...current, [title]: open } try { window.localStorage.setItem(categoryStorageKey, JSON.stringify(next)) - } catch { + } catch (error) { + console.error(error) // The in-memory state still works when storage is unavailable. } return next diff --git a/src/components/telegram/create-grant-dialog.tsx b/src/components/telegram/create-grant-dialog.tsx index f101aa9..0f37a30 100644 --- a/src/components/telegram/create-grant-dialog.tsx +++ b/src/components/telegram/create-grant-dialog.tsx @@ -266,6 +266,7 @@ export function CreateGrantDialog({ user: fixedUser }: CreateGrantDialogProps) { data: { userId: selectedUser.id, since, until, reason: reason.trim() || undefined }, }) if (result.error) { + console.error(result.error) toast.error(grantMutationError(result.error)) return } @@ -273,7 +274,8 @@ export function CreateGrantDialog({ user: fixedUser }: CreateGrantDialogProps) { closeAndReset() try { await router.invalidate({ sync: true }) - } catch { + } catch (error) { + console.error(error) toast.warning("The grant was created, but the latest grants could not be refreshed.") } } catch (error) { diff --git a/src/features/account/use-account.ts b/src/features/account/use-account.ts index bf4dd02..4c19f72 100644 --- a/src/features/account/use-account.ts +++ b/src/features/account/use-account.ts @@ -30,6 +30,8 @@ export function useAccount(initialSession: AdminSession) { try { const [passkeyResult, sessionResult] = await Promise.all([auth.passkey.listUserPasskeys(), auth.listSessions()]) if (passkeyResult.error || sessionResult.error) { + if (passkeyResult.error) console.error(passkeyResult.error) + if (sessionResult.error) console.error(sessionResult.error) setSecurityError("Could not load passkeys and active sessions. Your existing security data is still shown.") return false } @@ -37,7 +39,8 @@ export function useAccount(initialSession: AdminSession) { setSessions(sessionResult.data ?? []) setSecurityError("") return true - } catch { + } catch (error) { + console.error(error) setSecurityError("Could not load passkeys and active sessions. Your existing security data is still shown.") return false } finally { @@ -58,12 +61,15 @@ export function useAccount(initialSession: AdminSession) { setNotice(null) try { const result = await auth.updateUser({ name: name.trim() }) - if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not update your name." }) - else { + if (result.error) { + console.error(result.error) + setNotice({ type: "error", text: result.error.message ?? "Could not update your name." }) + } else { await sessionQuery.refetch() setNotice({ type: "success", text: "Profile name updated." }) } - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not update your name." }) } finally { setBusy(null) @@ -85,7 +91,8 @@ export function useAccount(initialSession: AdminSession) { await uploadProfilePictureFn({ data: formData }) await sessionQuery.refetch() setNotice({ type: "success", text: "Profile picture updated." }) - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not update your profile picture." }) } setBusy(null) @@ -96,12 +103,15 @@ export function useAccount(initialSession: AdminSession) { setNotice(null) try { const result = await auth.updateUser({ image: null }) - if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not remove the picture." }) - else { + if (result.error) { + console.error(result.error) + setNotice({ type: "error", text: result.error.message ?? "Could not remove the picture." }) + } else { await sessionQuery.refetch() setNotice({ type: "success", text: "Profile picture removed." }) } - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not remove the picture." }) } finally { setBusy(null) @@ -113,15 +123,18 @@ export function useAccount(initialSession: AdminSession) { setNotice(null) try { const result = await auth.passkey.addPasskey({ name: `Passkey ${passkeys.length + 1}` }) - if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not create the passkey." }) - else { + if (result.error) { + console.error(result.error) + setNotice({ type: "error", text: result.error.message ?? "Could not create the passkey." }) + } else { const refreshed = await refreshSecurityData() setNotice({ type: "success", text: refreshed ? "Passkey created." : "Passkey created. Refresh security data to see the updated list.", }) } - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not create the passkey." }) } finally { setBusy(null) @@ -133,15 +146,18 @@ export function useAccount(initialSession: AdminSession) { setNotice(null) try { const result = await auth.passkey.deletePasskey({ id }) - if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not delete the passkey." }) - else { + if (result.error) { + console.error(result.error) + setNotice({ type: "error", text: result.error.message ?? "Could not delete the passkey." }) + } else { const refreshed = await refreshSecurityData() setNotice({ type: "success", text: refreshed ? "Passkey deleted." : "Passkey deleted. Refresh security data to see the updated list.", }) } - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not delete the passkey." }) } finally { setBusy(null) @@ -153,8 +169,10 @@ export function useAccount(initialSession: AdminSession) { setNotice(null) try { const result = await auth.revokeOtherSessions() - if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not revoke other sessions." }) - else { + if (result.error) { + console.error(result.error) + setNotice({ type: "error", text: result.error.message ?? "Could not revoke other sessions." }) + } else { const refreshed = await refreshSecurityData() setNotice({ type: "success", @@ -163,7 +181,8 @@ export function useAccount(initialSession: AdminSession) { : "Sessions were signed out. Refresh security data to see the updated list.", }) } - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not revoke other sessions." }) } finally { setBusy(null) @@ -178,7 +197,8 @@ export function useAccount(initialSession: AdminSession) { if (result.error) throw new Error(result.error.message) await router.invalidate() await router.navigate({ to: "/login", replace: true }) - } catch { + } catch (error) { + console.error(error) setNotice({ type: "error", text: "Could not sign out. Please try again." }) setBusy(null) } diff --git a/src/features/associations/association-card.tsx b/src/features/associations/association-card.tsx index 3f774bf..84ff09c 100644 --- a/src/features/associations/association-card.tsx +++ b/src/features/associations/association-card.tsx @@ -1,60 +1,341 @@ -import { Languages, LinkIcon, Pencil, Trash2 } from "lucide-react" +import { CircleDashed, Languages, LinkIcon, LoaderCircle, Pencil, Save, Trash2, Upload, X } from "lucide-react" +import { type ChangeEvent, useEffect, useId, useState } from "react" +import { toast } from "sonner" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { ASSOCIATION_LINK_FIELDS, getAssociationInitials } from "./associations.constants" -import type { Association } from "./types" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" +import { ASSOCIATION_LINK_FIELDS, getAssociationInitials, validateAssociationLogo } from "./associations.constants" +import type { Association, AssociationFormValues } from "./types" + +type AssociationCardProps = { + association: Association + draft: boolean + initialEditActive: boolean + onCancelDraft: () => void + onDelete: () => Promise + onEditLinks: () => void + onSave: (values: AssociationFormValues) => Promise +} export function AssociationCard({ association, - onEdit, - onEditLinks, + draft, + initialEditActive, + onCancelDraft, onDelete, -}: { - association: Association - onEdit: () => void - onEditLinks: () => void - onDelete: () => void -}) { + onEditLinks, + onSave, +}: AssociationCardProps) { + const logoInputId = useId() + const [editing, setEditing] = useState(initialEditActive) + const [name, setName] = useState(association.name) + const [descriptionIt, setDescriptionIt] = useState(association.descriptionIt) + const [descriptionEn, setDescriptionEn] = useState(association.descriptionEn) + const [logoFile, setLogoFile] = useState(null) + const [logoPreview, setLogoPreview] = useState(null) + const [saving, setSaving] = useState(false) + const [deleting, setDeleting] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const canSave = Boolean(name.trim() && descriptionIt.trim() && descriptionEn.trim()) + + useEffect(() => { + return () => { + if (logoPreview) URL.revokeObjectURL(logoPreview) + } + }, [logoPreview]) + + function resetFields() { + setName(association.name) + setDescriptionIt(association.descriptionIt) + setDescriptionEn(association.descriptionEn) + setLogoFile(null) + setLogoPreview(null) + } + + function cancelEdit() { + if (draft) { + onCancelDraft() + return + } + resetFields() + setEditing(false) + } + + function selectLogo(event: ChangeEvent) { + const file = event.target.files?.[0] + if (!file) return + const error = validateAssociationLogo(file) + if (error) { + toast.error(error) + event.target.value = "" + return + } + if (logoPreview) URL.revokeObjectURL(logoPreview) + setLogoFile(file) + setLogoPreview(URL.createObjectURL(file)) + } + + async function save() { + if (saving || !canSave) return + setSaving(true) + try { + const saved = await onSave({ + name: name.trim(), + descriptionIt: descriptionIt.trim(), + descriptionEn: descriptionEn.trim(), + logo: association.logo, + logoFile, + }) + if (saved) { + setLogoFile(null) + setLogoPreview(null) + setEditing(false) + } + } finally { + setSaving(false) + } + } + + async function remove() { + setDeleting(true) + try { + if (await onDelete()) setDeleteOpen(false) + } finally { + setDeleting(false) + } + } + + const logo = logoPreview ?? association.logo const linkCount = ASSOCIATION_LINK_FIELDS.filter(({ key }) => association.links[key]).length return ( - - - - - {association.logo ? ( - + <> + + + + {editing ? ( + <> + +
+ {draft && ( + + Unsaved draft + + )} + setName(event.target.value)} + className="bg-background text-base font-medium" + maxLength={200} + required + autoFocus={draft} + /> +
+ + ) : ( + <> + + {association.logo ? ( + + ) : ( + getAssociationInitials(association.name) + )} + + {association.name} + + )} +
+ + {editing ? ( + <> + + + + ) : ( + <> + + + + )} + +
+ +
+ {editing ? ( + <> + + + ) : ( - getAssociationInitials(association.name) + <> + + + )} - - {association.name} - - - - - - - -
- - -
-
- - {linkCount} {linkCount === 1 ? "public link" : "public links"} - - -
-
- +
+ {!draft && ( +
+ + {linkCount} {linkCount === 1 ? "public link" : "public links"} + + +
+ )} +
+
+ + !deleting && setDeleteOpen(open)}> + + + + + + Delete association + + Are you sure you want to delete {association.name}? This action cannot be undone. + + + + setDeleteOpen(false)}> + Cancel + + void remove()}> + {deleting && } Delete + + + + + + ) +} + +function EditableDescription({ + language, + ariaLabel, + value, + onChange, + draft, +}: { + language: string + ariaLabel: string + value: string + onChange: (value: string) => void + draft: boolean +}) { + return ( +
+
+ {language} +
+