feat: add Data Cards feature with CRUD operations and UI integration
This commit is contained in:
@@ -13,7 +13,8 @@ DEFAULT_STATE = {
|
|||||||
"showing": False,
|
"showing": False,
|
||||||
"image_url": "",
|
"image_url": "",
|
||||||
"caption": ""
|
"caption": ""
|
||||||
}
|
},
|
||||||
|
"data_cards": []
|
||||||
}
|
}
|
||||||
|
|
||||||
MINIO_CLIENT = Minio(
|
MINIO_CLIENT = Minio(
|
||||||
@@ -73,6 +74,29 @@ def main(args):
|
|||||||
current["image_popup"]["showing"] = False
|
current["image_popup"]["showing"] = False
|
||||||
_write_state(current)
|
_write_state(current)
|
||||||
return {"status": "success"}
|
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:
|
else:
|
||||||
return {"status": "error", "message": "Unknown push route"}
|
return {"status": "error", "message": "Unknown push route"}
|
||||||
elif route.startswith("pull"):
|
elif route.startswith("pull"):
|
||||||
@@ -83,5 +107,7 @@ def main(args):
|
|||||||
return {"status": "success", "image_popup": current.get("image_popup", {})}
|
return {"status": "success", "image_popup": current.get("image_popup", {})}
|
||||||
elif route == "pull_full":
|
elif route == "pull_full":
|
||||||
return {"status": "success", "state": current}
|
return {"status": "success", "state": current}
|
||||||
|
elif route == "pull_data_cards":
|
||||||
|
return {"status": "success", "data_cards": current.get("data_cards", [])}
|
||||||
else:
|
else:
|
||||||
return {"status": "error", "message": "Unknown pull route"}
|
return {"status": "error", "message": "Unknown pull route"}
|
||||||
@@ -2,6 +2,7 @@ import { StatusBar } from "expo-status-bar";
|
|||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { BottomNav } from "./src/components/BottomNav";
|
import { BottomNav } from "./src/components/BottomNav";
|
||||||
import { NotFoundPage } from "./src/pages/NotFound";
|
import { NotFoundPage } from "./src/pages/NotFound";
|
||||||
|
import { DataCardsPage } from "./src/pages/datacards";
|
||||||
import { ImagePage } from "./src/pages/image";
|
import { ImagePage } from "./src/pages/image";
|
||||||
import { IndexPage } from "./src/pages/index";
|
import { IndexPage } from "./src/pages/index";
|
||||||
import { TextPage } from "./src/pages/text";
|
import { TextPage } from "./src/pages/text";
|
||||||
@@ -16,6 +17,7 @@ const TABS: Tab[] = [
|
|||||||
{ label: "Home", route: "home", page: IndexPage },
|
{ label: "Home", route: "home", page: IndexPage },
|
||||||
{ label: "Text", route: "text", page: TextPage, hideInNav: true },
|
{ label: "Text", route: "text", page: TextPage, hideInNav: true },
|
||||||
{ label: "Image", route: "image", page: ImagePage, hideInNav: true },
|
{ label: "Image", route: "image", page: ImagePage, hideInNav: true },
|
||||||
|
{ label: "Data Cards", route: "datacards", page: DataCardsPage, hideInNav: true },
|
||||||
];
|
];
|
||||||
export { TABS, type Tab };
|
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.cardTitle}>Image Popup</Text>
|
||||||
<Text style={styles.cardDesc}>Take a photo and show it on the TV.</Text>
|
<Text style={styles.cardDesc}>Take a photo and show it on the TV.</Text>
|
||||||
</TouchableOpacity>
|
</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>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState } from "react";
|
import React, { createContext, useContext, useState } from "react";
|
||||||
|
|
||||||
export type Route = "home" | "text" | "image";
|
export type Route = "home" | "text" | "image" | "datacards";
|
||||||
|
|
||||||
interface RouterContextValue {
|
interface RouterContextValue {
|
||||||
route: Route;
|
route: Route;
|
||||||
|
|||||||
124
tv/src/App.tsx
124
tv/src/App.tsx
@@ -11,12 +11,124 @@ interface ImagePopupState {
|
|||||||
caption: 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() {
|
function App() {
|
||||||
const [screenStatus, setScreenStatus] = useState<
|
const [screenStatus, setScreenStatus] = useState<
|
||||||
"notfullscreen" | "fullscreen"
|
"notfullscreen" | "fullscreen"
|
||||||
>("notfullscreen");
|
>("notfullscreen");
|
||||||
const [textState, setTextState] = useState<TextState>({ showing: false, title: "" });
|
const [textState, setTextState] = useState<TextState>({ showing: false, title: "" });
|
||||||
const [imagePopup, setImagePopup] = useState<ImagePopupState>({ showing: false, image_url: "", caption: "" });
|
const [imagePopup, setImagePopup] = useState<ImagePopupState>({ showing: false, image_url: "", caption: "" });
|
||||||
|
const [dataCards, setDataCards] = useState<DataCard[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFullscreenChange = () => {
|
const handleFullscreenChange = () => {
|
||||||
@@ -45,6 +157,7 @@ function App() {
|
|||||||
const state = data.state ?? {};
|
const state = data.state ?? {};
|
||||||
setTextState(state.text ?? { showing: false, title: "" });
|
setTextState(state.text ?? { showing: false, title: "" });
|
||||||
setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" });
|
setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" });
|
||||||
|
setDataCards(state.data_cards ?? []);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error pulling state:", error);
|
console.error("Error pulling state:", error);
|
||||||
@@ -141,7 +254,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Background idle state */}
|
{/* 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">
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
<p className="text-gray-600 text-lg">
|
<p className="text-gray-600 text-lg">
|
||||||
Waiting for content
|
Waiting for content
|
||||||
@@ -151,6 +264,15 @@ function App() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user