feat: add Data Cards feature with CRUD operations and UI integration
This commit is contained in:
124
tv/src/App.tsx
124
tv/src/App.tsx
@@ -11,12 +11,124 @@ interface ImagePopupState {
|
||||
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 = () => {
|
||||
@@ -45,6 +157,7 @@ function App() {
|
||||
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);
|
||||
@@ -141,7 +254,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{/* Background idle state */}
|
||||
{!textState.showing && !imagePopup.showing && (
|
||||
{!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
|
||||
@@ -151,6 +264,15 @@ function App() {
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user