import DateTimePicker, { DateTimePickerEvent } from "@react-native-community/datetimepicker"; import * as ImagePicker from "expo-image-picker"; import { useEffect, useState } from "react"; import { ActivityIndicator, Alert, Modal, Platform, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View, } from "react-native"; import { useRouter } from "../router"; import { colors } from "../styles"; const BASE_URL = "https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329"; // ─── Types ──────────────────────────────────────────────────────────────────── export type CardType = "custom_json" | "static_text" | "clock" | "image_rotator"; /** 4-column × 4-row grid, 1-based */ export interface CardLayout { grid_col: number; grid_row: number; col_span: number; row_span: number; } // custom_json interface DataCardConfig { url: string; refresh_interval: number; display_options: { font_size: number; text_color: string }; take?: string; additional_headers?: Record; } interface CustomJsonCard { id: string; type: "custom_json"; name: string; config: DataCardConfig; layout?: CardLayout; } // static_text interface StaticTextCard { id: string; type: "static_text"; name: string; config: { text: string; font_size: number; text_color: string }; layout?: CardLayout; } // clock interface ClockCard { id: string; type: "clock"; name: string; config: { mode: "time" | "timer"; timezone?: string; target_iso?: string; font_size: number; text_color: string; show_seconds?: boolean; }; layout?: CardLayout; } // image_rotator interface ImageRotatorCard { id: string; type: "image_rotator"; name: string; config: { images: string[]; interval: number; fit: "cover" | "contain" }; layout?: CardLayout; } export type DataCard = CustomJsonCard | StaticTextCard | ClockCard | ImageRotatorCard; // ─── Flat form state ────────────────────────────────────────────────────────── interface FormState { type: CardType; name: string; // layout grid_col: number; grid_row: number; col_span: number; row_span: number; // display (custom_json / static_text / clock) font_size: string; text_color: string; // custom_json url: string; refresh_interval: string; take: string; headers: string; // static_text static_text: string; // clock clock_mode: "time" | "timer"; clock_timezone: string; clock_target_iso: string; clock_show_seconds: boolean; // image_rotator image_urls: string[]; image_interval: string; image_fit: "cover" | "contain"; } const EMPTY_FORM: FormState = { type: "custom_json", name: "", grid_col: 1, grid_row: 1, col_span: 1, row_span: 1, font_size: "16", text_color: "#ffffff", url: "", refresh_interval: "60", take: "", headers: "", static_text: "", clock_mode: "time", clock_timezone: "Europe/London", clock_target_iso: "", clock_show_seconds: true, image_urls: [], image_interval: "10", image_fit: "cover", }; // ─── Helpers ────────────────────────────────────────────────────────────────── function parseHeaders(text: string): Record | undefined { const result: Record = {}; 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 | undefined): string { if (!headers) return ""; return Object.entries(headers) .map(([k, v]) => `${k}: ${v}`) .join("\n"); } function cardToForm(card: DataCard): FormState { const base: FormState = { ...EMPTY_FORM, type: card.type, name: card.name, grid_col: card.layout?.grid_col ?? 1, grid_row: card.layout?.grid_row ?? 1, col_span: card.layout?.col_span ?? 1, row_span: card.layout?.row_span ?? 1, }; if (card.type === "static_text") { return { ...base, static_text: card.config.text, font_size: String(card.config.font_size ?? 16), text_color: card.config.text_color ?? "#ffffff", }; } if (card.type === "clock") { return { ...base, clock_mode: card.config.mode, clock_timezone: card.config.timezone ?? "", clock_target_iso: card.config.target_iso ?? "", clock_show_seconds: card.config.show_seconds !== false, font_size: String(card.config.font_size ?? 48), text_color: card.config.text_color ?? "#ffffff", }; } if (card.type === "image_rotator") { return { ...base, image_urls: card.config.images ?? [], image_interval: String(card.config.interval ?? 10), image_fit: card.config.fit ?? "cover", }; } // custom_json return { ...base, url: (card as CustomJsonCard).config.url, refresh_interval: String((card as CustomJsonCard).config.refresh_interval), font_size: String((card as CustomJsonCard).config.display_options?.font_size ?? 16), text_color: (card as CustomJsonCard).config.display_options?.text_color ?? "#ffffff", take: (card as CustomJsonCard).config.take ?? "", headers: headersToText((card as CustomJsonCard).config.additional_headers), }; } function formToCard(form: FormState, id: string): DataCard { const layout: CardLayout = { grid_col: form.grid_col, grid_row: form.grid_row, col_span: form.col_span, row_span: form.row_span, }; if (form.type === "static_text") { return { id, type: "static_text", name: form.name.trim(), config: { text: form.static_text, font_size: Math.max(8, parseInt(form.font_size, 10) || 16), text_color: form.text_color.trim() || "#ffffff", }, layout, }; } if (form.type === "clock") { return { id, type: "clock", name: form.name.trim(), config: { mode: form.clock_mode, timezone: form.clock_timezone.trim() || undefined, target_iso: form.clock_mode === "timer" ? form.clock_target_iso.trim() || undefined : undefined, font_size: Math.max(8, parseInt(form.font_size, 10) || 48), text_color: form.text_color.trim() || "#ffffff", show_seconds: form.clock_show_seconds, }, layout, }; } if (form.type === "image_rotator") { return { id, type: "image_rotator", name: form.name.trim(), config: { images: form.image_urls, interval: Math.max(2, parseInt(form.image_interval, 10) || 10), fit: form.image_fit, }, layout, }; } // custom_json 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), }, layout, }; } function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } // ─── Grid Picker ────────────────────────────────────────────────────────────────── const GRID_COLS = 4; const GRID_ROWS = 4; interface GridPickerProps { gridCol: number; gridRow: number; colSpan: number; rowSpan: number; /** Layouts of other (already-placed) cards to show as occupied cells */ otherLayouts?: Array<{ name: string; layout: CardLayout }>; onChange: (gridCol: number, gridRow: number, colSpan: number, rowSpan: number) => void; } function GridPicker({ gridCol, gridRow, colSpan, rowSpan, otherLayouts, onChange }: GridPickerProps) { // Phase: "start" = next tap sets the top-left; "end" = next tap extends/sets bottom-right const [phase, setPhase] = useState<"start" | "end">("start"); const endCol = gridCol + colSpan - 1; const endRow = gridRow + rowSpan - 1; const handleCellTap = (col: number, row: number) => { if (phase === "start") { // First tap: place a 1×1 at this cell and wait for the end tap onChange(col, row, 1, 1); setPhase("end"); } else { // Second tap if (col >= gridCol && row >= gridRow) { // Extend the selection onChange(gridCol, gridRow, col - gridCol + 1, row - gridRow + 1); } else { // Reset to new 1×1 at the tapped cell onChange(col, row, 1, 1); } setPhase("start"); } }; const colLabels = ["1", "2", "3", "4"]; const rowLabels = ["1", "2", "3", "4"]; return ( {/* Phase indicator banner */} {phase === "start" ? "↖" : "↘"} {phase === "start" ? "Step 1 — Pick top-left corner" : "Step 2 — Pick bottom-right corner"} {phase === "start" ? "1 / 2" : "2 / 2"} {/* Column labels */} {colLabels.map((l, ci) => ( {l} ))} {Array.from({ length: GRID_ROWS }, (_, ri) => ( {/* Row label */} {rowLabels[ri]} {Array.from({ length: GRID_COLS }, (_, ci) => { const col = ci + 1; const row = ri + 1; const selected = col >= gridCol && col <= endCol && row >= gridRow && row <= endRow; const isStart = col === gridCol && row === gridRow; const occupiedBy = (otherLayouts ?? []).find( ({ layout: l }) => col >= l.grid_col && col <= l.grid_col + l.col_span - 1 && row >= l.grid_row && row <= l.grid_row + l.row_span - 1 ); return ( handleCellTap(col, row)} activeOpacity={0.7} > {isStart && } {!!occupiedBy && !selected && ( {occupiedBy.name.slice(0, 4)} )} ); })} ))} Size: {colSpan}×{rowSpan}{colSpan === 1 && rowSpan === 1 ? " (1×1)" : ` – ${colSpan * rowSpan} cells`} ); } const gridStyles = StyleSheet.create({ container: { gap: 6, }, phaseBanner: { flexDirection: "row", alignItems: "center", gap: 8, borderRadius: 10, borderWidth: 1.5, borderColor: colors.border, backgroundColor: colors.surface, paddingVertical: 10, paddingHorizontal: 12, }, phaseBannerEnd: { borderColor: colors.accent, backgroundColor: colors.accent + "18", }, phaseIcon: { fontSize: 18, width: 24, textAlign: "center", color: colors.textMuted, }, phaseTitle: { fontSize: 13, fontWeight: "600", color: colors.textSecondary, }, phaseTitleEnd: { color: colors.accent, }, phasePill: { borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3, backgroundColor: colors.surfaceElevated, borderWidth: 1, borderColor: colors.border, }, phasePillEnd: { backgroundColor: colors.accent + "33", borderColor: colors.accent, }, phasePillText: { fontSize: 11, fontWeight: "700", color: colors.textMuted, }, phasePillTextEnd: { color: colors.accent, }, colLabels: { flexDirection: "row", marginBottom: 2, }, colLabel: { flex: 1, alignItems: "center", }, rowLabelSpacer: { width: 22, }, row: { flexDirection: "row", gap: 4, alignItems: "center", }, rowLabel: { width: 18, alignItems: "center", }, axisLabel: { fontSize: 10, color: colors.textMuted, fontWeight: "600", }, cell: { flex: 1, aspectRatio: 1, borderRadius: 6, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, alignItems: "center", justifyContent: "center", }, cellSelected: { backgroundColor: colors.accent + "44", borderColor: colors.accent, }, cellStart: { backgroundColor: colors.accent + "88", borderColor: colors.accent, }, cellStartDot: { fontSize: 16, color: colors.accent, lineHeight: 18, }, cellOccupied: { backgroundColor: colors.dangerBg, borderColor: colors.dangerBorder, }, cellOccupiedLabel: { fontSize: 8, color: colors.dangerText, textAlign: "center", fontWeight: "600", }, hint: { fontSize: 11, color: colors.textMuted, marginTop: 2, }, }); // ─── Type selector ──────────────────────────────────────────────────────────── const CARD_TYPES: { type: CardType; label: string; icon: string; desc: string }[] = [ { type: "custom_json", label: "JSON Feed", icon: "⚡", desc: "Live data from any JSON API" }, { type: "static_text", label: "Static Text", icon: "📝", desc: "Fixed text or note" }, { type: "clock", label: "Clock / Timer", icon: "🕐", desc: "Live time or countdown" }, { type: "image_rotator", label: "Image Slideshow", icon: "🖼", desc: "Rotating image gallery" }, ]; interface TypeSelectorProps { selected: CardType; disabled?: boolean; onSelect: (t: CardType) => void; } function TypeSelector({ selected, disabled, onSelect }: TypeSelectorProps) { return ( {CARD_TYPES.map(({ type, label, icon, desc }) => ( !disabled && onSelect(type)} activeOpacity={disabled ? 1 : 0.75} > {icon} {label} {desc} {selected === type && } ))} ); } const typeSelStyles = StyleSheet.create({ row: { flexDirection: "row", alignItems: "center", gap: 12, borderRadius: 12, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, padding: 12 }, rowSelected: { borderColor: colors.accent, backgroundColor: colors.accent + "18" }, rowDisabled: { opacity: 0.5 }, icon: { fontSize: 22, width: 32, textAlign: "center" }, label: { fontSize: 15, fontWeight: "600", color: colors.textPrimary }, labelSelected: { color: colors.accent }, desc: { fontSize: 12, color: colors.textMuted, marginTop: 1 }, check: { fontSize: 16, color: colors.accent, fontWeight: "700" }, }); // ─── Type-specific form sections ────────────────────────────────────────────── function SectionLabel({ text }: { text: string }) { return {text}; } function FieldLabel({ text }: { text: string }) { return {text}; } function CustomJsonFields({ form, onChange }: { form: FormState; onChange: (k: keyof FormState, v: string) => void }) { return ( <> onChange("url", v)} autoCapitalize="none" keyboardType="url" /> onChange("refresh_interval", v)} keyboardType="numeric" /> Extract a nested value, e.g. $out.data.temperature onChange("take", v)} autoCapitalize="none" /> One per line: Key: Value onChange("headers", v)} multiline autoCapitalize="none" /> ); } function StaticTextFields({ form, onChange }: { form: FormState; onChange: (k: keyof FormState, v: string) => void }) { return ( onChange("static_text", v)} multiline textAlignVertical="top" /> ); } interface ClockFieldsProps { form: FormState; onChange: (k: keyof FormState, v: string) => void; onBoolChange: (k: keyof FormState, v: boolean) => void; } function ClockFields({ form, onChange, onBoolChange }: ClockFieldsProps) { return ( <> {(["time", "timer"] as const).map((m) => ( onChange("clock_mode", m)} activeOpacity={0.75} > {m === "time" ? "🕐 Live Time" : "⏱ Countdown"} ))} onChange("clock_timezone", v)} autoCapitalize="none" /> e.g. Europe/Berlin · America/New_York · Asia/Tokyo {form.clock_mode === "time" && ( Show Seconds onBoolChange("clock_show_seconds", v)} trackColor={{ true: colors.accent, false: colors.border }} thumbColor="#fff" /> )} {form.clock_mode === "timer" && ( onChange("clock_target_iso", iso)} /> )} ); } // ─── Target date/time picker ────────────────────────────────────────────────── function TargetDateTimePicker({ value, onChange }: { value: string; onChange: (iso: string) => void }) { const parsed = value ? new Date(value) : null; const validDate = parsed && !isNaN(parsed.getTime()) ? parsed : new Date(); // Android: two-step (date then time) const [showDate, setShowDate] = useState(false); const [showTime, setShowTime] = useState(false); const [pendingDate, setPendingDate] = useState(validDate); // iOS: show modal with datetime spinner const [showIOS, setShowIOS] = useState(false); const [iosDate, setIosDate] = useState(validDate); const displayStr = parsed && !isNaN(parsed.getTime()) ? parsed.toLocaleString("en-GB", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false }) : "Tap to pick date & time"; const toISO = (d: Date) => { const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }; const openPicker = () => { if (Platform.OS === "ios") { setIosDate(validDate); setShowIOS(true); } else { setPendingDate(validDate); setShowDate(true); } }; const onDateChange = (_: DateTimePickerEvent, selected?: Date) => { setShowDate(false); if (selected) { setPendingDate(selected); setShowTime(true); } }; const onTimeChange = (_: DateTimePickerEvent, selected?: Date) => { setShowTime(false); if (selected) { const combined = new Date(pendingDate); combined.setHours(selected.getHours(), selected.getMinutes(), 0, 0); onChange(toISO(combined)); } }; return ( {displayStr} {/* Android: two-step pickers */} {Platform.OS !== "ios" && showDate && ( )} {Platform.OS !== "ios" && showTime && ( )} {/* iOS: modal with datetime spinner */} {Platform.OS === "ios" && ( setShowIOS(false)}> setShowIOS(false)}> Cancel { onChange(toISO(iosDate)); setShowIOS(false); }}> Done d && setIosDate(d)} display="spinner" style={{ width: "100%" }} /> )} ); } // ─── Image Rotator Fields ───────────────────────────────────────────────────── interface ImageRotatorFieldsProps { form: FormState; onChange: (k: keyof FormState, v: string) => void; onUrlsChange: (urls: string[]) => void; } function ImageRotatorFields({ form, onChange, onUrlsChange }: ImageRotatorFieldsProps) { const [urlInput, setUrlInput] = useState(""); const [uploading, setUploading] = useState(false); const addUrl = () => { const url = urlInput.trim(); if (!url) return; onUrlsChange([...form.image_urls, url]); setUrlInput(""); }; const removeUrl = (idx: number) => { onUrlsChange(form.image_urls.filter((_, i) => i !== idx)); }; const pickAndUpload = async () => { const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!granted) { Alert.alert("Permission required", "Allow access to your photo library to upload images."); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: "images", quality: 1, base64: true, }); if (result.canceled) return; const asset = result.assets[0]; if (!asset.base64) { Alert.alert("Error", "Could not read image data."); return; } const ext = (asset.mimeType?.split("/")[1] ?? asset.uri.split(".").pop() ?? "jpg").toLowerCase(); setUploading(true); try { const res = await fetch(`${BASE_URL}/push_upload_images`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ images: [{ image_b64: asset.base64, ext }] }), }); const data = await res.json(); if (data.status !== "success") throw new Error(data.message ?? "Upload failed"); onUrlsChange([...form.image_urls, ...(data.urls ?? [])]); } catch (e) { Alert.alert("Upload Failed", String(e)); } finally { setUploading(false); } }; return ( <> {form.image_urls.length > 0 && ( {form.image_urls.map((url, idx) => ( {url} removeUrl(idx)} style={imgStyles.removeBtn} activeOpacity={0.7}> ))} )} Add {uploading ? : 📷 Pick from Camera Roll } onChange("image_interval", v)} keyboardType="numeric" /> {(["cover", "contain"] as const).map((fit) => ( onChange("image_fit", fit)} activeOpacity={0.75} > {fit === "cover" ? "Fill (Cover)" : "Fit (Contain)"} ))} ); } const imgStyles = StyleSheet.create({ urlRow: { flexDirection: "row", alignItems: "center", backgroundColor: colors.surfaceElevated, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8, gap: 8 }, urlText: { flex: 1, fontSize: 12, color: colors.textSecondary, fontFamily: "monospace" }, removeBtn: { padding: 4 }, removeText: { fontSize: 14, color: colors.dangerText }, addUrlBtn: { backgroundColor: colors.accent, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 11, justifyContent: "center" }, addUrlText: { fontSize: 15, fontWeight: "600", color: "#fff" }, pickBtn: { backgroundColor: colors.surfaceElevated, borderRadius: 12, borderWidth: 1, borderColor: colors.border, paddingVertical: 12, alignItems: "center" }, pickBtnText: { fontSize: 15, fontWeight: "500", color: colors.textPrimary }, }); // ─── Full form view ─────────────────────────────────────────────────────────── interface FormViewProps { form: FormState; onChange: (key: keyof FormState, value: string) => void; onBoolChange: (key: keyof FormState, value: boolean) => void; onLayoutChange: (gridCol: number, gridRow: number, colSpan: number, rowSpan: number) => void; onTypeChange: (t: CardType) => void; onUrlsChange: (urls: string[]) => void; onSave: () => void; onCancel: () => void; saving: boolean; isEdit: boolean; otherLayouts: Array<{ name: string; layout: CardLayout }>; } function FormView({ form, onChange, onBoolChange, onLayoutChange, onTypeChange, onUrlsChange, onSave, onCancel, saving, isEdit, otherLayouts }: FormViewProps) { const showDisplayOptions = form.type !== "image_rotator"; return ( ← Back {isEdit ? "Edit Widget" : "New Widget"} {isEdit && Type cannot be changed after creation.} onChange("name", v)} /> {form.type === "custom_json" && } {form.type === "static_text" && } {form.type === "clock" && } {form.type === "image_rotator" && } {showDisplayOptions && ( <> onChange("font_size", v)} keyboardType="numeric" /> onChange("text_color", v)} autoCapitalize="none" /> )} Tap top-left then bottom-right to place the widget on the 4×4 TV grid. {saving ? : {isEdit ? "Save Changes" : "Add Widget"}} ); } // ─── Card List Item ─────────────────────────────────────────────────────────── const CARD_TYPE_ICONS: Record = { custom_json: "⚡", static_text: "📝", clock: "🕐", image_rotator: "🖼", }; function cardSubtitle(card: DataCard): string { if (card.type === "static_text") return card.config.text.slice(0, 80) || "(empty)"; if (card.type === "clock") { const c = card.config; if (c.mode === "timer") return `Countdown → ${c.target_iso ?? "?"}`; return `Live time · ${c.timezone ?? "local"}`; } if (card.type === "image_rotator") return `${card.config.images.length} image(s) · ${card.config.interval}s rotation`; return (card as CustomJsonCard).config.url; } function cardMeta(card: DataCard): string { if (card.type === "custom_json") return `Refresh: ${(card as CustomJsonCard).config.refresh_interval}s`; if (card.type === "image_rotator") return `Fit: ${card.config.fit}`; if (card.type === "clock") return card.config.mode === "time" ? "Mode: live time" : "Mode: countdown"; return ""; } interface CardItemProps { card: DataCard; onEdit: () => void; onDelete: () => void; onDuplicate: () => void; } function CardItem({ card, onEdit, onDelete, onDuplicate }: CardItemProps) { return ( {CARD_TYPE_ICONS[card.type]} {card.name} {cardSubtitle(card)} {cardMeta(card) ? {cardMeta(card)} : null} Edit Duplicate Delete ); } // ─── Main Page ──────────────────────────────────────────────────────────────── type PageView = "list" | "form"; export function DataCardsPage() { const { navigate } = useRouter(); const [pageView, setPageView] = useState("list"); const [cards, setCards] = useState([]); const [loading, setLoading] = useState(true); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(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(() => Alert.alert("Error", "Failed to load widgets.")) .finally(() => setLoading(false)); }; const handleAdd = () => { setEditingId(null); setForm(EMPTY_FORM); setPageView("form"); }; const handleEdit = (card: DataCard) => { setEditingId(card.id); setForm(cardToForm(card)); setPageView("form"); }; const handleDuplicate = (card: DataCard) => { const duplicate: DataCard = { ...card, id: genId(), name: `Copy of ${card.name}` } as DataCard; fetch(`${BASE_URL}/push_data_card`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ card: duplicate }) }) .then((r) => r.json()) .then((data) => { if (data.status !== "success") throw new Error(data.message); setCards((prev) => [...prev, duplicate]); }) .catch(() => Alert.alert("Error", "Failed to duplicate widget.")); }; const handleDelete = (card: DataCard) => { Alert.alert("Delete Widget", `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(() => Alert.alert("Error", "Failed to delete widget.")); }}, ]); }; const handleFormChange = (key: keyof FormState, value: string) => setForm((prev) => ({ ...prev, [key]: value } as FormState)); const handleBoolChange = (key: keyof FormState, value: boolean) => setForm((prev) => ({ ...prev, [key]: value } as FormState)); const handleLayoutChange = (gridCol: number, gridRow: number, colSpan: number, rowSpan: number) => setForm((prev) => ({ ...prev, grid_col: gridCol, grid_row: gridRow, col_span: colSpan, row_span: rowSpan })); const handleTypeChange = (t: CardType) => setForm((prev) => ({ ...EMPTY_FORM, type: t, name: prev.name })); const handleUrlsChange = (urls: string[]) => setForm((prev) => ({ ...prev, image_urls: urls })); const handleSave = () => { if (!form.name.trim()) { Alert.alert("Validation", "Please enter a name."); return; } if (form.type === "custom_json" && !form.url.trim()) { Alert.alert("Validation", "Please enter a URL."); return; } if (form.type === "static_text" && !form.static_text.trim()) { Alert.alert("Validation", "Please enter some text."); return; } if (form.type === "clock" && form.clock_mode === "timer" && !form.clock_target_iso.trim()) { Alert.alert("Validation", "Please enter a target date/time."); return; } if (form.type === "image_rotator" && form.image_urls.length === 0) { Alert.alert("Validation", "Please add at least one image."); return; } const id = editingId ?? genId(); 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]); } setPageView("list"); }) .catch(() => Alert.alert("Error", "Failed to save widget.")) .finally(() => setSaving(false)); }; if (pageView === "form") { const otherLayouts = cards .filter((c) => c.id !== editingId && c.layout) .map((c) => ({ name: c.name, layout: c.layout! })); return ( setPageView("list")} saving={saving} isEdit={editingId !== null} otherLayouts={otherLayouts} /> ); } return ( navigate("home")} style={styles.backBtn} activeOpacity={0.7}> ← Back Data Cards Widgets shown on the TV display. {loading ? ( ) : ( {cards.length === 0 && ( 📭 No widgets yet Add your first widget – JSON feed, text, clock, or slideshow. )} {cards.map((card) => ( handleEdit(card)} onDelete={() => handleDelete(card)} onDuplicate={() => handleDuplicate(card)} /> ))} + Add Widget )} ); } // ─── Styles ─────────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg }, header: { paddingTop: 16, paddingHorizontal: 24, paddingBottom: 4, gap: 4 }, backBtn: { alignSelf: "flex-start", paddingVertical: 4 }, backBtnText: { fontSize: 14, color: colors.accent, fontWeight: "500" }, title: { fontSize: 26, fontWeight: "700", color: colors.textPrimary, marginTop: 4 }, subtitle: { fontSize: 14, color: colors.textSecondary, paddingHorizontal: 24, marginBottom: 12 }, scrollArea: { flex: 1 }, scrollContent: { paddingHorizontal: 24, gap: 12, paddingTop: 4 }, emptyState: { alignItems: "center", paddingVertical: 48, gap: 8 }, emptyIcon: { fontSize: 40 }, emptyTitle: { fontSize: 17, fontWeight: "600", color: colors.textSecondary }, emptyDesc: { fontSize: 14, color: colors.textMuted, textAlign: "center", maxWidth: 260 }, cardItem: { backgroundColor: colors.surface, borderRadius: 14, borderWidth: 1, borderColor: colors.border, padding: 16, shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.3, shadowRadius: 4, elevation: 3, gap: 10 }, cardItemBody: { gap: 3 }, cardItemName: { fontSize: 16, fontWeight: "600", color: colors.textPrimary }, cardItemUrl: { fontSize: 12, color: colors.textSecondary, fontFamily: "monospace" }, cardItemMeta: { fontSize: 12, color: colors.textMuted }, cardItemActions: { flexDirection: "row", gap: 8 }, editBtn: { flex: 1, paddingVertical: 8, borderRadius: 8, backgroundColor: colors.surfaceElevated, alignItems: "center", borderWidth: 1, borderColor: colors.border }, editBtnText: { fontSize: 14, fontWeight: "500", color: colors.textPrimary }, duplicateBtn: { flex: 1, paddingVertical: 8, borderRadius: 8, backgroundColor: colors.surfaceElevated, alignItems: "center", borderWidth: 1, borderColor: colors.accent + "66" }, duplicateBtnText: { fontSize: 14, fontWeight: "500", color: colors.accent }, deleteBtn: { flex: 1, paddingVertical: 8, borderRadius: 8, backgroundColor: colors.dangerBg, alignItems: "center", borderWidth: 1, borderColor: colors.dangerBorder }, deleteBtnText: { fontSize: 14, fontWeight: "500", color: colors.dangerText }, addBtn: { backgroundColor: colors.accent, borderRadius: 14, paddingVertical: 14, alignItems: "center", marginTop: 4 }, addBtnText: { fontSize: 16, fontWeight: "600", color: "#fff" }, sectionLabel: { fontSize: 11, fontWeight: "700", color: colors.textMuted, textTransform: "uppercase", letterSpacing: 1.2, marginTop: 8, marginBottom: -4 }, field: { gap: 6 }, label: { fontSize: 13, fontWeight: "600", color: colors.textMuted, textTransform: "uppercase", letterSpacing: 0.6 }, hint: { fontSize: 12, color: colors.textMuted, lineHeight: 17 }, code: { fontFamily: "monospace", fontSize: 12, color: colors.accent }, input: { backgroundColor: colors.surface, borderRadius: 10, borderWidth: 1, borderColor: colors.border, paddingHorizontal: 14, paddingVertical: 11, fontSize: 15, color: colors.textPrimary }, multilineInput: { minHeight: 80, textAlignVertical: "top", paddingTop: 10 }, row: { flexDirection: "row", gap: 12 }, flex1: { flex: 1 }, saveBtn: { backgroundColor: colors.accent, borderRadius: 14, paddingVertical: 14, alignItems: "center", marginTop: 8 }, saveBtnDisabled: { opacity: 0.45 }, saveBtnText: { fontSize: 16, fontWeight: "600", color: "#fff" }, segRow: { flexDirection: "row", gap: 8 }, segBtn: { flex: 1, paddingVertical: 10, borderRadius: 10, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, alignItems: "center" }, segBtnActive: { borderColor: colors.accent, backgroundColor: colors.accent + "22" }, segBtnText: { fontSize: 14, fontWeight: "500", color: colors.textSecondary }, segBtnTextActive: { color: colors.accent, fontWeight: "600" }, datePickerBtn: { borderWidth: 1, borderColor: colors.border, borderRadius: 12, paddingVertical: 13, paddingHorizontal: 14, backgroundColor: colors.surface }, datePickerBtnText: { fontSize: 15, color: colors.textPrimary }, iosPickerOverlay: { flex: 1, justifyContent: "flex-end", backgroundColor: "rgba(0,0,0,0.45)" }, iosPickerSheet: { backgroundColor: colors.surface, borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingBottom: 32 }, iosPickerHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 20, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: colors.border }, });