283 lines
9.6 KiB
TypeScript
283 lines
9.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
interface TextState {
|
|
showing: boolean;
|
|
title: string;
|
|
}
|
|
|
|
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]);
|
|
}
|
|
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;
|
|
}
|
|
|
|
// ─── Data Card Widget ─────────────────────────────────────────────────────────
|
|
|
|
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));
|
|
});
|
|
};
|
|
|
|
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 fontSize = card.config.display_options?.font_size ?? 16;
|
|
const textColor = card.config.display_options?.text_color ?? "#ffffff";
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
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[]>([]);
|
|
|
|
useEffect(() => {
|
|
const handleFullscreenChange = () => {
|
|
if (!document.fullscreenElement) {
|
|
setScreenStatus("notfullscreen");
|
|
} else {
|
|
setScreenStatus("fullscreen");
|
|
}
|
|
};
|
|
|
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
|
|
return () => {
|
|
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (screenStatus === "fullscreen") {
|
|
const handlePullState = () => {
|
|
fetch(
|
|
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329/pull_full",
|
|
)
|
|
.then((response) => response.json())
|
|
.then((data) => {
|
|
const state = data.state ?? {};
|
|
setTextState(state.text ?? { showing: false, title: "" });
|
|
setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" });
|
|
setDataCards(state.data_cards ?? []);
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error pulling state:", error);
|
|
});
|
|
};
|
|
|
|
handlePullState();
|
|
const interval = setInterval(handlePullState, 5000);
|
|
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [screenStatus]);
|
|
|
|
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>
|
|
|
|
<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>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default App;
|