{/* Desktop Table */}
-
+
- Image
-
+ Image
+
-
- {showStateCountry && State/Country}
-
+
+ {showStateCountry && State/Country}
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
Actions
-
+
@@ -2212,7 +2209,7 @@ export default function PlateTable({
plate={plate}
onClick={(e) => handleImageClick(e, plate)}
priority={plateIndex === 0}
- className=""
+ className="max-w-full h-auto"
/>
{showStateCountry &&
-
+
}
-
+
{formatConfidence(plate.confidence)}
-
- {plate.occurrence_count}
+
+ {plate.occurrence_count}
@@ -2238,19 +2235,19 @@ export default function PlateTable({
- {tag.name}
+ {tag.name}
{canManageTags &&
-
- {plate.camera_name || (
+
+ {plate.camera_name || (
Unknown
- )}
+ )}
-
+
@@ -2290,199 +2287,27 @@ export default function PlateTable({
/>
-
-
- {canManageTags &&
-
-
-
-
-
-
- Add tag
-
-
- {availableTags.map((tag) => (
-
- onAddTag(plate.plate_number, tag.name)
- }
- >
-
-
- ))}
-
- }
- {canManageKnownPlates &&
-
-
-
- Add to known plates
- }
- {canReview &&
-
-
-
- Correct plate
- }
- {canRead &&
-
-
-
- Review history
- }
- {biHost && buildBlueIrisPlatePlaybackPath(
- plate.bi_path,
- plate.plate_bi_camera,
- plate.timestamp,
- ) ? (
-
-
-
-
- Open in Blue Iris
-
- ) : (
-
-
-
-
-
-
-
- Blue Iris link unavailable
-
-
- )}
- {canViewAudit &&
-
-
-
- View logs for this read
- }
-
- {canDelete &&
-
-
-
- Delete record
- }
-
+
+ ({ id: tag.name, label: tag.name, color: tag.color, onSelect: () => onAddTag(plate.plate_number, tag.name) })) },
+ canManageKnownPlates && { id: "known", label: "Add to known plates", ariaLabel: `Add ${plate.plate_number} to known plates`, icon: Plus, onSelect: () => { setActivePlate(plate); setIsAddKnownPlateOpen(true); } },
+ canReview && { id: "correct", label: "Correct plate", ariaLabel: `Correct plate ${plate.plate_number}`, icon: Pencil, onSelect: () => {
+ setCorrection(correctionDraft({ canBatchReview, canManageAliases, id: plate.id, plateNumber: plate.plate_number, observedPlate: plate.observed_plate || plate.plate_number, cameraName: plate.camera_name || "", image: correctionImageFromRead(plate) }));
+ setIsCorrectPlateOpen(true);
+ } },
+ canRead && { id: "history", label: "Review history", ariaLabel: `Review history for ${plate.plate_number}`, icon: History, onSelect: () => openReviewHistory(plate) },
+ { id: "blue-iris", label: biHost && buildBlueIrisPlatePlaybackPath(plate.bi_path, plate.plate_bi_camera, plate.timestamp) ? "Open in Blue Iris" : "Blue Iris link unavailable", ariaLabel: `Open ${plate.plate_number} in Blue Iris`, icon: ExternalLink,
+ disabled: !biHost || !buildBlueIrisPlatePlaybackPath(plate.bi_path, plate.plate_bi_camera, plate.timestamp),
+ onSelect: () => window.open(buildBlueIrisUiUrl(biHost, buildBlueIrisPlatePlaybackPath(plate.bi_path, plate.plate_bi_camera, plate.timestamp)), "_blank") },
+ canViewAudit && { id: "logs", label: "View logs for this read", ariaLabel: `View logs for read ${plate.id}`, icon: ScrollText, href: `/logs?readId=${plate.id}&expand=first` },
+ canDelete && { id: "delete", label: "Delete record", ariaLabel: `Delete record for ${plate.plate_number}`, icon: Trash2, destructive: true, onSelect: () => { setActivePlate(plate); setIsDeleteConfirmOpen(true); } },
+ ]} />
))
)}
-
+
{/* Mobile Card View */}
diff --git a/components/ResizablePlateTable.jsx b/components/ResizablePlateTable.jsx
new file mode 100644
index 0000000..a6a0534
--- /dev/null
+++ b/components/ResizablePlateTable.jsx
@@ -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
+
+
+
+ Drag header dividers to resize. 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.
+
+
+
+
+ {columns.map((column) => )}
+ {children}
+
+
+ ;
+}
+
+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
+ {children}
+ 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); }}
+ >
+ ;
+}
+
+export function TableValue({ children, className }) {
+ return
{children};
+}
diff --git a/components/TableRowActions.jsx b/components/TableRowActions.jsx
new file mode 100644
index 0000000..8a6b928
--- /dev/null
+++ b/components/TableRowActions.jsx
@@ -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 <>
{action.label}>;
+}
+
+function TagItems({ items }) {
+ return items.length ? items.map((item) =>
+ {item.label}
+ ) :
No tags available;
+}
+
+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
+ {expanded ?
+ {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
+ ?
+ : ;
+ return action.items ?
+ {iconButton}{action.label}
+
+ : {action.disabled ? {iconButton} : iconButton}{action.label};
+ })}
+
:
+
+
+ {visibleActions.map((action) => action.items ?
+
+
+ :
+ {action.href ? : }
+ )}
+
+ }
+
;
+}
diff --git a/components/plateDbTable.jsx b/components/plateDbTable.jsx
index 9423300..29b3126 100644
--- a/components/plateDbTable.jsx
+++ b/components/plateDbTable.jsx
@@ -100,6 +100,8 @@ import {
} from "@/lib/table-page-size-preference.mjs";
import ResultsPagination from "@/components/ResultsPagination";
import PlateStateCountryField from "@/components/PlateStateCountryField";
+import ResizablePlateTable, { ResizableTableHead, TableValue } from "@/components/ResizablePlateTable";
+import TableRowActions from "@/components/TableRowActions";
const formatDaysAgo = (days) => {
if (days === 0) return "Today";
@@ -266,12 +268,12 @@ export default function PlateTable({ matchingSettings, showStateCountry = false
const getSortIcon = (columnKey) => {
if (sortConfig.key !== columnKey) {
- return
;
+ return
;
}
return sortConfig.direction === "asc" ? (
-
+
) : (
-
+
);
};
@@ -470,81 +472,81 @@ export default function PlateTable({ matchingSettings, showStateCountry = false
{/* Desktop Table View */}
-
+
-
+
-
-
+
+
-
- {showStateCountry && State/Country}
-
+
+ {showStateCountry && State/Country}
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
- Actions
+
+ Actions
@@ -559,7 +561,8 @@ export default function PlateTable({ matchingSettings, showStateCountry = false
handleOpenInsights(plate.plate_number)}
@@ -567,18 +570,18 @@ export default function PlateTable({ matchingSettings, showStateCountry = false
{plate.plate_number}
- {plate.occurrence_count}
+ {plate.occurrence_count}
{showStateCountry &&
- {
+ {
setData((current) => current.map((row) => row.plate_number === plate_number ? { ...row, state_country } : row));
}} />
}
- {plate.name}
- {plate.notes}
+ {plate.name}
+ {plate.notes}
- {formatFirstSeen(plate.first_seen_at)}
+ {formatFirstSeen(plate.first_seen_at)}
- {formatLastSeen(plate.last_seen_at)}
+ {formatLastSeen(plate.last_seen_at)}
{plate.tags?.length > 0 ? (
@@ -586,19 +589,19 @@ export default function PlateTable({ matchingSettings, showStateCountry = false
- {tag.name}
+ {tag.name}
{canManageTags &&
-
- {canManageTags &&
-
-
-
-
-
-
- Add tag
-
-
- {availableTags.map((tag) => (
-
- handleAddTag(plate.plate_number, tag.name)
- }
- >
-
-
- ))}
-
- }
- {canManageKnownPlates &&
-
-
-
- Add to known plates
- }
-
-
-
-
- Review individual reads
-
- {canReview &&
-
-
-
-
- {plate.flagged ? "Stop monitoring" : "Monitor plate"}
-
- }
-
- {canDelete &&
-
-
-
- Delete record
- }
-
+ ({ id: tag.name, label: tag.name, color: tag.color, onSelect: () => handleAddTag(plate.plate_number, tag.name) })) },
+ canManageKnownPlates && { id: "known", label: "Add to known plates", ariaLabel: `Add ${plate.plate_number} to known plates`, icon: Plus, onSelect: () => { setActivePlate(plate); setIsAddKnownPlateOpen(true); } },
+ { id: "history", label: "Review individual reads", ariaLabel: `Review individual reads for ${plate.plate_number}`, icon: History, href: `/live_feed?search=${encodeURIComponent(plate.plate_number)}` },
+ canReview && { id: "monitor", label: plate.flagged ? "Stop monitoring" : "Monitor plate", ariaLabel: `${plate.flagged ? "Stop monitoring" : "Monitor"} ${plate.plate_number}`, icon: Flag, active: plate.flagged, iconClassName: plate.flagged ? "fill-current" : "", onSelect: () => handleToggleFlag(plate.plate_number, !plate.flagged) },
+ canDelete && { id: "delete", label: "Delete record", ariaLabel: `Delete record for ${plate.plate_number}`, icon: Trash2, destructive: true, onSelect: () => { setActivePlate(plate); setIsDeleteConfirmOpen(true); } },
+ ]} />
))
)}
-
+
{/* Mobile Card View */}
diff --git a/docs/COMMUNITY_PRODUCT_ROADMAP.md b/docs/COMMUNITY_PRODUCT_ROADMAP.md
index 931f97d..6f4142e 100644
--- a/docs/COMMUNITY_PRODUCT_ROADMAP.md
+++ b/docs/COMMUNITY_PRODUCT_ROADMAP.md
@@ -2,6 +2,16 @@
Last reviewed: October 21, 2026
+## Adjustable table columns
+
+Community v0.1.58 adds draggable header dividers to Recognition Feed and Plate
+Database. Each table remembers its widths separately in the current browser.
+Keyboard resizing, individual resets and Reset widths are available. Narrow text
+uses ellipses and full-value hover text; minimum widths preserve controls and
+wider tables scroll horizontally. Actions starts as a compact menu and expands
+to individual icons when the column is wide enough. Mobile cards retain their
+existing layout.
+
## Plate review and State/Country
Community v0.1.57 enables batch correction and recurring misread aliases by
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
index 9986ca1..2f0012b 100644
--- a/docs/INSTALL.md
+++ b/docs/INSTALL.md
@@ -64,7 +64,7 @@ Clone the canonical repository and detach at the exact stable release tag:
```bash
git clone https://github.com/prsmith777/ALPR-Database-Community.git
cd ALPR-Database-Community
-git checkout --detach v0.1.57
+git checkout --detach v0.1.58
./alpr-community install
```
diff --git a/docs/UPDATES.md b/docs/UPDATES.md
index 82f526c..3d77ab0 100644
--- a/docs/UPDATES.md
+++ b/docs/UPDATES.md
@@ -47,7 +47,7 @@ shown on the GitHub Releases page, rather than deploying moving `main`:
```bash
git clone https://github.com/prsmith777/ALPR-Database-Community.git
cd ALPR-Database-Community
-git checkout --detach v0.1.57
+git checkout --detach v0.1.58
./alpr-community install
```
@@ -88,7 +88,7 @@ alpr_native_update() (
set -Eeuo pipefail
download_dir="$(mktemp -d /tmp/alpr-maintenance-download.XXXXXX)"
trap 'rm -rf -- "$download_dir"' EXIT
- asset_base="https://github.com/prsmith777/ALPR-Database-Community/releases/download/v0.1.57"
+ asset_base="https://github.com/prsmith777/ALPR-Database-Community/releases/download/v0.1.58"
curl -fLsS --retry 3 "$asset_base/community-maintenance.sh" -o "$download_dir/community-maintenance.sh"
curl -fLsS --retry 3 "$asset_base/community-maintenance.sh.sha256" -o "$download_dir/community-maintenance.sh.sha256"
(cd "$download_dir" && sha256sum --check --strict community-maintenance.sh.sha256)
@@ -99,7 +99,7 @@ unset -f alpr_native_update
```
The launcher verifies the canonical repository and exact released tag, loads the
-fixed tools outside your checkout, and asks for **INSTALL v0.1.57**. It uses the
+fixed tools outside your checkout, and asks for **INSTALL v0.1.58**. It uses the
same guarded backup/install workflow and does not reset or reinstall your system.
It temporarily stops only this installation's matching per-user systemd update
agent, then restarts it with the installed code. Keep the terminal open and do
diff --git a/lib/help-manual.mjs b/lib/help-manual.mjs
index d0b7774..a24c953 100644
--- a/lib/help-manual.mjs
+++ b/lib/help-manual.mjs
@@ -12,7 +12,7 @@ export const HELP_MANUAL = Object.freeze({
title: "ALPR Database Community User Guide",
shortTitle: "User Guide",
description: "A practical guide to using, configuring, and safely administering the portable Community application.",
- manualVersion: "3.25",
+ manualVersion: "3.26",
updatedAt: "October 21, 2026",
coverageBaseline: "Published clean-history Community release with portable configuration and no private deployment controls.",
filename: "ALPR-Database-Community-User-Guide.pdf",
@@ -264,6 +264,8 @@ export const HELP_MANUAL = Object.freeze({
title: "Review reads",
items: [
"Filter by plate, camera, date, confidence, tags, or review status.",
+ "In the Recognition Feed and Plate Database desktop tables, drag the divider at the right of a column heading to change its width. Widths are saved separately for each table in this browser. Double-click a divider to reset that column, or choose Reset widths to restore the full layout. Keyboard users can focus a divider and use Left/Right arrows (Shift for larger steps), Home for the minimum or End for the maximum.",
+ "Narrow text columns use an ellipsis; hover over the value to see its full text. Minimum widths keep controls usable, and wider tables scroll horizontally. Actions starts as a compact menu with full action names; widen that column to show the individual icons. Mobile cards keep their existing layout.",
"Date and hour filters use the installation's configured IANA time zone, selected during setup. Enter the local calendar date and local clock hour you want to review; do not convert them to UTC. The same rule applies to Database filters and exports.",
"Open an image to inspect the plate capture and any available local vehicle view. A blank image area means no source image was supplied or the referenced file is unavailable.",
"Operators and administrators can confirm a read or correct one read without losing evidence of the original OCR value.",
diff --git a/lib/release-info.mjs b/lib/release-info.mjs
index b576b31..2cb69b1 100644
--- a/lib/release-info.mjs
+++ b/lib/release-info.mjs
@@ -4,9 +4,14 @@ import builtReleaseMetadata from "./built-release-metadata.mjs";
import { HELP_MANUAL } from "./help-manual.mjs";
export const CURRENT_RELEASE_NOTES = Object.freeze({
- title: "Plate review defaults and State/Country",
+ title: "Resizable columns and compact actions",
publishedAt: "2026-10-21",
items: Object.freeze([
+ "Resize Recognition Feed and Plate Database columns with draggable header dividers or the keyboard; remember widths separately for each table in this browser.",
+ "Reset one column by double-clicking its divider, or restore the full layout with Reset widths.",
+ "Show narrow text with ellipses and full-value hover text, protect minimum control widths, and scroll wider tables horizontally.",
+ "Keep Actions compact in a menu with full names; widening the column restores individual icons while preserving permissions and confirmations.",
+ "Update User Guide 3.26 with adjustable-column and compact-action instructions.",
"Start administrator corrections with batch correction and recurring misread aliases enabled, retaining preview and unchanged-plate safeguards.",
"Scroll long tag menus to the final tag in the records table and image detail modal.",
"Enable the optional State/Country column in Settings > General; it is off by default in Recognition Feed and Plate Database.",
diff --git a/lib/table-column-widths.mjs b/lib/table-column-widths.mjs
new file mode 100644
index 0000000..ad6b95e
--- /dev/null
+++ b/lib/table-column-widths.mjs
@@ -0,0 +1,78 @@
+const TABLE_COLUMNS = {
+ "live-feed": [
+ { id: "image", label: "Image", width: 104, min: 72 },
+ { id: "plate_number", label: "Plate Number", width: 148, min: 104 },
+ { id: "state_country", label: "State/Country", width: 176, min: 104 },
+ { id: "confidence", label: "Confidence", width: 72, min: 56 },
+ { id: "occurrence_count", label: "Count", width: 80, min: 64 },
+ { id: "tags", label: "Tags", width: 160, min: 88 },
+ { id: "camera_name", label: "Camera", width: 152, min: 88 },
+ { id: "direction", label: "Direction", width: 128, min: 88 },
+ { id: "timestamp", label: "Timestamp", width: 176, min: 104 },
+ { id: "actions", label: "Actions", width: 80, min: 56 },
+ ],
+ "plate-database": [
+ { id: "plate_number", label: "Plate Number", width: 160, min: 104 },
+ { id: "occurrence_count", label: "Seen", width: 80, min: 64 },
+ { id: "state_country", label: "State/Country", width: 176, min: 104 },
+ { id: "name", label: "Name", width: 200, min: 88 },
+ { id: "notes", label: "Notes", width: 260, min: 104 },
+ { id: "first_seen_at", label: "First Seen", width: 144, min: 104 },
+ { id: "last_seen_at", label: "Last Seen", width: 176, min: 104 },
+ { id: "tags", label: "Tags", width: 160, min: 88 },
+ { id: "actions", label: "Actions", width: 80, min: 56 },
+ ],
+};
+
+export function tableColumns(surface, showStateCountry = true) {
+ const columns = TABLE_COLUMNS[surface];
+ if (!columns) throw new Error(`Unsupported table: ${surface}`);
+ return columns.filter((column) => showStateCountry || column.id !== "state_country");
+}
+
+export function clampColumnWidth(column, value) {
+ return Math.round(Math.min(1000, Math.max(column.min, Number.isFinite(value) ? value : column.width)));
+}
+
+export function columnWidthForKey(column, width, key, shift = false) {
+ if (key === "Home") return column.min;
+ if (key === "End") return 1000;
+ if (key !== "ArrowLeft" && key !== "ArrowRight") return null;
+ return clampColumnWidth(column, width + (key === "ArrowRight" ? 1 : -1) * (shift ? 50 : 10));
+}
+
+export function normalizeColumnWidths(surface, value) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ const widths = {};
+ for (const column of tableColumns(surface)) {
+ if (typeof value[column.id] === "number" && Number.isFinite(value[column.id])) {
+ widths[column.id] = clampColumnWidth(column, value[column.id]);
+ }
+ }
+ return Object.keys(widths).length ? widths : null;
+}
+
+function preferenceKey(surface) {
+ tableColumns(surface);
+ return `alpr.table.columnWidths.v1.${surface}`;
+}
+
+export function readColumnWidths(surface, storage) {
+ try {
+ return normalizeColumnWidths(surface, JSON.parse((storage ?? globalThis.localStorage)?.getItem(preferenceKey(surface)) ?? "null"));
+ } catch {
+ return null;
+ }
+}
+
+export function writeColumnWidths(surface, value, storage) {
+ const widths = normalizeColumnWidths(surface, value);
+ try {
+ const target = storage ?? globalThis.localStorage;
+ if (widths) target?.setItem(preferenceKey(surface), JSON.stringify(widths));
+ else target?.removeItem(preferenceKey(surface));
+ } catch {
+ // Resizing still works for this visit when browser storage is blocked.
+ }
+ return widths;
+}
diff --git a/package.json b/package.json
index c3ff8e2..21ff2c1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "blueiris-alpr-database",
- "version": "0.1.57",
+ "version": "0.1.58",
"private": true,
"engines": {
"node": ">=24.0.0 <25"
diff --git a/test/action-tooltips.test.mjs b/test/action-tooltips.test.mjs
index c386990..8f25f9e 100644
--- a/test/action-tooltips.test.mjs
+++ b/test/action-tooltips.test.mjs
@@ -7,27 +7,32 @@ async function source(path) {
}
test("plate tables label icon-only row actions with accessible tooltips", async () => {
- const [liveFeed, plateDatabase, knownPlates, databaseFilters] = await Promise.all([
+ const [liveFeed, plateDatabase, knownPlates, databaseFilters, rowActions] = await Promise.all([
source("components/PlateTable.jsx"),
source("components/plateDbTable.jsx"),
source("components/KnownPlatesTable.jsx"),
source("components/PlateDatabaseFilters.jsx"),
+ source("components/TableRowActions.jsx"),
]);
for (const component of [liveFeed, plateDatabase]) {
assert.match(component, /TooltipProvider/);
- assert.match(component, /
Add tag<\/TooltipContent>/);
+ assert.match(component, /Remove tag<\/TooltipContent>/);
- assert.match(component, /Delete record<\/TooltipContent>/);
+ assert.match(component, /label: "Delete record"/);
assert.match(component, /aria-label=\{`More actions for \$\{plate\.plate_number\}`\}/);
}
+ assert.match(rowActions, /\{action\.label\}<\/TooltipContent>/);
+ assert.match(rowActions, /aria-label=\{action\.ariaLabel\}/);
+ assert.match(rowActions, /aria-label=\{`Actions for \$\{plateNumber\}`\}/);
assert.match(liveFeed, /aria-label="Open filters"/);
assert.match(liveFeed, /Open filters<\/TooltipContent>/);
- assert.match(liveFeed, /Correct plate<\/TooltipContent>/);
+ assert.match(liveFeed, /label: "Correct plate"/);
assert.match(liveFeed, /Confirm detected plate/);
- assert.match(liveFeed, /View logs for this read<\/TooltipContent>/);
- assert.match(liveFeed, /href=\{`\/logs\?readId=\$\{plate\.id\}&expand=first`\}/);
+ assert.match(liveFeed, /label: "View logs for this read"/);
+ assert.match(liveFeed, /href: `\/logs\?readId=\$\{plate\.id\}&expand=first`/);
assert.match(plateDatabase, /View insights<\/TooltipContent>/);
assert.match(plateDatabase, /Monitor plate/);
assert.match(knownPlates, //);
diff --git a/test/help-manual.test.mjs b/test/help-manual.test.mjs
index 7441347..94ea70d 100644
--- a/test/help-manual.test.mjs
+++ b/test/help-manual.test.mjs
@@ -18,7 +18,7 @@ async function source(path) {
}
test("the user guide is structured, searchable, and role-aware", () => {
- assert.equal(HELP_MANUAL.manualVersion, "3.25");
+ assert.equal(HELP_MANUAL.manualVersion, "3.26");
assert.ok(HELP_MANUAL.sections.length >= 24);
const ids = HELP_MANUAL.sections.map((section) => section.id);
diff --git a/test/release-info.test.mjs b/test/release-info.test.mjs
index 3886309..e69ca45 100644
--- a/test/release-info.test.mjs
+++ b/test/release-info.test.mjs
@@ -23,8 +23,8 @@ test("release information resolves a commit-pinned deployment image", () => {
ALPR_RELEASE_CHANNEL: "staging",
});
- assert.equal(release.version, "0.1.57");
- assert.equal(release.manualVersion, "3.25");
+ assert.equal(release.version, "0.1.58");
+ assert.equal(release.manualVersion, "3.26");
assert.equal(release.manualUpdatedAt, "October 21, 2026");
assert.equal(release.gitSha, "8cd2fa8");
assert.equal(release.channel, "staging");
@@ -32,11 +32,13 @@ test("release information resolves a commit-pinned deployment image", () => {
assert.equal(release.readOnly, true);
assert.equal(
release.notes.title,
- "Plate review defaults and State/Country"
+ "Resizable columns and compact actions"
);
assert.equal(release.notes.publishedAt, "2026-10-21");
assert.ok(release.notes.items.length >= 3);
const notes = release.notes.items.join(" ");
+ assert.match(notes, /draggable header dividers/);
+ assert.match(notes, /Actions compact/);
assert.match(notes, /State\/Country/);
assert.match(notes, /off by default/);
assert.match(notes, /preview and unchanged-plate safeguards/);
diff --git a/test/table-column-widths.test.mjs b/test/table-column-widths.test.mjs
new file mode 100644
index 0000000..fb9dc05
--- /dev/null
+++ b/test/table-column-widths.test.mjs
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { tableColumns, clampColumnWidth, columnWidthForKey, normalizeColumnWidths, readColumnWidths, writeColumnWidths } from "../lib/table-column-widths.mjs";
+
+function storageFixture() {
+ const values = new Map();
+ return { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: (key) => values.delete(key) };
+}
+
+test("each table can hide State/Country without changing other column identities or persisted widths", () => {
+ for (const surface of ["live-feed", "plate-database"]) {
+ const all = tableColumns(surface);
+ const hidden = tableColumns(surface, false);
+ assert.deepEqual(hidden.map((c) => c.id), all.filter((c) => c.id !== "state_country").map((c) => c.id));
+ const storage = storageFixture();
+ writeColumnWidths(surface, { state_country: 320, actions: 56 }, storage);
+ assert.deepEqual(readColumnWidths(surface, storage), { state_country: 320, actions: 56 });
+ assert.equal(tableColumns(surface, true).find((c) => c.id === "state_country").width, 176);
+ }
+});
+
+test("dragging through zero cannot hide controls or create unbounded columns", () => {
+ for (const surface of ["live-feed", "plate-database"]) {
+ for (const column of tableColumns(surface)) {
+ assert.equal(clampColumnWidth(column, -500), column.min);
+ assert.equal(clampColumnWidth(column, 50000), 1000);
+ assert.equal(clampColumnWidth(column, NaN), column.width);
+ assert.ok(column.width >= column.min);
+ }
+ assert.equal(tableColumns(surface).find((c) => c.id === "actions").min, 56);
+ }
+});
+
+test("keyboard resizing uses bounded small and large steps and leaves unrelated keys alone", () => {
+ const column = tableColumns("live-feed").find((c) => c.id === "state_country");
+ assert.equal(columnWidthForKey(column, 176, "ArrowLeft"), 166);
+ assert.equal(columnWidthForKey(column, 176, "ArrowRight", true), 226);
+ assert.equal(columnWidthForKey(column, 110, "ArrowLeft", true), column.min);
+ assert.equal(columnWidthForKey(column, 990, "ArrowRight", true), 1000);
+ assert.equal(columnWidthForKey(column, 500, "Home"), column.min);
+ assert.equal(columnWidthForKey(column, 500, "End"), 1000);
+ assert.equal(columnWidthForKey(column, 176, "Enter"), null);
+ assert.equal(columnWidthForKey(column, 176, "Tab"), null);
+});
+
+test("saved preferences survive visits, stay separate per table, and reset independently", () => {
+ const storage = storageFixture();
+ assert.equal(readColumnWidths("live-feed", storage), null);
+ writeColumnWidths("live-feed", { plate_number: 220, actions: 56 }, storage);
+ writeColumnWidths("plate-database", { plate_number: 310, notes: 400 }, storage);
+ assert.deepEqual(readColumnWidths("live-feed", storage), { plate_number: 220, actions: 56 });
+ assert.deepEqual(readColumnWidths("plate-database", storage), { plate_number: 310, notes: 400 });
+ writeColumnWidths("live-feed", null, storage);
+ assert.equal(readColumnWidths("live-feed", storage), null);
+ assert.deepEqual(readColumnWidths("plate-database", storage), { plate_number: 310, notes: 400 });
+});
+
+test("old or corrupted storage cannot introduce missing, hidden, or excessively wide columns", () => {
+ const original = { actions: -5, plate_number: 40000, tags: NaN, state_country: "250", unknown: 200 };
+ assert.deepEqual(normalizeColumnWidths("live-feed", original), { plate_number: 1000, actions: 56 });
+ assert.equal(original.actions, -5);
+ for (const value of [null, [], "{}", {}, { unknown: 200 }]) assert.equal(normalizeColumnWidths("live-feed", value), null);
+ assert.equal(readColumnWidths("live-feed", { getItem() { return "{broken"; } }), null);
+});
+
+test("browser storage restrictions preserve working in-memory widths", () => {
+ const blocked = { getItem() { throw new Error("blocked"); }, setItem() { throw new Error("full"); }, removeItem() { throw new Error("blocked"); } };
+ assert.equal(readColumnWidths("live-feed", blocked), null);
+ assert.deepEqual(writeColumnWidths("live-feed", { actions: 60 }, blocked), { actions: 60 });
+ assert.equal(writeColumnWidths("live-feed", null, blocked), null);
+});
diff --git a/test/ui-table-enhancements.test.mjs b/test/ui-table-enhancements.test.mjs
index 185617b..ff11911 100644
--- a/test/ui-table-enhancements.test.mjs
+++ b/test/ui-table-enhancements.test.mjs
@@ -21,7 +21,7 @@ test("live feed plate identities open exact matching read history", async () =>
}
assert.match(
plateTable,
- /className="text-foreground underline-offset-4 hover:underline/
+ /className="[^"]*text-foreground underline-offset-4 hover:underline/
);
assert.doesNotMatch(
plateTable,
@@ -205,18 +205,16 @@ test("live feed image review advances visibly and starts focused on the plate",
assert.match(plateTable, //);
});
-test("records table keeps its action controls compact at the right edge", async () => {
- const plateTable = await source("components/PlateTable.jsx");
-
- assert.match(plateTable, /const TABLE_ACTION_BUTTON_CLASS = "h-8 w-8 p-0"/);
- assert.match(
- plateTable,
- /\s*Actions/
- );
- assert.match(
- plateTable,
- /\s*/
- );
+test("records table keeps action controls compact and accessible at the right edge", async () => {
+ const [plateTable, rowActions] = await Promise.all([
+ source("components/PlateTable.jsx"), source("components/TableRowActions.jsx"),
+ ]);
+ assert.match(plateTable, /
/);
+ assert.match(plateTable, /\s* {
@@ -267,7 +265,7 @@ test("plate identifiers request a slashed-zero glyph throughout the interface",
assert.match(styles, /font-variant-numeric: slashed-zero/);
assert.match(styles, /font-feature-settings: "zero" 1/);
assert.match(styles, /var\(--font-geist-mono\)/);
- assert.match(plateTable, /Camera read \{observed\}/);
+ assert.match(plateTable, /Camera read (?:\{observed\}|\$\{observed\})/);
assert.doesNotMatch(plateTable, /text-\[11px\] font-sans text-muted-foreground/);
});