feat: add Data Cards feature with CRUD operations and UI integration
This commit is contained in:
@@ -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"}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
663
mobile/src/pages/datacards.tsx
Normal file
663
mobile/src/pages/datacards.tsx
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<string, string> | undefined {
|
||||
const result: Record<string, string> = {};
|
||||
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<string, string> | 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 (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onCancel} style={styles.backBtn} activeOpacity={0.7}>
|
||||
<Text style={styles.backBtnText}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>{isEdit ? "Edit Data Source" : "New Data Source"}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||
{/* Name */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Name *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="My Weather Feed"
|
||||
value={form.name}
|
||||
onChangeText={(v) => onChange("name", v)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* URL */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>JSON URL *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://api.example.com/data"
|
||||
value={form.url}
|
||||
onChangeText={(v) => onChange("url", v)}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Refresh Interval */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Refresh Interval (seconds)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="60"
|
||||
value={form.refresh_interval}
|
||||
onChangeText={(v) => onChange("refresh_interval", v)}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Section: Display */}
|
||||
<Text style={styles.sectionLabel}>Display Options</Text>
|
||||
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.field, styles.flex1]}>
|
||||
<Text style={styles.label}>Font Size</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="16"
|
||||
value={form.font_size}
|
||||
onChangeText={(v) => onChange("font_size", v)}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.field, styles.flex1]}>
|
||||
<Text style={styles.label}>Text Color (hex)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="#ffffff"
|
||||
value={form.text_color}
|
||||
onChangeText={(v) => onChange("text_color", v)}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Section: Advanced */}
|
||||
<Text style={styles.sectionLabel}>Advanced</Text>
|
||||
|
||||
{/* Take Path */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Value Path (optional)</Text>
|
||||
<Text style={styles.hint}>
|
||||
Extract a nested value from the JSON response.{"\n"}
|
||||
Example: <Text style={styles.code}>$out.data.temperature</Text> or{" "}
|
||||
<Text style={styles.code}>$out.items[0].value</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="$out.data.value"
|
||||
value={form.take}
|
||||
onChangeText={(v) => onChange("take", v)}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Additional Headers */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Additional Headers (optional)</Text>
|
||||
<Text style={styles.hint}>One header per line: Key: Value</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.multilineInput]}
|
||||
placeholder={"Authorization: Bearer token123\nX-API-Key: mykey"}
|
||||
value={form.headers}
|
||||
onChangeText={(v) => onChange("headers", v)}
|
||||
multiline
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Save Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.saveBtn, saving && styles.saveBtnDisabled]}
|
||||
onPress={onSave}
|
||||
activeOpacity={0.8}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.saveBtnText}>{isEdit ? "Save Changes" : "Add Data Source"}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ height: 40 }} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card List Item ───────────────────────────────────────────────────────────
|
||||
|
||||
interface CardItemProps {
|
||||
card: DataCard;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function CardItem({ card, onEdit, onDelete }: CardItemProps) {
|
||||
return (
|
||||
<View style={styles.cardItem}>
|
||||
<View style={styles.cardItemBody}>
|
||||
<Text style={styles.cardItemName}>{card.name}</Text>
|
||||
<Text style={styles.cardItemUrl} numberOfLines={1}>
|
||||
{card.config.url}
|
||||
</Text>
|
||||
<Text style={styles.cardItemMeta}>
|
||||
Refresh: {card.config.refresh_interval}s
|
||||
{card.config.take ? ` · Path: ${card.config.take}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardItemActions}>
|
||||
<TouchableOpacity style={styles.editBtn} onPress={onEdit} activeOpacity={0.7}>
|
||||
<Text style={styles.editBtnText}>Edit</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.deleteBtn} onPress={onDelete} activeOpacity={0.7}>
|
||||
<Text style={styles.deleteBtnText}>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
type CView = "list" | "form";
|
||||
|
||||
export function DataCardsPage() {
|
||||
const { navigate } = useRouter();
|
||||
const [CView, setCView] = useState<CView>("list");
|
||||
const [cards, setCards] = useState<DataCard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(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 (
|
||||
<FormCView
|
||||
form={form}
|
||||
onChange={handleFormChange}
|
||||
onSave={handleSave}
|
||||
onCancel={() => setCView("list")}
|
||||
saving={saving}
|
||||
isEdit={editingId !== null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── List CView
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => navigate("home")} style={styles.backBtn} activeOpacity={0.7}>
|
||||
<Text style={styles.backBtnText}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Data Cards</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.subtitle}>
|
||||
Live JSON data sources shown on the TV display.
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" style={{ marginTop: 32 }} />
|
||||
) : (
|
||||
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
|
||||
{cards.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>📭</Text>
|
||||
<Text style={styles.emptyTitle}>No data sources yet</Text>
|
||||
<Text style={styles.emptyDesc}>
|
||||
Add a custom JSON source and it will appear on the TV.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{cards.map((card) => (
|
||||
<CardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
onEdit={() => handleEdit(card)}
|
||||
onDelete={() => handleDelete(card)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<TouchableOpacity style={styles.addBtn} onPress={handleAdd} activeOpacity={0.8}>
|
||||
<Text style={styles.addBtnText}>+ Add Data Source</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ height: 24 }} />
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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",
|
||||
},
|
||||
});
|
||||
@@ -21,6 +21,12 @@ export function IndexPage() {
|
||||
<Text style={styles.cardTitle}>Image Popup</Text>
|
||||
<Text style={styles.cardDesc}>Take a photo and show it on the TV.</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.card} onPress={() => navigate("datacards")} activeOpacity={0.8}>
|
||||
<Text style={styles.cardIcon}>📊</Text>
|
||||
<Text style={styles.cardTitle}>Data Cards</Text>
|
||||
<Text style={styles.cardDesc}>Display live data from custom JSON sources on the TV.</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
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