feat(web): install lucide-react and replace unicode icons (#67)

This commit is contained in:
Forbes
2026-02-13 13:44:48 -06:00
parent 8316ac085c
commit b53ce94274
13 changed files with 631 additions and 256 deletions

10
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "silo-web", "name": "silo-web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"lucide-react": "^0.564.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0"
@@ -1499,6 +1500,15 @@
"yallist": "^3.0.2" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^0.564.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0" "react-router-dom": "^7.0.0"

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from "react";
import { Check } from "lucide-react";
export interface ContextMenuItem { export interface ContextMenuItem {
label: string; 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(); if (ref.current && !ref.current.contains(e.target as Node)) onClose();
}; };
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === "Escape") onClose();
}; };
const handleScroll = () => onClose(); const handleScroll = () => onClose();
document.addEventListener('mousedown', handleClick); document.addEventListener("mousedown", handleClick);
document.addEventListener('keydown', handleKey); document.addEventListener("keydown", handleKey);
window.addEventListener('scroll', handleScroll, true); window.addEventListener("scroll", handleScroll, true);
return () => { return () => {
document.removeEventListener('mousedown', handleClick); document.removeEventListener("mousedown", handleClick);
document.removeEventListener('keydown', handleKey); document.removeEventListener("keydown", handleKey);
window.removeEventListener('scroll', handleScroll, true); window.removeEventListener("scroll", handleScroll, true);
}; };
}, [onClose]); }, [onClose]);
// Clamp position to viewport // Clamp position to viewport
const style: React.CSSProperties = { const style: React.CSSProperties = {
position: 'fixed', position: "fixed",
left: Math.min(x, window.innerWidth - 220), left: Math.min(x, window.innerWidth - 220),
top: Math.min(y, window.innerHeight - items.length * 32 - 16), top: Math.min(y, window.innerHeight - items.length * 32 - 16),
zIndex: 9999, zIndex: 9999,
backgroundColor: 'var(--ctp-surface0)', backgroundColor: "var(--ctp-surface0)",
border: '1px solid var(--ctp-surface1)', border: "1px solid var(--ctp-surface1)",
borderRadius: '0.5rem', borderRadius: "0.5rem",
padding: '0.25rem 0', padding: "0.25rem 0",
minWidth: 200, minWidth: 200,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)', boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
}; };
return ( return (
<div ref={ref} style={style}> <div ref={ref} style={style}>
{items.map((item, i) => {items.map((item, i) =>
item.divider ? ( item.divider ? (
<div key={i} style={{ borderTop: '1px solid var(--ctp-surface1)', margin: '0.25rem 0' }} /> <div
key={i}
style={{
borderTop: "1px solid var(--ctp-surface1)",
margin: "0.25rem 0",
}}
/>
) : ( ) : (
<button <button
key={i} key={i}
onClick={() => { onClick={() => {
if (item.onToggle) item.onToggle(); if (item.onToggle) item.onToggle();
else if (item.onClick) { item.onClick(); onClose(); } else if (item.onClick) {
item.onClick();
onClose();
}
}} }}
disabled={item.disabled} disabled={item.disabled}
style={{ style={{
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
gap: '0.5rem', gap: "0.5rem",
width: '100%', width: "100%",
padding: '0.35rem 0.75rem', padding: "0.35rem 0.75rem",
background: 'none', background: "none",
border: 'none', border: "none",
color: item.disabled ? 'var(--ctp-overlay0)' : 'var(--ctp-text)', color: item.disabled ? "var(--ctp-overlay0)" : "var(--ctp-text)",
fontSize: '0.85rem', fontSize: "0.85rem",
cursor: item.disabled ? 'default' : 'pointer', cursor: item.disabled ? "default" : "pointer",
textAlign: 'left', textAlign: "left",
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!item.disabled) e.currentTarget.style.backgroundColor = 'var(--ctp-surface1)'; if (!item.disabled)
e.currentTarget.style.backgroundColor = "var(--ctp-surface1)";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.backgroundColor = "transparent";
}} }}
> >
{item.checked !== undefined && ( {item.checked !== undefined && (
<span style={{ <span
width: 16, height: 16, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', style={{
border: '1px solid var(--ctp-overlay0)', borderRadius: 3, width: 16,
backgroundColor: item.checked ? 'var(--ctp-mauve)' : 'transparent', height: 16,
color: item.checked ? 'var(--ctp-crust)' : 'transparent', display: "inline-flex",
fontSize: '0.7rem', fontWeight: 700, flexShrink: 0, alignItems: "center",
}}> justifyContent: "center",
{item.checked ? '✓' : ''} border: "1px solid var(--ctp-overlay0)",
borderRadius: 3,
backgroundColor: item.checked
? "var(--ctp-mauve)"
: "transparent",
color: item.checked ? "var(--ctp-crust)" : "transparent",
flexShrink: 0,
}}
>
{item.checked ? <Check size={14} /> : ""}
</span> </span>
)} )}
{item.label} {item.label}

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback } from "react";
import { X } from "lucide-react";
export interface TagOption { export interface TagOption {
id: string; id: string;
@@ -12,34 +13,45 @@ interface TagInputProps {
searchFn: (query: string) => Promise<TagOption[]>; searchFn: (query: string) => Promise<TagOption[]>;
} }
export function TagInput({ value, onChange, placeholder, searchFn }: TagInputProps) { export function TagInput({
const [query, setQuery] = useState(''); value,
onChange,
placeholder,
searchFn,
}: TagInputProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<TagOption[]>([]); const [results, setResults] = useState<TagOption[]>([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0); const [highlighted, setHighlighted] = useState(0);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
// Debounced search // Debounced search
const search = useCallback( const search = useCallback(
(q: string) => { (q: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
if (q.trim() === '') { if (q.trim() === "") {
// Show all results when input is empty but focused // Show all results when input is empty but focused
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
searchFn('').then((opts) => { searchFn("")
setResults(opts.filter((o) => !value.includes(o.id))); .then((opts) => {
setHighlighted(0); setResults(opts.filter((o) => !value.includes(o.id)));
}).catch(() => setResults([])); setHighlighted(0);
})
.catch(() => setResults([]));
}, 100); }, 100);
return; return;
} }
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
searchFn(q).then((opts) => { searchFn(q)
setResults(opts.filter((o) => !value.includes(o.id))); .then((opts) => {
setHighlighted(0); setResults(opts.filter((o) => !value.includes(o.id)));
}).catch(() => setResults([])); setHighlighted(0);
})
.catch(() => setResults([]));
}, 200); }, 200);
}, },
[searchFn, value], [searchFn, value],
@@ -53,17 +65,20 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
// Close on click outside // Close on click outside
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false); setOpen(false);
} }
}; };
document.addEventListener('mousedown', handler); document.addEventListener("mousedown", handler);
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener("mousedown", handler);
}, []); }, []);
const select = (id: string) => { const select = (id: string) => {
onChange([...value, id]); onChange([...value, id]);
setQuery(''); setQuery("");
setOpen(false); setOpen(false);
inputRef.current?.focus(); inputRef.current?.focus();
}; };
@@ -73,22 +88,22 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && query === '' && value.length > 0) { if (e.key === "Backspace" && query === "" && value.length > 0) {
onChange(value.slice(0, -1)); onChange(value.slice(0, -1));
return; return;
} }
if (e.key === 'Escape') { if (e.key === "Escape") {
setOpen(false); setOpen(false);
return; return;
} }
if (!open || results.length === 0) return; if (!open || results.length === 0) return;
if (e.key === 'ArrowDown') { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
setHighlighted((h) => (h + 1) % results.length); setHighlighted((h) => (h + 1) % results.length);
} else if (e.key === 'ArrowUp') { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
setHighlighted((h) => (h - 1 + results.length) % results.length); setHighlighted((h) => (h - 1 + results.length) % results.length);
} else if (e.key === 'Enter') { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (results[highlighted]) select(results[highlighted].id); if (results[highlighted]) select(results[highlighted].id);
} }
@@ -99,19 +114,19 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
for (const r of results) labelMap.current.set(r.id, r.label); for (const r of results) labelMap.current.set(r.id, r.label);
return ( return (
<div ref={containerRef} style={{ position: 'relative' }}> <div ref={containerRef} style={{ position: "relative" }}>
<div <div
style={{ style={{
display: 'flex', display: "flex",
flexWrap: 'wrap', flexWrap: "wrap",
alignItems: 'center', alignItems: "center",
gap: '0.25rem', gap: "0.25rem",
padding: '0.25rem 0.5rem', padding: "0.25rem 0.5rem",
backgroundColor: 'var(--ctp-base)', backgroundColor: "var(--ctp-base)",
border: '1px solid var(--ctp-surface1)', border: "1px solid var(--ctp-surface1)",
borderRadius: '0.3rem', borderRadius: "0.3rem",
cursor: 'text', cursor: "text",
minHeight: '1.8rem', minHeight: "1.8rem",
}} }}
onClick={() => inputRef.current?.focus()} onClick={() => inputRef.current?.focus()}
> >
@@ -119,14 +134,14 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
<span <span
key={id} key={id}
style={{ style={{
display: 'inline-flex', display: "inline-flex",
alignItems: 'center', alignItems: "center",
gap: '0.25rem', gap: "0.25rem",
padding: '0.1rem 0.5rem', padding: "0.1rem 0.5rem",
borderRadius: '1rem', borderRadius: "1rem",
backgroundColor: 'rgba(203,166,247,0.15)', backgroundColor: "rgba(203,166,247,0.15)",
color: 'var(--ctp-mauve)', color: "var(--ctp-mauve)",
fontSize: '0.75rem', fontSize: "0.75rem",
}} }}
> >
{labelMap.current.get(id) ?? id} {labelMap.current.get(id) ?? id}
@@ -137,16 +152,16 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
remove(id); remove(id);
}} }}
style={{ style={{
background: 'none', background: "none",
border: 'none', border: "none",
cursor: 'pointer', cursor: "pointer",
color: 'var(--ctp-mauve)', color: "var(--ctp-mauve)",
padding: 0, padding: 0,
fontSize: '0.8rem',
lineHeight: 1, lineHeight: 1,
display: "inline-flex",
}} }}
> >
× <X size={14} />
</button> </button>
</span> </span>
))} ))}
@@ -166,30 +181,30 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
placeholder={value.length === 0 ? placeholder : undefined} placeholder={value.length === 0 ? placeholder : undefined}
style={{ style={{
flex: 1, flex: 1,
minWidth: '4rem', minWidth: "4rem",
border: 'none', border: "none",
outline: 'none', outline: "none",
background: 'transparent', background: "transparent",
color: 'var(--ctp-text)', color: "var(--ctp-text)",
fontSize: '0.85rem', fontSize: "0.85rem",
padding: '0.1rem 0', padding: "0.1rem 0",
}} }}
/> />
</div> </div>
{open && results.length > 0 && ( {open && results.length > 0 && (
<div <div
style={{ style={{
position: 'absolute', position: "absolute",
top: '100%', top: "100%",
left: 0, left: 0,
right: 0, right: 0,
zIndex: 10, zIndex: 10,
marginTop: '0.2rem', marginTop: "0.2rem",
backgroundColor: 'var(--ctp-surface0)', backgroundColor: "var(--ctp-surface0)",
border: '1px solid var(--ctp-surface1)', border: "1px solid var(--ctp-surface1)",
borderRadius: '0.3rem', borderRadius: "0.3rem",
maxHeight: '160px', maxHeight: "160px",
overflowY: 'auto', overflowY: "auto",
}} }}
> >
{results.map((opt, i) => ( {results.map((opt, i) => (
@@ -201,15 +216,15 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
}} }}
onMouseEnter={() => setHighlighted(i)} onMouseEnter={() => setHighlighted(i)}
style={{ style={{
padding: '0.25rem 0.5rem', padding: "0.25rem 0.5rem",
height: '28px', height: "28px",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
fontSize: '0.8rem', fontSize: "0.8rem",
cursor: 'pointer', cursor: "pointer",
color: 'var(--ctp-text)', color: "var(--ctp-text)",
backgroundColor: backgroundColor:
i === highlighted ? 'var(--ctp-surface1)' : 'transparent', i === highlighted ? "var(--ctp-surface1)" : "transparent",
}} }}
> >
{opt.label} {opt.label}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { Plus, Download } from "lucide-react";
import { get, post, put, del } from "../../api/client"; import { get, post, put, del } from "../../api/client";
import type { BOMEntry } from "../../api/types"; import type { BOMEntry } from "../../api/types";
@@ -233,9 +234,14 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
onClick={() => { onClick={() => {
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`; window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`;
}} }}
style={toolBtnStyle} style={{
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
> >
Export CSV <Download size={14} /> Export CSV
</button> </button>
{isEditor && ( {isEditor && (
<button <button
@@ -244,9 +250,14 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
setEditIdx(null); setEditIdx(null);
setForm(emptyForm); setForm(emptyForm);
}} }}
style={toolBtnStyle} style={{
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
> >
+ Add <Plus size={14} /> Add
</button> </button>
)} )}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { X } from "lucide-react";
import { get } from "../../api/client"; import { get } from "../../api/client";
import type { Item } from "../../api/types"; import type { Item } from "../../api/types";
import { MainTab } from "./MainTab"; import { MainTab } from "./MainTab";
@@ -131,9 +132,13 @@ export function ItemDetail({
)} )}
<button <button
onClick={onClose} onClick={onClose}
style={{ ...headerBtnStyle, fontSize: "1rem" }} style={{
...headerBtnStyle,
display: "inline-flex",
alignItems: "center",
}}
> >
× <X size={14} />
</button> </button>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { ChevronUp, ChevronDown } from "lucide-react";
import type { Item } from "../../api/types"; import type { Item } from "../../api/types";
import { ContextMenu, type ContextMenuItem } from "../ContextMenu"; import { ContextMenu, type ContextMenuItem } from "../ContextMenu";
@@ -191,8 +192,18 @@ export function ItemTable({
> >
{col.label} {col.label}
{sortKey === col.key && ( {sortKey === col.key && (
<span style={{ marginLeft: 4 }}> <span
{sortDir === "asc" ? "▲" : "▼"} style={{
marginLeft: 4,
display: "inline-flex",
verticalAlign: "middle",
}}
>
{sortDir === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)}
</span> </span>
)} )}
</th> </th>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Columns2, Rows2, Plus, Download, Upload } from "lucide-react";
import { get } from "../../api/client"; import { get } from "../../api/client";
import type { Project } from "../../api/types"; import type { Project } from "../../api/types";
import type { ItemFilters } from "../../hooks/useItems"; import type { ItemFilters } from "../../hooks/useItems";
@@ -127,20 +128,42 @@ export function ItemsToolbar({
onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal") onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal")
} }
title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`} title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`}
style={toolBtnStyle} style={{
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
}}
> >
{layout === "horizontal" ? "⬌" : "⬍"} {layout === "horizontal" ? <Columns2 size={14} /> : <Rows2 size={14} />}
</button> </button>
{/* Export */} {/* Export */}
<button onClick={onExport} style={toolBtnStyle} title="Export CSV"> <button
Export onClick={onExport}
style={{
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
title="Export CSV"
>
<Download size={14} /> Export
</button> </button>
{/* Import (editor only) */} {/* Import (editor only) */}
{isEditor && ( {isEditor && (
<button onClick={onImport} style={toolBtnStyle} title="Import CSV"> <button
Import onClick={onImport}
style={{
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
title="Import CSV"
>
<Upload size={14} /> Import
</button> </button>
)} )}
@@ -152,9 +175,12 @@ export function ItemsToolbar({
...toolBtnStyle, ...toolBtnStyle,
backgroundColor: "var(--ctp-mauve)", backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)", color: "var(--ctp-crust)",
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}} }}
> >
+ New <Plus size={14} /> New
</button> </button>
)} )}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { X } from "lucide-react";
import { get, post, del } from "../../api/client"; import { get, post, del } from "../../api/client";
import type { Item, Project, Revision } from "../../api/types"; import type { Item, Project, Revision } from "../../api/types";
@@ -192,11 +193,11 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
border: "none", border: "none",
color: "var(--ctp-overlay0)", color: "var(--ctp-overlay0)",
cursor: "pointer", cursor: "pointer",
fontSize: "0.8rem",
padding: 0, padding: 0,
display: "inline-flex",
}} }}
> >
× <X size={14} />
</button> </button>
)} )}
</span> </span>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from "react";
import { post } from '../../api/client'; import { X, Plus } from "lucide-react";
import type { Item } from '../../api/types'; import { post } from "../../api/client";
import type { Item } from "../../api/types";
interface PropertiesTabProps { interface PropertiesTabProps {
item: Item; item: Item;
@@ -8,24 +9,24 @@ interface PropertiesTabProps {
isEditor: boolean; isEditor: boolean;
} }
type Mode = 'form' | 'json'; type Mode = "form" | "json";
interface PropRow { interface PropRow {
key: string; key: string;
value: string; value: string;
type: 'string' | 'number' | 'boolean'; type: "string" | "number" | "boolean";
} }
function detectType(v: unknown): PropRow['type'] { function detectType(v: unknown): PropRow["type"] {
if (typeof v === 'number') return 'number'; if (typeof v === "number") return "number";
if (typeof v === 'boolean') return 'boolean'; if (typeof v === "boolean") return "boolean";
return 'string'; return "string";
} }
function toRows(props: Record<string, unknown>): PropRow[] { function toRows(props: Record<string, unknown>): PropRow[] {
return Object.entries(props).map(([key, value]) => ({ return Object.entries(props).map(([key, value]) => ({
key, key,
value: String(value ?? ''), value: String(value ?? ""),
type: detectType(value), type: detectType(value),
})); }));
} }
@@ -35,17 +36,26 @@ function fromRows(rows: PropRow[]): Record<string, unknown> {
for (const row of rows) { for (const row of rows) {
if (!row.key.trim()) continue; if (!row.key.trim()) continue;
switch (row.type) { switch (row.type) {
case 'number': obj[row.key] = Number(row.value) || 0; break; case "number":
case 'boolean': obj[row.key] = row.value === 'true'; break; obj[row.key] = Number(row.value) || 0;
default: obj[row.key] = row.value; break;
case "boolean":
obj[row.key] = row.value === "true";
break;
default:
obj[row.key] = row.value;
} }
} }
return obj; return obj;
} }
export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps) { export function PropertiesTab({
item,
onReload,
isEditor,
}: PropertiesTabProps) {
const props = item.properties ?? {}; const props = item.properties ?? {};
const [mode, setMode] = useState<Mode>('form'); const [mode, setMode] = useState<Mode>("form");
const [rows, setRows] = useState<PropRow[]>(toRows(props)); const [rows, setRows] = useState<PropRow[]>(toRows(props));
const [jsonText, setJsonText] = useState(JSON.stringify(props, null, 2)); const [jsonText, setJsonText] = useState(JSON.stringify(props, null, 2));
const [jsonError, setJsonError] = useState<string | null>(null); const [jsonError, setJsonError] = useState<string | null>(null);
@@ -62,18 +72,20 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
setRows(toRows(parsed)); setRows(toRows(parsed));
setJsonError(null); setJsonError(null);
} catch (e) { } catch (e) {
setJsonError(e instanceof Error ? e.message : 'Invalid JSON'); setJsonError(e instanceof Error ? e.message : "Invalid JSON");
} }
}; };
const switchMode = (m: Mode) => { const switchMode = (m: Mode) => {
if (m === 'json') syncFormToJson(); if (m === "json") syncFormToJson();
else syncJsonToForm(); else syncJsonToForm();
setMode(m); setMode(m);
}; };
const updateRow = (idx: number, field: keyof PropRow, value: string) => { 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) => { const removeRow = (idx: number) => {
@@ -81,72 +93,112 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
}; };
const addRow = () => { const addRow = () => {
setRows((prev) => [...prev, { key: '', value: '', type: 'string' }]); setRows((prev) => [...prev, { key: "", value: "", type: "string" }]);
}; };
const handleSave = async () => { const handleSave = async () => {
let properties: Record<string, unknown>; let properties: Record<string, unknown>;
if (mode === 'json') { if (mode === "json") {
try { try {
properties = JSON.parse(jsonText) as Record<string, unknown>; properties = JSON.parse(jsonText) as Record<string, unknown>;
} catch { } catch {
setJsonError('Invalid JSON'); setJsonError("Invalid JSON");
return; return;
} }
} else { } else {
properties = fromRows(rows); properties = fromRows(rows);
} }
const comment = prompt('Revision comment (optional):') ?? ''; const comment = prompt("Revision comment (optional):") ?? "";
setSaving(true); setSaving(true);
try { try {
await post(`/api/items/${encodeURIComponent(item.part_number)}/revisions`, { properties, comment }); await post(
`/api/items/${encodeURIComponent(item.part_number)}/revisions`,
{ properties, comment },
);
onReload(); onReload();
} catch (e) { } catch (e) {
alert(e instanceof Error ? e.message : 'Failed to save properties'); alert(e instanceof Error ? e.message : "Failed to save properties");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
padding: '0.25rem 0.4rem', fontSize: '0.8rem', padding: "0.25rem 0.4rem",
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)', fontSize: "0.8rem",
borderRadius: '0.3rem', color: 'var(--ctp-text)', backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
color: "var(--ctp-text)",
}; };
return ( return (
<div> <div>
{/* Mode toggle */} {/* Mode toggle */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem', alignItems: 'center' }}> <div
<button onClick={() => switchMode('form')} style={mode === 'form' ? activeTabBtn : tabBtn}>Form</button> style={{
<button onClick={() => switchMode('json')} style={mode === 'json' ? activeTabBtn : tabBtn}>JSON</button> display: "flex",
gap: "0.5rem",
marginBottom: "0.5rem",
alignItems: "center",
}}
>
<button
onClick={() => switchMode("form")}
style={mode === "form" ? activeTabBtn : tabBtn}
>
Form
</button>
<button
onClick={() => switchMode("json")}
style={mode === "json" ? activeTabBtn : tabBtn}
>
JSON
</button>
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
{isEditor && ( {isEditor && (
<button onClick={() => void handleSave()} disabled={saving} style={{ <button
padding: '0.3rem 0.75rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem', onClick={() => void handleSave()}
backgroundColor: 'var(--ctp-mauve)', color: 'var(--ctp-crust)', cursor: 'pointer', disabled={saving}
opacity: saving ? 0.6 : 1, style={{
}}> padding: "0.3rem 0.75rem",
{saving ? 'Saving...' : 'Save (New Revision)'} fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
cursor: "pointer",
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Saving..." : "Save (New Revision)"}
</button> </button>
)} )}
</div> </div>
{mode === 'form' ? ( {mode === "form" ? (
<div> <div>
{rows.map((row, idx) => ( {rows.map((row, idx) => (
<div key={idx} style={{ display: 'flex', gap: '0.3rem', marginBottom: '0.25rem', alignItems: 'center' }}> <div
key={idx}
style={{
display: "flex",
gap: "0.3rem",
marginBottom: "0.25rem",
alignItems: "center",
}}
>
<input <input
value={row.key} value={row.key}
onChange={(e) => updateRow(idx, 'key', e.target.value)} onChange={(e) => updateRow(idx, "key", e.target.value)}
placeholder="Key" placeholder="Key"
style={{ ...inputStyle, width: 140 }} style={{ ...inputStyle, width: 140 }}
disabled={!isEditor} disabled={!isEditor}
/> />
<select <select
value={row.type} value={row.type}
onChange={(e) => updateRow(idx, 'type', e.target.value)} onChange={(e) => updateRow(idx, "type", e.target.value)}
style={{ ...inputStyle, width: 80 }} style={{ ...inputStyle, width: 80 }}
disabled={!isEditor} disabled={!isEditor}
> >
@@ -154,44 +206,90 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
<option value="number">num</option> <option value="number">num</option>
<option value="boolean">bool</option> <option value="boolean">bool</option>
</select> </select>
{row.type === 'boolean' ? ( {row.type === "boolean" ? (
<select value={row.value} onChange={(e) => updateRow(idx, 'value', e.target.value)} style={{ ...inputStyle, flex: 1 }} disabled={!isEditor}> <select
value={row.value}
onChange={(e) => updateRow(idx, "value", e.target.value)}
style={{ ...inputStyle, flex: 1 }}
disabled={!isEditor}
>
<option value="true">true</option> <option value="true">true</option>
<option value="false">false</option> <option value="false">false</option>
</select> </select>
) : ( ) : (
<input <input
type={row.type === 'number' ? 'number' : 'text'} type={row.type === "number" ? "number" : "text"}
value={row.value} value={row.value}
onChange={(e) => updateRow(idx, 'value', e.target.value)} onChange={(e) => updateRow(idx, "value", e.target.value)}
placeholder="Value" placeholder="Value"
style={{ ...inputStyle, flex: 1 }} style={{ ...inputStyle, flex: 1 }}
disabled={!isEditor} disabled={!isEditor}
/> />
)} )}
{isEditor && ( {isEditor && (
<button onClick={() => removeRow(idx)} style={{ background: 'none', border: 'none', color: 'var(--ctp-red)', cursor: 'pointer', fontSize: '0.9rem' }}>×</button> <button
onClick={() => removeRow(idx)}
style={{
background: "none",
border: "none",
color: "var(--ctp-red)",
cursor: "pointer",
display: "inline-flex",
}}
>
<X size={14} />
</button>
)} )}
</div> </div>
))} ))}
{isEditor && ( {isEditor && (
<button onClick={addRow} style={{ ...tabBtn, marginTop: '0.25rem' }}>+ Add Property</button> <button
onClick={addRow}
style={{
...tabBtn,
marginTop: "0.25rem",
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
>
<Plus size={14} /> Add Property
</button>
)} )}
</div> </div>
) : ( ) : (
<div> <div>
<textarea <textarea
value={jsonText} value={jsonText}
onChange={(e) => { setJsonText(e.target.value); setJsonError(null); }} onChange={(e) => {
setJsonText(e.target.value);
setJsonError(null);
}}
disabled={!isEditor} disabled={!isEditor}
style={{ style={{
width: '100%', minHeight: 200, padding: '0.5rem', width: "100%",
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8rem', minHeight: 200,
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)', padding: "0.5rem",
borderRadius: '0.4rem', color: 'var(--ctp-text)', resize: 'vertical', fontFamily: "'JetBrains Mono', monospace",
fontSize: "0.8rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
color: "var(--ctp-text)",
resize: "vertical",
}} }}
/> />
{jsonError && <div style={{ color: 'var(--ctp-red)', fontSize: '0.8rem', marginTop: '0.25rem' }}>{jsonError}</div>} {jsonError && (
<div
style={{
color: "var(--ctp-red)",
fontSize: "0.8rem",
marginTop: "0.25rem",
}}
>
{jsonError}
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -199,11 +297,17 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
} }
const tabBtn: React.CSSProperties = { const tabBtn: React.CSSProperties = {
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem', padding: "0.25rem 0.5rem",
backgroundColor: 'var(--ctp-surface0)', color: 'var(--ctp-subtext1)', cursor: 'pointer', fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-subtext1)",
cursor: "pointer",
}; };
const activeTabBtn: React.CSSProperties = { const activeTabBtn: React.CSSProperties = {
...tabBtn, ...tabBtn,
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-mauve)', backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-mauve)",
}; };

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { get, post } from '../../api/client'; import { Download } from "lucide-react";
import type { Revision, RevisionComparison } from '../../api/types'; import { get, post } from "../../api/client";
import type { Revision, RevisionComparison } from "../../api/types";
interface RevisionsTabProps { interface RevisionsTabProps {
partNumber: string; partNumber: string;
@@ -8,28 +9,35 @@ interface RevisionsTabProps {
} }
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
draft: 'var(--ctp-overlay1)', draft: "var(--ctp-overlay1)",
review: 'var(--ctp-yellow)', review: "var(--ctp-yellow)",
released: 'var(--ctp-green)', released: "var(--ctp-green)",
obsolete: 'var(--ctp-red)', obsolete: "var(--ctp-red)",
}; };
function formatDate(s: string) { function formatDate(s: string) {
if (!s) return ''; if (!s) return "";
return new Date(s).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); return new Date(s).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
} }
export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) { export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
const [revisions, setRevisions] = useState<Revision[]>([]); const [revisions, setRevisions] = useState<Revision[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [compareFrom, setCompareFrom] = useState(''); const [compareFrom, setCompareFrom] = useState("");
const [compareTo, setCompareTo] = useState(''); const [compareTo, setCompareTo] = useState("");
const [comparison, setComparison] = useState<RevisionComparison | null>(null); const [comparison, setComparison] = useState<RevisionComparison | null>(null);
const load = () => { const load = () => {
setLoading(true); setLoading(true);
get<Revision[]>(`/api/items/${encodeURIComponent(partNumber)}/revisions`) get<Revision[]>(`/api/items/${encodeURIComponent(partNumber)}/revisions`)
.then((r) => { setRevisions(r); setLoading(false); }) .then((r) => {
setRevisions(r);
setLoading(false);
})
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}; };
@@ -39,97 +47,177 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
if (!compareFrom || !compareTo) return; if (!compareFrom || !compareTo) return;
try { try {
const result = await get<RevisionComparison>( const result = await get<RevisionComparison>(
`/api/items/${encodeURIComponent(partNumber)}/revisions/compare?from=${compareFrom}&to=${compareTo}` `/api/items/${encodeURIComponent(partNumber)}/revisions/compare?from=${compareFrom}&to=${compareTo}`,
); );
setComparison(result); setComparison(result);
} catch (e) { } catch (e) {
alert(e instanceof Error ? e.message : 'Compare failed'); alert(e instanceof Error ? e.message : "Compare failed");
} }
}; };
const handleStatusChange = async (rev: number, status: string) => { const handleStatusChange = async (rev: number, status: string) => {
try { try {
await fetch(`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}`, { await fetch(
method: 'PATCH', `/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}`,
headers: { 'Content-Type': 'application/json' }, {
credentials: 'include', method: "PATCH",
body: JSON.stringify({ status }), headers: { "Content-Type": "application/json" },
}); credentials: "include",
body: JSON.stringify({ status }),
},
);
load(); load();
} catch (e) { } catch (e) {
alert(e instanceof Error ? e.message : 'Status update failed'); alert(e instanceof Error ? e.message : "Status update failed");
} }
}; };
const handleRollback = async (rev: number) => { const handleRollback = async (rev: number) => {
if (!confirm(`Rollback to revision ${rev}? This creates a new revision with data from rev ${rev}.`)) return; if (
const comment = prompt('Rollback comment:') ?? `Rollback to rev ${rev}`; !confirm(
`Rollback to revision ${rev}? This creates a new revision with data from rev ${rev}.`,
)
)
return;
const comment = prompt("Rollback comment:") ?? `Rollback to rev ${rev}`;
try { try {
await post(`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`, { comment }); await post(
`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`,
{ comment },
);
load(); load();
} catch (e) { } catch (e) {
alert(e instanceof Error ? e.message : 'Rollback failed'); alert(e instanceof Error ? e.message : "Rollback failed");
} }
}; };
if (loading) return <div style={{ color: 'var(--ctp-subtext0)' }}>Loading revisions...</div>; if (loading)
return (
<div style={{ color: "var(--ctp-subtext0)" }}>Loading revisions...</div>
);
const selectStyle: React.CSSProperties = { const selectStyle: React.CSSProperties = {
padding: '0.25rem 0.4rem', fontSize: '0.8rem', padding: "0.25rem 0.4rem",
backgroundColor: 'var(--ctp-surface0)', border: '1px solid var(--ctp-surface1)', fontSize: "0.8rem",
borderRadius: '0.3rem', color: 'var(--ctp-text)', backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
color: "var(--ctp-text)",
}; };
return ( return (
<div> <div>
{/* Compare controls */} {/* Compare controls */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem' }}> <div
<select value={compareFrom} onChange={(e) => setCompareFrom(e.target.value)} style={selectStyle}> style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
marginBottom: "0.75rem",
}}
>
<select
value={compareFrom}
onChange={(e) => setCompareFrom(e.target.value)}
style={selectStyle}
>
<option value="">From rev...</option> <option value="">From rev...</option>
{revisions.map((r) => <option key={r.id} value={r.revision_number}>Rev {r.revision_number}</option>)} {revisions.map((r) => (
<option key={r.id} value={r.revision_number}>
Rev {r.revision_number}
</option>
))}
</select> </select>
<select value={compareTo} onChange={(e) => setCompareTo(e.target.value)} style={selectStyle}> <select
value={compareTo}
onChange={(e) => setCompareTo(e.target.value)}
style={selectStyle}
>
<option value="">To rev...</option> <option value="">To rev...</option>
{revisions.map((r) => <option key={r.id} value={r.revision_number}>Rev {r.revision_number}</option>)} {revisions.map((r) => (
<option key={r.id} value={r.revision_number}>
Rev {r.revision_number}
</option>
))}
</select> </select>
<button onClick={() => void handleCompare()} disabled={!compareFrom || !compareTo} style={{ <button
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem', onClick={() => void handleCompare()}
backgroundColor: 'var(--ctp-mauve)', color: 'var(--ctp-crust)', cursor: 'pointer', disabled={!compareFrom || !compareTo}
opacity: (!compareFrom || !compareTo) ? 0.5 : 1, style={{
}}> padding: "0.25rem 0.5rem",
fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
cursor: "pointer",
opacity: !compareFrom || !compareTo ? 0.5 : 1,
}}
>
Compare Compare
</button> </button>
</div> </div>
{/* Compare results */} {/* Compare results */}
{comparison && ( {comparison && (
<div style={{ <div
padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem', style={{
fontSize: '0.8rem', marginBottom: '0.75rem', fontFamily: "'JetBrains Mono', monospace", padding: "0.5rem",
}}> backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
fontSize: "0.8rem",
marginBottom: "0.75rem",
fontFamily: "'JetBrains Mono', monospace",
}}
>
{comparison.status_changed && ( {comparison.status_changed && (
<div>Status: <span style={{ color: 'var(--ctp-red)' }}>{comparison.status_changed.from}</span> <span style={{ color: 'var(--ctp-green)' }}>{comparison.status_changed.to}</span></div> <div>
Status:{" "}
<span style={{ color: "var(--ctp-red)" }}>
{comparison.status_changed.from}
</span>{" "}
{" "}
<span style={{ color: "var(--ctp-green)" }}>
{comparison.status_changed.to}
</span>
</div>
)}
{comparison.file_changed && (
<div style={{ color: "var(--ctp-yellow)" }}>File changed</div>
)} )}
{comparison.file_changed && <div style={{ color: 'var(--ctp-yellow)' }}>File changed</div>}
{Object.entries(comparison.added).map(([k, v]) => ( {Object.entries(comparison.added).map(([k, v]) => (
<div key={k} style={{ color: 'var(--ctp-green)' }}>+ {k}: {String(v)}</div> <div key={k} style={{ color: "var(--ctp-green)" }}>
+ {k}: {String(v)}
</div>
))} ))}
{Object.entries(comparison.removed).map(([k, v]) => ( {Object.entries(comparison.removed).map(([k, v]) => (
<div key={k} style={{ color: 'var(--ctp-red)' }}>- {k}: {String(v)}</div> <div key={k} style={{ color: "var(--ctp-red)" }}>
- {k}: {String(v)}
</div>
))} ))}
{Object.entries(comparison.changed).map(([k, c]) => ( {Object.entries(comparison.changed).map(([k, c]) => (
<div key={k} style={{ color: 'var(--ctp-yellow)' }}>~ {k}: {String(c.from)} {String(c.to)}</div> <div key={k} style={{ color: "var(--ctp-yellow)" }}>
~ {k}: {String(c.from)} {String(c.to)}
</div>
))} ))}
{!comparison.status_changed && !comparison.file_changed && {!comparison.status_changed &&
Object.keys(comparison.added).length === 0 && Object.keys(comparison.removed).length === 0 && !comparison.file_changed &&
Object.keys(comparison.changed).length === 0 && ( Object.keys(comparison.added).length === 0 &&
<div style={{ color: 'var(--ctp-subtext0)' }}>No differences</div> Object.keys(comparison.removed).length === 0 &&
)} Object.keys(comparison.changed).length === 0 && (
<div style={{ color: "var(--ctp-subtext0)" }}>No differences</div>
)}
</div> </div>
)} )}
{/* Revisions table */} {/* Revisions table */}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}> <table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead> <thead>
<tr> <tr>
<th style={thStyle}>Rev</th> <th style={thStyle}>Rev</th>
@@ -143,17 +231,32 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
</thead> </thead>
<tbody> <tbody>
{revisions.map((rev, idx) => ( {revisions.map((rev, idx) => (
<tr key={rev.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}> <tr
key={rev.id}
style={{
backgroundColor:
idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
}}
>
<td style={tdStyle}>{rev.revision_number}</td> <td style={tdStyle}>{rev.revision_number}</td>
<td style={tdStyle}> <td style={tdStyle}>
{isEditor ? ( {isEditor ? (
<select <select
value={rev.status} value={rev.status}
onChange={(e) => void handleStatusChange(rev.revision_number, e.target.value)} onChange={(e) =>
void handleStatusChange(
rev.revision_number,
e.target.value,
)
}
style={{ style={{
padding: '0.1rem 0.3rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.3rem', padding: "0.1rem 0.3rem",
backgroundColor: 'transparent', color: statusColors[rev.status] ?? 'var(--ctp-text)', fontSize: "0.75rem",
cursor: 'pointer', border: "none",
borderRadius: "0.3rem",
backgroundColor: "transparent",
color: statusColors[rev.status] ?? "var(--ctp-text)",
cursor: "pointer",
}} }}
> >
<option value="draft">draft</option> <option value="draft">draft</option>
@@ -162,27 +265,58 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
<option value="obsolete">obsolete</option> <option value="obsolete">obsolete</option>
</select> </select>
) : ( ) : (
<span style={{ color: statusColors[rev.status] ?? 'var(--ctp-text)' }}>{rev.status}</span> <span
style={{
color: statusColors[rev.status] ?? "var(--ctp-text)",
}}
>
{rev.status}
</span>
)} )}
</td> </td>
<td style={tdStyle}>{formatDate(rev.created_at)}</td> <td style={tdStyle}>{formatDate(rev.created_at)}</td>
<td style={tdStyle}>{rev.created_by ?? '—'}</td> <td style={tdStyle}>{rev.created_by ?? "—"}</td>
<td style={{ ...tdStyle, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>{rev.comment ?? ''}</td> <td
style={{
...tdStyle,
maxWidth: 150,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{rev.comment ?? ""}
</td>
<td style={tdStyle}> <td style={tdStyle}>
{rev.file_key ? ( {rev.file_key ? (
<button <button
onClick={() => { window.location.href = `/api/items/${encodeURIComponent(partNumber)}/file/${rev.revision_number}`; }} onClick={() => {
style={{ background: 'none', border: 'none', color: 'var(--ctp-sapphire)', cursor: 'pointer', fontSize: '0.8rem' }} window.location.href = `/api/items/${encodeURIComponent(partNumber)}/file/${rev.revision_number}`;
}}
style={{
background: "none",
border: "none",
color: "var(--ctp-sapphire)",
cursor: "pointer",
display: "inline-flex",
}}
> >
<Download size={14} />
</button> </button>
) : '—'} ) : (
"—"
)}
</td> </td>
{isEditor && ( {isEditor && (
<td style={tdStyle}> <td style={tdStyle}>
<button <button
onClick={() => void handleRollback(rev.revision_number)} onClick={() => void handleRollback(rev.revision_number)}
style={{ background: 'none', border: 'none', color: 'var(--ctp-peach)', cursor: 'pointer', fontSize: '0.75rem' }} style={{
background: "none",
border: "none",
color: "var(--ctp-peach)",
cursor: "pointer",
fontSize: "0.75rem",
}}
title="Rollback to this revision" title="Rollback to this revision"
> >
Rollback Rollback
@@ -198,10 +332,18 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
} }
const thStyle: React.CSSProperties = { const thStyle: React.CSSProperties = {
padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)', padding: "0.3rem 0.5rem",
color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
fontWeight: 600,
fontSize: "0.7rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
}; };
const tdStyle: React.CSSProperties = { const tdStyle: React.CSSProperties = {
padding: '0.25rem 0.5rem', borderBottom: '1px solid var(--ctp-surface0)', whiteSpace: 'nowrap', padding: "0.25rem 0.5rem",
borderBottom: "1px solid var(--ctp-surface0)",
whiteSpace: "nowrap",
}; };

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback, type FormEvent } from "react"; import { useEffect, useState, useCallback, type FormEvent } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Plus, ChevronUp, ChevronDown } from "lucide-react";
import { get, post, put, del } from "../api/client"; import { get, post, put, del } from "../api/client";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import type { import type {
@@ -180,7 +181,17 @@ export function ProjectsPage() {
}; };
const sortArrow = (key: typeof sortKey) => const sortArrow = (key: typeof sortKey) =>
sortKey === key ? (sortAsc ? " \u25B2" : " \u25BC") : ""; sortKey === key ? (
<span
style={{
marginLeft: 4,
display: "inline-flex",
verticalAlign: "middle",
}}
>
{sortAsc ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
) : null;
if (loading) if (loading)
return <p style={{ color: "var(--ctp-subtext0)" }}>Loading projects...</p>; return <p style={{ color: "var(--ctp-subtext0)" }}>Loading projects...</p>;
@@ -199,8 +210,16 @@ export function ProjectsPage() {
> >
<h2>Projects ({projects.length})</h2> <h2>Projects ({projects.length})</h2>
{isEditor && mode === "list" && ( {isEditor && mode === "list" && (
<button onClick={openCreate} style={btnPrimaryStyle}> <button
+ New Project onClick={openCreate}
style={{
...btnPrimaryStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
>
<Plus size={14} /> New Project
</button> </button>
)} )}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { ChevronRight, ChevronDown, Plus } from "lucide-react";
import { get, post, put, del } from "../api/client"; import { get, post, put, del } from "../api/client";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import type { Schema, SchemaSegment } from "../api/types"; import type { Schema, SchemaSegment } from "../api/types";
@@ -282,10 +283,13 @@ function SchemaCard({
color: "var(--ctp-sapphire)", color: "var(--ctp-sapphire)",
userSelect: "none", userSelect: "none",
marginTop: "1rem", marginTop: "1rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}} }}
> >
{isExpanded ? "\u25BC" : "\u25B6"} View Segments ( {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}{" "}
{schema.segments.length}) View Segments ({schema.segments.length})
</div> </div>
{isExpanded && {isExpanded &&
@@ -642,9 +646,15 @@ function SegmentBlock({
!(isThisSegment(editState) && editState!.mode === "add") && ( !(isThisSegment(editState) && editState!.mode === "add") && (
<button <button
onClick={() => onStartAdd(schemaName, segment.name)} onClick={() => onStartAdd(schemaName, segment.name)}
style={{ ...btnTinyPrimaryStyle, marginTop: "0.5rem" }} style={{
...btnTinyPrimaryStyle,
marginTop: "0.5rem",
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
> >
+ Add Value <Plus size={14} /> Add Value
</button> </button>
)} )}
</div> </div>