79a6567530
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>
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|