diff --git a/src/app/dashboard/(active)/web/associations/association-links.tsx b/src/app/dashboard/(active)/web/associations/association-links.tsx new file mode 100644 index 0000000..e73a00c --- /dev/null +++ b/src/app/dashboard/(active)/web/associations/association-links.tsx @@ -0,0 +1,109 @@ +"use client" + +import { Link, Save } from "lucide-react" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { LINK_FIELDS } from "./constants" +import type { AssociationLinks } from "./types" + +function normalizeLinks(links: AssociationLinks) { + return Object.fromEntries( + LINK_FIELDS.map((field) => { + const value = links[field.key]?.trim() + return [field.key, value || null] + }) + ) as AssociationLinks +} + +export function AssociationLinksDialog({ + name, + links, + onSave, +}: { + name: string + links: AssociationLinks + onSave: (links: AssociationLinks) => boolean | Promise +}) { + const [open, setOpen] = useState(false) + const [pending, setPending] = useState(false) + const [draftLinks, setDraftLinks] = useState(links) + + function handleOpenChange(value: boolean) { + setOpen(value) + if (value) setDraftLinks(links) + } + + async function handleSave() { + if (pending) return + + setPending(true) + try { + const saved = await onSave(normalizeLinks(draftLinks)) + if (saved) setOpen(false) + } finally { + setPending(false) + } + } + + return ( + + + + + } + /> + + + + {name} links + Manage the public links for this association. + + +
+ {LINK_FIELDS.map((field) => { + const FieldIcon = field.icon + + return ( +
+ + + setDraftLinks((currentLinks) => ({ + ...currentLinks, + [field.key]: event.target.value, + })) + } + /> +
+ ) + })} +
+ + + + +
+
+ ) +} diff --git a/src/app/dashboard/(active)/web/associations/associations-view.tsx b/src/app/dashboard/(active)/web/associations/associations-view.tsx new file mode 100644 index 0000000..bed4087 --- /dev/null +++ b/src/app/dashboard/(active)/web/associations/associations-view.tsx @@ -0,0 +1,185 @@ +"use client" + +import { PlusIcon } from "lucide-react" +import { useRouter } from "next/navigation" +import { useState } from "react" +import { toast } from "sonner" +import WebHeader from "@/components/web-header" +import { createAssociation, deleteAssociation, editAssociation, editAssociationLinks } from "@/server/actions/web" +import CardAssociation from "./card-association" +import { EMPTY_ASSOCIATION_LINKS } from "./constants" +import type { Association, AssociationLinks } from "./types" + +export function AssociationsView({ initialAssociations }: { initialAssociations: Association[] }) { + const router = useRouter() + const [associations, setAssociations] = useState(initialAssociations) + const [editingAssociationId, setEditingAssociationId] = useState(null) + const [draftAssociationIds, setDraftAssociationIds] = useState>(new Set()) + + // Creates a new temporary association, id will not be saved and will be replaced + function handleAdd() { + const association: Association = { + id: Date.now(), + name: "New Association", + logoSvg: null, + descriptionIt: "Description in Italian", + descriptionEn: "Description in English", + links: EMPTY_ASSOCIATION_LINKS, + } + + setAssociations((items) => [association, ...items]) + setEditingAssociationId(association.id) + setDraftAssociationIds((ids) => new Set(ids).add(association.id)) + } + + function removeAssociationLocally(id: number) { + setAssociations((items) => items.filter((item) => item.id !== id)) + setDraftAssociationIds((ids) => { + const nextIds = new Set(ids) + nextIds.delete(id) + return nextIds + }) + setEditingAssociationId((editingId) => (editingId === id ? null : editingId)) + } + + async function handleDelete(id: number) { + if (draftAssociationIds.has(id)) { + removeAssociationLocally(id) + return + } + + try { + const result = await deleteAssociation(id) + + if (result.error === "UNAUTHORIZED") { + toast.error("You don't have permission to delete associations.") + return + } else if (result.error === "NOT_FOUND") { + toast.info("This association was already deleted.") + } else { + toast.success("Association deleted successfully.") + } + + removeAssociationLocally(id) + router.refresh() + } catch (_e) { + toast.error("There was an error deleting the association.") + } + } + + // Draft ne crea una nuova, altrimenti modifica quella esistente + async function handleSave(id: number, values: Association) { + try { + const isDraft = draftAssociationIds.has(id) + const result = isDraft + ? await createAssociation({ + name: values.name, + descriptionIt: values.descriptionIt, + descriptionEn: values.descriptionEn, + logoSvg: values.logoSvg, + }) + : await editAssociation({ + id, + name: values.name, + descriptionIt: values.descriptionIt, + descriptionEn: values.descriptionEn, + logoSvg: values.logoSvg, + }) + + if (result.error === "UNAUTHORIZED") { + toast.error("You don't have permission to save associations.") + return false + } else if (result.error === "NOT_FOUND") { + toast.error("This association does not exist anymore.") + return false + } else if (!result.association) { + toast.error("There was an error saving the association.") + return false + } + + // anche qui, se facessi il refresh perderei gli altri edit locali + setAssociations((items) => items.map((item) => (item.id === id ? result.association : item))) + setDraftAssociationIds((ids) => { + const nextIds = new Set(ids) + nextIds.delete(id) + return nextIds + }) + setEditingAssociationId((editingId) => (editingId === id ? null : editingId)) + toast.success(`Association ${isDraft ? "created" : "updated"} successfully.`) + router.refresh() + // Mi ritorna true cosi poi chiudo l'edit della card + return true + } catch (_e) { + toast.error("There was an error saving the association.") + return false + } + } + + async function handleSaveLinks(id: number, links: AssociationLinks) { + try { + const result = await editAssociationLinks({ id, links }) + + if (result.error === "UNAUTHORIZED") { + toast.error("You don't have permission to save association links.") + return false + } else if (result.error === "NOT_FOUND") { + toast.error("This association does not exist anymore.") + return false + } else if (!result.association) { + toast.error("There was an error saving the association links.") + return false + } + + setAssociations((items) => + items.map((item) => (item.id === id ? { ...item, links: result.association.links } : item)) + ) + toast.success("Association links updated successfully.") + router.refresh() + return true + } catch (_e) { + toast.error("There was an error saving the association links.") + return false + } + } + + return ( + <> + , + onClick: handleAdd, + }} + /> + +
+ {associations.length === 0 && ( +
+

+ No associations found. Click "Add Association" to create one. +

+
+ )} + {associations.map((item) => ( + removeAssociationLocally(item.id)} + onDelete={() => handleDelete(item.id)} + onSave={(values) => handleSave(item.id, values)} + onSaveLinks={(links) => handleSaveLinks(item.id, links)} + /> + ))} +
+ + ) +} diff --git a/src/app/dashboard/(active)/web/associations/card-association.tsx b/src/app/dashboard/(active)/web/associations/card-association.tsx new file mode 100644 index 0000000..d3b8e3d --- /dev/null +++ b/src/app/dashboard/(active)/web/associations/card-association.tsx @@ -0,0 +1,212 @@ +"use client" + +import { Languages, LucidePencil, Save, Upload, X } from "lucide-react" +import type { ChangeEvent } from "react" +import { useState } from "react" +import { DeleteDialog } from "@/components/delete-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { getInitials } from "@/lib/utils" +import { cn } from "@/lib/utils/shadcn" +import { AssociationLinksDialog } from "./association-links" +import type { Association, AssociationLinks } from "./types" + +export default function CardAssociation( + item: Association & { + initialEditActive?: boolean + isDraft?: boolean + onCancelCreate?: () => void + onDelete: () => void + onSave: (values: Association) => boolean | Promise + onSaveLinks: (links: AssociationLinks) => boolean | Promise + } +) { + const iconInputId = `association-icon-${item.id}` + const [editActive, setEditActive] = useState(item.initialEditActive ?? false) + const [name, setName] = useState(item.name) + const [logoSvg, setLogoSvg] = useState(item.logoSvg) + const [descriptionIt, setDescriptionIt] = useState(item.descriptionIt) + const [descriptionEn, setDescriptionEn] = useState(item.descriptionEn) + const [links, setLinks] = useState(item.links) + const [pending, setPending] = useState(false) + const initials = getInitials(name) + + async function handleIconUpload(event: ChangeEvent) { + const file = event.target.files?.[0] + if (!file) return + setLogoSvg(await file.text()) + } + + // If it's draft, remove the card, otherwise reset the values to the original ones + function handleCancelEdit() { + if (item.isDraft) { + item.onCancelCreate?.() + return + } + + setName(item.name) + setLogoSvg(item.logoSvg) + setDescriptionIt(item.descriptionIt) + setDescriptionEn(item.descriptionEn) + setLinks(item.links) + setEditActive(false) + } + + // TODO: forse spostare la cosa salvata per ultima nella lista? Perche poi ordinata per id finisce li + // se gli id sono crescenti. O tipo la creo direttamente ultima e non in cima? Pero poi devi scorrere per editarla + async function saveChanges() { + if (pending) return + + setPending(true) + try { + const saved = await item.onSave({ id: item.id, name, logoSvg, descriptionIt, descriptionEn, links }) + if (saved) setEditActive(false) + } finally { + setPending(false) + } + } + + async function saveLinks(nextLinks: AssociationLinks) { + const saved = await item.onSaveLinks(nextLinks) + if (saved) setLinks(nextLinks) + return saved + } + + function renderIcon() { + if (logoSvg) { + return ( +