Files
tv-control/mobile/src/pages/datacards.tsx
space 2e64661436
All checks were successful
Build App / build (push) Successful in 7m10s
fix: improve image quality setting and enhance error handling in image upload
2026-03-01 17:06:36 +01:00

1192 lines
48 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string>;
}
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<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 {
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 (
<View style={gridStyles.container}>
{/* Phase indicator banner */}
<View style={[gridStyles.phaseBanner, phase === "end" && gridStyles.phaseBannerEnd]}>
<Text style={[gridStyles.phaseIcon]}>{phase === "start" ? "↖" : "↘"}</Text>
<View style={{ flex: 1 }}>
<Text style={[gridStyles.phaseTitle, phase === "end" && gridStyles.phaseTitleEnd]}>
{phase === "start" ? "Step 1 — Pick top-left corner" : "Step 2 — Pick bottom-right corner"}
</Text>
</View>
<View style={[gridStyles.phasePill, phase === "end" && gridStyles.phasePillEnd]}>
<Text style={[gridStyles.phasePillText, phase === "end" && gridStyles.phasePillTextEnd]}>
{phase === "start" ? "1 / 2" : "2 / 2"}
</Text>
</View>
</View>
{/* Column labels */}
<View style={gridStyles.colLabels}>
<View style={gridStyles.rowLabelSpacer} />
{colLabels.map((l, ci) => (
<View key={ci} style={gridStyles.colLabel}>
<Text style={gridStyles.axisLabel}>{l}</Text>
</View>
))}
</View>
{Array.from({ length: GRID_ROWS }, (_, ri) => (
<View key={ri} style={gridStyles.row}>
{/* Row label */}
<View style={gridStyles.rowLabel}>
<Text style={gridStyles.axisLabel}>{rowLabels[ri]}</Text>
</View>
{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 (
<TouchableOpacity
key={ci}
style={[
gridStyles.cell,
!!occupiedBy && gridStyles.cellOccupied,
selected && gridStyles.cellSelected,
isStart && gridStyles.cellStart,
]}
onPress={() => handleCellTap(col, row)}
activeOpacity={0.7}
>
{isStart && <Text style={gridStyles.cellStartDot}></Text>}
{!!occupiedBy && !selected && (
<Text style={gridStyles.cellOccupiedLabel} numberOfLines={1}>
{occupiedBy.name.slice(0, 4)}
</Text>
)}
</TouchableOpacity>
);
})}
</View>
))}
<Text style={gridStyles.hint}>
Size: {colSpan}×{rowSpan}{colSpan === 1 && rowSpan === 1 ? " (1×1)" : ` ${colSpan * rowSpan} cells`}
</Text>
</View>
);
}
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 (
<View style={{ gap: 8 }}>
{CARD_TYPES.map(({ type, label, icon, desc }) => (
<TouchableOpacity
key={type}
style={[typeSelStyles.row, selected === type && typeSelStyles.rowSelected, disabled && typeSelStyles.rowDisabled]}
onPress={() => !disabled && onSelect(type)}
activeOpacity={disabled ? 1 : 0.75}
>
<Text style={typeSelStyles.icon}>{icon}</Text>
<View style={{ flex: 1 }}>
<Text style={[typeSelStyles.label, selected === type && typeSelStyles.labelSelected]}>{label}</Text>
<Text style={typeSelStyles.desc}>{desc}</Text>
</View>
{selected === type && <Text style={typeSelStyles.check}></Text>}
</TouchableOpacity>
))}
</View>
);
}
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 style={styles.sectionLabel}>{text}</Text>;
}
function FieldLabel({ text }: { text: string }) {
return <Text style={styles.label}>{text}</Text>;
}
function CustomJsonFields({ form, onChange }: { form: FormState; onChange: (k: keyof FormState, v: string) => void }) {
return (
<>
<View style={styles.field}>
<FieldLabel text="JSON URL *" />
<TextInput style={styles.input} placeholder="https://api.example.com/data" placeholderTextColor={colors.placeholderColor} value={form.url} onChangeText={(v) => onChange("url", v)} autoCapitalize="none" keyboardType="url" />
</View>
<View style={styles.field}>
<FieldLabel text="Refresh Interval (seconds)" />
<TextInput style={styles.input} placeholder="60" placeholderTextColor={colors.placeholderColor} value={form.refresh_interval} onChangeText={(v) => onChange("refresh_interval", v)} keyboardType="numeric" />
</View>
<SectionLabel text="Advanced" />
<View style={styles.field}>
<FieldLabel text="Value Path (optional)" />
<Text style={styles.hint}>Extract a nested value, e.g. <Text style={styles.code}>$out.data.temperature</Text></Text>
<TextInput style={styles.input} placeholder="$out.data.value" placeholderTextColor={colors.placeholderColor} value={form.take} onChangeText={(v) => onChange("take", v)} autoCapitalize="none" />
</View>
<View style={styles.field}>
<FieldLabel text="Additional Headers (optional)" />
<Text style={styles.hint}>One per line: Key: Value</Text>
<TextInput style={[styles.input, styles.multilineInput]} placeholder={"Authorization: Bearer token123\nX-API-Key: mykey"} placeholderTextColor={colors.placeholderColor} value={form.headers} onChangeText={(v) => onChange("headers", v)} multiline autoCapitalize="none" />
</View>
</>
);
}
function StaticTextFields({ form, onChange }: { form: FormState; onChange: (k: keyof FormState, v: string) => void }) {
return (
<View style={styles.field}>
<FieldLabel text="Text *" />
<TextInput
style={[styles.input, styles.multilineInput, { minHeight: 120 }]}
placeholder="Enter the text to display on the TV…"
placeholderTextColor={colors.placeholderColor}
value={form.static_text}
onChangeText={(v) => onChange("static_text", v)}
multiline
textAlignVertical="top"
/>
</View>
);
}
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 (
<>
<View style={styles.field}>
<FieldLabel text="Mode" />
<View style={styles.segRow}>
{(["time", "timer"] as const).map((m) => (
<TouchableOpacity
key={m}
style={[styles.segBtn, form.clock_mode === m && styles.segBtnActive]}
onPress={() => onChange("clock_mode", m)}
activeOpacity={0.75}
>
<Text style={[styles.segBtnText, form.clock_mode === m && styles.segBtnTextActive]}>
{m === "time" ? "🕐 Live Time" : "⏱ Countdown"}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<View style={styles.field}>
<FieldLabel text={form.clock_mode === "time" ? "Timezone (IANA)" : "Timezone for target display"} />
<TextInput style={styles.input} placeholder="Europe/Berlin" placeholderTextColor={colors.placeholderColor} value={form.clock_timezone} onChangeText={(v) => onChange("clock_timezone", v)} autoCapitalize="none" />
<Text style={styles.hint}>e.g. Europe/Berlin · America/New_York · Asia/Tokyo</Text>
</View>
{form.clock_mode === "time" && (
<View style={[styles.field, styles.row, { alignItems: "center" }]}>
<Text style={[styles.label, { flex: 1 }]}>Show Seconds</Text>
<Switch
value={form.clock_show_seconds}
onValueChange={(v) => onBoolChange("clock_show_seconds", v)}
trackColor={{ true: colors.accent, false: colors.border }}
thumbColor="#fff"
/>
</View>
)}
{form.clock_mode === "timer" && (
<TargetDateTimePicker
value={form.clock_target_iso}
onChange={(iso) => 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<Date>(validDate);
// iOS: show modal with datetime spinner
const [showIOS, setShowIOS] = useState(false);
const [iosDate, setIosDate] = useState<Date>(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 (
<View style={styles.field}>
<FieldLabel text="Target Date/Time *" />
<TouchableOpacity style={styles.datePickerBtn} onPress={openPicker} activeOpacity={0.75}>
<Text style={styles.datePickerBtnText}>{displayStr}</Text>
</TouchableOpacity>
{/* Android: two-step pickers */}
{Platform.OS !== "ios" && showDate && (
<DateTimePicker mode="date" value={pendingDate} onChange={onDateChange} />
)}
{Platform.OS !== "ios" && showTime && (
<DateTimePicker mode="time" value={pendingDate} onChange={onTimeChange} is24Hour />
)}
{/* iOS: modal with datetime spinner */}
{Platform.OS === "ios" && (
<Modal transparent animationType="slide" visible={showIOS} onRequestClose={() => setShowIOS(false)}>
<View style={styles.iosPickerOverlay}>
<View style={styles.iosPickerSheet}>
<View style={styles.iosPickerHeader}>
<TouchableOpacity onPress={() => setShowIOS(false)}>
<Text style={{ color: colors.textMuted, fontSize: 16 }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => { onChange(toISO(iosDate)); setShowIOS(false); }}>
<Text style={{ color: colors.accent, fontSize: 16, fontWeight: "600" }}>Done</Text>
</TouchableOpacity>
</View>
<DateTimePicker
mode="datetime"
value={iosDate}
onChange={(_, d) => d && setIosDate(d)}
display="spinner"
style={{ width: "100%" }}
/>
</View>
</View>
</Modal>
)}
</View>
);
}
// ─── 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 && (
<View style={styles.field}>
<FieldLabel text={`Images (${form.image_urls.length})`} />
{form.image_urls.map((url, idx) => (
<View key={idx} style={imgStyles.urlRow}>
<Text style={imgStyles.urlText} numberOfLines={1}>{url}</Text>
<TouchableOpacity onPress={() => removeUrl(idx)} style={imgStyles.removeBtn} activeOpacity={0.7}>
<Text style={imgStyles.removeText}></Text>
</TouchableOpacity>
</View>
))}
</View>
)}
<View style={styles.field}>
<FieldLabel text="Add Image URL" />
<View style={styles.row}>
<TextInput
style={[styles.input, { flex: 1 }]}
placeholder="https://example.com/photo.jpg"
placeholderTextColor={colors.placeholderColor}
value={urlInput}
onChangeText={setUrlInput}
autoCapitalize="none"
keyboardType="url"
/>
<TouchableOpacity style={imgStyles.addUrlBtn} onPress={addUrl} activeOpacity={0.8}>
<Text style={imgStyles.addUrlText}>Add</Text>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
style={[imgStyles.pickBtn, uploading && styles.saveBtnDisabled]}
onPress={pickAndUpload}
activeOpacity={0.8}
disabled={uploading}
>
{uploading
? <ActivityIndicator color="#fff" size="small" />
: <Text style={imgStyles.pickBtnText}>📷 Pick from Camera Roll</Text>
}
</TouchableOpacity>
<View style={styles.field}>
<FieldLabel text="Rotation Interval (seconds)" />
<TextInput style={styles.input} placeholder="10" placeholderTextColor={colors.placeholderColor} value={form.image_interval} onChangeText={(v) => onChange("image_interval", v)} keyboardType="numeric" />
</View>
<View style={styles.field}>
<FieldLabel text="Image Fit" />
<View style={styles.segRow}>
{(["cover", "contain"] as const).map((fit) => (
<TouchableOpacity
key={fit}
style={[styles.segBtn, form.image_fit === fit && styles.segBtnActive]}
onPress={() => onChange("image_fit", fit)}
activeOpacity={0.75}
>
<Text style={[styles.segBtnText, form.image_fit === fit && styles.segBtnTextActive]}>
{fit === "cover" ? "Fill (Cover)" : "Fit (Contain)"}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</>
);
}
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 (
<View style={styles.container}>
<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 Widget" : "New Widget"}</Text>
</View>
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
<SectionLabel text="Widget Type" />
<TypeSelector selected={form.type} disabled={isEdit} onSelect={onTypeChange} />
{isEdit && <Text style={styles.hint}>Type cannot be changed after creation.</Text>}
<SectionLabel text="General" />
<View style={styles.field}>
<FieldLabel text="Name *" />
<TextInput style={styles.input} placeholder="My Widget" placeholderTextColor={colors.placeholderColor} value={form.name} onChangeText={(v) => onChange("name", v)} />
</View>
<SectionLabel text={form.type === "custom_json" ? "Data Source" : form.type === "static_text" ? "Content" : form.type === "clock" ? "Clock Settings" : "Images"} />
{form.type === "custom_json" && <CustomJsonFields form={form} onChange={onChange} />}
{form.type === "static_text" && <StaticTextFields form={form} onChange={onChange} />}
{form.type === "clock" && <ClockFields form={form} onChange={onChange} onBoolChange={onBoolChange} />}
{form.type === "image_rotator" && <ImageRotatorFields form={form} onChange={onChange} onUrlsChange={onUrlsChange} />}
{showDisplayOptions && (
<>
<SectionLabel text="Display" />
<View style={styles.row}>
<View style={[styles.field, styles.flex1]}>
<FieldLabel text="Font Size" />
<TextInput style={styles.input} placeholder={form.type === "clock" ? "48" : "16"} placeholderTextColor={colors.placeholderColor} value={form.font_size} onChangeText={(v) => onChange("font_size", v)} keyboardType="numeric" />
</View>
<View style={[styles.field, styles.flex1]}>
<FieldLabel text="Text Color (hex)" />
<TextInput style={styles.input} placeholder="#ffffff" placeholderTextColor={colors.placeholderColor} value={form.text_color} onChangeText={(v) => onChange("text_color", v)} autoCapitalize="none" />
</View>
</View>
</>
)}
<SectionLabel text="Layout" />
<View style={styles.field}>
<Text style={styles.hint}>Tap top-left then bottom-right to place the widget on the 4Ã4 TV grid.</Text>
<GridPicker gridCol={form.grid_col} gridRow={form.grid_row} colSpan={form.col_span} rowSpan={form.row_span} otherLayouts={otherLayouts} onChange={onLayoutChange} />
</View>
<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 Widget"}</Text>}
</TouchableOpacity>
<View style={{ height: 40 }} />
</ScrollView>
</View>
);
}
// ─── Card List Item ───────────────────────────────────────────────────────────
const CARD_TYPE_ICONS: Record<CardType, string> = {
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 (
<View style={styles.cardItem}>
<View style={styles.cardItemBody}>
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
<Text style={{ fontSize: 18 }}>{CARD_TYPE_ICONS[card.type]}</Text>
<Text style={styles.cardItemName}>{card.name}</Text>
</View>
<Text style={styles.cardItemUrl} numberOfLines={2}>{cardSubtitle(card)}</Text>
{cardMeta(card) ? <Text style={styles.cardItemMeta}>{cardMeta(card)}</Text> : null}
</View>
<View style={styles.cardItemActions}>
<TouchableOpacity style={styles.editBtn} onPress={onEdit} activeOpacity={0.7}>
<Text style={styles.editBtnText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.duplicateBtn} onPress={onDuplicate} activeOpacity={0.7}>
<Text style={styles.duplicateBtnText}>Duplicate</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.deleteBtn} onPress={onDelete} activeOpacity={0.7}>
<Text style={styles.deleteBtnText}>Delete</Text>
</TouchableOpacity>
</View>
</View>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
type PageView = "list" | "form";
export function DataCardsPage() {
const { navigate } = useRouter();
const [pageView, setPageView] = useState<PageView>("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(() => 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 (
<FormView
form={form}
onChange={handleFormChange}
onBoolChange={handleBoolChange}
onLayoutChange={handleLayoutChange}
onTypeChange={handleTypeChange}
onUrlsChange={handleUrlsChange}
onSave={handleSave}
onCancel={() => setPageView("list")}
saving={saving}
isEdit={editingId !== null}
otherLayouts={otherLayouts}
/>
);
}
return (
<View style={styles.container}>
<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}>Widgets 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 widgets yet</Text>
<Text style={styles.emptyDesc}>Add your first widget JSON feed, text, clock, or slideshow.</Text>
</View>
)}
{cards.map((card) => (
<CardItem key={card.id} card={card} onEdit={() => handleEdit(card)} onDelete={() => handleDelete(card)} onDuplicate={() => handleDuplicate(card)} />
))}
<TouchableOpacity style={styles.addBtn} onPress={handleAdd} activeOpacity={0.8}>
<Text style={styles.addBtnText}>+ Add Widget</Text>
</TouchableOpacity>
<View style={{ height: 24 }} />
</ScrollView>
)}
</View>
);
}
// ─── 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 },
});