diff --git a/functions/control/main.py b/functions/control/main.py index 52cbea2..1dd8a09 100644 --- a/functions/control/main.py +++ b/functions/control/main.py @@ -13,7 +13,8 @@ DEFAULT_STATE = { "showing": False, "image_url": "", "caption": "" - } + }, + "data_cards": [] } MINIO_CLIENT = Minio( @@ -73,6 +74,29 @@ def main(args): current["image_popup"]["showing"] = False _write_state(current) return {"status": "success"} + elif route == "push_data_card": + # Upsert a data card by id + card = body.get("card") + if not card or not card.get("id"): + return {"status": "error", "message": "Missing card or card id"} + current = _read_state() + cards = current.get("data_cards", []) + existing = next((i for i, c in enumerate(cards) if c["id"] == card["id"]), None) + if existing is not None: + cards[existing] = card + else: + cards.append(card) + current["data_cards"] = cards + _write_state(current) + return {"status": "success"} + elif route == "push_delete_data_card": + card_id = body.get("id") + if not card_id: + return {"status": "error", "message": "Missing card id"} + current = _read_state() + current["data_cards"] = [c for c in current.get("data_cards", []) if c["id"] != card_id] + _write_state(current) + return {"status": "success"} else: return {"status": "error", "message": "Unknown push route"} elif route.startswith("pull"): @@ -83,5 +107,7 @@ def main(args): return {"status": "success", "image_popup": current.get("image_popup", {})} elif route == "pull_full": return {"status": "success", "state": current} + elif route == "pull_data_cards": + return {"status": "success", "data_cards": current.get("data_cards", [])} else: return {"status": "error", "message": "Unknown pull route"} \ No newline at end of file diff --git a/mobile/App.tsx b/mobile/App.tsx index 500fbfb..0f7a1f9 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -2,6 +2,7 @@ import { StatusBar } from "expo-status-bar"; import { StyleSheet, View } from "react-native"; import { BottomNav } from "./src/components/BottomNav"; import { NotFoundPage } from "./src/pages/NotFound"; +import { DataCardsPage } from "./src/pages/datacards"; import { ImagePage } from "./src/pages/image"; import { IndexPage } from "./src/pages/index"; import { TextPage } from "./src/pages/text"; @@ -16,6 +17,7 @@ const TABS: Tab[] = [ { label: "Home", route: "home", page: IndexPage }, { label: "Text", route: "text", page: TextPage, hideInNav: true }, { label: "Image", route: "image", page: ImagePage, hideInNav: true }, + { label: "Data Cards", route: "datacards", page: DataCardsPage, hideInNav: true }, ]; export { TABS, type Tab }; diff --git a/mobile/src/pages/datacards.tsx b/mobile/src/pages/datacards.tsx new file mode 100644 index 0000000..1d2dd94 --- /dev/null +++ b/mobile/src/pages/datacards.tsx @@ -0,0 +1,663 @@ +import { useEffect, useState } from "react"; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { useRouter } from "../router"; + +const BASE_URL = + "https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface DisplayOptions { + font_size: number; + text_color: string; +} + +interface DataCardConfig { + url: string; + refresh_interval: number; + display_options: DisplayOptions; + take?: string; + additional_headers?: Record; +} + +export interface DataCard { + id: string; + type: "custom_json"; + name: string; + config: DataCardConfig; +} + +// ─── Flat form state (easier to wire to inputs) ─────────────────────────────── + +interface FormState { + name: string; + url: string; + refresh_interval: string; // kept as string for TextInput + font_size: string; + text_color: string; + take: string; + headers: string; // "Key: Value\nKey2: Value2" +} + +const EMPTY_FORM: FormState = { + name: "", + url: "", + refresh_interval: "60", + font_size: "16", + text_color: "#ffffff", + take: "", + headers: "", +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function parseHeaders(text: string): Record | undefined { + const result: Record = {}; + let hasAny = false; + for (const line of text.split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + if (key) { + result[key] = value; + hasAny = true; + } + } + return hasAny ? result : undefined; +} + +function headersToText(headers: Record | undefined): string { + if (!headers) return ""; + return Object.entries(headers) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); +} + +function cardToForm(card: DataCard): FormState { + return { + name: card.name, + url: card.config.url, + refresh_interval: String(card.config.refresh_interval), + font_size: String(card.config.display_options?.font_size ?? 16), + text_color: card.config.display_options?.text_color ?? "#ffffff", + take: card.config.take ?? "", + headers: headersToText(card.config.additional_headers), + }; +} + +function formToCard(form: FormState, id: string): DataCard { + return { + id, + type: "custom_json", + name: form.name.trim(), + config: { + url: form.url.trim(), + refresh_interval: Math.max(5, parseInt(form.refresh_interval, 10) || 60), + display_options: { + font_size: Math.max(8, parseInt(form.font_size, 10) || 16), + text_color: form.text_color.trim() || "#ffffff", + }, + take: form.take.trim() || undefined, + additional_headers: parseHeaders(form.headers), + }, + }; +} + +// ─── Form CView ──────────────────────────────────────────────────────────────── + +interface FormCViewProps { + form: FormState; + onChange: (key: keyof FormState, value: string) => void; + onSave: () => void; + onCancel: () => void; + saving: boolean; + isEdit: boolean; +} + +function FormCView({ form, onChange, onSave, onCancel, saving, isEdit }: FormCViewProps) { + return ( + + {/* Header */} + + + ← Back + + {isEdit ? "Edit Data Source" : "New Data Source"} + + + + {/* Name */} + + Name * + onChange("name", v)} + /> + + + {/* URL */} + + JSON URL * + onChange("url", v)} + autoCapitalize="none" + keyboardType="url" + /> + + + {/* Refresh Interval */} + + Refresh Interval (seconds) + onChange("refresh_interval", v)} + keyboardType="numeric" + /> + + + {/* Section: Display */} + Display Options + + + + Font Size + onChange("font_size", v)} + keyboardType="numeric" + /> + + + Text Color (hex) + onChange("text_color", v)} + autoCapitalize="none" + /> + + + + {/* Section: Advanced */} + Advanced + + {/* Take Path */} + + Value Path (optional) + + Extract a nested value from the JSON response.{"\n"} + Example: $out.data.temperature or{" "} + $out.items[0].value + + onChange("take", v)} + autoCapitalize="none" + /> + + + {/* Additional Headers */} + + Additional Headers (optional) + One header per line: Key: Value + onChange("headers", v)} + multiline + autoCapitalize="none" + /> + + + {/* Save Button */} + + {saving ? ( + + ) : ( + {isEdit ? "Save Changes" : "Add Data Source"} + )} + + + + + + ); +} + +// ─── Card List Item ─────────────────────────────────────────────────────────── + +interface CardItemProps { + card: DataCard; + onEdit: () => void; + onDelete: () => void; +} + +function CardItem({ card, onEdit, onDelete }: CardItemProps) { + return ( + + + {card.name} + + {card.config.url} + + + Refresh: {card.config.refresh_interval}s + {card.config.take ? ` · Path: ${card.config.take}` : ""} + + + + + Edit + + + Delete + + + + ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +type CView = "list" | "form"; + +export function DataCardsPage() { + const { navigate } = useRouter(); + const [CView, setCView] = useState("list"); + const [cards, setCards] = useState([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + + useEffect(() => { + loadCards(); + }, []); + + const loadCards = () => { + setLoading(true); + fetch(`${BASE_URL}/pull_data_cards`) + .then((r) => r.json()) + .then((data) => { + setCards(data.data_cards ?? []); + }) + .catch((err) => { + console.error("Error loading data cards:", err); + Alert.alert("Error", "Failed to load data cards."); + }) + .finally(() => setLoading(false)); + }; + + const handleAdd = () => { + setEditingId(null); + setForm(EMPTY_FORM); + setCView("form"); + }; + + const handleEdit = (card: DataCard) => { + setEditingId(card.id); + setForm(cardToForm(card)); + setCView("form"); + }; + + const handleDelete = (card: DataCard) => { + Alert.alert( + "Delete Data Source", + `Remove "${card.name}"? This cannot be undone.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + fetch(`${BASE_URL}/push_delete_data_card`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: card.id }), + }) + .then((r) => r.json()) + .then((data) => { + if (data.status !== "success") throw new Error(data.message); + setCards((prev) => prev.filter((c) => c.id !== card.id)); + }) + .catch((err) => { + console.error("Error deleting card:", err); + Alert.alert("Error", "Failed to delete data source."); + }); + }, + }, + ], + ); + }; + + const handleFormChange = (key: keyof FormState, value: string) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleSave = () => { + if (!form.name.trim()) { + Alert.alert("Validation", "Please enter a name for this data source."); + return; + } + if (!form.url.trim()) { + Alert.alert("Validation", "Please enter a URL."); + return; + } + + const id = editingId ?? Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + const card = formToCard(form, id); + + setSaving(true); + fetch(`${BASE_URL}/push_data_card`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ card }), + }) + .then((r) => r.json()) + .then((data) => { + if (data.status !== "success") throw new Error(data.message); + if (editingId) { + setCards((prev) => prev.map((c) => (c.id === editingId ? card : c))); + } else { + setCards((prev) => [...prev, card]); + } + setCView("list"); + }) + .catch((err) => { + console.error("Error saving card:", err); + Alert.alert("Error", "Failed to save data source."); + }) + .finally(() => setSaving(false)); + }; + + // ── Form CView + if (CView === "form") { + return ( + setCView("list")} + saving={saving} + isEdit={editingId !== null} + /> + ); + } + + // ── List CView + return ( + + {/* Header */} + + navigate("home")} style={styles.backBtn} activeOpacity={0.7}> + ← Back + + Data Cards + + + + Live JSON data sources shown on the TV display. + + + {loading ? ( + + ) : ( + + {cards.length === 0 && ( + + 📭 + No data sources yet + + Add a custom JSON source and it will appear on the TV. + + + )} + + {cards.map((card) => ( + handleEdit(card)} + onDelete={() => handleDelete(card)} + /> + ))} + + + + Add Data Source + + + + + )} + + ); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#f9f9f9", + }, + header: { + paddingTop: 16, + paddingHorizontal: 24, + paddingBottom: 4, + gap: 4, + }, + backBtn: { + alignSelf: "flex-start", + paddingVertical: 4, + }, + backBtnText: { + fontSize: 14, + color: "#007AFF", + fontWeight: "500", + }, + title: { + fontSize: 26, + fontWeight: "700", + color: "#111", + marginTop: 4, + }, + subtitle: { + fontSize: 14, + color: "#888", + paddingHorizontal: 24, + marginBottom: 12, + }, + scrollArea: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 24, + gap: 12, + paddingTop: 4, + }, + // ── Empty state + emptyState: { + alignItems: "center", + paddingVertical: 48, + gap: 8, + }, + emptyIcon: { + fontSize: 40, + }, + emptyTitle: { + fontSize: 17, + fontWeight: "600", + color: "#444", + }, + emptyDesc: { + fontSize: 14, + color: "#888", + textAlign: "center", + maxWidth: 260, + }, + // ── Card item + cardItem: { + backgroundColor: "#fff", + borderRadius: 14, + borderWidth: 1, + borderColor: "#e8e8e8", + padding: 16, + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 3, + elevation: 1, + gap: 10, + }, + cardItemBody: { + gap: 3, + }, + cardItemName: { + fontSize: 16, + fontWeight: "600", + color: "#111", + }, + cardItemUrl: { + fontSize: 12, + color: "#888", + fontFamily: "monospace", + }, + cardItemMeta: { + fontSize: 12, + color: "#aaa", + }, + cardItemActions: { + flexDirection: "row", + gap: 8, + }, + editBtn: { + flex: 1, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: "#f0f0f0", + alignItems: "center", + }, + editBtnText: { + fontSize: 14, + fontWeight: "500", + color: "#333", + }, + deleteBtn: { + flex: 1, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: "#fff0f0", + alignItems: "center", + }, + deleteBtnText: { + fontSize: 14, + fontWeight: "500", + color: "#d00", + }, + // ── Add button + addBtn: { + backgroundColor: "#111", + borderRadius: 14, + paddingVertical: 14, + alignItems: "center", + marginTop: 4, + }, + addBtnText: { + fontSize: 16, + fontWeight: "600", + color: "#fff", + }, + // ── Form + sectionLabel: { + fontSize: 12, + fontWeight: "600", + color: "#888", + textTransform: "uppercase", + letterSpacing: 0.8, + marginTop: 8, + marginBottom: -4, + }, + field: { + gap: 6, + }, + label: { + fontSize: 14, + fontWeight: "500", + color: "#333", + }, + hint: { + fontSize: 12, + color: "#999", + lineHeight: 17, + }, + code: { + fontFamily: "monospace", + fontSize: 12, + color: "#555", + }, + input: { + backgroundColor: "#fff", + borderRadius: 10, + borderWidth: 1, + borderColor: "#ddd", + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 15, + color: "#111", + }, + multilineInput: { + minHeight: 80, + textAlignVertical: "top", + paddingTop: 10, + }, + row: { + flexDirection: "row", + gap: 12, + }, + flex1: { + flex: 1, + }, + saveBtn: { + backgroundColor: "#111", + borderRadius: 14, + paddingVertical: 14, + alignItems: "center", + marginTop: 8, + }, + saveBtnDisabled: { + opacity: 0.6, + }, + saveBtnText: { + fontSize: 16, + fontWeight: "600", + color: "#fff", + }, +}); diff --git a/mobile/src/pages/index.tsx b/mobile/src/pages/index.tsx index acf4b79..0ea0481 100644 --- a/mobile/src/pages/index.tsx +++ b/mobile/src/pages/index.tsx @@ -21,6 +21,12 @@ export function IndexPage() { Image Popup Take a photo and show it on the TV. + + navigate("datacards")} activeOpacity={0.8}> + 📊 + Data Cards + Display live data from custom JSON sources on the TV. + ); diff --git a/mobile/src/router.tsx b/mobile/src/router.tsx index 5b6af77..e5815fa 100644 --- a/mobile/src/router.tsx +++ b/mobile/src/router.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState } from "react"; -export type Route = "home" | "text" | "image"; +export type Route = "home" | "text" | "image" | "datacards"; interface RouterContextValue { route: Route; diff --git a/tv/src/App.tsx b/tv/src/App.tsx index 0e6aa16..8cc77c8 100644 --- a/tv/src/App.tsx +++ b/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; +} + +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)[token]; + } + } + return current; +} + +// ─── Data Card Widget ───────────────────────────────────────────────────────── + +function DataCardWidget({ card }: { card: DataCard }) { + const [value, setValue] = useState("…"); + const [lastUpdated, setLastUpdated] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = () => { + const headers: Record = { + 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 ( +
+ + {card.name} + + {error ? ( + ⚠ {error} + ) : ( + + {value} + + )} + {lastUpdated && ( + + {lastUpdated.toLocaleTimeString()} + + )} +
+ ); +} + function App() { const [screenStatus, setScreenStatus] = useState< "notfullscreen" | "fullscreen" >("notfullscreen"); const [textState, setTextState] = useState({ showing: false, title: "" }); const [imagePopup, setImagePopup] = useState({ showing: false, image_url: "", caption: "" }); + const [dataCards, setDataCards] = useState([]); 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 && (

Waiting for content @@ -151,6 +264,15 @@ function App() {

)} + + {/* Data cards — persistent widgets at the bottom */} + {dataCards.length > 0 && ( +
+ {dataCards.map((card) => ( + + ))} +
+ )} )}