feat: add Data Cards feature with CRUD operations and UI integration

This commit is contained in:
2026-03-01 13:28:40 +01:00
parent b4528920da
commit f4ce115a95
6 changed files with 822 additions and 3 deletions

View File

@@ -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>
)}
</>