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

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