feat(web): user-selectable density mode (compact/comfortable) #48

Merged
forbes merged 2 commits from ui-density-mode into main 2026-02-09 01:20:52 +00:00
12 changed files with 685 additions and 378 deletions
Showing only changes of commit cb88b3977c - Show all commits

View File

@@ -1,59 +1,79 @@
import { NavLink, Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { NavLink, Outlet } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { useDensity } from "../hooks/useDensity";
const navLinks = [
{ to: '/', label: 'Items' },
{ to: '/projects', label: 'Projects' },
{ to: '/schemas', label: 'Schemas' },
{ to: '/audit', label: 'Audit' },
{ to: '/settings', label: 'Settings' },
{ to: "/", label: "Items" },
{ to: "/projects", label: "Projects" },
{ to: "/schemas", label: "Schemas" },
{ to: "/audit", label: "Audit" },
{ to: "/settings", label: "Settings" },
];
const roleBadgeStyle: Record<string, React.CSSProperties> = {
admin: { background: 'rgba(203,166,247,0.2)', color: 'var(--ctp-mauve)' },
editor: { background: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' },
viewer: { background: 'rgba(148,226,213,0.2)', color: 'var(--ctp-teal)' },
admin: { background: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" },
editor: { background: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
viewer: { background: "rgba(148,226,213,0.2)", color: "var(--ctp-teal)" },
};
export function AppShell() {
const { user, loading, logout } = useAuth();
const [density, toggleDensity] = useDensity();
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
}}
>
<div className="spinner" />
</div>
);
}
return (
<>
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<header
style={{
backgroundColor: 'var(--ctp-mantle)',
borderBottom: '1px solid var(--ctp-surface0)',
padding: '1rem 2rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: "var(--ctp-mantle)",
borderBottom: "1px solid var(--ctp-surface0)",
padding: "var(--d-header-py) var(--d-header-px)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexShrink: 0,
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, color: 'var(--ctp-mauve)' }}>Silo</h1>
<h1
style={{
fontSize: "var(--d-header-logo)",
fontWeight: 600,
color: "var(--ctp-mauve)",
}}
>
Silo
</h1>
<nav style={{ display: 'flex', gap: '1.5rem' }}>
<nav style={{ display: "flex", gap: "var(--d-nav-gap)" }}>
{navLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === '/'}
end={link.to === "/"}
style={({ isActive }) => ({
color: isActive ? 'var(--ctp-mauve)' : 'var(--ctp-subtext1)',
backgroundColor: isActive ? 'var(--ctp-surface1)' : 'transparent',
color: isActive ? "var(--ctp-mauve)" : "var(--ctp-subtext1)",
backgroundColor: isActive
? "var(--ctp-surface1)"
: "transparent",
fontWeight: 500,
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
textDecoration: 'none',
transition: 'all 0.2s',
padding: "var(--d-nav-py) var(--d-nav-px)",
borderRadius: "var(--d-nav-radius)",
textDecoration: "none",
transition: "all 0.2s",
})}
>
{link.label}
@@ -62,32 +82,60 @@ export function AppShell() {
</nav>
{user && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ color: 'var(--ctp-subtext1)', fontSize: '0.9rem' }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "var(--d-user-gap)",
}}
>
<span
style={{
color: "var(--ctp-subtext1)",
fontSize: "var(--d-user-font)",
}}
>
{user.display_name}
</span>
<span
style={{
display: 'inline-block',
padding: '0.15rem 0.5rem',
borderRadius: '1rem',
fontSize: '0.75rem',
display: "inline-block",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 600,
...roleBadgeStyle[user.role],
}}
>
{user.role}
</span>
<button
onClick={toggleDensity}
title={`Switch to ${density === "comfortable" ? "compact" : "comfortable"} view`}
style={{
padding: "0.2rem 0.5rem",
fontSize: "0.7rem",
borderRadius: "0.3rem",
cursor: "pointer",
border: "1px solid var(--ctp-surface1)",
background: "var(--ctp-surface0)",
color: "var(--ctp-subtext1)",
fontFamily: "'JetBrains Mono', monospace",
letterSpacing: "0.05em",
}}
>
{density === "comfortable" ? "COM" : "CMP"}
</button>
<button
onClick={logout}
style={{
padding: '0.35rem 0.75rem',
fontSize: '0.8rem',
borderRadius: '0.4rem',
cursor: 'pointer',
border: 'none',
background: 'var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
padding: "0.35rem 0.75rem",
fontSize: "0.8rem",
borderRadius: "0.4rem",
cursor: "pointer",
border: "none",
background: "var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
}}
>
Logout
@@ -96,9 +144,11 @@ export function AppShell() {
)}
</header>
<main style={{ padding: '1rem 1rem 0 1rem' }}>
<main
style={{ flex: 1, padding: "1rem 1rem 0 1rem", overflow: "hidden" }}
>
<Outlet />
</main>
</>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import type { ReactNode } from 'react';
interface PageFooterProps {
stats?: ReactNode;
page?: number;
pageSize?: number;
itemCount?: number;
onPageChange?: (page: number) => void;
}
export function PageFooter({ stats, page, pageSize, itemCount, onPageChange }: PageFooterProps) {
const hasPagination = page !== undefined && onPageChange !== undefined;
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 'var(--d-footer-h)',
backgroundColor: 'var(--ctp-surface0)',
borderTop: '1px solid var(--ctp-surface1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 var(--d-footer-px)',
fontSize: 'var(--d-footer-font)',
color: 'var(--ctp-subtext0)',
zIndex: 100,
}}>
<div style={{ display: 'flex', gap: '1.5rem', alignItems: 'center' }}>
{stats}
</div>
{hasPagination && (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
style={pageBtnStyle}
>
Prev
</button>
<span>
Page {page}
{itemCount !== undefined && ` \u00b7 ${itemCount} items`}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={pageSize !== undefined && itemCount !== undefined && itemCount < pageSize}
style={pageBtnStyle}
>
Next
</button>
</div>
)}
</div>
);
}
const pageBtnStyle: React.CSSProperties = {
padding: '0.15rem 0.4rem',
fontSize: 'inherit',
border: 'none',
borderRadius: '0.25rem',
backgroundColor: 'var(--ctp-surface1)',
color: 'var(--ctp-text)',
cursor: 'pointer',
};

View File

@@ -23,7 +23,13 @@ export function AuditTable({
}: AuditTableProps) {
if (loading) {
return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)", textAlign: "center" }}>
<div
style={{
padding: "2rem",
color: "var(--ctp-subtext0)",
textAlign: "center",
}}
>
Loading audit data...
</div>
);
@@ -31,7 +37,13 @@ export function AuditTable({
if (items.length === 0) {
return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)", textAlign: "center" }}>
<div
style={{
padding: "2rem",
color: "var(--ctp-subtext0)",
textAlign: "center",
}}
>
No items found
</div>
);
@@ -39,16 +51,27 @@ export function AuditTable({
return (
<div style={{ overflow: "auto", flex: 1 }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead>
<tr>
{["Score", "Part Number", "Description", "Category", "Sourcing", "Missing"].map(
(h) => (
<th key={h} style={thStyle}>
{h}
</th>
),
)}
{[
"Score",
"Part Number",
"Description",
"Category",
"Sourcing",
"Missing",
].map((h) => (
<th key={h} style={thStyle}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
@@ -68,7 +91,8 @@ export function AuditTable({
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.backgroundColor = "var(--ctp-surface0)";
e.currentTarget.style.backgroundColor =
"var(--ctp-surface0)";
}}
onMouseLeave={(e) => {
if (!isSelected)
@@ -100,7 +124,15 @@ export function AuditTable({
>
{item.part_number}
</td>
<td style={{ ...tdStyle, maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
<td
style={{
...tdStyle,
maxWidth: 300,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.description}
</td>
<td style={tdStyle}>{item.category_name || item.category}</td>
@@ -119,7 +151,8 @@ export function AuditTable({
const thStyle: React.CSSProperties = {
textAlign: "left",
padding: "0.5rem 0.75rem",
padding: "var(--d-th-py) var(--d-th-px)",
fontSize: "var(--d-th-font)",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext0)",
fontWeight: 500,
@@ -130,7 +163,8 @@ const thStyle: React.CSSProperties = {
};
const tdStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
padding: "var(--d-td-py) var(--d-td-px)",
fontSize: "var(--d-td-font)",
borderBottom: "1px solid var(--ctp-surface0)",
color: "var(--ctp-text)",
};

View File

@@ -33,9 +33,9 @@ export function AuditToolbar({
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
gap: "var(--d-toolbar-gap)",
alignItems: "center",
marginBottom: "0.5rem",
marginBottom: "var(--d-toolbar-mb)",
}}
>
<select
@@ -95,8 +95,8 @@ export function AuditToolbar({
}
const selectStyle: React.CSSProperties = {
padding: "0.35rem 0.5rem",
fontSize: "0.8rem",
padding: "var(--d-input-py) var(--d-input-px)",
fontSize: "var(--d-input-font)",
borderRadius: "0.4rem",
border: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-surface0)",
@@ -104,8 +104,8 @@ const selectStyle: React.CSSProperties = {
};
const btnStyle: React.CSSProperties = {
padding: "0.35rem 0.6rem",
fontSize: "0.8rem",
padding: "var(--d-input-py) var(--d-input-px)",
fontSize: "var(--d-input-font)",
borderRadius: "0.4rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",

View File

@@ -1,36 +0,0 @@
import type { Item } from '../../api/types';
interface FooterStatsProps {
items: Item[];
}
export function FooterStats({ items }: FooterStatsProps) {
const total = items.length;
const parts = items.filter((i) => i.item_type === 'part').length;
const assemblies = items.filter((i) => i.item_type === 'assembly').length;
const documents = items.filter((i) => i.item_type === 'document').length;
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 28,
backgroundColor: 'var(--ctp-surface0)',
borderTop: '1px solid var(--ctp-surface1)',
display: 'flex',
alignItems: 'center',
padding: '0 2rem',
gap: '2rem',
fontSize: '0.75rem',
color: 'var(--ctp-subtext0)',
zIndex: 100,
}}>
<span>Total: <strong style={{ color: 'var(--ctp-text)' }}>{total}</strong></span>
<span>Parts: <strong style={{ color: 'var(--ctp-blue)' }}>{parts}</strong></span>
<span>Assemblies: <strong style={{ color: 'var(--ctp-green)' }}>{assemblies}</strong></span>
<span>Documents: <strong style={{ color: 'var(--ctp-yellow)' }}>{documents}</strong></span>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import type { Item } from '../../api/types';
import { ContextMenu, type ContextMenuItem } from '../ContextMenu';
import { useState, useCallback } from "react";
import type { Item } from "../../api/types";
import { ContextMenu, type ContextMenuItem } from "../ContextMenu";
export interface ColumnDef {
key: string;
@@ -8,17 +8,29 @@ export interface ColumnDef {
}
export const ALL_COLUMNS: ColumnDef[] = [
{ key: 'part_number', label: 'Part Number' },
{ key: 'item_type', label: 'Type' },
{ key: 'description', label: 'Description' },
{ key: 'revision', label: 'Rev' },
{ key: 'projects', label: 'Projects' },
{ key: 'created', label: 'Created' },
{ key: 'actions', label: 'Actions' },
{ key: "part_number", label: "Part Number" },
{ key: "item_type", label: "Type" },
{ key: "description", label: "Description" },
{ key: "revision", label: "Rev" },
{ key: "projects", label: "Projects" },
{ key: "created", label: "Created" },
{ key: "actions", label: "Actions" },
];
export const DEFAULT_COLUMNS_H = ['part_number', 'item_type', 'description', 'revision'];
export const DEFAULT_COLUMNS_V = ['part_number', 'item_type', 'description', 'revision', 'created', 'actions'];
export const DEFAULT_COLUMNS_H = [
"part_number",
"item_type",
"description",
"revision",
];
export const DEFAULT_COLUMNS_V = [
"part_number",
"item_type",
"description",
"revision",
"created",
"actions",
];
interface ItemTableProps {
items: Item[];
@@ -30,21 +42,25 @@ interface ItemTableProps {
onEdit?: (pn: string) => void;
onDelete?: (pn: string) => void;
sortKey: string;
sortDir: 'asc' | 'desc';
sortDir: "asc" | "desc";
onSort: (key: string) => void;
}
const typeColors: Record<string, { bg: string; color: string }> = {
part: { bg: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' },
assembly: { bg: 'rgba(166,227,161,0.2)', color: 'var(--ctp-green)' },
document: { bg: 'rgba(249,226,175,0.2)', color: 'var(--ctp-yellow)' },
tooling: { bg: 'rgba(243,139,168,0.2)', color: 'var(--ctp-red)' },
part: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
assembly: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" },
document: { bg: "rgba(249,226,175,0.2)", color: "var(--ctp-yellow)" },
tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" },
};
function formatDate(s: string) {
if (!s) return '';
if (!s) return "";
const d = new Date(s);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function copyPN(pn: string) {
@@ -52,8 +68,17 @@ function copyPN(pn: string) {
}
export function ItemTable({
items, loading, selectedPN, onSelect, visibleColumns, onColumnsChange,
onEdit, onDelete, sortKey, sortDir, onSort,
items,
loading,
selectedPN,
onSelect,
visibleColumns,
onColumnsChange,
onEdit,
onDelete,
sortKey,
sortDir,
onSort,
}: ItemTableProps) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
@@ -62,74 +87,99 @@ export function ItemTable({
setCtxMenu({ x: e.clientX, y: e.clientY });
}, []);
const toggleColumn = useCallback((key: string) => {
if (key === 'part_number') return; // always visible
const next = visibleColumns.includes(key)
? visibleColumns.filter((c) => c !== key)
: [...visibleColumns, key];
if (next.length > 0) onColumnsChange(next);
}, [visibleColumns, onColumnsChange]);
const toggleColumn = useCallback(
(key: string) => {
if (key === "part_number") return; // always visible
const next = visibleColumns.includes(key)
? visibleColumns.filter((c) => c !== key)
: [...visibleColumns, key];
if (next.length > 0) onColumnsChange(next);
},
[visibleColumns, onColumnsChange],
);
const cols = ALL_COLUMNS.filter((c) => visibleColumns.includes(c.key));
const sortedItems = [...items].sort((a, b) => {
let av: string | number = '';
let bv: string | number = '';
let av: string | number = "";
let bv: string | number = "";
switch (sortKey) {
case 'part_number': av = a.part_number; bv = b.part_number; break;
case 'item_type': av = a.item_type; bv = b.item_type; break;
case 'description': av = a.description; bv = b.description; break;
case 'revision': av = a.current_revision; bv = b.current_revision; break;
case 'created': av = a.created_at; bv = b.created_at; break;
default: return 0;
case "part_number":
av = a.part_number;
bv = b.part_number;
break;
case "item_type":
av = a.item_type;
bv = b.item_type;
break;
case "description":
av = a.description;
bv = b.description;
break;
case "revision":
av = a.current_revision;
bv = b.current_revision;
break;
case "created":
av = a.created_at;
bv = b.created_at;
break;
default:
return 0;
}
if (av < bv) return sortDir === 'asc' ? -1 : 1;
if (av > bv) return sortDir === 'asc' ? 1 : -1;
if (av < bv) return sortDir === "asc" ? -1 : 1;
if (av > bv) return sortDir === "asc" ? 1 : -1;
return 0;
});
const thStyle: React.CSSProperties = {
padding: '0.35rem 0.75rem',
textAlign: 'left',
borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
padding: "var(--d-th-py) var(--d-th-px)",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
fontWeight: 600,
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
cursor: 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
fontSize: "var(--d-th-font)",
textTransform: "uppercase",
letterSpacing: "0.05em",
cursor: "pointer",
userSelect: "none",
whiteSpace: "nowrap",
};
const tdStyle: React.CSSProperties = {
padding: '0.25rem 0.75rem',
fontSize: '0.85rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: "var(--d-td-py) var(--d-td-px)",
fontSize: "var(--d-td-font)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 300,
};
if (loading) {
return <div style={{ padding: '2rem', color: 'var(--ctp-subtext0)' }}>Loading items...</div>;
return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)" }}>
Loading items...
</div>
);
}
return (
<>
<div style={{ overflow: 'auto', height: '100%' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div style={{ overflow: "auto", height: "100%" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead onContextMenu={handleHeaderContext}>
<tr>
{cols.map((col) => (
<th
key={col.key}
style={thStyle}
onClick={() => col.key !== 'actions' && onSort(col.key)}
onClick={() => col.key !== "actions" && onSort(col.key)}
>
{col.label}
{sortKey === col.key && (
<span style={{ marginLeft: 4 }}>{sortDir === 'asc' ? '▲' : '▼'}</span>
<span style={{ marginLeft: 4 }}>
{sortDir === "asc" ? "▲" : "▼"}
</span>
)}
</th>
))}
@@ -139,10 +189,10 @@ export function ItemTable({
{sortedItems.map((item, idx) => {
const isSelected = item.part_number === selectedPN;
const rowBg = isSelected
? 'var(--ctp-surface1)'
? "var(--ctp-surface1)"
: idx % 2 === 0
? 'var(--ctp-base)'
: 'var(--ctp-surface0)';
? "var(--ctp-base)"
: "var(--ctp-surface0)";
return (
<tr
@@ -150,68 +200,110 @@ export function ItemTable({
onClick={() => onSelect(item.part_number)}
style={{
backgroundColor: rowBg,
cursor: 'pointer',
borderBottom: '1px solid var(--ctp-surface0)',
cursor: "pointer",
borderBottom: "1px solid var(--ctp-surface0)",
}}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--ctp-surface0)';
if (!isSelected)
e.currentTarget.style.backgroundColor =
"var(--ctp-surface0)";
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = rowBg;
if (!isSelected)
e.currentTarget.style.backgroundColor = rowBg;
}}
>
{cols.map((col) => {
switch (col.key) {
case 'part_number':
case "part_number":
return (
<td key={col.key} style={tdStyle}>
<span
onClick={(e) => { e.stopPropagation(); copyPN(item.part_number); }}
onClick={(e) => {
e.stopPropagation();
copyPN(item.part_number);
}}
title="Click to copy"
style={{
fontFamily: "'JetBrains Mono', monospace",
color: 'var(--ctp-peach)',
cursor: 'copy',
color: "var(--ctp-peach)",
cursor: "copy",
}}
>
{item.part_number}
</span>
</td>
);
case 'item_type': {
const tc = typeColors[item.item_type] ?? { bg: 'var(--ctp-surface1)', color: 'var(--ctp-text)' };
case "item_type": {
const tc = typeColors[item.item_type] ?? {
bg: "var(--ctp-surface1)",
color: "var(--ctp-text)",
};
return (
<td key={col.key} style={tdStyle}>
<span style={{
padding: '0.1rem 0.5rem', borderRadius: '1rem',
fontSize: '0.75rem', fontWeight: 500,
backgroundColor: tc.bg, color: tc.color,
}}>
<span
style={{
padding: "0.1rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 500,
backgroundColor: tc.bg,
color: tc.color,
}}
>
{item.item_type}
</span>
</td>
);
}
case 'description':
return <td key={col.key} style={{ ...tdStyle, maxWidth: 400 }}>{item.description}</td>;
case 'revision':
return <td key={col.key} style={tdStyle}>Rev {item.current_revision}</td>;
case 'projects':
return <td key={col.key} style={tdStyle}></td>;
case 'created':
return <td key={col.key} style={tdStyle}>{formatDate(item.created_at)}</td>;
case 'actions':
case "description":
return (
<td
key={col.key}
style={{ ...tdStyle, maxWidth: 400 }}
>
{item.description}
</td>
);
case "revision":
return (
<td key={col.key} style={tdStyle}>
Rev {item.current_revision}
</td>
);
case "projects":
return (
<td key={col.key} style={tdStyle}>
</td>
);
case "created":
return (
<td key={col.key} style={tdStyle}>
{formatDate(item.created_at)}
</td>
);
case "actions":
return (
<td key={col.key} style={tdStyle}>
<button
onClick={(e) => { e.stopPropagation(); onEdit?.(item.part_number); }}
onClick={(e) => {
e.stopPropagation();
onEdit?.(item.part_number);
}}
style={actionBtnStyle}
>
Edit
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete?.(item.part_number); }}
style={{ ...actionBtnStyle, color: 'var(--ctp-red)' }}
onClick={(e) => {
e.stopPropagation();
onDelete?.(item.part_number);
}}
style={{
...actionBtnStyle,
color: "var(--ctp-red)",
}}
>
Del
</button>
@@ -226,7 +318,14 @@ export function ItemTable({
})}
{sortedItems.length === 0 && (
<tr>
<td colSpan={cols.length} style={{ padding: '2rem', textAlign: 'center', color: 'var(--ctp-subtext0)' }}>
<td
colSpan={cols.length}
style={{
padding: "2rem",
textAlign: "center",
color: "var(--ctp-subtext0)",
}}
>
No items found
</td>
</tr>
@@ -239,12 +338,14 @@ export function ItemTable({
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={ALL_COLUMNS.map((col): ContextMenuItem => ({
label: col.label,
checked: visibleColumns.includes(col.key),
onToggle: () => toggleColumn(col.key),
disabled: col.key === 'part_number',
}))}
items={ALL_COLUMNS.map(
(col): ContextMenuItem => ({
label: col.label,
checked: visibleColumns.includes(col.key),
onToggle: () => toggleColumn(col.key),
disabled: col.key === "part_number",
}),
)}
/>
)}
</>
@@ -252,11 +353,11 @@ export function ItemTable({
}
const actionBtnStyle: React.CSSProperties = {
background: 'none',
border: 'none',
color: 'var(--ctp-subtext1)',
cursor: 'pointer',
fontSize: '0.8rem',
padding: '0.15rem 0.4rem',
borderRadius: '0.25rem',
background: "none",
border: "none",
color: "var(--ctp-subtext1)",
cursor: "pointer",
fontSize: "0.8rem",
padding: "0.15rem 0.4rem",
borderRadius: "0.25rem",
};

View File

@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react';
import { get } from '../../api/client';
import type { Project } from '../../api/types';
import type { ItemFilters } from '../../hooks/useItems';
import { useEffect, useState } from "react";
import { get } from "../../api/client";
import type { Project } from "../../api/types";
import type { ItemFilters } from "../../hooks/useItems";
interface ItemsToolbarProps {
filters: ItemFilters;
onFilterChange: (partial: Partial<ItemFilters>) => void;
layout: 'horizontal' | 'vertical';
onLayoutChange: (layout: 'horizontal' | 'vertical') => void;
layout: "horizontal" | "vertical";
onLayoutChange: (layout: "horizontal" | "vertical") => void;
onExport: () => void;
onImport: () => void;
onCreate: () => void;
@@ -15,26 +15,40 @@ interface ItemsToolbarProps {
}
export function ItemsToolbar({
filters, onFilterChange, layout, onLayoutChange,
onExport, onImport, onCreate, isEditor,
filters,
onFilterChange,
layout,
onLayoutChange,
onExport,
onImport,
onCreate,
isEditor,
}: ItemsToolbarProps) {
const [projects, setProjects] = useState<Project[]>([]);
useEffect(() => {
get<Project[]>('/api/projects').then(setProjects).catch(() => {});
get<Project[]>("/api/projects")
.then(setProjects)
.catch(() => {});
}, []);
const scopeBtn = (scope: ItemFilters['searchScope'], label: string) => (
const scopeBtn = (scope: ItemFilters["searchScope"], label: string) => (
<button
onClick={() => onFilterChange({ searchScope: scope })}
style={{
padding: '0.3rem 0.6rem',
fontSize: '0.8rem',
border: 'none',
borderRadius: '0.3rem',
cursor: 'pointer',
backgroundColor: filters.searchScope === scope ? 'var(--ctp-mauve)' : 'var(--ctp-surface1)',
color: filters.searchScope === scope ? 'var(--ctp-crust)' : 'var(--ctp-subtext1)',
padding: "var(--d-input-py) var(--d-input-px)",
fontSize: "var(--d-input-font)",
border: "none",
borderRadius: "0.3rem",
cursor: "pointer",
backgroundColor:
filters.searchScope === scope
? "var(--ctp-mauve)"
: "var(--ctp-surface1)",
color:
filters.searchScope === scope
? "var(--ctp-crust)"
: "var(--ctp-subtext1)",
}}
>
{label}
@@ -42,15 +56,17 @@ export function ItemsToolbar({
);
return (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
alignItems: 'center',
padding: '0.75rem 0',
borderBottom: '1px solid var(--ctp-surface0)',
marginBottom: '0.5rem',
}}>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "var(--d-toolbar-gap)",
alignItems: "center",
padding: "var(--d-toolbar-py) 0",
borderBottom: "1px solid var(--ctp-surface0)",
marginBottom: "var(--d-toolbar-mb)",
}}
>
{/* Search */}
<input
type="text"
@@ -60,20 +76,20 @@ export function ItemsToolbar({
style={{
flex: 1,
minWidth: 200,
padding: '0.4rem 0.75rem',
backgroundColor: 'var(--ctp-surface0)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.4rem',
color: 'var(--ctp-text)',
fontSize: '0.85rem',
padding: "var(--d-input-py) var(--d-input-px)",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
color: "var(--ctp-text)",
fontSize: "var(--d-input-font)",
}}
/>
{/* Search scope */}
<div style={{ display: 'flex', gap: '0.25rem' }}>
{scopeBtn('all', 'All')}
{scopeBtn('part_number', 'PN')}
{scopeBtn('description', 'Desc')}
<div style={{ display: "flex", gap: "0.25rem" }}>
{scopeBtn("all", "All")}
{scopeBtn("part_number", "PN")}
{scopeBtn("description", "Desc")}
</div>
{/* Type filter */}
@@ -97,34 +113,46 @@ export function ItemsToolbar({
>
<option value="">All Projects</option>
{projects.map((p) => (
<option key={p.code} value={p.code}>{p.code}{p.name ? `${p.name}` : ''}</option>
<option key={p.code} value={p.code}>
{p.code}
{p.name ? `${p.name}` : ""}
</option>
))}
</select>
{/* Layout toggle */}
<button
onClick={() => onLayoutChange(layout === 'horizontal' ? 'vertical' : 'horizontal')}
title={`Switch to ${layout === 'horizontal' ? 'vertical' : 'horizontal'} layout`}
onClick={() =>
onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal")
}
title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`}
style={toolBtnStyle}
>
{layout === 'horizontal' ? '⬌' : '⬍'}
{layout === "horizontal" ? "⬌" : "⬍"}
</button>
{/* Export */}
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">Export</button>
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">
Export
</button>
{/* Import (editor only) */}
{isEditor && (
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">Import</button>
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">
Import
</button>
)}
{/* Create (editor only) */}
{isEditor && (
<button onClick={onCreate} style={{
...toolBtnStyle,
backgroundColor: 'var(--ctp-mauve)',
color: 'var(--ctp-crust)',
}}>
<button
onClick={onCreate}
style={{
...toolBtnStyle,
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
}}
>
+ New
</button>
)}
@@ -133,20 +161,20 @@ export function ItemsToolbar({
}
const selectStyle: React.CSSProperties = {
padding: '0.4rem 0.6rem',
backgroundColor: 'var(--ctp-surface0)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.4rem',
color: 'var(--ctp-text)',
fontSize: '0.85rem',
padding: "var(--d-input-py) var(--d-input-px)",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
color: "var(--ctp-text)",
fontSize: "var(--d-input-font)",
};
const toolBtnStyle: React.CSSProperties = {
padding: '0.4rem 0.75rem',
backgroundColor: 'var(--ctp-surface1)',
border: 'none',
borderRadius: '0.4rem',
color: 'var(--ctp-text)',
fontSize: '0.85rem',
cursor: 'pointer',
padding: "var(--d-input-py) var(--d-input-px)",
backgroundColor: "var(--ctp-surface1)",
border: "none",
borderRadius: "0.4rem",
color: "var(--ctp-text)",
fontSize: "var(--d-input-font)",
cursor: "pointer",
};

View File

@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import { useLocalStorage } from './useLocalStorage';
export type Density = 'comfortable' | 'compact';
function applyDensity(density: Density) {
document.documentElement.setAttribute('data-density', density);
}
export function useDensity(): [Density, () => void] {
const [density, setDensity] = useLocalStorage<Density>('silo-density', 'comfortable');
applyDensity(density);
const toggle = useCallback(() => {
const next: Density = density === 'comfortable' ? 'compact' : 'comfortable';
setDensity(next);
applyDensity(next);
}, [density, setDensity]);
return [density, toggle];
}

View File

@@ -1,11 +1,20 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { App } from './App';
import './styles/global.css';
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import { App } from "./App";
import "./styles/global.css";
createRoot(document.getElementById('root')!).render(
// Apply saved density before first paint to prevent FOUC
try {
const saved = localStorage.getItem("silo-density");
const density = saved ? JSON.parse(saved) : "comfortable";
document.documentElement.setAttribute("data-density", density);
} catch {
document.documentElement.setAttribute("data-density", "comfortable");
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>

View File

@@ -6,6 +6,7 @@ import { AuditToolbar } from "../components/audit/AuditToolbar";
import { AuditTable } from "../components/audit/AuditTable";
import { AuditDetailPanel } from "../components/audit/AuditDetailPanel";
import { SplitPanel } from "../components/items/SplitPanel";
import { PageFooter } from "../components/PageFooter";
type PaneMode = { type: "none" } | { type: "detail"; partNumber: string };
@@ -47,8 +48,8 @@ export function AuditPage() {
style={{
display: "flex",
flexDirection: "column",
height: "calc(100vh - 64px)",
paddingBottom: 28,
height: "100%",
paddingBottom: "var(--d-footer-h)",
}}
>
{error && (
@@ -91,45 +92,18 @@ export function AuditPage() {
storageKey="silo-audit-split"
/>
{/* Pagination */}
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "0.75rem",
padding: "0.4rem",
flexShrink: 0,
}}
>
<button
onClick={() => updateFilters({ page: Math.max(1, filters.page - 1) })}
disabled={filters.page <= 1}
style={pageBtnStyle}
>
Prev
</button>
<span style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}>
Page {filters.page} · {items.length} items
</span>
<button
onClick={() => updateFilters({ page: filters.page + 1 })}
disabled={items.length < filters.pageSize}
style={pageBtnStyle}
>
Next
</button>
</div>
<PageFooter
stats={
<>
<span>{summary.total_items} items</span>
<span>Avg: {(summary.avg_score * 100).toFixed(1)}%</span>
</>
}
page={filters.page}
pageSize={filters.pageSize}
itemCount={items.length}
onPageChange={(p) => updateFilters({ page: p })}
/>
</div>
);
}
const pageBtnStyle: React.CSSProperties = {
padding: "0.25rem 0.6rem",
fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-text)",
cursor: "pointer",
};

View File

@@ -14,7 +14,7 @@ import { EditItemPane } from "../components/items/EditItemPane";
import { DeleteItemPane } from "../components/items/DeleteItemPane";
import { ImportItemsPane } from "../components/items/ImportItemsPane";
import { SplitPanel } from "../components/items/SplitPanel";
import { FooterStats } from "../components/items/FooterStats";
import { PageFooter } from "../components/PageFooter";
type PaneMode =
| { type: "none" }
@@ -170,8 +170,8 @@ export function ItemsPage() {
style={{
display: "flex",
flexDirection: "column",
height: "calc(100vh - 64px)",
paddingBottom: 28,
height: "100%",
paddingBottom: "var(--d-footer-h)",
}}
>
{error && (
@@ -217,47 +217,40 @@ export function ItemsPage() {
secondary={secondaryPane}
/>
{/* Pagination */}
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "0.75rem",
padding: "0.4rem",
flexShrink: 0,
}}
>
<button
onClick={() => updateFilters({ page: Math.max(1, filters.page - 1) })}
disabled={filters.page <= 1}
style={pageBtnStyle}
>
Prev
</button>
<span style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}>
Page {filters.page} · {items.length} items
</span>
<button
onClick={() => updateFilters({ page: filters.page + 1 })}
disabled={items.length < filters.pageSize}
style={pageBtnStyle}
>
Next
</button>
</div>
<FooterStats items={items} />
<PageFooter
stats={
<>
<span>
Total:{" "}
<strong style={{ color: "var(--ctp-text)" }}>
{items.length}
</strong>
</span>
<span>
Parts:{" "}
<strong style={{ color: "var(--ctp-blue)" }}>
{items.filter((i) => i.item_type === "part").length}
</strong>
</span>
<span>
Assemblies:{" "}
<strong style={{ color: "var(--ctp-green)" }}>
{items.filter((i) => i.item_type === "assembly").length}
</strong>
</span>
<span>
Documents:{" "}
<strong style={{ color: "var(--ctp-yellow)" }}>
{items.filter((i) => i.item_type === "document").length}
</strong>
</span>
</>
}
page={filters.page}
pageSize={filters.pageSize}
itemCount={items.length}
onPageChange={(p) => updateFilters({ page: p })}
/>
</div>
);
}
const pageBtnStyle: React.CSSProperties = {
padding: "0.25rem 0.6rem",
fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-text)",
cursor: "pointer",
};

View File

@@ -1,29 +1,92 @@
/* Catppuccin Mocha Theme */
:root {
--ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-maroon: #eba0ac;
--ctp-peach: #fab387;
--ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1;
--ctp-teal: #94e2d5;
--ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa;
--ctp-lavender: #b4befe;
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086;
--ctp-surface2: #585b70;
--ctp-surface1: #45475a;
--ctp-surface0: #313244;
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
--ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-maroon: #eba0ac;
--ctp-peach: #fab387;
--ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1;
--ctp-teal: #94e2d5;
--ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa;
--ctp-lavender: #b4befe;
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086;
--ctp-surface2: #585b70;
--ctp-surface1: #45475a;
--ctp-surface0: #313244;
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
}
/* ── Density: comfortable (default) ── */
[data-density="comfortable"],
:root {
--d-header-py: 0.625rem;
--d-header-px: 2rem;
--d-header-logo: 1.25rem;
--d-nav-gap: 1rem;
--d-nav-py: 0.35rem;
--d-nav-px: 0.75rem;
--d-nav-radius: 0.4rem;
--d-user-gap: 0.6rem;
--d-user-font: 0.85rem;
--d-th-py: 0.35rem;
--d-th-px: 0.75rem;
--d-th-font: 0.75rem;
--d-td-py: 0.25rem;
--d-td-px: 0.75rem;
--d-td-font: 0.85rem;
--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-footer-h: 28px;
--d-footer-font: 0.75rem;
--d-footer-px: 2rem;
}
/* ── Density: compact ── */
[data-density="compact"] {
--d-header-py: 0.35rem;
--d-header-px: 1.25rem;
--d-header-logo: 1.1rem;
--d-nav-gap: 0.5rem;
--d-nav-py: 0.2rem;
--d-nav-px: 0.5rem;
--d-nav-radius: 0.3rem;
--d-user-gap: 0.35rem;
--d-user-font: 0.8rem;
--d-th-py: 0.2rem;
--d-th-px: 0.5rem;
--d-th-font: 0.7rem;
--d-td-py: 0.125rem;
--d-td-px: 0.5rem;
--d-td-font: 0.8rem;
--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-footer-h: 24px;
--d-footer-font: 0.7rem;
--d-footer-px: 1.25rem;
}