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

@@ -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"}

View File

@@ -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 };

View 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",
},
});

View File

@@ -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>
);

View File

@@ -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;

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