MAJOR UPDATE ALERT!!!!!

This commit is contained in:
2026-03-01 14:57:18 +01:00
parent bb46330af8
commit 6497eb9770
21 changed files with 2374 additions and 915 deletions

View File

@@ -1,149 +1,76 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { CardLayout, DataCard, ImagePopupState, SettingsState, TextState } from "./types";
import { DataCardWidget } from "./components/DataCardWidget";
import { TextPopup } from "./components/TextPopup";
import { ImagePopup } from "./components/ImagePopup";
import { NotFullscreen } from "./components/NotFullscreen";
interface TextState {
showing: boolean;
title: string;
}
const GRID_COLS = 4;
const GRID_ROWS = 4;
interface ImagePopupState {
showing: boolean;
image_url: string;
caption: string;
}
interface DisplayOptions {
font_size: number;
text_color: string;
}
interface DataCardConfig {
url: string;
refresh_interval: number;
display_options: DisplayOptions;
take?: string;
additional_headers?: Record<string, string>;
}
interface DataCard {
id: string;
type: "custom_json";
name: string;
config: DataCardConfig;
}
// ─── Path evaluator for "$out.foo.bar[0].baz" ────────────────────────────────
function evaluatePath(data: unknown, path: string): unknown {
if (!path || !path.startsWith("$out")) return data;
const expr = path.slice(4); // strip "$out"
const tokens: string[] = [];
const regex = /\.([a-zA-Z_][a-zA-Z0-9_]*)|\[(\d+)\]/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(expr)) !== null) {
tokens.push(match[1] ?? match[2]);
/** Deterministic hash of a string → non-negative integer */
function hashStr(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = Math.imul(31, h) + s.charCodeAt(i) | 0;
}
let current: unknown = data;
for (const token of tokens) {
if (current == null) return null;
const idx = parseInt(token, 10);
if (!isNaN(idx) && Array.isArray(current)) {
current = current[idx];
} else {
current = (current as Record<string, unknown>)[token];
}
}
return current;
return Math.abs(h);
}
// ─── Data Card Widget ─────────────────────────────────────────────────────────
/**
* Process cards in insertion order.
* If two cards share the same starting (grid_col, grid_row), the later one
* is relocated to a free starting position chosen deterministically via its id.
*/
function assignLayouts(cards: DataCard[]): Array<{ card: DataCard; resolvedLayout: CardLayout }> {
const occupied = new Set<string>();
function DataCardWidget({ card }: { card: DataCard }) {
const [value, setValue] = useState<string>("…");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = () => {
const headers: Record<string, string> = {
Accept: "application/json",
...(card.config.additional_headers ?? {}),
};
fetch(card.config.url, { headers })
.then((r) => r.json())
.then((data) => {
const result = card.config.take
? evaluatePath(data, card.config.take)
: data;
const display =
result === null || result === undefined
? "(null)"
: typeof result === "object"
? JSON.stringify(result, null, 2)
: String(result);
setValue(display);
setLastUpdated(new Date());
setError(null);
})
.catch((err) => {
setError(String(err));
});
return cards.map((card) => {
const layout: CardLayout = card.layout ?? {
grid_col: 1,
grid_row: 4,
col_span: 1,
row_span: 1,
};
const key = `${layout.grid_col},${layout.grid_row}`;
fetchData();
const ms = Math.max(5000, (card.config.refresh_interval ?? 60) * 1000);
const interval = setInterval(fetchData, ms);
return () => clearInterval(interval);
}, [card.id, card.config.url, card.config.refresh_interval, card.config.take]);
if (!occupied.has(key)) {
occupied.add(key);
return { card, resolvedLayout: layout };
}
const fontSize = card.config.display_options?.font_size ?? 16;
const textColor = card.config.display_options?.text_color ?? "#ffffff";
// Collect all free starting positions
const free: Array<[number, number]> = [];
for (let r = 1; r <= GRID_ROWS; r++) {
for (let c = 1; c <= GRID_COLS; c++) {
if (!occupied.has(`${c},${r}`)) free.push([c, r]);
}
}
return (
<div className="flex flex-col gap-1 bg-black/40 backdrop-blur-sm rounded-2xl border border-white/10 px-5 py-4 min-w-[160px] max-w-[320px]">
<span className="text-gray-400 text-xs font-semibold uppercase tracking-wider">
{card.name}
</span>
{error ? (
<span className="text-red-400 text-sm break-all"> {error}</span>
) : (
<span
className="font-mono break-all whitespace-pre-wrap leading-snug"
style={{ fontSize, color: textColor }}
>
{value}
</span>
)}
{lastUpdated && (
<span className="text-gray-600 text-xs mt-1">
{lastUpdated.toLocaleTimeString()}
</span>
)}
</div>
);
if (free.length === 0) {
// Every slot taken let it overlap at its original position
return { card, resolvedLayout: layout };
}
const [rc, rr] = free[hashStr(card.id) % free.length];
occupied.add(`${rc},${rr}`);
return { card, resolvedLayout: { ...layout, grid_col: rc, grid_row: rr } };
});
}
function App() {
const [screenStatus, setScreenStatus] = useState<
"notfullscreen" | "fullscreen"
>("notfullscreen");
const [screenStatus, setScreenStatus] = useState<"notfullscreen" | "fullscreen">("notfullscreen");
const [textState, setTextState] = useState<TextState>({ showing: false, title: "" });
const [imagePopup, setImagePopup] = useState<ImagePopupState>({ showing: false, image_url: "", caption: "" });
const [dataCards, setDataCards] = useState<DataCard[]>([]);
const [settings, setSettings] = useState<SettingsState>({ background_url: "" });
useEffect(() => {
const handleFullscreenChange = () => {
if (!document.fullscreenElement) {
setScreenStatus("notfullscreen");
} else {
setScreenStatus("fullscreen");
}
setScreenStatus(document.fullscreenElement ? "fullscreen" : "notfullscreen");
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
useEffect(() => {
@@ -158,6 +85,7 @@ function App() {
setTextState(state.text ?? { showing: false, title: "" });
setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" });
setDataCards(state.data_cards ?? []);
setSettings(state.settings ?? { background_url: "" });
})
.catch((error) => {
console.error("Error pulling state:", error);
@@ -166,116 +94,72 @@ function App() {
handlePullState();
const interval = setInterval(handlePullState, 5000);
return () => clearInterval(interval);
}
}, [screenStatus]);
// Stable: only recompute when the serialised card list changes
const resolvedCards = useMemo(
() => assignLayouts(dataCards),
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(dataCards)],
);
if (screenStatus === "notfullscreen") {
return <NotFullscreen />;
}
const isIdle = !textState.showing && !imagePopup.showing && dataCards.length === 0;
return (
<>
{screenStatus === "notfullscreen" ? (
<div className="flex flex-col items-center gap-8 px-8 text-center">
<div className="flex flex-col items-center gap-2">
<div className="w-16 h-16 rounded-2xl bg-gray-800 border border-gray-700 flex items-center justify-center mb-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-8 h-8 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 8V6a2 2 0 012-2h8a2 2 0 012 2v2M6 16v2a2 2 0 002 2h8a2 2 0 002-2v-2M3 12h18"
/>
</svg>
</div>
<h1 className="text-3xl font-semibold tracking-tight text-white">
TV View
</h1>
<p className="text-gray-500 text-sm max-w-xs">
Enter fullscreen mode to start displaying content.
</p>
</div>
<div className="w-screen h-screen relative">
{settings.background_url && (
<img
src={settings.background_url}
className="absolute inset-0 w-full h-full object-cover z-0"
alt=""
/>
)}
<TextPopup state={textState} />
<ImagePopup state={imagePopup} />
<button
onClick={() => document.documentElement.requestFullscreen()}
className="group flex items-center gap-2 bg-white text-gray-900 font-medium px-6 py-3 rounded-xl hover:bg-gray-200 active:scale-95 transition-all duration-150 cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 8V4m0 0h4M4 4l5 5m11-5h-4m4 0v4m0-4l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
Go Fullscreen
</button>
</div>
) : (
<div className="w-screen h-screen relative">
{/* Text popup modal */}
{textState.showing && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="bg-gray-900 border border-gray-700 rounded-3xl px-16 py-12 shadow-2xl max-w-3xl w-full mx-8">
<h1 className="text-6xl font-bold tracking-tight text-white text-center">
{textState.title}
</h1>
</div>
</div>
)}
{/* Image popup modal */}
{imagePopup.showing && imagePopup.image_url && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/75 backdrop-blur-sm p-12">
<div className="flex flex-col items-center gap-5 p-6 bg-gray-950/80 rounded-3xl border border-white/10 shadow-2xl max-w-[82vw]">
{imagePopup.caption && (
<h2 className="text-4xl font-bold text-white tracking-tight text-center">
{imagePopup.caption}
</h2>
)}
<img
src={imagePopup.image_url}
alt="TV display"
className="max-w-full max-h-[72vh] rounded-2xl object-contain shadow-xl"
style={{ display: "block" }}
/>
</div>
</div>
)}
{/* Background idle state */}
{!textState.showing && !imagePopup.showing && dataCards.length === 0 && (
<div className="flex items-center justify-center w-full h-full">
<p className="text-gray-600 text-lg">
Waiting for content
<span className="dot-1">.</span>
<span className="dot-2">.</span>
<span className="dot-3">.</span>
</p>
</div>
)}
{/* Data cards — persistent widgets at the bottom */}
{dataCards.length > 0 && (
<div className="absolute bottom-0 left-0 right-0 z-0 flex flex-row flex-wrap gap-4 p-6 items-end">
{dataCards.map((card) => (
<DataCardWidget key={card.id} card={card} />
))}
</div>
)}
{isIdle && (
<div className="flex items-center justify-center w-full h-full">
<p className="text-gray-600 text-lg">
Waiting for content
<span className="dot-1">.</span>
<span className="dot-2">.</span>
<span className="dot-3">.</span>
</p>
</div>
)}
</>
{resolvedCards.length > 0 && (
<div
className="absolute inset-0 z-0 p-4"
style={{
display: "grid",
gridTemplateColumns: `repeat(${GRID_COLS}, 1fr)`,
gridTemplateRows: `repeat(${GRID_ROWS}, 1fr)`,
gap: "12px",
}}
>
{resolvedCards.map(({ card, resolvedLayout }) => (
<div
key={card.id}
style={{
gridColumn: `${resolvedLayout.grid_col} / span ${resolvedLayout.col_span}`,
gridRow: `${resolvedLayout.grid_row} / span ${resolvedLayout.row_span}`,
minWidth: 0,
minHeight: 0,
}}
>
<DataCardWidget card={card} layout={resolvedLayout} />
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,301 @@
import { useEffect, useState } from "react";
import type {
CardLayout,
ClockCard,
CustomJsonCard,
DataCard,
ImageRotatorCard,
StaticTextCard,
} from "../types";
import { evaluatePath } from "../utils/evaluatePath";
// ─── Shared card shell ────────────────────────────────────────────────────────
function CardShell({ name, children, footer }: { name: string; children: React.ReactNode; footer?: React.ReactNode }) {
return (
<div className="flex flex-col gap-1 bg-black/40 backdrop-blur-sm rounded-2xl border border-white/10 px-5 py-4 w-full h-full overflow-hidden">
<span className="text-gray-400 text-xs font-semibold uppercase tracking-wider truncate shrink-0">
{name}
</span>
<div className="flex-1 overflow-hidden min-h-0">{children}</div>
{footer && <div className="shrink-0">{footer}</div>}
</div>
);
}
// ─── custom_json widget ───────────────────────────────────────────────────────
function CustomJsonWidget({ card, layout }: { card: CustomJsonCard; layout?: CardLayout }) {
const [value, setValue] = useState<string>("…");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState<Date>(() => new Date());
const [pulling, setPulling] = useState(false);
const [dots, setDots] = useState(0);
const [responseMs, setResponseMs] = useState<number | null>(null);
useEffect(() => {
const ticker = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(ticker);
}, []);
useEffect(() => {
if (!pulling) return;
const dotsTimer = setInterval(() => setDots((d) => (d + 1) % 4), 400);
return () => clearInterval(dotsTimer);
}, [pulling]);
useEffect(() => {
const fetchData = () => {
setPulling(true);
setDots(0);
const start = performance.now();
const headers: Record<string, string> = {
Accept: "application/json",
...(card.config.additional_headers ?? {}),
};
fetch(card.config.url, { headers })
.then((r) => r.json())
.then((data) => {
const result = card.config.take ? evaluatePath(data, card.config.take) : data;
const display =
result === null || result === undefined
? "(null)"
: typeof result === "object"
? JSON.stringify(result, null, 2)
: String(result);
setValue(display);
setLastUpdated(new Date());
setResponseMs(Math.round(performance.now() - start));
setError(null);
setPulling(false);
})
.catch((err) => {
setResponseMs(Math.round(performance.now() - start));
setError(String(err));
setPulling(false);
});
};
fetchData();
const ms = Math.max(5000, (card.config.refresh_interval ?? 60) * 1000);
const interval = setInterval(fetchData, ms);
return () => clearInterval(interval);
}, [card.id, card.config.url, card.config.refresh_interval, card.config.take]);
const refreshMs = Math.max(5000, (card.config.refresh_interval ?? 60) * 1000);
const secAgo = lastUpdated ? Math.floor((now.getTime() - lastUpdated.getTime()) / 1000) : null;
const nextIn = lastUpdated
? Math.max(0, Math.ceil((lastUpdated.getTime() + refreshMs - now.getTime()) / 1000))
: null;
const baseFontSize = card.config.display_options?.font_size ?? 16;
const textColor = card.config.display_options?.text_color ?? "#ffffff";
const colSpan = layout?.col_span ?? 1;
const rowSpan = layout?.row_span ?? 1;
const fontSize = Math.round(baseFontSize * Math.sqrt(colSpan * rowSpan));
const centered = colSpan > 1 || rowSpan > 1;
return (
<CardShell
name={card.name}
footer={
pulling ? (
<span className="text-gray-500 text-xs">pulling{"." .repeat(dots)}</span>
) : secAgo !== null && nextIn !== null ? (
<span className="text-gray-600 text-xs">
{secAgo}s ago · next in {nextIn}s{responseMs !== null ? ` · ${responseMs}ms` : ""}
</span>
) : undefined
}
>
{error ? (
<span className="text-red-400 text-sm break-all"> {error}</span>
) : (
<div className={`flex h-full overflow-hidden${centered ? " items-center justify-center" : ""}`}>
<span
className={`font-mono break-all whitespace-pre-wrap leading-snug${centered ? " text-center" : " block"}`}
style={{ fontSize, color: textColor }}
>
{value}
</span>
</div>
)}
</CardShell>
);
}
// ─── static_text widget ───────────────────────────────────────────────────────
function StaticTextWidget({ card, layout }: { card: StaticTextCard; layout?: CardLayout }) {
const baseFontSize = card.config.font_size ?? 16;
const textColor = card.config.text_color ?? "#ffffff";
const colSpan = layout?.col_span ?? 1;
const rowSpan = layout?.row_span ?? 1;
const fontSize = Math.round(baseFontSize * Math.sqrt(colSpan * rowSpan));
const centered = colSpan > 1 || rowSpan > 1;
return (
<CardShell name={card.name}>
<div className={`flex h-full overflow-hidden${centered ? " items-center justify-center" : ""}`}>
<span
className={`whitespace-pre-wrap break-words leading-snug${centered ? " text-center" : " block"}`}
style={{ fontSize, color: textColor }}
>
{card.config.text}
</span>
</div>
</CardShell>
);
}
// ─── clock widget ─────────────────────────────────────────────────────────────
function formatDuration(totalSeconds: number): string {
if (totalSeconds <= 0) return "00:00:00";
const d = Math.floor(totalSeconds / 86400);
const h = Math.floor((totalSeconds % 86400) / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
const timePart = [String(h).padStart(2, "0"), String(m).padStart(2, "0"), String(s).padStart(2, "0")].join(":");
return d > 0 ? `${d}d ${timePart}` : timePart;
}
function ClockWidget({ card, layout }: { card: ClockCard; layout?: CardLayout }) {
const [now, setNow] = useState<Date>(() => new Date());
useEffect(() => {
const ticker = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(ticker);
}, []);
const baseFontSize = card.config.font_size ?? 48;
const textColor = card.config.text_color ?? "#ffffff";
const colSpan = layout?.col_span ?? 1;
const rowSpan = layout?.row_span ?? 1;
const fontSize = Math.round(baseFontSize * Math.sqrt(colSpan * rowSpan));
const centered = colSpan > 1 || rowSpan > 1;
let display = "";
let subtitle = "";
if (card.config.mode === "time") {
const tz = card.config.timezone;
const showSec = card.config.show_seconds !== false;
try {
display = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
second: showSec ? "2-digit" : undefined,
hour12: false,
timeZone: tz || undefined,
}).format(now);
} catch {
display = now.toLocaleTimeString();
}
if (tz) subtitle = tz.replace("_", " ");
} else {
// timer / countdown
const target = card.config.target_iso ? new Date(card.config.target_iso) : null;
if (!target || isNaN(target.getTime())) {
display = "invalid target";
} else {
const diff = Math.max(0, Math.floor((target.getTime() - now.getTime()) / 1000));
if (diff === 0) {
display = "00:00";
subtitle = "Time's up!";
} else {
display = formatDuration(diff);
subtitle = `until ${new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short",
timeZone: card.config.timezone || undefined,
}).format(target)}`;
}
}
}
return (
<CardShell name={card.name}>
<div className={`flex flex-col justify-center h-full gap-1${centered ? " items-center" : ""}`}>
<span
className={`font-mono tabular-nums leading-none${centered ? " text-center" : ""}`}
style={{ fontSize, color: textColor }}
>
{display}
</span>
{subtitle && (
<span className={`text-gray-500 text-xs mt-1${centered ? " text-center" : ""}`}>{subtitle}</span>
)}
</div>
</CardShell>
);
}
// ─── image_rotator widget ─────────────────────────────────────────────────────
function ImageRotatorWidget({ card }: { card: ImageRotatorCard }) {
const images = card.config.images ?? [];
const [index, setIndex] = useState(0);
const [fade, setFade] = useState(true);
useEffect(() => {
if (images.length <= 1) return;
const ms = Math.max(2000, (card.config.interval ?? 10) * 1000);
const timer = setInterval(() => {
setFade(false);
setTimeout(() => {
setIndex((i) => (i + 1) % images.length);
setFade(true);
}, 400);
}, ms);
return () => clearInterval(timer);
}, [images.length, card.config.interval]);
if (images.length === 0) {
return (
<CardShell name={card.name}>
<span className="text-gray-500 text-sm">No images configured.</span>
</CardShell>
);
}
const fit = card.config.fit === "contain" ? "contain" : "cover";
return (
<div className="relative w-full h-full overflow-hidden rounded-2xl">
<img
key={images[index]}
src={images[index]}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: fit,
transition: "opacity 0.4s ease",
opacity: fade ? 1 : 0,
display: "block",
}}
/>
{/* Name overlay */}
<div className="absolute bottom-0 left-0 right-0 px-4 py-2 bg-gradient-to-t from-black/70 to-transparent">
<span className="text-white text-xs font-semibold truncate">{card.name}</span>
{images.length > 1 && (
<span className="text-white/50 text-xs ml-2">
{index + 1}/{images.length}
</span>
)}
</div>
</div>
);
}
// ─── Dispatcher ───────────────────────────────────────────────────────────────
export function DataCardWidget({ card, layout }: { card: DataCard; layout?: CardLayout }) {
if (card.type === "static_text") return <StaticTextWidget card={card} layout={layout} />;
if (card.type === "clock") return <ClockWidget card={card} layout={layout} />;
if (card.type === "image_rotator") return <ImageRotatorWidget card={card} />;
// default: custom_json
return <CustomJsonWidget card={card as CustomJsonCard} layout={layout} />;
}

View File

@@ -0,0 +1,23 @@
import { type ImagePopupState } from "../types";
export function ImagePopup({ state }: { state: ImagePopupState }) {
if (!state.showing || !state.image_url) return null;
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/75 backdrop-blur-sm p-12">
<div className="flex flex-col items-center gap-5 p-6 bg-gray-950/80 rounded-3xl border border-white/10 shadow-2xl max-w-[82vw]">
{state.caption && (
<h2 className="text-4xl font-bold text-white tracking-tight text-center">
{state.caption}
</h2>
)}
<img
src={state.image_url}
alt="TV display"
className="max-w-full max-h-[72vh] rounded-2xl object-contain shadow-xl"
style={{ display: "block" }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
export function NotFullscreen() {
return (
<div className="flex flex-col items-center gap-8 px-8 text-center">
<div className="flex flex-col items-center gap-2">
<div className="w-16 h-16 rounded-2xl bg-gray-800 border border-gray-700 flex items-center justify-center mb-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-8 h-8 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 8V6a2 2 0 012-2h8a2 2 0 012 2v2M6 16v2a2 2 0 002 2h8a2 2 0 002-2v-2M3 12h18"
/>
</svg>
</div>
<h1 className="text-3xl font-semibold tracking-tight text-white">
TV View
</h1>
<p className="text-gray-500 text-sm max-w-xs">
Enter fullscreen mode to start displaying content.
</p>
</div>
<button
onClick={() => document.documentElement.requestFullscreen()}
className="group flex items-center gap-2 bg-white text-gray-900 font-medium px-6 py-3 rounded-xl hover:bg-gray-200 active:scale-95 transition-all duration-150 cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 8V4m0 0h4M4 4l5 5m11-5h-4m4 0v4m0-4l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
Go Fullscreen
</button>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { type TextState } from "../types";
export function TextPopup({ state }: { state: TextState }) {
if (!state.showing) return null;
return (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="bg-gray-900 border border-gray-700 rounded-3xl px-16 py-12 shadow-2xl max-w-3xl mx-8">
<h1 className="text-6xl font-bold tracking-tight text-white text-center">
{state.title}
</h1>
</div>
</div>
);
}

106
tv/src/types.ts Normal file
View File

@@ -0,0 +1,106 @@
export interface TextState {
showing: boolean;
title: string;
}
export interface SettingsState {
/** Portrait background image URL (height > width). Empty string = no background. */
background_url: string;
}
export interface ImagePopupState {
showing: boolean;
image_url: string;
caption: string;
}
/** Grid layout: 1-based col/row, 4-column × 4-row grid */
export interface CardLayout {
grid_col: number; // 14
grid_row: number; // 14
col_span: number; // 14
row_span: number; // 14
}
// ─── custom_json ───────────────────────────────────────────────────────────────
export interface DisplayOptions {
font_size: number;
text_color: string;
}
export interface DataCardConfig {
url: string;
refresh_interval: number;
display_options: DisplayOptions;
take?: string;
additional_headers?: Record<string, string>;
}
export interface CustomJsonCard {
id: string;
type: "custom_json";
name: string;
config: DataCardConfig;
layout?: CardLayout;
}
// ─── static_text ──────────────────────────────────────────────────────────────
export interface StaticTextConfig {
text: string;
font_size: number;
text_color: string;
}
export interface StaticTextCard {
id: string;
type: "static_text";
name: string;
config: StaticTextConfig;
layout?: CardLayout;
}
// ─── clock ────────────────────────────────────────────────────────────────────
export interface ClockConfig {
/** "time" = live clock, "timer" = countdown to target_iso */
mode: "time" | "timer";
/** IANA timezone string, e.g. "Europe/Berlin" */
timezone?: string;
/** ISO-8601 target datetime for mode="timer", e.g. "2026-12-31T23:59:59" */
target_iso?: string;
font_size: number;
text_color: string;
show_seconds?: boolean;
}
export interface ClockCard {
id: string;
type: "clock";
name: string;
config: ClockConfig;
layout?: CardLayout;
}
// ─── image_rotator ────────────────────────────────────────────────────────────
export interface ImageRotatorConfig {
/** List of public image URLs to cycle through */
images: string[];
/** Seconds between transitions */
interval: number;
fit: "cover" | "contain";
}
export interface ImageRotatorCard {
id: string;
type: "image_rotator";
name: string;
config: ImageRotatorConfig;
layout?: CardLayout;
}
// ─── Union ────────────────────────────────────────────────────────────────────
export type DataCard = CustomJsonCard | StaticTextCard | ClockCard | ImageRotatorCard;

View File

@@ -0,0 +1,21 @@
export function evaluatePath(data: unknown, path: string): unknown {
if (!path || !path.startsWith("$out")) return data;
const expr = path.slice(4); // strip "$out"
const tokens: string[] = [];
const regex = /\.([a-zA-Z_][a-zA-Z0-9_]*)|\[(\d+)\]/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(expr)) !== null) {
tokens.push(match[1] ?? match[2]);
}
let current: unknown = data;
for (const token of tokens) {
if (current == null) return null;
const idx = parseInt(token, 10);
if (!isNaN(idx) && Array.isArray(current)) {
current = current[idx];
} else {
current = (current as Record<string, unknown>)[token];
}
}
return current;
}