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
738 lines
21 KiB
TypeScript
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",
|
|
};
|