diff --git a/services/web-ui/public/visuals.jsx b/services/web-ui/public/visuals.jsx new file mode 100644 index 0000000..6f7c5c3 --- /dev/null +++ b/services/web-ui/public/visuals.jsx @@ -0,0 +1,237 @@ +// visuals.jsx - reusable visual elements: thumbnails, waveforms, sparklines, filmstrips + +const { thumbGrad } = window.ZAMPP_DATA; + +// AssetThumb — renders a placeholder thumbnail for an asset. +// For video: gradient background + scan lines + framed window + tiny "preview" content +// For audio: waveform +function AssetThumb({ asset, size = "md" }) { + const aspect = size === "tall" ? "9 / 16" : "16 / 9"; + const seed = asset.seed || 1; + + if (asset.type === "audio") { + return ( +
+ +
+ +
+
+ ); + } + + return ( +
+ +
+
+ ); +} + +// FauxFrame - draws a stylized video frame using shapes/SVG +function FauxFrame({ seed }) { + // pick a "shot" type by seed + const shots = ["stage", "track", "interview", "aerial", "crowd", "trophy"]; + const shot = shots[seed % shots.length]; + + return ( + + + + + + + + + + + + {shot === "stage" && ( + <> + + + + + + + + + + )} + {shot === "track" && ( + <> + + + + + + + + + + )} + {shot === "interview" && ( + <> + + + + + + + + )} + {shot === "aerial" && ( + <> + + + + + + + + + )} + {shot === "crowd" && ( + <> + + + {Array.from({ length: 40 }).map((_, i) => { + const x = (i * 4.5) % 160; + const y = 50 + Math.sin(i * 1.7) * 4; + return ; + })} + + )} + {shot === "trophy" && ( + <> + + + + + + + + )} + + ); +} + +// Waveform — pseudorandom but deterministic +function Waveform({ seed = 1, color = "var(--accent)", className = "" }) { + const bars = 60; + const pts = React.useMemo(() => { + return Array.from({ length: bars }).map((_, i) => { + const x = i / bars; + const n = Math.sin(i * 0.7 + seed) * 0.5 + Math.sin(i * 2.1 + seed * 1.3) * 0.3 + Math.sin(i * 4.3 + seed * 0.7) * 0.2; + return Math.max(0.1, Math.min(1, 0.5 + n * 0.5)); + }); + }, [seed]); + + return ( + + {pts.map((p, i) => ( + + ))} + + ); +} + +// Live scrolling thumbnail strip - shows recent frames coming in +function LiveStrip({ seed = 1, count = 8 }) { + const [tick, setTick] = React.useState(0); + React.useEffect(() => { + const i = setInterval(() => setTick(t => t + 1), 2000); + return () => clearInterval(i); + }, []); + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ +
+ ))} +
+ + NOW +
+
+ ); +} + +// Sparkline +function Sparkline({ data, color = "var(--accent)", height = 28, fill = true }) { + const max = Math.max(...data, 1); + const min = Math.min(...data, 0); + const range = max - min || 1; + const w = 100; + const pts = data.map((d, i) => { + const x = (i / (data.length - 1)) * w; + const y = height - ((d - min) / range) * height; + return `${x},${y}`; + }).join(" "); + const area = `0,${height} ${pts} ${w},${height}`; + return ( + + {fill && } + + + ); +} + +// Animated audio meter +function AudioMeter({ level = 0.7, peak = 0.85, vertical = false }) { + const segs = 20; + return ( +
+ {Array.from({ length: segs }).map((_, i) => { + const v = i / segs; + const on = v < level; + const color = v < 0.6 ? "var(--success)" : v < 0.85 ? "var(--warning)" : "var(--danger)"; + return
; + })} +
+ ); +} + +// Status dot with pulse +function StatusDot({ status }) { + const map = { + online: { color: "var(--success)", pulse: false }, + recording: { color: "var(--live)", pulse: true }, + armed: { color: "var(--accent)", pulse: false }, + idle: { color: "var(--text-4)", pulse: false }, + error: { color: "var(--danger)", pulse: true }, + offline: { color: "var(--text-4)", pulse: false }, + processing: { color: "var(--warning)", pulse: true }, + ready: { color: "var(--success)", pulse: false }, + live: { color: "var(--live)", pulse: true }, + queued: { color: "var(--text-3)", pulse: false }, + running: { color: "var(--accent)", pulse: true }, + done: { color: "var(--success)", pulse: false }, + failed: { color: "var(--danger)", pulse: false }, + }; + const s = map[status] || { color: "var(--text-3)" }; + return ( + + ); +} + +// Live-style ticking clock for elapsed times +function Elapsed({ seconds, live = false }) { + const [t, setT] = React.useState(seconds); + React.useEffect(() => { + if (!live) return; + const i = setInterval(() => setT(x => x + 1), 1000); + return () => clearInterval(i); + }, [live]); + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const s = t % 60; + return {String(h).padStart(2, "0")}:{String(m).padStart(2, "0")}:{String(s).padStart(2, "0")}; +} + +Object.assign(window, { AssetThumb, FauxFrame, Waveform, LiveStrip, Sparkline, AudioMeter, StatusDot, Elapsed });