+
-
- {marker.label}
-
+
+
+ {marker.label}
+
+
);
}
diff --git a/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx b/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx
index c1338e10..0b9c2511 100644
--- a/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx
+++ b/src/app/map/[id]/components/controls/MarkersControl/MarkersControl.tsx
@@ -25,7 +25,7 @@ import { LayerType } from "@/types";
import { CollectionIcon } from "../../Icons";
import LayerControlWrapper from "../LayerControlWrapper";
import LayerHeader from "../LayerHeader";
-import MarkersList from "./MarkersList";
+import MarkersList from "../MarkersList";
export default function MarkersControl() {
const router = useRouter();
diff --git a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx b/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx
deleted file mode 100644
index 71200ed1..00000000
--- a/src/app/map/[id]/components/controls/MarkersControl/MarkersList.tsx
+++ /dev/null
@@ -1,504 +0,0 @@
-import {
- DndContext,
- DragOverlay,
- KeyboardSensor,
- PointerSensor,
- closestCorners,
- useSensor,
- useSensors,
-} from "@dnd-kit/core";
-import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
-import {
- SortableContext,
- sortableKeyboardCoordinates,
- verticalListSortingStrategy,
-} from "@dnd-kit/sortable";
-import { useQueryClient } from "@tanstack/react-query";
-import { useCallback, useMemo, useState } from "react";
-import { createPortal } from "react-dom";
-import {
- useMarkerDataSources,
- useMembersDataSource,
-} from "@/app/map/[id]/hooks/useDataSources";
-import {
- useFolderMutations,
- useFoldersQuery,
-} from "@/app/map/[id]/hooks/useFolders";
-import { useMapViews } from "@/app/map/[id]/hooks/useMapViews";
-import {
- usePlacedMarkerMutations,
- usePlacedMarkersQuery,
-} from "@/app/map/[id]/hooks/usePlacedMarkers";
-import { useTable } from "@/app/map/[id]/hooks/useTable";
-import {
- compareByPositionAndId,
- getNewFirstPosition,
- getNewLastPosition,
- getNewPositionAfter,
- getNewPositionBefore,
- sortByPositionAndId,
-} from "@/app/map/[id]/utils/position";
-import { useTRPC } from "@/services/trpc/react";
-import { LayerType } from "@/types";
-import { useMapId } from "../../../hooks/useMapCore";
-import DataSourceControl from "../DataSourceItem";
-import EmptyLayer from "../LayerEmptyMessage";
-import MarkerDragOverlay from "./MarkerDragOverlay";
-import SortableFolderItem from "./SortableFolderItem";
-import UnassignedFolder from "./UnassignedFolder";
-import type { DropdownMenuItemType } from "@/components/MultiDropdownMenu";
-import type { PlacedMarker } from "@/server/models/PlacedMarker";
-import type {
- DragEndEvent,
- DragOverEvent,
- DragStartEvent,
-} from "@dnd-kit/core";
-
-export default function MarkersList({
- dropdownItems,
-}: {
- dropdownItems?: DropdownMenuItemType[];
-}) {
- const mapId = useMapId();
- const trpc = useTRPC();
- const queryClient = useQueryClient();
- const { viewConfig } = useMapViews();
- const { data: folders = [] } = useFoldersQuery();
- const { updateFolder } = useFolderMutations();
- const { data: placedMarkers = [] } = usePlacedMarkersQuery();
- const { updatePlacedMarker } = usePlacedMarkerMutations();
- const { selectedDataSourceId, handleDataSourceSelect } = useTable();
- const markerDataSources = useMarkerDataSources();
- const membersDataSource = useMembersDataSource();
-
- const [activeId, setActiveId] = useState
(null);
-
- // Brief pulsing folder animation on some drag actions
- const [pulsingFolderId, _setPulsingFolderId] = useState(null);
-
- const setPulsingFolderId = useCallback((id: string | null) => {
- _setPulsingFolderId(id);
- setTimeout(() => {
- _setPulsingFolderId(null);
- }, 600);
- }, []);
-
- // Keep track of if a text input is active to disable keyboard dragging
- const [keyboardCapture, setKeyboardCapture] = useState(false);
-
- // Update cache only (for optimistic updates during drag) - NO mutation
- const updateMarkerInCache = useCallback(
- (placedMarker: Omit) => {
- if (!mapId) return;
-
- const fullMarker = {
- ...placedMarker,
- mapId,
- folderId: placedMarker.folderId ?? null,
- };
-
- queryClient.setQueryData(trpc.map.byId.queryKey({ mapId }), (old) => {
- if (!old) return old;
- return {
- ...old,
- placedMarkers:
- old.placedMarkers?.map((m) =>
- m.id === placedMarker.id ? fullMarker : m,
- ) || [],
- };
- });
- },
- [mapId, queryClient, trpc.map.byId],
- );
-
- // DnD sensors
- const sensors = useSensors(
- useSensor(PointerSensor, {
- activationConstraint: {
- distance: 8,
- },
- }),
- useSensor(KeyboardSensor, {
- coordinateGetter: sortableKeyboardCoordinates,
- // Disable keyboard while text input is active
- keyboardCodes: keyboardCapture
- ? { start: [], cancel: [], end: [] }
- : undefined,
- }),
- );
-
- // Drag and drop handlers
- const handleDragStart = useCallback((event: DragStartEvent) => {
- setActiveId(event.active.id.toString());
- }, []);
-
- const handleDragOver = useCallback(
- (event: DragOverEvent) => {
- const { active, over } = event;
-
- // Early exit if marker is not over anything or over itself
- if (!over || active.id === over.id) {
- return;
- }
-
- if (!mapId) return;
-
- // Handle moving into a different folder - CACHE UPDATE ONLY (no mutation)
- const activeMarkerId = active.id.toString().replace("marker-", "");
-
- // Get current cache data (reflects any previous drag over updates)
- const currentCacheData = queryClient.getQueryData(
- trpc.map.byId.queryKey({ mapId }),
- );
- const currentMarkers = currentCacheData?.placedMarkers || [];
-
- const activeMarker = currentMarkers.find((m) => m.id === activeMarkerId);
-
- if (!activeMarker) {
- return;
- }
-
- // Check if we're over another marker
- if (over.id.toString().startsWith("marker-")) {
- const overMarkerId = over.id.toString().replace("marker-", "");
- const overMarker = currentMarkers.find((m) => m.id === overMarkerId);
-
- // If we're over a marker in a DIFFERENT container, move to that container
- if (overMarker && overMarker.folderId !== activeMarker.folderId) {
- const activeWasBeforeOver =
- compareByPositionAndId(activeMarker, overMarker) < 0;
-
- // Get other markers in the target container
- const otherMarkers = currentMarkers.filter(
- (m) =>
- m.id !== activeMarker.id && m.folderId === overMarker.folderId,
- );
-
- const newPosition = activeWasBeforeOver
- ? getNewPositionAfter(overMarker.position, otherMarkers)
- : getNewPositionBefore(overMarker.position, otherMarkers);
-
- updateMarkerInCache({
- ...activeMarker,
- folderId: overMarker.folderId,
- position: newPosition,
- });
- }
- return;
- }
-
- if (over.id.toString().startsWith("folder")) {
- let folderId: string;
-
- // Handle header, footer, and draggable folder element IDs
- let append = false;
- if (over.id.toString().startsWith("folder-footer-")) {
- folderId = over.id.toString().replace("folder-footer-", "");
- append = true;
- } else if (over.id.toString().startsWith("folder-drag-")) {
- folderId = over.id.toString().replace("folder-drag-", "");
- append = true;
- } else {
- folderId = over.id.toString().replace("folder-", "");
- }
-
- const folderMarkers = currentMarkers.filter(
- (m) => m.folderId === folderId,
- );
-
- const newPosition = append
- ? getNewLastPosition(folderMarkers)
- : getNewFirstPosition(folderMarkers);
-
- // Update CACHE only - no mutation sent to server
- updateMarkerInCache({
- ...activeMarker,
- folderId,
- position: newPosition,
- });
- } else if (over.id === "unassigned") {
- // Only update cache if the marker is not already unassigned
- if (activeMarker.folderId !== null) {
- const unassignedMarkers = currentMarkers.filter(
- (m) => m.folderId === null,
- );
- const newPosition = getNewFirstPosition(unassignedMarkers);
-
- // Update CACHE only - no mutation sent to server
- updateMarkerInCache({
- ...activeMarker,
- folderId: null,
- position: newPosition,
- });
- }
- }
- },
- [mapId, queryClient, trpc.map.byId, updateMarkerInCache],
- );
-
- const handleDragEndMarker = useCallback(
- (event: DragEndEvent) => {
- const { active, over } = event;
-
- const activeMarkerId = active.id.toString().replace("marker-", "");
- const activeMarker = placedMarkers.find((m) => m.id === activeMarkerId);
-
- if (!activeMarker) {
- return;
- }
-
- // Handle moving into a different folder
- if (over && over.id.toString().startsWith("folder")) {
- let folderId: string;
-
- // Handle header, footer, and draggable folder element IDs
- let append = false;
- if (over.id.toString().startsWith("folder-footer-")) {
- folderId = over.id.toString().replace("folder-footer-", "");
- append = true;
- } else if (over.id.toString().startsWith("folder-drag-")) {
- folderId = over.id.toString().replace("folder-drag-", "");
- append = true;
- } else {
- folderId = over.id.toString().replace("folder-", "");
- }
-
- const folderMarkers = placedMarkers.filter(
- (m) => m.folderId === folderId,
- );
-
- const newPosition = append
- ? getNewLastPosition(folderMarkers)
- : getNewFirstPosition(folderMarkers);
-
- updatePlacedMarker({
- ...activeMarker,
- folderId,
- position: newPosition,
- });
-
- // Animate movement - pulse the folder that received the marker
- setPulsingFolderId(folderId);
- } else if (over && over.id === "unassigned") {
- const unassignedMarkers = placedMarkers.filter(
- (m) => m.folderId === null,
- );
- const newPosition = getNewFirstPosition(unassignedMarkers);
- updatePlacedMarker({
- ...activeMarker,
- folderId: null,
- position: newPosition,
- });
- } else if (over && over.id.toString().startsWith("marker-")) {
- // Handle reordering within the same container OR moving to a different container
- const overMarkerId = over.id.toString().replace("marker-", "");
- const overMarker = placedMarkers.find((m) => m.id === overMarkerId);
-
- if (overMarker && activeMarker.id !== overMarker.id) {
- let newPosition = 0;
-
- const activeWasBeforeOver =
- compareByPositionAndId(activeMarker, overMarker) < 0;
-
- // Get other markers in the SAME container as the over marker
- const otherMarkers = placedMarkers.filter(
- (m) =>
- m.id !== activeMarker.id && m.folderId === overMarker.folderId,
- );
-
- if (activeWasBeforeOver) {
- // If active marker was before, make it after
- newPosition = getNewPositionAfter(
- overMarker.position,
- otherMarkers,
- );
- } else {
- // If active marker was after, make it before
- newPosition = getNewPositionBefore(
- overMarker.position,
- otherMarkers,
- );
- }
-
- updatePlacedMarker({
- ...activeMarker,
- folderId: overMarker.folderId, // Move to the same folder as the marker we're dropping on
- position: newPosition,
- });
- }
- }
- },
- [placedMarkers, updatePlacedMarker, setPulsingFolderId],
- );
-
- const handleDragEndFolder = useCallback(
- (event: DragEndEvent) => {
- const { active, over } = event;
-
- const activeFolderId = active.id.toString().replace("folder-drag-", "");
- const activeFolder = folders.find((m) => m.id === activeFolderId);
-
- if (!activeFolder) {
- return;
- }
-
- if (over && over.id.toString().startsWith("folder-drag-")) {
- const overFolderId = over.id.toString().replace("folder-drag-", "");
- const overFolder = folders.find((m) => m.id === overFolderId);
-
- if (overFolder && activeFolder.id !== overFolder.id) {
- let newPosition = 0;
-
- const activeWasBeforeOver =
- compareByPositionAndId(activeFolder, overFolder) < 0;
-
- // Get other folders to position against
- const otherFolders = folders.filter((m) => m.id !== activeFolder.id);
-
- if (activeWasBeforeOver) {
- // If active folder was before, make it after
- newPosition = getNewPositionAfter(
- overFolder.position,
- otherFolders,
- );
- } else {
- // If active folder was after, make it before
- newPosition = getNewPositionBefore(
- overFolder.position,
- otherFolders,
- );
- }
-
- updateFolder({ ...activeFolder, position: newPosition });
- }
- }
- },
- [folders, updateFolder],
- );
-
- const handleDragEnd = useCallback(
- (event: DragEndEvent) => {
- const { active } = event;
-
- const activeId = active.id.toString();
- if (activeId.startsWith("marker-")) {
- handleDragEndMarker(event);
- } else if (activeId.startsWith("folder-drag-")) {
- handleDragEndFolder(event);
- }
-
- // Update UI AFTER handling the drag
- setActiveId(null);
- },
- [handleDragEndFolder, handleDragEndMarker],
- );
-
- const sortedFolders = useMemo(() => {
- return sortByPositionAndId(folders);
- }, [folders]);
-
- // Get active marker for drag overlay
- const getActiveMarker = () => {
- if (!activeId) return null;
- const markerId = activeId.replace("marker-", "");
- return placedMarkers.find((marker) => marker.id === markerId) || null;
- };
-
- const hasMarkers =
- membersDataSource ||
- markerDataSources?.length ||
- placedMarkers.length ||
- folders.length;
-
- return (
-
-
setActiveId(null)}
- modifiers={[restrictToVerticalAxis]}
- >
-
-
- {!hasMarkers && (
-
- )}
-
- {/* Member data source */}
- {membersDataSource && (
-
-
-
- )}
-
- {/* Marker data sources */}
- {markerDataSources && markerDataSources.length > 0 && (
-
- {markerDataSources.map((dataSource) => (
-
- ))}
-
- )}
-
- {/* Folders */}
-
`folder-drag-${f.id}`)}
- strategy={verticalListSortingStrategy}
- >
- {sortedFolders.map((folder) => (
- p.folderId === folder.id,
- )}
- activeId={activeId}
- setKeyboardCapture={setKeyboardCapture}
- isPulsing={pulsingFolderId === folder.id}
- />
- ))}
-
-
- {/* Unassigned markers */}
-
- !p.folderId)}
- activeId={activeId}
- folders={folders}
- setKeyboardCapture={setKeyboardCapture}
- />
-
-
-
- {createPortal(
-
- {activeId && getActiveMarker() && (
-
- )}
- ,
- document.body,
- )}
-
-
- );
-}
diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx
index 58ffbe6b..f897a9e2 100644
--- a/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx
+++ b/src/app/map/[id]/components/controls/MarkersControl/SortableFolderItem.tsx
@@ -16,18 +16,24 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import { sortByPositionAndId } from "@/app/map/[id]/utils/position";
+import ColorPalette from "@/components/ColorPalette";
import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/shadcn/ui/context-menu";
import { cn } from "@/shadcn/utils";
import { LayerType } from "@/types";
import { useFolderMutations } from "../../../hooks/useFolders";
+import { useMapConfig } from "../../../hooks/useMapConfig";
import { usePlacedMarkerState } from "../../../hooks/usePlacedMarkers";
+import { mapColors } from "../../../styles";
import ControlEditForm from "../ControlEditForm";
import ControlWrapper from "../ControlWrapper";
import SortableMarkerItem from "./SortableMarkerItem";
@@ -78,6 +84,27 @@ export default function SortableFolderItem({
usePlacedMarkerState();
const { updateFolder, deleteFolder } = useFolderMutations();
+ const { mapConfig, updateMapConfig } = useMapConfig();
+
+ // Get current folder color (defaults to marker color)
+ const currentFolderColor =
+ mapConfig.folderColors?.[folder.id] ?? mapColors.markers.color;
+
+ const handleFolderColorChange = (color: string) => {
+ // Update folder color and all marker colors in one operation
+ const updatedMarkerColors = { ...mapConfig.placedMarkerColors };
+ markers.forEach((marker) => {
+ updatedMarkerColors[marker.id] = color;
+ });
+
+ updateMapConfig({
+ folderColors: {
+ ...mapConfig.folderColors,
+ [folder.id]: color,
+ },
+ placedMarkerColors: updatedMarkerColors,
+ });
+ };
const [isExpanded, setExpanded] = useState(false);
const [isEditing, setEditing] = useState(false);
@@ -135,6 +162,7 @@ export default function SortableFolderItem({
layerType={LayerType.Marker}
isVisible={isFolderVisible}
onVisibilityToggle={() => onVisibilityToggle()}
+ color={currentFolderColor}
>
{isEditing ? (
+
+
+
+
+
+
+
+
+
setShowDeleteDialog(true)}
diff --git a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx
index 44bfbc7b..bb01ddb2 100644
--- a/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx
+++ b/src/app/map/[id]/components/controls/MarkersControl/SortableMarkerItem.tsx
@@ -5,20 +5,26 @@ import { CSS } from "@dnd-kit/utilities";
import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
+import ColorPalette from "@/components/ColorPalette";
import DeleteConfirmationDialog from "@/components/DeleteConfirmationDialog";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/shadcn/ui/context-menu";
import { LayerType } from "@/types";
+import { useMapConfig } from "../../../hooks/useMapConfig";
import { useMapRef } from "../../../hooks/useMapCore";
import {
usePlacedMarkerMutations,
usePlacedMarkerState,
} from "../../../hooks/usePlacedMarkers";
+import { mapColors } from "../../../styles";
import ControlEditForm from "../ControlEditForm";
import ControlWrapper from "../ControlWrapper";
import type { PlacedMarker } from "@/server/models/PlacedMarker";
@@ -47,11 +53,25 @@ export default function SortableMarkerItem({
setPlacedMarkerVisibility,
} = usePlacedMarkerState();
const { updatePlacedMarker, deletePlacedMarker } = usePlacedMarkerMutations();
+ const { mapConfig, updateMapConfig } = useMapConfig();
const mapRef = useMapRef();
const [isEditing, setEditing] = useState(false);
const [editText, setEditText] = useState(marker.label);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ // Get current color (defaults to marker color)
+ const currentColor =
+ mapConfig.placedMarkerColors?.[marker.id] ?? mapColors.markers.color;
+
+ const handleColorChange = (color: string) => {
+ updateMapConfig({
+ placedMarkerColors: {
+ ...mapConfig.placedMarkerColors,
+ [marker.id]: color,
+ },
+ });
+ };
+
// Check if this marker is the one being dragged (even outside its container)
const isCurrentlyDragging = isDragging || activeId === `marker-${marker.id}`;
const isVisible = getPlacedMarkerVisibility(marker.id);
@@ -121,6 +141,7 @@ export default function SortableMarkerItem({
onVisibilityToggle={() =>
setPlacedMarkerVisibility(marker.id, !isVisible)
}
+ color={currentColor}
>
{isEditing ? (
+
+
+
+
+
+
+
+
+
setShowDeleteDialog(true)}
diff --git a/src/app/map/[id]/components/controls/MarkersList/index.tsx b/src/app/map/[id]/components/controls/MarkersList/index.tsx
new file mode 100644
index 00000000..f7eee875
--- /dev/null
+++ b/src/app/map/[id]/components/controls/MarkersList/index.tsx
@@ -0,0 +1,216 @@
+import {
+ DndContext,
+ DragOverlay,
+ KeyboardSensor,
+ PointerSensor,
+ closestCorners,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { useCallback, useMemo } from "react";
+import { createPortal } from "react-dom";
+import {
+ useMarkerDataSources,
+ useMembersDataSource,
+} from "@/app/map/[id]/hooks/useDataSources";
+import { useFoldersQuery } from "@/app/map/[id]/hooks/useFolders";
+import { useMapViews } from "@/app/map/[id]/hooks/useMapViews";
+import { usePlacedMarkersQuery } from "@/app/map/[id]/hooks/usePlacedMarkers";
+import { useTable } from "@/app/map/[id]/hooks/useTable";
+import { sortByPositionAndId } from "@/app/map/[id]/utils/position";
+import { LayerType } from "@/types";
+import DataSourceControl from "../DataSourceItem";
+import EmptyLayer from "../LayerEmptyMessage";
+import MarkerDragOverlay from "../MarkersControl/MarkerDragOverlay";
+import SortableFolderItem from "../MarkersControl/SortableFolderItem";
+import UnassignedFolder from "../MarkersControl/UnassignedFolder";
+import { useDragHandlers } from "./useDragHandlers";
+import { useMarkerListState } from "./useMarkerListState";
+import type { DropdownMenuItemType } from "@/components/MultiDropdownMenu";
+import type { PlacedMarker } from "@/server/models/PlacedMarker";
+import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
+
+export default function MarkersList({
+ dropdownItems,
+}: {
+ dropdownItems?: DropdownMenuItemType[];
+}) {
+ const { viewConfig } = useMapViews();
+ const { data: folders = [] } = useFoldersQuery();
+ const { data: placedMarkers = [] } = usePlacedMarkersQuery();
+ const { selectedDataSourceId, handleDataSourceSelect } = useTable();
+ const markerDataSources = useMarkerDataSources();
+ const membersDataSource = useMembersDataSource();
+
+ const {
+ activeId,
+ setActiveId,
+ pulsingFolderId,
+ keyboardCapture,
+ setKeyboardCapture,
+ updateMarkerInCache,
+ setPulsingFolderId,
+ getActiveMarker,
+ getActiveMarkerColor,
+ } = useMarkerListState(placedMarkers);
+
+ const { handleDragOver, handleDragEndMarker, handleDragEndFolder } =
+ useDragHandlers({
+ placedMarkers,
+ folders,
+ updateMarkerInCache,
+ setPulsingFolderId,
+ });
+
+ // DnD sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ // Disable keyboard while text input is active
+ keyboardCodes: keyboardCapture
+ ? { start: [], cancel: [], end: [] }
+ : undefined,
+ }),
+ );
+
+ // Drag and drop handlers
+ const handleDragStart = useCallback(
+ (event: DragStartEvent) => {
+ setActiveId(event.active.id.toString());
+ },
+ [setActiveId],
+ );
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active } = event;
+
+ const activeIdStr = active.id.toString();
+ if (activeIdStr.startsWith("marker-")) {
+ handleDragEndMarker(event);
+ } else if (activeIdStr.startsWith("folder-drag-")) {
+ handleDragEndFolder(event);
+ }
+
+ // Update UI AFTER handling the drag
+ setActiveId(null);
+ },
+ [handleDragEndFolder, handleDragEndMarker, setActiveId],
+ );
+
+ const sortedFolders = useMemo(() => {
+ return sortByPositionAndId(folders);
+ }, [folders]);
+
+ const hasMarkers =
+ membersDataSource ||
+ markerDataSources?.length ||
+ placedMarkers.length ||
+ folders.length;
+
+ return (
+
+
setActiveId(null)}
+ modifiers={[restrictToVerticalAxis]}
+ >
+
+
+ {!hasMarkers && (
+
+ )}
+
+ {/* Member data source */}
+ {membersDataSource && (
+
+
+
+ )}
+
+ {/* Marker data sources */}
+ {markerDataSources && markerDataSources.length > 0 && (
+
+ {markerDataSources.map((dataSource) => (
+
+ ))}
+
+ )}
+
+ {/* Folders */}
+
`folder-drag-${f.id}`)}
+ strategy={verticalListSortingStrategy}
+ >
+ {sortedFolders.map((folder) => (
+ p.folderId === folder.id,
+ )}
+ activeId={activeId}
+ setKeyboardCapture={setKeyboardCapture}
+ isPulsing={pulsingFolderId === folder.id}
+ />
+ ))}
+
+
+ {/* Unassigned markers */}
+
+ !p.folderId)}
+ activeId={activeId}
+ folders={folders}
+ setKeyboardCapture={setKeyboardCapture}
+ />
+
+
+
+ {createPortal(
+
+ {activeId && getActiveMarker() && (
+
+ )}
+ ,
+ document.body,
+ )}
+
+
+ );
+}
diff --git a/src/app/map/[id]/components/controls/MarkersList/useDragHandlers.ts b/src/app/map/[id]/components/controls/MarkersList/useDragHandlers.ts
new file mode 100644
index 00000000..ed4581eb
--- /dev/null
+++ b/src/app/map/[id]/components/controls/MarkersList/useDragHandlers.ts
@@ -0,0 +1,274 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { useCallback } from "react";
+import { useFolderMutations } from "@/app/map/[id]/hooks/useFolders";
+import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig";
+import { useMapId } from "@/app/map/[id]/hooks/useMapCore";
+import { usePlacedMarkerMutations } from "@/app/map/[id]/hooks/usePlacedMarkers";
+import { mapColors } from "@/app/map/[id]/styles";
+import {
+ compareByPositionAndId,
+ getNewFirstPosition,
+ getNewLastPosition,
+ getNewPositionAfter,
+ getNewPositionBefore,
+} from "@/app/map/[id]/utils/position";
+import { useTRPC } from "@/services/trpc/react";
+import type { Folder } from "@/server/models/Folder";
+import type { PlacedMarker } from "@/server/models/PlacedMarker";
+import type { DragEndEvent, DragOverEvent } from "@dnd-kit/core";
+
+interface DragHandlerDeps {
+ placedMarkers: PlacedMarker[];
+ folders: Folder[];
+ updateMarkerInCache: (marker: Omit) => void;
+ setPulsingFolderId: (id: string | null) => void;
+}
+
+export function useDragHandlers({
+ placedMarkers,
+ folders,
+ updateMarkerInCache,
+ setPulsingFolderId,
+}: DragHandlerDeps) {
+ const queryClient = useQueryClient();
+ const trpc = useTRPC();
+ const mapId = useMapId();
+ const { updatePlacedMarker } = usePlacedMarkerMutations();
+ const { updateFolder } = useFolderMutations();
+ const { mapConfig, updateMapConfig } = useMapConfig();
+
+ const handleDragOver = useCallback(
+ (event: DragOverEvent) => {
+ const { active, over } = event;
+
+ if (!over || active.id === over.id) {
+ return;
+ }
+
+ if (!mapId) return;
+
+ const activeMarkerId = active.id.toString().replace("marker-", "");
+ const currentCacheData = queryClient.getQueryData(
+ trpc.map.byId.queryKey({ mapId }),
+ );
+ const currentMarkers = currentCacheData?.placedMarkers || [];
+ const activeMarker = currentMarkers.find((m) => m.id === activeMarkerId);
+
+ if (!activeMarker) {
+ return;
+ }
+
+ if (over.id.toString().startsWith("marker-")) {
+ const overMarkerId = over.id.toString().replace("marker-", "");
+ const overMarker = currentMarkers.find((m) => m.id === overMarkerId);
+
+ if (overMarker && overMarker.folderId !== activeMarker.folderId) {
+ const activeWasBeforeOver =
+ compareByPositionAndId(activeMarker, overMarker) < 0;
+
+ const otherMarkers = currentMarkers.filter(
+ (m) =>
+ m.id !== activeMarker.id && m.folderId === overMarker.folderId,
+ );
+
+ const newPosition = activeWasBeforeOver
+ ? getNewPositionAfter(overMarker.position, otherMarkers)
+ : getNewPositionBefore(overMarker.position, otherMarkers);
+
+ updateMarkerInCache({
+ ...activeMarker,
+ folderId: overMarker.folderId,
+ position: newPosition,
+ });
+ }
+ return;
+ }
+
+ if (over.id.toString().startsWith("folder")) {
+ let folderId: string;
+ let append = false;
+
+ if (over.id.toString().startsWith("folder-footer-")) {
+ folderId = over.id.toString().replace("folder-footer-", "");
+ append = true;
+ } else if (over.id.toString().startsWith("folder-drag-")) {
+ folderId = over.id.toString().replace("folder-drag-", "");
+ append = true;
+ } else {
+ folderId = over.id.toString().replace("folder-", "");
+ }
+
+ const folderMarkers = currentMarkers.filter(
+ (m) => m.folderId === folderId,
+ );
+
+ const newPosition = append
+ ? getNewLastPosition(folderMarkers)
+ : getNewFirstPosition(folderMarkers);
+
+ updateMarkerInCache({
+ ...activeMarker,
+ folderId,
+ position: newPosition,
+ });
+ } else if (over.id === "unassigned") {
+ if (activeMarker.folderId !== null) {
+ const unassignedMarkers = currentMarkers.filter(
+ (m) => m.folderId === null,
+ );
+ const newPosition = getNewFirstPosition(unassignedMarkers);
+
+ updateMarkerInCache({
+ ...activeMarker,
+ folderId: null,
+ position: newPosition,
+ });
+ }
+ }
+ },
+ [mapId, queryClient, trpc.map.byId, updateMarkerInCache],
+ );
+
+ const handleDragEndMarker = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ const activeMarkerId = active.id.toString().replace("marker-", "");
+ const activeMarker = placedMarkers.find((m) => m.id === activeMarkerId);
+
+ if (!activeMarker) {
+ return;
+ }
+
+ if (over && over.id.toString().startsWith("folder")) {
+ let folderId: string;
+ let append = false;
+
+ if (over.id.toString().startsWith("folder-footer-")) {
+ folderId = over.id.toString().replace("folder-footer-", "");
+ append = true;
+ } else if (over.id.toString().startsWith("folder-drag-")) {
+ folderId = over.id.toString().replace("folder-drag-", "");
+ append = true;
+ } else {
+ folderId = over.id.toString().replace("folder-", "");
+ }
+
+ const folderMarkers = placedMarkers.filter(
+ (m) => m.folderId === folderId,
+ );
+
+ const newPosition = append
+ ? getNewLastPosition(folderMarkers)
+ : getNewFirstPosition(folderMarkers);
+
+ updatePlacedMarker({
+ ...activeMarker,
+ folderId,
+ position: newPosition,
+ } as PlacedMarker);
+
+ const folderColor =
+ mapConfig.folderColors?.[folderId] || mapColors.markers.color;
+ updateMapConfig({
+ placedMarkerColors: {
+ ...(mapConfig.placedMarkerColors ?? {}),
+ [activeMarker.id]: folderColor,
+ },
+ });
+
+ setPulsingFolderId(folderId);
+ } else if (over && over.id === "unassigned") {
+ const unassignedMarkers = placedMarkers.filter(
+ (m) => m.folderId === null,
+ );
+ const newPosition = getNewFirstPosition(unassignedMarkers);
+ updatePlacedMarker({
+ ...activeMarker,
+ folderId: null,
+ position: newPosition,
+ } as PlacedMarker);
+ } else if (over && over.id.toString().startsWith("marker-")) {
+ const overMarkerId = over.id.toString().replace("marker-", "");
+ const overMarker = placedMarkers.find((m) => m.id === overMarkerId);
+
+ if (overMarker && activeMarker.id !== overMarker.id) {
+ const activeWasBeforeOver =
+ compareByPositionAndId(activeMarker, overMarker) < 0;
+
+ const otherMarkers = placedMarkers.filter(
+ (m) =>
+ m.id !== activeMarker.id && m.folderId === overMarker.folderId,
+ );
+
+ const newPosition = activeWasBeforeOver
+ ? getNewPositionAfter(overMarker.position, otherMarkers)
+ : getNewPositionBefore(overMarker.position, otherMarkers);
+
+ updatePlacedMarker({
+ ...activeMarker,
+ folderId: overMarker.folderId,
+ position: newPosition,
+ } as PlacedMarker);
+
+ if (overMarker.folderId) {
+ const folderColor =
+ mapConfig.folderColors?.[overMarker.folderId] ||
+ mapColors.markers.color;
+ updateMapConfig({
+ placedMarkerColors: {
+ ...mapConfig.placedMarkerColors,
+ [activeMarker.id]: folderColor,
+ },
+ });
+ }
+ }
+ }
+ },
+ [
+ placedMarkers,
+ updatePlacedMarker,
+ setPulsingFolderId,
+ mapConfig,
+ updateMapConfig,
+ ],
+ );
+
+ const handleDragEndFolder = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ const activeFolderId = active.id.toString().replace("folder-drag-", "");
+ const activeFolder = folders.find((m) => m.id === activeFolderId);
+
+ if (!activeFolder) {
+ return;
+ }
+
+ if (over && over.id.toString().startsWith("folder-drag-")) {
+ const overFolderId = over.id.toString().replace("folder-drag-", "");
+ const overFolder = folders.find((m) => m.id === overFolderId);
+
+ if (overFolder && activeFolder.id !== overFolder.id) {
+ const activeWasBeforeOver =
+ compareByPositionAndId(activeFolder, overFolder) < 0;
+
+ const otherFolders = folders.filter((m) => m.id !== activeFolder.id);
+
+ const newPosition = activeWasBeforeOver
+ ? getNewPositionAfter(overFolder.position, otherFolders)
+ : getNewPositionBefore(overFolder.position, otherFolders);
+
+ updateFolder({ ...activeFolder, position: newPosition });
+ }
+ }
+ },
+ [folders, updateFolder],
+ );
+
+ return {
+ handleDragOver,
+ handleDragEndMarker,
+ handleDragEndFolder,
+ };
+}
diff --git a/src/app/map/[id]/components/controls/MarkersList/useMarkerListState.ts b/src/app/map/[id]/components/controls/MarkersList/useMarkerListState.ts
new file mode 100644
index 00000000..0650f5c0
--- /dev/null
+++ b/src/app/map/[id]/components/controls/MarkersList/useMarkerListState.ts
@@ -0,0 +1,84 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { useCallback, useState } from "react";
+import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig";
+import { useMapId } from "@/app/map/[id]/hooks/useMapCore";
+import { mapColors } from "@/app/map/[id]/styles";
+import { useTRPC } from "@/services/trpc/react";
+import type { PlacedMarker } from "@/server/models/PlacedMarker";
+
+export function useMarkerListState(placedMarkers: PlacedMarker[]) {
+ const queryClient = useQueryClient();
+ const trpc = useTRPC();
+ const mapId = useMapId();
+ const { mapConfig } = useMapConfig();
+
+ const [activeId, setActiveId] = useState(null);
+ const [pulsingFolderId, _setPulsingFolderId] = useState(null);
+ const [keyboardCapture, setKeyboardCapture] = useState(false);
+
+ // Brief pulsing folder animation on some drag actions
+ const setPulsingFolderId = useCallback((id: string | null) => {
+ _setPulsingFolderId(id);
+ setTimeout(() => {
+ _setPulsingFolderId(null);
+ }, 600);
+ }, []);
+
+ // Update cache only (for optimistic updates during drag) - NO mutation
+ const updateMarkerInCache = useCallback(
+ (placedMarker: Omit) => {
+ if (!mapId) return;
+
+ const fullMarker = {
+ ...placedMarker,
+ mapId,
+ folderId: placedMarker.folderId ?? null,
+ };
+
+ queryClient.setQueryData(trpc.map.byId.queryKey({ mapId }), (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ placedMarkers:
+ old.placedMarkers?.map((m) =>
+ m.id === placedMarker.id ? fullMarker : m,
+ ) || [],
+ };
+ });
+ },
+ [mapId, queryClient, trpc.map.byId],
+ );
+
+ // Get active marker and color for drag overlay
+ const getActiveMarker = useCallback(() => {
+ if (!activeId) return null;
+ const markerId = activeId.replace("marker-", "");
+ return placedMarkers.find((marker) => marker.id === markerId) || null;
+ }, [activeId, placedMarkers]);
+
+ const getActiveMarkerColor = useCallback(() => {
+ const marker = getActiveMarker();
+ if (!marker) return mapColors.markers.color;
+
+ // Get marker color (check explicit marker color first, then folder color, then default)
+ if (mapConfig.placedMarkerColors?.[marker.id]) {
+ return mapConfig.placedMarkerColors[marker.id];
+ }
+ if (marker.folderId && mapConfig.folderColors?.[marker.folderId]) {
+ return mapConfig.folderColors[marker.folderId];
+ }
+ return mapColors.markers.color;
+ }, [getActiveMarker, mapConfig]);
+
+ return {
+ activeId,
+ setActiveId,
+ pulsingFolderId,
+ keyboardCapture,
+ setKeyboardCapture,
+ updateMarkerInCache,
+ setPulsingFolderId,
+ getActiveMarker,
+ getActiveMarkerColor,
+ };
+}
diff --git a/src/components/ColorPalette.tsx b/src/components/ColorPalette.tsx
new file mode 100644
index 00000000..ca35dd50
--- /dev/null
+++ b/src/components/ColorPalette.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { CheckIcon } from "lucide-react";
+import { cn } from "@/shadcn/utils";
+
+const COLOR_PALETTE_DATA = [
+ { hex: "#FF6B6B", name: "Red" },
+ { hex: "#678DE3", name: "Blue" },
+ { hex: "#4DAB37", name: "Green" },
+ { hex: "#FFA500", name: "Orange" },
+ { hex: "#9B59B6", name: "Purple" },
+ { hex: "#1ABC9C", name: "Turquoise" },
+ { hex: "#E67E22", name: "Carrot" },
+ { hex: "#34495E", name: "Dark Blue Grey" },
+ { hex: "#E74C3C", name: "Dark Red" },
+ { hex: "#3498DB", name: "Light Blue" },
+ { hex: "#2ECC71", name: "Emerald" },
+ { hex: "#8E44AD", name: "Dark Purple" },
+] as const;
+
+export const DEFAULT_COLOR_PALETTE = COLOR_PALETTE_DATA.map((c) => c.hex);
+
+const COLOR_NAMES: Record = Object.fromEntries(
+ COLOR_PALETTE_DATA.map((c) => [c.hex.toUpperCase(), c.name]),
+);
+
+const getColorLabel = (color: string): string => {
+ const colorName = COLOR_NAMES[color.toUpperCase()];
+ return colorName
+ ? `Select ${colorName} color (${color})`
+ : `Select color ${color}`;
+};
+
+export interface ColorPaletteProps {
+ colors?: string[];
+ selectedColor?: string;
+ onColorSelect: (color: string) => void;
+ className?: string;
+}
+
+export default function ColorPalette({
+ colors = DEFAULT_COLOR_PALETTE,
+ selectedColor,
+ onColorSelect,
+ className,
+}: ColorPaletteProps) {
+ return (
+
+ {colors.map((color) => (
+
+ ))}
+
+ );
+}
diff --git a/src/server/models/Map.ts b/src/server/models/Map.ts
index 713eedc6..6efe174e 100644
--- a/src/server/models/Map.ts
+++ b/src/server/models/Map.ts
@@ -1,9 +1,20 @@
import z from "zod";
import type { ColumnType, Generated, Insertable, Updateable } from "kysely";
+export enum MarkerDisplayMode {
+ Clusters = "clusters",
+ Heatmap = "heatmap",
+}
+
+const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
+
export const mapConfigSchema = z.object({
markerDataSourceIds: z.array(z.string()),
membersDataSourceId: z.string().nullish(),
+ markerDisplayModes: z.record(z.nativeEnum(MarkerDisplayMode)).optional(),
+ markerColors: z.record(hexColorSchema).optional(),
+ placedMarkerColors: z.record(hexColorSchema).optional(),
+ folderColors: z.record(hexColorSchema).optional(),
});
export type MapConfig = z.infer;
diff --git a/src/server/trpc/routers/map.ts b/src/server/trpc/routers/map.ts
index 1f1480a6..cef5e10b 100644
--- a/src/server/trpc/routers/map.ts
+++ b/src/server/trpc/routers/map.ts
@@ -125,6 +125,10 @@ export const mapRouter = router({
const config = {
markerDataSourceIds: mapConfig.markerDataSourceIds?.filter(Boolean),
membersDataSourceId: mapConfig.membersDataSourceId,
+ markerDisplayModes: mapConfig.markerDisplayModes,
+ markerColors: mapConfig.markerColors,
+ placedMarkerColors: mapConfig.placedMarkerColors,
+ folderColors: mapConfig.folderColors,
} as z.infer;
return updateMap(mapId, { config });