Files
biggus-dickus/web/src/objects/objects-table.tsx
T
logaritmisk 79a6567530 fix(web): dark-mode tokens for popup primitives + theme-color/color-scheme sync (#68)
Tooltip, toast, and combobox popups still hardcoded light colors
(bg-white, neutral-*, indigo-50) and rendered as white boxes in dark
mode; the objects-table page-size select did the same in app code.
Swap all of them to theme tokens (popover/accent/muted/destructive/
success) and replace the toast's literal "×" with the lucide X icon.

Wire browser chrome into the theme: color-scheme via CSS on
:root/.dark (follows the in-app toggle, not just the OS), a
theme-color meta kept in sync by the preload script and applyTheme(),
plus a unit test for the meta sync.

Extend check-no-raw-colors to also flag shadeless white/black
utilities outside components/ui/ so the objects-table case can't
recur.

Closes #68

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:42:57 +02:00

324 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react";
import type { components } from "../api/schema";
import { useObjectsPage } from "../api/queries";
import { useDebouncedValue } from "../lib/use-debounced-value";
import { focusRing } from "../lib/focus-ring";
import { segmentClass, rowStateClass } from "../lib/class-recipes";
import { formatTimestamp } from "../lib/format-timestamp";
import { useConfig } from "../config/config-context";
import { VisibilityBadge } from "./visibility-badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
type AdminObjectView = components["schemas"]["AdminObjectView"];
const PAGE_SIZES = [25, 50, 100, 200];
const VIS = ["all", "draft", "internal", "public"] as const;
const DEFAULT_SORT = "object_number";
const DEFAULT_LIMIT = 50;
type SortColumn = "object_number" | "object_name" | "updated_at";
const COLUMN_KEYS: Record<SortColumn, string> = {
object_number: "objects.columns.number",
object_name: "objects.columns.name",
updated_at: "objects.columns.updated",
};
export function ObjectsTable() {
const { t, i18n } = useTranslation();
const { default_timezone } = useConfig();
const navigate = useNavigate();
const { id: selectedId } = useParams();
const [params, setParams] = useSearchParams();
const sort = params.get("sort") ?? DEFAULT_SORT;
const order = (params.get("order") === "desc" ? "desc" : "asc") as "asc" | "desc";
const visibility = params.get("visibility") ?? "all";
const limit = Number(params.get("limit")) || DEFAULT_LIMIT;
const offset = Number(params.get("offset")) || 0;
const qParam = params.get("q") ?? "";
const [qText, setQText] = useState(qParam);
const q = useDebouncedValue(qText, 300);
// Mirror the search-panel pattern: sync the debounced quick-filter into the URL
// (setParams is router state, not component setState, so the lint rule allows it).
// Guard on the URL already matching `q` so re-renders caused by other URL updates
// (e.g. pagination changing `offset`) don't re-run this and clobber that state.
useEffect(() => {
const term = q.trim();
setParams(
(prev) => {
if ((prev.get("q") ?? "") === term) return prev;
const next = new URLSearchParams(prev);
if (term) next.set("q", term);
else next.delete("q");
next.delete("offset");
return next;
},
{ replace: true },
);
}, [q, setParams]);
const { data, isLoading, isError } = useObjectsPage({
limit,
offset,
sort,
order,
visibility: visibility === "all" ? undefined : visibility,
q: q.trim() || undefined,
});
const setParam = (mutate: (next: URLSearchParams) => void) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
mutate(next);
return next;
},
{ replace: true },
);
const toggleSort = (col: SortColumn) =>
setParam((next) => {
const curOrder = next.get("order") === "desc" ? "desc" : "asc";
const curSort = next.get("sort") ?? DEFAULT_SORT;
const nextOrder = curSort === col && curOrder === "asc" ? "desc" : "asc";
next.set("sort", col);
next.set("order", nextOrder);
next.delete("offset");
});
const setVisibility = (value: string) =>
setParam((next) => {
if (value === "all") next.delete("visibility");
else next.set("visibility", value);
next.delete("offset");
});
const setLimit = (value: number) =>
setParam((next) => {
next.set("limit", String(value));
next.delete("offset");
});
const goToOffset = (value: number) =>
setParam((next) => {
if (value <= 0) next.delete("offset");
else next.set("offset", String(value));
});
const headerCell = (col: SortColumn) => {
const active = sort === col;
const ariaSort = active ? (order === "asc" ? "ascending" : "descending") : "none";
const Icon = !active ? ChevronsUpDown : order === "asc" ? ChevronUp : ChevronDown;
return (
<th key={col} scope="col" aria-sort={ariaSort} className="px-3 py-2 text-left font-medium">
<button
type="button"
onClick={() => toggleSort(col)}
className="flex items-center gap-1 hover:text-foreground"
>
{t(COLUMN_KEYS[col])}
<Icon className="size-3.5 text-muted-foreground" aria-hidden="true" />
</button>
</th>
);
};
const total = data?.total ?? 0;
const from = total === 0 ? 0 : offset + 1;
const to = Math.min(offset + limit, total);
const toolbar = (
<div className="flex flex-wrap items-center gap-2 border-b p-3">
<Input
value={qText}
onChange={(event) => setQText(event.target.value)}
placeholder={t("objects.filter")}
aria-label={t("objects.filter")}
className="max-w-xs"
/>
<div className="flex gap-1 text-xs">
{VIS.map((value) => {
const active = visibility === value;
return (
<button
key={value}
type="button"
aria-pressed={active}
onClick={() => setVisibility(value)}
className={segmentClass(active, "px-2 py-1")}
>
{value === "all" ? t("search.all") : t(`visibility.${value}`)}
</button>
);
})}
</div>
<Link to="/objects/new" className={`${buttonVariants({ size: "sm" })} ml-auto`}>
{t("objects.new")}
</Link>
</div>
);
const columns = (
<thead className="border-b bg-muted text-xs text-muted-foreground">
<tr>
{headerCell("object_number")}
{headerCell("object_name")}
<th scope="col" className="px-3 py-2 text-left font-medium">
{t("objects.columns.visibility")}
</th>
<th scope="col" className="px-3 py-2 text-left font-medium">
{t("objects.columns.location")}
</th>
<th scope="col" className="px-3 py-2 text-right font-medium">
{t("objects.columns.count")}
</th>
{headerCell("updated_at")}
</tr>
</thead>
);
let body;
if (isLoading) {
body = (
<tbody>
{Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b">
<td colSpan={6} className="px-3 py-2">
<Skeleton className="h-5 w-full" />
</td>
</tr>
))}
</tbody>
);
} else if (isError) {
body = (
<tbody>
<tr>
<td colSpan={6} role="alert" className="px-3 py-6 text-center text-sm text-destructive">
{t("objects.loadError")}
</td>
</tr>
</tbody>
);
} else if (!data || data.items.length === 0) {
body = (
<tbody>
<tr>
<td colSpan={6} className="px-3 py-6 text-center text-sm text-muted-foreground">
{t("objects.empty")}
</td>
</tr>
</tbody>
);
} else {
body = (
<tbody>
{data.items.map((item) => {
const object = item as AdminObjectView;
const selected = object.id === selectedId;
return (
<tr
key={object.id}
onClick={() => navigate(`/objects/${object.id}?${params}`)}
className={`cursor-pointer border-b text-sm ${rowStateClass(selected)}`}
>
<td className="px-3 py-2 text-muted-foreground">
<Link
to={`/objects/${object.id}?${params}`}
aria-current={selected ? "page" : undefined}
onClick={(event) => event.stopPropagation()}
className={`${focusRing} rounded-sm hover:underline`}
>
{object.object_number}
</Link>
</td>
<td className="px-3 py-2 font-medium">{object.object_name}</td>
<td className="px-3 py-2">
<VisibilityBadge visibility={object.visibility} />
</td>
<td className="px-3 py-2 text-muted-foreground">{object.current_location ?? "—"}</td>
<td className="px-3 py-2 text-right tabular-nums">{object.number_of_objects}</td>
<td className="px-3 py-2 text-muted-foreground">
{formatTimestamp(object.updated_at, default_timezone, i18n.language)}
</td>
</tr>
);
})}
</tbody>
);
}
return (
<div className="flex h-full flex-col">
{toolbar}
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse" aria-busy={isLoading || undefined}>
<caption className="sr-only" aria-live="polite">
{isLoading ? t("common.loading") : t("objects.tableLabel")}
</caption>
{columns}
{body}
</table>
</div>
<div className="flex items-center justify-between gap-2 border-t px-3 py-2 text-xs text-muted-foreground">
<label className="flex items-center gap-1">
<span>{t("objects.pageSize")}</span>
<select
value={limit}
onChange={(event) => setLimit(Number(event.target.value))}
aria-label={t("objects.pageSize")}
className="rounded-md border bg-background px-1 py-0.5"
>
{PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<span>
{from}{to} {t("objects.of")} {total}
</span>
<span className="flex gap-2">
<Button
variant="ghost"
size="sm"
disabled={offset === 0}
onClick={() => goToOffset(Math.max(0, offset - limit))}
>
{t("objects.prev")}
</Button>
<Button
variant="ghost"
size="sm"
disabled={offset + limit >= total}
onClick={() => goToOffset(offset + limit)}
>
{t("objects.next")}
</Button>
</span>
</div>
</div>
);
}