Files
silo/web/src/components/items/RevisionsTab.tsx
Forbes 07c4aa1c28 fix(web): align spacing values to style guide grid (#71)
- Replace 0.3rem padding/margin/gap with 0.25rem (xs)
- Replace 0.2rem margins with 0.25rem (xs)
- Replace 0.1rem padding with 0.15rem (badge spec)
- Replace 0.6rem margins/padding with 0.5rem (sm)
- Fix borderRadius 0.3rem to 0.375rem (6px per style guide)
- Preserve style-guide-specified values: 0.35rem button gap, 0.4rem cell padding, 0.45rem input padding
2026-02-13 14:37:40 -06:00

350 lines
11 KiB
TypeScript

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;
isEditor: boolean;
}
const statusColors: Record<string, string> = {
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",
});
}
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 [comparison, setComparison] = useState<RevisionComparison | null>(null);
const load = () => {
setLoading(true);
get<Revision[]>(`/api/items/${encodeURIComponent(partNumber)}/revisions`)
.then((r) => {
setRevisions(r);
setLoading(false);
})
.catch(() => setLoading(false));
};
useEffect(load, [partNumber]);
const handleCompare = async () => {
if (!compareFrom || !compareTo) return;
try {
const result = await get<RevisionComparison>(
`/api/items/${encodeURIComponent(partNumber)}/revisions/compare?from=${compareFrom}&to=${compareTo}`,
);
setComparison(result);
} catch (e) {
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 }),
},
);
load();
} catch (e) {
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}`;
try {
await post(
`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`,
{ comment },
);
load();
} catch (e) {
alert(e instanceof Error ? e.message : "Rollback failed");
}
};
if (loading)
return (
<div style={{ color: "var(--ctp-subtext0)" }}>Loading revisions...</div>
);
const selectStyle: React.CSSProperties = {
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}
>
<option value="">From rev...</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}
>
<option value="">To rev...</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: "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: "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>
)}
{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>
))}
{Object.entries(comparison.removed).map(([k, v]) => (
<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>
))}
{!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: "var(--font-table)",
}}
>
<thead>
<tr>
<th style={thStyle}>Rev</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Created</th>
<th style={thStyle}>By</th>
<th style={thStyle}>Comment</th>
<th style={thStyle}>File</th>
{isEditor && <th style={thStyle}>Actions</th>}
</tr>
</thead>
<tbody>
{revisions.map((rev, idx) => (
<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,
)
}
style={{
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>
<option value="review">review</option>
<option value="released">released</option>
<option value="obsolete">obsolete</option>
</select>
) : (
<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.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",
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",
}}
title="Rollback to this revision"
>
Rollback
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
const thStyle: React.CSSProperties = {
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",
};