feat: add Data Cards feature with CRUD operations and UI integration
This commit is contained in:
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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user