diff --git a/web/package-lock.json b/web/package-lock.json index 2b9e8dd..25905b8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "silo-web", "version": "0.0.0", "dependencies": { + "lucide-react": "^0.564.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.0.0" @@ -1499,6 +1500,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.564.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.564.0.tgz", + "integrity": "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/web/package.json b/web/package.json index b30bec5..ad2910d 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "lucide-react": "^0.564.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.0.0" diff --git a/web/src/components/ContextMenu.tsx b/web/src/components/ContextMenu.tsx index 13e4e6c..a3e71d3 100644 --- a/web/src/components/ContextMenu.tsx +++ b/web/src/components/ContextMenu.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef } from "react"; +import { Check } from "lucide-react"; export interface ContextMenuItem { label: string; @@ -24,76 +25,95 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === "Escape") onClose(); }; const handleScroll = () => onClose(); - document.addEventListener('mousedown', handleClick); - document.addEventListener('keydown', handleKey); - window.addEventListener('scroll', handleScroll, true); + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleKey); + window.addEventListener("scroll", handleScroll, true); return () => { - document.removeEventListener('mousedown', handleClick); - document.removeEventListener('keydown', handleKey); - window.removeEventListener('scroll', handleScroll, true); + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKey); + window.removeEventListener("scroll", handleScroll, true); }; }, [onClose]); // Clamp position to viewport const style: React.CSSProperties = { - position: 'fixed', + position: "fixed", left: Math.min(x, window.innerWidth - 220), top: Math.min(y, window.innerHeight - items.length * 32 - 16), zIndex: 9999, - backgroundColor: 'var(--ctp-surface0)', - border: '1px solid var(--ctp-surface1)', - borderRadius: '0.5rem', - padding: '0.25rem 0', + backgroundColor: "var(--ctp-surface0)", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.5rem", + padding: "0.25rem 0", minWidth: 200, - boxShadow: '0 4px 12px rgba(0,0,0,0.4)', + boxShadow: "0 4px 12px rgba(0,0,0,0.4)", }; return (
{items.map((item, i) => item.divider ? ( -
+
) : ( ))} @@ -166,30 +181,30 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro placeholder={value.length === 0 ? placeholder : undefined} style={{ flex: 1, - minWidth: '4rem', - border: 'none', - outline: 'none', - background: 'transparent', - color: 'var(--ctp-text)', - fontSize: '0.85rem', - padding: '0.1rem 0', + minWidth: "4rem", + border: "none", + outline: "none", + background: "transparent", + color: "var(--ctp-text)", + fontSize: "0.85rem", + padding: "0.1rem 0", }} />
{open && results.length > 0 && (
{results.map((opt, i) => ( @@ -201,15 +216,15 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro }} onMouseEnter={() => setHighlighted(i)} style={{ - padding: '0.25rem 0.5rem', - height: '28px', - display: 'flex', - alignItems: 'center', - fontSize: '0.8rem', - cursor: 'pointer', - color: 'var(--ctp-text)', + padding: "0.25rem 0.5rem", + height: "28px", + display: "flex", + alignItems: "center", + fontSize: "0.8rem", + cursor: "pointer", + color: "var(--ctp-text)", backgroundColor: - i === highlighted ? 'var(--ctp-surface1)' : 'transparent', + i === highlighted ? "var(--ctp-surface1)" : "transparent", }} > {opt.label} diff --git a/web/src/components/items/BOMTab.tsx b/web/src/components/items/BOMTab.tsx index 0d1e64a..cf44fd2 100644 --- a/web/src/components/items/BOMTab.tsx +++ b/web/src/components/items/BOMTab.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from "react"; +import { Plus, Download } from "lucide-react"; import { get, post, put, del } from "../../api/client"; import type { BOMEntry } from "../../api/types"; @@ -233,9 +234,14 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) { onClick={() => { window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`; }} - style={toolBtnStyle} + style={{ + ...toolBtnStyle, + display: "inline-flex", + alignItems: "center", + gap: "0.35rem", + }} > - Export CSV + Export CSV {isEditor && ( )}
diff --git a/web/src/components/items/ItemDetail.tsx b/web/src/components/items/ItemDetail.tsx index f7fdf39..c663c82 100644 --- a/web/src/components/items/ItemDetail.tsx +++ b/web/src/components/items/ItemDetail.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { X } from "lucide-react"; import { get } from "../../api/client"; import type { Item } from "../../api/types"; import { MainTab } from "./MainTab"; @@ -131,9 +132,13 @@ export function ItemDetail({ )}
diff --git a/web/src/components/items/ItemTable.tsx b/web/src/components/items/ItemTable.tsx index 32b82fd..5f7b56d 100644 --- a/web/src/components/items/ItemTable.tsx +++ b/web/src/components/items/ItemTable.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from "react"; +import { ChevronUp, ChevronDown } from "lucide-react"; import type { Item } from "../../api/types"; import { ContextMenu, type ContextMenuItem } from "../ContextMenu"; @@ -191,8 +192,18 @@ export function ItemTable({ > {col.label} {sortKey === col.key && ( - - {sortDir === "asc" ? "▲" : "▼"} + + {sortDir === "asc" ? ( + + ) : ( + + )} )} diff --git a/web/src/components/items/ItemsToolbar.tsx b/web/src/components/items/ItemsToolbar.tsx index fdfd238..2a97c2f 100644 --- a/web/src/components/items/ItemsToolbar.tsx +++ b/web/src/components/items/ItemsToolbar.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { Columns2, Rows2, Plus, Download, Upload } from "lucide-react"; import { get } from "../../api/client"; import type { Project } from "../../api/types"; import type { ItemFilters } from "../../hooks/useItems"; @@ -127,20 +128,42 @@ export function ItemsToolbar({ onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal") } title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`} - style={toolBtnStyle} + style={{ + ...toolBtnStyle, + display: "inline-flex", + alignItems: "center", + }} > - {layout === "horizontal" ? "⬌" : "⬍"} + {layout === "horizontal" ? : } {/* Export */} - {/* Import (editor only) */} {isEditor && ( - )} @@ -152,9 +175,12 @@ export function ItemsToolbar({ ...toolBtnStyle, backgroundColor: "var(--ctp-mauve)", color: "var(--ctp-crust)", + display: "inline-flex", + alignItems: "center", + gap: "0.35rem", }} > - + New + New )}
diff --git a/web/src/components/items/MainTab.tsx b/web/src/components/items/MainTab.tsx index 52f43e1..95eafea 100644 --- a/web/src/components/items/MainTab.tsx +++ b/web/src/components/items/MainTab.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { X } from "lucide-react"; import { get, post, del } from "../../api/client"; import type { Item, Project, Revision } from "../../api/types"; @@ -192,11 +193,11 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) { border: "none", color: "var(--ctp-overlay0)", cursor: "pointer", - fontSize: "0.8rem", padding: 0, + display: "inline-flex", }} > - × + )} diff --git a/web/src/components/items/PropertiesTab.tsx b/web/src/components/items/PropertiesTab.tsx index 47fda08..d083260 100644 --- a/web/src/components/items/PropertiesTab.tsx +++ b/web/src/components/items/PropertiesTab.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; -import { post } from '../../api/client'; -import type { Item } from '../../api/types'; +import { useState } from "react"; +import { X, Plus } from "lucide-react"; +import { post } from "../../api/client"; +import type { Item } from "../../api/types"; interface PropertiesTabProps { item: Item; @@ -8,24 +9,24 @@ interface PropertiesTabProps { isEditor: boolean; } -type Mode = 'form' | 'json'; +type Mode = "form" | "json"; interface PropRow { key: string; value: string; - type: 'string' | 'number' | 'boolean'; + type: "string" | "number" | "boolean"; } -function detectType(v: unknown): PropRow['type'] { - if (typeof v === 'number') return 'number'; - if (typeof v === 'boolean') return 'boolean'; - return 'string'; +function detectType(v: unknown): PropRow["type"] { + if (typeof v === "number") return "number"; + if (typeof v === "boolean") return "boolean"; + return "string"; } function toRows(props: Record): PropRow[] { return Object.entries(props).map(([key, value]) => ({ key, - value: String(value ?? ''), + value: String(value ?? ""), type: detectType(value), })); } @@ -35,17 +36,26 @@ function fromRows(rows: PropRow[]): Record { for (const row of rows) { if (!row.key.trim()) continue; switch (row.type) { - case 'number': obj[row.key] = Number(row.value) || 0; break; - case 'boolean': obj[row.key] = row.value === 'true'; break; - default: obj[row.key] = row.value; + case "number": + obj[row.key] = Number(row.value) || 0; + break; + case "boolean": + obj[row.key] = row.value === "true"; + break; + default: + obj[row.key] = row.value; } } return obj; } -export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps) { +export function PropertiesTab({ + item, + onReload, + isEditor, +}: PropertiesTabProps) { const props = item.properties ?? {}; - const [mode, setMode] = useState('form'); + const [mode, setMode] = useState("form"); const [rows, setRows] = useState(toRows(props)); const [jsonText, setJsonText] = useState(JSON.stringify(props, null, 2)); const [jsonError, setJsonError] = useState(null); @@ -62,18 +72,20 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps) setRows(toRows(parsed)); setJsonError(null); } catch (e) { - setJsonError(e instanceof Error ? e.message : 'Invalid JSON'); + setJsonError(e instanceof Error ? e.message : "Invalid JSON"); } }; const switchMode = (m: Mode) => { - if (m === 'json') syncFormToJson(); + if (m === "json") syncFormToJson(); else syncJsonToForm(); setMode(m); }; const updateRow = (idx: number, field: keyof PropRow, value: string) => { - setRows((prev) => prev.map((r, i) => i === idx ? { ...r, [field]: value } : r)); + setRows((prev) => + prev.map((r, i) => (i === idx ? { ...r, [field]: value } : r)), + ); }; const removeRow = (idx: number) => { @@ -81,72 +93,112 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps) }; const addRow = () => { - setRows((prev) => [...prev, { key: '', value: '', type: 'string' }]); + setRows((prev) => [...prev, { key: "", value: "", type: "string" }]); }; const handleSave = async () => { let properties: Record; - if (mode === 'json') { + if (mode === "json") { try { properties = JSON.parse(jsonText) as Record; } catch { - setJsonError('Invalid JSON'); + setJsonError("Invalid JSON"); return; } } else { properties = fromRows(rows); } - const comment = prompt('Revision comment (optional):') ?? ''; + const comment = prompt("Revision comment (optional):") ?? ""; setSaving(true); try { - await post(`/api/items/${encodeURIComponent(item.part_number)}/revisions`, { properties, comment }); + await post( + `/api/items/${encodeURIComponent(item.part_number)}/revisions`, + { properties, comment }, + ); onReload(); } catch (e) { - alert(e instanceof Error ? e.message : 'Failed to save properties'); + alert(e instanceof Error ? e.message : "Failed to save properties"); } finally { setSaving(false); } }; const inputStyle: React.CSSProperties = { - padding: '0.25rem 0.4rem', fontSize: '0.8rem', - backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)', - borderRadius: '0.3rem', color: 'var(--ctp-text)', + padding: "0.25rem 0.4rem", + fontSize: "0.8rem", + backgroundColor: "var(--ctp-base)", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.3rem", + color: "var(--ctp-text)", }; return (
{/* Mode toggle */} -
- - +
+ + {isEditor && ( - )}
- {mode === 'form' ? ( + {mode === "form" ? (
{rows.map((row, idx) => ( -
+
updateRow(idx, 'key', e.target.value)} + onChange={(e) => updateRow(idx, "key", e.target.value)} placeholder="Key" style={{ ...inputStyle, width: 140 }} disabled={!isEditor} /> - {row.type === 'boolean' ? ( - updateRow(idx, "value", e.target.value)} + style={{ ...inputStyle, flex: 1 }} + disabled={!isEditor} + > ) : ( updateRow(idx, 'value', e.target.value)} + onChange={(e) => updateRow(idx, "value", e.target.value)} placeholder="Value" style={{ ...inputStyle, flex: 1 }} disabled={!isEditor} /> )} {isEditor && ( - + )}
))} {isEditor && ( - + )}
) : (