Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ body:
attributes:
label: ALPR Community version
description: Find this under Settings → Release. Include the source revision there when reporting an installation or update problem.
placeholder: v0.1.57
placeholder: v0.1.58
validations:
required: true

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.1.58 — 2026-10-21

- Resize Recognition Feed and Plate Database columns using draggable header dividers or the keyboard, with separate saved widths per browser and a reset option.
- Keep narrow text readable through ellipses and hover text, protect minimum control widths, and allow horizontal scrolling.
- Start Actions as a compact menu with full labels; widening the column restores the individual action icons.
- Update User Guide 3.26 and current installation/update examples.

## 0.1.57 — 2026-10-21

- Enable batch correction and recurring OCR-misread aliases by default for administrators; retain the batch preview and unchanged-plate safeguards. Operators retain single-read correction.
Expand Down
5 changes: 5 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,8 @@
@apply bg-background text-foreground;
}
}

/* Fixed column widths keep long content inside its own cell. */
.resizable-plate-table td {
overflow: hidden;
}
8 changes: 4 additions & 4 deletions components/PlateStateCountryField.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { getPlateStateCountries, updatePlateStateCountry } from "@/app/actions";

export default function PlateStateCountryField({ plateNumber, value, onSaved }) {
export default function PlateStateCountryField({ plateNumber, value, onSaved, compact = false }) {
const { can } = useAccess();
const inputId = useId();
const optionsId = useId();
Expand All @@ -37,7 +37,7 @@ export default function PlateStateCountryField({ plateNumber, value, onSaved })
return () => { active = false; };
}, [open]);

if (!can("plate.review")) return <span className="whitespace-pre-wrap break-words">{savedValue || "—"}</span>;
if (!can("plate.review")) return <span className={compact ? "block truncate" : "whitespace-pre-wrap break-words"} title={savedValue || ""}>{savedValue || "—"}</span>;

const save = async (event) => {
event.preventDefault();
Expand All @@ -58,8 +58,8 @@ export default function PlateStateCountryField({ plateNumber, value, onSaved })
};

return <>
<Button type="button" variant="ghost" size="sm" className="h-auto max-w-48 justify-start gap-2 whitespace-normal px-1 py-1 text-left font-normal" aria-label={"Edit State/Country for " + plateNumber} onClick={() => { setDraft(savedValue); setError(""); setOpen(true); }}>
<span className="min-w-0 break-words">{savedValue || "Add State/Country"}</span><Pencil className="h-3 w-3 shrink-0" />
<Button type="button" variant="ghost" size="sm" className={compact ? "h-auto w-full max-w-full justify-start gap-2 whitespace-nowrap px-1 py-1 text-left font-normal" : "h-auto max-w-48 justify-start gap-2 whitespace-normal px-1 py-1 text-left font-normal"} title={savedValue || "Add State/Country"} aria-label={"Edit State/Country for " + plateNumber} onClick={() => { setDraft(savedValue); setError(""); setOpen(true); }}>
<span className={compact ? "min-w-0 truncate" : "min-w-0 break-words"}>{savedValue || "Add State/Country"}</span><Pencil className="h-3 w-3 shrink-0" />
</Button>
<Dialog open={open} onOpenChange={(next) => { if (!saving) setOpen(next); }}>
<DialogContent className="sm:max-w-md">
Expand Down
297 changes: 61 additions & 236 deletions components/PlateTable.jsx

Large diffs are not rendered by default.

124 changes: 124 additions & 0 deletions components/ResizablePlateTable.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"use client";

import { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
import { RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Table, TableHead } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { tableColumns, clampColumnWidth, columnWidthForKey, readColumnWidths, writeColumnWidths } from "@/lib/table-column-widths.mjs";

const ColumnContext = createContext(null);

export default function ResizablePlateTable({ surface, showStateCountry = false, children }) {
const columns = useMemo(() => tableColumns(surface, showStateCountry), [surface, showStateCountry]);
const [widths, setWidths] = useState(null);
const [resizing, setResizing] = useState(false);
const tableRef = useRef(null);
const widthsRef = useRef(null);
const drag = useRef(null);
const instructionsId = useId();

useEffect(() => {
const saved = readColumnWidths(surface);
widthsRef.current = saved;
setWidths(saved);
}, [surface]);

const update = (next, persist = false) => {
widthsRef.current = next;
setWidths(next);
if (persist) writeColumnWidths(surface, next);
};

// Freeze the rendered widths before changing one column so adjacent columns
// do not redistribute the available space during a drag.
const measure = () => {
const next = { ...widthsRef.current };
for (const column of columns) {
const heading = tableRef.current?.querySelector(`[data-column-id="${column.id}"]`);
const measured = heading?.getBoundingClientRect().width;
next[column.id] = clampColumnWidth(column, measured || next[column.id] || column.width);
}
return next;
};

const finish = (event) => {
if (drag.current?.pointerId !== event.pointerId) return;
drag.current = null;
setResizing(false);
writeColumnWidths(surface, widthsRef.current);
};

const context = {
columns, widths, instructionsId,
start(column, event) {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const base = measure();
drag.current = { pointerId: event.pointerId, x: event.clientX, column, base };
event.currentTarget.setPointerCapture(event.pointerId);
setResizing(true);
update(base);
},
move(event) {
const current = drag.current;
if (!current || current.pointerId !== event.pointerId) return;
const { column, base, x } = current;
update({ ...base, [column.id]: clampColumnWidth(column, base[column.id] + event.clientX - x) });
},
finish,
key(column, event) {
const base = measure();
const width = columnWidthForKey(column, base[column.id], event.key, event.shiftKey);
if (width === null) return;
event.preventDefault();
event.stopPropagation();
update({ ...base, [column.id]: width }, true);
},
reset(column) {
update({ ...measure(), [column.id]: column.width }, true);
},
};
const totalWidth = columns.reduce((sum, column) => sum + (widths?.[column.id] ?? column.width), 0);

return <ColumnContext.Provider value={context}>
<div className={cn("min-w-0 max-w-full", resizing && "select-none")}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-1.5">
<p id={instructionsId} className="text-xs text-muted-foreground">
Drag header dividers to resize. <span className="sr-only">Focus a divider and use Left or Right arrows; hold Shift for larger steps. Home selects the minimum width and End the maximum. Double-click a divider to reset its column.</span>
</p>
<Button type="button" variant="ghost" size="sm" className="h-7 gap-1.5 text-xs" onClick={() => update(null, true)}>
<RotateCcw className="h-3 w-3" />Reset widths
</Button>
</div>
<Table ref={tableRef} className="resizable-plate-table table-fixed" style={{ width: totalWidth, minWidth: widths ? undefined : "100%" }}>
<colgroup>{columns.map((column) => <col key={column.id} style={{ width: widths?.[column.id] ?? column.width }} />)}</colgroup>
{children}
</Table>
</div>
</ColumnContext.Provider>;
}

export function ResizableTableHead({ columnId, className, children }) {
const context = useContext(ColumnContext);
const column = context.columns.find((item) => item.id === columnId);
const width = context.widths?.[columnId] ?? column.width;
return <TableHead scope="col" data-column-id={columnId} className={cn("relative pr-4", className)}>
<div className="min-w-0 max-w-full truncate" title={column.label}>{children}</div>
<span
role="separator" aria-orientation="vertical" aria-label={`Resize ${column.label} column`}
aria-describedby={context.instructionsId} aria-valuemin={column.min} aria-valuemax={1000} aria-valuenow={width}
tabIndex={0} title={`Drag to resize ${column.label}. Double-click to reset.`}
className="absolute inset-y-0 right-0 z-10 flex w-3 cursor-col-resize touch-none items-center justify-center rounded-sm hover:bg-blue-500/20 focus-visible:bg-blue-500/20 focus-visible:outline-2 focus-visible:outline-blue-500"
onPointerDown={(event) => context.start(column, event)} onPointerMove={context.move}
onPointerUp={context.finish} onPointerCancel={context.finish} onLostPointerCapture={context.finish}
onKeyDown={(event) => context.key(column, event)}
onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => { event.stopPropagation(); context.reset(column); }}
><span className="h-5 w-px bg-zinc-300 dark:bg-zinc-600" /></span>
</TableHead>;
}

export function TableValue({ children, className }) {
return <span className={cn("block min-w-0 truncate", className)} title={(typeof children === "string" || typeof children === "number") ? String(children) : undefined}>{children}</span>;
}
59 changes: 59 additions & 0 deletions components/TableRowActions.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuPortal } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";

function ActionLabel({ action }) {
const Icon = action.icon;
return <><Icon className={cn("h-4 w-4 shrink-0", action.iconClassName)} /><span>{action.label}</span></>;
}

function TagItems({ items }) {
return items.length ? items.map((item) => <DropdownMenuItem key={item.id} onSelect={item.onSelect}>
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: item.color }} />{item.label}
</DropdownMenuItem>) : <DropdownMenuItem disabled>No tags available</DropdownMenuItem>;
}

export default function TableRowActions({ plateNumber, actions }) {
const container = useRef(null);
const [availableWidth, setAvailableWidth] = useState(0);
const visibleActions = actions.filter(Boolean);
const expanded = availableWidth >= visibleActions.length * 34 - 2;

useEffect(() => {
const observer = new ResizeObserver(([entry]) => setAvailableWidth(entry.contentRect.width));
observer.observe(container.current);
return () => observer.disconnect();
}, []);

return <div ref={container} className="flex w-full min-w-0 justify-end" data-action-layout={expanded ? "icons" : "menu"}>
{expanded ? <div className="flex items-center gap-0.5">
{visibleActions.map((action) => {
const Icon = action.icon;
const className = cn("h-8 w-8 shrink-0 p-0", (action.destructive || action.active) && "text-red-500 hover:text-red-700");
const iconButton = action.href
? <Button variant="ghost" size="icon" className={className} asChild><Link href={action.href} aria-label={action.ariaLabel}><Icon className={cn("h-4 w-4", action.iconClassName)} /></Link></Button>
: <Button type="button" variant="ghost" size="icon" className={className} aria-label={action.ariaLabel} disabled={action.disabled} onClick={action.items ? undefined : action.onSelect}><Icon className={cn("h-4 w-4", action.iconClassName)} /></Button>;
return action.items ? <DropdownMenu key={action.id}>
<Tooltip><TooltipTrigger asChild><DropdownMenuTrigger asChild>{iconButton}</DropdownMenuTrigger></TooltipTrigger><TooltipContent>{action.label}</TooltipContent></Tooltip>
<DropdownMenuContent align="end"><TagItems items={action.items} /></DropdownMenuContent>
</DropdownMenu> : <Tooltip key={action.id}><TooltipTrigger asChild>{action.disabled ? <span tabIndex={0} aria-label={action.label}>{iconButton}</span> : iconButton}</TooltipTrigger><TooltipContent>{action.label}</TooltipContent></Tooltip>;
})}
</div> : <DropdownMenu>
<DropdownMenuTrigger asChild><Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" aria-label={`Actions for ${plateNumber}`}><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{visibleActions.map((action) => action.items ? <DropdownMenuSub key={action.id}>
<DropdownMenuSubTrigger><ActionLabel action={action} /></DropdownMenuSubTrigger>
<DropdownMenuPortal><DropdownMenuSubContent><TagItems items={action.items} /></DropdownMenuSubContent></DropdownMenuPortal>
</DropdownMenuSub> : <DropdownMenuItem key={action.id} disabled={action.disabled} onSelect={action.href ? undefined : action.onSelect} className={cn((action.destructive || action.active) && "text-red-500 focus:text-red-500")} asChild={Boolean(action.href)}>
{action.href ? <Link href={action.href}><ActionLabel action={action} /></Link> : <ActionLabel action={action} />}
</DropdownMenuItem>)}
</DropdownMenuContent>
</DropdownMenu>}
</div>;
}
Loading
Loading