- Created comprehensive QA checklist covering edge cases (missing EXIF, timezones, codecs, corrupt files) - Added ErrorBoundary component wrapped around TimelineTree and MediaPanel - Created global error.tsx page for unhandled errors - Improved failed asset UX with red borders, warning icons, and inline error display - Added loading skeletons to TimelineTree and MediaPanel - Added retry button for failed media loads - Created DEPLOYMENT_VALIDATION.md with validation commands and checklist - Applied k8s recommendations: - Changed node affinity to required for compute nodes (Pi 5) - Enabled Tailscale LoadBalancer service for MinIO S3 (reliable Range requests) - Enabled cleanup CronJob for staging files
421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
type Granularity = "year" | "month" | "day";
|
|
|
|
type TreeNode = {
|
|
id: string;
|
|
label: string;
|
|
ts: string; // ISO string from API
|
|
countTotal: number;
|
|
countReady: number;
|
|
children?: TreeNode[];
|
|
};
|
|
|
|
type ApiTreeRow = {
|
|
bucket: string;
|
|
group_ts: string;
|
|
count_total: number;
|
|
count_ready: number;
|
|
};
|
|
|
|
type ApiTreeResponse = {
|
|
granularity: Granularity;
|
|
start: string | null;
|
|
end: string | null;
|
|
nodes: ApiTreeRow[];
|
|
};
|
|
|
|
type Orientation = "vertical" | "horizontal";
|
|
|
|
type ExpandedState = Record<string, boolean>;
|
|
|
|
function monthKey(d: Date) {
|
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
function dayKey(d: Date) {
|
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
function yearKey(d: Date) {
|
|
return `${d.getUTCFullYear()}`;
|
|
}
|
|
|
|
function buildHierarchy(dayRows: ApiTreeRow[]): TreeNode[] {
|
|
const years = new Map<string, TreeNode>();
|
|
const months = new Map<string, TreeNode>();
|
|
|
|
for (const row of dayRows) {
|
|
const date = new Date(row.group_ts);
|
|
const year = yearKey(date);
|
|
const month = monthKey(date);
|
|
const day = dayKey(date);
|
|
|
|
const yMapKey = `${row.bucket}:${year}`;
|
|
const mMapKey = `${row.bucket}:${month}`;
|
|
|
|
let yNode = years.get(yMapKey);
|
|
if (!yNode) {
|
|
yNode = {
|
|
id: `y:${year}:${row.bucket}`,
|
|
label: year,
|
|
ts: new Date(Date.UTC(Number(year), 0, 1)).toISOString(),
|
|
countTotal: 0,
|
|
countReady: 0,
|
|
children: [],
|
|
};
|
|
years.set(yMapKey, yNode);
|
|
}
|
|
|
|
let mNode = months.get(mMapKey);
|
|
if (!mNode) {
|
|
const [yy, mm] = month.split("-");
|
|
mNode = {
|
|
id: `m:${month}:${row.bucket}`,
|
|
label: `${yy}-${mm}`,
|
|
ts: new Date(Date.UTC(Number(yy), Number(mm) - 1, 1)).toISOString(),
|
|
countTotal: 0,
|
|
countReady: 0,
|
|
children: [],
|
|
};
|
|
months.set(mMapKey, mNode);
|
|
yNode.children!.push(mNode);
|
|
}
|
|
|
|
const dNode: TreeNode = {
|
|
id: `d:${day}:${row.bucket}`,
|
|
label: day,
|
|
ts: row.group_ts,
|
|
countTotal: row.count_total,
|
|
countReady: row.count_ready,
|
|
};
|
|
|
|
mNode.children!.push(dNode);
|
|
|
|
// rollups
|
|
mNode.countTotal += row.count_total;
|
|
mNode.countReady += row.count_ready;
|
|
yNode.countTotal += row.count_total;
|
|
yNode.countReady += row.count_ready;
|
|
}
|
|
|
|
// Sort ascending within each parent
|
|
const yearNodes = Array.from(years.values()).sort((a, b) =>
|
|
a.label.localeCompare(b.label),
|
|
);
|
|
for (const y of yearNodes) {
|
|
y.children = (y.children ?? []).sort((a, b) =>
|
|
a.label.localeCompare(b.label),
|
|
);
|
|
for (const m of y.children) {
|
|
m.children = (m.children ?? []).sort((a, b) =>
|
|
a.label.localeCompare(b.label),
|
|
);
|
|
}
|
|
}
|
|
|
|
return yearNodes;
|
|
}
|
|
|
|
function gatherVisible(
|
|
roots: TreeNode[],
|
|
expanded: ExpandedState,
|
|
): Array<{ node: TreeNode; depth: number; parentId: string | null }> {
|
|
const out: Array<{ node: TreeNode; depth: number; parentId: string | null }> =
|
|
[];
|
|
|
|
function walk(nodes: TreeNode[], depth: number, parentId: string | null) {
|
|
for (const n of nodes) {
|
|
out.push({ node: n, depth, parentId });
|
|
const isExpanded = expanded[n.id] ?? depth < 1; // default expand years
|
|
if (n.children?.length && isExpanded) {
|
|
walk(n.children, depth + 1, n.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(roots, 0, null);
|
|
return out;
|
|
}
|
|
|
|
export function TimelineTree(props: {
|
|
onSelectDay?: (dayIso: string) => void;
|
|
}) {
|
|
const [orientation, setOrientation] = useState<Orientation>("vertical");
|
|
const [expanded, setExpanded] = useState<ExpandedState>({});
|
|
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// simple pan/zoom via viewBox
|
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 1200, h: 800 });
|
|
const dragState = useRef<{
|
|
active: boolean;
|
|
startX: number;
|
|
startY: number;
|
|
startViewBox: typeof viewBox;
|
|
} | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function load() {
|
|
try {
|
|
setError(null);
|
|
const res = await fetch(
|
|
"/api/tree?granularity=day&limit=500&includeFailed=1",
|
|
{
|
|
cache: "no-store",
|
|
},
|
|
);
|
|
if (!res.ok) throw new Error(`tree_fetch_failed:${res.status}`);
|
|
const json = (await res.json()) as ApiTreeResponse;
|
|
if (!cancelled) setRows(json.nodes);
|
|
} catch (e) {
|
|
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}
|
|
void load();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]);
|
|
const visible = useMemo(
|
|
() => gatherVisible(roots, expanded),
|
|
[roots, expanded],
|
|
);
|
|
|
|
const layout = useMemo(() => {
|
|
const nodeGap = 56;
|
|
const depthGap = 180;
|
|
|
|
const positions = new Map<string, { x: number; y: number }>();
|
|
|
|
for (let i = 0; i < visible.length; i++) {
|
|
const { node, depth } = visible[i];
|
|
const primary = i * nodeGap;
|
|
const secondary = depth * depthGap;
|
|
|
|
const x = orientation === "vertical" ? secondary : primary;
|
|
const y = orientation === "vertical" ? primary : secondary;
|
|
|
|
positions.set(node.id, { x, y });
|
|
}
|
|
|
|
// compute bounds
|
|
let maxX = 0;
|
|
let maxY = 0;
|
|
for (const p of positions.values()) {
|
|
maxX = Math.max(maxX, p.x);
|
|
maxY = Math.max(maxY, p.y);
|
|
}
|
|
|
|
const padding = 120;
|
|
const w = maxX + padding * 2;
|
|
const h = maxY + padding * 2;
|
|
|
|
return { positions, w, h, padding };
|
|
}, [visible, orientation]);
|
|
|
|
useEffect(() => {
|
|
// reset viewBox when layout changes
|
|
setViewBox((vb) => ({
|
|
...vb,
|
|
w: Math.max(layout.w, 1200),
|
|
h: Math.max(layout.h, 800),
|
|
}));
|
|
}, [layout.w, layout.h]);
|
|
|
|
function toggleNode(id: string) {
|
|
setExpanded((prev) => ({ ...prev, [id]: !(prev[id] ?? false) }));
|
|
}
|
|
|
|
function onPointerDown(e: React.PointerEvent<SVGSVGElement>) {
|
|
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
|
|
dragState.current = {
|
|
active: true,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
startViewBox: viewBox,
|
|
};
|
|
}
|
|
|
|
function onPointerMove(e: React.PointerEvent<SVGSVGElement>) {
|
|
if (!dragState.current?.active) return;
|
|
const dx = e.clientX - dragState.current.startX;
|
|
const dy = e.clientY - dragState.current.startY;
|
|
|
|
// Convert pixels to viewBox units roughly; this is intentionally simple.
|
|
const scaleX = viewBox.w / 900;
|
|
const scaleY = viewBox.h / 600;
|
|
|
|
setViewBox({
|
|
...viewBox,
|
|
x: dragState.current.startViewBox.x - dx * scaleX,
|
|
y: dragState.current.startViewBox.y - dy * scaleY,
|
|
});
|
|
}
|
|
|
|
function onPointerUp(e: React.PointerEvent<SVGSVGElement>) {
|
|
dragState.current = null;
|
|
try {
|
|
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function onWheel(e: React.WheelEvent<SVGSVGElement>) {
|
|
e.preventDefault();
|
|
const factor = e.deltaY < 0 ? 0.9 : 1.1;
|
|
|
|
const mouseX = e.clientX;
|
|
const mouseY = e.clientY;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
|
|
const px = (mouseX - rect.left) / rect.width;
|
|
const py = (mouseY - rect.top) / rect.height;
|
|
|
|
const newW = Math.max(300, Math.min(5000, viewBox.w * factor));
|
|
const newH = Math.max(300, Math.min(5000, viewBox.h * factor));
|
|
|
|
const newX = viewBox.x + (viewBox.w - newW) * px;
|
|
const newY = viewBox.y + (viewBox.h - newH) * py;
|
|
|
|
setViewBox({ x: newX, y: newY, w: newW, h: newH });
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: "grid", gap: 12 }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 8,
|
|
alignItems: "center",
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<strong>Timeline</strong>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setOrientation((o) =>
|
|
o === "vertical" ? "horizontal" : "vertical",
|
|
)
|
|
}
|
|
>
|
|
Orientation: {orientation}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setViewBox({ x: 0, y: 0, w: 1200, h: 800 })}
|
|
>
|
|
Reset view
|
|
</button>
|
|
{rows ? (
|
|
<span style={{ color: "#666" }}>{rows.length} day nodes</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
|
{!rows && !error ? (
|
|
<div
|
|
style={{
|
|
border: "1px solid #ddd",
|
|
borderRadius: 8,
|
|
overflow: "hidden",
|
|
height: 600,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
}}
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="h-4 w-48 animate-pulse rounded bg-gray-200" />
|
|
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
|
|
<div className="h-4 w-40 animate-pulse rounded bg-gray-200" />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
style={{
|
|
border: "1px solid #ddd",
|
|
borderRadius: 8,
|
|
overflow: "hidden",
|
|
height: 600,
|
|
}}
|
|
>
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`}
|
|
width="100%"
|
|
height="100%"
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onWheel={onWheel}
|
|
style={{ touchAction: "none", background: "#fafafa" }}
|
|
>
|
|
<g transform={`translate(${layout.padding} ${layout.padding})`}>
|
|
{/* edges */}
|
|
{visible.map(({ node, parentId }) => {
|
|
if (!parentId) return null;
|
|
const p = layout.positions.get(parentId);
|
|
const c = layout.positions.get(node.id);
|
|
if (!p || !c) return null;
|
|
return (
|
|
<line
|
|
key={`${parentId}->${node.id}`}
|
|
x1={p.x}
|
|
y1={p.y}
|
|
x2={c.x}
|
|
y2={c.y}
|
|
stroke="#bbb"
|
|
strokeWidth={2}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* nodes */}
|
|
{visible.map(({ node }) => {
|
|
const pos = layout.positions.get(node.id);
|
|
if (!pos) return null;
|
|
|
|
const hasChildren = Boolean(node.children?.length);
|
|
const isExpanded = expanded[node.id] ?? node.id.startsWith("y:");
|
|
|
|
const isDay = node.id.startsWith("d:");
|
|
const clickCursor = hasChildren || isDay ? "pointer" : "default";
|
|
|
|
return (
|
|
<g
|
|
key={node.id}
|
|
transform={`translate(${pos.x} ${pos.y})`}
|
|
style={{ cursor: clickCursor }}
|
|
onClick={() => {
|
|
if (hasChildren) toggleNode(node.id);
|
|
if (isDay) props.onSelectDay?.(node.ts);
|
|
}}
|
|
>
|
|
<circle r={12} fill={hasChildren ? "#1f6feb" : "#666"} />
|
|
<text x={20} y={5} fontSize={14} fill="#111">
|
|
{node.label} ({node.countReady}/{node.countTotal})
|
|
{hasChildren ? (isExpanded ? " ▼" : " ▶") : ""}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
|
|
<div style={{ color: "#666", fontSize: 12 }}>
|
|
Drag to pan, mouse wheel to zoom. Click years/months to expand.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|