,\n coordinates: [number, number]\n ) => void;\n /** Callback when a cluster is clicked. If not provided, zooms into the cluster */\n onClusterClick?: (\n clusterId: number,\n coordinates: [number, number],\n pointCount: number\n ) => void;\n};\n\nfunction MapClusterLayer<\n P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties\n>({\n data,\n clusterMaxZoom = 14,\n clusterRadius = 50,\n clusterColors = [\"#22c55e\", \"#eab308\", \"#ef4444\"],\n clusterThresholds = [100, 750],\n pointColor = \"#3b82f6\",\n onPointClick,\n onClusterClick,\n}: MapClusterLayerProps) {\n const { map, isLoaded } = useMap();\n const id = useId();\n const sourceId = `cluster-source-${id}`;\n const clusterLayerId = `clusters-${id}`;\n const clusterCountLayerId = `cluster-count-${id}`;\n const unclusteredLayerId = `unclustered-point-${id}`;\n\n const stylePropsRef = useRef({\n clusterColors,\n clusterThresholds,\n pointColor,\n });\n\n // Add source and layers on mount\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Add clustered GeoJSON source\n map.addSource(sourceId, {\n type: \"geojson\",\n data,\n cluster: true,\n clusterMaxZoom,\n clusterRadius,\n });\n\n // Add cluster circles layer\n map.addLayer({\n id: clusterLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n paint: {\n \"circle-color\": [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ],\n \"circle-radius\": [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ],\n \"circle-stroke-width\": 1,\n \"circle-stroke-color\": \"#fff\",\n \"circle-opacity\": 0.85,\n },\n });\n\n // Add cluster count text layer\n map.addLayer({\n id: clusterCountLayerId,\n type: \"symbol\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n layout: {\n \"text-field\": \"{point_count_abbreviated}\",\n \"text-font\": [\"Open Sans\"],\n \"text-size\": 12,\n },\n paint: {\n \"text-color\": \"#fff\",\n },\n });\n\n // Add unclustered point layer\n map.addLayer({\n id: unclusteredLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"!\", [\"has\", \"point_count\"]],\n paint: {\n \"circle-color\": pointColor,\n \"circle-radius\": 5,\n \"circle-stroke-width\": 2,\n \"circle-stroke-color\": \"#fff\",\n },\n });\n\n return () => {\n try {\n if (map.getLayer(clusterCountLayerId))\n map.removeLayer(clusterCountLayerId);\n if (map.getLayer(unclusteredLayerId))\n map.removeLayer(unclusteredLayerId);\n if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isLoaded, map, sourceId]);\n\n // Update source data when data prop changes (only for non-URL data)\n useEffect(() => {\n if (!isLoaded || !map || typeof data === \"string\") return;\n\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n if (source) {\n source.setData(data);\n }\n }, [isLoaded, map, data, sourceId]);\n\n // Update layer styles when props change\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n const prev = stylePropsRef.current;\n const colorsChanged =\n prev.clusterColors !== clusterColors ||\n prev.clusterThresholds !== clusterThresholds;\n\n // Update cluster layer colors and sizes\n if (map.getLayer(clusterLayerId) && colorsChanged) {\n map.setPaintProperty(clusterLayerId, \"circle-color\", [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ]);\n map.setPaintProperty(clusterLayerId, \"circle-radius\", [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ]);\n }\n\n // Update unclustered point layer color\n if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) {\n map.setPaintProperty(unclusteredLayerId, \"circle-color\", pointColor);\n }\n\n stylePropsRef.current = { clusterColors, clusterThresholds, pointColor };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n clusterColors,\n clusterThresholds,\n pointColor,\n ]);\n\n // Handle click events\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Cluster click handler - zoom into cluster\n const handleClusterClick = async (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n }\n ) => {\n const features = map.queryRenderedFeatures(e.point, {\n layers: [clusterLayerId],\n });\n if (!features.length) return;\n\n const feature = features[0];\n const clusterId = feature.properties?.cluster_id as number;\n const pointCount = feature.properties?.point_count as number;\n const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [\n number,\n number\n ];\n\n if (onClusterClick) {\n onClusterClick(clusterId, coordinates, pointCount);\n } else {\n // Default behavior: zoom to cluster expansion zoom\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n const zoom = await source.getClusterExpansionZoom(clusterId);\n map.easeTo({\n center: coordinates,\n zoom,\n });\n }\n };\n\n // Unclustered point click handler\n const handlePointClick = (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n }\n ) => {\n if (!onPointClick || !e.features?.length) return;\n\n const feature = e.features[0];\n const coordinates = (\n feature.geometry as GeoJSON.Point\n ).coordinates.slice() as [number, number];\n\n // Handle world copies\n while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {\n coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;\n }\n\n onPointClick(\n feature as unknown as GeoJSON.Feature,\n coordinates\n );\n };\n\n // Cursor style handlers\n const handleMouseEnterCluster = () => {\n map.getCanvas().style.cursor = \"pointer\";\n };\n const handleMouseLeaveCluster = () => {\n map.getCanvas().style.cursor = \"\";\n };\n const handleMouseEnterPoint = () => {\n if (onPointClick) {\n map.getCanvas().style.cursor = \"pointer\";\n }\n };\n const handleMouseLeavePoint = () => {\n map.getCanvas().style.cursor = \"\";\n };\n\n map.on(\"click\", clusterLayerId, handleClusterClick);\n map.on(\"click\", unclusteredLayerId, handlePointClick);\n map.on(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.on(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.on(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.on(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n\n return () => {\n map.off(\"click\", clusterLayerId, handleClusterClick);\n map.off(\"click\", unclusteredLayerId, handlePointClick);\n map.off(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.off(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.off(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.off(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n sourceId,\n onClusterClick,\n onPointClick,\n ]);\n\n return null;\n}\n\nexport {\n Map,\n useMap,\n MapMarker,\n MarkerContent,\n MarkerPopup,\n MarkerTooltip,\n MarkerLabel,\n MapPopup,\n MapControls,\n MapRoute,\n MapClusterLayer,\n};\n\nexport type { MapRef, MapViewport };\n",
+ "content": "\"use client\";\n\nimport MapLibreGL, { type PopupOptions, type MarkerOptions } from \"maplibre-gl\";\nimport \"maplibre-gl/dist/maplibre-gl.css\";\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { X, Minus, Plus, Locate, Maximize, Loader2 } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst defaultStyles = {\n dark: \"https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json\",\n light: \"https://basemaps.cartocdn.com/gl/positron-gl-style/style.json\",\n};\n\ntype Theme = \"light\" | \"dark\";\n\n// Check document class for theme (works with next-themes, etc.)\nfunction getDocumentTheme(): Theme | null {\n if (typeof document === \"undefined\") return null;\n if (document.documentElement.classList.contains(\"dark\")) return \"dark\";\n if (document.documentElement.classList.contains(\"light\")) return \"light\";\n return null;\n}\n\n// Get system preference\nfunction getSystemTheme(): Theme {\n if (typeof window === \"undefined\") return \"light\";\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n ? \"dark\"\n : \"light\";\n}\n\nfunction useResolvedTheme(themeProp?: \"light\" | \"dark\"): Theme {\n const [detectedTheme, setDetectedTheme] = useState(\n () => getDocumentTheme() ?? getSystemTheme()\n );\n\n useEffect(() => {\n if (themeProp) return; // Skip detection if theme is provided via prop\n\n // Watch for document class changes (e.g., next-themes toggling dark class)\n const observer = new MutationObserver(() => {\n const docTheme = getDocumentTheme();\n if (docTheme) {\n setDetectedTheme(docTheme);\n }\n });\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\"],\n });\n\n // Also watch for system preference changes\n const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n const handleSystemChange = (e: MediaQueryListEvent) => {\n // Only use system preference if no document class is set\n if (!getDocumentTheme()) {\n setDetectedTheme(e.matches ? \"dark\" : \"light\");\n }\n };\n mediaQuery.addEventListener(\"change\", handleSystemChange);\n\n return () => {\n observer.disconnect();\n mediaQuery.removeEventListener(\"change\", handleSystemChange);\n };\n }, [themeProp]);\n\n return themeProp ?? detectedTheme;\n}\n\ntype MapContextValue = {\n map: MapLibreGL.Map | null;\n isLoaded: boolean;\n};\n\nconst MapContext = createContext(null);\n\nfunction useMap() {\n const context = useContext(MapContext);\n if (!context) {\n throw new Error(\"useMap must be used within a Map component\");\n }\n return context;\n}\n\n/** Map viewport state */\ntype MapViewport = {\n /** Center coordinates [longitude, latitude] */\n center: [number, number];\n /** Zoom level */\n zoom: number;\n /** Bearing (rotation) in degrees */\n bearing: number;\n /** Pitch (tilt) in degrees */\n pitch: number;\n};\n\ntype MapStyleOption = string | MapLibreGL.StyleSpecification;\n\ntype MapRef = MapLibreGL.Map;\n\ntype MapProps = {\n children?: ReactNode;\n /** Additional CSS classes for the map container */\n className?: string;\n /**\n * Theme for the map. If not provided, automatically detects system preference.\n * Pass your theme value here.\n */\n theme?: Theme;\n /** Custom map styles for light and dark themes. Overrides the default Carto styles. */\n styles?: {\n light?: MapStyleOption;\n dark?: MapStyleOption;\n };\n /** Map projection type. Use `{ type: \"globe\" }` for 3D globe view. */\n projection?: MapLibreGL.ProjectionSpecification;\n /**\n * Controlled viewport. When provided with onViewportChange,\n * the map becomes controlled and viewport is driven by this prop.\n */\n viewport?: Partial;\n /**\n * Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch).\n * Can be used standalone to observe changes, or with `viewport` prop\n * to enable controlled mode where the map viewport is driven by your state.\n */\n onViewportChange?: (viewport: MapViewport) => void;\n /** Show a loading indicator on the map */\n loading?: boolean;\n} & Omit;\n\nfunction DefaultLoader() {\n return (\n \n );\n}\n\nfunction getViewport(map: MapLibreGL.Map): MapViewport {\n const center = map.getCenter();\n return {\n center: [center.lng, center.lat],\n zoom: map.getZoom(),\n bearing: map.getBearing(),\n pitch: map.getPitch(),\n };\n}\n\nconst Map = forwardRef(function Map(\n {\n children,\n className,\n theme: themeProp,\n styles,\n projection,\n viewport,\n onViewportChange,\n loading = false,\n ...props\n },\n ref\n) {\n const containerRef = useRef(null);\n const [mapInstance, setMapInstance] = useState(null);\n const [isLoaded, setIsLoaded] = useState(false);\n const [isStyleLoaded, setIsStyleLoaded] = useState(false);\n const currentStyleRef = useRef(null);\n const styleTimeoutRef = useRef | null>(null);\n const internalUpdateRef = useRef(false);\n const resolvedTheme = useResolvedTheme(themeProp);\n\n const isControlled = viewport !== undefined && onViewportChange !== undefined;\n\n const onViewportChangeRef = useRef(onViewportChange);\n onViewportChangeRef.current = onViewportChange;\n\n const mapStyles = useMemo(\n () => ({\n dark: styles?.dark ?? defaultStyles.dark,\n light: styles?.light ?? defaultStyles.light,\n }),\n [styles]\n );\n\n // Expose the map instance to the parent component\n useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);\n\n const clearStyleTimeout = useCallback(() => {\n if (styleTimeoutRef.current) {\n clearTimeout(styleTimeoutRef.current);\n styleTimeoutRef.current = null;\n }\n }, []);\n\n // Initialize the map\n useEffect(() => {\n if (!containerRef.current) return;\n\n const initialStyle =\n resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n currentStyleRef.current = initialStyle;\n\n const map = new MapLibreGL.Map({\n container: containerRef.current,\n style: initialStyle,\n renderWorldCopies: false,\n attributionControl: {\n compact: true,\n },\n ...props,\n ...viewport,\n });\n\n const styleDataHandler = () => {\n clearStyleTimeout();\n // Delay to ensure style is fully processed before allowing layer operations\n // This is a workaround to avoid race conditions with the style loading\n // else we have to force update every layer on setStyle change\n styleTimeoutRef.current = setTimeout(() => {\n setIsStyleLoaded(true);\n if (projection) {\n map.setProjection(projection);\n }\n }, 100);\n };\n const loadHandler = () => setIsLoaded(true);\n\n // Viewport change handler - skip if triggered by internal update\n const handleMove = () => {\n if (internalUpdateRef.current) return;\n onViewportChangeRef.current?.(getViewport(map));\n };\n\n map.on(\"load\", loadHandler);\n map.on(\"styledata\", styleDataHandler);\n map.on(\"move\", handleMove);\n setMapInstance(map);\n\n return () => {\n clearStyleTimeout();\n map.off(\"load\", loadHandler);\n map.off(\"styledata\", styleDataHandler);\n map.off(\"move\", handleMove);\n map.remove();\n setIsLoaded(false);\n setIsStyleLoaded(false);\n setMapInstance(null);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Sync controlled viewport to map\n useEffect(() => {\n if (!mapInstance || !isControlled || !viewport) return;\n if (mapInstance.isMoving()) return;\n\n const current = getViewport(mapInstance);\n const next = {\n center: viewport.center ?? current.center,\n zoom: viewport.zoom ?? current.zoom,\n bearing: viewport.bearing ?? current.bearing,\n pitch: viewport.pitch ?? current.pitch,\n };\n\n if (\n next.center[0] === current.center[0] &&\n next.center[1] === current.center[1] &&\n next.zoom === current.zoom &&\n next.bearing === current.bearing &&\n next.pitch === current.pitch\n ) {\n return;\n }\n\n internalUpdateRef.current = true;\n mapInstance.jumpTo(next);\n internalUpdateRef.current = false;\n }, [mapInstance, isControlled, viewport]);\n\n // Handle style change\n useEffect(() => {\n if (!mapInstance || !resolvedTheme) return;\n\n const newStyle =\n resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n\n if (currentStyleRef.current === newStyle) return;\n\n clearStyleTimeout();\n currentStyleRef.current = newStyle;\n setIsStyleLoaded(false);\n\n mapInstance.setStyle(newStyle, { diff: true });\n }, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]);\n\n const contextValue = useMemo(\n () => ({\n map: mapInstance,\n isLoaded: isLoaded && isStyleLoaded,\n }),\n [mapInstance, isLoaded, isStyleLoaded]\n );\n\n return (\n \n \n {(!isLoaded || loading) && }\n {/* SSR-safe: children render only when map is loaded on client */}\n {mapInstance && children}\n
\n \n );\n});\n\ntype MarkerContextValue = {\n marker: MapLibreGL.Marker;\n map: MapLibreGL.Map | null;\n};\n\nconst MarkerContext = createContext(null);\n\nfunction useMarkerContext() {\n const context = useContext(MarkerContext);\n if (!context) {\n throw new Error(\"Marker components must be used within MapMarker\");\n }\n return context;\n}\n\ntype MapMarkerProps = {\n /** Longitude coordinate for marker position */\n longitude: number;\n /** Latitude coordinate for marker position */\n latitude: number;\n /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */\n children: ReactNode;\n /** Callback when marker is clicked */\n onClick?: (e: MouseEvent) => void;\n /** Callback when mouse enters marker */\n onMouseEnter?: (e: MouseEvent) => void;\n /** Callback when mouse leaves marker */\n onMouseLeave?: (e: MouseEvent) => void;\n /** Callback when marker drag starts (requires draggable: true) */\n onDragStart?: (lngLat: { lng: number; lat: number }) => void;\n /** Callback during marker drag (requires draggable: true) */\n onDrag?: (lngLat: { lng: number; lat: number }) => void;\n /** Callback when marker drag ends (requires draggable: true) */\n onDragEnd?: (lngLat: { lng: number; lat: number }) => void;\n} & Omit;\n\nfunction MapMarker({\n longitude,\n latitude,\n children,\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n draggable = false,\n ...markerOptions\n}: MapMarkerProps) {\n const { map } = useMap();\n\n const callbacksRef = useRef({\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n });\n callbacksRef.current = {\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n };\n\n const marker = useMemo(() => {\n const markerInstance = new MapLibreGL.Marker({\n ...markerOptions,\n element: document.createElement(\"div\"),\n draggable,\n }).setLngLat([longitude, latitude]);\n\n const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);\n const handleMouseEnter = (e: MouseEvent) =>\n callbacksRef.current.onMouseEnter?.(e);\n const handleMouseLeave = (e: MouseEvent) =>\n callbacksRef.current.onMouseLeave?.(e);\n\n markerInstance.getElement()?.addEventListener(\"click\", handleClick);\n markerInstance\n .getElement()\n ?.addEventListener(\"mouseenter\", handleMouseEnter);\n markerInstance\n .getElement()\n ?.addEventListener(\"mouseleave\", handleMouseLeave);\n\n const handleDragStart = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n const handleDrag = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n const handleDragEnd = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n\n markerInstance.on(\"dragstart\", handleDragStart);\n markerInstance.on(\"drag\", handleDrag);\n markerInstance.on(\"dragend\", handleDragEnd);\n\n return markerInstance;\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n marker.addTo(map);\n\n return () => {\n marker.remove();\n };\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (\n marker.getLngLat().lng !== longitude ||\n marker.getLngLat().lat !== latitude\n ) {\n marker.setLngLat([longitude, latitude]);\n }\n if (marker.isDraggable() !== draggable) {\n marker.setDraggable(draggable);\n }\n\n const currentOffset = marker.getOffset();\n const newOffset = markerOptions.offset ?? [0, 0];\n const [newOffsetX, newOffsetY] = Array.isArray(newOffset)\n ? newOffset\n : [newOffset.x, newOffset.y];\n if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) {\n marker.setOffset(newOffset);\n }\n\n if (marker.getRotation() !== markerOptions.rotation) {\n marker.setRotation(markerOptions.rotation ?? 0);\n }\n if (marker.getRotationAlignment() !== markerOptions.rotationAlignment) {\n marker.setRotationAlignment(markerOptions.rotationAlignment ?? \"auto\");\n }\n if (marker.getPitchAlignment() !== markerOptions.pitchAlignment) {\n marker.setPitchAlignment(markerOptions.pitchAlignment ?? \"auto\");\n }\n\n return (\n \n {children}\n \n );\n}\n\ntype MarkerContentProps = {\n /** Custom marker content. Defaults to a blue dot if not provided */\n children?: ReactNode;\n /** Additional CSS classes for the marker container */\n className?: string;\n};\n\nfunction MarkerContent({ children, className }: MarkerContentProps) {\n const { marker } = useMarkerContext();\n\n return createPortal(\n \n {children || }\n
,\n marker.getElement()\n );\n}\n\nfunction DefaultMarkerIcon() {\n return (\n \n );\n}\n\ntype MarkerPopupProps = {\n /** Popup content */\n children: ReactNode;\n /** Additional CSS classes for the popup container */\n className?: string;\n /** Show a close button in the popup (default: false) */\n closeButton?: boolean;\n} & Omit;\n\nfunction MarkerPopup({\n children,\n className,\n closeButton = false,\n ...popupOptions\n}: MarkerPopupProps) {\n const { marker, map } = useMarkerContext();\n const container = useMemo(() => document.createElement(\"div\"), []);\n const prevPopupOptions = useRef(popupOptions);\n\n const popup = useMemo(() => {\n const popupInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeButton: false,\n })\n .setMaxWidth(\"none\")\n .setDOMContent(container);\n\n return popupInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n popup.setDOMContent(container);\n marker.setPopup(popup);\n\n return () => {\n marker.setPopup(null);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (popup.isOpen()) {\n const prev = prevPopupOptions.current;\n\n if (prev.offset !== popupOptions.offset) {\n popup.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n popup.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n\n prevPopupOptions.current = popupOptions;\n }\n\n const handleClose = () => popup.remove();\n\n return createPortal(\n \n {closeButton && (\n \n )}\n {children}\n
,\n container\n );\n}\n\ntype MarkerTooltipProps = {\n /** Tooltip content */\n children: ReactNode;\n /** Additional CSS classes for the tooltip container */\n className?: string;\n} & Omit;\n\nfunction MarkerTooltip({\n children,\n className,\n ...popupOptions\n}: MarkerTooltipProps) {\n const { marker, map } = useMarkerContext();\n const container = useMemo(() => document.createElement(\"div\"), []);\n const prevTooltipOptions = useRef(popupOptions);\n\n const tooltip = useMemo(() => {\n const tooltipInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeOnClick: true,\n closeButton: false,\n }).setMaxWidth(\"none\");\n\n return tooltipInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n tooltip.setDOMContent(container);\n\n const handleMouseEnter = () => {\n tooltip.setLngLat(marker.getLngLat()).addTo(map);\n };\n const handleMouseLeave = () => tooltip.remove();\n\n marker.getElement()?.addEventListener(\"mouseenter\", handleMouseEnter);\n marker.getElement()?.addEventListener(\"mouseleave\", handleMouseLeave);\n\n return () => {\n marker.getElement()?.removeEventListener(\"mouseenter\", handleMouseEnter);\n marker.getElement()?.removeEventListener(\"mouseleave\", handleMouseLeave);\n tooltip.remove();\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (tooltip.isOpen()) {\n const prev = prevTooltipOptions.current;\n\n if (prev.offset !== popupOptions.offset) {\n tooltip.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n tooltip.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n\n prevTooltipOptions.current = popupOptions;\n }\n\n return createPortal(\n \n {children}\n
,\n container\n );\n}\n\ntype MarkerLabelProps = {\n /** Label text content */\n children: ReactNode;\n /** Additional CSS classes for the label */\n className?: string;\n /** Position of the label relative to the marker (default: \"top\") */\n position?: \"top\" | \"bottom\";\n};\n\nfunction MarkerLabel({\n children,\n className,\n position = \"top\",\n}: MarkerLabelProps) {\n const positionClasses = {\n top: \"bottom-full mb-1\",\n bottom: \"top-full mt-1\",\n };\n\n return (\n \n {children}\n
\n );\n}\n\ntype MapControlsProps = {\n /** Position of the controls on the map (default: \"bottom-right\") */\n position?: \"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\";\n /** Show zoom in/out buttons (default: true) */\n showZoom?: boolean;\n /** Show compass button to reset bearing (default: false) */\n showCompass?: boolean;\n /** Show locate button to find user's location (default: false) */\n showLocate?: boolean;\n /** Show fullscreen toggle button (default: false) */\n showFullscreen?: boolean;\n /** Additional CSS classes for the controls container */\n className?: string;\n /** Callback with user coordinates when located */\n onLocate?: (coords: { longitude: number; latitude: number }) => void;\n};\n\nconst positionClasses = {\n \"top-left\": \"top-2 left-2\",\n \"top-right\": \"top-2 right-2\",\n \"bottom-left\": \"bottom-2 left-2\",\n \"bottom-right\": \"bottom-10 right-2\",\n};\n\nfunction ControlGroup({ children }: { children: React.ReactNode }) {\n return (\n button:not(:last-child)]:border-b [&>button:not(:last-child)]:border-border\">\n {children}\n
\n );\n}\n\nfunction ControlButton({\n onClick,\n label,\n children,\n disabled = false,\n}: {\n onClick: () => void;\n label: string;\n children: React.ReactNode;\n disabled?: boolean;\n}) {\n return (\n \n );\n}\n\nfunction MapControls({\n position = \"bottom-right\",\n showZoom = true,\n showCompass = false,\n showLocate = false,\n showFullscreen = false,\n className,\n onLocate,\n}: MapControlsProps) {\n const { map } = useMap();\n const [waitingForLocation, setWaitingForLocation] = useState(false);\n\n const handleZoomIn = useCallback(() => {\n map?.zoomTo(map.getZoom() + 1, { duration: 300 });\n }, [map]);\n\n const handleZoomOut = useCallback(() => {\n map?.zoomTo(map.getZoom() - 1, { duration: 300 });\n }, [map]);\n\n const handleResetBearing = useCallback(() => {\n map?.resetNorthPitch({ duration: 300 });\n }, [map]);\n\n const handleLocate = useCallback(() => {\n setWaitingForLocation(true);\n if (\"geolocation\" in navigator) {\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const coords = {\n longitude: pos.coords.longitude,\n latitude: pos.coords.latitude,\n };\n map?.flyTo({\n center: [coords.longitude, coords.latitude],\n zoom: 14,\n duration: 1500,\n });\n onLocate?.(coords);\n setWaitingForLocation(false);\n },\n (error) => {\n console.error(\"Error getting location:\", error);\n setWaitingForLocation(false);\n }\n );\n }\n }, [map, onLocate]);\n\n const handleFullscreen = useCallback(() => {\n const container = map?.getContainer();\n if (!container) return;\n if (document.fullscreenElement) {\n document.exitFullscreen();\n } else {\n container.requestFullscreen();\n }\n }, [map]);\n\n return (\n \n {showZoom && (\n
\n \n \n \n \n \n \n \n )}\n {showCompass && (\n
\n \n \n )}\n {showLocate && (\n
\n \n {waitingForLocation ? (\n \n ) : (\n \n )}\n \n \n )}\n {showFullscreen && (\n
\n \n \n \n \n )}\n
\n );\n}\n\nfunction CompassButton({ onClick }: { onClick: () => void }) {\n const { map } = useMap();\n const compassRef = useRef(null);\n\n useEffect(() => {\n if (!map || !compassRef.current) return;\n\n const compass = compassRef.current;\n\n const updateRotation = () => {\n const bearing = map.getBearing();\n const pitch = map.getPitch();\n compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;\n };\n\n map.on(\"rotate\", updateRotation);\n map.on(\"pitch\", updateRotation);\n updateRotation();\n\n return () => {\n map.off(\"rotate\", updateRotation);\n map.off(\"pitch\", updateRotation);\n };\n }, [map]);\n\n return (\n \n \n \n );\n}\n\ntype MapPopupProps = {\n /** Longitude coordinate for popup position */\n longitude: number;\n /** Latitude coordinate for popup position */\n latitude: number;\n /** Callback when popup is closed */\n onClose?: () => void;\n /** Popup content */\n children: ReactNode;\n /** Additional CSS classes for the popup container */\n className?: string;\n /** Show a close button in the popup (default: false) */\n closeButton?: boolean;\n} & Omit;\n\nfunction MapPopup({\n longitude,\n latitude,\n onClose,\n children,\n className,\n closeButton = false,\n ...popupOptions\n}: MapPopupProps) {\n const { map } = useMap();\n const popupOptionsRef = useRef(popupOptions);\n const onCloseRef = useRef(onClose);\n onCloseRef.current = onClose;\n const container = useMemo(() => document.createElement(\"div\"), []);\n\n const popup = useMemo(() => {\n const popupInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeButton: false,\n })\n .setMaxWidth(\"none\")\n .setLngLat([longitude, latitude]);\n\n return popupInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n const onCloseProp = () => onCloseRef.current?.();\n\n popup.on(\"close\", onCloseProp);\n\n popup.setDOMContent(container);\n popup.addTo(map);\n\n return () => {\n popup.off(\"close\", onCloseProp);\n if (popup.isOpen()) {\n popup.remove();\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (popup.isOpen()) {\n const prev = popupOptionsRef.current;\n\n if (\n popup.getLngLat().lng !== longitude ||\n popup.getLngLat().lat !== latitude\n ) {\n popup.setLngLat([longitude, latitude]);\n }\n\n if (prev.offset !== popupOptions.offset) {\n popup.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n popup.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n popupOptionsRef.current = popupOptions;\n }\n\n const handleClose = () => {\n popup.remove();\n };\n\n return createPortal(\n \n {closeButton && (\n \n )}\n {children}\n
,\n container\n );\n}\n\ntype MapRouteProps = {\n /** Optional unique identifier for the route layer */\n id?: string;\n /** Array of [longitude, latitude] coordinate pairs defining the route */\n coordinates: [number, number][];\n /** Line color as CSS color value (default: \"#4285F4\") */\n color?: string;\n /** Line width in pixels (default: 3) */\n width?: number;\n /** Line opacity from 0 to 1 (default: 0.8) */\n opacity?: number;\n /** Dash pattern [dash length, gap length] for dashed lines */\n dashArray?: [number, number];\n /** Callback when the route line is clicked */\n onClick?: () => void;\n /** Callback when mouse enters the route line */\n onMouseEnter?: () => void;\n /** Callback when mouse leaves the route line */\n onMouseLeave?: () => void;\n /** Whether the route is interactive - shows pointer cursor on hover (default: true) */\n interactive?: boolean;\n};\n\nfunction MapRoute({\n id: propId,\n coordinates,\n color = \"#4285F4\",\n width = 3,\n opacity = 0.8,\n dashArray,\n onClick,\n onMouseEnter,\n onMouseLeave,\n interactive = true,\n}: MapRouteProps) {\n const { map, isLoaded } = useMap();\n const autoId = useId();\n const id = propId ?? autoId;\n const sourceId = `route-source-${id}`;\n const layerId = `route-layer-${id}`;\n\n // Add source and layer on mount\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n map.addSource(sourceId, {\n type: \"geojson\",\n data: {\n type: \"Feature\",\n properties: {},\n geometry: { type: \"LineString\", coordinates: [] },\n },\n });\n\n map.addLayer({\n id: layerId,\n type: \"line\",\n source: sourceId,\n layout: { \"line-join\": \"round\", \"line-cap\": \"round\" },\n paint: {\n \"line-color\": color,\n \"line-width\": width,\n \"line-opacity\": opacity,\n ...(dashArray && { \"line-dasharray\": dashArray }),\n },\n });\n\n return () => {\n try {\n if (map.getLayer(layerId)) map.removeLayer(layerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isLoaded, map]);\n\n // When coordinates change, update the source data\n useEffect(() => {\n if (!isLoaded || !map || coordinates.length < 2) return;\n\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n if (source) {\n source.setData({\n type: \"Feature\",\n properties: {},\n geometry: { type: \"LineString\", coordinates },\n });\n }\n }, [isLoaded, map, coordinates, sourceId]);\n\n useEffect(() => {\n if (!isLoaded || !map || !map.getLayer(layerId)) return;\n\n map.setPaintProperty(layerId, \"line-color\", color);\n map.setPaintProperty(layerId, \"line-width\", width);\n map.setPaintProperty(layerId, \"line-opacity\", opacity);\n if (dashArray) {\n map.setPaintProperty(layerId, \"line-dasharray\", dashArray);\n }\n }, [isLoaded, map, layerId, color, width, opacity, dashArray]);\n\n // Handle click and hover events\n useEffect(() => {\n if (!isLoaded || !map || !interactive) return;\n\n const handleClick = () => {\n onClick?.();\n };\n const handleMouseEnter = () => {\n map.getCanvas().style.cursor = \"pointer\";\n onMouseEnter?.();\n };\n const handleMouseLeave = () => {\n map.getCanvas().style.cursor = \"\";\n onMouseLeave?.();\n };\n\n map.on(\"click\", layerId, handleClick);\n map.on(\"mouseenter\", layerId, handleMouseEnter);\n map.on(\"mouseleave\", layerId, handleMouseLeave);\n\n return () => {\n map.off(\"click\", layerId, handleClick);\n map.off(\"mouseenter\", layerId, handleMouseEnter);\n map.off(\"mouseleave\", layerId, handleMouseLeave);\n };\n }, [\n isLoaded,\n map,\n layerId,\n onClick,\n onMouseEnter,\n onMouseLeave,\n interactive,\n ]);\n\n return null;\n}\n\ntype MapClusterLayerProps<\n P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties\n> = {\n /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */\n data: string | GeoJSON.FeatureCollection;\n /** Maximum zoom level to cluster points on (default: 14) */\n clusterMaxZoom?: number;\n /** Radius of each cluster when clustering points in pixels (default: 50) */\n clusterRadius?: number;\n /** Colors for cluster circles: [small, medium, large] based on point count (default: [\"#22c55e\", \"#eab308\", \"#ef4444\"]) */\n clusterColors?: [string, string, string];\n /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */\n clusterThresholds?: [number, number];\n /** Color for unclustered individual points (default: \"#3b82f6\") */\n pointColor?: string;\n /** Callback when an unclustered point is clicked */\n onPointClick?: (\n feature: GeoJSON.Feature,\n coordinates: [number, number]\n ) => void;\n /** Callback when a cluster is clicked. If not provided, zooms into the cluster */\n onClusterClick?: (\n clusterId: number,\n coordinates: [number, number],\n pointCount: number\n ) => void;\n};\n\nfunction MapClusterLayer<\n P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties\n>({\n data,\n clusterMaxZoom = 14,\n clusterRadius = 50,\n clusterColors = [\"#22c55e\", \"#eab308\", \"#ef4444\"],\n clusterThresholds = [100, 750],\n pointColor = \"#3b82f6\",\n onPointClick,\n onClusterClick,\n}: MapClusterLayerProps) {\n const { map, isLoaded } = useMap();\n const id = useId();\n const sourceId = `cluster-source-${id}`;\n const clusterLayerId = `clusters-${id}`;\n const clusterCountLayerId = `cluster-count-${id}`;\n const unclusteredLayerId = `unclustered-point-${id}`;\n\n const stylePropsRef = useRef({\n clusterColors,\n clusterThresholds,\n pointColor,\n });\n\n // Add source and layers on mount\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Add clustered GeoJSON source\n map.addSource(sourceId, {\n type: \"geojson\",\n data,\n cluster: true,\n clusterMaxZoom,\n clusterRadius,\n });\n\n // Add cluster circles layer\n map.addLayer({\n id: clusterLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n paint: {\n \"circle-color\": [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ],\n \"circle-radius\": [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ],\n \"circle-stroke-width\": 1,\n \"circle-stroke-color\": \"#fff\",\n \"circle-opacity\": 0.85,\n },\n });\n\n // Add cluster count text layer\n map.addLayer({\n id: clusterCountLayerId,\n type: \"symbol\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n layout: {\n \"text-field\": \"{point_count_abbreviated}\",\n \"text-font\": [\"Open Sans\"],\n \"text-size\": 12,\n },\n paint: {\n \"text-color\": \"#fff\",\n },\n });\n\n // Add unclustered point layer\n map.addLayer({\n id: unclusteredLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"!\", [\"has\", \"point_count\"]],\n paint: {\n \"circle-color\": pointColor,\n \"circle-radius\": 5,\n \"circle-stroke-width\": 2,\n \"circle-stroke-color\": \"#fff\",\n },\n });\n\n return () => {\n try {\n if (map.getLayer(clusterCountLayerId))\n map.removeLayer(clusterCountLayerId);\n if (map.getLayer(unclusteredLayerId))\n map.removeLayer(unclusteredLayerId);\n if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isLoaded, map, sourceId]);\n\n // Update source data when data prop changes (only for non-URL data)\n useEffect(() => {\n if (!isLoaded || !map || typeof data === \"string\") return;\n\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n if (source) {\n source.setData(data);\n }\n }, [isLoaded, map, data, sourceId]);\n\n // Update layer styles when props change\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n const prev = stylePropsRef.current;\n const colorsChanged =\n prev.clusterColors !== clusterColors ||\n prev.clusterThresholds !== clusterThresholds;\n\n // Update cluster layer colors and sizes\n if (map.getLayer(clusterLayerId) && colorsChanged) {\n map.setPaintProperty(clusterLayerId, \"circle-color\", [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ]);\n map.setPaintProperty(clusterLayerId, \"circle-radius\", [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ]);\n }\n\n // Update unclustered point layer color\n if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) {\n map.setPaintProperty(unclusteredLayerId, \"circle-color\", pointColor);\n }\n\n stylePropsRef.current = { clusterColors, clusterThresholds, pointColor };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n clusterColors,\n clusterThresholds,\n pointColor,\n ]);\n\n // Handle click events\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Cluster click handler - zoom into cluster\n const handleClusterClick = async (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n }\n ) => {\n const features = map.queryRenderedFeatures(e.point, {\n layers: [clusterLayerId],\n });\n if (!features.length) return;\n\n const feature = features[0];\n const clusterId = feature.properties?.cluster_id as number;\n const pointCount = feature.properties?.point_count as number;\n const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [\n number,\n number\n ];\n\n if (onClusterClick) {\n onClusterClick(clusterId, coordinates, pointCount);\n } else {\n // Default behavior: zoom to cluster expansion zoom\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n const zoom = await source.getClusterExpansionZoom(clusterId);\n map.easeTo({\n center: coordinates,\n zoom,\n });\n }\n };\n\n // Unclustered point click handler\n const handlePointClick = (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n }\n ) => {\n if (!onPointClick || !e.features?.length) return;\n\n const feature = e.features[0];\n const coordinates = (\n feature.geometry as GeoJSON.Point\n ).coordinates.slice() as [number, number];\n\n // Handle world copies\n while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {\n coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;\n }\n\n onPointClick(\n feature as unknown as GeoJSON.Feature,\n coordinates\n );\n };\n\n // Cursor style handlers\n const handleMouseEnterCluster = () => {\n map.getCanvas().style.cursor = \"pointer\";\n };\n const handleMouseLeaveCluster = () => {\n map.getCanvas().style.cursor = \"\";\n };\n const handleMouseEnterPoint = () => {\n if (onPointClick) {\n map.getCanvas().style.cursor = \"pointer\";\n }\n };\n const handleMouseLeavePoint = () => {\n map.getCanvas().style.cursor = \"\";\n };\n\n map.on(\"click\", clusterLayerId, handleClusterClick);\n map.on(\"click\", unclusteredLayerId, handlePointClick);\n map.on(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.on(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.on(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.on(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n\n return () => {\n map.off(\"click\", clusterLayerId, handleClusterClick);\n map.off(\"click\", unclusteredLayerId, handlePointClick);\n map.off(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.off(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.off(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.off(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n sourceId,\n onClusterClick,\n onPointClick,\n ]);\n\n return null;\n}\n\nexport {\n Map,\n useMap,\n MapMarker,\n MarkerContent,\n MarkerPopup,\n MarkerTooltip,\n MarkerLabel,\n MapPopup,\n MapControls,\n MapRoute,\n MapClusterLayer,\n};\n\nexport type { MapRef, MapViewport };\n",
"type": "registry:ui",
"target": "components/ui/map.tsx"
}
diff --git a/public/r/registry.json b/public/r/registry.json
index 7579221..af7d423 100644
--- a/public/r/registry.json
+++ b/public/r/registry.json
@@ -27,6 +27,114 @@
}
}
}
+ },
+ {
+ "name": "analytics-map",
+ "type": "registry:block",
+ "title": "Analytics Map",
+ "description": "Real-time analytics overview with a world map, breakdown cards, and device stats.",
+ "dependencies": ["recharts", "lucide-react"],
+ "registryDependencies": ["map", "card", "chart"],
+ "files": [
+ {
+ "path": "src/registry/blocks/analytics-map/page.tsx",
+ "type": "registry:page",
+ "target": "app/analytics/page.tsx"
+ },
+ {
+ "path": "src/registry/blocks/analytics-map/data.ts",
+ "type": "registry:component",
+ "target": "app/analytics/data.ts"
+ },
+ {
+ "path": "src/registry/blocks/analytics-map/components/overview-card.tsx",
+ "type": "registry:component",
+ "target": "app/analytics/components/overview-card.tsx"
+ },
+ {
+ "path": "src/registry/blocks/analytics-map/components/breakdown-card.tsx",
+ "type": "registry:component",
+ "target": "app/analytics/components/breakdown-card.tsx"
+ }
+ ],
+ "categories": ["analytics", "dashboard"],
+ "meta": {
+ "iframeHeight": "970px"
+ }
+ },
+ {
+ "name": "logistics-network",
+ "type": "registry:block",
+ "title": "Logistics Network",
+ "description": "Domestic logistics map with a sidebar of stats.",
+ "dependencies": ["lucide-react"],
+ "registryDependencies": ["map", "card", "badge", "button"],
+ "files": [
+ {
+ "path": "src/registry/blocks/logistics-network/page.tsx",
+ "type": "registry:page",
+ "target": "app/logistics/page.tsx"
+ },
+ {
+ "path": "src/registry/blocks/logistics-network/data.ts",
+ "type": "registry:component",
+ "target": "app/logistics/data.ts"
+ },
+ {
+ "path": "src/registry/blocks/logistics-network/components/map-arcs.tsx",
+ "type": "registry:component",
+ "target": "app/logistics/components/map-arcs.tsx"
+ },
+ {
+ "path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx",
+ "type": "registry:component",
+ "target": "app/logistics/components/filter-sidebar.tsx"
+ },
+ {
+ "path": "src/registry/blocks/logistics-network/components/network-map.tsx",
+ "type": "registry:component",
+ "target": "app/logistics/components/network-map.tsx"
+ }
+ ],
+ "categories": ["logistics", "network"],
+ "meta": {
+ "iframeHeight": "800px"
+ }
+ },
+ {
+ "name": "heatmap",
+ "type": "registry:block",
+ "title": "Heatmap",
+ "description": "Globe-projected heatmap visualizing earthquake density with zoom-dependent styling.",
+ "dependencies": [],
+ "registryDependencies": ["map", "card"],
+ "files": [
+ {
+ "path": "src/registry/blocks/heatmap/page.tsx",
+ "type": "registry:page",
+ "target": "app/heatmap/page.tsx"
+ }
+ ],
+ "categories": ["visualization", "heatmap"]
+ },
+ {
+ "name": "delivery-tracker",
+ "type": "registry:block",
+ "title": "Delivery Tracker",
+ "description": "Live order tracking with route progress, courier position, and order details.",
+ "dependencies": ["lucide-react"],
+ "registryDependencies": ["map", "card", "badge", "button"],
+ "files": [
+ {
+ "path": "src/registry/blocks/delivery-tracker/page.tsx",
+ "type": "registry:page",
+ "target": "app/delivery/page.tsx"
+ }
+ ],
+ "categories": ["tracking", "delivery"],
+ "meta": {
+ "iframeHeight": "680px"
+ }
}
]
}
diff --git a/registry.json b/registry.json
index 7579221..af7d423 100644
--- a/registry.json
+++ b/registry.json
@@ -27,6 +27,114 @@
}
}
}
+ },
+ {
+ "name": "analytics-map",
+ "type": "registry:block",
+ "title": "Analytics Map",
+ "description": "Real-time analytics overview with a world map, breakdown cards, and device stats.",
+ "dependencies": ["recharts", "lucide-react"],
+ "registryDependencies": ["map", "card", "chart"],
+ "files": [
+ {
+ "path": "src/registry/blocks/analytics-map/page.tsx",
+ "type": "registry:page",
+ "target": "app/analytics/page.tsx"
+ },
+ {
+ "path": "src/registry/blocks/analytics-map/data.ts",
+ "type": "registry:component",
+ "target": "app/analytics/data.ts"
+ },
+ {
+ "path": "src/registry/blocks/analytics-map/components/overview-card.tsx",
+ "type": "registry:component",
+ "target": "app/analytics/components/overview-card.tsx"
+ },
+ {
+ "path": "src/registry/blocks/analytics-map/components/breakdown-card.tsx",
+ "type": "registry:component",
+ "target": "app/analytics/components/breakdown-card.tsx"
+ }
+ ],
+ "categories": ["analytics", "dashboard"],
+ "meta": {
+ "iframeHeight": "970px"
+ }
+ },
+ {
+ "name": "logistics-network",
+ "type": "registry:block",
+ "title": "Logistics Network",
+ "description": "Domestic logistics map with a sidebar of stats.",
+ "dependencies": ["lucide-react"],
+ "registryDependencies": ["map", "card", "badge", "button"],
+ "files": [
+ {
+ "path": "src/registry/blocks/logistics-network/page.tsx",
+ "type": "registry:page",
+ "target": "app/logistics/page.tsx"
+ },
+ {
+ "path": "src/registry/blocks/logistics-network/data.ts",
+ "type": "registry:component",
+ "target": "app/logistics/data.ts"
+ },
+ {
+ "path": "src/registry/blocks/logistics-network/components/map-arcs.tsx",
+ "type": "registry:component",
+ "target": "app/logistics/components/map-arcs.tsx"
+ },
+ {
+ "path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx",
+ "type": "registry:component",
+ "target": "app/logistics/components/filter-sidebar.tsx"
+ },
+ {
+ "path": "src/registry/blocks/logistics-network/components/network-map.tsx",
+ "type": "registry:component",
+ "target": "app/logistics/components/network-map.tsx"
+ }
+ ],
+ "categories": ["logistics", "network"],
+ "meta": {
+ "iframeHeight": "800px"
+ }
+ },
+ {
+ "name": "heatmap",
+ "type": "registry:block",
+ "title": "Heatmap",
+ "description": "Globe-projected heatmap visualizing earthquake density with zoom-dependent styling.",
+ "dependencies": [],
+ "registryDependencies": ["map", "card"],
+ "files": [
+ {
+ "path": "src/registry/blocks/heatmap/page.tsx",
+ "type": "registry:page",
+ "target": "app/heatmap/page.tsx"
+ }
+ ],
+ "categories": ["visualization", "heatmap"]
+ },
+ {
+ "name": "delivery-tracker",
+ "type": "registry:block",
+ "title": "Delivery Tracker",
+ "description": "Live order tracking with route progress, courier position, and order details.",
+ "dependencies": ["lucide-react"],
+ "registryDependencies": ["map", "card", "badge", "button"],
+ "files": [
+ {
+ "path": "src/registry/blocks/delivery-tracker/page.tsx",
+ "type": "registry:page",
+ "target": "app/delivery/page.tsx"
+ }
+ ],
+ "categories": ["tracking", "delivery"],
+ "meta": {
+ "iframeHeight": "680px"
+ }
}
]
}
diff --git a/src/app/(home)/_components/examples/ev-charging-example.tsx b/src/app/(home)/_components/examples/ev-charging-example.tsx
deleted file mode 100644
index 528498e..0000000
--- a/src/app/(home)/_components/examples/ev-charging-example.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-"use client";
-
-import { Map, MapMarker, MarkerContent, MarkerTooltip } from "@/registry/map";
-import { Zap } from "lucide-react";
-import { ExampleCard } from "./example-card";
-
-export function EVChargingExample() {
- return (
-
-
-
- );
-}
diff --git a/src/app/(home)/_components/footer.tsx b/src/app/(home)/_components/footer.tsx
deleted file mode 100644
index 39530ee..0000000
--- a/src/app/(home)/_components/footer.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Button } from "@/components/ui/button";
-import Link from "next/link";
-
-export function Footer() {
- return (
-
- );
-}
diff --git a/src/app/(home)/_components/hero.tsx b/src/app/(home)/_components/hero.tsx
deleted file mode 100644
index 1b44627..0000000
--- a/src/app/(home)/_components/hero.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { Copy, Check, ArrowRight } from "lucide-react";
-import Link from "next/link";
-import { useState } from "react";
-
-const installCommand = "npx shadcn@latest add @mapcn/map";
-
-function CopyButton({ text }: { text: string }) {
- const [copied, setCopied] = useState(false);
-
- const copy = () => {
- navigator.clipboard.writeText(text);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
-
- return (
-
- );
-}
-
-export function Hero() {
- return (
-
-
-
-
-
-
- Beautiful maps, made simple.
-
-
-
-
- Ready to use, customizable map components for React.
-
- Built on MapLibre. Styled with Tailwind.
-
-
-
-
-
-
-
-
-
-
-
- $
-
- {installCommand}
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx
deleted file mode 100644
index 18f28c6..0000000
--- a/src/app/(home)/page.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Hero } from "./_components/hero";
-import { ExamplesGrid } from "./_components/examples-grid";
-import { Footer } from "./_components/footer";
-import { Header } from "./_components/header";
-
-export default function Page() {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/(home)/_components/examples-grid.tsx b/src/app/(main)/(home)/_components/examples-grid.tsx
similarity index 80%
rename from src/app/(home)/_components/examples-grid.tsx
rename to src/app/(main)/(home)/_components/examples-grid.tsx
index e9939ef..f4f8f46 100644
--- a/src/app/(home)/_components/examples-grid.tsx
+++ b/src/app/(main)/(home)/_components/examples-grid.tsx
@@ -9,7 +9,7 @@ import { FlyToExample } from "./examples/flyto-example";
export function ExamplesGrid() {
return (
-
+
diff --git a/src/app/(home)/_components/examples/analytics-example.tsx b/src/app/(main)/(home)/_components/examples/analytics-example.tsx
similarity index 100%
rename from src/app/(home)/_components/examples/analytics-example.tsx
rename to src/app/(main)/(home)/_components/examples/analytics-example.tsx
diff --git a/src/app/(home)/_components/examples/delivery-example.tsx b/src/app/(main)/(home)/_components/examples/delivery-example.tsx
similarity index 96%
rename from src/app/(home)/_components/examples/delivery-example.tsx
rename to src/app/(main)/(home)/_components/examples/delivery-example.tsx
index bff24c4..0056752 100644
--- a/src/app/(home)/_components/examples/delivery-example.tsx
+++ b/src/app/(main)/(home)/_components/examples/delivery-example.tsx
@@ -56,7 +56,7 @@ export function DeliveryExample() {
)}
-
+
Store
diff --git a/src/app/(main)/(home)/_components/examples/ev-charging-example.tsx b/src/app/(main)/(home)/_components/examples/ev-charging-example.tsx
new file mode 100644
index 0000000..c614647
--- /dev/null
+++ b/src/app/(main)/(home)/_components/examples/ev-charging-example.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { Map, MapMarker, MarkerContent, MarkerTooltip } from "@/registry/map";
+import { Zap } from "lucide-react";
+import { ExampleCard } from "./example-card";
+
+type Status = "available" | "in-use" | "offline";
+
+interface ChargingStation {
+ name: string;
+ lng: number;
+ lat: number;
+ status: Status;
+ detail: string;
+}
+
+const stations: ChargingStation[] = [
+ {
+ name: "Union Square",
+ lng: -122.4074,
+ lat: 37.7879,
+ status: "available",
+ detail: "50 kW • $0.28/kWh",
+ },
+ {
+ name: "Castro Station",
+ lng: -122.435,
+ lat: 37.7625,
+ status: "in-use",
+ detail: "~15 min remaining",
+ },
+ {
+ name: "Hayes Valley",
+ lng: -122.4264,
+ lat: 37.7759,
+ status: "offline",
+ detail: "",
+ },
+ {
+ name: "Embarcadero",
+ lng: -122.3934,
+ lat: 37.7935,
+ status: "available",
+ detail: "350 kW • $0.40/kWh",
+ },
+ {
+ name: "Marina District",
+ lng: -122.437,
+ lat: 37.801,
+ status: "available",
+ detail: "150 kW • $0.32/kWh",
+ },
+ {
+ name: "SoMa Charger",
+ lng: -122.401,
+ lat: 37.778,
+ status: "available",
+ detail: "50 kW • $0.30/kWh",
+ },
+ {
+ name: "Noe Valley",
+ lng: -122.431,
+ lat: 37.75,
+ status: "available",
+ detail: "150 kW • $0.33/kWh",
+ },
+ {
+ name: "Richmond Charger",
+ lng: -122.478,
+ lat: 37.781,
+ status: "in-use",
+ detail: "~8 min remaining",
+ },
+ {
+ name: "Potrero Hill",
+ lng: -122.401,
+ lat: 37.76,
+ status: "offline",
+ detail: "",
+ },
+ {
+ name: "Mission Bay",
+ lng: -122.391,
+ lat: 37.77,
+ status: "available",
+ detail: "350 kW • $0.38/kWh",
+ },
+ {
+ name: "Golden Gate Park",
+ lng: -122.466,
+ lat: 37.77,
+ status: "available",
+ detail: "150 kW • $0.34/kWh",
+ },
+];
+
+const statusConfig: Record<
+ Status,
+ { bg: string; label: string; textClass: string }
+> = {
+ available: {
+ bg: "bg-emerald-500",
+ label: "Available",
+ textClass: "text-emerald-500",
+ },
+ "in-use": {
+ bg: "bg-amber-500",
+ label: "In Use",
+ textClass: "text-amber-500",
+ },
+ offline: {
+ bg: "bg-zinc-400",
+ label: "Offline",
+ textClass: "text-muted-foreground",
+ },
+};
+
+export function EVChargingExample() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(home)/_components/examples/example-card.tsx b/src/app/(main)/(home)/_components/examples/example-card.tsx
similarity index 60%
rename from src/app/(home)/_components/examples/example-card.tsx
rename to src/app/(main)/(home)/_components/examples/example-card.tsx
index 1c95dd7..7fc3481 100644
--- a/src/app/(home)/_components/examples/example-card.tsx
+++ b/src/app/(main)/(home)/_components/examples/example-card.tsx
@@ -18,13 +18,13 @@ export function ExampleCard({
return (
{label && (
-
+
{label}
)}
diff --git a/src/app/(home)/_components/examples/flyto-example.tsx b/src/app/(main)/(home)/_components/examples/flyto-example.tsx
similarity index 100%
rename from src/app/(home)/_components/examples/flyto-example.tsx
rename to src/app/(main)/(home)/_components/examples/flyto-example.tsx
diff --git a/src/app/(home)/_components/examples/index.tsx b/src/app/(main)/(home)/_components/examples/index.tsx
similarity index 100%
rename from src/app/(home)/_components/examples/index.tsx
rename to src/app/(main)/(home)/_components/examples/index.tsx
diff --git a/src/app/(home)/_components/examples/trail-example.tsx b/src/app/(main)/(home)/_components/examples/trail-example.tsx
similarity index 100%
rename from src/app/(home)/_components/examples/trail-example.tsx
rename to src/app/(main)/(home)/_components/examples/trail-example.tsx
diff --git a/src/app/(home)/_components/examples/trending-example.tsx b/src/app/(main)/(home)/_components/examples/trending-example.tsx
similarity index 100%
rename from src/app/(home)/_components/examples/trending-example.tsx
rename to src/app/(main)/(home)/_components/examples/trending-example.tsx
diff --git a/src/app/(main)/(home)/page.tsx b/src/app/(main)/(home)/page.tsx
new file mode 100644
index 0000000..fbb7e94
--- /dev/null
+++ b/src/app/(main)/(home)/page.tsx
@@ -0,0 +1,40 @@
+import { ExamplesGrid } from "./_components/examples-grid";
+import { Footer } from "@/components/footer";
+import {
+ PageHeader,
+ PageHeaderHeading,
+ PageHeaderDescription,
+ PageActions,
+} from "@/components/page-header";
+import { Button } from "@/components/ui/button";
+import Link from "next/link";
+
+export default function Page() {
+ return (
+ <>
+
+ Beautiful maps, made simple
+
+ Ready to use, customizable map components for React.
+
+ Built on MapLibre. Styled with Tailwind.
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(main)/blocks/_components/block-display.tsx b/src/app/(main)/blocks/_components/block-display.tsx
new file mode 100644
index 0000000..b619dfe
--- /dev/null
+++ b/src/app/(main)/blocks/_components/block-display.tsx
@@ -0,0 +1,40 @@
+import { getAllBlocks, createFileTreeForRegistryItemFiles } from "@/lib/blocks";
+import { getBlockFileSource } from "@/lib/get-block-file-source";
+import { highlightCode } from "@/lib/highlight";
+import { BlockPreview } from "./block-preview";
+import { IframePreview } from "./iframe-preview";
+
+interface BlockDisplayProps {
+ name: string;
+}
+
+export async function BlockDisplay({ name }: BlockDisplayProps) {
+ const blocks = getAllBlocks();
+ const block = blocks.find((b) => b.name === name);
+
+ if (!block || !block.files?.length) {
+ return null;
+ }
+
+ const tree = createFileTreeForRegistryItemFiles(block.files);
+
+ const highlightedFiles = await Promise.all(
+ block.files.map(async (file) => {
+ const content = getBlockFileSource(file.path);
+ const lang = file.path.split(".").pop() ?? "tsx";
+ const highlightedContent = await highlightCode(content, lang);
+ return {
+ path: file.path,
+ target: file.target ?? file.path,
+ content,
+ highlightedContent,
+ };
+ })
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(main)/blocks/_components/block-preview.tsx b/src/app/(main)/blocks/_components/block-preview.tsx
new file mode 100644
index 0000000..057b214
--- /dev/null
+++ b/src/app/(main)/blocks/_components/block-preview.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useState } from "react";
+import { Check, Code, Eye, Fullscreen, Terminal } from "lucide-react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { RegistryBlockItem, type FileTree } from "@/lib/blocks";
+import { BlockViewerCode, type HighlightedFile } from "./block-viewer-code";
+import { Separator } from "@/components/ui/separator";
+import Link from "next/link";
+
+interface BlockPreviewProps {
+ block: RegistryBlockItem;
+ children: React.ReactNode;
+ tree: FileTree[];
+ highlightedFiles: HighlightedFile[];
+}
+
+export function BlockPreview({
+ block,
+ children,
+ tree,
+ highlightedFiles,
+}: BlockPreviewProps) {
+ const { name, title, description, meta } = block;
+ const [copiedType, setCopiedType] = useState<"code" | "cli" | null>(null);
+
+ async function copyCli() {
+ await navigator.clipboard.writeText(`npx shadcn@latest add @mapcn/${name}`);
+ setCopiedType("cli");
+ setTimeout(() => setCopiedType(null), 2000);
+ }
+
+ return (
+
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+
+
+
+
+
+
+ Preview
+
+
+
+ Code
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/blocks/_components/block-viewer-code.tsx b/src/app/(main)/blocks/_components/block-viewer-code.tsx
new file mode 100644
index 0000000..87dd6c9
--- /dev/null
+++ b/src/app/(main)/blocks/_components/block-viewer-code.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import * as React from "react";
+import { Check, ChevronRight, Copy, File, Folder } from "lucide-react";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Button } from "@/components/ui/button";
+import {
+ Sidebar,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarProvider,
+} from "@/components/ui/sidebar";
+import { type FileTree } from "@/lib/blocks";
+
+export interface HighlightedFile {
+ path: string;
+ target: string;
+ content: string;
+ highlightedContent: string;
+}
+
+interface BlockViewerCodeContext {
+ activeFile: string;
+ setActiveFile: (file: string) => void;
+ highlightedFiles: HighlightedFile[];
+ tree: FileTree[];
+}
+
+const BlockViewerCodeCtx = React.createContext
(
+ null
+);
+
+function useBlockViewerCode() {
+ const ctx = React.useContext(BlockViewerCodeCtx);
+ if (!ctx) {
+ throw new Error("useBlockViewerCode must be used within BlockViewerCode");
+ }
+ return ctx;
+}
+
+interface BlockViewerCodeProps {
+ tree: FileTree[];
+ highlightedFiles: HighlightedFile[];
+}
+
+export function BlockViewerCode({
+ tree,
+ highlightedFiles,
+}: BlockViewerCodeProps) {
+ const [activeFile, setActiveFile] = React.useState(
+ highlightedFiles[0]?.target ?? ""
+ );
+
+ const file = React.useMemo(
+ () => highlightedFiles.find((f) => f.target === activeFile),
+ [highlightedFiles, activeFile]
+ );
+
+ if (!file) return null;
+
+ return (
+
+
+
+ );
+}
+
+function FileTreeSidebar() {
+ const { tree } = useBlockViewerCode();
+
+ return (
+
+
+
+ Files
+
+
+
+
+ {tree.map((file, index) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+function TreeNode({ item, index }: { item: FileTree; index: number }) {
+ const { activeFile, setActiveFile } = useBlockViewerCode();
+
+ if (!item.children) {
+ return (
+
+ item.path && setActiveFile(item.path)}
+ className="hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15 rounded-none whitespace-nowrap pl-(--index)"
+ data-index={index}
+ style={
+ {
+ "--index": `${index * (index === 2 ? 1.2 : 1.3)}rem`,
+ } as React.CSSProperties
+ }
+ >
+
+
+ {item.name}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {item.name}
+
+
+
+
+ {item.children.map((subItem, key) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function CopyCodeButton() {
+ const { activeFile, highlightedFiles } = useBlockViewerCode();
+ const [copied, setCopied] = React.useState(false);
+
+ const file = React.useMemo(
+ () => highlightedFiles.find((f) => f.target === activeFile),
+ [highlightedFiles, activeFile]
+ );
+
+ if (!file) return null;
+
+ return (
+
+ );
+}
diff --git a/src/app/(main)/blocks/_components/iframe-preview.tsx b/src/app/(main)/blocks/_components/iframe-preview.tsx
new file mode 100644
index 0000000..567e84c
--- /dev/null
+++ b/src/app/(main)/blocks/_components/iframe-preview.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+interface IframePreviewProps {
+ src: string;
+ title: string;
+}
+
+export function IframePreview({ src, title }: IframePreviewProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(main)/blocks/page.tsx b/src/app/(main)/blocks/page.tsx
new file mode 100644
index 0000000..042aba3
--- /dev/null
+++ b/src/app/(main)/blocks/page.tsx
@@ -0,0 +1,55 @@
+import {
+ PageActions,
+ PageHeader,
+ PageHeaderDescription,
+ PageHeaderHeading,
+} from "@/components/page-header";
+import { Button } from "@/components/ui/button";
+import Link from "next/link";
+import { Footer } from "@/components/footer";
+import { Metadata } from "next";
+import { getAllBlocks } from "@/lib/blocks";
+import { BlockDisplay } from "./_components/block-display";
+
+export const metadata: Metadata = {
+ title: "Map blocks for your application",
+ description:
+ "Pre-built, ready-to-use map blocks. Browse, preview, and copy them into your app with one command.",
+};
+
+export default async function Page() {
+ const blocks = getAllBlocks();
+
+ return (
+ <>
+
+
+ Map blocks for your application
+
+
+ Pre-built, ready-to-use map blocks. Browse, preview, and copy them
+ into your app with one command.
+
+
+
+
+
+
+
+
+ {blocks.map((block) => (
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/src/app/docs/_components/code-block.tsx b/src/app/(main)/docs/_components/code-block.tsx
similarity index 100%
rename from src/app/docs/_components/code-block.tsx
rename to src/app/(main)/docs/_components/code-block.tsx
diff --git a/src/app/docs/_components/component-preview-client.tsx b/src/app/(main)/docs/_components/component-preview-client.tsx
similarity index 100%
rename from src/app/docs/_components/component-preview-client.tsx
rename to src/app/(main)/docs/_components/component-preview-client.tsx
diff --git a/src/app/docs/_components/component-preview.tsx b/src/app/(main)/docs/_components/component-preview.tsx
similarity index 100%
rename from src/app/docs/_components/component-preview.tsx
rename to src/app/(main)/docs/_components/component-preview.tsx
diff --git a/src/app/docs/_components/copy-button.tsx b/src/app/(main)/docs/_components/copy-button.tsx
similarity index 100%
rename from src/app/docs/_components/copy-button.tsx
rename to src/app/(main)/docs/_components/copy-button.tsx
diff --git a/src/app/docs/_components/docs-sidebar.tsx b/src/app/(main)/docs/_components/docs-sidebar.tsx
similarity index 57%
rename from src/app/docs/_components/docs-sidebar.tsx
rename to src/app/(main)/docs/_components/docs-sidebar.tsx
index d001ce8..dcae248 100644
--- a/src/app/docs/_components/docs-sidebar.tsx
+++ b/src/app/(main)/docs/_components/docs-sidebar.tsx
@@ -6,37 +6,31 @@ import { usePathname } from "next/navigation";
import {
Sidebar,
SidebarContent,
- SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
- SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
-import { docsNavigation } from "@/lib/docs-navigation";
-import { Logo } from "@/components/logo";
-import { CommandSearch } from "@/components/command-search";
-import { ThemeToggle } from "@/components/theme-toggle";
-import { GitHubButton } from "@/components/github-button";
+import { docsNavigation } from "@/lib/site-navigation";
export function DocsSidebar() {
const pathname = usePathname();
const { setOpenMobile } = useSidebar();
return (
-
-
-
-
-
-
-
+
+
{docsNavigation.map((group) => (
- {group.title}
+
+ {group.title}
+
{group.items.map((item) => (
@@ -44,14 +38,13 @@ export function DocsSidebar() {
setOpenMobile(false)}
>
-
- {item.title}
+ {item.title}
@@ -61,14 +54,6 @@ export function DocsSidebar() {
))}
-
-
-
-
-
-
-
-
);
}
diff --git a/src/app/docs/_components/docs-toc.tsx b/src/app/(main)/docs/_components/docs-toc.tsx
similarity index 83%
rename from src/app/docs/_components/docs-toc.tsx
rename to src/app/(main)/docs/_components/docs-toc.tsx
index 37ec5ee..a5929e8 100644
--- a/src/app/docs/_components/docs-toc.tsx
+++ b/src/app/(main)/docs/_components/docs-toc.tsx
@@ -15,7 +15,7 @@ function useActiveItem(itemIds: string[]) {
}
}
},
- { rootMargin: "0% 0% -80% 0%" }
+ { rootMargin: "0% 0% -80% 0%" },
);
for (const id of itemIds ?? []) {
@@ -58,11 +58,11 @@ export function DocsToc({ items, className }: DocsTocProps) {
return (
-
+
On This Page
-
+
{items.map((item) => {
const isActive = item.slug === activeHeading;
@@ -71,14 +71,14 @@ export function DocsToc({ items, className }: DocsTocProps) {
key={item.slug}
href={`#${item.slug}`}
className={cn(
- "relative pl-3 py-1 text-[13px] no-underline transition-colors",
+ "relative py-1 pl-3 text-[0.8rem] no-underline transition-colors",
isActive
? "text-foreground"
- : "text-muted-foreground hover:text-foreground"
+ : "text-muted-foreground hover:text-foreground",
)}
>
{isActive && (
-
+
)}
{item.title}
diff --git a/src/app/docs/_components/docs.tsx b/src/app/(main)/docs/_components/docs.tsx
similarity index 97%
rename from src/app/docs/_components/docs.tsx
rename to src/app/(main)/docs/_components/docs.tsx
index 7cfa3d9..ff9e235 100644
--- a/src/app/docs/_components/docs.tsx
+++ b/src/app/(main)/docs/_components/docs.tsx
@@ -62,12 +62,10 @@ export function DocsLayout({
toc = [],
}: DocsLayoutProps) {
return (
-
-
+
+
-
{children}
-
{(prev || next) && (
{prev ? (
@@ -118,7 +116,7 @@ interface DocsSectionProps {
export function DocsSection({ title, children }: DocsSectionProps) {
const id = title ? slugify(title) : undefined;
return (
-
+
{title && (
{title}
diff --git a/src/app/docs/_components/examples/advanced-usage-example.tsx b/src/app/(main)/docs/_components/examples/advanced-usage-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/advanced-usage-example.tsx
rename to src/app/(main)/docs/_components/examples/advanced-usage-example.tsx
diff --git a/src/app/docs/_components/examples/basic-map-example.tsx b/src/app/(main)/docs/_components/examples/basic-map-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/basic-map-example.tsx
rename to src/app/(main)/docs/_components/examples/basic-map-example.tsx
diff --git a/src/app/docs/_components/examples/cluster-example.tsx b/src/app/(main)/docs/_components/examples/cluster-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/cluster-example.tsx
rename to src/app/(main)/docs/_components/examples/cluster-example.tsx
diff --git a/src/app/docs/_components/examples/controlled-map-example.tsx b/src/app/(main)/docs/_components/examples/controlled-map-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/controlled-map-example.tsx
rename to src/app/(main)/docs/_components/examples/controlled-map-example.tsx
diff --git a/src/app/docs/_components/examples/custom-layer-example.tsx b/src/app/(main)/docs/_components/examples/custom-layer-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/custom-layer-example.tsx
rename to src/app/(main)/docs/_components/examples/custom-layer-example.tsx
diff --git a/src/app/docs/_components/examples/custom-style-example.tsx b/src/app/(main)/docs/_components/examples/custom-style-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/custom-style-example.tsx
rename to src/app/(main)/docs/_components/examples/custom-style-example.tsx
diff --git a/src/app/docs/_components/examples/draggable-marker-example.tsx b/src/app/(main)/docs/_components/examples/draggable-marker-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/draggable-marker-example.tsx
rename to src/app/(main)/docs/_components/examples/draggable-marker-example.tsx
diff --git a/src/app/docs/_components/examples/layer-markers-example.tsx b/src/app/(main)/docs/_components/examples/layer-markers-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/layer-markers-example.tsx
rename to src/app/(main)/docs/_components/examples/layer-markers-example.tsx
diff --git a/src/app/docs/_components/examples/map-controls-example.tsx b/src/app/(main)/docs/_components/examples/map-controls-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/map-controls-example.tsx
rename to src/app/(main)/docs/_components/examples/map-controls-example.tsx
diff --git a/src/app/docs/_components/examples/markers-example.tsx b/src/app/(main)/docs/_components/examples/markers-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/markers-example.tsx
rename to src/app/(main)/docs/_components/examples/markers-example.tsx
diff --git a/src/app/docs/_components/examples/osrm-route-example.tsx b/src/app/(main)/docs/_components/examples/osrm-route-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/osrm-route-example.tsx
rename to src/app/(main)/docs/_components/examples/osrm-route-example.tsx
diff --git a/src/app/docs/_components/examples/popup-example.tsx b/src/app/(main)/docs/_components/examples/popup-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/popup-example.tsx
rename to src/app/(main)/docs/_components/examples/popup-example.tsx
diff --git a/src/app/docs/_components/examples/route-example.tsx b/src/app/(main)/docs/_components/examples/route-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/route-example.tsx
rename to src/app/(main)/docs/_components/examples/route-example.tsx
diff --git a/src/app/docs/_components/examples/standalone-popup-example.tsx b/src/app/(main)/docs/_components/examples/standalone-popup-example.tsx
similarity index 100%
rename from src/app/docs/_components/examples/standalone-popup-example.tsx
rename to src/app/(main)/docs/_components/examples/standalone-popup-example.tsx
diff --git a/src/lib/get-example-source.ts b/src/app/(main)/docs/_components/get-example-source.ts
similarity index 81%
rename from src/lib/get-example-source.ts
rename to src/app/(main)/docs/_components/get-example-source.ts
index 405e912..29069e2 100644
--- a/src/lib/get-example-source.ts
+++ b/src/app/(main)/docs/_components/get-example-source.ts
@@ -3,13 +3,12 @@ import path from "path";
const EXAMPLES_DIR = path.join(
process.cwd(),
- "src/app/docs/_components/examples"
+ "src/app/(main)/docs/_components/examples"
);
export function getExampleSource(filename: string): string {
const filePath = path.join(EXAMPLES_DIR, filename);
const source = fs.readFileSync(filePath, "utf-8");
- // Clean up the source for display:
return source.replace(/@\/registry\/map/g, "@/components/ui/map");
}
diff --git a/src/app/docs/advanced-usage/page.tsx b/src/app/(main)/docs/advanced-usage/page.tsx
similarity index 98%
rename from src/app/docs/advanced-usage/page.tsx
rename to src/app/(main)/docs/advanced-usage/page.tsx
index 007b2b0..a094110 100644
--- a/src/app/docs/advanced-usage/page.tsx
+++ b/src/app/(main)/docs/advanced-usage/page.tsx
@@ -10,7 +10,7 @@ import { AdvancedUsageExample } from "../_components/examples/advanced-usage-exa
import { CustomLayerExample } from "../_components/examples/custom-layer-example";
import { LayerMarkersExample } from "../_components/examples/layer-markers-example";
import { CodeBlock } from "../_components/code-block";
-import { getExampleSource } from "@/lib/get-example-source";
+import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {
diff --git a/src/app/docs/api-reference/page.tsx b/src/app/(main)/docs/api-reference/page.tsx
similarity index 99%
rename from src/app/docs/api-reference/page.tsx
rename to src/app/(main)/docs/api-reference/page.tsx
index 058ba70..eaa9304 100644
--- a/src/app/docs/api-reference/page.tsx
+++ b/src/app/(main)/docs/api-reference/page.tsx
@@ -136,6 +136,12 @@ export default function ApiReferencePage() {
description:
"Callback fired continuously as the viewport changes (during pan, zoom, rotate). Can be used alone to observe changes, or with viewport prop to enable controlled mode.",
},
+ {
+ name: "loading",
+ type: "boolean",
+ default: "false",
+ description: "Show a loading indicator on the map.",
+ },
]}
/>
diff --git a/src/app/docs/basic-map/page.tsx b/src/app/(main)/docs/basic-map/page.tsx
similarity index 97%
rename from src/app/docs/basic-map/page.tsx
rename to src/app/(main)/docs/basic-map/page.tsx
index e566ab0..cb7778f 100644
--- a/src/app/docs/basic-map/page.tsx
+++ b/src/app/(main)/docs/basic-map/page.tsx
@@ -8,7 +8,7 @@ import { ComponentPreview } from "../_components/component-preview";
import { BasicMapExample } from "../_components/examples/basic-map-example";
import { ControlledMapExample } from "../_components/examples/controlled-map-example";
import { CustomStyleExample } from "../_components/examples/custom-style-example";
-import { getExampleSource } from "@/lib/get-example-source";
+import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {
diff --git a/src/app/docs/clusters/page.tsx b/src/app/(main)/docs/clusters/page.tsx
similarity index 94%
rename from src/app/docs/clusters/page.tsx
rename to src/app/(main)/docs/clusters/page.tsx
index ad8a7c8..59b2221 100644
--- a/src/app/docs/clusters/page.tsx
+++ b/src/app/(main)/docs/clusters/page.tsx
@@ -1,7 +1,7 @@
import { DocsLayout, DocsSection, DocsCode } from "../_components/docs";
import { ComponentPreview } from "../_components/component-preview";
import ClusterExample from "../_components/examples/cluster-example";
-import { getExampleSource } from "@/lib/get-example-source";
+import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {
diff --git a/src/app/docs/controls/page.tsx b/src/app/(main)/docs/controls/page.tsx
similarity index 93%
rename from src/app/docs/controls/page.tsx
rename to src/app/(main)/docs/controls/page.tsx
index b91673f..4c739ee 100644
--- a/src/app/docs/controls/page.tsx
+++ b/src/app/(main)/docs/controls/page.tsx
@@ -1,7 +1,7 @@
import { DocsLayout, DocsSection, DocsCode } from "../_components/docs";
import { ComponentPreview } from "../_components/component-preview";
import { MapControlsExample } from "../_components/examples/map-controls-example";
-import { getExampleSource } from "@/lib/get-example-source";
+import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {
diff --git a/src/app/docs/installation/page.tsx b/src/app/(main)/docs/installation/page.tsx
similarity index 100%
rename from src/app/docs/installation/page.tsx
rename to src/app/(main)/docs/installation/page.tsx
diff --git a/src/app/(main)/docs/layout.tsx b/src/app/(main)/docs/layout.tsx
new file mode 100644
index 0000000..1e8e8d5
--- /dev/null
+++ b/src/app/(main)/docs/layout.tsx
@@ -0,0 +1,17 @@
+import { SidebarProvider } from "@/components/ui/sidebar";
+import { DocsSidebar } from "./_components/docs-sidebar";
+
+export default function DocsLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/docs/markers/page.tsx b/src/app/(main)/docs/markers/page.tsx
similarity index 97%
rename from src/app/docs/markers/page.tsx
rename to src/app/(main)/docs/markers/page.tsx
index dc1bde6..7e11d17 100644
--- a/src/app/docs/markers/page.tsx
+++ b/src/app/(main)/docs/markers/page.tsx
@@ -9,7 +9,7 @@ import { ComponentPreview } from "../_components/component-preview";
import { MarkersExample } from "../_components/examples/markers-example";
import { PopupExample } from "../_components/examples/popup-example";
import { DraggableMarkerExample } from "../_components/examples/draggable-marker-example";
-import { getExampleSource } from "@/lib/get-example-source";
+import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {
diff --git a/src/app/docs/page.tsx b/src/app/(main)/docs/page.tsx
similarity index 100%
rename from src/app/docs/page.tsx
rename to src/app/(main)/docs/page.tsx
diff --git a/src/app/docs/popups/page.tsx b/src/app/(main)/docs/popups/page.tsx
similarity index 94%
rename from src/app/docs/popups/page.tsx
rename to src/app/(main)/docs/popups/page.tsx
index d9bbab1..25380f6 100644
--- a/src/app/docs/popups/page.tsx
+++ b/src/app/(main)/docs/popups/page.tsx
@@ -1,7 +1,7 @@
import { DocsLayout, DocsSection, DocsCode } from "../_components/docs";
import { ComponentPreview } from "../_components/component-preview";
import { StandalonePopupExample } from "../_components/examples/standalone-popup-example";
-import { getExampleSource } from "@/lib/get-example-source";
+import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {
diff --git a/src/app/docs/routes/page.tsx b/src/app/(main)/docs/routes/page.tsx
similarity index 96%
rename from src/app/docs/routes/page.tsx
rename to src/app/(main)/docs/routes/page.tsx
index e7ca24c..eb98c0c 100644
--- a/src/app/docs/routes/page.tsx
+++ b/src/app/(main)/docs/routes/page.tsx
@@ -7,7 +7,7 @@ import {
import { ComponentPreview } from "../_components/component-preview";
import { RouteExample } from "../_components/examples/route-example";
import { OsrmRouteExample } from "../_components/examples/osrm-route-example";
-import { getExampleSource } from "@/lib/get-example-source";
+import { getExampleSource } from "../_components/get-example-source";
import { Metadata } from "next";
export const metadata: Metadata = {
diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx
new file mode 100644
index 0000000..4ad5eac
--- /dev/null
+++ b/src/app/(main)/layout.tsx
@@ -0,0 +1,14 @@
+import { Header } from "@/components/header";
+
+export default function MainLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/app/(view)/view/[name]/page.tsx b/src/app/(view)/view/[name]/page.tsx
new file mode 100644
index 0000000..92a7a2d
--- /dev/null
+++ b/src/app/(view)/view/[name]/page.tsx
@@ -0,0 +1,38 @@
+import { slugToTitle } from "@/lib/utils";
+import { blockComponents } from "@/registry/blocks/__index__";
+import { notFound } from "next/navigation";
+
+interface BlockViewPageProps {
+ params: Promise<{ name: string }>;
+}
+
+export const generateMetadata = async ({ params }: BlockViewPageProps) => {
+ const { name } = await params;
+ const Component = blockComponents[name];
+
+ if (!Component) {
+ return notFound();
+ }
+
+ const title = slugToTitle(name);
+
+ return {
+ title,
+ description: `View the ${title} block`,
+ };
+};
+
+export default async function BlockViewPage({ params }: BlockViewPageProps) {
+ const { name } = await params;
+ const Component = blockComponents[name];
+
+ if (!Component) {
+ notFound();
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/docs/_components/docs-header.tsx b/src/app/docs/_components/docs-header.tsx
deleted file mode 100644
index 629cdbb..0000000
--- a/src/app/docs/_components/docs-header.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbPage,
- BreadcrumbSeparator,
-} from "@/components/ui/breadcrumb";
-import { SidebarTrigger } from "@/components/ui/sidebar";
-import { docsNavigation, type NavItem } from "@/lib/docs-navigation";
-import { cn } from "@/lib/utils";
-
-export function DocsHeader({ className }: { className?: string }) {
- const pathname = usePathname();
-
- let activeItem: (NavItem & { groupTitle: string }) | null = null;
- let groupHref = "/docs";
- for (const group of docsNavigation) {
- const item = group.items.find((navItem) => navItem.href === pathname);
- if (item) {
- activeItem = { ...item, groupTitle: group.title };
- groupHref = group.items[0]?.href ?? "/docs";
- break;
- }
- }
-
- return (
-
-
-
- );
-}
diff --git a/src/app/docs/layout.tsx b/src/app/docs/layout.tsx
deleted file mode 100644
index b0f7b8e..0000000
--- a/src/app/docs/layout.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
-import { DocsSidebar } from "./_components/docs-sidebar";
-import { DocsHeader } from "./_components/docs-header";
-
-export default function DocsLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- return (
-
-
-
-
- {children}
-
-
- );
-}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 37abc33..039477e 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,8 +2,10 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Analytics } from "@vercel/analytics/next";
+import "@/styles/globals.css";
+
import { ThemeProvider } from "@/components/theme-provider";
-import "./globals.css";
+import { TooltipProvider } from "@/components/ui/tooltip";
const geist = Geist({
subsets: ["latin"],
@@ -100,9 +102,9 @@ export default function RootLayout({
className={`${geist.variable} ${geistMono.variable}`}
suppressHydrationWarning
>
-
+
- {children}
+ {children}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index 92608de..3e76689 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
-
+
diff --git a/src/components/command-search.tsx b/src/components/command-search.tsx
index 43649b9..e4d9758 100644
--- a/src/components/command-search.tsx
+++ b/src/components/command-search.tsx
@@ -19,7 +19,7 @@ import {
CommandList,
} from "@/components/ui/command";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
-import { docsNavigation } from "@/lib/docs-navigation";
+import { siteNavigation } from "@/lib/site-navigation";
import { Button } from "./ui/button";
import { cn } from "@/lib/utils";
@@ -47,17 +47,17 @@ export function CommandSearch({ className }: { className?: string }) {
return (
<>
- {docsNavigation.map((group) => (
+ {siteNavigation.map((group) => (
{group.items.map((item) => (
+
+
+
+
+
+
+ mapcn
+
+
+
+ Free & open-source, ready-to-use, customizable map components for
+ React.
+
+
+ Built by{" "}
+
+ @anmol
+
+
+
+
+
+
Product
+
+ {footerLinks.product.map((link) => (
+ -
+
+ {link.label}
+
+
+ ))}
+
+
+
+
+
Community
+
+ {footerLinks.community.map((link) => (
+ -
+
+ {link.label}
+
+
+ ))}
+
+
+
+
+
Resources
+
+ {footerLinks.resources.map((link) => (
+ -
+
+ {link.label}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/app/(home)/_components/header.tsx b/src/components/header.tsx
similarity index 71%
rename from src/app/(home)/_components/header.tsx
rename to src/components/header.tsx
index 7edf09d..b3f361d 100644
--- a/src/app/(home)/_components/header.tsx
+++ b/src/components/header.tsx
@@ -1,6 +1,8 @@
import { Heart } from "lucide-react";
import { Logo } from "@/components/logo";
+import { MainNav } from "@/components/main-nav";
+import { MobileNav } from "@/components/mobile-nav";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
@@ -15,11 +17,15 @@ import { cn } from "@/lib/utils";
export function Header({ className }: { className?: string }) {
return (
-
-