From 6497eb9770dbc13a2ada9788979a0fe4e2d27417 Mon Sep 17 00:00:00 2001 From: space Date: Sun, 1 Mar 2026 14:57:18 +0100 Subject: [PATCH] MAJOR UPDATE ALERT!!!!! --- .github/copilot-instructions.md | 76 +- functions/control/main.py | 11 +- mobile/App.tsx | 18 +- mobile/package.json | 1 + mobile/pnpm-lock.yaml | 28 +- mobile/src/components/BottomNav.tsx | 13 +- mobile/src/pages/NotFound.tsx | 9 +- mobile/src/pages/datacards.tsx | 1420 ++++++++++++++++++-------- mobile/src/pages/image.tsx | 156 +-- mobile/src/pages/index.tsx | 76 +- mobile/src/pages/settings.tsx | 286 ++++++ mobile/src/pages/text.tsx | 107 +- mobile/src/router.tsx | 30 +- mobile/src/styles.ts | 203 ++++ tv/src/App.tsx | 338 ++---- tv/src/components/DataCardWidget.tsx | 301 ++++++ tv/src/components/ImagePopup.tsx | 23 + tv/src/components/NotFullscreen.tsx | 51 + tv/src/components/TextPopup.tsx | 15 + tv/src/types.ts | 106 ++ tv/src/utils/evaluatePath.ts | 21 + 21 files changed, 2374 insertions(+), 915 deletions(-) create mode 100644 mobile/src/pages/settings.tsx create mode 100644 mobile/src/styles.ts create mode 100644 tv/src/components/DataCardWidget.tsx create mode 100644 tv/src/components/ImagePopup.tsx create mode 100644 tv/src/components/NotFullscreen.tsx create mode 100644 tv/src/components/TextPopup.tsx create mode 100644 tv/src/types.ts create mode 100644 tv/src/utils/evaluatePath.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a3dfdee..d5aff2c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,45 +5,77 @@ Three-component system for remote-controlling a TV display from a mobile phone: - **`mobile/`** — Expo React Native app (the "remote control"). Sends commands to the backend. -- **`tv/`** — Vite + React web app (the TV display). Runs in a browser, polls backend every 5s when fullscreen. -- **`functions/control/`** — Python serverless function deployed on `shsf-api.reversed.dev`. Acts as state store; persists to `/app/state.json` on the container. +- **`tv/`** — Vite + React web app (the TV display). Runs in a browser on the TV, polls backend every 5s when fullscreen. +- **`functions/control/`** — Python serverless function on `shsf-api.reversed.dev`. Single state store; persists to `/app/state.json` on the container. +- **`functions/data-sources/`** — Second serverless function (stub); currently unused beyond a `test` route. ## API / Backend -All communication goes through a single endpoint: +All communication goes through one base URL: ``` https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329/{route} ``` -Routes (all `POST` except pull routes which are `GET` implicitly via `fetch`): -- `push_text` — body: `{ title: string }` -- `push_dismiss_text` -- `push_image` — body: `{ image_b64: string, caption: string }`; uploads to MinIO, stores public URL -- `push_dismiss_image` -- `pull_full` — returns full state `{ text, image_popup }` +All routes use `POST` (fetch with no body is still POST). `pull_full` returns `{ status, state }` — the state object is under the `state` key, not the root. -Images are stored in MinIO at `content2.reversed.dev`, bucket `tv-control`. +| Route | Body | Notes | +|---|---|---| +| `push_text` | `{ title }` | | +| `push_dismiss_text` | — | | +| `push_image` | `{ image_b64, caption }` | Uploads to MinIO, stores URL | +| `push_dismiss_image` | — | | +| `push_data_card` | `{ card: DataCard }` | Upserts by `card.id` | +| `push_delete_data_card` | `{ id }` | | +| `push_upload_images` | `{ images: [{image_b64, ext}] }` | Returns `{ urls[] }`, no state change | +| `push_settings` | `{ background_url }` | Persists global TV settings | +| `pull_full` | — | Returns `{ state: { text, image_popup, data_cards[], settings } }` | +| `pull_data_cards` | — | Returns `{ data_cards[] }` | + +Images/rotator assets stored in MinIO at `content2.reversed.dev`, bucket `tv-control`. + +## Global Settings + +`settings` is a persistent object with global TV display configuration (typed as `SettingsState` in `tv/src/types.ts`). + +- **`background_url`** — a portrait image (height > width) rendered as a full-screen `object-cover` background behind all TV content. Empty string = no background. +- Set via `push_settings { background_url }`, cleared by passing `""`. +- The constraint (portrait orientation) is enforced in the mobile picker (`mobile/src/pages/settings.tsx`) — `asset.width >= asset.height` is rejected. +- Images are uploaded first via `push_upload_images` to get a MinIO URL, then saved via `push_settings`. +- The Settings tab is visible in the mobile bottom nav (no `hideInNav`). + +## Data Cards System + +`data_cards` is a persistent array of typed card objects displayed on the TV in a **4-column × 4-row CSS grid**. Each card has `{ id, type, name, config, layout? }`. + +Four card types (defined in `tv/src/types.ts` and mirrored in `mobile/src/pages/datacards.tsx`): +- **`custom_json`** — polls an external URL; `config.take` uses `$out.path[0].field` syntax (handled by `tv/src/utils/evaluatePath.ts`) to extract a value +- **`static_text`** — fixed text with font size/color +- **`clock`** — live clock (`mode: "time"`) or countdown (`mode: "timer"`) with optional `timezone` (IANA) and `target_iso` +- **`image_rotator`** — cycles through an array of image URLs + +`layout` defaults to `{ grid_col: 1, grid_row: 4, col_span: 1, row_span: 1 }` when absent. `assignLayouts()` in `tv/src/App.tsx` resolves collisions deterministically via a string hash of `card.id`. ## Adding a New Content Type -1. Add state shape to `DEFAULT_STATE` in `functions/control/main.py` -2. Add `push_*` / `pull_*` handlers in `main(args)` in the same file -3. Add a TypeScript interface and `useState` in `tv/src/App.tsx`; render the popup in the fullscreen branch +1. Add state to `DEFAULT_STATE` in `functions/control/main.py`; add `push_*`/`pull_*` handlers in `main(args)` +2. Add TypeScript types to `tv/src/types.ts` +3. Add `useState` and rendering in `tv/src/App.tsx` (fullscreen branch) 4. Add a page in `mobile/src/pages/` -5. Register it in the `TABS` array in `mobile/App.tsx` with `hideInNav: true` (prevents it appearing in `BottomNav`, navigation is triggered programmatically via `navigate()`) +5. Add a tab to `TABS` in `mobile/App.tsx` with `hideInNav: true`; update the `Route` union in `mobile/src/router.tsx` ## Mobile App Conventions -- Uses a **custom in-memory router** (`mobile/src/router.tsx`) — no expo-router or react-navigation. `Route` type is a string union: `"home" | "text" | "image"`. -- `TABS` in `mobile/App.tsx` is the single source of truth for routes and pages. `BottomNav` imports `TABS` directly from `App.tsx`. -- Tabs with `hideInNav: true` are reachable only via `navigate(route)`, not from the bottom bar. -- Styling uses React Native `StyleSheet` throughout — no CSS or Tailwind. +- **Custom in-memory router** (`mobile/src/router.tsx`) — no expo-router or react-navigation. `Route = "home" | "text" | "image" | "datacards"`. History stack backed by a `useRef`; Android back button wired via `BackHandler`. +- `TABS` in `mobile/App.tsx` is the single source of truth for routes/pages. `BottomNav` imports it directly. +- `hideInNav: true` tabs are reachable only via `navigate(route)`. +- Styling uses React Native `StyleSheet` — no CSS or Tailwind. ## TV App Conventions -- Uses **Tailwind CSS v4** via `@tailwindcss/vite` (no `tailwind.config.js` — config lives in `vite.config.ts`). -- Polling only starts when the browser enters fullscreen (`screenStatus === "fullscreen"`). -- State shape mirrors `DEFAULT_STATE` from the Python function exactly. +- **Tailwind CSS v4** via `@tailwindcss/vite` — no `tailwind.config.js`; config lives in `vite.config.ts`. +- Polling only starts when fullscreen (`screenStatus === "fullscreen"`). +- Data cards grid uses **inline CSS grid styles** (not Tailwind) because `gridColumn`/`gridRow` values are dynamic. All card widgets wrap in the `CardShell` component (`tv/src/components/DataCardWidget.tsx`). +- State shape in `tv/src/App.tsx` must exactly mirror `DEFAULT_STATE` in the Python function. ## Dev Workflows @@ -57,4 +89,4 @@ cd mobile && pnpm android # Android emulator cd mobile && pnpm ios # iOS simulator ``` -Package manager: **pnpm** for both `tv/` and `mobile/`. Python function has no local runner — deploy changes directly. +Package manager: **pnpm** for both `tv/` and `mobile/`. Python functions have no local runner — deploy changes directly. diff --git a/functions/control/main.py b/functions/control/main.py index da11dfa..639f0f6 100644 --- a/functions/control/main.py +++ b/functions/control/main.py @@ -14,7 +14,10 @@ DEFAULT_STATE = { "image_url": "", "caption": "" }, - "data_cards": [] + "data_cards": [], + "settings": { + "background_url": "" + } } MINIO_CLIENT = Minio( @@ -97,6 +100,12 @@ def main(args): current["data_cards"] = [c for c in current.get("data_cards", []) if c["id"] != card_id] _write_state(current) return {"status": "success"} + elif route == "push_settings": + bg_url = body.get("background_url", "") + current = _read_state() + current.setdefault("settings", {})["background_url"] = bg_url + _write_state(current) + return {"status": "success"} elif route == "push_upload_images": # Upload multiple images to MinIO; returns their public URLs (no state change) images = body.get("images", []) diff --git a/mobile/App.tsx b/mobile/App.tsx index 0f7a1f9..82bc56e 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -1,10 +1,12 @@ import { StatusBar } from "expo-status-bar"; import { StyleSheet, View } from "react-native"; import { BottomNav } from "./src/components/BottomNav"; +import { colors } from "./src/styles"; import { NotFoundPage } from "./src/pages/NotFound"; import { DataCardsPage } from "./src/pages/datacards"; import { ImagePage } from "./src/pages/image"; import { IndexPage } from "./src/pages/index"; +import { SettingsPage } from "./src/pages/settings"; import { TextPage } from "./src/pages/text"; import { Route, RouterProvider, useRouter } from "./src/router"; interface Tab { @@ -17,7 +19,13 @@ const TABS: Tab[] = [ { label: "Home", route: "home", page: IndexPage }, { label: "Text", route: "text", page: TextPage, hideInNav: true }, { label: "Image", route: "image", page: ImagePage, hideInNav: true }, - { label: "Data Cards", route: "datacards", page: DataCardsPage, hideInNav: true }, + { + label: "Data Cards", + route: "datacards", + page: DataCardsPage, + hideInNav: true, + }, + { label: "Settings", route: "settings", page: SettingsPage }, ]; export { TABS, type Tab }; @@ -41,8 +49,10 @@ export default function App() { return ( - - + + + + @@ -52,6 +62,6 @@ export default function App() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#fff", + backgroundColor: colors.bg, }, }); diff --git a/mobile/package.json b/mobile/package.json index 8cecdce..6eca003 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -9,6 +9,7 @@ "web": "expo start --web" }, "dependencies": { + "@react-native-community/datetimepicker": "^8.6.0", "expo": "~54.0.33", "expo-image-picker": "^55.0.10", "expo-status-bar": "~3.0.9", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 0a8a1c6..5ba323f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@react-native-community/datetimepicker': + specifier: ^8.6.0 + version: 8.6.0(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo: specifier: ~54.0.33 version: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -702,6 +705,19 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@react-native-community/datetimepicker@8.6.0': + resolution: {integrity: sha512-yxPSqNfxgpGaqHQIpatqe6ykeBdU/1pdsk/G3x01mY2bpTflLpmVTLqFSJYd3MiZzxNZcMs/j1dQakUczSjcYA==} + peerDependencies: + expo: '>=52.0.0' + react: '*' + react-native: '*' + react-native-windows: '*' + peerDependenciesMeta: + expo: + optional: true + react-native-windows: + optional: true + '@react-native/assets-registry@0.81.5': resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==} engines: {node: '>= 20.19.4'} @@ -1630,28 +1646,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -3546,6 +3558,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@react-native-community/datetimepicker@8.6.0(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + dependencies: + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) + optionalDependencies: + expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + '@react-native/assets-registry@0.81.5': {} '@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)': diff --git a/mobile/src/components/BottomNav.tsx b/mobile/src/components/BottomNav.tsx index 562e723..19aeec8 100644 --- a/mobile/src/components/BottomNav.tsx +++ b/mobile/src/components/BottomNav.tsx @@ -1,6 +1,7 @@ import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { useRouter } from "../router"; import { TABS } from "../../App"; +import { colors } from "../styles"; export function BottomNav() { @@ -32,9 +33,9 @@ const styles = StyleSheet.create({ bar: { flexDirection: "row", height: 74, - borderTopWidth:1, - borderTopColor: "#e5e5e5", - backgroundColor: "#fff", + borderTopWidth: 1, + borderTopColor: colors.border, + backgroundColor: colors.surface, }, tab: { flex: 1, @@ -43,11 +44,11 @@ const styles = StyleSheet.create({ }, label: { fontSize: 13, - color: "#aaa", + color: colors.textMuted, fontWeight: "500", }, labelActive: { - color: "#1a1a1a", + color: colors.textPrimary, fontWeight: "700", }, indicator: { @@ -56,6 +57,6 @@ const styles = StyleSheet.create({ width: 32, height: 3, borderRadius: 2, - backgroundColor: "#1a1a1a", + backgroundColor: colors.accent, }, }); diff --git a/mobile/src/pages/NotFound.tsx b/mobile/src/pages/NotFound.tsx index 523e35c..08bfb10 100644 --- a/mobile/src/pages/NotFound.tsx +++ b/mobile/src/pages/NotFound.tsx @@ -1,4 +1,5 @@ import { StyleSheet, Text, View } from "react-native"; +import { colors } from "../styles"; export function NotFoundPage() { return ( @@ -15,12 +16,12 @@ const styles = StyleSheet.create({ flex: 1, alignItems: "center", justifyContent: "center", - backgroundColor: "#f9f9f9", + backgroundColor: colors.bg, }, code: { fontSize: 72, fontWeight: "800", - color: "#ccc", + color: colors.border, lineHeight: 80, }, title: { @@ -28,9 +29,11 @@ const styles = StyleSheet.create({ fontWeight: "600", marginTop: 8, marginBottom: 6, + color: colors.textPrimary, }, subtitle: { fontSize: 14, - color: "#999", + color: colors.textSecondary, }, }); + diff --git a/mobile/src/pages/datacards.tsx b/mobile/src/pages/datacards.tsx index 1d2dd94..4c8f2a2 100644 --- a/mobile/src/pages/datacards.tsx +++ b/mobile/src/pages/datacards.tsx @@ -1,61 +1,126 @@ +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 ──────────────────────────────────────────────────────────────────── -interface DisplayOptions { - font_size: number; - text_color: string; +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: DisplayOptions; + display_options: { font_size: number; text_color: string }; take?: string; additional_headers?: Record; } - -export interface DataCard { - id: string; - type: "custom_json"; - name: string; - config: DataCardConfig; +interface CustomJsonCard { + id: string; type: "custom_json"; name: string; + config: DataCardConfig; layout?: CardLayout; } -// ─── Flat form state (easier to wire to inputs) ─────────────────────────────── +// 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; - url: string; - refresh_interval: string; // kept as string for TextInput + // 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; // "Key: Value\nKey2: Value2" + 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: "", - url: "", - refresh_interval: "60", + 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 ────────────────────────────────────────────────────────────────── @@ -68,10 +133,7 @@ function parseHeaders(text: string): Record | undefined { 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; - } + if (key) { result[key] = value; hasAny = true; } } return hasAny ? result : undefined; } @@ -84,22 +146,98 @@ function headersToText(headers: Record | undefined): string { } function cardToForm(card: DataCard): FormState { - return { + const base: FormState = { + ...EMPTY_FORM, + type: card.type, name: card.name, - url: card.config.url, - refresh_interval: String(card.config.refresh_interval), - font_size: String(card.config.display_options?.font_size ?? 16), - text_color: card.config.display_options?.text_color ?? "#ffffff", - take: card.config.take ?? "", - headers: headersToText(card.config.additional_headers), + 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(), + id, type: "custom_json", name: form.name.trim(), config: { url: form.url.trim(), refresh_interval: Math.max(5, parseInt(form.refresh_interval, 10) || 60), @@ -110,142 +248,699 @@ function formToCard(form: FormState, id: string): DataCard { take: form.take.trim() || undefined, additional_headers: parseHeaders(form.headers), }, + layout, }; } -// ─── Form CView ──────────────────────────────────────────────────────────────── +function genId() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 6); +} -interface FormCViewProps { +// ─── 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: 0.8, + 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.uri.split(".").pop()?.toLowerCase() ?? "jpg"; + 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 FormCView({ form, onChange, onSave, onCancel, saving, isEdit }: FormCViewProps) { +function FormView({ form, onChange, onBoolChange, onLayoutChange, onTypeChange, onUrlsChange, onSave, onCancel, saving, isEdit, otherLayouts }: FormViewProps) { + const showDisplayOptions = form.type !== "image_rotator"; return ( - {/* Header */} ← Back - {isEdit ? "Edit Data Source" : "New Data Source"} + {isEdit ? "Edit Widget" : "New Widget"} - - {/* Name */} + + + {isEdit && Type cannot be changed after creation.} + + - Name * - onChange("name", v)} - /> + + onChange("name", v)} /> - {/* URL */} + + {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" /> + + + + )} + + - JSON URL * - onChange("url", v)} - autoCapitalize="none" - keyboardType="url" - /> + Tap top-left then bottom-right to place the widget on the 4×4 TV grid. + - {/* Refresh Interval */} - - Refresh Interval (seconds) - onChange("refresh_interval", v)} - keyboardType="numeric" - /> - - - {/* Section: Display */} - Display Options - - - - Font Size - onChange("font_size", v)} - keyboardType="numeric" - /> - - - Text Color (hex) - onChange("text_color", v)} - autoCapitalize="none" - /> - - - - {/* Section: Advanced */} - Advanced - - {/* Take Path */} - - Value Path (optional) - - Extract a nested value from the JSON response.{"\n"} - Example: $out.data.temperature or{" "} - $out.items[0].value - - onChange("take", v)} - autoCapitalize="none" - /> - - - {/* Additional Headers */} - - Additional Headers (optional) - One header per line: Key: Value - onChange("headers", v)} - multiline - autoCapitalize="none" - /> - - - {/* Save Button */} - - {saving ? ( - - ) : ( - {isEdit ? "Save Changes" : "Add Data Source"} - )} + + {saving ? : {isEdit ? "Save Changes" : "Add Widget"}} - @@ -254,29 +949,56 @@ function FormCView({ form, onChange, onSave, onCancel, saving, isEdit }: FormCVi // ─── 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 }: CardItemProps) { +function CardItem({ card, onEdit, onDelete, onDuplicate }: CardItemProps) { return ( - {card.name} - - {card.config.url} - - - Refresh: {card.config.refresh_interval}s - {card.config.take ? ` · Path: ${card.config.take}` : ""} - + + {CARD_TYPE_ICONS[card.type]} + {card.name} + + {cardSubtitle(card)} + {cardMeta(card) ? {cardMeta(card)} : null} Edit + + Duplicate + Delete @@ -287,173 +1009,127 @@ function CardItem({ card, onEdit, onDelete }: CardItemProps) { // ─── Main Page ──────────────────────────────────────────────────────────────── -type CView = "list" | "form"; +type PageView = "list" | "form"; export function DataCardsPage() { const { navigate } = useRouter(); - const [CView, setCView] = useState("list"); + 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(); - }, []); + useEffect(() => { loadCards(); }, []); const loadCards = () => { setLoading(true); fetch(`${BASE_URL}/pull_data_cards`) .then((r) => r.json()) - .then((data) => { - setCards(data.data_cards ?? []); - }) - .catch((err) => { - console.error("Error loading data cards:", err); - Alert.alert("Error", "Failed to load data cards."); - }) + .then((data) => setCards(data.data_cards ?? [])) + .catch(() => Alert.alert("Error", "Failed to load widgets.")) .finally(() => setLoading(false)); }; - const handleAdd = () => { - setEditingId(null); - setForm(EMPTY_FORM); - setCView("form"); - }; + const handleAdd = () => { setEditingId(null); setForm(EMPTY_FORM); setPageView("form"); }; + const handleEdit = (card: DataCard) => { setEditingId(card.id); setForm(cardToForm(card)); setPageView("form"); }; - const handleEdit = (card: DataCard) => { - setEditingId(card.id); - setForm(cardToForm(card)); - setCView("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 Data Source", - `Remove "${card.name}"? This cannot be undone.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: () => { - fetch(`${BASE_URL}/push_delete_data_card`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: card.id }), - }) - .then((r) => r.json()) - .then((data) => { - if (data.status !== "success") throw new Error(data.message); - setCards((prev) => prev.filter((c) => c.id !== card.id)); - }) - .catch((err) => { - console.error("Error deleting card:", err); - Alert.alert("Error", "Failed to delete data source."); - }); - }, - }, - ], - ); + 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 })); - }; + 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 for this data source."); - return; - } - if (!form.url.trim()) { - Alert.alert("Validation", "Please enter a URL."); - return; - } + 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 ?? Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + 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 }), - }) + fetch(`${BASE_URL}/push_data_card`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ card }) }) .then((r) => r.json()) .then((data) => { if (data.status !== "success") throw new Error(data.message); - if (editingId) { - setCards((prev) => prev.map((c) => (c.id === editingId ? card : c))); - } else { - setCards((prev) => [...prev, card]); - } - setCView("list"); - }) - .catch((err) => { - console.error("Error saving card:", err); - Alert.alert("Error", "Failed to save data source."); + 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)); }; - // ── Form CView - if (CView === "form") { + if (pageView === "form") { + const otherLayouts = cards + .filter((c) => c.id !== editingId && c.layout) + .map((c) => ({ name: c.name, layout: c.layout! })); return ( - setCView("list")} + onCancel={() => setPageView("list")} saving={saving} isEdit={editingId !== null} + otherLayouts={otherLayouts} /> ); } - // ── List CView return ( - {/* Header */} navigate("home")} style={styles.backBtn} activeOpacity={0.7}> ← Back Data Cards - - - Live JSON data sources shown on the TV display. - - + Widgets shown on the TV display. {loading ? ( ) : ( {cards.length === 0 && ( - 📭 - No data sources yet - - Add a custom JSON source and it will appear on the TV. - + 📭 + No widgets yet + Add your first widget – JSON feed, text, clock, or slideshow. )} - {cards.map((card) => ( - handleEdit(card)} - onDelete={() => handleDelete(card)} - /> + handleEdit(card)} onDelete={() => handleDelete(card)} onDuplicate={() => handleDuplicate(card)} /> ))} - - + Add Data Source + + Add Widget - )} @@ -464,200 +1140,52 @@ export function DataCardsPage() { // ─── Styles ─────────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#f9f9f9", - }, - header: { - paddingTop: 16, - paddingHorizontal: 24, - paddingBottom: 4, - gap: 4, - }, - backBtn: { - alignSelf: "flex-start", - paddingVertical: 4, - }, - backBtnText: { - fontSize: 14, - color: "#007AFF", - fontWeight: "500", - }, - title: { - fontSize: 26, - fontWeight: "700", - color: "#111", - marginTop: 4, - }, - subtitle: { - fontSize: 14, - color: "#888", - paddingHorizontal: 24, - marginBottom: 12, - }, - scrollArea: { - flex: 1, - }, - scrollContent: { - paddingHorizontal: 24, - gap: 12, - paddingTop: 4, - }, - // ── Empty state - emptyState: { - alignItems: "center", - paddingVertical: 48, - gap: 8, - }, - emptyIcon: { - fontSize: 40, - }, - emptyTitle: { - fontSize: 17, - fontWeight: "600", - color: "#444", - }, - emptyDesc: { - fontSize: 14, - color: "#888", - textAlign: "center", - maxWidth: 260, - }, - // ── Card item - cardItem: { - backgroundColor: "#fff", - borderRadius: 14, - borderWidth: 1, - borderColor: "#e8e8e8", - padding: 16, - shadowColor: "#000", - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 3, - elevation: 1, - gap: 10, - }, - cardItemBody: { - gap: 3, - }, - cardItemName: { - fontSize: 16, - fontWeight: "600", - color: "#111", - }, - cardItemUrl: { - fontSize: 12, - color: "#888", - fontFamily: "monospace", - }, - cardItemMeta: { - fontSize: 12, - color: "#aaa", - }, - cardItemActions: { - flexDirection: "row", - gap: 8, - }, - editBtn: { - flex: 1, - paddingVertical: 8, - borderRadius: 8, - backgroundColor: "#f0f0f0", - alignItems: "center", - }, - editBtnText: { - fontSize: 14, - fontWeight: "500", - color: "#333", - }, - deleteBtn: { - flex: 1, - paddingVertical: 8, - borderRadius: 8, - backgroundColor: "#fff0f0", - alignItems: "center", - }, - deleteBtnText: { - fontSize: 14, - fontWeight: "500", - color: "#d00", - }, - // ── Add button - addBtn: { - backgroundColor: "#111", - borderRadius: 14, - paddingVertical: 14, - alignItems: "center", - marginTop: 4, - }, - addBtnText: { - fontSize: 16, - fontWeight: "600", - color: "#fff", - }, - // ── Form - sectionLabel: { - fontSize: 12, - fontWeight: "600", - color: "#888", - textTransform: "uppercase", - letterSpacing: 0.8, - marginTop: 8, - marginBottom: -4, - }, - field: { - gap: 6, - }, - label: { - fontSize: 14, - fontWeight: "500", - color: "#333", - }, - hint: { - fontSize: 12, - color: "#999", - lineHeight: 17, - }, - code: { - fontFamily: "monospace", - fontSize: 12, - color: "#555", - }, - input: { - backgroundColor: "#fff", - borderRadius: 10, - borderWidth: 1, - borderColor: "#ddd", - paddingHorizontal: 14, - paddingVertical: 10, - fontSize: 15, - color: "#111", - }, - multilineInput: { - minHeight: 80, - textAlignVertical: "top", - paddingTop: 10, - }, - row: { - flexDirection: "row", - gap: 12, - }, - flex1: { - flex: 1, - }, - saveBtn: { - backgroundColor: "#111", - borderRadius: 14, - paddingVertical: 14, - alignItems: "center", - marginTop: 8, - }, - saveBtnDisabled: { - opacity: 0.6, - }, - saveBtnText: { - fontSize: 16, - fontWeight: "600", - color: "#fff", - }, + 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 }, }); diff --git a/mobile/src/pages/image.tsx b/mobile/src/pages/image.tsx index c1e1081..a037edd 100644 --- a/mobile/src/pages/image.tsx +++ b/mobile/src/pages/image.tsx @@ -1,6 +1,7 @@ import * as ImagePicker from "expo-image-picker"; import { useState } from "react"; -import { ActivityIndicator, Button, Image, StyleSheet, Text, TextInput, View } from "react-native"; +import { ActivityIndicator, Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native"; +import { colors, shared } from "../styles"; const BASE_URL = "https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329"; @@ -8,27 +9,12 @@ const BASE_URL = export function ImagePage() { const [caption, setCaption] = useState(""); const [uploading, setUploading] = useState(false); + const [dismissing, setDismissing] = useState(false); const [previewUri, setPreviewUri] = useState(null); - const handleTakePhoto = async () => { - const { granted } = await ImagePicker.requestCameraPermissionsAsync(); - if (!granted) { - alert("Camera permission is required to take photos."); - return; - } - - const result = await ImagePicker.launchCameraAsync({ - mediaTypes: "images", - quality: 1, - base64: true, - }); - - if (result.canceled) return; - - const asset = result.assets[0]; + const uploadAsset = async (asset: ImagePicker.ImagePickerAsset) => { setPreviewUri(asset.uri); setUploading(true); - try { const res = await fetch(`${BASE_URL}/push_image`, { method: "POST", @@ -45,7 +31,42 @@ export function ImagePage() { } }; + const handleTakePhoto = async () => { + const { granted } = await ImagePicker.requestCameraPermissionsAsync(); + if (!granted) { + alert("Camera permission is required to take photos."); + return; + } + + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: "images", + quality: 1, + base64: true, + }); + + if (result.canceled) return; + await uploadAsset(result.assets[0]); + }; + + const handlePickFromGallery = async () => { + const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!granted) { + alert("Media library permission is required to pick photos."); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: "images", + quality: 1, + base64: true, + }); + + if (result.canceled) return; + await uploadAsset(result.assets[0]); + }; + const handleDismiss = () => { + setDismissing(true); fetch(`${BASE_URL}/push_dismiss_image`, { method: "POST" }) .then((r) => r.json()) .then((data) => { @@ -54,19 +75,21 @@ export function ImagePage() { .catch((error) => { console.error("Error dismissing image:", error); alert("Error dismissing image."); - }); + }) + .finally(() => setDismissing(false)); }; return ( - - Image Popup - Show a photo on the TV with an optional caption. + + Image Popup + Show a photo on the TV with an optional caption. - - Caption (optional) + + Caption (optional) @@ -77,69 +100,50 @@ export function ImagePage() { )} {uploading ? ( - + ) : ( - - - - - ) : ( -
- {/* Text popup modal */} - {textState.showing && ( -
-
-

- {textState.title} -

-
-
- )} - - {/* Image popup modal */} - {imagePopup.showing && imagePopup.image_url && ( -
-
- {imagePopup.caption && ( -

- {imagePopup.caption} -

- )} - TV display -
-
- )} - - {/* Background idle state */} - {!textState.showing && !imagePopup.showing && dataCards.length === 0 && ( -
-

- Waiting for content - . - . - . -

-
- )} - - {/* Data cards — persistent widgets at the bottom */} - {dataCards.length > 0 && ( -
- {dataCards.map((card) => ( - - ))} -
- )} + {isIdle && ( +
+

+ Waiting for content + . + . + . +

)} - + + {resolvedCards.length > 0 && ( +
+ {resolvedCards.map(({ card, resolvedLayout }) => ( +
+ +
+ ))} +
+ )} +
); } diff --git a/tv/src/components/DataCardWidget.tsx b/tv/src/components/DataCardWidget.tsx new file mode 100644 index 0000000..619c115 --- /dev/null +++ b/tv/src/components/DataCardWidget.tsx @@ -0,0 +1,301 @@ +import { useEffect, useState } from "react"; +import type { + CardLayout, + ClockCard, + CustomJsonCard, + DataCard, + ImageRotatorCard, + StaticTextCard, +} from "../types"; +import { evaluatePath } from "../utils/evaluatePath"; + +// ─── Shared card shell ──────────────────────────────────────────────────────── + +function CardShell({ name, children, footer }: { name: string; children: React.ReactNode; footer?: React.ReactNode }) { + return ( +
+ + {name} + +
{children}
+ {footer &&
{footer}
} +
+ ); +} + +// ─── custom_json widget ─────────────────────────────────────────────────────── + +function CustomJsonWidget({ card, layout }: { card: CustomJsonCard; layout?: CardLayout }) { + const [value, setValue] = useState("…"); + const [lastUpdated, setLastUpdated] = useState(null); + const [error, setError] = useState(null); + const [now, setNow] = useState(() => new Date()); + const [pulling, setPulling] = useState(false); + const [dots, setDots] = useState(0); + const [responseMs, setResponseMs] = useState(null); + + useEffect(() => { + const ticker = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(ticker); + }, []); + + useEffect(() => { + if (!pulling) return; + const dotsTimer = setInterval(() => setDots((d) => (d + 1) % 4), 400); + return () => clearInterval(dotsTimer); + }, [pulling]); + + useEffect(() => { + const fetchData = () => { + setPulling(true); + setDots(0); + const start = performance.now(); + const headers: Record = { + Accept: "application/json", + ...(card.config.additional_headers ?? {}), + }; + fetch(card.config.url, { headers }) + .then((r) => r.json()) + .then((data) => { + const result = card.config.take ? evaluatePath(data, card.config.take) : data; + const display = + result === null || result === undefined + ? "(null)" + : typeof result === "object" + ? JSON.stringify(result, null, 2) + : String(result); + setValue(display); + setLastUpdated(new Date()); + setResponseMs(Math.round(performance.now() - start)); + setError(null); + setPulling(false); + }) + .catch((err) => { + setResponseMs(Math.round(performance.now() - start)); + setError(String(err)); + setPulling(false); + }); + }; + + fetchData(); + const ms = Math.max(5000, (card.config.refresh_interval ?? 60) * 1000); + const interval = setInterval(fetchData, ms); + return () => clearInterval(interval); + }, [card.id, card.config.url, card.config.refresh_interval, card.config.take]); + + const refreshMs = Math.max(5000, (card.config.refresh_interval ?? 60) * 1000); + const secAgo = lastUpdated ? Math.floor((now.getTime() - lastUpdated.getTime()) / 1000) : null; + const nextIn = lastUpdated + ? Math.max(0, Math.ceil((lastUpdated.getTime() + refreshMs - now.getTime()) / 1000)) + : null; + + const baseFontSize = card.config.display_options?.font_size ?? 16; + const textColor = card.config.display_options?.text_color ?? "#ffffff"; + const colSpan = layout?.col_span ?? 1; + const rowSpan = layout?.row_span ?? 1; + const fontSize = Math.round(baseFontSize * Math.sqrt(colSpan * rowSpan)); + const centered = colSpan > 1 || rowSpan > 1; + + return ( + pulling{"." .repeat(dots)} + ) : secAgo !== null && nextIn !== null ? ( + + {secAgo}s ago · next in {nextIn}s{responseMs !== null ? ` · ${responseMs}ms` : ""} + + ) : undefined + } + > + {error ? ( + ⚠ {error} + ) : ( +
+ + {value} + +
+ )} +
+ ); +} + +// ─── static_text widget ─────────────────────────────────────────────────────── + +function StaticTextWidget({ card, layout }: { card: StaticTextCard; layout?: CardLayout }) { + const baseFontSize = card.config.font_size ?? 16; + const textColor = card.config.text_color ?? "#ffffff"; + const colSpan = layout?.col_span ?? 1; + const rowSpan = layout?.row_span ?? 1; + const fontSize = Math.round(baseFontSize * Math.sqrt(colSpan * rowSpan)); + const centered = colSpan > 1 || rowSpan > 1; + + return ( + +
+ + {card.config.text} + +
+
+ ); +} + +// ─── clock widget ───────────────────────────────────────────────────────────── + +function formatDuration(totalSeconds: number): string { + if (totalSeconds <= 0) return "00:00:00"; + const d = Math.floor(totalSeconds / 86400); + const h = Math.floor((totalSeconds % 86400) / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + const timePart = [String(h).padStart(2, "0"), String(m).padStart(2, "0"), String(s).padStart(2, "0")].join(":"); + return d > 0 ? `${d}d ${timePart}` : timePart; +} + +function ClockWidget({ card, layout }: { card: ClockCard; layout?: CardLayout }) { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const ticker = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(ticker); + }, []); + + const baseFontSize = card.config.font_size ?? 48; + const textColor = card.config.text_color ?? "#ffffff"; + const colSpan = layout?.col_span ?? 1; + const rowSpan = layout?.row_span ?? 1; + const fontSize = Math.round(baseFontSize * Math.sqrt(colSpan * rowSpan)); + const centered = colSpan > 1 || rowSpan > 1; + + let display = ""; + let subtitle = ""; + + if (card.config.mode === "time") { + const tz = card.config.timezone; + const showSec = card.config.show_seconds !== false; + try { + display = new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: showSec ? "2-digit" : undefined, + hour12: false, + timeZone: tz || undefined, + }).format(now); + } catch { + display = now.toLocaleTimeString(); + } + if (tz) subtitle = tz.replace("_", " "); + } else { + // timer / countdown + const target = card.config.target_iso ? new Date(card.config.target_iso) : null; + if (!target || isNaN(target.getTime())) { + display = "invalid target"; + } else { + const diff = Math.max(0, Math.floor((target.getTime() - now.getTime()) / 1000)); + if (diff === 0) { + display = "00:00"; + subtitle = "Time's up!"; + } else { + display = formatDuration(diff); + subtitle = `until ${new Intl.DateTimeFormat("en-GB", { + dateStyle: "medium", + timeStyle: "short", + timeZone: card.config.timezone || undefined, + }).format(target)}`; + } + } + } + + return ( + +
+ + {display} + + {subtitle && ( + {subtitle} + )} +
+
+ ); +} + +// ─── image_rotator widget ───────────────────────────────────────────────────── + +function ImageRotatorWidget({ card }: { card: ImageRotatorCard }) { + const images = card.config.images ?? []; + const [index, setIndex] = useState(0); + const [fade, setFade] = useState(true); + + useEffect(() => { + if (images.length <= 1) return; + const ms = Math.max(2000, (card.config.interval ?? 10) * 1000); + const timer = setInterval(() => { + setFade(false); + setTimeout(() => { + setIndex((i) => (i + 1) % images.length); + setFade(true); + }, 400); + }, ms); + return () => clearInterval(timer); + }, [images.length, card.config.interval]); + + if (images.length === 0) { + return ( + + No images configured. + + ); + } + + const fit = card.config.fit === "contain" ? "contain" : "cover"; + + return ( +
+ + {/* Name overlay */} +
+ {card.name} + {images.length > 1 && ( + + {index + 1}/{images.length} + + )} +
+
+ ); +} + +// ─── Dispatcher ─────────────────────────────────────────────────────────────── + +export function DataCardWidget({ card, layout }: { card: DataCard; layout?: CardLayout }) { + if (card.type === "static_text") return ; + if (card.type === "clock") return ; + if (card.type === "image_rotator") return ; + // default: custom_json + return ; +} diff --git a/tv/src/components/ImagePopup.tsx b/tv/src/components/ImagePopup.tsx new file mode 100644 index 0000000..b0c1517 --- /dev/null +++ b/tv/src/components/ImagePopup.tsx @@ -0,0 +1,23 @@ +import { type ImagePopupState } from "../types"; + +export function ImagePopup({ state }: { state: ImagePopupState }) { + if (!state.showing || !state.image_url) return null; + + return ( +
+
+ {state.caption && ( +

+ {state.caption} +

+ )} + TV display +
+
+ ); +} diff --git a/tv/src/components/NotFullscreen.tsx b/tv/src/components/NotFullscreen.tsx new file mode 100644 index 0000000..6318b3d --- /dev/null +++ b/tv/src/components/NotFullscreen.tsx @@ -0,0 +1,51 @@ +export function NotFullscreen() { + return ( +
+
+
+ + + +
+

+ TV View +

+

+ Enter fullscreen mode to start displaying content. +

+
+ + +
+ ); +} diff --git a/tv/src/components/TextPopup.tsx b/tv/src/components/TextPopup.tsx new file mode 100644 index 0000000..0a5737f --- /dev/null +++ b/tv/src/components/TextPopup.tsx @@ -0,0 +1,15 @@ +import { type TextState } from "../types"; + +export function TextPopup({ state }: { state: TextState }) { + if (!state.showing) return null; + + return ( +
+
+

+ {state.title} +

+
+
+ ); +} diff --git a/tv/src/types.ts b/tv/src/types.ts new file mode 100644 index 0000000..2bc7352 --- /dev/null +++ b/tv/src/types.ts @@ -0,0 +1,106 @@ +export interface TextState { + showing: boolean; + title: string; +} + +export interface SettingsState { + /** Portrait background image URL (height > width). Empty string = no background. */ + background_url: string; +} + +export interface ImagePopupState { + showing: boolean; + image_url: string; + caption: string; +} + +/** Grid layout: 1-based col/row, 4-column × 4-row grid */ +export interface CardLayout { + grid_col: number; // 1–4 + grid_row: number; // 1–4 + col_span: number; // 1–4 + row_span: number; // 1–4 +} + +// ─── custom_json ─────────────────────────────────────────────────────────────── + +export interface DisplayOptions { + font_size: number; + text_color: string; +} + +export interface DataCardConfig { + url: string; + refresh_interval: number; + display_options: DisplayOptions; + take?: string; + additional_headers?: Record; +} + +export interface CustomJsonCard { + id: string; + type: "custom_json"; + name: string; + config: DataCardConfig; + layout?: CardLayout; +} + +// ─── static_text ────────────────────────────────────────────────────────────── + +export interface StaticTextConfig { + text: string; + font_size: number; + text_color: string; +} + +export interface StaticTextCard { + id: string; + type: "static_text"; + name: string; + config: StaticTextConfig; + layout?: CardLayout; +} + +// ─── clock ──────────────────────────────────────────────────────────────────── + +export interface ClockConfig { + /** "time" = live clock, "timer" = countdown to target_iso */ + mode: "time" | "timer"; + /** IANA timezone string, e.g. "Europe/Berlin" */ + timezone?: string; + /** ISO-8601 target datetime for mode="timer", e.g. "2026-12-31T23:59:59" */ + target_iso?: string; + font_size: number; + text_color: string; + show_seconds?: boolean; +} + +export interface ClockCard { + id: string; + type: "clock"; + name: string; + config: ClockConfig; + layout?: CardLayout; +} + +// ─── image_rotator ──────────────────────────────────────────────────────────── + +export interface ImageRotatorConfig { + /** List of public image URLs to cycle through */ + images: string[]; + /** Seconds between transitions */ + interval: number; + fit: "cover" | "contain"; +} + +export interface ImageRotatorCard { + id: string; + type: "image_rotator"; + name: string; + config: ImageRotatorConfig; + layout?: CardLayout; +} + +// ─── Union ──────────────────────────────────────────────────────────────────── + +export type DataCard = CustomJsonCard | StaticTextCard | ClockCard | ImageRotatorCard; diff --git a/tv/src/utils/evaluatePath.ts b/tv/src/utils/evaluatePath.ts new file mode 100644 index 0000000..c96b2a1 --- /dev/null +++ b/tv/src/utils/evaluatePath.ts @@ -0,0 +1,21 @@ +export function evaluatePath(data: unknown, path: string): unknown { + if (!path || !path.startsWith("$out")) return data; + const expr = path.slice(4); // strip "$out" + const tokens: string[] = []; + const regex = /\.([a-zA-Z_][a-zA-Z0-9_]*)|\[(\d+)\]/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(expr)) !== null) { + tokens.push(match[1] ?? match[2]); + } + let current: unknown = data; + for (const token of tokens) { + if (current == null) return null; + const idx = parseInt(token, 10); + if (!isNaN(idx) && Array.isArray(current)) { + current = current[idx]; + } else { + current = (current as Record)[token]; + } + } + return current; +}