From 1b8c5c89a0f4a64e4896c1f71b9950c1da093f01 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Tue, 18 Aug 2026 22:31:52 +0200 Subject: [PATCH] feat: migrate associations management to TanStack Start Add the associations route, CRUD dialogs, validated server functions, logo handling, navigation, and security coverage using the current dashboard conventions. --- src/components/dashboard-navigation.ts | 6 +- .../associations/association-card.tsx | 70 +++++ .../associations/association-dialogs.tsx | 254 ++++++++++++++++++ .../associations/association-links-dialog.tsx | 106 ++++++++ .../associations/associations-page.tsx | 135 ++++++++++ .../associations/associations.constants.ts | 39 +++ .../associations/associations.functions.ts | 68 +++++ .../associations/associations.validation.ts | 64 +++++ src/features/associations/types.ts | 5 + src/routeTree.gen.ts | 22 ++ src/routes/dashboard/web/associations.tsx | 14 + tests/server-security.test.mjs | 44 +++ 12 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 src/features/associations/association-card.tsx create mode 100644 src/features/associations/association-dialogs.tsx create mode 100644 src/features/associations/association-links-dialog.tsx create mode 100644 src/features/associations/associations-page.tsx create mode 100644 src/features/associations/associations.constants.ts create mode 100644 src/features/associations/associations.functions.ts create mode 100644 src/features/associations/associations.validation.ts create mode 100644 src/features/associations/types.ts create mode 100644 src/routes/dashboard/web/associations.tsx diff --git a/src/components/dashboard-navigation.ts b/src/components/dashboard-navigation.ts index 428c2ae..6d7e311 100644 --- a/src/components/dashboard-navigation.ts +++ b/src/components/dashboard-navigation.ts @@ -6,6 +6,7 @@ import { type LucideIcon, Settings, ShieldCheck, + Users, UsersRound, } from "lucide-react" import azureIcon from "@/assets/svg/azure.svg" @@ -54,7 +55,10 @@ export const dashboardNavigation = [ title: "Web", icon: Globe, iconSrc: undefined, - items: [{ title: "Guides", url: "/dashboard/web/guides", icon: BookOpen }], + items: [ + { title: "Associations", url: "/dashboard/web/associations", icon: Users }, + { title: "Guides", url: "/dashboard/web/guides", icon: BookOpen }, + ], }, ] as const satisfies readonly DashboardNavigationCategory[] diff --git a/src/features/associations/association-card.tsx b/src/features/associations/association-card.tsx new file mode 100644 index 0000000..3f774bf --- /dev/null +++ b/src/features/associations/association-card.tsx @@ -0,0 +1,70 @@ +import { Languages, LinkIcon, Pencil, Trash2 } from "lucide-react" +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" + +export function AssociationCard({ + association, + onEdit, + onEditLinks, + onDelete, +}: { + association: Association + onEdit: () => void + onEditLinks: () => void + onDelete: () => void +}) { + const linkCount = ASSOCIATION_LINK_FIELDS.filter(({ key }) => association.links[key]).length + + return ( + + + + + {association.logo ? ( + + ) : ( + getAssociationInitials(association.name) + )} + + {association.name} + + + + + + + +
+ + +
+
+ + {linkCount} {linkCount === 1 ? "public link" : "public links"} + + +
+
+
+ ) +} + +function Description({ language, text }: { language: string; text: string }) { + return ( +
+
+ {language} +
+

{text}

+
+ ) +} diff --git a/src/features/associations/association-dialogs.tsx b/src/features/associations/association-dialogs.tsx new file mode 100644 index 0000000..34f79b1 --- /dev/null +++ b/src/features/associations/association-dialogs.tsx @@ -0,0 +1,254 @@ +import { useServerFn } from "@tanstack/react-start" +import { LoaderCircle, OctagonX, Upload } from "lucide-react" +import { useEffect, useRef, useState } from "react" +import { toast } from "sonner" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { getAssociationInitials } from "./associations.constants" +import { createAssociation, deleteAssociation, editAssociation } from "./associations.functions" +import type { Association } from "./types" + +export type AssociationDialogState = { mode: "create" } | { mode: "edit"; association: Association } + +export function AssociationDialog({ + dialog, + onClose, + onSaved, +}: { + dialog: AssociationDialogState + onClose: () => void + onSaved: (association: Association, mode: AssociationDialogState["mode"]) => void +}) { + const editing = dialog.mode === "edit" + const association = editing ? dialog.association : null + 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 [pending, setPending] = useState(false) + const [error, setError] = useState("") + const logoInput = useRef(null) + const createAssociationFn = useServerFn(createAssociation) + const editAssociationFn = useServerFn(editAssociation) + + useEffect( + () => () => { + if (logoPreview) URL.revokeObjectURL(logoPreview) + }, + [logoPreview] + ) + + function selectLogo(file: File | null) { + if (logoPreview) URL.revokeObjectURL(logoPreview) + setLogoFile(file) + setLogoPreview(file ? URL.createObjectURL(file) : null) + setError("") + } + + async function submit(event: React.FormEvent) { + event.preventDefault() + if (pending) return + if ( + logoFile && + (!["image/jpeg", "image/png", "image/svg+xml"].includes(logoFile.type) || logoFile.size > 2 * 1024 * 1024) + ) { + setError("Choose a JPG, PNG, or SVG logo no larger than 2 MB.") + return + } + + setPending(true) + setError("") + try { + const data = new FormData() + data.set("name", name) + data.set("descriptionIt", descriptionIt) + data.set("descriptionEn", descriptionEn) + if (logoFile) data.set("logo", logoFile) + else if (association?.logo) data.set("logo", association.logo) + + if (editing) data.set("id", String(dialog.association.id)) + const saved = editing ? await editAssociationFn({ data }) : await createAssociationFn({ data }) + onSaved(saved, dialog.mode) + } catch (cause) { + const message = cause instanceof Error ? cause.message : "" + setError( + message.includes("NOT_FOUND") + ? "This association no longer exists." + : message.includes("LOGO") + ? "Choose a JPG, PNG, or SVG logo no larger than 2 MB." + : "The association could not be saved. Check the fields and your permissions." + ) + } finally { + setPending(false) + } + } + + const logoSource = logoPreview ?? association?.logo + + return ( + !open && !pending && onClose()}> + + +

+ WEB · ASSOCIATIONS +

+ + {editing ? "Edit association" : "Add an association"} + + + {editing + ? "Update the public identity and bilingual descriptions." + : "Create an association entry for the public website."} + +
+
void submit(event)}> + +
+ + {logoSource ? ( + + ) : ( + getAssociationInitials(name) || "?" + )} + + + Logo + selectLogo(event.target.files?.[0] ?? null)} + /> + + Optional JPG, PNG, or SVG, up to 2 MB. + +
+ + Name + setName(event.target.value)} + maxLength={200} + required + autoFocus + /> + +
+ + Italian description +