diff --git a/src/app/(private)/data-sources/components/UserDataSourcesList.tsx b/src/app/(private)/data-sources/components/UserDataSourcesList.tsx index dd0ededc..a3f3d137 100644 --- a/src/app/(private)/data-sources/components/UserDataSourcesList.tsx +++ b/src/app/(private)/data-sources/components/UserDataSourcesList.tsx @@ -1,9 +1,7 @@ "use client"; -import { Boxes, Database, PlusIcon, Users } from "lucide-react"; +import { Boxes, Database, PlusIcon, Users, UsersIcon } from "lucide-react"; import { useMemo, useState } from "react"; -import { CollectionIcon } from "@/app/map/[id]/components/Icons"; -import { mapColors } from "@/app/map/[id]/styles"; import { DataSourceItem } from "@/components/DataSourceItem"; import DataSourceRecordTypeIcon, { dataSourceRecordTypeColors, @@ -30,17 +28,39 @@ export default function UserDataSourcesList({ const memberDataSources = useMemo( () => - dataSources?.filter( - (dataSource) => dataSource.recordType === DataSourceRecordType.Members, - ), + dataSources + ?.filter( + (dataSource) => + dataSource.recordType === DataSourceRecordType.Members, + ) + .sort((a, b) => { + const aDate = a.importInfo?.lastCompleted + ? new Date(a.importInfo.lastCompleted).getTime() + : 0; + const bDate = b.importInfo?.lastCompleted + ? new Date(b.importInfo.lastCompleted).getTime() + : 0; + return bDate - aDate; // Most recent first + }), [dataSources], ); const otherDataSources = useMemo( () => - dataSources?.filter( - (dataSource) => dataSource.recordType !== DataSourceRecordType.Members, - ), + dataSources + ?.filter( + (dataSource) => + dataSource.recordType !== DataSourceRecordType.Members, + ) + .sort((a, b) => { + const aDate = a.importInfo?.lastCompleted + ? new Date(a.importInfo.lastCompleted).getTime() + : 0; + const bDate = b.importInfo?.lastCompleted + ? new Date(b.importInfo.lastCompleted).getTime() + : 0; + return bDate - aDate; // Most recent first + }), [dataSources], ); @@ -96,8 +116,8 @@ export default function UserDataSourcesList({ {/* Member Collections Section */}
-

- +

+ Member data sources

@@ -122,8 +142,7 @@ export default function UserDataSourcesList({ {/* Other Data Sources Section */}
-

- +

Other data sources

diff --git a/src/app/map/[id]/components/Markers.tsx b/src/app/map/[id]/components/Markers.tsx index 833fbe80..07eaa360 100644 --- a/src/app/map/[id]/components/Markers.tsx +++ b/src/app/map/[id]/components/Markers.tsx @@ -5,6 +5,7 @@ import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { useMarkerQueries } from "@/app/map/[id]/hooks/useMarkerQueries"; import { publicMapColorSchemes } from "@/app/map/[id]/styles"; +import { MarkerDisplayMode } from "@/server/models/Map"; import { useLayers } from "../hooks/useLayers"; import { mapColors } from "../styles"; import { PublicFiltersContext } from "../view/[viewIdOrHost]/publish/context/PublicFiltersContext"; @@ -14,19 +15,19 @@ import type { FeatureCollection } from "geojson"; const MARKER_CLIENT_EXCLUDED_KEY = "__clientExcluded"; -// function hexToRgb(hex: string) { -// const normalized = hex.replace("#", ""); -// const bigint = parseInt(normalized, 16); -// const r = (bigint >> 16) & 255; -// const g = (bigint >> 8) & 255; -// const b = bigint & 255; -// return { r, g, b }; -// } +function hexToRgb(hex: string) { + const normalized = hex.replace("#", ""); + const bigint = parseInt(normalized, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + return { r, g, b }; +} -// function rgbaString(hex: string, alpha: number) { -// const { r, g, b } = hexToRgb(hex); -// return `rgba(${r}, ${g}, ${b}, ${alpha})`; -// } +function rgbaString(hex: string, alpha: number) { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} export default function Markers() { const { viewConfig } = useMapViews(); @@ -57,6 +58,7 @@ export default function Markers() { key={memberMarkers.dataSourceId} dataSourceMarkers={memberMarkers} isMembers + mapConfig={mapConfig} /> )} {otherMarkers.map((markers) => { @@ -72,6 +74,7 @@ export default function Markers() { key={markers.dataSourceId} dataSourceMarkers={markers} isMembers={false} + mapConfig={mapConfig} /> ); })} @@ -82,13 +85,23 @@ export default function Markers() { function DataSourceMarkers({ dataSourceMarkers, isMembers, + mapConfig, }: { dataSourceMarkers: { dataSourceId: string; markers: MarkerFeature[] }; isMembers: boolean; + mapConfig: { + markerDisplayModes?: Record; + markerColors?: Record; + }; }) { const { filteredRecords, publicFilters } = useContext(PublicFiltersContext); const { publicMap, colorScheme } = useContext(PublicMapContext); + // Get display mode for this data source (defaults to Clusters) + const displayMode = + mapConfig.markerDisplayModes?.[dataSourceMarkers.dataSourceId] ?? + MarkerDisplayMode.Clusters; + const safeMarkers = useMemo(() => { // Don't add MARKER_CLIENT_EXCLUDED_KEY property if no public filters exist if (Object.keys(publicFilters).length === 0) { @@ -123,11 +136,14 @@ function DataSourceMarkers({ publicMap?.id && colorScheme ? publicMapColorSchemes[colorScheme]?.primary : ""; - const color = publicMapColor - ? publicMapColor - : isMembers - ? mapColors.member.color - : mapColors.dataSource.color; + + // Get custom color from mapConfig, or fall back to defaults + const customColor = mapConfig.markerColors?.[dataSourceMarkers.dataSourceId]; + const defaultColor = isMembers + ? mapColors.member.color + : mapColors.dataSource.color; + + const color = publicMapColor || customColor || defaultColor; return ( - - - {/* TODO: Restore this with a switch + {displayMode === MarkerDisplayMode.Clusters && ( + )} + {displayMode === MarkerDisplayMode.Clusters && ( + + )} + {displayMode === MarkerDisplayMode.Heatmap && ( + */} - - ", ["length", ["get", "name"]], 20], "...", ""], - ], - "text-font": ["DIN Pro Medium", "Arial Unicode MS Bold"], - "text-size": 12, - "text-transform": "uppercase", - "text-offset": [0, -1.25], - }} - paint={{ - "text-color": color, - "text-halo-color": "#ffffff", - "text-halo-width": 1, - }} - /> + /> + )} + {/* Individual pins - only show for cluster mode or at high zoom in heatmap mode */} + {displayMode === MarkerDisplayMode.Clusters && ( + + )} + {displayMode === MarkerDisplayMode.Clusters && ( + ", ["length", ["get", "name"]], 20], "...", ""], + ], + "text-font": ["DIN Pro Medium", "Arial Unicode MS Bold"], + "text-size": 12, + "text-transform": "uppercase", + "text-offset": [0, -1.25], + }} + paint={{ + "text-color": color, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }} + /> + )} + {displayMode === MarkerDisplayMode.Heatmap && ( + + )} + {displayMode === MarkerDisplayMode.Heatmap && ( + ", ["length", ["get", "name"]], 20], "...", ""], + ], + "text-font": ["DIN Pro Medium", "Arial Unicode MS Bold"], + "text-size": 12, + "text-transform": "uppercase", + "text-offset": [0, -1.25], + }} + paint={{ + "text-color": color, + "text-halo-color": "#ffffff", + "text-halo-width": 1, + }} + /> + )} ); } diff --git a/src/app/map/[id]/components/PlacedMarkers.tsx b/src/app/map/[id]/components/PlacedMarkers.tsx index faa95c04..5438ecea 100644 --- a/src/app/map/[id]/components/PlacedMarkers.tsx +++ b/src/app/map/[id]/components/PlacedMarkers.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { Layer, Source } from "react-map-gl/mapbox"; import { useFoldersQuery } from "@/app/map/[id]/hooks/useFolders"; +import { useMapConfig } from "@/app/map/[id]/hooks/useMapConfig"; import { useMapViews } from "@/app/map/[id]/hooks/useMapViews"; import { usePlacedMarkerState, @@ -12,6 +13,7 @@ import type { FeatureCollection, Point } from "geojson"; export default function PlacedMarkers() { const { viewConfig } = useMapViews(); + const { mapConfig } = useMapConfig(); const { data: folders = [] } = useFoldersQuery(); const { data: placedMarkers = [] } = usePlacedMarkersQuery(); const { selectedPlacedMarkerId, getPlacedMarkerVisibility } = @@ -40,6 +42,8 @@ export default function PlacedMarkers() { properties: { id: marker.id, name: marker.label, + color: + mapConfig.placedMarkerColors?.[marker.id] ?? mapColors.markers.color, }, geometry: { type: "Point", @@ -61,7 +65,7 @@ export default function PlacedMarkers() { source="search-history" paint={{ "circle-radius": ["interpolate", ["linear"], ["zoom"], 8, 3, 16, 8], - "circle-color": mapColors.markers.color, + "circle-color": ["get", "color"], "circle-opacity": 0.8, "circle-stroke-width": 1, "circle-stroke-color": "#ffffff", @@ -83,7 +87,7 @@ export default function PlacedMarkers() { "text-anchor": "top", }} paint={{ - "text-color": mapColors.markers.color, + "text-color": ["get", "color"], "text-halo-color": "#ffffff", "text-halo-width": 1, }} @@ -109,7 +113,7 @@ export default function PlacedMarkers() { "circle-color": "#ffffff", "circle-opacity": 0, "circle-stroke-width": 2, - "circle-stroke-color": mapColors.markers.color, + "circle-stroke-color": ["get", "color"], }} /> )} diff --git a/src/app/map/[id]/components/controls/ControlWrapper.tsx b/src/app/map/[id]/components/controls/ControlWrapper.tsx index dc6979d6..3f121690 100644 --- a/src/app/map/[id]/components/controls/ControlWrapper.tsx +++ b/src/app/map/[id]/components/controls/ControlWrapper.tsx @@ -10,14 +10,20 @@ export default function ControlWrapper({ name, isVisible, onVisibilityToggle, + color, }: { children: ReactNode; name: string; isVisible: boolean; onVisibilityToggle: () => void; layerType?: LayerType; + color?: string; }) { const getLayerColor = () => { + // Use custom color if provided, otherwise use default layer color + if (color) { + return color; + } switch (layerType) { case LayerType.Member: return mapColors.member.color; diff --git a/src/app/map/[id]/components/controls/DataSourceItem.tsx b/src/app/map/[id]/components/controls/DataSourceItem.tsx index 01f5c021..9a11578d 100644 --- a/src/app/map/[id]/components/controls/DataSourceItem.tsx +++ b/src/app/map/[id]/components/controls/DataSourceItem.tsx @@ -4,8 +4,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { EyeIcon, EyeOffIcon, PencilIcon, TrashIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import ColorPalette from "@/components/ColorPalette"; import ContextMenuContentWithFocus from "@/components/ContextMenuContentWithFocus"; import DataSourceIcon from "@/components/DataSourceIcon"; +import { MarkerDisplayMode } from "@/server/models/Map"; import { useTRPC } from "@/services/trpc/react"; import { AlertDialog, @@ -19,8 +21,13 @@ import { } from "@/shadcn/ui/alert-dialog"; import { ContextMenu, + ContextMenuCheckboxItem, ContextMenuItem, + ContextMenuLabel, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "@/shadcn/ui/context-menu"; import { LayerType } from "@/types"; @@ -64,6 +71,31 @@ export default function DataSourceItem({ const isVisible = getDataSourceVisibility(dataSource?.id); + // Get current display mode (defaults to Clusters) + const currentDisplayMode = + mapConfig.markerDisplayModes?.[dataSource.id] ?? MarkerDisplayMode.Clusters; + + // Get current color (defaults to layer color) + const currentColor = mapConfig.markerColors?.[dataSource.id] ?? layerColor; + + const handleDisplayModeChange = (mode: MarkerDisplayMode) => { + updateMapConfig({ + markerDisplayModes: { + ...mapConfig.markerDisplayModes, + [dataSource.id]: mode, + }, + }); + }; + + const handleColorChange = (color: string) => { + updateMapConfig({ + markerColors: { + ...mapConfig.markerColors, + [dataSource.id]: color, + }, + }); + }; + // Focus management for rename input useEffect(() => { if (isRenaming) { @@ -138,12 +170,13 @@ export default function DataSourceItem({ onVisibilityToggle={() => setDataSourceVisibility(dataSource?.id, !isVisible) } + color={currentColor} > + ))} +
+ ); +} diff --git a/src/components/DataSourceItem.tsx b/src/components/DataSourceItem.tsx index 710d663e..ead47112 100644 --- a/src/components/DataSourceItem.tsx +++ b/src/components/DataSourceItem.tsx @@ -1,8 +1,8 @@ import { formatDistanceToNow } from "date-fns"; import { Database, RefreshCw } from "lucide-react"; import DataSourceIcon from "@/components/DataSourceIcon"; -import { DataSourceType } from "@/server/models/DataSource"; import { cn } from "@/shadcn/utils"; +import type { DataSourceType } from "@/server/models/DataSource"; import type { RouterOutputs } from "@/services/trpc/react"; type DataSourceItemType = NonNullable< @@ -67,15 +67,15 @@ const getDataSourceStyle = (type: DataSourceType | "unknown") => { const getGeocodingStatus = (dataSource: DataSourceItemType) => { const geocodingConfig = dataSource.geocodingConfig; if (geocodingConfig.type === "None") { - return { status: "No geocoding", color: "text-neutral-500" }; + return { status: "No geocoding", color: "text-neutral-400" }; } if (geocodingConfig.type === "Address") { - return { status: "Address geocoding", color: "text-green-600" }; + return { status: "Address geocoding", color: "text-neutral-400" }; } if (geocodingConfig.type === "Code" || geocodingConfig.type === "Name") { - return { status: "Area-based geocoding", color: "text-blue-600" }; + return { status: "Area-based geocoding", color: "text-neutral-400" }; } - return { status: "Geocoding configured", color: "text-blue-600" }; + return { status: "Geocoding configured", color: "text-neutral-400" }; }; export function DataSourceItem({ @@ -96,62 +96,62 @@ export function DataSourceItem({ return (
-
- {/* Icon */} - - - {/* Content */} -
- {/* Header */} -
-
-

{dataSource.name}

-
- -
- {dataSource.public && ( - - Public - - )} - {lastImportedText && ( - - - {lastImportedText} - - )} -
-
+ {/* Header: Icon and Name */} +
+
+ +

{dataSourceType}

+ {lastImportedText && ( + + + {lastImportedText} + + )}
- {/* Stats */} -
- {dataSource.columnDefs.length} columns - - {dataSource.recordCount || "Unknown"} records - - {geocodingStatus.status} +
+

+ {dataSource.name} +

- {/* Type-specific info */} - {dataSourceType === DataSourceType.ActionNetwork && ( - - Activist engagement data - - )} - {dataSourceType === DataSourceType.Mailchimp && ( - Email subscriber data - )} - {dataSource.autoImport && ( - - - Auto-import enabled - - )} + {/* Metadata: Consolidated stats and status */} +
+ {/* Primary stats: Records, columns, and last updated */} +
+ + {dataSource.recordCount?.toLocaleString() || "Unknown"} records + + + + {dataSource.columnDefs.length} columns + +
+ + {/* Secondary info: Geocoding (muted) and status badges */} +
+ + {geocodingStatus.status} + + + {dataSource.public && ( + + Public + + )} + + {dataSource.autoImport && ( + + + Auto-import + + )} +
+
); } diff --git a/src/server/models/Map.ts b/src/server/models/Map.ts index 713eedc6..a5031c4a 100644 --- a/src/server/models/Map.ts +++ b/src/server/models/Map.ts @@ -1,9 +1,18 @@ import z from "zod"; import type { ColumnType, Generated, Insertable, Updateable } from "kysely"; +export enum MarkerDisplayMode { + Clusters = "clusters", + Heatmap = "heatmap", +} + export const mapConfigSchema = z.object({ markerDataSourceIds: z.array(z.string()), membersDataSourceId: z.string().nullish(), + markerDisplayModes: z.record(z.nativeEnum(MarkerDisplayMode)).optional(), + markerColors: z.record(z.string()).optional(), + placedMarkerColors: z.record(z.string()).optional(), + folderColors: z.record(z.string()).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 });