fix(web): style guide batch 3 — icons, font scale, spacing #85

Merged
forbes merged 3 commits from fix-style-guide-batch-3 into main 2026-02-13 21:00:01 +00:00
29 changed files with 875 additions and 435 deletions

10
web/package-lock.json generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -113,9 +113,9 @@ export function AppShell() {
onClick={toggleDensity}
title={`Switch to ${density === "comfortable" ? "compact" : "comfortable"} view`}
style={{
padding: "0.2rem 0.5rem",
fontSize: "0.7rem",
borderRadius: "0.3rem",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-sm)",
borderRadius: "0.375rem",
cursor: "pointer",
border: "1px solid var(--ctp-surface1)",
background: "var(--ctp-surface0)",
@@ -130,7 +130,7 @@ export function AppShell() {
onClick={logout}
style={{
padding: "0.35rem 0.75rem",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
borderRadius: "0.4rem",
cursor: "pointer",
border: "none",

View File

@@ -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 (
<div ref={ref} style={style}>
{items.map((item, i) =>
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
key={i}
onClick={() => {
if (item.onToggle) item.onToggle();
else if (item.onClick) { item.onClick(); onClose(); }
else if (item.onClick) {
item.onClick();
onClose();
}
}}
disabled={item.disabled}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
width: '100%',
padding: '0.35rem 0.75rem',
background: 'none',
border: 'none',
color: item.disabled ? 'var(--ctp-overlay0)' : 'var(--ctp-text)',
fontSize: '0.85rem',
cursor: item.disabled ? 'default' : 'pointer',
textAlign: 'left',
display: "flex",
alignItems: "center",
gap: "0.5rem",
width: "100%",
padding: "0.35rem 0.75rem",
background: "none",
border: "none",
color: item.disabled ? "var(--ctp-overlay0)" : "var(--ctp-text)",
fontSize: "var(--font-body)",
cursor: item.disabled ? "default" : "pointer",
textAlign: "left",
}}
onMouseEnter={(e) => {
if (!item.disabled) e.currentTarget.style.backgroundColor = 'var(--ctp-surface1)';
if (!item.disabled)
e.currentTarget.style.backgroundColor = "var(--ctp-surface1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.backgroundColor = "transparent";
}}
>
{item.checked !== undefined && (
<span style={{
width: 16, height: 16, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
border: '1px solid var(--ctp-overlay0)', borderRadius: 3,
backgroundColor: item.checked ? 'var(--ctp-mauve)' : 'transparent',
color: item.checked ? 'var(--ctp-crust)' : 'transparent',
fontSize: '0.7rem', fontWeight: 700, flexShrink: 0,
}}>
{item.checked ? '✓' : ''}
<span
style={{
width: 16,
height: 16,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
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>
)}
{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 {
id: string;
@@ -12,34 +13,45 @@ interface TagInputProps {
searchFn: (query: string) => Promise<TagOption[]>;
}
export function TagInput({ value, onChange, placeholder, searchFn }: TagInputProps) {
const [query, setQuery] = useState('');
export function TagInput({
value,
onChange,
placeholder,
searchFn,
}: TagInputProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<TagOption[]>([]);
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
// Debounced search
const search = useCallback(
(q: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (q.trim() === '') {
if (q.trim() === "") {
// Show all results when input is empty but focused
debounceRef.current = setTimeout(() => {
searchFn('').then((opts) => {
setResults(opts.filter((o) => !value.includes(o.id)));
setHighlighted(0);
}).catch(() => setResults([]));
searchFn("")
.then((opts) => {
setResults(opts.filter((o) => !value.includes(o.id)));
setHighlighted(0);
})
.catch(() => setResults([]));
}, 100);
return;
}
debounceRef.current = setTimeout(() => {
searchFn(q).then((opts) => {
setResults(opts.filter((o) => !value.includes(o.id)));
setHighlighted(0);
}).catch(() => setResults([]));
searchFn(q)
.then((opts) => {
setResults(opts.filter((o) => !value.includes(o.id)));
setHighlighted(0);
})
.catch(() => setResults([]));
}, 200);
},
[searchFn, value],
@@ -53,17 +65,20 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
// Close on click outside
useEffect(() => {
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);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const select = (id: string) => {
onChange([...value, id]);
setQuery('');
setQuery("");
setOpen(false);
inputRef.current?.focus();
};
@@ -73,22 +88,22 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
};
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));
return;
}
if (e.key === 'Escape') {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (!open || results.length === 0) return;
if (e.key === 'ArrowDown') {
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlighted((h) => (h + 1) % results.length);
} else if (e.key === 'ArrowUp') {
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlighted((h) => (h - 1 + results.length) % results.length);
} else if (e.key === 'Enter') {
} else if (e.key === "Enter") {
e.preventDefault();
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);
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<div ref={containerRef} style={{ position: "relative" }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
backgroundColor: 'var(--ctp-base)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem',
cursor: 'text',
minHeight: '1.8rem',
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.25rem",
padding: "0.25rem 0.5rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
cursor: "text",
minHeight: "1.8rem",
}}
onClick={() => inputRef.current?.focus()}
>
@@ -119,14 +134,14 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
<span
key={id}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.1rem 0.5rem',
borderRadius: '1rem',
backgroundColor: 'rgba(203,166,247,0.15)',
color: 'var(--ctp-mauve)',
fontSize: '0.75rem',
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
backgroundColor: "rgba(203,166,247,0.15)",
color: "var(--ctp-mauve)",
fontSize: "0.75rem",
}}
>
{labelMap.current.get(id) ?? id}
@@ -137,16 +152,16 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
remove(id);
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--ctp-mauve)',
background: "none",
border: "none",
cursor: "pointer",
color: "var(--ctp-mauve)",
padding: 0,
fontSize: '0.8rem',
lineHeight: 1,
display: "inline-flex",
}}
>
×
<X size={14} />
</button>
</span>
))}
@@ -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: "var(--font-body)",
padding: "0.15rem 0",
}}
/>
</div>
{open && results.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
position: "absolute",
top: "100%",
left: 0,
right: 0,
zIndex: 10,
marginTop: '0.2rem',
backgroundColor: 'var(--ctp-surface0)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem',
maxHeight: '160px',
overflowY: 'auto',
marginTop: "0.25rem",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
maxHeight: "160px",
overflowY: "auto",
}}
>
{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: "var(--font-table)",
cursor: "pointer",
color: "var(--ctp-text)",
backgroundColor:
i === highlighted ? 'var(--ctp-surface1)' : 'transparent',
i === highlighted ? "var(--ctp-surface1)" : "transparent",
}}
>
{opt.label}

View File

@@ -210,7 +210,7 @@ export function AuditDetailPanel({
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
fontWeight: 600,
fontSize: "1rem",
fontSize: "var(--font-body)",
}}
>
{audit.part_number}
@@ -263,7 +263,7 @@ export function AuditDetailPanel({
style={{
padding: "0.5rem 1rem",
color: "var(--ctp-red)",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
}}
>
{error}
@@ -274,7 +274,7 @@ export function AuditDetailPanel({
<div
style={{
padding: "0.5rem 1rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
color: "var(--ctp-subtext1)",
borderBottom: "1px solid var(--ctp-surface0)",
flexShrink: 0,
@@ -361,8 +361,8 @@ function FieldGroup({
<div style={{ marginBottom: "0.75rem" }}>
<div
style={{
padding: "0.3rem 1rem",
fontSize: "0.7rem",
padding: "0.25rem 1rem",
fontSize: "var(--font-sm)",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
@@ -424,7 +424,7 @@ function FieldRow({
style={{
display: "flex",
alignItems: "center",
padding: "0.3rem 1rem",
padding: "0.25rem 1rem",
borderLeft: `3px solid ${borderColor}`,
marginLeft: "0.5rem",
gap: "0.5rem",
@@ -434,7 +434,7 @@ function FieldRow({
style={{
width: 140,
flexShrink: 0,
fontSize: "0.78rem",
fontSize: "var(--font-table)",
color: "var(--ctp-subtext1)",
}}
title={`Weight: ${field.weight}`}
@@ -445,7 +445,7 @@ function FieldRow({
style={{
marginLeft: 4,
color: "var(--ctp-red)",
fontSize: "0.65rem",
fontSize: "var(--font-xs)",
}}
>
*
@@ -456,7 +456,7 @@ function FieldRow({
<div
style={{
flex: 1,
fontSize: "0.8rem",
fontSize: "var(--font-table)",
color: field.filled ? "var(--ctp-text)" : "var(--ctp-subtext0)",
fontStyle: field.filled ? "normal" : "italic",
}}
@@ -477,10 +477,10 @@ function FieldRow({
placeholder="---"
style={{
flex: 1,
padding: "0.2rem 0.4rem",
fontSize: "0.8rem",
padding: "0.25rem 0.4rem",
fontSize: "var(--font-table)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-text)",
outline: "none",
@@ -492,10 +492,10 @@ function FieldRow({
}
const closeBtnStyle: React.CSSProperties = {
padding: "0.2rem 0.5rem",
fontSize: "0.8rem",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
border: "none",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
cursor: "pointer",

View File

@@ -51,7 +51,7 @@ export function AuditSummaryBar({
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.7rem",
fontSize: "var(--font-sm)",
fontWeight: 600,
color: "var(--ctp-crust)",
transition: "all 0.15s ease",
@@ -71,7 +71,7 @@ export function AuditSummaryBar({
display: "flex",
gap: "1.5rem",
marginTop: "0.4rem",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
color: "var(--ctp-subtext0)",
}}
>

View File

@@ -55,7 +55,7 @@ export function AuditTable({
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
}}
>
<thead>

View File

@@ -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";
@@ -117,11 +118,11 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
};
const inputStyle: React.CSSProperties = {
padding: "0.2rem 0.4rem",
fontSize: "0.8rem",
padding: "0.25rem 0.4rem",
fontSize: "var(--font-table)",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
color: "var(--ctp-text)",
width: "100%",
};
@@ -225,7 +226,9 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
marginBottom: "0.5rem",
}}
>
<span style={{ fontSize: "0.85rem", color: "var(--ctp-subtext1)" }}>
<span
style={{ fontSize: "var(--font-body)", color: "var(--ctp-subtext1)" }}
>
{entries.length} entries
</span>
<span style={{ flex: 1 }} />
@@ -233,9 +236,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
<Download size={14} /> Export CSV
</button>
{isEditor && (
<button
@@ -244,9 +252,14 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
setEditIdx(null);
setForm(emptyForm);
}}
style={toolBtnStyle}
style={{
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
>
+ Add
<Plus size={14} /> Add
</button>
)}
</div>
@@ -254,9 +267,9 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
{isEditor && assemblyCount > 0 && (
<div
style={{
padding: "0.35rem 0.6rem",
padding: "0.35rem 0.5rem",
marginBottom: "0.5rem",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
backgroundColor: "rgba(148,226,213,0.1)",
border: "1px solid rgba(148,226,213,0.3)",
fontSize: "0.75rem",
@@ -274,7 +287,7 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
}}
>
<thead>
@@ -403,12 +416,12 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
}
const thStyle: React.CSSProperties = {
padding: "0.3rem 0.5rem",
padding: "0.25rem 0.5rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-overlay1)",
fontWeight: 600,
fontSize: "0.7rem",
fontSize: "var(--font-sm)",
textTransform: "uppercase",
letterSpacing: "0.05em",
whiteSpace: "nowrap",
@@ -438,12 +451,12 @@ const actionBtnStyle: React.CSSProperties = {
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.1rem 0.3rem",
padding: "0.15rem 0.25rem",
borderRadius: "0.375rem",
};
const saveBtnStyle: React.CSSProperties = {
padding: "0.2rem 0.4rem",
padding: "0.25rem 0.4rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
@@ -455,9 +468,9 @@ const saveBtnStyle: React.CSSProperties = {
};
const sourceBadgeBase: React.CSSProperties = {
padding: "0.1rem 0.4rem",
padding: "0.15rem 0.4rem",
borderRadius: "1rem",
fontSize: "0.7rem",
fontSize: "var(--font-sm)",
fontWeight: 500,
};
@@ -474,7 +487,7 @@ const manualBadge: React.CSSProperties = {
};
const cancelBtnStyle: React.CSSProperties = {
padding: "0.2rem 0.4rem",
padding: "0.25rem 0.4rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",

View File

@@ -95,7 +95,7 @@ export function CategoryPicker({
}
}}
style={{
padding: "0.2rem 0.5rem",
padding: "0.25rem 0.5rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
@@ -134,7 +134,7 @@ export function CategoryPicker({
style={{
width: "100%",
padding: "0.4rem 0.5rem",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
border: "none",
borderBottom: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-mantle)",
@@ -152,7 +152,7 @@ export function CategoryPicker({
padding: "0.75rem",
textAlign: "center",
color: "var(--ctp-subtext0)",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
}}
>
Select a domain to see categories
@@ -163,7 +163,7 @@ export function CategoryPicker({
padding: "0.75rem",
textAlign: "center",
color: "var(--ctp-subtext0)",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
}}
>
No categories found
@@ -180,9 +180,9 @@ export function CategoryPicker({
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.3rem 0.5rem",
padding: "0.25rem 0.5rem",
cursor: "pointer",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
backgroundColor: isSelected
? "rgba(203,166,247,0.12)"
: "transparent",
@@ -228,7 +228,7 @@ export function CategoryPicker({
{value && categories[value] && (
<div
style={{
padding: "0.3rem 0.5rem",
padding: "0.25rem 0.5rem",
fontSize: "0.75rem",
color: "var(--ctp-subtext0)",
borderTop: "1px solid var(--ctp-surface0)",

View File

@@ -263,7 +263,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
style={{
color: "var(--ctp-green)",
fontWeight: 600,
fontSize: "0.9rem",
fontSize: "var(--font-body)",
}}
>
New Item
@@ -400,13 +400,19 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
/>
) : thumbnailFile?.uploadStatus === "uploading" ? (
<span
style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}
style={{
fontSize: "var(--font-table)",
color: "var(--ctp-subtext0)",
}}
>
Uploading... {thumbnailFile.uploadProgress}%
</span>
) : (
<span
style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}
style={{
fontSize: "var(--font-table)",
color: "var(--ctp-subtext0)",
}}
>
Click to upload
</span>
@@ -571,7 +577,7 @@ function SectionHeader({ children }: { children: React.ReactNode }) {
>
<span
style={{
fontSize: "0.7rem",
fontSize: "var(--font-sm)",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
@@ -608,7 +614,7 @@ function SidebarSection({
>
<div
style={{
fontSize: "0.7rem",
fontSize: "var(--font-sm)",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
@@ -629,7 +635,7 @@ function MetaRow({ label, value }: { label: string; value: string }) {
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
padding: "0.15rem 0",
}}
>
@@ -647,13 +653,13 @@ function FormGroup({
children: React.ReactNode;
}) {
return (
<div style={{ marginBottom: "0.6rem" }}>
<div style={{ marginBottom: "0.5rem" }}>
<label
style={{
display: "block",
fontSize: "0.75rem",
color: "var(--ctp-subtext0)",
marginBottom: "0.2rem",
marginBottom: "0.25rem",
}}
>
{label}
@@ -676,7 +682,7 @@ const headerStyle: React.CSSProperties = {
};
const actionBtnStyle: React.CSSProperties = {
padding: "0.3rem 0.75rem",
padding: "0.25rem 0.75rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
@@ -692,17 +698,17 @@ const cancelBtnStyle: React.CSSProperties = {
color: "var(--ctp-subtext1)",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.2rem 0.4rem",
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.35rem 0.5rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
color: "var(--ctp-text)",
boxSizing: "border-box",
};
@@ -717,7 +723,7 @@ const errorStyle: React.CSSProperties = {
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
marginBottom: "0.5rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
};

View File

@@ -45,7 +45,7 @@ export function DeleteItemPane({
style={{
color: "var(--ctp-red)",
fontWeight: 600,
fontSize: "0.9rem",
fontSize: "var(--font-body)",
}}
>
Delete Item
@@ -73,8 +73,8 @@ export function DeleteItemPane({
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem 1rem",
borderRadius: "0.3rem",
fontSize: "0.85rem",
borderRadius: "0.375rem",
fontSize: "var(--font-body)",
width: "100%",
textAlign: "center",
}}
@@ -86,7 +86,7 @@ export function DeleteItemPane({
<div style={{ textAlign: "center" }}>
<p
style={{
fontSize: "0.9rem",
fontSize: "var(--font-body)",
color: "var(--ctp-text)",
marginBottom: "0.5rem",
}}
@@ -97,7 +97,7 @@ export function DeleteItemPane({
style={{
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
fontSize: "1.1rem",
fontSize: "var(--font-title)",
fontWeight: 600,
}}
>
@@ -108,7 +108,7 @@ export function DeleteItemPane({
<p
style={{
color: "var(--ctp-subtext0)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
textAlign: "center",
maxWidth: 300,
}}
@@ -163,6 +163,6 @@ const headerBtnStyle: React.CSSProperties = {
color: "var(--ctp-subtext1)",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.2rem 0.4rem",
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
};

View File

@@ -79,7 +79,7 @@ export function EditItemPane({
style={{
color: "var(--ctp-blue)",
fontWeight: 600,
fontSize: "0.9rem",
fontSize: "var(--font-body)",
}}
>
Edit {partNumber}
@@ -89,7 +89,7 @@ export function EditItemPane({
onClick={() => void handleSave()}
disabled={saving}
style={{
padding: "0.3rem 0.75rem",
padding: "0.25rem 0.75rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
@@ -114,9 +114,9 @@ export function EditItemPane({
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
marginBottom: "0.5rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
{error}
@@ -190,13 +190,13 @@ function FormGroup({
children: React.ReactNode;
}) {
return (
<div style={{ marginBottom: "0.6rem" }}>
<div style={{ marginBottom: "0.5rem" }}>
<label
style={{
display: "block",
fontSize: "0.75rem",
color: "var(--ctp-subtext0)",
marginBottom: "0.2rem",
marginBottom: "0.25rem",
}}
>
{label}
@@ -209,10 +209,10 @@ function FormGroup({
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.35rem 0.5rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
color: "var(--ctp-text)",
};
@@ -223,6 +223,6 @@ const headerBtnStyle: React.CSSProperties = {
color: "var(--ctp-subtext1)",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.2rem 0.4rem",
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
};

View File

@@ -76,7 +76,9 @@ export function FileDropZone({
transition: "all 0.15s ease",
}}
>
<div style={{ fontSize: "0.85rem", color: "var(--ctp-subtext1)" }}>
<div
style={{ fontSize: "var(--font-body)", color: "var(--ctp-subtext1)" }}
>
Drop files here or{" "}
<span style={{ color: "var(--ctp-mauve)", fontWeight: 600 }}>
browse
@@ -85,7 +87,7 @@ export function FileDropZone({
{accept && (
<div
style={{
fontSize: "0.7rem",
fontSize: "var(--font-sm)",
color: "var(--ctp-overlay0)",
marginTop: "0.25rem",
}}
@@ -141,8 +143,8 @@ function FileRow({
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.3rem 0.4rem",
borderRadius: "0.3rem",
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
position: "relative",
}}
>
@@ -151,13 +153,13 @@ function FileRow({
style={{
width: 28,
height: 28,
borderRadius: "0.3rem",
borderRadius: "0.375rem",
backgroundColor: color,
opacity: 0.8,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.6rem",
fontSize: "var(--font-xs)",
fontWeight: 700,
color: "var(--ctp-crust)",
flexShrink: 0,
@@ -170,7 +172,7 @@ function FileRow({
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.8rem",
fontSize: "var(--font-table)",
color: "var(--ctp-text)",
overflow: "hidden",
textOverflow: "ellipsis",
@@ -179,7 +181,9 @@ function FileRow({
>
{attachment.file.name}
</div>
<div style={{ fontSize: "0.7rem", color: "var(--ctp-overlay0)" }}>
<div
style={{ fontSize: "var(--font-sm)", color: "var(--ctp-overlay0)" }}
>
{formatSize(attachment.file.size)}
{attachment.uploadStatus === "error" && (
<span style={{ color: "var(--ctp-red)", marginLeft: "0.5rem" }}>
@@ -215,7 +219,7 @@ function FileRow({
{attachment.uploadStatus === "complete" ? (
<span
style={{
fontSize: "0.7rem",
fontSize: "var(--font-sm)",
color: "var(--ctp-green)",
flexShrink: 0,
}}
@@ -233,7 +237,7 @@ function FileRow({
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
color: hovered ? "var(--ctp-red)" : "var(--ctp-overlay0)",
padding: "0 0.2rem",
flexShrink: 0,

View File

@@ -72,7 +72,7 @@ export function ImportItemsPane({
style={{
color: "var(--ctp-yellow)",
fontWeight: 600,
fontSize: "0.9rem",
fontSize: "var(--font-body)",
}}
>
Import Items (CSV)
@@ -90,9 +90,9 @@ export function ImportItemsPane({
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
marginBottom: "0.5rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
{error}
@@ -102,7 +102,7 @@ export function ImportItemsPane({
{/* Instructions */}
<div
style={{
fontSize: "0.8rem",
fontSize: "var(--font-table)",
color: "var(--ctp-subtext0)",
marginBottom: "0.75rem",
}}
@@ -120,7 +120,10 @@ export function ImportItemsPane({
</p>
<a
href="/api/items/template.csv"
style={{ color: "var(--ctp-sapphire)", fontSize: "0.8rem" }}
style={{
color: "var(--ctp-sapphire)",
fontSize: "var(--font-table)",
}}
>
Download CSV template
</a>
@@ -149,7 +152,7 @@ export function ImportItemsPane({
color: "var(--ctp-subtext1)",
cursor: "pointer",
width: "100%",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
{file ? file.name : "Choose CSV file..."}
@@ -162,7 +165,7 @@ export function ImportItemsPane({
display: "flex",
alignItems: "center",
gap: "0.4rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
color: "var(--ctp-subtext1)",
marginBottom: "0.75rem",
}}
@@ -225,7 +228,7 @@ export function ImportItemsPane({
padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
}}
>
<p>
@@ -259,7 +262,7 @@ export function ImportItemsPane({
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
padding: "0.1rem 0",
padding: "0.15rem 0",
}}
>
Row {err.row}
@@ -293,6 +296,6 @@ const headerBtnStyle: React.CSSProperties = {
color: "var(--ctp-subtext1)",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.2rem 0.4rem",
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
};

View File

@@ -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";
@@ -95,16 +96,16 @@ export function ItemDetail({
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
fontWeight: 600,
fontSize: "0.9rem",
fontSize: "var(--font-body)",
}}
>
{item.part_number}
</span>
<span
style={{
padding: "0.1rem 0.5rem",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.7rem",
fontSize: "var(--font-sm)",
fontWeight: 500,
backgroundColor: tc.bg,
color: tc.color,
@@ -131,9 +132,13 @@ export function ItemDetail({
)}
<button
onClick={onClose}
style={{ ...headerBtnStyle, fontSize: "1rem" }}
style={{
...headerBtnStyle,
display: "inline-flex",
alignItems: "center",
}}
>
×
<X size={14} />
</button>
</div>
@@ -153,7 +158,7 @@ export function ItemDetail({
onClick={() => setActiveTab(tab.key)}
style={{
padding: "0.4rem 0.75rem",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
border: "none",
borderBottom:
activeTab === tab.key
@@ -199,6 +204,6 @@ const headerBtnStyle: React.CSSProperties = {
border: "none",
cursor: "pointer",
color: "var(--ctp-subtext1)",
fontSize: "0.8rem",
padding: "0.2rem 0.4rem",
fontSize: "var(--font-table)",
padding: "0.25rem 0.4rem",
};

View File

@@ -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 && (
<span style={{ marginLeft: 4 }}>
{sortDir === "asc" ? "▲" : "▼"}
<span
style={{
marginLeft: 4,
display: "inline-flex",
verticalAlign: "middle",
}}
>
{sortDir === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)}
</span>
)}
</th>
@@ -257,7 +268,7 @@ export function ItemTable({
<td key={col.key} style={tdStyle}>
<span
style={{
padding: "0.1rem 0.5rem",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 500,

View File

@@ -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" ? <Columns2 size={14} /> : <Rows2 size={14} />}
</button>
{/* Export */}
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">
Export
<button
onClick={onExport}
style={{
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
title="Export CSV"
>
<Download size={14} /> Export
</button>
{/* Import (editor only) */}
{isEditor && (
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">
Import
<button
onClick={onImport}
style={{
...toolBtnStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
title="Import CSV"
>
<Upload size={14} /> Import
</button>
)}
@@ -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
<Plus size={14} /> New
</button>
)}
</div>

View File

@@ -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";
@@ -83,8 +84,8 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
style={{
display: "flex",
gap: "1rem",
padding: "0.3rem 0",
fontSize: "0.85rem",
padding: "0.25rem 0",
fontSize: "var(--font-body)",
}}
>
<span style={{ width: 120, flexShrink: 0, color: "var(--ctp-subtext0)" }}>
@@ -134,7 +135,7 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
<div
@@ -176,7 +177,7 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.1rem 0.5rem",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
backgroundColor: "rgba(203,166,247,0.15)",
color: "var(--ctp-mauve)",
@@ -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",
}}
>
×
<X size={14} />
</button>
)}
</span>
@@ -207,11 +208,11 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
value={addProject}
onChange={(e) => setAddProject(e.target.value)}
style={{
padding: "0.1rem 0.3rem",
padding: "0.15rem 0.25rem",
fontSize: "0.75rem",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
color: "var(--ctp-text)",
}}
>
@@ -228,12 +229,12 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
<button
onClick={() => void handleAddProject()}
style={{
padding: "0.1rem 0.4rem",
fontSize: "0.7rem",
padding: "0.15rem 0.4rem",
fontSize: "var(--font-sm)",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
cursor: "pointer",
}}
>
@@ -269,7 +270,7 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
display: "flex",
alignItems: "center",
gap: "0.75rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
{latestRev.file_size != null && (
@@ -292,12 +293,12 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
window.location.href = `/api/items/${encodeURIComponent(item.part_number)}/file/${latestRev.revision_number}`;
}}
style={{
padding: "0.2rem 0.5rem",
fontSize: "0.8rem",
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
borderRadius: "0.3rem",
borderRadius: "0.375rem",
cursor: "pointer",
}}
>

View File

@@ -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<string, unknown>): 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<string, unknown> {
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<Mode>('form');
const [mode, setMode] = useState<Mode>("form");
const [rows, setRows] = useState<PropRow[]>(toRows(props));
const [jsonText, setJsonText] = useState(JSON.stringify(props, null, 2));
const [jsonError, setJsonError] = useState<string | null>(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<string, unknown>;
if (mode === 'json') {
if (mode === "json") {
try {
properties = JSON.parse(jsonText) as Record<string, unknown>;
} 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: "var(--font-table)",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
color: "var(--ctp-text)",
};
return (
<div>
{/* Mode toggle */}
<div style={{ 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>
<div
style={{
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 }} />
{isEditor && (
<button onClick={() => void handleSave()} disabled={saving} style={{
padding: '0.3rem 0.75rem', 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
onClick={() => void handleSave()}
disabled={saving}
style={{
padding: "0.25rem 0.75rem",
fontSize: "var(--font-table)",
border: "none",
borderRadius: "0.375rem",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
cursor: "pointer",
opacity: saving ? 0.6 : 1,
}}
>
{saving ? "Saving..." : "Save (New Revision)"}
</button>
)}
</div>
{mode === 'form' ? (
{mode === "form" ? (
<div>
{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.25rem",
marginBottom: "0.25rem",
alignItems: "center",
}}
>
<input
value={row.key}
onChange={(e) => updateRow(idx, 'key', e.target.value)}
onChange={(e) => updateRow(idx, "key", e.target.value)}
placeholder="Key"
style={{ ...inputStyle, width: 140 }}
disabled={!isEditor}
/>
<select
value={row.type}
onChange={(e) => updateRow(idx, 'type', e.target.value)}
onChange={(e) => updateRow(idx, "type", e.target.value)}
style={{ ...inputStyle, width: 80 }}
disabled={!isEditor}
>
@@ -154,44 +206,90 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
<option value="number">num</option>
<option value="boolean">bool</option>
</select>
{row.type === 'boolean' ? (
<select value={row.value} onChange={(e) => updateRow(idx, 'value', e.target.value)} style={{ ...inputStyle, flex: 1 }} disabled={!isEditor}>
{row.type === "boolean" ? (
<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="false">false</option>
</select>
) : (
<input
type={row.type === 'number' ? 'number' : 'text'}
type={row.type === "number" ? "number" : "text"}
value={row.value}
onChange={(e) => updateRow(idx, 'value', e.target.value)}
onChange={(e) => updateRow(idx, "value", e.target.value)}
placeholder="Value"
style={{ ...inputStyle, flex: 1 }}
disabled={!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>
))}
{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>
<textarea
value={jsonText}
onChange={(e) => { setJsonText(e.target.value); setJsonError(null); }}
onChange={(e) => {
setJsonText(e.target.value);
setJsonError(null);
}}
disabled={!isEditor}
style={{
width: '100%', minHeight: 200, padding: '0.5rem',
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',
width: "100%",
minHeight: 200,
padding: "0.5rem",
fontFamily: "'JetBrains Mono', monospace",
fontSize: "var(--font-table)",
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: "var(--font-table)",
marginTop: "0.25rem",
}}
>
{jsonError}
</div>
)}
</div>
)}
</div>
@@ -199,11 +297,17 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
}
const tabBtn: React.CSSProperties = {
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'var(--ctp-surface0)', color: 'var(--ctp-subtext1)', cursor: 'pointer',
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
border: "none",
borderRadius: "0.375rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-subtext1)",
cursor: "pointer",
};
const activeTabBtn: React.CSSProperties = {
...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 { get, post } from '../../api/client';
import type { Revision, RevisionComparison } from '../../api/types';
import { useState, useEffect } from "react";
import { Download } from "lucide-react";
import { get, post } from "../../api/client";
import type { Revision, RevisionComparison } from "../../api/types";
interface RevisionsTabProps {
partNumber: string;
@@ -8,28 +9,35 @@ interface RevisionsTabProps {
}
const statusColors: Record<string, string> = {
draft: 'var(--ctp-overlay1)',
review: 'var(--ctp-yellow)',
released: 'var(--ctp-green)',
obsolete: 'var(--ctp-red)',
draft: "var(--ctp-overlay1)",
review: "var(--ctp-yellow)",
released: "var(--ctp-green)",
obsolete: "var(--ctp-red)",
};
function formatDate(s: string) {
if (!s) return '';
return new Date(s).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
if (!s) return "";
return new Date(s).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
const [revisions, setRevisions] = useState<Revision[]>([]);
const [loading, setLoading] = useState(true);
const [compareFrom, setCompareFrom] = useState('');
const [compareTo, setCompareTo] = useState('');
const [compareFrom, setCompareFrom] = useState("");
const [compareTo, setCompareTo] = useState("");
const [comparison, setComparison] = useState<RevisionComparison | null>(null);
const load = () => {
setLoading(true);
get<Revision[]>(`/api/items/${encodeURIComponent(partNumber)}/revisions`)
.then((r) => { setRevisions(r); setLoading(false); })
.then((r) => {
setRevisions(r);
setLoading(false);
})
.catch(() => setLoading(false));
};
@@ -39,97 +47,177 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
if (!compareFrom || !compareTo) return;
try {
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);
} 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) => {
try {
await fetch(`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ status }),
});
await fetch(
`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ status }),
},
);
load();
} 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) => {
if (!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}`;
if (
!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 {
await post(`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`, { comment });
await post(
`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`,
{ comment },
);
load();
} 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 = {
padding: '0.25rem 0.4rem', fontSize: '0.8rem',
backgroundColor: 'var(--ctp-surface0)', border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem', color: 'var(--ctp-text)',
padding: "0.25rem 0.4rem",
fontSize: "var(--font-table)",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
color: "var(--ctp-text)",
};
return (
<div>
{/* Compare controls */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem' }}>
<select value={compareFrom} onChange={(e) => setCompareFrom(e.target.value)} style={selectStyle}>
<div
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>
{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 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>
{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>
<button onClick={() => void handleCompare()} disabled={!compareFrom || !compareTo} 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,
}}>
<button
onClick={() => void handleCompare()}
disabled={!compareFrom || !compareTo}
style={{
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
border: "none",
borderRadius: "0.375rem",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
cursor: "pointer",
opacity: !compareFrom || !compareTo ? 0.5 : 1,
}}
>
Compare
</button>
</div>
{/* Compare results */}
{comparison && (
<div style={{
padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem',
fontSize: '0.8rem', marginBottom: '0.75rem', fontFamily: "'JetBrains Mono', monospace",
}}>
<div
style={{
padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
fontSize: "var(--font-table)",
marginBottom: "0.75rem",
fontFamily: "'JetBrains Mono', monospace",
}}
>
{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]) => (
<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]) => (
<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]) => (
<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 &&
Object.keys(comparison.added).length === 0 && Object.keys(comparison.removed).length === 0 &&
Object.keys(comparison.changed).length === 0 && (
<div style={{ color: 'var(--ctp-subtext0)' }}>No differences</div>
)}
{!comparison.status_changed &&
!comparison.file_changed &&
Object.keys(comparison.added).length === 0 &&
Object.keys(comparison.removed).length === 0 &&
Object.keys(comparison.changed).length === 0 && (
<div style={{ color: "var(--ctp-subtext0)" }}>No differences</div>
)}
</div>
)}
{/* Revisions table */}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "var(--font-table)",
}}
>
<thead>
<tr>
<th style={thStyle}>Rev</th>
@@ -143,17 +231,32 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
</thead>
<tbody>
{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}>
{isEditor ? (
<select
value={rev.status}
onChange={(e) => void handleStatusChange(rev.revision_number, e.target.value)}
onChange={(e) =>
void handleStatusChange(
rev.revision_number,
e.target.value,
)
}
style={{
padding: '0.1rem 0.3rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'transparent', color: statusColors[rev.status] ?? 'var(--ctp-text)',
cursor: 'pointer',
padding: "0.15rem 0.25rem",
fontSize: "0.75rem",
border: "none",
borderRadius: "0.375rem",
backgroundColor: "transparent",
color: statusColors[rev.status] ?? "var(--ctp-text)",
cursor: "pointer",
}}
>
<option value="draft">draft</option>
@@ -162,27 +265,58 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
<option value="obsolete">obsolete</option>
</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 style={tdStyle}>{formatDate(rev.created_at)}</td>
<td style={tdStyle}>{rev.created_by ?? '—'}</td>
<td style={{ ...tdStyle, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>{rev.comment ?? ''}</td>
<td style={tdStyle}>{rev.created_by ?? "—"}</td>
<td
style={{
...tdStyle,
maxWidth: 150,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{rev.comment ?? ""}
</td>
<td style={tdStyle}>
{rev.file_key ? (
<button
onClick={() => { window.location.href = `/api/items/${encodeURIComponent(partNumber)}/file/${rev.revision_number}`; }}
style={{ background: 'none', border: 'none', color: 'var(--ctp-sapphire)', cursor: 'pointer', fontSize: '0.8rem' }}
onClick={() => {
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>
) : '—'}
) : (
"—"
)}
</td>
{isEditor && (
<td style={tdStyle}>
<button
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"
>
Rollback
@@ -198,10 +332,18 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
}
const thStyle: React.CSSProperties = {
padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em',
padding: "0.25rem 0.5rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
fontWeight: 600,
fontSize: "var(--font-sm)",
textTransform: "uppercase",
letterSpacing: "0.05em",
};
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,6 +1,6 @@
import { useState, useEffect } from 'react';
import { get } from '../../api/client';
import type { WhereUsedEntry } from '../../api/types';
import { useState, useEffect } from "react";
import { get } from "../../api/client";
import type { WhereUsedEntry } from "../../api/types";
interface WhereUsedTabProps {
partNumber: string;
@@ -12,20 +12,35 @@ export function WhereUsedTab({ partNumber }: WhereUsedTabProps) {
useEffect(() => {
setLoading(true);
get<WhereUsedEntry[]>(`/api/items/${encodeURIComponent(partNumber)}/bom/where-used`)
get<WhereUsedEntry[]>(
`/api/items/${encodeURIComponent(partNumber)}/bom/where-used`,
)
.then(setEntries)
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, [partNumber]);
if (loading) return <div style={{ color: 'var(--ctp-subtext0)' }}>Loading where-used...</div>;
if (loading)
return (
<div style={{ color: "var(--ctp-subtext0)" }}>Loading where-used...</div>
);
if (entries.length === 0) {
return <div style={{ color: 'var(--ctp-subtext0)', padding: '1rem' }}>Not used in any assemblies.</div>;
return (
<div style={{ color: "var(--ctp-subtext0)", padding: "1rem" }}>
Not used in any assemblies.
</div>
);
}
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "var(--font-table)",
}}
>
<thead>
<tr>
<th style={thStyle}>Parent PN</th>
@@ -36,13 +51,25 @@ export function WhereUsedTab({ partNumber }: WhereUsedTabProps) {
</thead>
<tbody>
{entries.map((e, idx) => (
<tr key={e.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
<tr
key={e.id}
style={{
backgroundColor:
idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
}}
>
<td
style={{
...tdStyle,
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
}}
>
{e.parent_part_number}
</td>
<td style={tdStyle}>{e.parent_description}</td>
<td style={tdStyle}>{e.rel_type}</td>
<td style={tdStyle}>{e.quantity ?? '—'}</td>
<td style={tdStyle}>{e.quantity ?? "—"}</td>
</tr>
))}
</tbody>
@@ -51,10 +78,18 @@ export function WhereUsedTab({ partNumber }: WhereUsedTabProps) {
}
const thStyle: React.CSSProperties = {
padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em',
padding: "0.25rem 0.5rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
fontWeight: 600,
fontSize: "var(--font-sm)",
textTransform: "uppercase",
letterSpacing: "0.05em",
};
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

@@ -57,7 +57,7 @@ export function AuditPage() {
style={{
color: "var(--ctp-red)",
padding: "0.5rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
Error: {error}

View File

@@ -179,7 +179,7 @@ export function ItemsPage() {
style={{
color: "var(--ctp-red)",
padding: "0.5rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
Error: {error}

View File

@@ -78,7 +78,7 @@ export function LoginPage() {
style={{
padding: "0 1rem",
color: "var(--ctp-overlay0)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
or
@@ -123,7 +123,7 @@ const titleStyle: React.CSSProperties = {
const subtitleStyle: React.CSSProperties = {
color: "var(--ctp-subtext0)",
textAlign: "center",
fontSize: "0.9rem",
fontSize: "var(--font-body)",
marginBottom: "2rem",
};
@@ -134,7 +134,7 @@ const errorStyle: React.CSSProperties = {
padding: "0.75rem 1rem",
borderRadius: "0.5rem",
marginBottom: "1rem",
fontSize: "0.9rem",
fontSize: "var(--font-body)",
};
const formGroupStyle: React.CSSProperties = {
@@ -146,7 +146,7 @@ const labelStyle: React.CSSProperties = {
marginBottom: "0.5rem",
fontWeight: 500,
color: "var(--ctp-subtext1)",
fontSize: "0.9rem",
fontSize: "var(--font-body)",
};
const inputStyle: React.CSSProperties = {
@@ -156,7 +156,7 @@ const inputStyle: React.CSSProperties = {
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.5rem",
color: "var(--ctp-text)",
fontSize: "1rem",
fontSize: "var(--font-body)",
boxSizing: "border-box",
};

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, ChevronUp, ChevronDown } from "lucide-react";
import { get, post, put, del } from "../api/client";
import { useAuth } from "../hooks/useAuth";
import type {
@@ -180,7 +181,17 @@ export function ProjectsPage() {
};
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)
return <p style={{ color: "var(--ctp-subtext0)" }}>Loading projects...</p>;
@@ -199,8 +210,16 @@ export function ProjectsPage() {
>
<h2>Projects ({projects.length})</h2>
{isEditor && mode === "list" && (
<button onClick={openCreate} style={btnPrimaryStyle}>
+ New Project
<button
onClick={openCreate}
style={{
...btnPrimaryStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
>
<Plus size={14} /> New Project
</button>
)}
</div>
@@ -319,7 +338,7 @@ export function ProjectsPage() {
style={{
color: "var(--ctp-red)",
marginTop: "0.5rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
This action cannot be undone.
@@ -478,7 +497,7 @@ const btnDangerStyle: React.CSSProperties = {
};
const btnSmallStyle: React.CSSProperties = {
padding: "0.3rem 0.6rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
@@ -501,7 +520,7 @@ const formHeaderStyle: React.CSSProperties = {
alignItems: "center",
padding: "0.5rem 1rem",
color: "var(--ctp-crust)",
fontSize: "0.9rem",
fontSize: "var(--font-body)",
};
const formCloseStyle: React.CSSProperties = {
@@ -521,7 +540,7 @@ const errorBannerStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem",
borderRadius: "0.4rem",
marginBottom: "0.75rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
};
const fieldStyle: React.CSSProperties = {
@@ -533,7 +552,7 @@ const labelStyle: React.CSSProperties = {
marginBottom: "0.35rem",
fontWeight: 500,
color: "var(--ctp-subtext1)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
};
const inputStyle: React.CSSProperties = {
@@ -543,7 +562,7 @@ const inputStyle: React.CSSProperties = {
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
color: "var(--ctp-text)",
fontSize: "0.9rem",
fontSize: "var(--font-body)",
boxSizing: "border-box",
};
@@ -560,7 +579,7 @@ const thStyle: React.CSSProperties = {
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-overlay1)",
fontWeight: 600,
fontSize: "0.8rem",
fontSize: "var(--font-table)",
textTransform: "uppercase",
letterSpacing: "0.05em",
cursor: "pointer",
@@ -570,5 +589,5 @@ const thStyle: React.CSSProperties = {
const tdStyle: React.CSSProperties = {
padding: "0.35rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
};

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from "react";
import { ChevronRight, ChevronDown, Plus } from "lucide-react";
import { get, post, put, del } from "../api/client";
import { useAuth } from "../hooks/useAuth";
import type { Schema, SchemaSegment } from "../api/types";
@@ -282,10 +283,13 @@ function SchemaCard({
color: "var(--ctp-sapphire)",
userSelect: "none",
marginTop: "1rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
>
{isExpanded ? "\u25BC" : "\u25B6"} View Segments (
{schema.segments.length})
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}{" "}
View Segments ({schema.segments.length})
</div>
{isExpanded &&
@@ -381,7 +385,7 @@ function SegmentBlock({
style={{
color: "var(--ctp-subtext0)",
marginBottom: "0.5rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
{segment.description}
@@ -415,7 +419,9 @@ function SegmentBlock({
return (
<tr key={code}>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
<code style={{ fontSize: "var(--font-body)" }}>
{code}
</code>
</td>
<td style={tdStyle}>
<form
@@ -479,13 +485,15 @@ function SegmentBlock({
style={{ backgroundColor: "rgba(243, 139, 168, 0.1)" }}
>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
<code style={{ fontSize: "var(--font-body)" }}>
{code}
</code>
</td>
<td style={tdStyle}>
<span
style={{
color: "var(--ctp-red)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
}}
>
Delete this value?
@@ -527,7 +535,9 @@ function SegmentBlock({
return (
<tr key={code}>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
<code style={{ fontSize: "var(--font-body)" }}>
{code}
</code>
</td>
<td style={tdStyle}>{desc}</td>
{isEditor && (
@@ -642,9 +652,15 @@ function SegmentBlock({
!(isThisSegment(editState) && editState!.mode === "add") && (
<button
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>
)}
</div>
@@ -664,7 +680,7 @@ const codeStyle: React.CSSProperties = {
background: "var(--ctp-surface1)",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
};
const segmentStyle: React.CSSProperties = {
@@ -696,19 +712,19 @@ const thStyle: React.CSSProperties = {
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-overlay1)",
fontWeight: 600,
fontSize: "0.8rem",
fontSize: "var(--font-table)",
textTransform: "uppercase",
letterSpacing: "0.05em",
};
const tdStyle: React.CSSProperties = {
padding: "0.3rem 0.75rem",
padding: "0.25rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
};
const btnTinyStyle: React.CSSProperties = {
padding: "0.2rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
@@ -719,7 +735,7 @@ const btnTinyStyle: React.CSSProperties = {
};
const btnTinyPrimaryStyle: React.CSSProperties = {
padding: "0.2rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
@@ -735,7 +751,7 @@ const inlineInputStyle: React.CSSProperties = {
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
width: "100%",
boxSizing: "border-box",
};

View File

@@ -116,7 +116,7 @@ export function SettingsPage() {
display: "inline-block",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.8rem",
fontSize: "var(--font-table)",
fontWeight: 600,
...roleBadgeStyles[user.role],
}}
@@ -137,7 +137,7 @@ export function SettingsPage() {
style={{
color: "var(--ctp-subtext0)",
marginBottom: "1.25rem",
fontSize: "0.9rem",
fontSize: "var(--font-body)",
}}
>
API tokens allow the FreeCAD plugin and scripts to authenticate with
@@ -175,7 +175,7 @@ export function SettingsPage() {
<p
style={{
color: "var(--ctp-subtext0)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
marginTop: "0.5rem",
}}
>
@@ -212,7 +212,7 @@ export function SettingsPage() {
{tokensLoading ? (
<p style={mutedStyle}>Loading tokens...</p>
) : tokensError ? (
<p style={{ color: "var(--ctp-red)", fontSize: "0.85rem" }}>
<p style={{ color: "var(--ctp-red)", fontSize: "var(--font-body)" }}>
{tokensError}
</p>
) : (
@@ -332,7 +332,7 @@ const cardStyle: React.CSSProperties = {
const cardTitleStyle: React.CSSProperties = {
marginBottom: "1rem",
fontSize: "1.1rem",
fontSize: "var(--font-title)",
};
const dlStyle: React.CSSProperties = {
@@ -344,12 +344,12 @@ const dlStyle: React.CSSProperties = {
const dtStyle: React.CSSProperties = {
color: "var(--ctp-subtext0)",
fontWeight: 500,
fontSize: "0.9rem",
fontSize: "var(--font-body)",
};
const ddStyle: React.CSSProperties = {
margin: 0,
fontSize: "0.9rem",
fontSize: "var(--font-body)",
};
const mutedStyle: React.CSSProperties = {
@@ -371,7 +371,7 @@ const tokenDisplayStyle: React.CSSProperties = {
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.5rem",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
color: "var(--ctp-peach)",
wordBreak: "break-all",
};
@@ -389,7 +389,7 @@ const labelStyle: React.CSSProperties = {
marginBottom: "0.35rem",
fontWeight: 500,
color: "var(--ctp-subtext1)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
};
const inputStyle: React.CSSProperties = {
@@ -399,7 +399,7 @@ const inputStyle: React.CSSProperties = {
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
color: "var(--ctp-text)",
fontSize: "0.9rem",
fontSize: "var(--font-body)",
boxSizing: "border-box",
};
@@ -441,7 +441,7 @@ const btnDangerStyle: React.CSSProperties = {
background: "rgba(243, 139, 168, 0.15)",
color: "var(--ctp-red)",
border: "none",
padding: "0.3rem 0.6rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
cursor: "pointer",
fontSize: "0.75rem",
@@ -452,7 +452,7 @@ const btnRevokeConfirmStyle: React.CSSProperties = {
background: "var(--ctp-red)",
color: "var(--ctp-crust)",
border: "none",
padding: "0.2rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
cursor: "pointer",
fontSize: "0.75rem",
@@ -460,7 +460,7 @@ const btnRevokeConfirmStyle: React.CSSProperties = {
};
const btnTinyStyle: React.CSSProperties = {
padding: "0.2rem 0.5rem",
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
@@ -472,7 +472,7 @@ const btnTinyStyle: React.CSSProperties = {
const errorStyle: React.CSSProperties = {
color: "var(--ctp-red)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
marginTop: "0.25rem",
};
@@ -482,7 +482,7 @@ const thStyle: React.CSSProperties = {
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-overlay1)",
fontWeight: 600,
fontSize: "0.8rem",
fontSize: "var(--font-table)",
textTransform: "uppercase",
letterSpacing: "0.05em",
};
@@ -490,5 +490,5 @@ const thStyle: React.CSSProperties = {
const tdStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "0.85rem",
fontSize: "var(--font-body)",
};

View File

@@ -28,6 +28,15 @@
--ctp-crust: #11111b;
}
/* ── Font scale ── */
:root {
--font-title: 1.1rem; /* page titles */
--font-body: 0.8125rem; /* 13px — body text, breadcrumbs */
--font-table: 0.75rem; /* 12px — table cells, inputs, buttons */
--font-sm: 0.6875rem; /* 11px — section headers, labels, captions */
--font-xs: 0.625rem; /* 10px — badges (minimum) */
}
/* ── Density: comfortable (default) ── */
[data-density="comfortable"],
:root {
@@ -39,24 +48,24 @@
--d-nav-px: 0.75rem;
--d-nav-radius: 0.4rem;
--d-user-gap: 0.6rem;
--d-user-font: 0.85rem;
--d-user-font: var(--font-body);
--d-th-py: 0.35rem;
--d-th-px: 0.75rem;
--d-th-font: 0.75rem;
--d-th-font: var(--font-table);
--d-td-py: 0.25rem;
--d-td-px: 0.75rem;
--d-td-font: 0.85rem;
--d-td-font: var(--font-body);
--d-toolbar-gap: 0.5rem;
--d-toolbar-py: 0.5rem;
--d-toolbar-mb: 0.35rem;
--d-input-py: 0.35rem;
--d-input-px: 0.6rem;
--d-input-font: 0.85rem;
--d-input-font: var(--font-body);
--d-footer-h: 28px;
--d-footer-font: 0.75rem;
--d-footer-font: var(--font-table);
--d-footer-px: 2rem;
}
@@ -70,23 +79,23 @@
--d-nav-px: 0.5rem;
--d-nav-radius: 0.3rem;
--d-user-gap: 0.35rem;
--d-user-font: 0.8rem;
--d-user-font: var(--font-table);
--d-th-py: 0.2rem;
--d-th-px: 0.5rem;
--d-th-font: 0.7rem;
--d-th-font: var(--font-sm);
--d-td-py: 0.125rem;
--d-td-px: 0.5rem;
--d-td-font: 0.8rem;
--d-td-font: var(--font-table);
--d-toolbar-gap: 0.35rem;
--d-toolbar-py: 0.25rem;
--d-toolbar-mb: 0.15rem;
--d-input-py: 0.2rem;
--d-input-px: 0.4rem;
--d-input-font: 0.8rem;
--d-input-font: var(--font-table);
--d-footer-h: 24px;
--d-footer-font: 0.7rem;
--d-footer-font: var(--font-sm);
--d-footer-px: 1.25rem;
}