chore: update eslint and prettier configuration

This commit is contained in:
2026-06-17 16:23:28 +05:30
parent b54242cf7e
commit 4c2241ff72
140 changed files with 2270 additions and 1287 deletions

View File

@@ -44,7 +44,9 @@ export function BreadcrumbBasic() {
{isLast ? (
<BreadcrumbPage>{formatSegment(segment)}</BreadcrumbPage>
) : (
<BreadcrumbLink href={href}>{formatSegment(segment)}</BreadcrumbLink>
<BreadcrumbLink href={href}>
{formatSegment(segment)}
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}

View File

@@ -2,9 +2,17 @@
import * as React from 'react';
import { ChevronRight, Package } from 'lucide-react';
import { NavUser } from '@/components/nav-user';
import { filterMenuItems, menuItems, type MenuItem } from '@/config/menu.config';
import {
filterMenuItems,
menuItems,
type MenuItem,
} from '@/config/menu.config';
import { usePermissions } from '@/hooks/usePermissions';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Sidebar,
SidebarContent,
@@ -30,7 +38,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const authUser = useAppStore((state) => state.user);
const navItems = filterMenuItems(menuItems, hasPermission);
const user = {
name: [authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') || 'Admin',
name:
[authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') ||
'Admin',
email: authUser?.email || '',
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
};
@@ -52,7 +62,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarGroup>
<SidebarMenu>
{navItems.map((item) => (
<SidebarNavItem key={item.title} item={item} pathname={pathname} />
<SidebarNavItem
key={item.title}
item={item}
pathname={pathname}
/>
))}
</SidebarMenu>
</SidebarGroup>
@@ -65,14 +79,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
);
}
function SidebarNavItem({ item, pathname }: { item: MenuItem; pathname: string }) {
function SidebarNavItem({
item,
pathname,
}: {
item: MenuItem;
pathname: string;
}) {
const isActive = item.path ? pathname === item.path : false;
const isChildActive = item.children?.some((child) => child.path === pathname) ?? false;
const isChildActive =
item.children?.some((child) => child.path === pathname) ?? false;
const hasChildren = Boolean(item.children?.length);
if (hasChildren) {
return (
<Collapsible asChild defaultOpen={isChildActive} className="group/collapsible">
<Collapsible
asChild
defaultOpen={isChildActive}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={isChildActive}>
@@ -86,7 +111,10 @@ function SidebarNavItem({ item, pathname }: { item: MenuItem; pathname: string }
{item.children?.map((child) => (
<SidebarMenuSubItem key={child.title}>
{child.path ? (
<SidebarMenuSubButton asChild isActive={pathname === child.path}>
<SidebarMenuSubButton
asChild
isActive={pathname === child.path}
>
<Link href={child.path}>
<child.icon />
<span>{child.title}</span>

View File

@@ -15,7 +15,10 @@ import {
ComboboxValue,
} from '@/components/ui/combobox';
import type { AsyncComboboxOption, AsyncComboboxProps } from './AsyncCombobox.types';
import type {
AsyncComboboxOption,
AsyncComboboxProps,
} from './AsyncCombobox.types';
import { VirtualComboboxList } from './VirtualComboboxList';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
@@ -108,7 +111,9 @@ export function AsyncCombobox({
filter={null}
disabled={isDisabled}
itemToStringLabel={(item) => item.label}
isItemEqualToValue={(item, currentValue) => item.value === currentValue.value}
isItemEqualToValue={(item, currentValue) =>
item.value === currentValue.value
}
onItemHighlighted={handleItemHighlighted}
>
<ComboboxTrigger
@@ -131,7 +136,11 @@ export function AsyncCombobox({
}
/>
<ComboboxContent container={portalContainer}>
<ComboboxInput showTrigger={false} placeholder={searchPlaceholder} disabled={isDisabled} />
<ComboboxInput
showTrigger={false}
placeholder={searchPlaceholder}
disabled={isDisabled}
/>
{isError ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage || 'Unable to load options.'}

View File

@@ -1,6 +1,12 @@
'use client';
import { useCallback, useEffect, useImperativeHandle, useRef, type RefObject } from 'react';
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
type RefObject,
} from 'react';
import { Combobox } from '@base-ui/react/combobox';
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
import { Loader2 } from 'lucide-react';
@@ -72,7 +78,13 @@ export function VirtualComboboxList({
) {
onLoadMore();
}
}, [filteredItems.length, hasNextPage, isFetchingNextPage, lastVirtualIndex, onLoadMore]);
}, [
filteredItems.length,
hasNextPage,
isFetchingNextPage,
lastVirtualIndex,
onLoadMore,
]);
if (!filteredItems.length && !hasNextPage) {
return null;

View File

@@ -5,7 +5,10 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useDebounce } from '@/hooks/useDebounce';
import type { AsyncComboboxOption, PaginatedLookupConfig } from './AsyncCombobox.types';
import type {
AsyncComboboxOption,
PaginatedLookupConfig,
} from './AsyncCombobox.types';
export function usePaginatedLookup<T>({
enabled = true,
@@ -35,7 +38,10 @@ export function usePaginatedLookup<T>({
return undefined;
}
const loadedCount = allPages.reduce((count, page) => count + page.items.length, 0);
const loadedCount = allPages.reduce(
(count, page) => count + page.items.length,
0,
);
return loadedCount < lastPage.total ? loadedCount : undefined;
},
enabled,
@@ -67,7 +73,11 @@ export function usePaginatedLookup<T>({
const item = await resolveSelected(selectedValue);
return item ? mapOption(item) : null;
},
enabled: enabled && Boolean(selectedValue) && !selectedInList && Boolean(resolveSelected),
enabled:
enabled &&
Boolean(selectedValue) &&
!selectedInList &&
Boolean(resolveSelected),
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});

View File

@@ -55,7 +55,10 @@ const chartConfig = {
},
} satisfies ChartConfig;
export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartProps) {
export function ChainageBarChart({
data,
isLoading = false,
}: ChainageBarChartProps) {
if (isLoading) {
return (
<div className="h-[250px] flex items-center justify-center">
@@ -68,7 +71,9 @@ export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartPr
return (
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
<p className="text-sm">No segment data available</p>
<p className="text-xs mt-1">Process videos to see detections by segment</p>
<p className="text-xs mt-1">
Process videos to see detections by segment
</p>
</div>
);
}
@@ -94,17 +99,43 @@ export function ChainageBarChart({ data, isLoading = false }: ChainageBarChartPr
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => (value.length > 8 ? `${value.slice(0, 8)}...` : value)}
tickFormatter={(value) =>
value.length > 8 ? `${value.slice(0, 8)}...` : value
}
fontSize={12}
/>
<YAxis tickLine={false} axisLine={false} fontSize={12} tickMargin={10} />
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="dashed" />} />
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
tickMargin={10}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dashed" />}
/>
<Bar dataKey="pothole" fill="var(--color-pothole)" radius={4} />
<Bar dataKey="defected_sign_board" fill="var(--color-defected_sign_board)" radius={4} />
<Bar
dataKey="defected_sign_board"
fill="var(--color-defected_sign_board)"
radius={4}
/>
<Bar dataKey="road_crack" fill="var(--color-road_crack)" radius={4} />
<Bar dataKey="damaged_road_marking" fill="var(--color-damaged_road_marking)" radius={4} />
<Bar dataKey="drain_issue" fill="var(--color-drain_issue)" radius={4} />
<Bar dataKey="defective_culvert" fill="var(--color-defective_culvert)" radius={4} />
<Bar
dataKey="damaged_road_marking"
fill="var(--color-damaged_road_marking)"
radius={4}
/>
<Bar
dataKey="drain_issue"
fill="var(--color-drain_issue)"
radius={4}
/>
<Bar
dataKey="defective_culvert"
fill="var(--color-defective_culvert)"
radius={4}
/>
</BarChart>
</ChartContainer>
</div>

View File

@@ -1,7 +1,13 @@
'use client';
import { useEffect } from 'react';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
import {
MapContainer,
TileLayer,
CircleMarker,
Popup,
useMap,
} from 'react-leaflet';
import { LatLngBounds, LatLng } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Detection } from '@/types';
@@ -24,7 +30,9 @@ function FitBounds({ bounds }: { bounds: LatLngBounds }) {
return null;
}
export default function DashboardMapContent({ detections }: DashboardMapContentProps) {
export default function DashboardMapContent({
detections,
}: DashboardMapContentProps) {
if (detections.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center bg-muted/20">
@@ -56,9 +64,9 @@ export default function DashboardMapContent({ detections }: DashboardMapContentP
const getMarkerColor = (typeId: string) => {
const config = DETECTION_TYPES[typeId.toLowerCase()];
if (config) {
return {
fill: config.color,
stroke: '#ffffff' // White stroke for better visibility on the map
return {
fill: config.color,
stroke: '#ffffff', // White stroke for better visibility on the map
};
}
return { fill: '#64748b', stroke: '#475569' }; // Default Slate
@@ -104,28 +112,44 @@ export default function DashboardMapContent({ detections }: DashboardMapContentP
<div className="font-bold text-base border-b border-border pb-2 mb-3 leading-none">
{typeName}
</div>
<div className="space-y-2 mb-4">
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Class</span>
<span className="font-medium text-right capitalize">{detection.class.replace(/_/g, ' ')}</span>
<span className="font-medium text-right capitalize">
{detection.class.replace(/_/g, ' ')}
</span>
</div>
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Confidence</span>
<span className="font-medium text-right">{(detection.confidence * 100).toFixed(1)}%</span>
<span className="text-muted-foreground text-xs">
Confidence
</span>
<span className="font-medium text-right">
{(detection.confidence * 100).toFixed(1)}%
</span>
</div>
</div>
<div className="pt-2 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">Location Details</div>
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">
Location Details
</div>
<div className="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Latitude</span>
<span className="font-medium tracking-tighter">{detection.latitude!.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Latitude
</span>
<span className="font-medium tracking-tighter">
{detection.latitude!.toFixed(6)}
</span>
</div>
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Longitude</span>
<span className="font-medium tracking-tighter">{detection.longitude!.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Longitude
</span>
<span className="font-medium tracking-tighter">
{detection.longitude!.toFixed(6)}
</span>
</div>
</div>
</div>

View File

@@ -59,18 +59,23 @@ export function DashboardMap({
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
if (!pkg) continue;
const chainagesToProcess =
selectedChainageId && selectedChainageId !== 'all'
? { [selectedChainageId]: (pkg as any).chainages?.[selectedChainageId] }
? {
[selectedChainageId]: (pkg as any).chainages?.[
selectedChainageId
],
}
: (pkg as any).chainages || {};
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
if (!chn) continue;
const chainageDetections = ((chn as any).detections || []).filter(
(d: any) => (d.type || d.class || '').toLowerCase() !== 'good_sign_board'
);
filteredDetections.push(...chainageDetections);
const chainageDetections = ((chn as any).detections || []).filter(
(d: any) =>
(d.type || d.class || '').toLowerCase() !== 'good_sign_board',
);
filteredDetections.push(...chainageDetections);
}
}
@@ -99,16 +104,18 @@ export function DashboardMap({
const validDetections = detections.filter((d) => {
const hasGps = d.latitude && d.longitude;
if (!hasGps) return false;
if (selectedTypes.length === 0) return true;
const typeId = (d.type || d.class || '').toLowerCase();
return selectedTypes.includes(typeId);
});
return (
<Card
<Card
className={`overflow-hidden transition-all duration-300 p-2 rounded-lg ${
isMaximized ? 'fixed inset-0 z-100 m-0 rounded-none bg-background' : className
isMaximized
? 'fixed inset-0 z-100 m-0 rounded-none bg-background'
: className
}`}
>
<CardContent className="p-0 relative rounded-lg">
@@ -119,9 +126,13 @@ export function DashboardMap({
variant="default"
size="icon"
onClick={() => setIsMaximized(!isMaximized)}
title={isMaximized ? "Exit Fullscreen" : "Maximize Map"}
title={isMaximized ? 'Exit Fullscreen' : 'Maximize Map'}
>
{isMaximized ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />}
{isMaximized ? (
<Minimize2 className="h-5 w-5" />
) : (
<Maximize2 className="h-5 w-5" />
)}
</Button>
</div>
</div>
@@ -133,9 +144,12 @@ export function DashboardMap({
{Object.values(DETECTION_TYPES).map((type) => {
const typeId = type.id.toLowerCase();
const count = detections.filter(
(d) => (d.type || d.class || '').toLowerCase() === typeId && d.latitude && d.longitude
(d) =>
(d.type || d.class || '').toLowerCase() === typeId &&
d.latitude &&
d.longitude,
).length;
if (
count === 0 &&
(type.id.includes('culvert') || type.id === 'drain_issue')
@@ -143,21 +157,20 @@ export function DashboardMap({
return null;
if (type.id === 'good_sign_board') return null;
const isSelected = selectedTypes.length === 0 || selectedTypes.includes(typeId);
const isSelected =
selectedTypes.length === 0 || selectedTypes.includes(typeId);
return (
<button
key={type.id}
onClick={() => toggleType(typeId)}
className={`flex items-center gap-2 px-2 py-1.5 text-zinc-100 rounded-full transition-all text-xs ${
isSelected
? 'bg-zinc-800/80 '
: 'bg-transparent '
isSelected ? 'bg-zinc-800/80 ' : 'bg-transparent '
}`}
>
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: type.color }}
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: type.color }}
/>
<span>
{type.label} ({count})
@@ -169,7 +182,9 @@ export function DashboardMap({
</div>
</div>
<div className={`${isMaximized ? 'h-screen' : 'h-[600px]'} w-full relative transition-all`}>
<div
className={`${isMaximized ? 'h-screen' : 'h-[600px]'} w-full relative transition-all`}
>
{isLoading ? (
<div className="h-full w-full flex items-center justify-center bg-muted/50">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
@@ -181,7 +196,9 @@ export function DashboardMap({
) : validDetections.length === 0 ? (
<div className="h-full w-full flex items-center justify-center bg-muted/50">
<div className="text-center">
<p className="text-muted-foreground">No detections with GPS coordinates found</p>
<p className="text-muted-foreground">
No detections with GPS coordinates found
</p>
<p className="text-sm text-muted-foreground mt-1 opacity-70">
Process some videos to see detections on the map
</p>
@@ -192,7 +209,6 @@ export function DashboardMap({
)}
{/* Bottom Left Label Overlay */}
</div>
</CardContent>
</Card>

View File

@@ -45,9 +45,21 @@ export function DashboardSkeleton() {
</CardHeader>
<CardContent className="h-[300px] flex items-end justify-between gap-2 px-6 pb-8 pt-4">
{[...Array(8)].map((_, i) => {
const heights = ['60%', '40%', '75%', '50%', '65%', '35%', '80%', '45%'];
const heights = [
'60%',
'40%',
'75%',
'50%',
'65%',
'35%',
'80%',
'45%',
];
return (
<div key={i} className="flex flex-col gap-1 items-center flex-1">
<div
key={i}
className="flex flex-col gap-1 items-center flex-1"
>
<Skeleton
className="w-full rounded-t-sm"
style={{ height: heights[i % heights.length] }}

View File

@@ -96,7 +96,7 @@ export function DetectionDonutChart({
defectedSignboard,
roadCrack,
damagedRoadMarking,
// goodSignboard,
// goodSignboard,
drainIssue,
defectiveCulvert,
],
@@ -124,15 +124,32 @@ export function DetectionDonutChart({
}
return (
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<PieChart>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Pie data={chartData} dataKey="count" nameKey="type" innerRadius={60} strokeWidth={5}>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Pie
data={chartData}
dataKey="count"
nameKey="type"
innerRadius={60}
strokeWidth={5}
>
<Label
content={({ viewBox }) => {
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}

View File

@@ -38,7 +38,9 @@ export function StatsCard({
<>
<p className="text-3xl font-bold tracking-tight">{value}</p>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{subtitle}</p>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
{subtitle}
</p>
)}
</>
)}

View File

@@ -36,7 +36,7 @@ export function TableFooter<TData>({
</span>
{onPageSizeChange ? (
<div className="flex items-center gap-2">
<span >Rows per page</span>
<span>Rows per page</span>
<Select
value={String(currentPageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}

View File

@@ -5,7 +5,11 @@ import type { MouseEvent, ReactNode } from 'react';
import { PermissionGuard } from '@/guards';
import type { PermissionInput } from '@/hooks/usePermissions';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
interface TableActionButtonProps {

View File

@@ -1,8 +1,16 @@
'use client';
import { flexRender } from '@tanstack/react-table';
import { TableHead, TableHeader as ShadTableHeader, TableRow } from '@/components/ui/table';
import {
TableHead,
TableHeader as ShadTableHeader,
TableRow,
} from '@/components/ui/table';
import { Table } from '@tanstack/react-table';
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide } from 'lucide-react';
import {
ArrowDownWideNarrow,
ArrowUpDown,
ArrowUpNarrowWide,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
@@ -23,7 +31,9 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
return (
<TableHead
key={header.id}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onClick={
canSort ? header.column.getToggleSortingHandler() : undefined
}
className={cn(
'h-10 bg-card px-2 text-foreground transition-colors',
canSort && 'cursor-pointer select-none hover:bg-muted/60',
@@ -33,13 +43,18 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
{header.isPlaceholder ? null : (
<div className="flex min-h-8 w-full items-center justify-between gap-2">
<span className="truncate">
{flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{canSort ? (
<SortIcon
className={cn(
'size-3.5 shrink-0 transition-colors',
sortDirection ? 'text-foreground' : 'text-muted-foreground/60',
sortDirection
? 'text-foreground'
: 'text-muted-foreground/60',
)}
/>
) : null}

View File

@@ -67,12 +67,15 @@ export function DataTable<TData, TValue>({
onSortingChange,
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({});
const [internalSorting, setInternalSorting] = React.useState<SortingState>([]);
const [internalSorting, setInternalSorting] = React.useState<SortingState>(
[],
);
const sorting = controlledSorting ?? internalSorting;
const isManualSorting = Boolean(onSortingChange);
const handleSortingChange = React.useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => {
const nextSorting = typeof updater === 'function' ? updater(sorting) : updater;
const nextSorting =
typeof updater === 'function' ? updater(sorting) : updater;
if (onSortingChange) {
onSortingChange(nextSorting);
return;
@@ -95,7 +98,9 @@ export function DataTable<TData, TValue>({
<div className="flex justify-end gap-2">
{actions.map((action) => {
const label =
typeof action.label === 'function' ? action.label(item) : action.label;
typeof action.label === 'function'
? action.label(item)
: action.label;
return (
<TableActionButton
@@ -131,7 +136,9 @@ export function DataTable<TData, TValue>({
getPaginationRowModel: getPaginationRowModel(),
manualPagination: !!pagination,
manualSorting: isManualSorting,
pageCount: pagination?.totalItems ? Math.ceil(pagination.totalItems / pagination.limit) : -1,
pageCount: pagination?.totalItems
? Math.ceil(pagination.totalItems / pagination.limit)
: -1,
state: {
rowSelection,
sorting,
@@ -191,17 +198,26 @@ export function DataTable<TData, TValue>({
))
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-28 text-center">
<TableCell
colSpan={columns.length}
className="h-28 text-center"
>
<div className="flex flex-col items-center justify-center text-muted-foreground gap-1">
<p className="text-sm font-medium">{emptyTitle}</p>
<p className="text-xs">{emptyDescription}</p>

View File

@@ -30,13 +30,16 @@ export function FormField({
<div className={cn('space-y-2', className)}>
<div className="flex items-center justify-between gap-3">
<Label htmlFor={id} className={labelClassName}>
{label} {required ? <span className="text-destructive">*</span> : null}
{label}{' '}
{required ? <span className="text-destructive">*</span> : null}
</Label>
{labelEnd}
</div>
{children}
{error ? (
<p className={cn('-mt-1 text-sm text-destructive', errorClassName)}>{error}</p>
<p className={cn('-mt-1 text-sm text-destructive', errorClassName)}>
{error}
</p>
) : null}
</div>
);

View File

@@ -48,15 +48,19 @@ export function RoleCombobox({
effective_status: true,
sort_by: 'display_name',
sort_order: 'asc',
}),
}),
mapOption: mapRoleOption,
resolveSelected: (roleId) => {
const numericRoleId = parseRoleId(roleId);
return numericRoleId ? roleService.getRoleById(numericRoleId) : Promise.resolve(null);
return numericRoleId
? roleService.getRoleById(numericRoleId)
: Promise.resolve(null);
},
resolveSelectedQueryKey: (roleId) => {
const numericRoleId = parseRoleId(roleId);
return numericRoleId ? roleKeys.detail(numericRoleId) : [...roleKeys.details(), 'invalid', roleId];
return numericRoleId
? roleKeys.detail(numericRoleId)
: [...roleKeys.details(), 'invalid', roleId];
},
});

View File

@@ -1,11 +1,26 @@
'use client';
import { useEffect } from 'react';
import { MapContainer, TileLayer, Polyline, CircleMarker, Popup, useMap } from 'react-leaflet';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
MapContainer,
TileLayer,
Polyline,
CircleMarker,
Popup,
useMap,
} from 'react-leaflet';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { LatLngBounds, LatLng } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { getDetectionModeConfig, DETECTION_TYPES } from '@/constants/detectionModeConfig';
import {
getDetectionModeConfig,
DETECTION_TYPES,
} from '@/constants/detectionModeConfig';
import { DetectionType } from '@/types';
type Detection = {
@@ -47,7 +62,12 @@ function FitBounds({ bounds }: { bounds: LatLngBounds }) {
return null;
}
export default function MapModal({ open, onClose, detections, detectionType }: MapModalProps) {
export default function MapModal({
open,
onClose,
detections,
detectionType,
}: MapModalProps) {
const validDetections = detections.filter((d) => d.latitude && d.longitude);
if (validDetections.length === 0) {
@@ -73,7 +93,9 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
);
routeCoordinates.forEach((coord) => bounds.extend(new LatLng(coord[0], coord[1])));
routeCoordinates.forEach((coord) =>
bounds.extend(new LatLng(coord[0], coord[1])),
);
const center: [number, number] = [
(bounds.getNorth() + bounds.getSouth()) / 2,
(bounds.getEast() + bounds.getWest()) / 2,
@@ -85,20 +107,32 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-6xl h-[85vh] p-0 flex flex-col overflow-hidden border-none shadow-2xl">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-xl font-bold">{modeConfig.label} Map</DialogTitle>
<DialogTitle className="text-xl font-bold">
{modeConfig.label} Map
</DialogTitle>
<p className="text-xs text-muted-foreground font-medium">
{validDetections.length} points of interest identified with GPS data
</p>
</DialogHeader>
<div className="flex-1 w-full relative bg-muted/20">
<MapContainer center={center} zoom={13} className="h-full w-full" scrollWheelZoom={true}>
<MapContainer
center={center}
zoom={13}
className="h-full w-full"
scrollWheelZoom={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Polyline positions={routeCoordinates} color="#3b82f6" weight={4} opacity={0.6} />
<Polyline
positions={routeCoordinates}
color="#3b82f6"
weight={4}
opacity={0.6}
/>
{validDetections.map((detection, idx) => {
const type = (detection.type || '').toLowerCase();
@@ -121,30 +155,51 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
<Popup>
<div className="text-sm min-w-[200px] py-1">
<div className="font-bold text-base border-b border-border pb-2 mb-3 leading-none capitalize">
{(detection.type || '').replace(/_/g, ' ')} <span className="text-muted-foreground font-medium text-sm ml-1">#{detection.id}</span>
{(detection.type || '').replace(/_/g, ' ')}{' '}
<span className="text-muted-foreground font-medium text-sm ml-1">
#{detection.id}
</span>
</div>
<div className="space-y-2 mb-4">
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Frame</span>
<span className="font-medium text-right font-mono">{detection.frame_number}</span>
<span className="text-muted-foreground text-xs">
Frame
</span>
<span className="font-medium text-right font-mono">
{detection.frame_number}
</span>
</div>
<div className="flex justify-between items-baseline gap-4">
<span className="text-muted-foreground text-xs">Confidence</span>
<span className="font-medium text-right">{(detection.confidence * 100).toFixed(1)}%</span>
<span className="text-muted-foreground text-xs">
Confidence
</span>
<span className="font-medium text-right">
{(detection.confidence * 100).toFixed(1)}%
</span>
</div>
</div>
<div className="pt-2 border-t border-border/50">
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">Location Details</div>
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">
Location Details
</div>
<div className="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Latitude</span>
<span className="font-medium tracking-tighter">{detection.latitude.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Latitude
</span>
<span className="font-medium tracking-tighter">
{detection.latitude.toFixed(6)}
</span>
</div>
<div className="space-y-0.5">
<span className="text-[9px] block text-muted-foreground/70 uppercase">Longitude</span>
<span className="font-medium tracking-tighter">{detection.longitude.toFixed(6)}</span>
<span className="text-[9px] block text-muted-foreground/70 uppercase">
Longitude
</span>
<span className="font-medium tracking-tighter">
{detection.longitude.toFixed(6)}
</span>
</div>
</div>
</div>

View File

@@ -25,9 +25,15 @@ export function ModeToggle() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -1,6 +1,13 @@
'use client';
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react';
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
LogOut,
Sparkles,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -118,15 +125,15 @@ export function NavUser({
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
handleLogout();
}}
disabled={isLoggingOut}
>
<LogOut />
{isLoggingOut ? 'Logging out' : 'Log out'}
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
handleLogout();
}}
disabled={isLoggingOut}
>
<LogOut />
{isLoggingOut ? 'Logging out' : 'Log out'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -11,15 +11,20 @@ interface PageHeaderProps {
actions?: React.ReactNode;
}
export function PageHeader({ title, description, icon: Icon, children, actions }: PageHeaderProps) {
export function PageHeader({
title,
description,
icon: Icon,
children,
actions,
}: PageHeaderProps) {
return (
<Reveal direction="down" className="w-full">
<div className="flex items-center justify-between w-full">
<div className="flex flex-col">
<h1>{title}</h1>
<p className="text-muted-foreground opacity-80">{description}</p>
</div>
<div className="flex flex-col">
<h1>{title}</h1>
<p className="text-muted-foreground opacity-80">{description}</p>
</div>
{actions && <div className="flex items-center gap-4">{actions}</div>}
</div>
</Reveal>

View File

@@ -3,7 +3,9 @@
import { CheckboxTree } from '@/components/ui/tree';
import type { PermissionTreeItem, PermissionTreeNode } from '@/types';
export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeItem[] {
export function mapPermissionTree(
nodes: PermissionTreeNode[],
): PermissionTreeItem[] {
return nodes.map((node) => ({
id: node.id,
key: node.slug || node.name,
@@ -15,14 +17,19 @@ export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeIt
}));
}
export function collectDefaultPermissionIds(nodes: PermissionTreeItem[]): number[] {
export function collectDefaultPermissionIds(
nodes: PermissionTreeItem[],
): number[] {
return nodes.flatMap((node) => [
...(node.isGrantedByDefault ? [node.id] : []),
...collectDefaultPermissionIds(node.children),
]);
}
export function collectPermissionIdsByKeys(nodes: PermissionTreeItem[], keys: string[]): number[] {
export function collectPermissionIdsByKeys(
nodes: PermissionTreeItem[],
keys: string[],
): number[] {
const selectedKeys = new Set(keys);
return nodes.flatMap((node) => [
...(selectedKeys.has(node.key) ? [node.id] : []),

View File

@@ -1,7 +1,13 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
@@ -12,8 +18,17 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Loader2, Check, ArrowUp, ArrowDown } from 'lucide-react';
import { projectService, packageService, chainageService } from '@/services/api';
import { Project, Package as PackageType, Chainage, SessionContext } from '@/types';
import {
projectService,
packageService,
chainageService,
} from '@/services/api';
import {
Project,
Package as PackageType,
Chainage,
SessionContext,
} from '@/types';
import { cn } from '@/lib/utils';
type ProjectSelectionSectionProps = {
@@ -26,21 +41,27 @@ type ProjectSelectionSectionProps = {
let globalProjectsCache: Project[] | null = null;
let projectsPromise: Promise<Project[]> | null = null;
export function ProjectSelectionSection({
onSelectionComplete,
export function ProjectSelectionSection({
onSelectionComplete,
onSelectionChange,
asStep = false,
hideButton = false
hideButton = false,
}: ProjectSelectionSectionProps) {
// Data states
const [projects, setProjects] = useState<Project[]>(globalProjectsCache || []);
const [projects, setProjects] = useState<Project[]>(
globalProjectsCache || [],
);
const [packages, setPackages] = useState<PackageType[]>([]);
const [chainages, setChainages] = useState<Chainage[]>([]);
// Selection states
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(null);
const [selectedChainage, setSelectedChainage] = useState<Chainage | null>(null);
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(
null,
);
const [selectedChainage, setSelectedChainage] = useState<Chainage | null>(
null,
);
// Loading states
const [loadingProjects, setLoadingProjects] = useState(!globalProjectsCache);
@@ -53,7 +74,7 @@ export function ProjectSelectionSection({
// Load projects on mount
useEffect(() => {
let isMounted = true;
const loadProjects = async () => {
// Use cache if we already have it
if (globalProjectsCache) {
@@ -66,13 +87,16 @@ export function ProjectSelectionSection({
// If a fetch is already in flight, reuse that promise
if (!projectsPromise) {
projectsPromise = projectService.getProjects().then(data => {
globalProjectsCache = data.items;
return data.items;
}).catch(err => {
projectsPromise = null; // Reset on error to allow retry
throw err;
});
projectsPromise = projectService
.getProjects()
.then((data) => {
globalProjectsCache = data.items;
return data.items;
})
.catch((err) => {
projectsPromise = null; // Reset on error to allow retry
throw err;
});
}
try {
@@ -93,7 +117,9 @@ export function ProjectSelectionSection({
};
loadProjects();
return () => { isMounted = false; };
return () => {
isMounted = false;
};
}, []);
// Load packages when project changes
@@ -112,7 +138,9 @@ export function ProjectSelectionSection({
setSelectedPackage(null);
setSelectedChainage(null);
setChainages([]);
const data = await packageService.getPackagesByProject(selectedProject.id);
const data = await packageService.getPackagesByProject(
selectedProject.id,
);
if (isMounted) {
setPackages(data.items);
}
@@ -128,7 +156,9 @@ export function ProjectSelectionSection({
}
};
loadPackages();
return () => { isMounted = false; };
return () => {
isMounted = false;
};
}, [selectedProject]);
// Load chainages when package changes
@@ -145,7 +175,9 @@ export function ProjectSelectionSection({
setLoadingChainages(true);
setError(null);
setSelectedChainage(null);
const data = await chainageService.getChainagesByPackage(selectedPackage.id);
const data = await chainageService.getChainagesByPackage(
selectedPackage.id,
);
if (isMounted) {
setChainages(data.items);
}
@@ -161,7 +193,9 @@ export function ProjectSelectionSection({
}
};
loadChainages();
return () => { isMounted = false; };
return () => {
isMounted = false;
};
}, [selectedPackage]);
const handleProjectChange = (projectId: string) => {
@@ -185,13 +219,14 @@ export function ProjectSelectionSection({
// Handle change reporting
useEffect(() => {
if (onSelectionChange) {
const currentIds = selectedProject && selectedPackage && selectedChainage
? `${selectedProject.id}-${selectedPackage.id}-${selectedChainage.id}`
: null;
const currentIds =
selectedProject && selectedPackage && selectedChainage
? `${selectedProject.id}-${selectedPackage.id}-${selectedChainage.id}`
: null;
if (currentIds !== lastReportedIdRef.current) {
lastReportedIdRef.current = currentIds;
if (selectedProject && selectedPackage && selectedChainage) {
onSelectionChange({
projectId: selectedProject.id,
@@ -210,7 +245,12 @@ export function ProjectSelectionSection({
}, [selectedProject, selectedPackage, selectedChainage, onSelectionChange]);
const handleProceed = () => {
if (selectedProject && selectedPackage && selectedChainage && onSelectionComplete) {
if (
selectedProject &&
selectedPackage &&
selectedChainage &&
onSelectionComplete
) {
onSelectionComplete({
projectId: selectedProject.id,
projectName: selectedProject.name,
@@ -228,8 +268,18 @@ export function ProjectSelectionSection({
// Step status helpers
const getStepStatus = (step: number) => {
if (step === 1) return selectedProject ? 'completed' : 'active';
if (step === 2) return selectedPackage ? 'completed' : selectedProject ? 'active' : 'pending';
if (step === 3) return selectedChainage ? 'completed' : selectedPackage ? 'active' : 'pending';
if (step === 2)
return selectedPackage
? 'completed'
: selectedProject
? 'active'
: 'pending';
if (step === 3)
return selectedChainage
? 'completed'
: selectedPackage
? 'active'
: 'pending';
return 'pending';
};
@@ -291,7 +341,9 @@ export function ProjectSelectionSection({
</div>
) : (
<SelectValue
placeholder={selectedProject ? 'Select package' : 'Select project first'}
placeholder={
selectedProject ? 'Select package' : 'Select project first'
}
/>
)}
</SelectTrigger>
@@ -323,7 +375,9 @@ export function ProjectSelectionSection({
</div>
) : (
<SelectValue
placeholder={selectedPackage ? 'Select segment' : 'Select package first'}
placeholder={
selectedPackage ? 'Select segment' : 'Select package first'
}
/>
)}
</SelectTrigger>
@@ -331,9 +385,13 @@ export function ProjectSelectionSection({
{chainages.map((chn) => (
<SelectItem key={chn.id} value={chn.id}>
<div className="flex flex-col gap-0.5 py-0.5">
<span className="font-semibold text-sm">{chn.segment_name}</span>
<span className="font-semibold text-sm">
{chn.segment_name}
</span>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground font-medium">
<span>{chn.chainage_start_km}-{chn.chainage_end_km} km</span>
<span>
{chn.chainage_start_km}-{chn.chainage_end_km} km
</span>
<span className="h-2.5 w-px bg-border" />
<span className="flex items-center gap-1 uppercase">
{chn.direction === 'UP' ? (
@@ -399,9 +457,12 @@ export function ProjectSelectionSection({
<CardHeader className="pb-6">
<div className="flex flex-col gap-6">
<div>
<CardTitle className="text-2xl font-bold">Select Project Segment</CardTitle>
<CardTitle className="text-2xl font-bold">
Select Project Segment
</CardTitle>
<CardDescription className="mt-2 text-base">
Select Project, Package & Segment to begin intelligent road analysis.
Select Project, Package & Segment to begin intelligent road
analysis.
</CardDescription>
</div>
@@ -423,12 +484,18 @@ export function ProjectSelectionSection({
: 'bg-muted text-muted-foreground',
)}
>
{status === 'completed' ? <Check className="h-5 w-5" /> : step}
{status === 'completed' ? (
<Check className="h-5 w-5" />
) : (
step
)}
</div>
<span
className={cn(
'text-xs mt-2 font-medium',
status === 'pending' ? 'text-muted-foreground/60' : 'text-foreground',
status === 'pending'
? 'text-muted-foreground/60'
: 'text-foreground',
)}
>
{labels[index]}
@@ -438,7 +505,9 @@ export function ProjectSelectionSection({
<div
className={cn(
'w-16 h-0.5 mx-2 mb-6 rounded-full',
getStepStatus(step + 1) !== 'pending' ? 'bg-primary' : 'bg-border',
getStepStatus(step + 1) !== 'pending'
? 'bg-primary'
: 'bg-border',
)}
/>
)}
@@ -449,9 +518,7 @@ export function ProjectSelectionSection({
</div>
</CardHeader>
<CardContent className="pt-0">
{content}
</CardContent>
<CardContent className="pt-0">{content}</CardContent>
</Card>
);
}

View File

@@ -5,17 +5,26 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
@@ -32,7 +41,10 @@ function AvatarFallback({
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
);

View File

@@ -10,7 +10,8 @@ const badgeVariants = cva(
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
secondary:
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'bg-destructive text-white dark:bg-destructive/60 [a&]:hover:bg-destructive/90',
outline:
@@ -30,7 +31,8 @@ function Badge({
variant = 'default',
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'span';
return (

View File

@@ -62,7 +62,11 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
@@ -76,7 +80,10 @@ function BreadcrumbSeparator({ children, className, ...props }: React.ComponentP
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"

View File

@@ -10,11 +10,14 @@ const buttonVariants = cva(
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60',
destructive:
'bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground focus-visible:border-ring dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {

View File

@@ -52,14 +52,23 @@ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
@@ -72,4 +81,12 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -42,7 +42,9 @@ function ChartContainer({
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
@@ -59,14 +61,18 @@ function ChartContainer({
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
@@ -81,7 +87,9 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
@@ -135,7 +143,9 @@ function ChartTooltipContent({
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
@@ -144,7 +154,15 @@ function ChartTooltipContent({
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
@@ -287,29 +305,42 @@ function ChartLegendContent({
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {

View File

@@ -2,20 +2,32 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -31,7 +31,9 @@ function ComboboxTrigger({
className="pointer-events-none size-4 text-muted-foreground"
/>
);
const triggerRender = React.isValidElement<{ children?: React.ReactNode }>(render)
const triggerRender = React.isValidElement<{ children?: React.ReactNode }>(
render,
)
? React.cloneElement(render, {
children: (
<>
@@ -81,7 +83,10 @@ function ComboboxInput({
}) {
return (
<InputGroup className={cn('w-auto', className)}>
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
@@ -155,7 +160,11 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
);
}
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
@@ -180,11 +189,18 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
);
}
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
@@ -198,7 +214,9 @@ function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Pro
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
@@ -214,7 +232,10 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
);
}
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
@@ -227,7 +248,8 @@ function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
@@ -271,7 +293,11 @@ function ComboboxChip({
);
}
function ComboboxChipsInput({ className, children, ...props }: ComboboxPrimitive.Input.Props) {
function ComboboxChipsInput({
className,
children,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"

View File

@@ -7,19 +7,27 @@ import { Dialog as DialogPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
@@ -99,7 +107,10 @@ function DialogFooter({
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
>
{children}
@@ -112,8 +123,17 @@ function DialogFooter({
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return <DialogPrimitive.Title data-slot="dialog-title" className={cn(className)} {...props} />;
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(className)}
{...props}
/>
);
}
function DialogDescription({

View File

@@ -1,15 +1,15 @@
"use client"
'use client';
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import * as React from 'react';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
);
}
function DropdownMenuTrigger({
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
)
);
}
function DropdownMenuContent({
@@ -42,13 +42,13 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@@ -56,17 +56,17 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
@@ -75,11 +75,11 @@ function DropdownMenuItem({
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
checked={checked}
{...props}
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -148,19 +148,19 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -170,32 +170,32 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -212,14 +212,14 @@ function DropdownMenuSubTrigger({
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
'z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
)
);
}
export {
@@ -254,4 +254,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};

View File

@@ -41,8 +41,10 @@ const inputGroupAddonVariants = cva(
{
variants: {
align: {
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'inline-start':
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end':
'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
'block-end':
@@ -77,19 +79,23 @@ function InputGroupAddon({
);
}
const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
const inputGroupButtonVariants = cva(
'flex items-center gap-2 text-sm shadow-none',
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
},
defaultVariants: {
size: 'xs',
},
});
);
function InputGroupButton({
className,
@@ -122,7 +128,10 @@ function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
@@ -135,7 +144,10 @@ function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>)
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"

View File

@@ -5,7 +5,10 @@ import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"

View File

@@ -1,5 +1,9 @@
import * as React from 'react';
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
@@ -16,7 +20,10 @@ function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
);
}
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="pagination-content"
@@ -35,7 +42,12 @@ type PaginationLinkProps = {
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>;
function PaginationLink({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) {
function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
@@ -53,7 +65,10 @@ function PaginationLink({ className, isActive, size = 'icon', ...props }: Pagina
);
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
@@ -67,7 +82,10 @@ function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof
);
}
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
@@ -81,7 +99,10 @@ function PaginationNext({ className, ...props }: React.ComponentProps<typeof Pag
);
}
function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden

View File

@@ -5,11 +5,15 @@ import { Popover as PopoverPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
@@ -35,7 +39,9 @@ function PopoverContent({
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
@@ -50,10 +56,19 @@ function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) {
}
function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) {
return <div data-slot="popover-title" className={cn('font-medium', className)} {...props} />;
return (
<div
data-slot="popover-title"
className={cn('font-medium', className)}
{...props}
/>
);
}
function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) {
function PopoverDescription({
className,
...props
}: React.ComponentProps<'p'>) {
return (
<p
data-slot="popover-description"

View File

@@ -13,7 +13,10 @@ function Progress({
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator

View File

@@ -39,8 +39,10 @@ function ScrollBar({
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}

View File

@@ -6,15 +6,21 @@ import { Select as SelectPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
@@ -81,7 +87,10 @@ function SelectContent({
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
@@ -138,7 +147,10 @@ function SelectScrollUpButton({
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
@@ -153,7 +165,10 @@ function SelectScrollDownButton({
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />

View File

@@ -10,15 +10,21 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
@@ -99,7 +105,10 @@ function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
);
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"

View File

@@ -18,7 +18,12 @@ import {
SheetTitle,
} from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
@@ -91,7 +96,10 @@ function SidebarProvider({
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
@@ -245,7 +253,11 @@ function Sidebar({
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
@@ -306,7 +318,10 @@ function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
);
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
@@ -339,7 +354,10 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
);
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
@@ -419,7 +437,10 @@ function SidebarGroupAction({
);
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
@@ -556,7 +577,10 @@ function SidebarMenuAction({
);
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
@@ -592,7 +616,12 @@ function SidebarMenuSkeleton({
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
@@ -621,7 +650,10 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
);
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"

View File

@@ -39,7 +39,11 @@ export function Stepper({ steps, activeStep, className }: StepperProps) {
: 'bg-background border-muted text-muted-foreground',
)}
>
{isCompleted ? <Check className="h-5 w-5" /> : <span>{index + 1}</span>}
{isCompleted ? (
<Check className="h-5 w-5" />
) : (
<span>{index + 1}</span>
)}
</div>
<div className="mt-4 text-center px-2">

View File

@@ -24,7 +24,13 @@ function Table({
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />;
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
@@ -41,7 +47,10 @@ function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
);
@@ -86,7 +95,10 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
);
}
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
@@ -96,4 +108,13 @@ function TableCaption({ className, ...props }: React.ComponentProps<'caption'>)
);
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import * as React from 'react';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
@@ -15,19 +15,19 @@ function TooltipProvider({
delayDuration={delayDuration}
{...props}
/>
)
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
@@ -42,8 +42,8 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
className,
)}
{...props}
>
@@ -51,7 +51,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -11,8 +11,13 @@ export interface TreeItem {
children: TreeItem[];
}
export function collectTreeItemIds<TItem extends TreeItem>(item: TItem): number[] {
return [item.id, ...item.children.flatMap((child) => collectTreeItemIds(child as TItem))];
export function collectTreeItemIds<TItem extends TreeItem>(
item: TItem,
): number[] {
return [
item.id,
...item.children.flatMap((child) => collectTreeItemIds(child as TItem)),
];
}
function TreeCheckbox({
@@ -71,7 +76,9 @@ function CheckboxTreeNode<TItem extends TreeItem>({
<button
type="button"
onClick={() => setIsExpanded((current) => !current)}
aria-label={isExpanded ? `Collapse ${item.label}` : `Expand ${item.label}`}
aria-label={
isExpanded ? `Collapse ${item.label}` : `Expand ${item.label}`
}
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<ChevronDown
@@ -87,12 +94,16 @@ function CheckboxTreeNode<TItem extends TreeItem>({
onChange={(isChecked) => onToggle(item, isChecked)}
/>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-sm font-medium leading-none">{item.label}</span>
<span className="truncate text-sm font-medium leading-none">
{item.label}
</span>
{renderMeta?.(item)}
</div>
</div>
{showDescription && item.description ? (
<p className="mt-1.5 pl-11 text-xs text-muted-foreground">{item.description}</p>
<p className="mt-1.5 pl-11 text-xs text-muted-foreground">
{item.description}
</p>
) : null}
</div>
{hasChildren && isExpanded ? (

View File

@@ -1,21 +1,44 @@
'use client';
import { useMemo, useState, useCallback, useRef, useReducer, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
useMemo,
useState,
useCallback,
useRef,
useReducer,
useEffect,
} from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Film } from 'lucide-react';
import { DetectionData, DetectionType, DetectionCounts, DetectionLogEntry } from '@/types';
import {
DetectionData,
DetectionType,
DetectionCounts,
DetectionLogEntry,
} from '@/types';
import { useGpsMap } from '@/hooks/use-gps-map';
import { useFrameDetectionMap } from '@/hooks/use-frame-detection-map';
import { useCumulativeCounts } from '@/hooks/use-cumulative-counts';
import { useVideoDetectionLoop } from '@/hooks/use-video-detection-loop';
import VideoCanvasPlayer, { VideoCanvasPlayerRef } from './video/video-canvas-player';
import VideoCanvasPlayer, {
VideoCanvasPlayerRef,
} from './video/video-canvas-player';
import DetectionStatsBar from './video/detection-stats-bar';
import DetectionLogs from './video/detection-logs';
import SummarySection from './video/summary-section';
import DetailedSummarySection from './video/detailed-summary-section';
import { getDetectionModeConfig, getEnabledDetectionTypes } from '@/constants/detectionModeConfig';
import {
getDetectionModeConfig,
getEnabledDetectionTypes,
} from '@/constants/detectionModeConfig';
type VideoPlayerSectionProps = {
data: DetectionData;
@@ -27,13 +50,20 @@ type VideoPlayerSectionProps = {
const MAX_LOGS = 50;
type LogAction = { type: 'ADD_LOG'; payload: DetectionLogEntry } | { type: 'CLEAR' };
type LogAction =
| { type: 'ADD_LOG'; payload: DetectionLogEntry }
| { type: 'CLEAR' };
function logsReducer(state: DetectionLogEntry[], action: LogAction): DetectionLogEntry[] {
function logsReducer(
state: DetectionLogEntry[],
action: LogAction,
): DetectionLogEntry[] {
switch (action.type) {
case 'ADD_LOG':
// Move to top if already exists, or just add to top
const filtered = state.filter((log) => log.frame !== action.payload.frame);
const filtered = state.filter(
(log) => log.frame !== action.payload.frame,
);
return [action.payload, ...filtered].slice(0, MAX_LOGS);
case 'CLEAR':
return [];
@@ -81,7 +111,8 @@ export default function VideoPlayerSection({
return counts as DetectionCounts;
}, [enabledTypes]);
const [currentFrameCounts, setCurrentFrameCounts] = useState<DetectionCounts>(initialCounts);
const [currentFrameCounts, setCurrentFrameCounts] =
useState<DetectionCounts>(initialCounts);
// Logs Reducer
const [logs, dispatchLogs] = useReducer(logsReducer, []);
@@ -95,10 +126,10 @@ export default function VideoPlayerSection({
// Synthesize frames from lists if not present (specifically for gemini_video)
// Other models like YOLO return data.frames directly
const framesMap = new Map<number, any>();
// Sort all detections by their first_detected_frame
const allDetections: any[] = [];
enabledTypes.forEach(type => {
enabledTypes.forEach((type) => {
const list = (data as any)[type.listKey];
if (list && Array.isArray(list)) {
list.forEach((item: any) => {
@@ -107,50 +138,57 @@ export default function VideoPlayerSection({
}
});
allDetections.sort((a, b) => (a.first_detected_frame || 0) - (b.first_detected_frame || 0));
allDetections.sort(
(a, b) => (a.first_detected_frame || 0) - (b.first_detected_frame || 0),
);
// Current counts for sticky stats
const currentCounts: Record<string, number> = {};
enabledTypes.forEach(t => { currentCounts[t.frameCountKey] = 0; });
enabledTypes.forEach((t) => {
currentCounts[t.frameCountKey] = 0;
});
allDetections.forEach(det => {
allDetections.forEach((det) => {
const frameId = det.first_detected_frame || det.frame_number || 0;
// Spread detection across multiple frames so it stays visible (persistence)
// Gemini detections are sparse, so showing them for ~1 second (30 frames) helps
const persistenceFrames = 30;
const persistenceFrames = 30;
for (let i = 0; i < persistenceFrames; i++) {
const targetFrame = frameId + i;
if (!framesMap.has(targetFrame)) {
framesMap.set(targetFrame, { frame_id: targetFrame, detections: [] });
}
const frameData = framesMap.get(targetFrame);
// Update cumulative counts only on the first detected frame
if (i === 0) {
const typeConfig = enabledTypes.find(t => t.id === det._detType);
const typeConfig = enabledTypes.find((t) => t.id === det._detType);
if (typeConfig) {
currentCounts[typeConfig.frameCountKey] = (currentCounts[typeConfig.frameCountKey] || 0) + 1;
currentCounts[typeConfig.frameCountKey] =
(currentCounts[typeConfig.frameCountKey] || 0) + 1;
}
}
const countCopy = { ...currentCounts };
frameData.detections.push({
...det,
type: det.type || det._detType,
detection_id: det.detection_id || det.id,
count: countCopy
count: countCopy,
});
}
});
return {
...data,
frames: Array.from(framesMap.values()).sort((a, b) => (a.frame_id || 0) - (b.frame_id || 0))
frames: Array.from(framesMap.values()).sort(
(a, b) => (a.frame_id || 0) - (b.frame_id || 0),
),
};
}, [data, enabledTypes]);
@@ -160,7 +198,9 @@ export default function VideoPlayerSection({
normalizedData,
detectionType,
);
const { getStickyCounts, sortedFrameIndices } = useCumulativeCounts(normalizedData.frames || []);
const { getStickyCounts, sortedFrameIndices } = useCumulativeCounts(
normalizedData.frames || [],
);
// Memoized video URL
const videoUrl = useMemo(() => {
@@ -193,7 +233,8 @@ export default function VideoPlayerSection({
// Logging logic
if (dets && dets.length > 0) {
const firstDetId = dets[0].pothole_id ?? dets[0].signboard_id ?? dets[0].detection_id;
const firstDetId =
dets[0].pothole_id ?? dets[0].signboard_id ?? dets[0].detection_id;
const coords = gpsMap.get(firstDetId);
if (coords) {
@@ -211,14 +252,24 @@ export default function VideoPlayerSection({
type: d.type || d._detType,
bbox: d.bbox,
confidence: d.confidence,
latitude: gpsMap.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lat,
longitude: gpsMap.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lng,
latitude: gpsMap.get(
d.pothole_id ?? d.signboard_id ?? d.detection_id,
)?.lat,
longitude: gpsMap.get(
d.pothole_id ?? d.signboard_id ?? d.detection_id,
)?.lng,
})),
},
});
}
},
[data.video_info.fps, getNearestDetections, getStickyCounts, gpsMap, sortedDetectionIndices],
[
data.video_info.fps,
getNearestDetections,
getStickyCounts,
gpsMap,
sortedDetectionIndices,
],
);
// Playback loop hook
@@ -248,9 +299,14 @@ export default function VideoPlayerSection({
const currentFrame = Math.round(video.currentTime * fps);
// Snap to the next available frame index that has detections
const nextDetectionFrame = sortedDetectionIndices.find((f) => f >= currentFrame);
const nextDetectionFrame = sortedDetectionIndices.find(
(f) => f >= currentFrame,
);
if (nextDetectionFrame !== undefined && nextDetectionFrame !== currentFrame) {
if (
nextDetectionFrame !== undefined &&
nextDetectionFrame !== currentFrame
) {
video.currentTime = nextDetectionFrame / fps;
return;
}
@@ -276,7 +332,9 @@ export default function VideoPlayerSection({
<Film className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg font-bold">Detection Playback</CardTitle>
<CardTitle className="text-lg font-bold">
Detection Playback
</CardTitle>
<CardDescription className="text-xs">
Real-time object detection analysis
</CardDescription>
@@ -297,7 +355,10 @@ export default function VideoPlayerSection({
onLoadedData={handleLoadedData}
onEnded={handleVideoEnded}
onSeeked={handleVideoSeeked}
currentDetections={getNearestDetections(currentFrame, sortedDetectionIndices) || []}
currentDetections={
getNearestDetections(currentFrame, sortedDetectionIndices) ||
[]
}
/>
<DetectionStatsBar
@@ -316,7 +377,11 @@ export default function VideoPlayerSection({
{showSummary && (
<div className="space-y-6 pt-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<SummarySection data={data} show={showSummary} detectionType={detectionType} />
<SummarySection
data={data}
show={showSummary}
detectionType={detectionType}
/>
<DetailedSummarySection
projectId={projectId || ''}
videoId={videoId}

View File

@@ -3,7 +3,13 @@
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { Map as MapIcon, Activity } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -12,7 +18,9 @@ import { projectSummaryService } from '@/services/api';
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
// Dynamically import MapModal with SSR disabled (Leaflet requires window object)
const MapModal = dynamic(() => import('@/components/map-modal'), { ssr: false });
const MapModal = dynamic(() => import('@/components/map-modal'), {
ssr: false,
});
interface DetailedSummarySectionProps {
projectId: string;
@@ -27,7 +35,9 @@ const DetailedSummarySection = ({
show,
detectionType,
}: DetailedSummarySectionProps) => {
const [summaryData, setSummaryData] = useState<ChainageSummaryData | null>(null);
const [summaryData, setSummaryData] = useState<ChainageSummaryData | null>(
null,
);
const [loading, setLoading] = useState(false);
const [showMap, setShowMap] = useState(false);
@@ -37,10 +47,11 @@ const DetailedSummarySection = ({
const fetchSummary = async () => {
setLoading(true);
try {
const data = await projectSummaryService.getProjectSummaryByVideo<ChainageSummaryData>(
projectId,
videoId,
);
const data =
await projectSummaryService.getProjectSummaryByVideo<ChainageSummaryData>(
projectId,
videoId,
);
setSummaryData(data);
} catch (err) {
console.error('Failed to fetch summary:', err);
@@ -63,13 +74,17 @@ const DetailedSummarySection = ({
packageName: string;
}> = [];
Object.entries(summaryData.packages || {}).forEach(([packageName, packageData]) => {
Object.entries(packageData?.chainages || {}).forEach(([chainageName, chainageData]) => {
chainageData?.detections?.forEach((detection) => {
allDetections.push({ detection, chainageName, packageName });
});
});
});
Object.entries(summaryData.packages || {}).forEach(
([packageName, packageData]) => {
Object.entries(packageData?.chainages || {}).forEach(
([chainageName, chainageData]) => {
chainageData?.detections?.forEach((detection) => {
allDetections.push({ detection, chainageName, packageName });
});
},
);
},
);
return (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
@@ -82,10 +97,17 @@ const DetailedSummarySection = ({
</div>
<div>
<CardTitle className="text-base font-bold">Segments</CardTitle>
<CardDescription className="text-xs">{modeConfig.label} detected</CardDescription>
<CardDescription className="text-xs">
{modeConfig.label} detected
</CardDescription>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => setShowMap(true)} className="gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowMap(true)}
className="gap-2"
>
<MapIcon className="h-4 w-4" />
Map
</Button>
@@ -100,7 +122,9 @@ const DetailedSummarySection = ({
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Project
</span>
<span className="text-sm font-bold">{summaryData.project.name}</span>
<span className="text-sm font-bold">
{summaryData.project.name}
</span>
</div>
{summaryData.project.corridor_name && (
<div className="flex flex-col">
@@ -115,26 +139,32 @@ const DetailedSummarySection = ({
</div>
{/* Packages and Chainages */}
{Object.entries(summaryData.packages).map(([packageName, packageData]) => (
<div key={packageData.package_id} className="space-y-2">
<div className="text-xs font-bold text-muted-foreground truncate">
{packageName}
{Object.entries(summaryData.packages).map(
([packageName, packageData]) => (
<div key={packageData.package_id} className="space-y-2">
<div className="text-xs font-bold text-muted-foreground truncate">
{packageName}
</div>
<div className="pl-2 space-y-2 border-l-2 border-muted">
{Object.entries(packageData.chainages).map(
([chainageName, chainageData]) => (
<div
key={chainageData.chainage_id}
className="text-xs p-3 rounded-md bg-muted/50 flex items-center justify-between gap-4"
>
<div className="font-semibold truncate">
{chainageName}
</div>
<div className="text-[10px] bg-background px-2 py-0.5 rounded border font-bold">
{chainageData.detection_count}
</div>
</div>
),
)}
</div>
</div>
<div className="pl-2 space-y-2 border-l-2 border-muted">
{Object.entries(packageData.chainages).map(([chainageName, chainageData]) => (
<div
key={chainageData.chainage_id}
className="text-xs p-3 rounded-md bg-muted/50 flex items-center justify-between gap-4"
>
<div className="font-semibold truncate">{chainageName}</div>
<div className="text-[10px] bg-background px-2 py-0.5 rounded border font-bold">
{chainageData.detection_count}
</div>
</div>
))}
</div>
</div>
))}
),
)}
</div>
</ScrollArea>
</CardContent>
@@ -147,7 +177,9 @@ const DetailedSummarySection = ({
<Activity className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base font-bold">All Detections</CardTitle>
<CardTitle className="text-base font-bold">
All Detections
</CardTitle>
<CardDescription className="text-xs">
Complete list with GPS coordinates
</CardDescription>
@@ -164,7 +196,11 @@ const DetailedSummarySection = ({
>
<div className="flex items-center justify-between mb-2">
<span className="font-bold">
{(detection.class || detection.type || '').replace(/_/g, ' ')} #{detection.id}
{(detection.class || detection.type || '').replace(
/_/g,
' ',
)}{' '}
#{detection.id}
</span>
<Badge variant="outline" className="text-[10px] font-mono">
Frame {detection.frame_number}
@@ -172,7 +208,10 @@ const DetailedSummarySection = ({
</div>
<div className="grid grid-cols-2 gap-2 text-[10px] text-muted-foreground">
<div className="truncate">
Segment: <span className="text-foreground font-medium">{chainageName}</span>
Segment:{' '}
<span className="text-foreground font-medium">
{chainageName}
</span>
</div>
<div>
Confidence:{' '}

View File

@@ -39,29 +39,36 @@ const DetectionLogs = ({ logs, onSeek }: DetectionLogsProps) => {
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold">Frame: {log.frame}</span>
<span className="text-xs text-muted-foreground/80">{log.videoTime}</span>
<span className="text-xs text-muted-foreground/80">
{log.videoTime}
</span>
</div>
<div className="space-y-4">
{log.detections.map((d, j) => {
const typeId = (d.type || '').toLowerCase();
const typeConfig = DETECTION_TYPES[typeId];
const label = typeConfig ? typeConfig.label : typeId.replace(/_/g, ' ');
const label = typeConfig
? typeConfig.label
: typeId.replace(/_/g, ' ');
return (
<div key={`${d.id}-${j}`} className="space-y-1">
<div className="text-[13px] font-bold">
{label} ID: {d.id} | Confidence: {(d.confidence * 100).toFixed(1)}%
{label} ID: {d.id} | Confidence:{' '}
{(d.confidence * 100).toFixed(1)}%
</div>
{d.bbox && (
<div className="text-[12px] text-muted-foreground/80 font-medium">
Coordinates: ({Math.round(d.bbox.x1)}, {Math.round(d.bbox.y1)}){' '}
<span className="text-muted-foreground/40"></span> (
{Math.round(d.bbox.x2)}, {Math.round(d.bbox.y2)})
Coordinates: ({Math.round(d.bbox.x1)},{' '}
{Math.round(d.bbox.y1)}){' '}
<span className="text-muted-foreground/40"></span>{' '}
({Math.round(d.bbox.x2)}, {Math.round(d.bbox.y2)})
</div>
)}
{d.latitude && (
<div className="text-[12px] text-muted-foreground/80 font-medium">
GPS: {d.latitude.toFixed(8)}, {d.longitude?.toFixed(8)}
GPS: {d.latitude.toFixed(8)},{' '}
{d.longitude?.toFixed(8)}
</div>
)}
</div>

View File

@@ -26,7 +26,10 @@ const DetectionStatsBar = memo(
<div className="flex flex-wrap items-center gap-x-8 gap-y-3 py-3 px-6 bg-card border rounded-lg">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
{enabledTypes.map((type) => (
<div key={type.id} className="flex items-center gap-1.5 whitespace-nowrap">
<div
key={type.id}
className="flex items-center gap-1.5 whitespace-nowrap"
>
<span
className="text-[11px] font-bold uppercase tracking-tight"
style={{ color: type.color }}

View File

@@ -1,10 +1,27 @@
'use client';
import { Target, SignpostBig, AlertTriangle, Activity, Gauge, Monitor, Film } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Target,
SignpostBig,
AlertTriangle,
Activity,
Gauge,
Monitor,
Film,
} from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { DetectionData } from '@/types';
import { cn } from '@/lib/utils';
import { getDetectionModeConfig, getEnabledDetectionTypes } from '@/constants/detectionModeConfig';
import {
getDetectionModeConfig,
getEnabledDetectionTypes,
} from '@/constants/detectionModeConfig';
interface SummarySectionProps {
data: DetectionData;
@@ -74,7 +91,9 @@ const SummarySection = ({ data, show, detectionType }: SummarySectionProps) => {
</div>
<div>
<CardTitle className="text-base font-bold">Quick Stats</CardTitle>
<CardDescription className="text-xs">Detection analysis overview</CardDescription>
<CardDescription className="text-xs">
Detection analysis overview
</CardDescription>
</div>
</div>
</CardHeader>
@@ -90,12 +109,20 @@ const SummarySection = ({ data, show, detectionType }: SummarySectionProps) => {
<div className={cn('p-2 rounded-md mb-2', stat.bgColor)}>
<Icon
className={cn('h-5 w-5', stat.color)}
style={(stat as any).customColor ? { color: (stat as any).customColor } : {}}
style={
(stat as any).customColor
? { color: (stat as any).customColor }
: {}
}
/>
</div>
<div
className={cn('text-xl font-bold', stat.color)}
style={(stat as any).customColor ? { color: (stat as any).customColor } : {}}
style={
(stat as any).customColor
? { color: (stat as any).customColor }
: {}
}
>
{stat.value}
</div>

View File

@@ -1,6 +1,12 @@
'use client';
import { useRef, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
import {
useRef,
useEffect,
forwardRef,
useImperativeHandle,
useCallback,
} from 'react';
import { Badge } from '@/components/ui/badge';
import { drawBoundingBoxes } from '@/utils/canvas-drawing';
@@ -22,7 +28,10 @@ export interface VideoCanvasPlayerRef {
drawDetections: (detections: any[]) => void;
}
const VideoCanvasPlayer = forwardRef<VideoCanvasPlayerRef, VideoCanvasPlayerProps>(
const VideoCanvasPlayer = forwardRef<
VideoCanvasPlayerRef,
VideoCanvasPlayerProps
>(
(
{
videoRef,