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 diff --git a/components/CustomIconCreator.tsx b/components/CustomIconCreator.tsx new file mode 100644 index 0000000..826bc48 --- /dev/null +++ b/components/CustomIconCreator.tsx @@ -0,0 +1,241 @@ +"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/CreateIcon"; +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, + onOpenChange, + onIconsChange, + categories, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onIconsChange: (refresh: 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 [error, setError] = useState(null); + 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); + setError(null); + + 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; + + 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 ( + <> + + +
+ + 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])} +
+ )} + +
+ + { + setCustomName(e.target.value); + setError(null); + }} + /> +
+ +
+ + +
+ + {error && ( +
+ + {error} +
+ )} +
+ + + + +
+
+ + ); +}; + +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/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"; diff --git a/components/Legend.tsx b/components/Legend.tsx index a3dddf1..97a8566 100644 --- a/components/Legend.tsx +++ b/components/Legend.tsx @@ -6,20 +6,49 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { LucideIcon } from "lucide-react"; -import React, { useEffect } from "react"; +import { + LucideIcon, + PlusCircle, + MinusCircle, + Pencil, + CircleCheck, + CircleX, +} 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"; +import { DeleteIcons } from "@/lib/api/delete/DeleteIcons"; // Updated interface with generics interface LegendProps { isGettingStarted: boolean; onDrop: (event: DraggableEvent, icon: LucideIcon, label: string) => void; + onIconsChange: (refresh: boolean) => void; + categories: { + id: string; + title: string; + value: string; + items: { + id: string; + icon: LucideIcon; + label: string; + }[]; + }[]; } -const Legend: React.FC = ({ isGettingStarted, onDrop }) => { +const Legend: React.FC = ({ + isGettingStarted, + onDrop, + 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 @@ -49,6 +78,99 @@ const Legend: React.FC = ({ isGettingStarted, onDrop }) => { } }, []); + 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 (
= ({ isGettingStarted, onDrop }) => { "absolute left-0 transform -translate-x-full hover:translate-x-0" } transition-transform duration-700 ease-in-out`} > -

ICONS

+
+

ICONS

+
{renderActionButtons()}
+
{categories.map( (category) => @@ -67,20 +192,47 @@ const Legend: React.FC = ({ isGettingStarted, onDrop }) => { {category.title}
- {category.items.map((item, index) => ( - - ))} + {category.items.map((item, index) => { + return ( +
+ {editMode === "deletion" && ( +
toggleIconForDeletion(item.id)} + > + +
+ )} + +
+ ); + })}
) )}
+ + {/* Custom Icon Dialog */} +
); }; 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 new file mode 100644 index 0000000..3317088 --- /dev/null +++ b/components/LegendWrapper.tsx @@ -0,0 +1,82 @@ +"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: { + id: string; + icon: LucideIcon; + label: string; + }[]; + }; + + 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) { + 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 { + id: icon.id, + 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); + setRefresh(false); + } + } + + fetchCategories(); + }, [refresh]); + + if (loading) { + return
Loading icons...
; + } + + return ( + + ); +} + +export default LegendWrapper; diff --git a/lib/api/create/createEvent.ts b/lib/api/create/CreateEvent.ts similarity index 100% rename from lib/api/create/createEvent.ts rename to lib/api/create/CreateEvent.ts diff --git a/lib/api/create/CreateIcon.ts b/lib/api/create/CreateIcon.ts new file mode 100644 index 0000000..e2035f5 --- /dev/null +++ b/lib/api/create/CreateIcon.ts @@ -0,0 +1,136 @@ +"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 +) { + try { + return await prisma.icon.create({ + data: { + name, + customName, + categoryId, + }, + }); + } catch (error) { + throw error; + } +} 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, + }, + }, + }); +} 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