Files
silo/web/src/pages/SchemasPage.tsx
Forbes 50923cf56d feat: production release with React SPA, file attachments, and deploy tooling
Backend:
- Add file_handlers.go: presigned upload/download for item attachments
- Add item_files.go: item file and thumbnail DB operations
- Add migration 011: item_files table and thumbnail_key column
- Update items/projects/relationships DB with extended field support
- Update routes: React SPA serving from web/dist, file upload endpoints
- Update auth handlers and middleware for cookie + bearer token auth
- Remove Go HTML templates (replaced by React SPA)
- Update storage client for presigned URL generation

Frontend:
- Add TagInput component for tag/keyword entry
- Add SVG assets for Silo branding and UI icons
- Update API client and types for file uploads, auth, extended fields
- Update AuthContext for session-based auth flow
- Update LoginPage, ProjectsPage, SchemasPage, SettingsPage
- Fix tsconfig.node.json

Deployment:
- Update config.prod.yaml: single-binary SPA layout at /opt/silo
- Update silod.service: ReadOnlyPaths for /opt/silo
- Add scripts/deploy.sh: build, package, ship, migrate, start
- Update docker-compose.yaml and Dockerfile
- Add frontend-spec.md design document
2026-02-07 13:35:22 -06:00

738 lines
21 KiB
TypeScript

import { useEffect, useState, type FormEvent } from "react";
import { get, post, put, del } from "../api/client";
import { useAuth } from "../hooks/useAuth";
import type { Schema, SchemaSegment } from "../api/types";
interface EnumEditState {
schemaName: string;
segmentName: string;
code: string;
description: string;
mode: "add" | "edit" | "delete";
}
export function SchemasPage() {
const { user } = useAuth();
const isEditor = user?.role === "admin" || user?.role === "editor";
const [schemas, setSchemas] = useState<Schema[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [editState, setEditState] = useState<EnumEditState | null>(null);
const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false);
const loadSchemas = async () => {
try {
const list = await get<Schema[]>("/api/schemas");
setSchemas(list.filter((s) => s.name));
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load schemas");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadSchemas();
}, []);
const toggleExpand = (key: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const startAdd = (schemaName: string, segmentName: string) => {
setEditState({
schemaName,
segmentName,
code: "",
description: "",
mode: "add",
});
setFormError("");
};
const startEdit = (
schemaName: string,
segmentName: string,
code: string,
description: string,
) => {
setEditState({ schemaName, segmentName, code, description, mode: "edit" });
setFormError("");
};
const startDelete = (
schemaName: string,
segmentName: string,
code: string,
) => {
setEditState({
schemaName,
segmentName,
code,
description: "",
mode: "delete",
});
setFormError("");
};
const cancelEdit = () => {
setEditState(null);
setFormError("");
};
const handleAddValue = async (e: FormEvent) => {
e.preventDefault();
if (!editState) return;
setFormError("");
setSubmitting(true);
try {
await post(
`/api/schemas/${editState.schemaName}/segments/${editState.segmentName}/values`,
{ code: editState.code, description: editState.description },
);
cancelEdit();
await loadSchemas();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to add value");
} finally {
setSubmitting(false);
}
};
const handleUpdateValue = async (e: FormEvent) => {
e.preventDefault();
if (!editState) return;
setFormError("");
setSubmitting(true);
try {
await put(
`/api/schemas/${editState.schemaName}/segments/${editState.segmentName}/values/${editState.code}`,
{ description: editState.description },
);
cancelEdit();
await loadSchemas();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to update value");
} finally {
setSubmitting(false);
}
};
const handleDeleteValue = async () => {
if (!editState) return;
setSubmitting(true);
try {
await del(
`/api/schemas/${editState.schemaName}/segments/${editState.segmentName}/values/${editState.code}`,
);
cancelEdit();
await loadSchemas();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to delete value");
} finally {
setSubmitting(false);
}
};
if (loading)
return <p style={{ color: "var(--ctp-subtext0)" }}>Loading schemas...</p>;
if (error) return <p style={{ color: "var(--ctp-red)" }}>Error: {error}</p>;
return (
<div>
<h2 style={{ marginBottom: "1rem" }}>
Part Numbering Schemas ({schemas.length})
</h2>
{schemas.length === 0 ? (
<div style={emptyStyle}>No schemas found.</div>
) : (
schemas.map((schema) => (
<SchemaCard
key={schema.name}
schema={schema}
expanded={expanded}
toggleExpand={toggleExpand}
isEditor={isEditor}
editState={editState}
formError={formError}
submitting={submitting}
onStartAdd={startAdd}
onStartEdit={startEdit}
onStartDelete={startDelete}
onCancelEdit={cancelEdit}
onAdd={handleAddValue}
onUpdate={handleUpdateValue}
onDelete={handleDeleteValue}
onEditStateChange={setEditState}
/>
))
)}
</div>
);
}
// --- Sub-components (local to this file) ---
interface SchemaCardProps {
schema: Schema;
expanded: Set<string>;
toggleExpand: (key: string) => void;
isEditor: boolean;
editState: EnumEditState | null;
formError: string;
submitting: boolean;
onStartAdd: (schemaName: string, segmentName: string) => void;
onStartEdit: (
schemaName: string,
segmentName: string,
code: string,
desc: string,
) => void;
onStartDelete: (
schemaName: string,
segmentName: string,
code: string,
) => void;
onCancelEdit: () => void;
onAdd: (e: FormEvent) => void;
onUpdate: (e: FormEvent) => void;
onDelete: () => void;
onEditStateChange: (state: EnumEditState | null) => void;
}
function SchemaCard({
schema,
expanded,
toggleExpand,
isEditor,
editState,
formError,
submitting,
onStartAdd,
onStartEdit,
onStartDelete,
onCancelEdit,
onAdd,
onUpdate,
onDelete,
onEditStateChange,
}: SchemaCardProps) {
const segKey = `seg-${schema.name}`;
const isExpanded = expanded.has(segKey);
return (
<div style={cardStyle}>
<h3 style={{ color: "var(--ctp-mauve)", marginBottom: "0.5rem" }}>
{schema.name}
</h3>
{schema.description && (
<p style={{ color: "var(--ctp-subtext0)", marginBottom: "1rem" }}>
{schema.description}
</p>
)}
<p style={{ marginBottom: "0.5rem" }}>
<strong>Format:</strong> <code style={codeStyle}>{schema.format}</code>
</p>
<p style={{ marginBottom: "1rem" }}>
<strong>Version:</strong> {schema.version}
</p>
{schema.examples && schema.examples.length > 0 && (
<>
<p style={{ marginBottom: "0.5rem" }}>
<strong>Examples:</strong>
</p>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginBottom: "1rem",
}}
>
{schema.examples.map((ex) => (
<span
key={ex}
style={{
...codeStyle,
fontFamily: "'JetBrains Mono', monospace",
}}
>
{ex}
</span>
))}
</div>
</>
)}
<div
onClick={() => toggleExpand(segKey)}
style={{
cursor: "pointer",
color: "var(--ctp-sapphire)",
userSelect: "none",
marginTop: "1rem",
}}
>
{isExpanded ? "\u25BC" : "\u25B6"} View Segments (
{schema.segments.length})
</div>
{isExpanded &&
schema.segments.map((seg) => (
<SegmentBlock
key={seg.name}
schemaName={schema.name}
segment={seg}
isEditor={isEditor}
editState={editState}
formError={formError}
submitting={submitting}
onStartAdd={onStartAdd}
onStartEdit={onStartEdit}
onStartDelete={onStartDelete}
onCancelEdit={onCancelEdit}
onAdd={onAdd}
onUpdate={onUpdate}
onDelete={onDelete}
onEditStateChange={onEditStateChange}
/>
))}
</div>
);
}
interface SegmentBlockProps {
schemaName: string;
segment: SchemaSegment;
isEditor: boolean;
editState: EnumEditState | null;
formError: string;
submitting: boolean;
onStartAdd: (schemaName: string, segmentName: string) => void;
onStartEdit: (
schemaName: string,
segmentName: string,
code: string,
desc: string,
) => void;
onStartDelete: (
schemaName: string,
segmentName: string,
code: string,
) => void;
onCancelEdit: () => void;
onAdd: (e: FormEvent) => void;
onUpdate: (e: FormEvent) => void;
onDelete: () => void;
onEditStateChange: (state: EnumEditState | null) => void;
}
function SegmentBlock({
schemaName,
segment,
isEditor,
editState,
formError,
submitting,
onStartAdd,
onStartEdit,
onStartDelete,
onCancelEdit,
onAdd,
onUpdate,
onDelete,
onEditStateChange,
}: SegmentBlockProps) {
const isThisSegment = (es: EnumEditState | null) =>
es !== null &&
es.schemaName === schemaName &&
es.segmentName === segment.name;
const entries = segment.values
? Object.entries(segment.values).sort((a, b) => a[0].localeCompare(b[0]))
: [];
return (
<div style={segmentStyle}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<h4 style={{ margin: 0, color: "var(--ctp-blue)" }}>{segment.name}</h4>
<span style={typeBadgeStyle}>{segment.type}</span>
</div>
{segment.description && (
<p
style={{
color: "var(--ctp-subtext0)",
marginBottom: "0.5rem",
fontSize: "0.85rem",
}}
>
{segment.description}
</p>
)}
{segment.type === "enum" && entries.length > 0 && (
<div style={{ marginTop: "0.5rem", overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>Code</th>
<th style={thStyle}>Description</th>
{isEditor && (
<th style={{ ...thStyle, width: 120 }}>Actions</th>
)}
</tr>
</thead>
<tbody>
{entries.map(([code, desc]) => {
const isEditingThis =
isThisSegment(editState) &&
editState!.code === code &&
editState!.mode === "edit";
const isDeletingThis =
isThisSegment(editState) &&
editState!.code === code &&
editState!.mode === "delete";
if (isEditingThis) {
return (
<tr key={code}>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
</td>
<td style={tdStyle}>
<form
onSubmit={onUpdate}
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
}}
>
<input
type="text"
value={editState!.description}
onChange={(e) =>
onEditStateChange({
...editState!,
description: e.target.value,
})
}
required
style={inlineInputStyle}
autoFocus
/>
<button
type="submit"
disabled={submitting}
style={btnTinyPrimaryStyle}
>
Save
</button>
<button
type="button"
onClick={onCancelEdit}
style={btnTinyStyle}
>
Cancel
</button>
</form>
{formError && (
<div
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{formError}
</div>
)}
</td>
{isEditor && <td style={tdStyle} />}
</tr>
);
}
if (isDeletingThis) {
return (
<tr
key={code}
style={{ backgroundColor: "rgba(243, 139, 168, 0.1)" }}
>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
</td>
<td style={tdStyle}>
<span
style={{
color: "var(--ctp-red)",
fontSize: "0.85rem",
}}
>
Delete this value?
</span>
{formError && (
<div
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{formError}
</div>
)}
</td>
<td style={tdStyle}>
<div style={{ display: "flex", gap: "0.25rem" }}>
<button
onClick={onDelete}
disabled={submitting}
style={{
...btnTinyStyle,
backgroundColor: "var(--ctp-red)",
color: "var(--ctp-crust)",
}}
>
{submitting ? "..." : "Delete"}
</button>
<button onClick={onCancelEdit} style={btnTinyStyle}>
Cancel
</button>
</div>
</td>
</tr>
);
}
return (
<tr key={code}>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
</td>
<td style={tdStyle}>{desc}</td>
{isEditor && (
<td style={tdStyle}>
<div style={{ display: "flex", gap: "0.25rem" }}>
<button
onClick={() =>
onStartEdit(schemaName, segment.name, code, desc)
}
style={btnTinyStyle}
>
Edit
</button>
<button
onClick={() =>
onStartDelete(schemaName, segment.name, code)
}
style={{
...btnTinyStyle,
backgroundColor: "var(--ctp-surface2)",
}}
>
Delete
</button>
</div>
</td>
)}
</tr>
);
})}
{/* Add row */}
{isThisSegment(editState) && editState!.mode === "add" && (
<tr>
<td style={tdStyle}>
<input
type="text"
value={editState!.code}
onChange={(e) =>
onEditStateChange({
...editState!,
code: e.target.value,
})
}
placeholder="Code"
required
style={inlineInputStyle}
autoFocus
/>
</td>
<td style={tdStyle}>
<form
onSubmit={onAdd}
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
}}
>
<input
type="text"
value={editState!.description}
onChange={(e) =>
onEditStateChange({
...editState!,
description: e.target.value,
})
}
placeholder="Description"
required
style={inlineInputStyle}
/>
<button
type="submit"
disabled={submitting}
style={btnTinyPrimaryStyle}
>
Add
</button>
<button
type="button"
onClick={onCancelEdit}
style={btnTinyStyle}
>
Cancel
</button>
</form>
{formError && (
<div
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{formError}
</div>
)}
</td>
{isEditor && <td style={tdStyle} />}
</tr>
)}
</tbody>
</table>
</div>
)}
{segment.type === "enum" &&
isEditor &&
!(isThisSegment(editState) && editState!.mode === "add") && (
<button
onClick={() => onStartAdd(schemaName, segment.name)}
style={{ ...btnTinyPrimaryStyle, marginTop: "0.5rem" }}
>
+ Add Value
</button>
)}
</div>
);
}
// --- Styles ---
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.75rem",
padding: "1.25rem",
marginBottom: "1rem",
};
const codeStyle: React.CSSProperties = {
background: "var(--ctp-surface1)",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
fontSize: "0.85rem",
};
const segmentStyle: React.CSSProperties = {
marginTop: "1rem",
padding: "1rem",
background: "var(--ctp-base)",
borderRadius: "0.5rem",
};
const typeBadgeStyle: React.CSSProperties = {
display: "inline-block",
padding: "0.15rem 0.5rem",
borderRadius: "0.25rem",
fontSize: "0.75rem",
fontWeight: 600,
backgroundColor: "rgba(166, 227, 161, 0.15)",
color: "var(--ctp-green)",
};
const emptyStyle: React.CSSProperties = {
textAlign: "center",
padding: "2rem",
color: "var(--ctp-subtext0)",
};
const thStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
fontWeight: 600,
fontSize: "0.8rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
};
const tdStyle: React.CSSProperties = {
padding: "0.3rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "0.85rem",
};
const btnTinyStyle: React.CSSProperties = {
padding: "0.2rem 0.5rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
fontSize: "0.75rem",
cursor: "pointer",
};
const btnTinyPrimaryStyle: React.CSSProperties = {
padding: "0.2rem 0.5rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
fontSize: "0.75rem",
fontWeight: 600,
cursor: "pointer",
};
const inlineInputStyle: React.CSSProperties = {
padding: "0.25rem 0.5rem",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
fontSize: "0.85rem",
width: "100%",
boxSizing: "border-box",
};