diff --git a/next.config.ts b/next.config.ts index 609ecca..3fa77a8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,9 +10,20 @@ const config: NextConfig = { ignoreBuildErrors: true, }, output: "standalone", - transpilePackages: ["@t3-oss/env-nextjs", "@t3-oss/env-core"], + transpilePackages: ["@t3-oss/env-core", "@t3-oss/env-nextjs"], + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "**.blob.core.windows.net", + }, + ], + }, experimental: { reactCompiler: true, + serverActions: { + bodySizeLimit: "2mb", + }, }, } diff --git a/package.json b/package.json index cc55384..04676b8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@base-ui/react": "^1.3.0", "@better-auth/passkey": "^1.5.5", "@hookform/resolvers": "^3.9.1", - "@polinetwork/backend": "^0.16.0", + "@polinetwork/backend": "^0.16.3", "@radix-ui/react-dialog": "^1.1.15", "@t3-oss/env-nextjs": "^0.13.10", "@tanstack/react-table": "^8.21.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb0e106..4343494 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^3.9.1 version: 3.10.0(react-hook-form@7.55.0(react@18.3.1)) '@polinetwork/backend': - specifier: ^0.16.0 - version: 0.16.0 + specifier: ^0.16.3 + version: 0.16.3 '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -705,8 +705,8 @@ packages: '@cfworker/json-schema': optional: true - '@mongodb-js/saslprep@1.4.11': - resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} + '@mongodb-js/saslprep@1.4.12': + resolution: {integrity: sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==} '@mswjs/interceptors@0.41.3': resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} @@ -841,8 +841,8 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} - '@polinetwork/backend@0.16.0': - resolution: {integrity: sha512-z/xuzvQv9w8frEtKAw59NvfFEllJnx2lw9YVUwTsP9TXh9tfPu5jP1njVUhFGQe15UE+IxwXH1hV4rO0/9/MLg==} + '@polinetwork/backend@0.16.3': + resolution: {integrity: sha512-+mhqZPokWC8QJSZqdoPWmfntY9738OaDazBOBp/OL4V3y6r8CA1TnmrnEqAHPmI9a2oLr3tMylMiFpuxbDpzqQ==} '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1963,8 +1963,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bson@7.2.0: - resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} + bson@7.3.1: + resolution: {integrity: sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==} engines: {node: '>=20.19.0'} bundle-name@4.1.0: @@ -4004,7 +4004,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@mongodb-js/saslprep@1.4.11': + '@mongodb-js/saslprep@1.4.12': dependencies: sparse-bitfield: 3.0.3 @@ -4172,7 +4172,7 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 - '@polinetwork/backend@0.16.0': {} + '@polinetwork/backend@0.16.3': {} '@radix-ui/number@1.1.1': {} @@ -5256,7 +5256,7 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) - bson@7.2.0: {} + bson@7.3.1: {} bundle-name@4.1.0: dependencies: @@ -5870,8 +5870,8 @@ snapshots: mongodb@7.1.0: dependencies: - '@mongodb-js/saslprep': 1.4.11 - bson: 7.2.0 + '@mongodb-js/saslprep': 1.4.12 + bson: 7.3.1 mongodb-connection-string-url: 7.0.1 ms@2.1.3: {} 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..c529f08 --- /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, AssociationSaveValues } 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", + logo: 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: AssociationSaveValues) { + try { + const isDraft = draftAssociationIds.has(id) + const result = isDraft + ? await createAssociation({ + name: values.name, + descriptionIt: values.descriptionIt, + descriptionEn: values.descriptionEn, + logoFile: values.logoFile, + }) + : await editAssociation({ + id, + name: values.name, + descriptionIt: values.descriptionIt, + descriptionEn: values.descriptionEn, + logoFile: values.logoFile, + }) + + 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..90a830c --- /dev/null +++ b/src/app/dashboard/(active)/web/associations/card-association.tsx @@ -0,0 +1,229 @@ +"use client" + +import { Languages, LucidePencil, Save, Upload, X } from "lucide-react" +import Image from "next/image" +import type { ChangeEvent } from "react" +import { useEffect, 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, AssociationSaveValues } from "./types" + +export default function CardAssociation( + item: Association & { + initialEditActive?: boolean + isDraft?: boolean + onCancelCreate?: () => void + onDelete: () => void + onSave: (values: AssociationSaveValues) => 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 [logoFile, setLogoFile] = useState(null) + const [logoPreview, setLogoPreview] = useState(null) + 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 + setLogoFile(file) + setLogoPreview(URL.createObjectURL(file)) + } + + useEffect(() => { + return () => { + if (logoPreview) URL.revokeObjectURL(logoPreview) + } + }, [logoPreview]) + + // 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) + setLogoFile(null) + setLogoPreview(null) + 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, + logoFile, + descriptionIt, + descriptionEn, + links, + logo: null, + }) + if (saved) { + setLogoFile(null) + setLogoPreview(null) + setEditActive(false) + } + } finally { + setPending(false) + } + } + + async function saveLinks(nextLinks: AssociationLinks) { + const saved = await item.onSaveLinks(nextLinks) + if (saved) setLinks(nextLinks) + return saved + } + + function renderIcon() { + const logoSource = logoPreview ?? item.logo + if (logoSource) { + return + } + return ( + + ) + } + + return ( + + + + {editActive ? ( + <> + +
+ setName(event.target.value)} className="text-lg font-medium" /> +
+ + ) : ( + <> + {renderIcon()} + {name} + + )} +
+ + {editActive ? ( + <> + + + + ) : ( + <> + + + + + )} + +
+ + +
+ + + IT + +