From 9cf184aa0e7036998919c16c1ffedf9af5a27c5d Mon Sep 17 00:00:00 2001 From: Ashlyn DeVries Date: Wed, 23 Apr 2025 21:06:34 -0400 Subject: [PATCH 1/8] almost done! --- components/CustomIconCreator.tsx | 206 ++++++++++++++++++ components/EventFlow.tsx | 24 +- components/Legend.tsx | 37 +++- components/LegendWrapper.tsx | 75 +++++++ lib/api/create/CreateCategories.ts | 128 +++++++++++ .../{createEvent.ts => xCreateEvent.ts} | 0 lib/api/read/GetAllCategories.ts | 25 +++ lib/api/read/GetEvent.ts | 5 + package.json | 1 + prisma/schema.prisma | 16 ++ 10 files changed, 503 insertions(+), 14 deletions(-) create mode 100644 components/CustomIconCreator.tsx create mode 100644 components/LegendWrapper.tsx create mode 100644 lib/api/create/CreateCategories.ts rename lib/api/create/{createEvent.ts => xCreateEvent.ts} (100%) create mode 100644 lib/api/read/GetAllCategories.ts diff --git a/components/CustomIconCreator.tsx b/components/CustomIconCreator.tsx new file mode 100644 index 0000000..0d54e71 --- /dev/null +++ b/components/CustomIconCreator.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { icons, LucideIcon } from "lucide-react"; +import React, { useState, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { CreateIcon } from "@/lib/api/create/CreateCategories"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Fuse from "fuse.js"; + +const CustomIconCreator = ({ + open, + onOpenChange, + categories, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + categories: { + id: string; + title: string; + value: string; + items: { + icon: LucideIcon; + label: string; + }[]; + }[]; +}) => { + const [searchQuery, setSearchQuery] = useState(""); + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); + const [selectedCategory, setSelectedCategory] = useState(""); + const [customName, setCustomName] = useState(""); + const [selectedIcon, setSelectedIcon] = useState< + [string, React.ComponentType] | null + >(null); + + const fuse = useMemo(() => { + const iconEntries = Object.entries(icons).map(([name, component]) => ({ + name, + component, + })); + + return new Fuse(iconEntries, { + keys: ["name"], + threshold: 0.4, + }); + }, []); + + const filteredIcons = useMemo(() => { + if (!searchQuery.trim()) return Object.entries(icons); + + const results = fuse.search(searchQuery); + return results.map((result) => [result.item.name, result.item.component]); + }, [searchQuery, fuse]); + + const handleIconClick = (icon: [string, React.ComponentType]) => { + setSelectedIcon(icon); + + const formattedName = (icon[0] as string) + .replace(/([A-Z])/g, " $1") + .replace(/^./, (str) => str.toUpperCase()) + .trim(); + + setCustomName(formattedName); + setCategoryDialogOpen(true); + }; + + const handleDone = async () => { + const categoryId = categories.find( + (category) => category.value === selectedCategory + )?.id; + + await CreateIcon(selectedIcon[0] as string, customName.trim(), categoryId); + + setCategoryDialogOpen(false); + onOpenChange(false); + setSelectedIcon(null); + setSelectedCategory(""); + setCustomName(""); + }; + + const handleCancel = () => { + setCategoryDialogOpen(false); + setSelectedCategory(""); + setCustomName(""); + }; + + return ( + <> + + +
+ + Select Icon + + +
+ setSearchQuery(e.target.value)} + className="w-full p-2 border rounded-md focus:outline-none focus:ring-1 focus:ring-black" + /> +
+
+ +
+ {filteredIcons.length > 0 ? ( +
+ {filteredIcons.map(([name, Icon]) => ( +
+ handleIconClick([ + name as string, + Icon as React.ComponentType, + ]) + } + > + +
+ ))} +
+ ) : ( +
+

No icons found matching search query.

+
+ )} +
+
+
+ + + + + Customize Icon + +
+ {selectedIcon && ( +
+ {React.createElement(selectedIcon[1], { size: 32 })} +
+ )} + +
+ + setCustomName(e.target.value)} + /> +
+ +
+ + +
+
+ + + + +
+
+ + ); +}; + +export default CustomIconCreator; diff --git a/components/EventFlow.tsx b/components/EventFlow.tsx index 994185d..a57cc05 100644 --- a/components/EventFlow.tsx +++ b/components/EventFlow.tsx @@ -26,7 +26,7 @@ import SaveState from "@/lib/api/update/ReactFlowSave"; import { CustomImageNode } from "@components/CustomImageNode"; import EventMapSelect from "@components/EventMapSelect"; import { ActiveNodeContext, IconNode } from "@components/IconNode"; -import Legend from "@components/Legend"; +import LegendWrapper from "@components/LegendWrapper"; import ControlButtons from "./ControlButtons"; // Types @@ -72,12 +72,14 @@ function Flow({ const eventLocations = useRef>( event.locations.map((l) => l.location) ).current; - + const [nodes, setNodes] = useState(() => { - const savedState = eventLocation?.state ? JSON.parse(eventLocation.state) : {}; + const savedState = eventLocation?.state + ? JSON.parse(eventLocation.state) + : {}; return savedState?.nodes || []; }); - + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); const rfInstance = useReactFlow(); @@ -91,11 +93,11 @@ function Flow({ // Subscribe to real-time updates with proper client ID filtering useChannel("event-updates", "subscribe", (message) => { const { eventId, locationId, senderClientId } = message.data; - + // Skip processing messages from this client if ( senderClientId === clientId || - eventId !== event.id || + eventId !== event.id || locationId !== eventLocation?.locationId ) { return; @@ -467,7 +469,7 @@ function Flow({ () => ({ activeNodeId, setActiveNodeId }), [activeNodeId, setActiveNodeId] ); - + return (
@@ -494,7 +496,7 @@ function Flow({ {/* Hide legend on view only mode */} {isEditable && ( - + )} {isEditable && ( @@ -506,7 +508,11 @@ function Flow({ /> )} - +
diff --git a/components/Legend.tsx b/components/Legend.tsx index a3dddf1..548e669 100644 --- a/components/Legend.tsx +++ b/components/Legend.tsx @@ -6,20 +6,34 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { LucideIcon } from "lucide-react"; -import React, { useEffect } from "react"; +import { LucideIcon, PlusCircle } from "lucide-react"; +import React, { useEffect, useState } from "react"; import { DraggableEvent } from "react-draggable"; -import categories from "./Categories"; import LegendItem from "./LegendItem"; +import CustomIconCreator from "./CustomIconCreator"; // Updated interface with generics interface LegendProps { isGettingStarted: boolean; onDrop: (event: DraggableEvent, icon: LucideIcon, label: string) => void; + categories: { + id: string; + title: string; + value: string; + items: { + icon: LucideIcon; + label: string; + }[]; + }[]; } -const Legend: React.FC = ({ isGettingStarted, onDrop }) => { +const Legend: React.FC = ({ + isGettingStarted, + onDrop, + categories, +}) => { const isMobile = /Mobi|Android/i.test(navigator?.userAgent); + const [customIconDialogOpen, setCustomIconDialogOpen] = useState(false); // complicated (but only) way of effectively forcing the panel open // so mobile users see it before it hides. any interaction with the page @@ -57,7 +71,13 @@ const Legend: React.FC = ({ isGettingStarted, onDrop }) => { "absolute left-0 transform -translate-x-full hover:translate-x-0" } transition-transform duration-700 ease-in-out`} > -

ICONS

+
+

ICONS

+ setCustomIconDialogOpen(true)} + /> +
{categories.map( (category) => @@ -81,6 +101,13 @@ const Legend: React.FC = ({ isGettingStarted, onDrop }) => { ) )} + + {/* Custom Icon Dialog */} + ); }; diff --git a/components/LegendWrapper.tsx b/components/LegendWrapper.tsx new file mode 100644 index 0000000..4524004 --- /dev/null +++ b/components/LegendWrapper.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { LucideIcon } from "lucide-react"; +import { DraggableEvent } from "react-draggable"; +import { icons } from "lucide-react"; +import { GetAllCategories } from "@/lib/api/read/GetAllCategories"; +import Legend from "./Legend"; + +function LegendWrapper({ + isGettingStarted, + onDrop, +}: { + isGettingStarted: boolean; + onDrop: (event: DraggableEvent, icon: LucideIcon, label: string) => void; +}) { + type Category = { + id: string; + title: string; + value: string; + items: { + icon: LucideIcon; + label: string; + }[]; + }; + + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchCategories() { + try { + const dbCategories = await GetAllCategories(); + if (dbCategories) { + const formattedCategories = dbCategories.map((category) => { + const value = category.name.toLowerCase().replace(/\s+/g, "-"); + return { + id: category.id, + title: category.name, + value: value, + items: category.icons.map((icon) => { + return { + icon: icons[icon.name as keyof typeof icons], + label: icon.customName || icon.name, + }; + }), + }; + }); + + setCategories(formattedCategories); + } + } catch (error) { + console.error("Error fetching categories:", error); + } finally { + setLoading(false); + } + } + + fetchCategories(); + }, []); + + if (loading) { + return
Loading icons...
; + } + + return ( + + ); +} + +export default LegendWrapper; diff --git a/lib/api/create/CreateCategories.ts b/lib/api/create/CreateCategories.ts new file mode 100644 index 0000000..f368436 --- /dev/null +++ b/lib/api/create/CreateCategories.ts @@ -0,0 +1,128 @@ +"use server"; + +import { prisma } from "../db"; + +interface IconItem { + iconName: string; + label: string; +} + +interface CategoryData { + title: string; + value: string; + items: IconItem[]; +} + +export async function SeedCategoriesAndIcons() { + const categories: CategoryData[] = [ + { + title: "Outdoor Equipment", + value: "outdoor-equipment", + items: [ + { iconName: "TrafficCone", label: "Cone" }, + { iconName: "Signpost", label: "A-Frame" }, + ], + }, + { + title: "Indoor Equipment", + value: "indoor-equipment", + items: [ + { iconName: "Trash2", label: "Trash Cans" }, + { iconName: "Recycle", label: "Recycling" }, + { iconName: "Fence", label: "Stanchions" }, + { iconName: "Armchair", label: "Chairs" }, + { iconName: "Theater", label: "Stage Items" }, + ], + }, + { + title: "Tech", + value: "tech", + items: [ + { iconName: "Radio", label: "Soundboard" }, + { iconName: "Speaker", label: "Speakers" }, + { iconName: "Lightbulb", label: "Lights" }, + { iconName: "Tv", label: "TVs" }, + ], + }, + { + title: "Getting Started", + value: "getting-started", + items: [ + { iconName: "Tent", label: "Tents" }, + { iconName: "Flag", label: "Flags" }, + ], + }, + { + title: "Campus XMas", + value: "campus-xmas", + items: [ + { iconName: "TreePine", label: "Christmas Tree" }, + { iconName: "Gift", label: "Present" }, + ], + }, + { + title: "Yard Games", + value: "yard-games", + items: [ + { iconName: "Gamepad2", label: "Cornhole" }, + { iconName: "Gamepad2", label: "Spikeball" }, + { iconName: "Gamepad2", label: "Ping Pong" }, + { iconName: "Gamepad2", label: "9-Square" }, + { iconName: "Gamepad2", label: "Can-Jam" }, + ], + }, + { + title: "Rental Equipment", + value: "rental-equipment", + items: [ + { iconName: "Coffee", label: "Coffee Cart" }, + { iconName: "Truck", label: "Food Trucks" }, + { iconName: "Table", label: "6ft Table" }, + { iconName: "Table", label: "Bistro Table" }, + { iconName: "Pickaxe", label: "Round Table" }, + ], + }, + ]; + + const results = []; + + for (const category of categories) { + const createdCategory = await prisma.category.create({ + data: { + name: category.title, + }, + }); + + for (const item of category.items) { + const createdIcon = await prisma.icon.create({ + data: { + name: item.iconName, + customName: item.label, + categoryId: createdCategory.id, + }, + }); + + results.push(createdIcon); + } + } + + return results; +} + +export async function CreateCategory(name: string) { + return await prisma.category.create({ + data: { + name, + }, + }); +} + +export async function CreateIcon(name: string, customName: string, categoryId: string) { + return await prisma.icon.create({ + data: { + name, + customName, + categoryId, + }, + }); +} \ No newline at end of file diff --git a/lib/api/create/createEvent.ts b/lib/api/create/xCreateEvent.ts similarity index 100% rename from lib/api/create/createEvent.ts rename to lib/api/create/xCreateEvent.ts diff --git a/lib/api/read/GetAllCategories.ts b/lib/api/read/GetAllCategories.ts new file mode 100644 index 0000000..bb1f588 --- /dev/null +++ b/lib/api/read/GetAllCategories.ts @@ -0,0 +1,25 @@ +"use server"; + +import { prisma } from "../db"; + +export async function GetAllCategories() { + const categories = await prisma.category.findMany({ + select: { + id: true, + name: true, + icons: { + select: { + id: true, + name: true, + customName: true, + } + } + } + }); + + if (!categories) { + return null; + } + + return categories; +} \ No newline at end of file diff --git a/lib/api/read/GetEvent.ts b/lib/api/read/GetEvent.ts index 4479ea3..a06c799 100644 --- a/lib/api/read/GetEvent.ts +++ b/lib/api/read/GetEvent.ts @@ -11,5 +11,10 @@ export async function GetEvent(eventId: string) { locations: true, }, }); + + if (!event) { + return null; + } + return event; } diff --git a/package.json b/package.json index e8238a2..2c7be95 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "ably": "^2.6.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "fuse.js": "^7.1.0", "lucide-react": "^0.454.0", "next": "^15.2.4", "next-auth": "^4.24.11", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 022925d..91af6dd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,22 @@ model Event { locations EventToLocation[] } +model Category { + id String @id @default(cuid()) + name String + icons Icon[] +} + +model Icon { + id String @id @default(cuid()) + name String + customName String? + categoryId String + category Category @relation(fields: [categoryId], references: [id]) + + @@unique([name, customName, categoryId]) +} + model EventToLocation { id String @id @default(cuid()) eventId String From 2087e138509554af47df7600b113059dbdaaf786 Mon Sep 17 00:00:00 2001 From: Ashlyn DeVries Date: Wed, 23 Apr 2025 21:07:02 -0400 Subject: [PATCH 2/8] name err --- lib/api/create/{xCreateEvent.ts => CreateEvent.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/api/create/{xCreateEvent.ts => CreateEvent.ts} (100%) diff --git a/lib/api/create/xCreateEvent.ts b/lib/api/create/CreateEvent.ts similarity index 100% rename from lib/api/create/xCreateEvent.ts rename to lib/api/create/CreateEvent.ts From 2f63a52549c3528f87059dceab40d7026bb3a156 Mon Sep 17 00:00:00 2001 From: Ashlyn DeVries Date: Wed, 23 Apr 2025 21:08:01 -0400 Subject: [PATCH 3/8] name err --- components/EventSelectForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/EventSelectForm.tsx b/components/EventSelectForm.tsx index 3ae526f..98ebacd 100644 --- a/components/EventSelectForm.tsx +++ b/components/EventSelectForm.tsx @@ -32,7 +32,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import CreateEvent from "@/lib/api/create/createEvent"; +import CreateEvent from "@/lib/api/create/CreateEvent"; import DeleteEntity from "@/lib/api/delete/DeleteEntity"; import { GetEvent } from "@/lib/api/read/GetEvent"; import AddIcon from "@mui/icons-material/Add"; From 258eaa744300cdc9e39d9dd075ce92ec9c294edf Mon Sep 17 00:00:00 2001 From: Ashlyn DeVries Date: Wed, 23 Apr 2025 21:08:46 -0400 Subject: [PATCH 4/8] rm file --- components/Categories.ts | 94 ---------------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 components/Categories.ts diff --git a/components/Categories.ts b/components/Categories.ts deleted file mode 100644 index fe13c9e..0000000 --- a/components/Categories.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - Armchair, - Coffee, - Fence, - Flag, - Gamepad2, - Gift, - Lightbulb, - Pickaxe, - Radio, - Recycle, - Signpost, - Speaker, - Table, - Tent, - Theater, - TrafficCone, - Trash2, - TreePine, - Truck, - Tv, -} from "lucide-react"; - -const categories = [ - { - title: "Outdoor Equipment", - value: "outdoor-equipment", - items: [ - { icon: TrafficCone, label: "Cone" }, - { icon: Signpost, label: "A-Frame" }, - ], - }, - { - title: "Indoor Equipment", - value: "indoor-equipment", - items: [ - { icon: Trash2, label: "Trash Cans" }, - { icon: Recycle, label: "Recycling" }, - { icon: Fence, label: "Stanchions" }, - { icon: Armchair, label: "Chairs" }, - { icon: Theater, label: "Stage Items" }, - ], - }, - { - title: "Tech", - value: "tech", - items: [ - { icon: Radio, label: "Soundboard" }, - { icon: Speaker, label: "Speakers" }, - { icon: Lightbulb, label: "Lights" }, - { icon: Tv, label: "TVs" }, - ], - }, - { - title: "Getting Started", - value: "getting-started", - items: [ - { icon: Tent, label: "Tents" }, - { icon: Flag, label: "Flags" }, - ], - }, - { - title: "Campus XMas", - value: "campus-xmas", - items: [ - { icon: TreePine, label: "Christmas Tree" }, - { icon: Gift, label: "Present" }, - ], - }, - { - title: "Yard Games", - value: "yard-games", - items: [ - { icon: Gamepad2, label: "Cornhole" }, - { icon: Gamepad2, label: "Spikeball" }, - { icon: Gamepad2, label: "Ping Pong" }, - { icon: Gamepad2, label: "9-Square" }, - { icon: Gamepad2, label: "Can-Jam" }, - ], - }, - { - title: "Rental Equipment", - value: "rental-equipment", - items: [ - { icon: Coffee, label: "Coffee Cart" }, - { icon: Truck, label: "Food Trucks" }, - { icon: Table, label: "6ft Table" }, - { icon: Table, label: "Bistro Table" }, - { icon: Pickaxe, label: "Round Table" }, - ], - }, -]; - -export default categories; \ No newline at end of file From c00854a1a6f3063ab860985078d05c07520a54dc Mon Sep 17 00:00:00 2001 From: Ashlyn DeVries Date: Mon, 28 Apr 2025 21:25:54 -0400 Subject: [PATCH 5/8] done --- components/CustomIconCreator.tsx | 11 +++++++++-- components/Legend.tsx | 19 ++++++++++++++----- components/LegendWrapper.tsx | 7 ++++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/components/CustomIconCreator.tsx b/components/CustomIconCreator.tsx index 0d54e71..532fbd8 100644 --- a/components/CustomIconCreator.tsx +++ b/components/CustomIconCreator.tsx @@ -25,10 +25,12 @@ import Fuse from "fuse.js"; const CustomIconCreator = ({ open, onOpenChange, + onAddChange, categories, }: { open: boolean; onOpenChange: (open: boolean) => void; + onAddChange: (refresh: boolean) => void; categories: { id: string; title: string; @@ -83,10 +85,15 @@ const CustomIconCreator = ({ (category) => category.value === selectedCategory )?.id; - await CreateIcon(selectedIcon[0] as string, customName.trim(), categoryId); + await CreateIcon( + selectedIcon![0] as string, + customName.trim(), + categoryId! + ); setCategoryDialogOpen(false); onOpenChange(false); + onAddChange(true); setSelectedIcon(null); setSelectedCategory(""); setCustomName(""); @@ -153,7 +160,7 @@ const CustomIconCreator = ({
{selectedIcon && (
- {React.createElement(selectedIcon[1], { size: 32 })} + {React.createElement(selectedIcon[1])}
)} diff --git a/components/Legend.tsx b/components/Legend.tsx index 548e669..2b9e299 100644 --- a/components/Legend.tsx +++ b/components/Legend.tsx @@ -6,7 +6,7 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { LucideIcon, PlusCircle } from "lucide-react"; +import { LucideIcon, PlusSquare, MinusSquare } from "lucide-react"; import React, { useEffect, useState } from "react"; import { DraggableEvent } from "react-draggable"; import LegendItem from "./LegendItem"; @@ -16,6 +16,7 @@ import CustomIconCreator from "./CustomIconCreator"; interface LegendProps { isGettingStarted: boolean; onDrop: (event: DraggableEvent, icon: LucideIcon, label: string) => void; + onAdd: (refresh: boolean) => void; categories: { id: string; title: string; @@ -30,6 +31,7 @@ interface LegendProps { const Legend: React.FC = ({ isGettingStarted, onDrop, + onAdd, categories, }) => { const isMobile = /Mobi|Android/i.test(navigator?.userAgent); @@ -73,10 +75,16 @@ const Legend: React.FC = ({ >

ICONS

- setCustomIconDialogOpen(true)} - /> +
+ {}} + /> + setCustomIconDialogOpen(true)} + /> +
{categories.map( @@ -106,6 +114,7 @@ const Legend: React.FC = ({
diff --git a/components/LegendWrapper.tsx b/components/LegendWrapper.tsx index 4524004..757ba58 100644 --- a/components/LegendWrapper.tsx +++ b/components/LegendWrapper.tsx @@ -26,9 +26,12 @@ function LegendWrapper({ const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); + const [refresh, setRefresh] = useState(true); useEffect(() => { async function fetchCategories() { + if (!refresh) return; + try { const dbCategories = await GetAllCategories(); if (dbCategories) { @@ -53,11 +56,12 @@ function LegendWrapper({ console.error("Error fetching categories:", error); } finally { setLoading(false); + setRefresh(false); } } fetchCategories(); - }, []); + }, [refresh]); if (loading) { return
Loading icons...
; @@ -68,6 +72,7 @@ function LegendWrapper({ isGettingStarted={isGettingStarted} onDrop={onDrop} categories={categories} + onAdd={setRefresh} /> ); } From 8f6b0ef26b72531ee4d872db44e03a92017c1afd Mon Sep 17 00:00:00 2001 From: Ashlyn DeVries Date: Mon, 28 Apr 2025 22:24:40 -0400 Subject: [PATCH 6/8] rly done --- components/CustomIconCreator.tsx | 6 +- components/Legend.tsx | 160 ++++++++++++++++++++++++++----- components/LegendItem.tsx | 41 ++++++-- components/LegendWrapper.tsx | 4 +- lib/api/delete/DeleteIcons.ts | 15 +++ 5 files changed, 194 insertions(+), 32 deletions(-) create mode 100644 lib/api/delete/DeleteIcons.ts diff --git a/components/CustomIconCreator.tsx b/components/CustomIconCreator.tsx index 532fbd8..c964b09 100644 --- a/components/CustomIconCreator.tsx +++ b/components/CustomIconCreator.tsx @@ -25,12 +25,12 @@ import Fuse from "fuse.js"; const CustomIconCreator = ({ open, onOpenChange, - onAddChange, + onIconsChange, categories, }: { open: boolean; onOpenChange: (open: boolean) => void; - onAddChange: (refresh: boolean) => void; + onIconsChange: (refresh: boolean) => void; categories: { id: string; title: string; @@ -93,7 +93,7 @@ const CustomIconCreator = ({ setCategoryDialogOpen(false); onOpenChange(false); - onAddChange(true); + onIconsChange(true); setSelectedIcon(null); setSelectedCategory(""); setCustomName(""); diff --git a/components/Legend.tsx b/components/Legend.tsx index 2b9e299..97a8566 100644 --- a/components/Legend.tsx +++ b/components/Legend.tsx @@ -6,22 +6,31 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { LucideIcon, PlusSquare, MinusSquare } from "lucide-react"; +import { + LucideIcon, + PlusCircle, + MinusCircle, + Pencil, + CircleCheck, + CircleX, +} from "lucide-react"; import React, { useEffect, useState } from "react"; import { DraggableEvent } from "react-draggable"; import LegendItem from "./LegendItem"; import CustomIconCreator from "./CustomIconCreator"; +import { DeleteIcons } from "@/lib/api/delete/DeleteIcons"; // Updated interface with generics interface LegendProps { isGettingStarted: boolean; onDrop: (event: DraggableEvent, icon: LucideIcon, label: string) => void; - onAdd: (refresh: boolean) => void; + onIconsChange: (refresh: boolean) => void; categories: { id: string; title: string; value: string; items: { + id: string; icon: LucideIcon; label: string; }[]; @@ -31,11 +40,15 @@ interface LegendProps { const Legend: React.FC = ({ isGettingStarted, onDrop, - onAdd, + onIconsChange, categories, }) => { const isMobile = /Mobi|Android/i.test(navigator?.userAgent); const [customIconDialogOpen, setCustomIconDialogOpen] = useState(false); + const [iconsToDelete, setIconsToDelete] = useState>(new Set()); + const [editMode, setEditMode] = useState<"default" | "options" | "deletion">( + "default" + ); // complicated (but only) way of effectively forcing the panel open // so mobile users see it before it hides. any interaction with the page @@ -65,6 +78,99 @@ const Legend: React.FC = ({ } }, []); + useEffect(() => { + if (!customIconDialogOpen && editMode !== "default") { + setEditMode("default"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [customIconDialogOpen]); + + const handleEditClick = () => { + setEditMode("options"); + }; + + const handleDeleteClick = () => { + setEditMode("deletion"); + }; + + const handleAddClick = () => { + setCustomIconDialogOpen(true); + }; + + const handleConfirmDeletion = async () => { + await DeleteIcons(Array.from(iconsToDelete)); + + setIconsToDelete(new Set()); + setEditMode("default"); + onIconsChange(true); // Refresh the icons after deletion + }; + + const handleCancelDeletion = () => { + setIconsToDelete(new Set()); + setEditMode("default"); + }; + + const toggleIconForDeletion = (iconId: string) => { + setIconsToDelete((prev) => { + const newSet = new Set(prev); + if (newSet.has(iconId)) { + newSet.delete(iconId); + } else { + newSet.add(iconId); + } + return newSet; + }); + }; + + const isIconSelectedForDeletion = (iconId: string) => { + return iconsToDelete.has(iconId); + }; + + const renderActionButtons = () => { + switch (editMode) { + case "default": + return ( + + ); + case "options": + return ( + <> + + + + ); + case "deletion": + return ( + <> + + + + ); + default: + return null; + } + }; + return (
= ({ >

ICONS

-
- {}} - /> - setCustomIconDialogOpen(true)} - /> -
+
{renderActionButtons()}
{categories.map( @@ -95,14 +192,33 @@ const Legend: React.FC = ({ {category.title}
- {category.items.map((item, index) => ( - - ))} + {category.items.map((item, index) => { + return ( +
+ {editMode === "deletion" && ( +
toggleIconForDeletion(item.id)} + > + +
+ )} + +
+ ); + })}
@@ -114,7 +230,7 @@ const Legend: React.FC = ({
diff --git a/components/LegendItem.tsx b/components/LegendItem.tsx index 11bb59c..7b6ad02 100644 --- a/components/LegendItem.tsx +++ b/components/LegendItem.tsx @@ -8,12 +8,14 @@ interface LegendItemProps { icon: LucideIcon; label: string; onDrop: (event: DraggableEvent, icon: LucideIcon, label: string) => void; + isSelected?: boolean; } const LegendItem: React.FC = ({ icon: Icon, label, onDrop, + isSelected = false, }) => { // Create a ref to pass to Draggable component const nodeRef = useRef(null!); @@ -43,11 +45,25 @@ const LegendItem: React.FC = ({ {/* Static copy that stays in place */}
- - {label} + + + {label} +
{/* Draggable element with pointer-events */} @@ -57,10 +73,13 @@ const LegendItem: React.FC = ({ onStop={handleDragEnd} position={{ x: 0, y: 0 }} enableUserSelectHack={true} + disabled={isSelected} // Disable dragging when selected for deletion >
= ({ userSelect: "none", // Additional explicit styling to prevent selection }} > - - {label} + + + {label} +
diff --git a/components/LegendWrapper.tsx b/components/LegendWrapper.tsx index 757ba58..3317088 100644 --- a/components/LegendWrapper.tsx +++ b/components/LegendWrapper.tsx @@ -19,6 +19,7 @@ function LegendWrapper({ title: string; value: string; items: { + id: string; icon: LucideIcon; label: string; }[]; @@ -43,6 +44,7 @@ function LegendWrapper({ value: value, items: category.icons.map((icon) => { return { + id: icon.id, icon: icons[icon.name as keyof typeof icons], label: icon.customName || icon.name, }; @@ -72,7 +74,7 @@ function LegendWrapper({ isGettingStarted={isGettingStarted} onDrop={onDrop} categories={categories} - onAdd={setRefresh} + onIconsChange={setRefresh} /> ); } diff --git a/lib/api/delete/DeleteIcons.ts b/lib/api/delete/DeleteIcons.ts new file mode 100644 index 0000000..1a87908 --- /dev/null +++ b/lib/api/delete/DeleteIcons.ts @@ -0,0 +1,15 @@ +"use server"; + +import { prisma } from "../db"; + +export async function DeleteIcons(idsArray: string[]) { + if (idsArray.length === 0) return; + + await prisma.icon.deleteMany({ + where: { + id: { + in: idsArray, + }, + }, + }); +} From 91dd7e08486d406fac63b14568eab934004a6f67 Mon Sep 17 00:00:00 2001 From: Ashlyn DeVries Date: Mon, 28 Apr 2025 22:27:23 -0400 Subject: [PATCH 7/8] name err --- components/CustomIconCreator.tsx | 2 +- lib/api/create/{CreateCategories.ts => CreateIcon.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename lib/api/create/{CreateCategories.ts => CreateIcon.ts} (100%) diff --git a/components/CustomIconCreator.tsx b/components/CustomIconCreator.tsx index c964b09..12269f1 100644 --- a/components/CustomIconCreator.tsx +++ b/components/CustomIconCreator.tsx @@ -16,7 +16,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { CreateIcon } from "@/lib/api/create/CreateCategories"; +import { CreateIcon } from "@/lib/api/create/CreateIcon"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; diff --git a/lib/api/create/CreateCategories.ts b/lib/api/create/CreateIcon.ts similarity index 100% rename from lib/api/create/CreateCategories.ts rename to lib/api/create/CreateIcon.ts From 6e88ee8e0582bebb611921ed3460205f33e44293 Mon Sep 17 00:00:00 2001 From: Ashlyn DeVries Date: Mon, 28 Apr 2025 22:38:27 -0400 Subject: [PATCH 8/8] add unique constraint err msg --- components/CustomIconCreator.tsx | 56 ++++++++++++++++++++++++-------- lib/api/create/CreateIcon.ts | 30 ++++++++++------- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/components/CustomIconCreator.tsx b/components/CustomIconCreator.tsx index 12269f1..826bc48 100644 --- a/components/CustomIconCreator.tsx +++ b/components/CustomIconCreator.tsx @@ -21,6 +21,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import Fuse from "fuse.js"; +import { AlertCircle } from "lucide-react"; const CustomIconCreator = ({ open, @@ -45,6 +46,7 @@ const CustomIconCreator = ({ const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); const [selectedCategory, setSelectedCategory] = useState(""); const [customName, setCustomName] = useState(""); + const [error, setError] = useState(null); const [selectedIcon, setSelectedIcon] = useState< [string, React.ComponentType] | null >(null); @@ -70,6 +72,7 @@ const CustomIconCreator = ({ const handleIconClick = (icon: [string, React.ComponentType]) => { setSelectedIcon(icon); + setError(null); const formattedName = (icon[0] as string) .replace(/([A-Z])/g, " $1") @@ -85,24 +88,36 @@ const CustomIconCreator = ({ (category) => category.value === selectedCategory )?.id; - await CreateIcon( - selectedIcon![0] as string, - customName.trim(), - categoryId! - ); - - setCategoryDialogOpen(false); - onOpenChange(false); - onIconsChange(true); - setSelectedIcon(null); - setSelectedCategory(""); - setCustomName(""); + try { + await CreateIcon( + selectedIcon![0] as string, + customName.trim(), + categoryId! + ); + + setCategoryDialogOpen(false); + onOpenChange(false); + onIconsChange(true); + setSelectedIcon(null); + setSelectedCategory(""); + setCustomName(""); + setError(null); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + + if (errorMessage.includes("Unique constraint failed")) { + setError("This icon name already exists in this category"); + } else { + setError("Failed to create icon"); + } + } }; const handleCancel = () => { setCategoryDialogOpen(false); setSelectedCategory(""); setCustomName(""); + setError(null); }; return ( @@ -169,7 +184,10 @@ const CustomIconCreator = ({ setCustomName(e.target.value)} + onChange={(e) => { + setCustomName(e.target.value); + setError(null); + }} /> @@ -177,7 +195,10 @@ const CustomIconCreator = ({ + + {error && ( +
+ + {error} +
+ )}