1192 lines
48 KiB
TypeScript
1192 lines
48 KiB
TypeScript
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 },
|
||
});
|