feat(map): enhance map functionality with new controls and overlays

- Added GeolocateControl to the map for user location tracking.
- Introduced CategorySelector for filtering crime categories.
- Implemented TopNavigation for year, month, and category selection.
- Created MapSelectors for managing filters with reset functionality.
- Developed CrimeSidebar for displaying incidents, statistics, and reports.
- Added SidebarToggle for collapsing and expanding the sidebar.
- Prefetch crime data based on selected year and month.
- Updated Popover and Skeleton components for better UI experience.
- Refactored OverlayControl for improved rendering and cleanup.
- Enhanced styling and responsiveness across components.
This commit is contained in:
vergiLgood1 2025-05-02 02:47:16 +07:00
parent 897a130ff7
commit b564e6f330
19 changed files with 1121 additions and 258 deletions

View File

@ -0,0 +1,100 @@
"use client"
import { useEffect, useState, useCallback, useRef } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { useGetAvailableYears } from "../_queries/queries"
import { getCrimeByYearAndMonth } from "../action"
type CrimeData = any // Replace with your actual crime data type
interface PrefetchedCrimeDataResult {
availableYears: (number | null)[] | undefined
isYearsLoading: boolean
yearsError: Error | null
crimes: CrimeData | undefined
isCrimesLoading: boolean
crimesError: Error | null
setSelectedYear: (year: number) => void
setSelectedMonth: (month: number | "all") => void
selectedYear: number
selectedMonth: number | "all"
}
export function usePrefetchedCrimeData(initialYear: number = 2024, initialMonth: number | "all" = "all"): PrefetchedCrimeDataResult {
const [selectedYear, setSelectedYear] = useState<number>(initialYear)
const [selectedMonth, setSelectedMonth] = useState<number | "all">(initialMonth)
const [prefetchedData, setPrefetchedData] = useState<Record<string, CrimeData>>({})
const [isPrefetching, setIsPrefetching] = useState<boolean>(true)
const [prefetchError, setPrefetchError] = useState<Error | null>(null)
const queryClient = useQueryClient()
// Get available years
const { data: availableYears, isLoading: isYearsLoading, error: yearsError } = useGetAvailableYears()
// Track if we've prefetched data
const hasPrefetched = useRef<boolean>(false)
// Prefetch all data combinations
useEffect(() => {
const prefetchAllData = async () => {
if (!availableYears || hasPrefetched.current) return
setIsPrefetching(true)
const dataCache: Record<string, CrimeData> = {}
try {
// Prefetch data for all years with "all" months
for (const year of availableYears) {
if (year === null) continue
// Prefetch "all" months for this year
const allMonthsKey = `${year}-all`
const allMonthsData = await getCrimeByYearAndMonth(year, "all")
dataCache[allMonthsKey] = allMonthsData
// Also prefetch each individual month for this year
for (let month = 1; month <= 12; month++) {
const monthKey = `${year}-${month}`
const monthData = await getCrimeByYearAndMonth(year, month)
dataCache[monthKey] = monthData
// Pre-populate the React Query cache
queryClient.setQueryData(["crimes", year, month], monthData)
}
// Pre-populate the React Query cache for "all" months
queryClient.setQueryData(["crimes", year, "all"], allMonthsData)
}
setPrefetchedData(dataCache)
hasPrefetched.current = true
} catch (error) {
console.error("Error prefetching crime data:", error)
setPrefetchError(error instanceof Error ? error : new Error("Failed to prefetch data"))
} finally {
setIsPrefetching(false)
}
}
prefetchAllData()
}, [availableYears, queryClient])
// Get the current data based on selected filters
const currentKey = `${selectedYear}-${selectedMonth}`
const currentData = prefetchedData[currentKey]
return {
availableYears,
isYearsLoading,
yearsError,
crimes: currentData,
isCrimesLoading: isPrefetching && !currentData,
crimesError: prefetchError,
setSelectedYear,
setSelectedMonth,
selectedYear,
selectedMonth,
}
}

View File

@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import {
getAvailableYears,
getCrimeByYearAndMonth,
getCrimeCategories,
getCrimes,
} from '../action';
@ -28,3 +29,10 @@ export const useGetCrimes = () => {
queryFn: () => getCrimes(),
});
};
export const useGetCrimeCategories = () => {
return useQuery({
queryKey: ['crime-categories'],
queryFn: () => getCrimeCategories(),
});
};

View File

@ -59,6 +59,55 @@ export async function getAvailableYears() {
);
}
export async function getCrimeCategories() {
const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction(
'Crime Categories',
{ recordResponse: true },
async () => {
try {
const categories = await db.crime_categories.findMany({
select: {
id: true,
name: true,
type: true,
},
});
return categories;
} catch (err) {
if (err instanceof InputParseError) {
// return {
// error: err.message,
// };
throw new InputParseError(err.message);
}
if (err instanceof AuthenticationError) {
// return {
// error: 'User not found.',
// };
throw new AuthenticationError(
'There was an error with the credentials. Please try again or contact support.'
);
}
const crashReporterService = getInjection('ICrashReporterService');
crashReporterService.report(err);
// return {
// error:
// 'An error happened. The developers have been notified. Please try again later.',
// };
throw new Error(
'An error happened. The developers have been notified. Please try again later.'
);
}
}
);
}
export async function getCrimes() {
const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction(

View File

@ -0,0 +1,64 @@
"use client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
import { useEffect, useRef, useState } from "react"
import { Skeleton } from "../../ui/skeleton"
interface CategorySelectorProps {
categories: string[]
selectedCategory: string | "all"
onCategoryChange: (category: string | "all") => void
className?: string
includeAllOption?: boolean
isLoading?: boolean
}
export default function CategorySelector({
categories,
selectedCategory,
onCategoryChange,
className = "w-[150px]",
includeAllOption = true,
isLoading = false,
}: CategorySelectorProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [isClient, setIsClient] = useState(false)
useEffect(() => {
// This will ensure that the document is only used in the client-side context
setIsClient(true)
}, [])
const container = isClient ? document.getElementById("root") : null
return (
<div ref={containerRef} className="mapboxgl-category-selector">
{isLoading ? (
<div className="flex items-center justify-center h-8">
<Skeleton className="h-full w-full rounded-md" />
</div>
) : (
<Select
value={selectedCategory}
onValueChange={(value) => onCategoryChange(value)}
>
<SelectTrigger className={className}>
<SelectValue placeholder="Crime Category" />
</SelectTrigger>
<SelectContent
container={containerRef.current || container || undefined}
style={{ zIndex: 2000 }}
className={`${className}`}
>
{includeAllOption && <SelectItem value="all">All Categories</SelectItem>}
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}

View File

@ -1,61 +0,0 @@
"use client"
import { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
import {
Clock,
AlertTriangle,
Users,
Building,
Skull,
} from "lucide-react"
import { Overlay } from "../overlay"
import { ControlPosition } from "mapbox-gl"
import { IconBriefcaseOff, IconCategory, IconCategoryFilled } from "@tabler/icons-react"
interface MapMenusProps {
onControlChange: (control: string) => void
activeControl: string
position: ControlPosition
}
export default function MapMenus({ onControlChange, activeControl, position = "top-left" }: MapMenusProps) {
const menus = [
{ id: "crime-rate", icon: <Skull size={20} />, label: "Crime Rate" },
{ id: "population", icon: <Users size={20} />, label: "Population" },
{ id: "unemployment", icon: <IconBriefcaseOff size={20} />, label: "Unemployment" },
{ id: "alerts", icon: <AlertTriangle size={20} className="text-amber-500" />, label: "Alerts" },
{ id: "time", icon: <Clock size={20} />, label: "Time Analysis" },
{ id: "unit", icon: <Building size={20} />, label: "Unit" },
{ id: "category", icon: <IconCategory size={20} />, label: "Category" },
]
return (
<div className="absolute top-0 left-0 z-10 bg-black/75 rounded-md m-2 p-1 flex items-center space-x-1">
<TooltipProvider>
{menus.map((control) => (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="icon"
className={`h-8 w-8 rounded-md ${activeControl === control.id
? "bg-white text-black hover:bg-white/90"
: "text-white hover:bg-white/10"
}`}
onClick={() => onControlChange(control.id)}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{control.label}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
)
}

View File

@ -0,0 +1,219 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
import { Popover, PopoverContent, PopoverTrigger } from "@/app/_components/ui/popover"
import { ChevronDown } from "lucide-react"
import YearSelector from "./year-selector"
import MonthSelector from "./month-selector"
import CategorySelector from "./category-selector"
// Import all the icons we need
import {
AlertTriangle,
Shield,
FileText,
Users,
Map,
BarChart2,
Clock,
Filter,
Search,
RefreshCw,
Layers,
Siren,
BadgeAlert,
FolderOpen,
} from "lucide-react"
import { ITopTooltipsMapId } from "./map-tooltips"
interface TopNavigationProps {
onControlChange?: (controlId: ITopTooltipsMapId) => void
activeControl?: string
selectedYear: number
setSelectedYear: (year: number) => void
selectedMonth: number | "all"
setSelectedMonth: (month: number | "all") => void
selectedCategory: string | "all"
setSelectedCategory: (category: string | "all") => void
availableYears?: (number | null)[]
categories?: string[]
}
export default function TopNavigation({
onControlChange,
activeControl,
selectedYear,
setSelectedYear,
selectedMonth,
setSelectedMonth,
selectedCategory,
setSelectedCategory,
availableYears = [2022, 2023, 2024],
categories = [],
}: TopNavigationProps) {
const [showSelectors, setShowSelectors] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [isClient, setIsClient] = useState(false)
const container = isClient ? document.getElementById("root") : null
useEffect(() => {
// This will ensure that the document is only used in the client-side context
setIsClient(true)
}, [])
// Define the primary crime data controls
const crimeControls = [
{ id: "incidents" as ITopTooltipsMapId, icon: <AlertTriangle size={20} />, label: "All Incidents" },
{ id: "heatmap" as ITopTooltipsMapId, icon: <Map size={20} />, label: "Crime Heatmap" },
{ id: "trends" as ITopTooltipsMapId, icon: <BarChart2 size={20} />, label: "Crime Trends" },
{ id: "patrol" as ITopTooltipsMapId, icon: <Shield size={20} />, label: "Patrol Areas" },
{ id: "clusters" as ITopTooltipsMapId, icon: <Users size={20} />, label: "Offender Clusters" },
{ id: "timeline" as ITopTooltipsMapId, icon: <Clock size={20} />, label: "Time Analysis" },
]
// Define the additional tools and features
const additionalControls = [
{ id: "refresh" as ITopTooltipsMapId, icon: <RefreshCw size={20} />, label: "Refresh Data" },
{ id: "search" as ITopTooltipsMapId, icon: <Search size={20} />, label: "Search Cases" },
{ id: "alerts" as ITopTooltipsMapId, icon: <Siren size={20} className="text-red-500" />, label: "Active Alerts" },
{ id: "layers" as ITopTooltipsMapId, icon: <Layers size={20} />, label: "Map Layers" },
]
const toggleSelectors = () => {
setShowSelectors(!showSelectors)
}
return (
<div ref={containerRef} className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
{/* Main crime controls */}
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider>
{crimeControls.map((control) => (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="icon"
className={`h-8 w-8 rounded-md ${activeControl === control.id
? "bg-white text-black hover:bg-white/90"
: "text-white hover:bg-white/10"
}`}
onClick={() => onControlChange?.(control.id)}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{control.label}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
{/* Additional controls */}
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider>
{additionalControls.map((control) => (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="icon"
className={`h-8 w-8 rounded-md ${activeControl === control.id
? "bg-white text-black hover:bg-white/90"
: "text-white hover:bg-white/10"
}`}
onClick={() => onControlChange?.(control.id)}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{control.label}</p>
</TooltipContent>
</Tooltip>
))}
{/* Filters button */}
<Tooltip>
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-white hover:bg-white/10"
onClick={toggleSelectors}
>
<ChevronDown size={20} />
<span className="sr-only">Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent
container={containerRef.current || container || undefined}
className="w-auto p-3 bg-black/90 border-gray-700 text-white"
align="end"
style={{ zIndex: 2000 }}>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="text-xs w-16">Year:</span>
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Month:</span>
<MonthSelector
selectedMonth={selectedMonth}
onMonthChange={setSelectedMonth}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Category:</span>
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
className="w-[180px]"
/>
</div>
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Selectors row - visible when expanded */}
{showSelectors && (
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
className="w-[100px]"
/>
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[100px]" />
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
className="w-[100px]"
/>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,89 @@
"use client"
import { Button } from "@/app/_components/ui/button"
import { FilterX } from "lucide-react"
import YearSelector from "./year-selector"
import MonthSelector from "./month-selector"
import CategorySelector from "./category-selector"
import { Skeleton } from "../../ui/skeleton"
interface MapSelectorsProps {
availableYears: (number | null)[]
selectedYear: number
setSelectedYear: (year: number) => void
selectedMonth: number | "all"
setSelectedMonth: (month: number | "all") => void
selectedCategory: string | "all"
setSelectedCategory: (category: string | "all") => void
categories: string[]
isYearsLoading?: boolean
isCategoryLoading?: boolean
className?: string
compact?: boolean
}
export default function MapSelectors({
availableYears,
selectedYear,
setSelectedYear,
selectedMonth,
setSelectedMonth,
selectedCategory,
setSelectedCategory,
categories,
isYearsLoading = false,
isCategoryLoading = false,
className = "",
compact = false,
}: MapSelectorsProps) {
const resetFilters = () => {
setSelectedYear(2024)
setSelectedMonth("all")
setSelectedCategory("all")
}
return (
<div className={`flex items-center gap-2 ${className} ${compact ? "flex-col" : "flex-row"}`}>
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
isLoading={isYearsLoading}
className={compact ? "w-full" : ""}
/>
<MonthSelector
selectedMonth={selectedMonth}
onMonthChange={setSelectedMonth}
isLoading={isYearsLoading}
className={compact ? "w-full" : ""}
/>
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
isLoading={isCategoryLoading}
className={compact ? "w-full" : ""}
/>
{isYearsLoading ? (
<div className="flex items-center justify-center h-8 w-full">
<Skeleton className="h-full w-full rounded-md" />
</div>
) : (
<Button
variant={compact ? "secondary" : "ghost"}
size={compact ? "sm" : "default"}
onClick={resetFilters}
disabled={selectedYear === 2024 && selectedMonth === "all" && selectedCategory === "all"}
className={compact ? "w-full" : ""}
>
<FilterX className="h-4 w-4 mr-1" />
Reset
</Button>
)}
</div>
)
}

View File

@ -0,0 +1,35 @@
"use client"
import { ReactNode } from "react"
// Define the possible control IDs for the crime map
export type ITopTooltipsMapId =
// Crime data views
| "incidents"
| "heatmap"
| "trends"
| "patrol"
| "reports"
| "clusters"
| "timeline"
// Tools and features
| "refresh"
| "search"
| "alerts"
| "layers"
| "evidence"
| "arrests";
// Map tools type definition
export interface IMapTool {
id: ITopTooltipsMapId;
label: string;
icon: ReactNode;
description?: string;
}
// Default export for future expansion
export default function MapTools() {
return null;
}

View File

@ -2,6 +2,7 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
import { useEffect, useRef, useState } from "react"
import { Skeleton } from "../../ui/skeleton"
// Month options
const months = [
@ -24,13 +25,15 @@ interface MonthSelectorProps {
onMonthChange: (month: number | "all") => void
className?: string
includeAllOption?: boolean
isLoading?: boolean
}
export default function MonthSelector({
selectedMonth,
onMonthChange,
className = "w-[120px]",
includeAllOption = true
includeAllOption = true,
isLoading = false,
}: MonthSelectorProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [isClient, setIsClient] = useState(false)
@ -44,7 +47,12 @@ export default function MonthSelector({
return (
<div ref={containerRef} className="mapboxgl-month-selector">
<Select
{isLoading ? (
<div className="flex items-center justify-center h-8">
<Skeleton className="h-full w-full rounded-md" />
</div>
) : (
<Select
value={selectedMonth.toString()}
onValueChange={(value) => onMonthChange(value === "all" ? "all" : Number(value))}
>
@ -54,6 +62,7 @@ export default function MonthSelector({
<SelectContent
container={containerRef.current || container || undefined}
style={{ zIndex: 2000 }}
className={`${className}`}
>
{includeAllOption && <SelectItem value="all">All Months</SelectItem>}
{months.map((month) => (
@ -63,6 +72,7 @@ export default function MonthSelector({
))}
</SelectContent>
</Select>
)}
</div>
)
}

View File

@ -3,6 +3,7 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
import { createRoot } from "react-dom/client"
import { useRef, useEffect, useState } from "react"
import { Skeleton } from "../../ui/skeleton"
interface YearSelectorProps {
availableYears?: (number | null)[]
@ -42,7 +43,9 @@ function YearSelectorUI({
return (
<div ref={containerRef} className="mapboxgl-year-selector">
{isLoading ? (
<div className={`${className} h-9 rounded-md bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 animate-pulse`} />
<div className="flex items-center justify-center h-8">
<Skeleton className="h-full w-full rounded-md" />
</div>
) : (
<Select
value={selectedYear.toString()}
@ -56,6 +59,7 @@ function YearSelectorUI({
<SelectContent
container={containerRef.current || container || undefined}
style={{ zIndex: 2000 }}
className={`${className}`}
>
{availableYears
?.filter((year) => year !== null)

View File

@ -5,40 +5,84 @@ import { Skeleton } from "@/app/_components/ui/skeleton"
import DistrictLayer, { type DistrictFeature } from "./layers/district-layer"
import MapView from "./map"
import { Button } from "@/app/_components/ui/button"
import { AlertCircle, FilterX } from "lucide-react"
import { AlertCircle } from "lucide-react"
import { getMonthName } from "@/app/_utils/common"
import { useCrimeMapHandler } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_handlers/crime-map-handlers"
import { useRef, useState } from "react"
import { useRef, useState, useCallback, useMemo } from "react"
import { CrimePopup } from "./pop-up"
import type { CrimeIncident } from "./markers/crime-marker"
import YearSelector from "./controls/year-selector"
import MonthSelector from "./controls/month-selector"
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
import { Overlay } from "./overlay"
import { useGetAvailableYears, useGetCrimeByYearAndMonth } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
import MapLegend from "./controls/map-legend"
import { usePrefetchedCrimeData } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes"
import { useGetCrimeCategories } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
import { ITopTooltipsMapId } from "./controls/map-tooltips"
import MapSelectors from "./controls/map-selector"
import TopNavigation from "./controls/map-navigations"
import CrimeSidebar from "./sidebar/map-sidebar"
import SidebarToggle from "./sidebar/sidebar-toggle"
export default function CrimeMap() {
// Set default year to 2024 instead of "all"
const [selectedYear, setSelectedYear] = useState<number>(2024)
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
// State for sidebar
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
const [selectedIncident, setSelectedIncident] = useState<CrimeIncident | null>(null)
const [showLegend, setShowLegend] = useState<boolean>(true)
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents")
const mapContainerRef = useRef<HTMLDivElement>(null)
// Use the custom fullscreen hook
const { isFullscreen } = useFullscreen(mapContainerRef)
const { data: availableYears, isLoading: isYearsLoading, error: yearsError } = useGetAvailableYears()
// Toggle sidebar function
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(!sidebarCollapsed)
}, [sidebarCollapsed])
const { data: crimes, isLoading: isCrimesLoading, error: isCrimesError, refetch: refetchCrimes } = useGetCrimeByYearAndMonth(selectedYear, selectedMonth)
// Use our new prefetched data hook
const {
availableYears,
isYearsLoading,
crimes,
isCrimesLoading,
crimesError,
setSelectedYear,
setSelectedMonth,
selectedYear,
selectedMonth,
} = usePrefetchedCrimeData()
// Extract all unique categories
const { data: categoriesData, isLoading: isCategoryLoading } = useGetCrimeCategories()
// Transform categories data to string array
const categories = useMemo(() =>
categoriesData ? categoriesData.map(category => category.name) : []
, [categoriesData])
// Filter incidents based on selected category
const filteredCrimes = useMemo(() => {
if (!crimes) return []
if (selectedCategory === "all") return crimes
return crimes.map((district: { incidents: CrimeIncident[], number_of_crime: number }) => ({
...district,
incidents: district.incidents.filter(incident =>
incident.category === selectedCategory
),
// Update number_of_crime to reflect the filtered count
number_of_crime: district.incidents.filter(
incident => incident.category === selectedCategory
).length
}))
}, [crimes, selectedCategory])
// Extract all incidents from all districts for marker display
const allIncidents =
crimes?.flatMap((district) =>
const allIncidents = useMemo(() => {
if (!filteredCrimes) return []
return filteredCrimes.flatMap((district: { incidents: CrimeIncident[] }) =>
district.incidents.map((incident) => ({
id: incident.id,
timestamp: incident.timestamp,
@ -49,8 +93,9 @@ export default function CrimeMap() {
address: incident.address,
latitude: incident.latitude,
longitude: incident.longitude,
})),
) || []
}))
)
}, [filteredCrimes])
// Handle district click
const handleDistrictClick = (feature: DistrictFeature) => {
@ -62,17 +107,12 @@ export default function CrimeMap() {
setSelectedIncident(incident)
}
// Apply filters
const applyFilters = () => {
refetchCrimes()
}
// Reset filters
const resetFilters = () => {
const resetFilters = useCallback(() => {
setSelectedYear(2024)
setSelectedMonth("all")
refetchCrimes()
}
setSelectedCategory("all")
}, [setSelectedYear, setSelectedMonth])
// Determine the title based on filters
const getMapTitle = () => {
@ -80,6 +120,9 @@ export default function CrimeMap() {
if (selectedMonth !== "all") {
title += ` - ${getMonthName(Number(selectedMonth))}`
}
if (selectedCategory !== "all") {
title += ` - ${selectedCategory}`
}
return title
}
@ -87,47 +130,40 @@ export default function CrimeMap() {
<Card className="w-full p-0 border-none shadow-none h-96">
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
<CardTitle>Crime Map {getMapTitle()}</CardTitle>
<div className="flex items-center gap-2">
{/* Year selector component */}
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
isLoading={isYearsLoading}
/>
{/* Month selector component */}
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} />
<Button variant="ghost" onClick={resetFilters} disabled={selectedYear === 2024 && selectedMonth === "all"}>
<FilterX className="h-4 w-4" />
Reset
</Button>
</div>
<MapSelectors
availableYears={availableYears || []}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
selectedMonth={selectedMonth}
setSelectedMonth={setSelectedMonth}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
categories={categories}
isYearsLoading={isYearsLoading}
isCategoryLoading={isCategoryLoading}
/>
</CardHeader>
<CardContent className="p-0">
{isCrimesLoading ? (
<div className="flex items-center justify-center h-96">
<Skeleton className="h-full w-full rounded-md" />
</div>
) : isCrimesError ? (
) : crimesError ? (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle className="h-10 w-10 text-destructive" />
<p className="text-center">Failed to load crime data. Please try again later.</p>
<Button onClick={() => refetchCrimes()}>Retry</Button>
<Button onClick={() => window.location.reload()}>Retry</Button>
</div>
) : (
<div className="relative h-[600px]" ref={mapContainerRef}>
<MapView
mapStyle="mapbox://styles/mapbox/dark-v11"
className="h-[600px] w-full rounded-md"
>
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
{/* District Layer with crime data */}
<DistrictLayer
onClick={handleDistrictClick}
crimes={crimes || []}
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
/>
{/* Popup for selected incident */}
@ -140,38 +176,29 @@ export default function CrimeMap() {
/>
)}
{/* Components that are only visible in fullscreen mode */}
{isFullscreen && (
<>
<Overlay
position="top-left"
className="m-2 bg-transparent shadow-none p-0 border-none"
>
<div className="flex items-center gap-2 rounded-md p-0 shadow-lg">
<YearSelector
availableYears={availableYears}
<Overlay position="top" className="m-0 bg-transparent shadow-none p-0 border-none">
<div className="flex justify-center">
<TopNavigation
activeControl={activeControl}
onControlChange={setActiveControl}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
isLoading={isYearsLoading}
className=" gap-2 text-white"
/>
<MonthSelector
setSelectedYear={setSelectedYear}
selectedMonth={selectedMonth}
onMonthChange={setSelectedMonth}
className=" gap-2 hover:bg-red text-white"
setSelectedMonth={setSelectedMonth}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
availableYears={availableYears || []}
categories={categories}
/>
<Button
variant="secondary"
className=" hover:bg-red text-white"
onClick={resetFilters}
disabled={selectedYear === 2024 && selectedMonth === "all"}
size="sm"
>
<FilterX className="h-4 w-4 mr-1" />
Reset
</Button>
</div>
</Overlay>
{/* Sidebar component without overlay */}
<CrimeSidebar defaultCollapsed={sidebarCollapsed} />
<MapLegend position="bottom-right" />
</>
)}

View File

@ -22,6 +22,7 @@ export interface DistrictLayerProps {
onClick?: (feature: DistrictFeature) => void
year?: string
month?: string
filterCategory?: string | "all"
crimes?: Array<{
id: string
district_name: string
@ -38,6 +39,7 @@ export default function DistrictLayer({
onClick,
year,
month,
filterCategory = "all",
crimes = [],
tilesetId = MAPBOX_TILESET_ID,
}: DistrictLayerProps) {
@ -268,8 +270,16 @@ export default function DistrictLayer({
// Create a source for clustered incident markers
if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) {
// Collect all incidents from all districts
const allIncidents = crimes.flatMap((crime) =>
crime.incidents.map((incident) => ({
const allIncidents = crimes.flatMap((crime) => {
// Apply category filter if specified
let filteredIncidents = crime.incidents;
if (filterCategory !== "all") {
filteredIncidents = crime.incidents.filter(
incident => incident.category === filterCategory
);
}
return filteredIncidents.map((incident) => ({
type: "Feature" as const,
properties: {
id: incident.id,
@ -283,8 +293,8 @@ export default function DistrictLayer({
type: "Point" as const,
coordinates: [incident.longitude, incident.latitude],
},
})),
)
}));
});
// Add a clustered GeoJSON source for incidents
map.getMap().addSource("crime-incidents", {
@ -296,7 +306,7 @@ export default function DistrictLayer({
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
})
});
// Only add layers if they don't already exist
if (!map.getMap().getLayer("clusters")) {
@ -473,7 +483,7 @@ export default function DistrictLayer({
// This prevents the issue of removing default layers
}
}
}, [map, visible, tilesetId, crimes])
}, [map, visible, tilesetId, crimes, filterCategory])
// Update the crime data when it changes
useEffect(() => {
@ -515,6 +525,48 @@ export default function DistrictLayer({
}
}, [map, crimes])
// Update the incident data when it changes
useEffect(() => {
if (!map || !map.getMap().getSource("crime-incidents")) return;
try {
// Get all incidents, filtered by category if needed
const allIncidents = crimes.flatMap((crime) => {
// Apply category filter if specified
let filteredIncidents = crime.incidents;
if (filterCategory !== "all") {
filteredIncidents = crime.incidents.filter(
incident => incident.category === filterCategory
);
}
return filteredIncidents.map((incident) => ({
type: "Feature" as const,
properties: {
id: incident.id,
district: crime.district_name,
category: incident.category,
incidentType: incident.type,
level: crime.level,
description: incident.description,
},
geometry: {
type: "Point" as const,
coordinates: [incident.longitude, incident.latitude],
},
}));
});
// Update the data source
(map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
type: "FeatureCollection",
features: allIncidents,
});
} catch (error) {
console.error("Error updating incident data:", error);
}
}, [map, crimes, filterCategory]);
if (!visible) return null
return (
@ -536,8 +588,8 @@ export default function DistrictLayer({
{hoverInfo.feature.properties.number_of_crime} incidents
{hoverInfo.feature.properties.level && (
<span className="ml-2 text-xs font-semibold text-gray-500">({hoverInfo.feature.properties.level})</span>
)}
</p>
)}
</p>
)}
</div>
)}

View File

@ -1,16 +1,11 @@
"use client"
import type React from "react"
import { useState, useCallback, useRef, useEffect } from "react"
import { type ViewState, Map, type MapRef, NavigationControl } from "react-map-gl/mapbox"
import { useState, useCallback } from "react"
import { type ViewState, Map, type MapRef, NavigationControl, GeolocateControl } from "react-map-gl/mapbox"
import { FullscreenControl } from "react-map-gl/mapbox"
import { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAPBOX_STYLES, type MapboxStyle } from "@/app/_utils/const/map"
import "mapbox-gl/dist/mapbox-gl.css"
import { createRoot } from "react-dom/client"
import { YearSelectorControl } from "./controls/year-selector"
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
import { CustomControl } from "./controls/example"
import { toast } from "sonner"
interface MapViewProps {
children?: React.ReactNode
@ -35,8 +30,6 @@ export default function MapView({
onMoveEnd,
}: MapViewProps) {
const [mapRef, setMapRef] = useState<MapRef | null>(null)
const mapContainerRef = useRef<HTMLDivElement>(null)
const { isFullscreen } = useFullscreen(mapContainerRef)
const defaultViewState: Partial<ViewState> = {
longitude: BASE_LONGITUDE,
@ -53,11 +46,11 @@ export default function MapView({
onMoveEnd(event.viewState)
}
},
[onMoveEnd]
[onMoveEnd],
)
return (
<div ref={mapContainerRef} className={`relative ${className}`}>
<div className={`relative ${className}`}>
<div className="flex h-full">
<div className="relative flex-grow h-full transition-all duration-300">
<Map
@ -67,9 +60,11 @@ export default function MapView({
onMoveEnd={handleMoveEnd}
style={{ width: "100%", height: "100%" }}
attributionControl={false}
preserveDrawingBuffer={true} // This helps with fullscreen stability
>
<FullscreenControl position="top-right" />
<NavigationControl position="top-right" showCompass={false} />
{children}
</Map>
</div>

View File

@ -1,31 +1,36 @@
import { IControl, Map } from "mapbox-gl";
import { ControlPosition } from "mapbox-gl";
import { cloneElement, memo, ReactElement, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useControl } from "react-map-gl/mapbox";
import { v4 as uuidv4 } from 'uuid';
"use client"
import type React from "react"
import type { IControl, Map } from "mapbox-gl"
import type { ControlPosition } from "mapbox-gl"
import { cloneElement, memo, type ReactElement, useEffect, useState, useRef } from "react"
import { createPortal } from "react-dom"
import { useControl } from "react-map-gl/mapbox"
import { v4 as uuidv4 } from "uuid"
// Updated props type to include addControl in children props
type OverlayProps = {
position: ControlPosition;
position: ControlPosition
children: ReactElement<{
map?: Map;
addControl?: (control: IControl, position?: ControlPosition) => void;
}>;
id?: string;
className?: string;
style?: React.CSSProperties;
};
map?: Map
addControl?: (control: IControl, position?: ControlPosition) => void
}>
id?: string
className?: string
style?: React.CSSProperties
}
// Custom control for overlay
class OverlayControl implements IControl {
_map: Map | null = null;
_container: HTMLElement | null = null;
_position: ControlPosition;
_id: string;
_redraw?: () => void;
_className?: string;
_style?: React.CSSProperties;
_map: Map | null = null
_container: HTMLElement | null = null
_position: ControlPosition
_id: string
_redraw?: () => void
_className?: string
_style?: React.CSSProperties
_isDestroyed = false
constructor({
position,
@ -34,122 +39,138 @@ class OverlayControl implements IControl {
className,
style,
}: {
position: ControlPosition;
id: string;
redraw?: () => void;
className?: string;
style?: React.CSSProperties;
position: ControlPosition
id: string
redraw?: () => void
className?: string
style?: React.CSSProperties
}) {
this._position = position;
this._id = id;
this._redraw = redraw;
this._className = className;
this._style = style;
this._position = position
this._id = id
this._redraw = redraw
this._className = className
this._style = style
}
onAdd(map: Map) {
this._map = map;
this._container = document.createElement('div');
this._map = map
this._container = document.createElement("div")
// Apply base classes but keep it minimal to avoid layout conflicts
this._container.className = `mapboxgl-ctrl ${this._className || ''}`;
this._container.id = this._id;
this._container.className = `mapboxgl-ctrl ${this._className || ""}`
this._container.id = this._id
// Important: These styles make the overlay adapt to content
this._container.style.pointerEvents = 'auto';
this._container.style.display = 'inline-block'; // Allow container to size to content
this._container.style.maxWidth = 'none'; // Remove any max-width constraints
this._container.style.width = 'auto'; // Let width be determined by content
this._container.style.height = 'auto'; // Let height be determined by content
this._container.style.overflow = 'visible'; // Allow content to overflow if needed
this._container.style.pointerEvents = "auto"
this._container.style.display = "inline-block" // Allow container to size to content
this._container.style.maxWidth = "none" // Remove any max-width constraints
this._container.style.width = "auto" // Let width be determined by content
this._container.style.height = "auto" // Let height be determined by content
this._container.style.overflow = "visible" // Allow content to overflow if needed
this._container.style.zIndex = "10" // Ensure it's above other map elements
// Apply any custom styles passed as props
if (this._style) {
Object.entries(this._style).forEach(([key, value]) => {
// @ts-ignore - dynamically setting style properties
this._container.style[key] = value;
});
this._container.style[key] = value
})
}
if (this._redraw) {
map.on('move', this._redraw);
this._redraw();
map.on("move", this._redraw)
this._redraw()
}
return this._container;
return this._container
}
onRemove() {
if (!this._map || !this._container) return;
if (!this._map || !this._container || this._isDestroyed) return
if (this._redraw) {
this._map.off('move', this._redraw);
this._map.off("move", this._redraw)
}
this._container.remove();
this._map = null;
this._container.remove()
this._map = null
this._isDestroyed = true
}
getDefaultPosition() {
return this._position;
return this._position
}
getMap() {
return this._map;
return this._map
}
getElement() {
return this._container;
return this._container
}
// Method to add other controls to the map
addControl(control: IControl, position?: ControlPosition) {
if (this._map) {
this._map.addControl(control, position);
this._map.addControl(control, position)
}
return this;
return this
}
}
// Enhanced Overlay component
function _Overlay({ position, children, id = `overlay-${uuidv4()}`, className, style }: OverlayProps) {
const [container, setContainer] = useState<HTMLElement | null>(null);
const [map, setMap] = useState<Map | null>(null);
const [container, setContainer] = useState<HTMLElement | null>(null)
const [map, setMap] = useState<Map | null>(null)
const controlRef = useRef<OverlayControl | null>(null)
// Use useControl with unique ID to avoid conflicts
const ctrl = useControl<OverlayControl>(
() =>
new OverlayControl({
() => {
const control = new OverlayControl({
position,
id,
className,
style,
}),
{ position }
);
})
controlRef.current = control
return control
},
{ position },
)
// Update container and map instance when control is ready
useEffect(() => {
if (ctrl) {
setContainer(ctrl.getElement());
setMap(ctrl.getMap());
setContainer(ctrl.getElement())
setMap(ctrl.getMap())
}
}, [ctrl]);
// Cleanup function to ensure proper removal
return () => {
if (controlRef.current && !controlRef.current._isDestroyed) {
controlRef.current.onRemove()
}
}
}, [ctrl])
// Only render if container is ready
if (!container || !map) return null;
if (!container || !map) return null
// Use createPortal to render children to container and pass addControl method
// return createPortal(
// cloneElement(children, { map, addControl: ctrl.addControl.bind(ctrl) }),
// container
// );
// cloneElement(children, {
// map,
// addControl: (control: IControl, position?: ControlPosition) => ctrl.addControl(control, position),
// }),
// container,
// )
return createPortal(
cloneElement(children, { map }),
container
container,
)
}
// Export as memoized component
export const Overlay = memo(_Overlay);
export const Overlay = memo(_Overlay)

View File

@ -0,0 +1,234 @@
"use client"
import React, { useState } from "react"
import { AlertTriangle, BarChart, ChevronRight, MapPin, Skull, Shield, FileText } from "lucide-react"
import { Separator } from "@/app/_components/ui/separator"
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
import { cn } from "@/app/_lib/utils"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
interface CrimeSidebarProps {
className?: string
defaultCollapsed?: boolean
}
export default function CrimeSidebar({ className, defaultCollapsed = true }: CrimeSidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
const [activeTab, setActiveTab] = useState("incidents")
return (
<div className={cn(
"absolute top-0 left-0 h-full z-10 transition-all duration-300 ease-in-out bg-background backdrop-blur-sm border-r border-white/10 ",
isCollapsed ? "translate-x-[-100%]" : "translate-x-0",
className
)}>
<div className="relative h-full flex items-stretch">
{/* Main Sidebar Content */}
<div className="bg-background backdrop-blur-sm border-r border-white/10 h-full w-[320px]">
<div className="p-4 text-white h-full flex flex-col">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-xl font-semibold flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Crime Analysis
</CardTitle>
</CardHeader>
<Tabs defaultValue="incidents" className="w-full" value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full mb-2 bg-black/30">
<TabsTrigger value="incidents" className="flex-1">Incidents</TabsTrigger>
<TabsTrigger value="statistics" className="flex-1">Statistics</TabsTrigger>
<TabsTrigger value="reports" className="flex-1">Reports</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1 max-h-[calc(100vh-10rem)]">
<TabsContent value="incidents" className="m-0 p-0">
<SidebarSection title="Recent Incidents" icon={<AlertTriangle className="h-4 w-4 text-red-400" />}>
<div className="space-y-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<IncidentCard key={i} />
))}
</div>
</SidebarSection>
</TabsContent>
<TabsContent value="statistics" className="m-0 p-0">
<SidebarSection title="Crime Overview" icon={<BarChart className="h-4 w-4 text-blue-400" />}>
<div className="space-y-2">
<StatCard title="Total Incidents" value="254" change="+12%" />
<StatCard title="Hot Zones" value="6" change="+2" />
<StatCard title="Case Clearance" value="68%" change="+5%" isPositive />
</div>
</SidebarSection>
<Separator className="bg-white/20 my-4" />
<SidebarSection title="Most Reported" icon={<Skull className="h-4 w-4 text-amber-400" />}>
<div className="space-y-2">
<CrimeTypeCard type="Theft" count={42} percentage={23} />
<CrimeTypeCard type="Assault" count={28} percentage={15} />
<CrimeTypeCard type="Vandalism" count={19} percentage={10} />
<CrimeTypeCard type="Burglary" count={15} percentage={8} />
</div>
</SidebarSection>
</TabsContent>
<TabsContent value="reports" className="m-0 p-0">
<SidebarSection title="Recent Reports" icon={<FileText className="h-4 w-4 text-indigo-400" />}>
<div className="space-y-2">
<ReportCard
title="Monthly Crime Summary"
date="June 15, 2024"
author="Dept. Analysis Team"
/>
<ReportCard
title="High Risk Areas Analysis"
date="June 12, 2024"
author="Regional Coordinator"
/>
<ReportCard
title="Case Resolution Statistics"
date="June 10, 2024"
author="Investigation Unit"
/>
<ReportCard
title="Quarterly Report Q2 2024"
date="June 1, 2024"
author="Crime Analysis Department"
/>
</div>
</SidebarSection>
</TabsContent>
</div>
</Tabs>
</div>
</div>
{/* Toggle Button - always visible and positioned correctly */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={cn(
"absolute h-12 w-8 bg-background backdrop-blur-sm border-t border-b border-r border-white/10 flex items-center justify-center",
"top-1/2 -translate-y-1/2 transition-all duration-300 ease-in-out",
isCollapsed ? "-right-8 rounded-r-md" : "left-[320px] rounded-r-md",
)}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<ChevronRight
className={cn("h-5 w-5 text-white/80 transition-transform",
!isCollapsed && "rotate-180")}
/>
</button>
</div>
</div>
)
}
// Helper components for sidebar content
interface SidebarSectionProps {
title: string
children: React.ReactNode
icon?: React.ReactNode
}
function SidebarSection({ title, children, icon }: SidebarSectionProps) {
return (
<div>
<h3 className="text-sm font-medium text-white/80 mb-2 flex items-center gap-1.5">
{icon}
{title}
</h3>
{children}
</div>
)
}
function IncidentCard() {
return (
<Card className="bg-white/10 border-0 text-white shadow-none">
<CardContent className="p-3 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
<div>
<p className="font-medium">Theft reported at Jalan Srikandi</p>
<div className="flex items-center gap-2 mt-1 text-white/60">
<MapPin className="h-3 w-3" />
<span>Jombang District</span>
</div>
<div className="mt-1 text-white/60">3 hours ago</div>
</div>
</div>
</CardContent>
</Card>
)
}
interface StatCardProps {
title: string
value: string
change: string
isPositive?: boolean
}
function StatCard({ title, value, change, isPositive = false }: StatCardProps) {
return (
<Card className="bg-white/10 border-0 text-white shadow-none">
<CardContent className="p-3">
<div className="flex justify-between items-center">
<span className="text-xs text-white/70">{title}</span>
<span className={`text-xs ${isPositive ? "text-green-400" : "text-red-400"}`}>{change}</span>
</div>
<div className="text-xl font-bold mt-1">{value}</div>
</CardContent>
</Card>
)
}
interface CrimeTypeCardProps {
type: string
count: number
percentage: number
}
function CrimeTypeCard({ type, count, percentage }: CrimeTypeCardProps) {
return (
<Card className="bg-white/10 border-0 text-white shadow-none">
<CardContent className="p-3">
<div className="flex justify-between items-center">
<span className="font-medium">{type}</span>
<span className="text-sm text-white/70">{count} cases</span>
</div>
<div className="mt-2 h-1.5 bg-white/20 rounded-full overflow-hidden">
<div className="bg-blue-500 h-full rounded-full" style={{ width: `${percentage}%` }}></div>
</div>
<div className="mt-1 text-xs text-white/70 text-right">{percentage}%</div>
</CardContent>
</Card>
)
}
interface ReportCardProps {
title: string
date: string
author: string
}
function ReportCard({ title, date, author }: ReportCardProps) {
return (
<Card className="bg-white/10 border-0 text-white shadow-none">
<CardContent className="p-3 text-xs">
<div className="flex items-start gap-2">
<FileText className="h-4 w-4 text-indigo-400 shrink-0 mt-0.5" />
<div>
<p className="font-medium">{title}</p>
<div className="flex items-center gap-2 mt-1 text-white/60">
<Shield className="h-3 w-3" />
<span>{author}</span>
</div>
<div className="mt-1 text-white/60">{date}</div>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,41 @@
"use client"
import React from "react"
import { Button } from "@/app/_components/ui/button"
import { cn } from "@/app/_lib/utils"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Overlay } from "../overlay"
import type { ControlPosition } from "mapbox-gl"
interface SidebarToggleProps {
isCollapsed: boolean
onClick: () => void
className?: string
position?: ControlPosition
}
export default function SidebarToggle({
isCollapsed,
onClick,
className,
position = "left"
}: SidebarToggleProps) {
return (
<Overlay position={position}>
<Button
variant="ghost"
size="icon"
onClick={onClick}
className={cn(
"absolute z-10 shadow-md h-8 w-8 bg-background border"
)}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</Overlay>
)
}

View File

@ -9,11 +9,15 @@ const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
interface PopoverContentProps extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {
container?: HTMLElement
}
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
PopoverContentProps
>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => (
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content
ref={ref}
align={align}

View File

@ -6,7 +6,7 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
className={cn("animate-pulse rounded-md bg-muted/50", className)}
{...props}
/>
)

View File

@ -770,31 +770,3 @@ export const getDistrictName = (districtId: string): string => {
'Unknown District'
);
};
// This is a simplified version of
// https://github.com/facebook/react/blob/4131af3e4bf52f3a003537ec95a1655147c81270/src/renderers/dom/shared/CSSPropertyOperations.js#L62
const unitlessNumber =
/box|flex|grid|column|lineHeight|fontWeight|opacity|order|tabSize|zIndex/;
export function CApplyReactStyle(
element: HTMLElement,
styles: React.CSSProperties
) {
if (!element || !styles) {
return;
}
const style = element.style;
for (const key in styles) {
const value = styles[key as keyof React.CSSProperties];
if (value !== undefined) {
if (Number.isFinite(value) && !unitlessNumber.test(key)) {
style[key as any] = `${value}px`;
} else {
style[key as any] = value as string;
}
}
}
}