Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/dashboard-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type LucideIcon,
Settings,
ShieldCheck,
Users,
UsersRound,
} from "lucide-react"
import azureIcon from "@/assets/svg/azure.svg"
Expand Down Expand Up @@ -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[]

Expand Down
70 changes: 70 additions & 0 deletions src/features/associations/association-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="h-full">
<CardHeader className="grid-cols-[1fr_auto] gap-x-4">
<CardTitle className="flex min-w-0 items-center gap-3 text-lg">
<span className="grid size-12 shrink-0 place-items-center overflow-hidden rounded-xl border border-border bg-muted text-sm font-semibold text-muted-foreground">
{association.logo ? (
<img src={association.logo} alt="" className="size-full object-contain p-1" />
) : (
getAssociationInitials(association.name)
)}
</span>
<span className="truncate">{association.name}</span>
</CardTitle>
<CardAction className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`Edit ${association.name}`} onClick={onEdit}>
<Pencil />
</Button>
<Button variant="destructive" size="icon-sm" aria-label={`Delete ${association.name}`} onClick={onDelete}>
<Trash2 />
</Button>
</CardAction>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Description language="IT" text={association.descriptionIt} />
<Description language="EN" text={association.descriptionEn} />
</div>
<div className="mt-auto flex items-center justify-between gap-3 border-t border-border pt-4">
<Badge variant="secondary">
{linkCount} {linkCount === 1 ? "public link" : "public links"}
</Badge>
<Button variant="outline" size="sm" onClick={onEditLinks}>
<LinkIcon data-icon="inline-start" /> Manage links
</Button>
</div>
</CardContent>
</Card>
)
}

function Description({ language, text }: { language: string; text: string }) {
return (
<section className="min-w-0 rounded-lg bg-muted/45 p-3.5">
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold tracking-[0.1em] text-muted-foreground uppercase">
<Languages className="size-3.5" /> {language}
</div>
<p className="line-clamp-5 text-sm leading-6 text-foreground/85">{text}</p>
</section>
)
}
254 changes: 254 additions & 0 deletions src/features/associations/association-dialogs.tsx
Original file line number Diff line number Diff line change
@@ -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<File | null>(null)
const [logoPreview, setLogoPreview] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [error, setError] = useState("")
const logoInput = useRef<HTMLInputElement>(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 (
<Dialog open onOpenChange={(open) => !open && !pending && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] max-w-2xl overflow-y-auto border-border p-0">
<DialogHeader className="border-b border-border px-6 py-5">
<p className="font-mono text-[10px] font-medium tracking-[0.13em] text-muted-foreground">
WEB · ASSOCIATIONS
</p>
<DialogTitle className="text-xl font-semibold tracking-[-0.03em]">
{editing ? "Edit association" : "Add an association"}
</DialogTitle>
<DialogDescription>
{editing
? "Update the public identity and bilingual descriptions."
: "Create an association entry for the public website."}
</DialogDescription>
</DialogHeader>
<form className="px-6 py-5" onSubmit={(event) => void submit(event)}>
<FieldGroup>
<div className="flex items-center gap-4">
<span className="grid size-16 shrink-0 place-items-center overflow-hidden rounded-xl border border-border bg-muted text-lg font-semibold text-muted-foreground">
{logoSource ? (
<img src={logoSource} alt="" className="size-full object-contain p-1" />
) : (
getAssociationInitials(name) || "?"
)}
</span>
<Field>
<FieldLabel htmlFor="association-logo">Logo</FieldLabel>
<Input
ref={logoInput}
id="association-logo"
type="file"
accept="image/jpeg,image/png,image/svg+xml"
className="sr-only"
onChange={(event) => selectLogo(event.target.files?.[0] ?? null)}
/>
<Button type="button" variant="outline" onClick={() => logoInput.current?.click()}>
<Upload data-icon="inline-start" /> {logoFile ? "Change selected logo" : "Choose logo"}
</Button>
<FieldDescription>Optional JPG, PNG, or SVG, up to 2 MB.</FieldDescription>
</Field>
</div>
<Field>
<FieldLabel htmlFor="association-name">Name</FieldLabel>
<Input
id="association-name"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={200}
required
autoFocus
/>
</Field>
<div className="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel htmlFor="association-description-it">Italian description</FieldLabel>
<Textarea
id="association-description-it"
value={descriptionIt}
onChange={(event) => setDescriptionIt(event.target.value)}
className="min-h-40"
maxLength={20_000}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="association-description-en">English description</FieldLabel>
<Textarea
id="association-description-en"
value={descriptionEn}
onChange={(event) => setDescriptionEn(event.target.value)}
className="min-h-40"
maxLength={20_000}
required
/>
</Field>
</div>
{error && <FieldError>{error}</FieldError>}
</FieldGroup>
<DialogFooter className="-mx-6 -mb-5 mt-5 flex-row justify-end border-t border-border bg-muted/50 px-6 py-4">
<Button type="button" variant="outline" disabled={pending} onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={pending || !name.trim() || !descriptionIt.trim() || !descriptionEn.trim()}>
{pending && <LoaderCircle data-icon="inline-start" className="animate-spin-slow" />}
{editing ? "Save changes" : "Create association"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

export function DeleteAssociationDialog({
association,
onClose,
onDeleted,
}: {
association: Association
onClose: () => void
onDeleted: (id: number) => void
}) {
const [pending, setPending] = useState(false)
const deleteAssociationFn = useServerFn(deleteAssociation)

async function remove() {
setPending(true)
try {
await deleteAssociationFn({ data: { id: association.id } })
onDeleted(association.id)
} catch (cause) {
const message = cause instanceof Error ? cause.message : ""
if (message.includes("NOT_FOUND")) onDeleted(association.id)
else toast.error("The association could not be deleted. Check your permissions and try again.")
} finally {
setPending(false)
}
}

return (
<AlertDialog open onOpenChange={(open) => !open && !pending && onClose()}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20">
<OctagonX />
</AlertDialogMedia>
<AlertDialogTitle>Delete association</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{association.name}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending} onClick={onClose}>
Cancel
</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={pending} onClick={() => void remove()}>
{pending && <LoaderCircle data-icon="inline-start" className="animate-spin-slow" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
Loading
Loading