diff --git a/frontend/competition-view/package.json b/frontend/competition-view/package.json index ed403d3bd..462843d6a 100644 --- a/frontend/competition-view/package.json +++ b/frontend/competition-view/package.json @@ -7,6 +7,7 @@ "preinstall": "npx only-allow pnpm", "dev": "vite", "dev:main": "pnpm dev", + "mock:backend": "node scripts/mock-backend.mjs", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" diff --git a/frontend/competition-view/scripts/mock-backend.mjs b/frontend/competition-view/scripts/mock-backend.mjs new file mode 100644 index 000000000..24027e0cf --- /dev/null +++ b/frontend/competition-view/scripts/mock-backend.mjs @@ -0,0 +1,391 @@ +/** + * Mock backend for exercising Competition View without the pod. + * + * Stands in for the Go backend on port 4000. Its main job is to push messages + * on the `message/update` topic so the Messages page can be checked against + * every severity and payload variant the real backend can send. It also + * publishes a fixed VCU state (so the state-gated order buttons are enabled) + * and prints every `order/send` the GUI sends back, echoing the "order sent" + * info message the real backend produces. + * + * node scripts/mock-backend.mjs # one of every variant, then a random stream + * node scripts/mock-backend.mjs --interval 300 # faster stream + * node scripts/mock-backend.mjs --interval 0 # burst only, then stay quiet + * node scripts/mock-backend.mjs --state Idle # change the gating vehicle state + * + * Then run the view against it: pnpm dev --filter competition-view + */ + +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; + +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const STATE_PACKET_ID = 210; + +const args = process.argv.slice(2); +const argValue = (flag, fallback) => { + const i = args.indexOf(flag); + return i === -1 ? fallback : args[i + 1]; +}; + +const PORT = Number(argValue("--port", "4000")); +const VEHICLE_STATE = argValue("--state", "Ready"); +const INTERVAL = Number(argValue("--interval", "1500")); +const NO_BURST = args.includes("--no-burst"); + +// ------------------------------------------------------------------- messages + +/** RTC fields as the backend sends them — no timezone, just wall clock. */ +const timestamp = () => { + const d = new Date(); + return { + counter: 0, + second: d.getSeconds(), + minute: d.getMinutes(), + hour: d.getHours(), + day: d.getDate(), + month: d.getMonth() + 1, + year: d.getFullYear(), + }; +}; + +/** + * One entry per `kind` × payload shape the frontend knows how to render + * (see src/types/message.ts and src/lib/message.ts). Sent as a burst on + * connect so every branch of the Messages UI is covered in one run. + */ +const MESSAGE_VARIANTS = [ + { kind: "info", board: "VCU", name: "Order sent", payload: "Brake order dispatched" }, + { kind: "ok", board: "VCU", name: "Brakes nominal", payload: { kind: "WARNING", data: "Brake pressure back in range" } }, + { kind: "warning", board: "PCU", name: "Motor temperature", payload: { kind: "UPPER_BOUND", data: { bound: 80, value: 84.31 } } }, + { kind: "warning", board: "HVBMS", name: "Cell voltage", payload: { kind: "LOWER_BOUND", data: { bound: 3.2, value: 3.07 } } }, + { kind: "warning", board: "LCU", name: "Levitation height", payload: { kind: "OUT_OF_BOUNDS", data: { bounds: [5, 25], value: 27.4 } } }, + { kind: "fault", board: "VCU", name: "SDC open", payload: { kind: "EQUALS", data: { value: 0 } } }, + { kind: "fault", board: "VCU", name: "Brakes status", payload: { kind: "NOT_EQUALS", data: { want: 1, value: 0 } } }, + { kind: "fault", board: "HVSCU-Cabinet", name: "Contactor current", payload: { kind: "TIME_ACCUMULATION", data: { value: 132.5, bound: 120, timelimit: 2.5 } } }, + { kind: "fault", board: "PCU", name: "Inverter error", payload: { kind: "ERROR_HANDLER", data: "Inverter reported fault code 0x2A" } }, + { kind: "warning", board: "PCU", name: "IMU drift", payload: { kind: "WARNING", data: "IMU position drifting from encoder estimate" } }, +]; + +const randomMessage = () => { + const base = MESSAGE_VARIANTS[Math.floor(Math.random() * MESSAGE_VARIANTS.length)]; + // Only the reading varies; bounds and expected values are fixed protection + // limits, so jittering them would produce messages the pod can't produce. + const data = base.payload?.data; + const payload = + typeof data === "object" && typeof data.value === "number" + ? { + ...base.payload, + data: { ...data, value: Number((data.value * (0.9 + Math.random() * 0.2)).toFixed(2)) }, + } + : base.payload; + + return { ...base, payload }; +}; + +// --------------------------------------------------------------------- orders + +const numeric = (id, name, min, max) => ({ + kind: "numeric", + id, + name, + type: "float32", + safeRange: [min, max], + warningRange: [min, max], +}); + +/** Mirrors the ids in src/constants/vcuStateMachine.ts and orderColors.ts. */ +const VCU_ORDERS = [ + { id: 0, name: "Fault", fields: {} }, + { id: 30, name: "Stop", fields: {} }, + { id: 40, name: "Maintenance", fields: {} }, + { id: 41, name: "Precharge", fields: {} }, + { id: 42, name: "Open Contactors", fields: {} }, + { id: 50, name: "Unbrake", fields: {} }, + { id: 51, name: "Brake", fields: {} }, + { id: 60, name: "Static Levitation", fields: {} }, + { id: 61, name: "Propulsion", fields: {} }, + { id: 62, name: "Dynamic Levitation", fields: {} }, + { + id: 100, + name: "Set Propulsion Current", + fields: { propulsion_current_reference: numeric("propulsion_current_reference", "Current reference (A)", 0, 150) }, + }, + { + id: 101, + name: "Set Levitation Height", + fields: { levitation_target_height: numeric("levitation_target_height", "Target height (mm)", 0, 30) }, + }, + { + id: 102, + name: "Set Propulsion And Levitation", + fields: { + propulsion_current_reference: numeric("propulsion_current_reference", "Current reference (A)", 0, 150), + levitation_target_height: numeric("levitation_target_height", "Target height (mm)", 0, 30), + }, + }, +]; + +const ordersById = new Map(VCU_ORDERS.map((o) => [o.id, o])); + +const ORDER_STRUCTURES = { boards: [{ name: "VCU", orders: VCU_ORDERS }] }; +const POD_DATA_STRUCTURE = { boards: [{ name: "VCU", packets: [{ id: STATE_PACKET_ID }] }] }; + +const c = { + dim: (s) => `\x1b[2m${s}\x1b[0m`, + green: (s) => `\x1b[32m${s}\x1b[0m`, + red: (s) => `\x1b[31m${s}\x1b[0m`, + yellow: (s) => `\x1b[33m${s}\x1b[0m`, + cyan: (s) => `\x1b[36m${s}\x1b[0m`, + bold: (s) => `\x1b[1m${s}\x1b[0m`, +}; + +let sentCount = 0; +const receivedOrders = []; + +/** Prints a received order and the catalog problems it has, if any. */ +const reportOrder = (payload) => { + const spec = ordersById.get(payload?.id); + const problems = []; + + if (typeof payload?.id !== "number") problems.push("missing numeric `id`"); + else if (!spec) problems.push(`id ${payload.id} is not in the catalog`); + + const fields = payload?.fields ?? {}; + if (spec) { + for (const key of Object.keys(spec.fields)) { + if (fields[key] === undefined) problems.push(`field \`${key}\` not sent`); + } + for (const key of Object.keys(fields)) { + if (!(key in spec.fields)) problems.push(`unexpected field \`${key}\``); + } + } + + const fieldList = Object.entries(fields) + .map(([k, v]) => `${k}=${JSON.stringify(v?.value)}`) + .join(", "); + + const mark = problems.length === 0 ? c.green("ORDER OK ") : c.red("ORDER FAIL"); + console.log( + `${c.dim(new Date().toLocaleTimeString())} ${mark} ${c.bold(`#${payload?.id}`)} ` + + `${spec ? spec.name : "unknown order"}${fieldList ? ` ${c.dim(`{ ${fieldList} }`)}` : ""}`, + ); + for (const problem of problems) console.log(` ${c.red("->")} ${problem}`); + + receivedOrders.push({ id: payload?.id, ok: problems.length === 0 }); + return spec; +}; + +// ------------------------------------------------------------- websocket frames + +const encodeTextFrame = (text) => { + const payload = Buffer.from(text); + const len = payload.length; + let header; + + if (len < 126) { + header = Buffer.from([0x81, len]); + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + + return Buffer.concat([header, payload]); +}; + +/** Pulls every complete frame out of `buf`; returns the frames and the leftover bytes. */ +const decodeFrames = (buf) => { + const frames = []; + let offset = 0; + + while (buf.length - offset >= 2) { + const opcode = buf[offset] & 0x0f; + const masked = (buf[offset + 1] & 0x80) !== 0; + let len = buf[offset + 1] & 0x7f; + let cursor = offset + 2; + + if (len === 126) { + if (buf.length - cursor < 2) break; + len = buf.readUInt16BE(cursor); + cursor += 2; + } else if (len === 127) { + if (buf.length - cursor < 8) break; + len = Number(buf.readBigUInt64BE(cursor)); + cursor += 8; + } + + let mask; + if (masked) { + if (buf.length - cursor < 4) break; + mask = buf.subarray(cursor, cursor + 4); + cursor += 4; + } + + if (buf.length - cursor < len) break; + + const payload = Buffer.from(buf.subarray(cursor, cursor + len)); + if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]; + + frames.push({ opcode, payload }); + offset = cursor + len; + } + + return { frames, rest: buf.subarray(offset) }; +}; + +// ------------------------------------------------------------------- http + ws + +const server = createServer((req, res) => { + const body = + req.url === "/backend/orderStructures" + ? ORDER_STRUCTURES + : req.url === "/backend/podDataStructure" + ? POD_DATA_STRUCTURE + : null; + + if (!body) { + res.writeHead(404, { "Access-Control-Allow-Origin": "*" }).end(); + return; + } + + console.log(c.dim(` GET ${req.url}`)); + res + .writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }) + .end(JSON.stringify(body)); +}); + +server.on("upgrade", (req, socket) => { + const key = req.headers["sec-websocket-key"]; + if (!key) { + socket.destroy(); + return; + } + + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${createHash("sha1").update(key + WS_GUID).digest("base64")}\r\n\r\n`, + ); + + console.log(c.green(" GUI connected")); + const send = (topic, payload) => socket.write(encodeTextFrame(JSON.stringify({ topic, payload }))); + + const sendMessage = (message) => { + const packet = { ...message, timestamp: timestamp() }; + send("message/update", packet); + sentCount++; + const label = { info: c.cyan, ok: c.green, warning: c.yellow, fault: c.red }[packet.kind] ?? c.dim; + console.log( + `${c.dim(new Date().toLocaleTimeString())} ${label(packet.kind.toUpperCase().padEnd(10))} ` + + `${c.bold(packet.board)} ${packet.name}`, + ); + }; + + send("connection/update", { VCU: { name: "VCU", isConnected: true } }); + + // Keep publishing the vehicle state so state-gated order buttons stay enabled. + const telemetry = setInterval(() => { + send("podData/update", { + [STATE_PACKET_ID]: { + id: STATE_PACKET_ID, + count: 0, + cycleTime: 0, + hexValue: "", + measurementUpdates: { state: VEHICLE_STATE }, + }, + }); + }, 250); + + // One of every variant up front, so each Messages branch gets exercised. + const timers = [telemetry]; + if (!NO_BURST) { + MESSAGE_VARIANTS.forEach((variant, i) => { + timers.push(setTimeout(() => sendMessage(variant), 300 + i * 150)); + }); + } + if (INTERVAL > 0) { + const after = NO_BURST ? 0 : 300 + MESSAGE_VARIANTS.length * 150; + timers.push( + setTimeout(() => { + const stream = setInterval(() => sendMessage(randomMessage()), INTERVAL); + timers.push(stream); + }, after), + ); + } + + let buf = Buffer.alloc(0); + + socket.on("data", (chunk) => { + buf = Buffer.concat([buf, chunk]); + const { frames, rest } = decodeFrames(buf); + buf = rest; + + for (const { opcode, payload } of frames) { + if (opcode === 0x8) { + socket.end(); + return; + } + if (opcode !== 0x1) continue; + + let message; + try { + message = JSON.parse(payload.toString()); + } catch { + console.log(c.yellow(` unparseable frame: ${payload.toString().slice(0, 120)}`)); + continue; + } + + if (message.topic === "connection/heartbeat") continue; + + if (message.topic === "order/send") { + const spec = reportOrder(message.payload); + // The real backend reports dispatched orders back as info messages. + sendMessage({ + kind: "info", + board: "VCU", + name: "Order sent", + payload: `${spec ? spec.name : `Order ${message.payload?.id}`} dispatched`, + }); + } else { + console.log(c.dim(` ${message.topic}`)); + } + } + }); + + const close = () => { + for (const t of timers) { + clearInterval(t); + clearTimeout(t); + } + console.log(c.yellow(" GUI disconnected")); + }; + socket.on("close", close); + socket.on("error", close); +}); + +server.listen(PORT, "127.0.0.1", () => { + console.log(c.bold(`Mock backend on ws://127.0.0.1:${PORT}/backend`)); + console.log( + c.dim( + `messages: ${NO_BURST ? "no burst" : `${MESSAGE_VARIANTS.length}-variant burst`}` + + `${INTERVAL > 0 ? ` then one every ${INTERVAL}ms` : ", stream off"} | state: ${VEHICLE_STATE}\n`, + ), + ); +}); + +process.on("SIGINT", () => { + const failed = receivedOrders.filter((o) => !o.ok).length; + console.log( + `\n${sentCount} message(s) sent, ${receivedOrders.length} order(s) received ` + + `(${receivedOrders.length - failed} ok, ${failed} failed).`, + ); + process.exit(failed > 0 ? 1 : 0); +}); diff --git a/frontend/competition-view/src/App.tsx b/frontend/competition-view/src/App.tsx index ff901e1e2..6299147bb 100644 --- a/frontend/competition-view/src/App.tsx +++ b/frontend/competition-view/src/App.tsx @@ -13,6 +13,7 @@ import usePodCatalog from "./hooks/usePodCatalog"; import useSendOrder from "./hooks/useSendOrder"; import AppLayout from "./layout/AppLayout"; import Batteries from "./pages/Batteries"; +import Messages from "./pages/Messages"; import Overview from "./pages/Overview"; import { useStore } from "./store/store"; import type { Connection } from "./types/connection"; @@ -68,6 +69,7 @@ const App = () => { } /> } /> + } /> diff --git a/frontend/competition-view/src/constants/pages.ts b/frontend/competition-view/src/constants/pages.ts index 840dc1a88..bcc269e15 100644 --- a/frontend/competition-view/src/constants/pages.ts +++ b/frontend/competition-view/src/constants/pages.ts @@ -1,8 +1,9 @@ -import { Battery, Layout } from "@workspace/ui/icons"; +import { Battery, Layout, MessageSquare } from "@workspace/ui/icons"; export const PAGES = { "/": { title: "Competition View", icon: Layout }, "/batteries": { title: "Batteries", icon: Battery }, + "/messages": { title: "Messages", icon: MessageSquare }, } as const; export const PAGES_ARRAY = Object.entries(PAGES).map( diff --git a/frontend/competition-view/src/pages/Messages/Messages.tsx b/frontend/competition-view/src/pages/Messages/Messages.tsx index cee32a58a..8df2d0c84 100644 --- a/frontend/competition-view/src/pages/Messages/Messages.tsx +++ b/frontend/competition-view/src/pages/Messages/Messages.tsx @@ -1,5 +1,5 @@ -import { Button, Separator } from "@workspace/ui/components"; -import { ChevronUp } from "@workspace/ui/icons"; +import { Button, Separator, Slider } from "@workspace/ui/components"; +import { ChevronDown } from "@workspace/ui/icons"; import { useEffect, useRef, useState } from "react"; import { useStore } from "../../store/store"; import type { MessageKind } from "../../types/message"; @@ -14,13 +14,23 @@ const KIND_LABEL: Record = { ok: "Ok", }; +const FONT_SIZE_KEY = "messages.fontSize"; +const MIN_FONT_SIZE = 12; +const MAX_FONT_SIZE = 24; +const DEFAULT_FONT_SIZE = 16; + +const readStoredFontSize = () => { + const stored = Number(localStorage.getItem(FONT_SIZE_KEY)); + return stored >= MIN_FONT_SIZE && stored <= MAX_FONT_SIZE ? stored : DEFAULT_FONT_SIZE; +}; + /** * Full message log with per-kind filtering and a clear button. * - * Messages are stored newest-first, so the top of the list is always the - * latest entry. A floating "Latest" button appears when the user scrolls - * down into older messages, and new entries auto-scroll to the top only - * while the user hasn't scrolled away. + * Terminal-style log: messages are stored oldest-first, so the latest entry + * is at the bottom and the view follows it. A floating "Latest" button + * appears once the user scrolls up into history, which also pauses the + * auto-scroll until they return to the bottom. */ const Messages = () => { const messages = useStore((s) => s.messages); @@ -30,6 +40,11 @@ const Messages = () => { new Set(ALL_KINDS), ); const [scrolledAway, setScrolledAway] = useState(false); + const [fontSize, setFontSize] = useState(readStoredFontSize); + + useEffect(() => { + localStorage.setItem(FONT_SIZE_KEY, String(fontSize)); + }, [fontSize]); const scrollRef = useRef(null); const prevLenRef = useRef(0); @@ -43,21 +58,24 @@ const Messages = () => { const filtered = messages.filter((m) => activeKinds.has(m.kind)); - // Auto-scroll to top (latest message) when a new entry arrives, - // but only if the user hasn't scrolled down into history. + // Jumps instantly: a smooth scroll still in flight when the next message + // lands would read as "scrolled away" and stall the follow. + const scrollToLatest = (behavior: ScrollBehavior = "auto") => { + const el = scrollRef.current; + el?.scrollTo({ top: el.scrollHeight, behavior }); + }; + + // Follow the bottom as entries arrive, unless the user scrolled up. useEffect(() => { if (filtered.length > prevLenRef.current && !scrolledAway) { - scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + scrollToLatest(); } prevLenRef.current = filtered.length; }, [filtered.length, scrolledAway]); const handleScroll = (e: React.UIEvent) => { - setScrolledAway(e.currentTarget.scrollTop > 80); - }; - - const scrollToLatest = () => { - scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + const el = e.currentTarget; + setScrolledAway(el.scrollHeight - el.scrollTop - el.clientHeight > 80); }; return ( @@ -79,7 +97,21 @@ const Messages = () => { ))} -
+
+
+ A + setFontSize(value)} + min={MIN_FONT_SIZE} + max={MAX_FONT_SIZE} + step={1} + className="w-24" + aria-label="Message font size" + /> + A +
+ {filtered.length} / {messages.length} @@ -106,7 +138,9 @@ const Messages = () => {

No messages

) : ( - filtered.map((msg) => ) + filtered.map((msg) => ( + + )) )} {/* Floating "scroll to latest" button */} @@ -114,10 +148,10 @@ const Messages = () => { )} diff --git a/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx b/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx index 0521e4088..a37f267d8 100644 --- a/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx +++ b/frontend/competition-view/src/pages/Messages/components/MessageItem.tsx @@ -12,19 +12,30 @@ const KIND_BADGE_CLASS: Record = { const KIND_ROW_CLASS: Record = { info: "", - warning: "bg-amber-50/40 dark:bg-amber-900/10", - fault: "bg-red-50/40 dark:bg-red-900/10", + warning: "bg-amber-100 dark:bg-amber-900/10", + fault: "bg-red-100 dark:bg-red-900/10", ok: "", }; interface MessageItemProps { message: Message; + /** Body text size. "base" (default) is used by the dedicated Messages page; the compact Overview panel passes "sm". Ignored when `fontSizePx` is set. */ + size?: "sm" | "base"; + /** Overrides `size` with an exact pixel size, driven by the Messages page's font-size slider. */ + fontSizePx?: number; } // Memoised: message objects are immutable, so once rendered a row never // changes — this keeps a new message from re-rendering the whole list. -const MessageItem = memo(({ message }: MessageItemProps) => { +/** Badge-to-body ratio that keeps the slider's 16px default at the original 10px badge. */ +const BADGE_SIZE_RATIO = 10 / 16; +const MIN_BADGE_SIZE_PX = 10; + +const MessageItem = memo(({ message, size = "base", fontSizePx }: MessageItemProps) => { const time = formatMessageTimestamp(message.timestamp); + const badgeSizePx = fontSizePx + ? Math.max(MIN_BADGE_SIZE_PX, Math.round(fontSizePx * BADGE_SIZE_RATIO)) + : undefined; return (
@@ -33,11 +44,15 @@ const MessageItem = memo(({ message }: MessageItemProps) => { {message.kind} - + {messageContent(message.payload)}
diff --git a/frontend/competition-view/src/pages/Overview/Overview.tsx b/frontend/competition-view/src/pages/Overview/Overview.tsx index 437d5ee45..d8238c430 100644 --- a/frontend/competition-view/src/pages/Overview/Overview.tsx +++ b/frontend/competition-view/src/pages/Overview/Overview.tsx @@ -1,5 +1,5 @@ import { Button } from "@workspace/ui/components"; -import { ChevronUp, MessageSquare } from "@workspace/ui/icons"; +import { ChevronDown, MessageSquare } from "@workspace/ui/icons"; import { BatteryFull, Gauge, @@ -280,9 +280,14 @@ const MessagesPanel = () => { const scrollRef = useRef(null); const prevLenRef = useRef(0); + const scrollToLatest = (behavior: ScrollBehavior = "auto") => { + const el = scrollRef.current; + el?.scrollTo({ top: el.scrollHeight, behavior }); + }; + useEffect(() => { if (messages.length > prevLenRef.current && !scrolledAway) { - scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + scrollToLatest(); } prevLenRef.current = messages.length; }, [messages.length, scrolledAway]); @@ -303,19 +308,22 @@ const MessagesPanel = () => {
setScrolledAway(e.currentTarget.scrollTop > 60)} + onScroll={(e) => { + const el = e.currentTarget; + setScrolledAway(el.scrollHeight - el.scrollTop - el.clientHeight > 60); + }} className="relative min-h-0 flex-1 overflow-y-auto" > {messages.length === 0 ? (

No messages

) : ( - messages.map((msg) => ) + messages.map((msg) => ) )} {scrolledAway && ( )}
diff --git a/frontend/competition-view/src/store/slices/messagesSlice.ts b/frontend/competition-view/src/store/slices/messagesSlice.ts index bc5e02e32..3c4f4e1be 100644 --- a/frontend/competition-view/src/store/slices/messagesSlice.ts +++ b/frontend/competition-view/src/store/slices/messagesSlice.ts @@ -18,9 +18,10 @@ export const createMessagesSlice: StateCreator< > = (set) => ({ messages: [], + // Terminal order: newest last, so the cap drops the oldest entries. addMessage: (message) => set((state) => ({ - messages: [message, ...state.messages].slice(0, MAX_MESSAGES), + messages: [...state.messages, message].slice(-MAX_MESSAGES), })), clearMessages: () => set({ messages: [] }), diff --git a/frontend/frontend-kit/ui/src/components/shadcn/index.ts b/frontend/frontend-kit/ui/src/components/shadcn/index.ts index f8790d0fe..c1d4dc9da 100644 --- a/frontend/frontend-kit/ui/src/components/shadcn/index.ts +++ b/frontend/frontend-kit/ui/src/components/shadcn/index.ts @@ -18,6 +18,7 @@ export * from "./separator"; export * from "./sheet"; export * from "./sidebar"; export * from "./skeleton"; +export * from "./slider"; export * from "./spinner"; export * from "./tabs"; export * from "./table"; diff --git a/frontend/frontend-kit/ui/src/components/shadcn/slider.tsx b/frontend/frontend-kit/ui/src/components/shadcn/slider.tsx new file mode 100644 index 000000000..d930c32eb --- /dev/null +++ b/frontend/frontend-kit/ui/src/components/shadcn/slider.tsx @@ -0,0 +1,59 @@ +"use client" + +import * as React from "react" +import { Slider as SliderPrimitive } from "radix-ui" + +import { cn } from "@workspace/ui/lib/utils" + +function Slider({ + className, + defaultValue, + value, + min = 0, + max = 100, + ...props +}: React.ComponentProps) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max] + ) + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ) +} + +export { Slider }