< Summary

Information
Class: home.tsx
Assembly: app.routes
File(s): /home/runner/work/ClutterStock/ClutterStock/frontend/app/routes/home.tsx
Tag: 58_25416222083
Line coverage
0%
Covered lines: 0
Uncovered lines: 249
Coverable lines: 249
Total lines: 795
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 181
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/ClutterStock/ClutterStock/frontend/app/routes/home.tsx

#LineLine coverage
 1import { useState, useEffect, useCallback, useRef } from "react";
 2import { useFetcher } from "react-router";
 3import type { Route } from "./+types/home";
 4import {
 5  getLocations, getRooms, getItems,
 6  createItem, updateItem, deleteItem, createRoom, createLocation,
 7} from "~/api/client";
 8import type { LocationResponse, RoomResponse, ItemResponse } from "~/api/client";
 9import { CategoryTag } from "~/components/category-tag";
 10import { ItemThumb } from "~/components/item-thumb";
 11import { Sparkline } from "~/components/sparkline";
 12import { TuiPanel, TuiStatusBar } from "~/components/tui-widgets";
 13import { SidebarGroup, SidebarRow } from "~/components/sidebar";
 14import { CDEMenuBar, CDEFootRow, Win98MenuBar, Win98AddressBar, Win98StatusBar } from "~/components/theme-chrome";
 15import { ItemViewPanel } from "~/features/items/item-view-panel";
 16import { ItemEditPanel } from "~/features/items/item-edit-panel";
 17import { RoomFormPanel } from "~/features/rooms/room-form-panel";
 18import { LocationFormPanel } from "~/features/locations/location-form-panel";
 19import { TuiItemTable } from "~/features/items/tui-item-table";
 20import { TuiItemDetail } from "~/features/items/tui-item-detail";
 21import { TuiItemEdit } from "~/features/items/tui-item-edit";
 22import { Win98Tree } from "~/components/win98-tree";
 23import { TuiSearchBar } from "~/components/tui-search-bar";
 24import { HelpOverlay } from "~/components/help-overlay";
 25import { TuiTerminal } from "~/components/tui-terminal";
 26import { relativeTime } from "~/lib/time";
 27import { useTheme } from "~/lib/theme";
 28import { getVersionLine } from "~/lib/version";
 29
 30// ── Loader ────────────────────────────────────────────────────────────────────
 31
 032export async function loader({ request }: Route.LoaderArgs) {
 033  const [locations, rooms, items] = await Promise.all([
 34    getLocations(request),
 35    getRooms(request),
 36    getItems(request),
 37  ]);
 038  return { locations, rooms, items };
 39}
 40
 041export async function action({ request }: Route.ActionArgs) {
 042  const fd = await request.formData();
 043  const intent = (fd.get("_intent") as string) ?? "";
 044  const str = (k: string) => ((fd.get(k) as string) ?? "").trim();
 045  const opt = (k: string) => str(k) || undefined;
 046  try {
 047    if (intent === "create-item") {
 048      const item = await createItem({ roomId: Number(str("roomId")), name: str("name"), description: opt("description"),
 049      return { intent, ok: true as const, item };
 50    }
 051    if (intent === "update-item") {
 052      const roomIdStr = str("roomId");
 053      const roomId = roomIdStr ? Number(roomIdStr) : undefined;
 054      const item = await updateItem(
 55        Number(str("itemId")),
 56        { roomId, name: str("name"), description: opt("description"), category: opt("category"), notes: opt("notes") },
 57        request,
 58      );
 059      return { intent, ok: true as const, item };
 60    }
 061    if (intent === "delete-item") {
 062      await deleteItem(Number(str("itemId")), request);
 063      return { intent, ok: true as const };
 64    }
 065    if (intent === "create-room") {
 066      const room = await createRoom({ locationId: Number(str("locationId")), name: str("name"), description: opt("descri
 067      return { intent, ok: true as const, room };
 68    }
 069    if (intent === "create-location") {
 070      const location = await createLocation({ name: str("name"), description: opt("description") }, request);
 071      return { intent, ok: true as const, location };
 72    }
 073    return { intent, ok: false as const, error: "Unknown intent" };
 74  } catch (err) {
 075    if (err instanceof Response) throw err;
 076    return { intent, ok: false as const, error: "Something went wrong." };
 77  }
 78}
 79
 080export function meta() {
 081  return [{ title: "ClutterStock" }];
 82}
 83
 84// ── Types ─────────────────────────────────────────────────────────────────────
 85
 86type Panel =
 87  | { mode: "view";         item: ItemResponse }
 88  | { mode: "edit";         item: ItemResponse }
 89  | { mode: "new-item";     roomId: number }
 90  | { mode: "new-room";     locationId?: number }
 91  | { mode: "new-location" }
 92  | null;
 93
 94// ── Page ──────────────────────────────────────────────────────────────────────
 95
 096export default function Home({ loaderData }: Route.ComponentProps) {
 097  const { locations, rooms, items } = loaderData;
 098  const deleteFetcher = useFetcher<typeof action>();
 099  const theme = useTheme();
 0100  const isTui = theme === "tui";
 0101  const isWin98 = theme === "win98";
 102
 0103  const [panel, setPanel]                   = useState<Panel>(null);
 0104  const [filterRoomId, setFilterRoomId]     = useState<number | null>(null);
 0105  const [filterCategory, setFilterCategory] = useState<string | null>(null);
 0106  const [searchTerm, setSearchTerm]         = useState<string>("");
 0107  const [searchOpen, setSearchOpen]         = useState(false);
 0108  const [helpOpen, setHelpOpen]             = useState(false);
 0109  const [terminalOpen, setTerminalOpen]     = useState(false);
 110
 111  // Pane refs for Alt+1/2/3 keyboard focus shortcuts
 0112  const navRef = useRef<HTMLDivElement>(null);
 0113  const itemsRef = useRef<HTMLDivElement>(null);
 114  // gg state machine: tracks whether the previous keypress was 'g' (and not stale)
 0115  const pendingGRef = useRef<number | null>(null);
 116
 0117  function onSidebarKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
 0118    if (e.key !== "ArrowDown" && e.key !== "j" && e.key !== "ArrowUp" && e.key !== "k") return;
 0119    const root = navRef.current;
 0120    if (!root) return;
 0121    e.preventDefault();
 0122    const buttons = Array.from(root.querySelectorAll<HTMLElement>("button:not([disabled])"))
 0123      .filter(b => !/^new /i.test(b.getAttribute("title") ?? ""));
 0124    if (buttons.length === 0) return;
 0125    const dir = (e.key === "ArrowDown" || e.key === "j") ? 1 : -1;
 0126    const idx = buttons.indexOf(document.activeElement as HTMLElement);
 0127    const nextIdx = idx < 0
 128      ? (dir === 1 ? 0 : buttons.length - 1)
 129      : (idx + dir + buttons.length) % buttons.length;
 0130    buttons[nextIdx]?.focus();
 131  }
 132
 0133  const roomById     = Object.fromEntries(rooms.map(r => [r.id!, r]));
 0134  const locationById = Object.fromEntries(locations.map(l => [l.id!, l]));
 135
 0136  const categories = [...new Set(
 0137    items.map(i => i.category).filter((c): c is string => !!c)
 138  )].sort();
 139
 0140  const search = searchTerm.trim().toLowerCase();
 0141  const visibleItems = items.filter(item => {
 0142    if (filterRoomId !== null && item.roomId !== filterRoomId) return false;
 0143    if (filterCategory !== null && item.category !== filterCategory) return false;
 0144    if (search) {
 0145      const haystack = `${item.name ?? ""} ${item.description ?? ""} ${item.category ?? ""} ${item.notes ?? ""}`.toLower
 0146      if (!haystack.includes(search)) return false;
 147    }
 0148    return true;
 149  });
 150
 0151  const activeRoom     = filterRoomId != null ? roomById[filterRoomId] : null;
 0152  const activeLocation = activeRoom?.locationId != null ? locationById[activeRoom.locationId] : null;
 153
 0154  const defaultNewRoomId = filterRoomId ?? rooms[0]?.id ?? undefined;
 155
 0156  const focusedItem  = panel?.mode === "view" || panel?.mode === "edit" ? panel.item : null;
 0157  const viewRoom     = focusedItem?.roomId != null ? roomById[focusedItem.roomId] : null;
 0158  const viewLocation = viewRoom?.locationId != null ? locationById[viewRoom.locationId] : null;
 159
 0160  const openNewItem = useCallback(() => {
 0161    if (rooms.length === 0) {
 0162      setPanel(locations.length === 0 ? { mode: "new-location" } : { mode: "new-room" });
 163    } else {
 0164      setPanel({ mode: "new-item", roomId: defaultNewRoomId ?? rooms[0]!.id! });
 165    }
 166  }, [rooms, locations, defaultNewRoomId]);
 167
 0168  const handleItemSaved = useCallback((saved: ItemResponse) => {
 0169    setPanel({ mode: "view", item: saved });
 170  }, []);
 171
 0172  const handleItemDeleted = useCallback(() => {
 0173    setPanel(null);
 174  }, []);
 175
 0176  const handleLocationCreated = useCallback((loc: LocationResponse) => {
 0177    setPanel({ mode: "new-room", locationId: loc.id });
 178  }, []);
 179
 0180  const handleRoomCreated = useCallback((room: RoomResponse) => {
 0181    setPanel({ mode: "new-item", roomId: room.id! });
 182  }, []);
 183
 0184  const stats = [
 185    { label: "Total items", value: items.length },
 186    { label: "Locations",   value: locations.length },
 187    { label: "Rooms",       value: rooms.length },
 188    { label: "Categories",  value: categories.length },
 189  ];
 190
 0191  useEffect(() => {
 0192    function onKey(e: KeyboardEvent) {
 193      // Pane focus shortcuts — handled BEFORE the input-focus guard so they
 194      // work even while typing in the inline editor.
 0195      if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "1" || e.key === "2" || e.key === "3" || e.key === "4")) {
 0196        e.preventDefault();
 0197        if (e.key === "4") { setTerminalOpen(o => !o); return; }
 198        const root =
 0199          e.key === "1" ? navRef.current
 200          : e.key === "2" ? itemsRef.current
 201          : (document.querySelector<HTMLElement>(".tui-detail")
 202              ?? document.querySelector<HTMLElement>(".cs-body > aside.tui-panel:nth-of-type(2)"));
 0203        focusFirstIn(root);
 0204        return;
 205      }
 206
 0207      const tag = (e.target as HTMLElement).tagName;
 0208      if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
 209
 210      // When focus is inside the sidebar, the sidebar's own onKeyDown handles
 211      // arrow navigation and Enter/Space activation — don't run global shortcuts.
 0212      if (navRef.current?.contains(e.target as Node)) return;
 213
 214      // ? — open help (Shift+/ on most layouts)
 0215      if (e.key === "?") {
 0216        e.preventDefault();
 0217        setHelpOpen(true);
 0218        return;
 219      }
 220      // / — open filter
 0221      if (e.key === "/") {
 0222        e.preventDefault();
 0223        setSearchOpen(true);
 0224        return;
 225      }
 226      // : — open terminal (vim-style ex command line)
 0227      if (e.key === ":") {
 0228        e.preventDefault();
 0229        setTerminalOpen(true);
 0230        return;
 231      }
 232
 233      // Cancel any pending 'g' if the next key isn't 'g'
 0234      const hadPendingG = pendingGRef.current !== null;
 0235      if (e.key !== "g" && hadPendingG) {
 0236        clearTimeout(pendingGRef.current!);
 0237        pendingGRef.current = null;
 238      }
 239
 0240      const selectedIdx = panel?.mode === "view"
 0241        ? visibleItems.findIndex(i => i.id === panel.item.id)
 242        : -1;
 243
 0244      switch (e.key) {
 245        case "ArrowUp":
 246        case "k": {
 0247          e.preventDefault();
 0248          if (visibleItems.length === 0) break;
 0249          const prev = visibleItems[selectedIdx <= 0 ? visibleItems.length - 1 : selectedIdx - 1];
 0250          if (prev) setPanel({ mode: "view", item: prev });
 0251          break;
 252        }
 253        case "ArrowDown":
 254        case "j": {
 0255          e.preventDefault();
 0256          if (visibleItems.length === 0) break;
 0257          const next = visibleItems[selectedIdx >= visibleItems.length - 1 ? 0 : selectedIdx + 1];
 0258          if (next) setPanel({ mode: "view", item: next });
 0259          break;
 260        }
 261        case "g": {
 0262          e.preventDefault();
 0263          if (hadPendingG) {
 264            // Second 'g' — go to first item
 0265            clearTimeout(pendingGRef.current!);
 0266            pendingGRef.current = null;
 0267            const first = visibleItems[0];
 0268            if (first) setPanel({ mode: "view", item: first });
 269          } else {
 270            // First 'g' — wait briefly for a second
 0271            pendingGRef.current = window.setTimeout(() => { pendingGRef.current = null; }, 800);
 272          }
 0273          break;
 274        }
 275        case "G": {
 0276          e.preventDefault();
 0277          const last = visibleItems[visibleItems.length - 1];
 0278          if (last) setPanel({ mode: "view", item: last });
 0279          break;
 280        }
 281        case "e": {
 0282          if (panel?.mode === "view") {
 0283            e.preventDefault();
 0284            setPanel({ mode: "edit", item: panel.item });
 285          }
 0286          break;
 287        }
 288        case "d": {
 0289          if (panel?.mode === "view") {
 0290            e.preventDefault();
 0291            const item = panel.item;
 0292            if (confirm(`Delete "${item.name}"?`)) {
 0293              setPanel(null);
 0294              deleteFetcher.submit(
 295                { _intent: "delete-item", itemId: String(item.id) },
 296                { method: "post" },
 297              );
 298            }
 299          }
 0300          break;
 301        }
 302        case "o": {
 0303          e.preventDefault();
 0304          openNewItem();
 0305          break;
 306        }
 307        case "Escape": {
 0308          if (search || searchOpen) {
 0309            setSearchTerm("");
 0310            setSearchOpen(false);
 311          } else {
 0312            setPanel(null);
 313          }
 0314          break;
 315        }
 316      }
 317    }
 318
 0319    window.addEventListener("keydown", onKey);
 0320    return () => window.removeEventListener("keydown", onKey);
 321  }, [panel, visibleItems, openNewItem, deleteFetcher, search, searchOpen]);
 322
 0323  return (
 324    <div style={{
 325      flex: 1, display: "flex", flexDirection: "column",
 326      overflow: "hidden", minHeight: 0, background: "var(--c-bg)",
 327    }}>
 328
 329      <Win98MenuBar />
 330      <CDEMenuBar />
 331
 332      {/* Stats row */}
 333      <div className="cs-stats-row" style={{
 334        display: "grid", gridTemplateColumns: `repeat(${stats.length}, 1fr)`,
 335        gap: 1, background: "var(--c-border-2)",
 336        borderBottom: "1px solid var(--c-border)", flexShrink: 0,
 337      }}>
 0338        {stats.map((s, i) => (
 0339          <div key={i} style={{
 340            padding: "12px 20px", background: "var(--c-bg-2)",
 341            display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: 10,
 342          }}>
 343            <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
 344              <span style={{
 345                fontSize: 11, color: "var(--c-fg-2)",
 346                textTransform: "uppercase", letterSpacing: "0.04em", fontWeight: 500,
 347              }}>
 348                {s.label}
 349              </span>
 350              <span style={{
 351                fontSize: 26, fontWeight: 600,
 352                fontVariantNumeric: "tabular-nums", letterSpacing: "-0.02em", color: "var(--c-fg)",
 353              }}>
 354                {s.value}
 355              </span>
 356            </div>
 357            <Sparkline seed={s.value} />
 358          </div>
 359        ))}
 360      </div>
 361
 362      {/* Toolbar */}
 363      <div className="cs-toolbar" style={{
 364        display: "flex", alignItems: "center", justifyContent: "space-between",
 365        padding: "8px 20px", borderBottom: "1px solid var(--c-border)",
 366        background: "var(--c-bg-2)", flexShrink: 0, gap: 12,
 367      }}>
 368        <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
 369          {activeRoom && (
 370            <span style={{
 371              fontSize: 12, padding: "4px 10px",
 372              border: "1px solid var(--c-border)", background: "var(--c-accent-bg)",
 373              color: "var(--c-accent)", borderRadius: 6,
 374              display: "inline-flex", alignItems: "center", gap: 6,
 375            }}>
 376              <span style={{ opacity: 0.7 }}>Room</span>
 377              <span style={{ fontWeight: 500 }}>
 378                {activeLocation && `${activeLocation.name} / `}{activeRoom.name}
 379              </span>
 380              <button
 0381                onClick={() => setFilterRoomId(null)}
 382                style={{ border: "none", background: "transparent", color: "inherit", cursor: "pointer", padding: 0, lin
 383              >×</button>
 384            </span>
 385          )}
 386          {filterCategory && (
 387            <span style={{
 388              fontSize: 12, padding: "4px 10px",
 389              border: "1px solid var(--c-border)", background: "var(--c-accent-bg)",
 390              color: "var(--c-accent)", borderRadius: 6,
 391              display: "inline-flex", alignItems: "center", gap: 6,
 392            }}>
 393              <span style={{ opacity: 0.7 }}>Category</span>
 394              <span style={{ fontWeight: 500 }}>{filterCategory}</span>
 395              <button
 0396                onClick={() => setFilterCategory(null)}
 397                style={{ border: "none", background: "transparent", color: "inherit", cursor: "pointer", padding: 0, lin
 398              >×</button>
 399            </span>
 400          )}
 401          {!activeRoom && !filterCategory && (
 402            <span style={{ fontSize: 12, color: "var(--c-fg-3)" }}>All items</span>
 403          )}
 404        </div>
 405
 406        <div style={{ display: "flex", alignItems: "center", gap: 12, flexShrink: 0 }}>
 407          <span style={{ fontSize: 12, color: "var(--c-fg-2)", fontVariantNumeric: "tabular-nums" }}>
 408            <span style={{ color: "var(--c-fg-3)" }}>Showing </span>
 409            <strong style={{ fontWeight: 500, color: "var(--c-fg)" }}>{visibleItems.length}</strong>
 410            {visibleItems.length !== items.length && (
 411              <span style={{ color: "var(--c-fg-3)" }}> of {items.length}</span>
 412            )}
 413          </span>
 414          <button onClick={openNewItem} className="btn-primary">+ New item</button>
 415        </div>
 416      </div>
 417
 418      <Win98AddressBar location={activeLocation} room={activeRoom} />
 419
 420      <TuiTopBar
 421        locationPath={
 422          activeLocation && activeRoom
 423            ? `${activeLocation.name?.toLowerCase()}/${activeRoom.name?.toLowerCase()}`
 424            : activeLocation
 425              ? `${activeLocation.name?.toLowerCase()}`
 426              : "all items"
 427        }
 428        itemCount={items.length}
 429      />
 430
 431      {/* Body: TUI = sidebar + (table over detail); other themes = sidebar + table + right drawer */}
 432      <div className="cs-body" style={{ display: "flex", flex: 1, minHeight: 0 }}>
 433
 434        {/* Sidebar */}
 435        <TuiPanel as="aside" title="─[ rooms ]─" style={{
 436          width: 200, borderRight: "1px solid var(--c-border)",
 437          background: "var(--c-bg-2)", padding: "14px 0",
 438          flexShrink: 0,
 439          display: "flex", flexDirection: "column",
 440          minHeight: 0,
 441        }}>
 442          <div ref={navRef} onKeyDown={onSidebarKeyDown} style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
 443            {isWin98 ? (
 444              <Win98Tree
 445                locations={locations}
 446                rooms={rooms}
 447                items={items}
 448                categories={categories}
 449                filterRoomId={filterRoomId}
 450                filterCategory={filterCategory}
 0451                onSelectRoom={(id) => {
 0452                  setFilterRoomId(filterRoomId === id ? null : id);
 0453                  setFilterCategory(null);
 454                }}
 0455                onSelectCategory={(name) => {
 0456                  setFilterCategory(filterCategory === name ? null : name);
 0457                  setFilterRoomId(null);
 458                }}
 0459                onAddRoom={(locationId) => setPanel({ mode: "new-room", locationId })}
 460              />
 461            ) : (
 462              <>
 0463                {locations.map(loc => {
 0464                  const locRooms = rooms.filter(r => r.locationId === loc.id);
 0465                  return (
 466                    <SidebarGroup
 467                      key={loc.id}
 468                      title={loc.name ?? "Location"}
 0469                      onAdd={() => setPanel({ mode: "new-room", locationId: loc.id })}
 470                    >
 0471                      {locRooms.map(room => (
 0472                        <SidebarRow
 473                          key={room.id}
 474                          label={room.name ?? "Room"}
 0475                          count={items.filter(i => i.roomId === room.id).length}
 476                          active={filterRoomId === room.id}
 0477                          onClick={() => {
 0478                            setFilterRoomId(filterRoomId === room.id ? null : room.id!);
 0479                            setFilterCategory(null);
 480                          }}
 481                        />
 482                      ))}
 483                      {locRooms.length === 0 && (
 484                        <div style={{ padding: "3px 14px 6px", fontSize: 12, color: "var(--c-fg-3)", fontStyle: "italic"
 485                          No rooms yet
 486                        </div>
 487                      )}
 488                    </SidebarGroup>
 489                  );
 490                })}
 491
 492                {categories.length > 0 && (
 493                  <SidebarGroup title="Categories">
 0494                    {categories.map(cat => (
 0495                      <SidebarRow
 496                        key={cat}
 497                        label={cat}
 0498                        count={items.filter(i => i.category === cat).length}
 499                        active={filterCategory === cat}
 500                        dot
 0501                        onClick={() => {
 0502                          setFilterCategory(filterCategory === cat ? null : cat);
 0503                          setFilterRoomId(null);
 504                        }}
 505                      />
 506                    ))}
 507                  </SidebarGroup>
 508                )}
 509              </>
 510            )}
 511          </div>
 512
 513          {/* New location button at sidebar bottom */}
 514          <div style={{ borderTop: "1px solid var(--c-border)", padding: "10px 14px" }}>
 515            <button
 0516              onClick={() => setPanel({ mode: "new-location" })}
 517              style={{
 518                width: "100%", textAlign: "left", border: "none", cursor: "pointer",
 519                background: "transparent", fontFamily: "inherit",
 520                fontSize: 12, color: "var(--c-fg-3)", padding: "3px 0",
 521                display: "flex", alignItems: "center", gap: 6,
 522              }}
 523            >
 524              <span style={{ fontSize: 14, lineHeight: 1 }}>+</span>
 525              New location
 526            </button>
 527          </div>
 528        </TuiPanel>
 529
 530        {/* Center column: optional search bar, table, optional TUI detail strip */}
 531        <div ref={itemsRef} style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", minHeight: 0 }}>
 532          {searchOpen && (
 533            <TuiSearchBar
 534              value={searchTerm}
 535              onChange={setSearchTerm}
 0536              onClose={(clear) => {
 0537                if (clear) setSearchTerm("");
 0538                setSearchOpen(false);
 539              }}
 540            />
 541          )}
 542          {isTui ? (
 543            visibleItems.length === 0 ? (
 544              <TuiPanel title="─[ items · 0 ]─" style={{ flex: 1, minWidth: 0, background: "var(--c-bg)" }}>
 545                <div style={{
 546                  display: "flex", flexDirection: "column", alignItems: "center",
 547                  justifyContent: "center", height: "100%", gap: 8,
 548                  color: "var(--c-fg-3)", fontSize: 13,
 549                }}>
 550                  <span>No items{filterRoomId || filterCategory ? " matching filters" : ""}.</span>
 551                  <button
 552                    onClick={openNewItem}
 553                    className="link-text"
 554                    style={{ border: "none", background: "transparent", cursor: "pointer", fontFamily: "inherit", fontSi
 555                  >
 556                    {rooms.length === 0 ? "Set up a location to get started →" : "Add the first item →"}
 557                  </button>
 558                </div>
 559              </TuiPanel>
 560            ) : (
 561              <TuiItemTable
 562                items={visibleItems}
 563                roomById={roomById}
 564                selectedId={panel?.mode === "view" ? (panel.item.id ?? null) : null}
 0565                onSelect={item => {
 0566                  const same = panel?.mode === "view" && panel.item.id === item.id;
 0567                  setPanel(same ? null : { mode: "view", item });
 568                }}
 569                totalCount={items.length}
 570              />
 571            )
 572          ) : (
 573            <TuiPanel title={`─[ items · ${visibleItems.length}${visibleItems.length !== items.length ? ` of ${items.len
 574              {visibleItems.length === 0 ? (
 575                <div style={{
 576                  display: "flex", flexDirection: "column", alignItems: "center",
 577                  justifyContent: "center", height: "100%", gap: 8,
 578                  color: "var(--c-fg-3)", fontSize: 13,
 579                }}>
 580                  <span>No items{filterRoomId || filterCategory ? " matching filters" : ""}.</span>
 581                  <button
 582                    onClick={openNewItem}
 583                    className="link-text"
 584                    style={{ border: "none", background: "transparent", cursor: "pointer", fontFamily: "inherit", fontSi
 585                  >
 586                    {rooms.length === 0 ? "Set up a location to get started →" : "Add the first item →"}
 587                  </button>
 588                </div>
 589              ) : (
 590                <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
 591                  <thead>
 592                    <tr style={{ background: "var(--c-bg-2)", position: "sticky", top: 0, zIndex: 1 }}>
 0593                      {["Item", "Room", "Category", "Notes", "Updated"].map((h, i) => (
 0594                        <th key={i} style={{
 595                          textAlign: "left", padding: "8px 14px",
 596                          fontSize: 11, fontWeight: 500, color: "var(--c-fg-2)",
 597                          textTransform: "uppercase", letterSpacing: "0.04em",
 598                          borderBottom: "1px solid var(--c-border)",
 599                        }}>
 600                          {h}
 601                        </th>
 602                      ))}
 603                    </tr>
 604                  </thead>
 605                  <tbody>
 0606                    {visibleItems.map(item => {
 0607                      const isSelected = panel?.mode === "view" && panel.item.id === item.id;
 0608                      const room = item.roomId != null ? roomById[item.roomId] : null;
 0609                      return (
 610                        <tr
 611                          key={item.id}
 0612                          onClick={() => setPanel(isSelected ? null : { mode: "view", item })}
 613                          data-selected={isSelected ? "true" : undefined}
 614                          style={{
 615                            borderBottom: "1px solid var(--c-border-2)",
 616                            background: isSelected ? "var(--c-accent-bg-2)" : "transparent",
 617                            cursor: "pointer",
 618                          }}
 0619                          onMouseEnter={e => {
 0620                            if (!isSelected) (e.currentTarget as HTMLElement).style.background = "var(--c-accent-bg)";
 621                          }}
 0622                          onMouseLeave={e => {
 0623                            if (!isSelected) (e.currentTarget as HTMLElement).style.background = "transparent";
 624                          }}
 625                        >
 626                          <td style={{
 627                            padding: "10px 14px",
 628                            borderLeft: `2px solid ${isSelected ? "var(--c-accent)" : "transparent"}`,
 629                          }}>
 630                            <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
 631                              <ItemThumb name={item.name ?? ""} />
 632                              <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
 633                                <span style={{ fontWeight: 500 }}>{item.name ?? "Unnamed"}</span>
 634                                {item.description && (
 635                                  <span style={{ fontSize: 11, color: "var(--c-fg-3)" }}>{item.description}</span>
 636                                )}
 637                              </div>
 638                            </div>
 639                          </td>
 640                          <td style={{ padding: "10px 14px", color: "var(--c-fg-2)" }}>
 641                            {room?.name ?? "—"}
 642                          </td>
 643                          <td style={{ padding: "10px 14px" }}>
 644                            {item.category
 645                              ? <CategoryTag name={item.category} />
 646                              : <span style={{ color: "var(--c-fg-3)" }}>—</span>}
 647                          </td>
 648                          <td style={{ padding: "10px 14px", color: "var(--c-fg-3)", fontSize: 12 }}>
 649                            {item.notes || "—"}
 650                          </td>
 651                          <td style={{ padding: "10px 14px", color: "var(--c-fg-2)", fontSize: 12, fontVariantNumeric: "
 652                            {relativeTime(item.updatedAtUtc ?? item.createdAtUtc)}
 653                          </td>
 654                        </tr>
 655                      );
 656                    })}
 657                  </tbody>
 658                </table>
 659              )}
 660            </TuiPanel>
 661          )}
 662
 663          {/* TUI bottom strip — view (read-only) or edit (inline form) */}
 664          {isTui && panel?.mode === "view" && (
 665            <TuiItemDetail
 666              item={panel.item}
 667              room={viewRoom}
 668              location={viewLocation}
 0669              onEdit={() => setPanel({ mode: "edit", item: panel.item })}
 0670              onDelete={() => {
 0671                setPanel(null);
 0672                deleteFetcher.submit(
 673                  { _intent: "delete-item", itemId: String(panel.item.id) },
 674                  { method: "post" },
 675                );
 676              }}
 677            />
 678          )}
 679          {isTui && panel?.mode === "edit" && (
 680            <TuiItemEdit
 681              item={panel.item}
 682              rooms={rooms}
 683              locations={locations}
 0684              onCancel={() => setPanel({ mode: "view", item: panel.item })}
 0685              onSaved={(saved) => setPanel({ mode: "view", item: saved })}
 0686              onDelete={() => {
 0687                if (!confirm(`Delete "${panel.item.name}"?`)) return;
 0688                setPanel(null);
 0689                deleteFetcher.submit(
 690                  { _intent: "delete-item", itemId: String(panel.item.id) },
 691                  { method: "post" },
 692                );
 693              }}
 694            />
 695          )}
 696        </div>
 697
 698        {/* Right panel — non-TUI themes only */}
 699        {!isTui && panel?.mode === "view" && (
 700          <ItemViewPanel
 701            item={panel.item}
 702            room={viewRoom}
 703            location={viewLocation}
 0704            onClose={() => setPanel(null)}
 0705            onEdit={() => setPanel({ mode: "edit", item: panel.item })}
 0706            onDelete={() => {
 0707              setPanel(null);
 0708              deleteFetcher.submit(
 709                { _intent: "delete-item", itemId: String(panel.item.id) },
 710                { method: "post" },
 711              );
 712            }}
 713          />
 714        )}
 715        {(panel?.mode === "new-item" || (!isTui && panel?.mode === "edit")) && (
 716          <ItemEditPanel
 717            item={panel.mode === "edit" ? panel.item : undefined}
 718            rooms={rooms}
 719            locations={locations}
 720            defaultRoomId={panel.mode === "new-item" ? panel.roomId : undefined}
 0721            onClose={() => setPanel(null)}
 722            onSaved={handleItemSaved}
 723            onDeleted={handleItemDeleted}
 0724            onNewRoom={(locationId) => setPanel({ mode: "new-room", locationId })}
 725          />
 726        )}
 727        {panel?.mode === "new-room" && (
 728          <RoomFormPanel
 729            locations={locations}
 730            defaultLocationId={panel.locationId}
 0731            onClose={() => setPanel(null)}
 732            onCreated={handleRoomCreated}
 733          />
 734        )}
 735        {panel?.mode === "new-location" && (
 736          <LocationFormPanel
 0737            onClose={() => setPanel(null)}
 738            onCreated={handleLocationCreated}
 739          />
 740        )}
 741      </div>
 0742      <TuiStatusBar onOpenTerminal={() => setTerminalOpen(o => !o)} />
 743      <Win98StatusBar count={visibleItems.length} location={activeLocation} room={activeRoom} />
 744      <CDEFootRow onNewItem={openNewItem} />
 0745      {helpOpen && <HelpOverlay onClose={() => setHelpOpen(false)} />}
 746      <TuiTerminal
 747        open={terminalOpen}
 0748        onClose={() => setTerminalOpen(false)}
 749        items={items}
 750        rooms={rooms}
 751        locations={locations}
 752        currentFilter={searchTerm}
 0753        onFilter={(text) => {
 0754          setSearchTerm(text);
 0755          setSearchOpen(text !== "");
 756        }}
 757      />
 758    </div>
 759  );
 760}
 761
 0762function focusFirstIn(root: HTMLElement | null) {
 0763  if (!root) return;
 0764  const selected = root.querySelector<HTMLElement>('[data-selected="true"]');
 0765  if (selected) { selected.focus(); return; }
 0766  const focusable = root.querySelector<HTMLElement>(
 767    'button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]
 768  );
 0769  focusable?.focus();
 770}
 771
 0772function TuiTopBar({ locationPath, itemCount }: { locationPath: string; itemCount: number }) {
 0773  const [time, setTime] = useState<string>("");
 0774  useEffect(() => {
 0775    const tick = () => setTime(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
 0776    tick();
 0777    const id = setInterval(tick, 30_000);
 0778    return () => clearInterval(id);
 779  }, []);
 0780  const { line: version, sha } = getVersionLine();
 0781  return (
 782    <div className="cs-tui-topbar">
 783      <span className="cs-tui-topbar-left">
 784        <span className="cs-tui-topbar-bracket">╭─[ </span>
 785        <strong>clutter<span style={{ color: "#c4502a" }}>:stock</span></strong>
 786        <span className="cs-tui-topbar-bracket"> ]─[ </span>
 787        <span className="cs-tui-topbar-path">{locationPath}</span>
 788        <span className="cs-tui-topbar-bracket"> ]</span>
 789      </span>
 790      <span className="cs-tui-topbar-right" title={sha || undefined}>
 791        {version && `${version} · `}{itemCount} items{time && ` · synced ${time}`}
 792      </span>
 793    </div>
 794  );
 795}