diff --git a/components/Categories.ts b/components/Categories.ts new file mode 100644 index 0000000..fe13c9e --- /dev/null +++ b/components/Categories.ts @@ -0,0 +1,94 @@ +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/EventFlow.tsx b/components/EventFlow.tsx index 32f7968..994185d 100644 --- a/components/EventFlow.tsx +++ b/components/EventFlow.tsx @@ -1,36 +1,38 @@ "use client"; -import { useCallback, useEffect, useRef, useState, useMemo } from "react"; -import { ChannelProvider, useChannel } from "ably/react"; +import { useMemo } from "react"; import { createId } from "@paralleldrive/cuid2"; +import { Event, EventToLocation, Location } from "@prisma/client"; import { + Controls, + Panel, ReactFlow, ReactFlowProvider, - Controls, useReactFlow, NodeChange, applyNodeChanges, - Edge, - ReactFlowInstance, BackgroundVariant, Background, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { Event, EventToLocation, Location } from "@prisma/client"; +import { ChannelProvider, useChannel } from "ably/react"; +import { useCallback, useEffect, useRef, useState } from "react"; // API imports import { GetEventLocationInfo } from "@/lib/api/read/GetEventLocationInfo"; import SaveState from "@/lib/api/update/ReactFlowSave"; // Component imports -import { ActiveNodeContext, IconNode } from "@components/IconNode"; import { CustomImageNode } from "@components/CustomImageNode"; -import Legend from "@components/Legend"; import EventMapSelect from "@components/EventMapSelect"; +import { ActiveNodeContext, IconNode } from "@components/IconNode"; +import Legend from "@components/Legend"; import ControlButtons from "./ControlButtons"; // Types import { CustomNode } from "@/types/CustomNode"; +import { LucideIcon } from "lucide-react"; +import { DraggableEvent } from "react-draggable"; const getId = () => createId(); const clientId = createId(); @@ -61,6 +63,7 @@ function Flow({ }) { // Refs const timeoutId = useRef(); + const reactFlowWrapper = useRef(null); const isInitialLoad = useRef(true); const eventLocation = event.locations.find((l) => l.locationId === location); @@ -76,10 +79,7 @@ function Flow({ }); const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); - const [rfInstance, setRfInstance] = useState | null>(null); + const rfInstance = useReactFlow(); // History management const [undoStack, setUndoStack] = useState([]); @@ -361,88 +361,107 @@ function Flow({ } }, [redoStack, rfInstance, setNodes, setUndoStack]); - /** - * Handle drag over for node placement - */ - const onDragOver = useCallback( - (event: React.DragEvent) => { - event.preventDefault(); - // Block drag overs in view mode - if (isEditable) { - event.dataTransfer.dropEffect = "move"; - } - }, - [isEditable] - ); + const hasInitialNodesLoaded = useRef(false); - /** - * Handle node drop - */ - const onDrop = useCallback( - (event: React.DragEvent) => { - // Block drag and drops in view mode - if (!isEditable) return; + // Call fitView when the map node has loaded + useEffect(() => { + if (!hasInitialNodesLoaded.current) { + const observer = new MutationObserver(() => { + const mapNode = document.querySelector('[data-id="map"]'); + if (mapNode) { + fitView(); + hasInitialNodesLoaded.current = true; + observer.disconnect(); // Stop observing once the node is found + } + }); - event.preventDefault(); + observer.observe(document.body, { childList: true, subtree: true }); - const jsonData = event.dataTransfer.getData("application/reactflow"); - if (!jsonData) return; + return () => observer.disconnect(); // Cleanup observer on unmount + } + }, [nodes, fitView]); - const { type, iconName, label } = JSON.parse(jsonData); + const onDrop = useCallback( + (event: DraggableEvent, icon: LucideIcon, label: string) => { + if (!reactFlowWrapper.current) return; + + // Get bounds of react flow wrapper + const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect(); + + // Drop position + let clientX = 0; + let clientY = 0; + + if (event instanceof MouseEvent) { + // MouseEvent = browser drop + clientX = (event as MouseEvent).clientX; + clientY = (event as MouseEvent).clientY; + } else if (event instanceof TouchEvent) { + // TouchEvent = mobile drop + clientX = (event as TouchEvent).changedTouches[0].clientX; + clientY = (event as TouchEvent).changedTouches[0].clientY; + } - const position = screenToFlowPosition({ - x: event.clientX, - y: event.clientY, + // Make sure coords are valid + if (isNaN(clientX) || isNaN(clientY)) { + console.error("Invalid coordinates:", { clientX, clientY }); + return; + } + // Calculate the drop position in the flow + // First get the raw position where the cursor is + const rawPosition = screenToFlowPosition({ + x: clientX - reactFlowBounds.left, + y: clientY - reactFlowBounds.top, }); + // Get the node dimensions from CSS to center it on cursor + // The CustomNode has a width of 100px as defined in CustomNode.tsx + const nodeWidth = 100; + // Estimate height based on padding in CustomNode.tsx (10px top + 10px bottom) + const nodeHeight = 40; + + // Calculate the position with offset to center the node on cursor + const position = { + x: rawPosition.x - nodeWidth / 2, + y: rawPosition.y - nodeHeight / 2, + }; + + // Ensure position values are valid numbers + if (isNaN(position.x) || isNaN(position.y)) { + console.error("Invalid position:", position); + return; + } + + console.log("Drop position:", position, "Icon:", icon.displayName); + + // Create a new node const newNode: CustomNode = { id: getId(), - type, + type: "iconNode", position, data: { label, - iconName, + iconName: icon.displayName, color: "#57B9FF", rotation: 0, }, - draggable: true, - deletable: true, - parentId: "map", - extent: "parent", dragging: false, zIndex: 0, selectable: true, + deletable: true, selected: false, - isConnectable: false, - positionAbsoluteX: 0, - positionAbsoluteY: 0, + draggable: true, + isConnectable: true, + positionAbsoluteX: position.x, + positionAbsoluteY: position.y, }; - setNodes((nds) => [...nds, newNode]); + // Add the new node to the flow + setNodes((nds) => nds.concat(newNode)); }, - [screenToFlowPosition, setNodes, isEditable] + [screenToFlowPosition, setNodes] ); - const hasInitialNodesLoaded = useRef(false); - - // Call fitView when the map node has loaded - useEffect(() => { - if (!hasInitialNodesLoaded.current) { - const observer = new MutationObserver(() => { - const mapNode = document.querySelector('[data-id="map"]'); - if (mapNode) { - fitView(); - hasInitialNodesLoaded.current = true; - observer.disconnect(); // Stop observing once the node is found - } - }); - - observer.observe(document.body, { childList: true, subtree: true }); - - return () => observer.disconnect(); // Cleanup observer on unmount - } - }, [nodes, fitView]); - // Memoize the active node context value const activeNodeContextValue = useMemo( () => ({ activeNodeId, setActiveNodeId }), @@ -451,16 +470,14 @@ function Flow({ return ( -
+
setActiveNodeId(node.id)} // Fix the onNodeClick handler zoomOnScroll={false} panOnScroll={false} - onDrop={onDrop} - onDragOver={onDragOver} - onInit={setRfInstance} nodeTypes={nodeTypes} nodesDraggable={isEditable} elementsSelectable={isEditable} @@ -475,7 +492,11 @@ function Flow({ {/* Hide legend on view only mode */} - {isEditable && } + {isEditable && ( + + + + )} {isEditable && ( { - const onDragStart = (event: React.DragEvent) => { - const data = { - type: "iconNode", - iconName: Icon.displayName, // Use the display name of the Lucide icon - label, - }; - event.dataTransfer.setData("application/reactflow", JSON.stringify(data)); - event.dataTransfer.effectAllowed = "move"; - }; - - return ( -
- - {label} -
- ); -}; - -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: [ - //Rinnova Coffee Cart, Food Trucks, Tables (6ft, Bistro, Round) - { 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" }, - ], - }, -]; +import { LucideIcon } from "lucide-react"; +import React, { useEffect } from "react"; +import { DraggableEvent } from "react-draggable"; +import categories from "./Categories"; +import LegendItem from "./LegendItem"; -export default function Legend({ - isGettingStarted, -}: { +// Updated interface with generics +interface LegendProps { isGettingStarted: boolean; -}) { + onDrop: (event: DraggableEvent, icon: LucideIcon, label: string) => void; +} + +const Legend: React.FC = ({ isGettingStarted, onDrop }) => { const isMobile = /Mobi|Android/i.test(navigator?.userAgent); // complicated (but only) way of effectively forcing the panel open @@ -168,9 +50,8 @@ export default function Legend({ }, []); return ( - {categories.map( (category) => - (category.value != "getting-started" || - (category.value == "getting-started" && isGettingStarted)) && ( + (category.value !== "getting-started" || + (category.value === "getting-started" && isGettingStarted)) && ( {category.title}
{category.items.map((item, index) => ( - + ))}
@@ -195,6 +81,8 @@ export default function Legend({ ) )} -
+
); -} +}; + +export default Legend; diff --git a/components/LegendItem.tsx b/components/LegendItem.tsx new file mode 100644 index 0000000..11bb59c --- /dev/null +++ b/components/LegendItem.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { LucideIcon } from "lucide-react"; +import React, { useRef, useState } from "react"; +import Draggable, { DraggableEvent } from "react-draggable"; + +interface LegendItemProps { + icon: LucideIcon; + label: string; + onDrop: (event: DraggableEvent, icon: LucideIcon, label: string) => void; +} + +const LegendItem: React.FC = ({ + icon: Icon, + label, + onDrop, +}) => { + // Create a ref to pass to Draggable component + const nodeRef = useRef(null!); + + // Track dragging and hovering state + const [isDragging, setIsDragging] = useState(false); + const [isHovering, setIsHovering] = useState(false); + + // Handle drag start + const handleDragStart = () => { + setIsDragging(true); + }; + + // Handle drag end event from react-draggable + const handleDragEnd = (event: DraggableEvent) => { + setIsDragging(false); + onDrop(event, Icon, label); + }; + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + {/* Static copy that stays in place */} +
+ + {label} +
+ + {/* Draggable element with pointer-events */} + +
+ + {label} +
+
+
+ ); +}; + +export default LegendItem; diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx index 8dcf9b6..cd4b581 100644 --- a/components/ui/accordion.tsx +++ b/components/ui/accordion.tsx @@ -46,7 +46,7 @@ const AccordionContent = React.forwardRef< >(({ className, children, ...props }, ref) => (
{children}
diff --git a/package.json b/package.json index 8a28500..e8238a2 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-tabs": "^1.1.4", "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", "@xyflow/react": "^12.5.4", "ably": "^2.6.5", @@ -42,6 +42,7 @@ "prisma": "^6.5.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-draggable": "^4.4.6", "react-hook-form": "^7.55.0", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7",