diff --git a/package.json b/package.json index e56a047..48bed3a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@better-auth/passkey": "^1.5.5", + "@dnd-kit/react": "^0.4.0", "@polinetwork/backend": "^0.17.0", "@t3-oss/env-core": "^0.13.10", "@tanstack/react-router": "1.170.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a6d4e5..a1254fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@better-auth/passkey': specifier: ^1.5.5 version: 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@tanstack/react-start@1.168.27(crossws@0.4.9(srvx@0.11.21))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rolldown@1.1.5)(vite@8.1.4(@types/node@22.20.1)(jiti@2.7.0)))(mongodb@7.1.0)(next@15.5.18(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14))(better-call@1.3.2(zod@4.3.5))(nanostores@1.2.0) + '@dnd-kit/react': + specifier: ^0.4.0 + version: 0.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@polinetwork/backend': specifier: ^0.17.0 version: 0.17.0 @@ -469,6 +472,27 @@ packages: '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/abstract@0.4.0': + resolution: {integrity: sha512-loEEJxKT5oLOLeRBJVTO9qpgvvW/Qq902xO20v1JMbpANuN/NLurUdpxIwNpVz+RtOSyzznnbc7lO7psmOhc9A==} + + '@dnd-kit/collision@0.4.0': + resolution: {integrity: sha512-oOHHUkH1h9Vl2m8TwLw/mPHA7Blf+s0PYcRoLNWNBVxDzugJKZo8WdpU58EMu9qkqyQGrR/YTOozGiMPhlqZ5Q==} + + '@dnd-kit/dom@0.4.0': + resolution: {integrity: sha512-mJDKt0BtlHXetZyrvZXh6++aycleIbYWH/OVC4nlszDh8NvW7q8dfsxFllR5RtLKLcykLaI4o545Figfks/HZQ==} + + '@dnd-kit/geometry@0.4.0': + resolution: {integrity: sha512-d1n+CU54V/qF/g792bmJK2oR4f5jOL7Pls2IfC+j9f5UBECpjsYbcPZ/krom/z8LgieqvMh1qrUkdcBjJJ7vpg==} + + '@dnd-kit/react@0.4.0': + resolution: {integrity: sha512-J2/N4CpQf98zJBZhMljDNsc+QR4VtUKU9BRO1+Di4OGaB1qafMC4qZ11xKXOkjw+d7h82FRSXmXCo0c8+VWaWg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.4.0': + resolution: {integrity: sha512-vVdwOY9VsYdMNa7Z0xQhTXlzHqCcCugGuoM1kzvZhnZ0tYVPRdmIhWfeO6Y2ZoN92JwYAyJRRNl4ICkEe2mneg==} + '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -814,6 +838,9 @@ packages: '@polinetwork/backend@0.17.0': resolution: {integrity: sha512-NfU0i4It86LOMsZ5xyOX8ccOcbazwsC8j/D/B4sBb9PaJjENAdyFS27l9I4i0ihM2MrcFBJixxDdmnPO7/D/Rg==} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@radix-ui/primitive@1.1.6': resolution: {integrity: sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==} @@ -3689,6 +3716,45 @@ snapshots: '@date-fns/tz@1.5.0': {} + '@dnd-kit/abstract@0.4.0': + dependencies: + '@dnd-kit/geometry': 0.4.0 + '@dnd-kit/state': 0.4.0 + tslib: 2.8.1 + + '@dnd-kit/collision@0.4.0': + dependencies: + '@dnd-kit/abstract': 0.4.0 + '@dnd-kit/geometry': 0.4.0 + tslib: 2.8.1 + + '@dnd-kit/dom@0.4.0': + dependencies: + '@dnd-kit/abstract': 0.4.0 + '@dnd-kit/collision': 0.4.0 + '@dnd-kit/geometry': 0.4.0 + '@dnd-kit/state': 0.4.0 + tslib: 2.8.1 + + '@dnd-kit/geometry@0.4.0': + dependencies: + '@dnd-kit/state': 0.4.0 + tslib: 2.8.1 + + '@dnd-kit/react@0.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/abstract': 0.4.0 + '@dnd-kit/dom': 0.4.0 + '@dnd-kit/state': 0.4.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tslib: 2.8.1 + + '@dnd-kit/state@0.4.0': + dependencies: + '@preact/signals-core': 1.14.4 + tslib: 2.8.1 + '@dotenvx/dotenvx@1.75.1': dependencies: '@dotenvx/primitives': 0.8.0 @@ -4060,6 +4126,8 @@ snapshots: '@polinetwork/backend@0.17.0': {} + '@preact/signals-core@1.14.4': {} + '@radix-ui/primitive@1.1.6': {} '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': diff --git a/src/components/dashboard-navigation.ts b/src/components/dashboard-navigation.ts index 6d7e311..249d30c 100644 --- a/src/components/dashboard-navigation.ts +++ b/src/components/dashboard-navigation.ts @@ -1,6 +1,7 @@ import { BookOpen, Database, + FolderKanban, Globe, LayoutDashboard, type LucideIcon, @@ -56,6 +57,7 @@ export const dashboardNavigation = [ icon: Globe, iconSrc: undefined, items: [ + { title: "Projects", url: "/dashboard/web/projects", icon: FolderKanban }, { title: "Associations", url: "/dashboard/web/associations", icon: Users }, { title: "Guides", url: "/dashboard/web/guides", icon: BookOpen }, ], diff --git a/src/features/projects/project-card.tsx b/src/features/projects/project-card.tsx new file mode 100644 index 0000000..74d2af9 --- /dev/null +++ b/src/features/projects/project-card.tsx @@ -0,0 +1,421 @@ +import { useSortable } from "@dnd-kit/react/sortable" +import { + ExternalLink, + GripVertical, + Languages, + Link as LinkIcon, + LoaderCircle, + MoreHorizontal, + 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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" +import { PROJECT_CATEGORIES, PROJECT_LOGO_MAX_SIZE, PROJECT_LOGO_TYPES } from "./projects.constants" +import type { Project, ProjectCategory, ProjectFormValues } from "./types" + +type ProjectCardProps = { + project: Project + draft: boolean + initialEditActive: boolean + sortableIndex: number + onCancelDraft: () => void + onDelete: () => Promise + onCategoryChange: (category: ProjectCategory) => Promise + onSave: (values: ProjectFormValues) => Promise +} + +function initials(value: string) { + return value + .trim() + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") +} + +export function ProjectCard({ + project, + draft, + initialEditActive, + sortableIndex, + onCancelDraft, + onDelete, + onCategoryChange, + onSave, +}: ProjectCardProps) { + const { ref, handleRef, isDragging } = useSortable({ + id: project.id, + index: sortableIndex, + group: project.category, + disabled: draft, + }) + const logoInputId = useId() + const [editing, setEditing] = useState(initialEditActive) + const [title, setTitle] = useState(project.title) + const [descriptionIt, setDescriptionIt] = useState(project.descriptionIt) + const [descriptionEn, setDescriptionEn] = useState(project.descriptionEn) + const [link, setLink] = useState(project.link ?? "") + 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(title.trim() && descriptionIt.trim() && descriptionEn.trim()) + + useEffect(() => { + return () => { + if (logoPreview) URL.revokeObjectURL(logoPreview) + } + }, [logoPreview]) + + function resetFields() { + setTitle(project.title) + setDescriptionIt(project.descriptionIt) + setDescriptionEn(project.descriptionEn) + setLink(project.link ?? "") + 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 + if (!PROJECT_LOGO_TYPES.some((type) => type === file.type)) { + toast.error("Choose an SVG, PNG, or JPEG logo.") + event.target.value = "" + return + } + if (file.size > PROJECT_LOGO_MAX_SIZE) { + toast.error("The logo must be no larger than 1 MB.") + event.target.value = "" + return + } + setLogoFile(file) + setLogoPreview(URL.createObjectURL(file)) + } + + async function save() { + if (saving || !canSave) return + setSaving(true) + try { + const saved = await onSave({ + title: title.trim(), + descriptionIt: descriptionIt.trim(), + descriptionEn: descriptionEn.trim(), + link: link.trim() || null, + logo: project.logo, + logoFile, + category: project.category, + }) + 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 ?? project.logo + + return ( + <> + ref(element)} + className={cn("transition-opacity", isDragging && "relative z-10 opacity-60")} + > + + + + {editing ? ( + <> + + setTitle(event.target.value)} + className="min-w-0 text-base font-medium" + maxLength={160} + required + /> + + ) : ( + <> + + {project.title} + + )} + + + {editing ? ( + <> + + + + ) : ( + <> + + + + + )} + + + + + } className="md:col-span-2"> + {editing ? ( + setLink(event.target.value)} + maxLength={2048} + placeholder="https://…" + /> + ) : project.link ? ( + + {project.link} + + ) : ( +

+ No link provided +

+ )} +
+ }> + {editing ? ( +