MAJOR UPDATE ALERT!!!!!
This commit is contained in:
76
.github/copilot-instructions.md
vendored
76
.github/copilot-instructions.md
vendored
@@ -5,45 +5,77 @@
|
|||||||
Three-component system for remote-controlling a TV display from a mobile phone:
|
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.
|
- **`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.
|
- **`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 deployed on `shsf-api.reversed.dev`. Acts as state store; persists to `/app/state.json` on the container.
|
- **`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
|
## 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}
|
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`):
|
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.
|
||||||
- `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 }`
|
|
||||||
|
|
||||||
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
|
## Adding a New Content Type
|
||||||
|
|
||||||
1. Add state shape to `DEFAULT_STATE` in `functions/control/main.py`
|
1. Add state to `DEFAULT_STATE` in `functions/control/main.py`; add `push_*`/`pull_*` handlers in `main(args)`
|
||||||
2. Add `push_*` / `pull_*` handlers in `main(args)` in the same file
|
2. Add TypeScript types to `tv/src/types.ts`
|
||||||
3. Add a TypeScript interface and `useState` in `tv/src/App.tsx`; render the popup in the fullscreen branch
|
3. Add `useState` and rendering in `tv/src/App.tsx` (fullscreen branch)
|
||||||
4. Add a page in `mobile/src/pages/`
|
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
|
## 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"`.
|
- **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 and pages. `BottomNav` imports `TABS` directly from `App.tsx`.
|
- `TABS` in `mobile/App.tsx` is the single source of truth for routes/pages. `BottomNav` imports it directly.
|
||||||
- Tabs with `hideInNav: true` are reachable only via `navigate(route)`, not from the bottom bar.
|
- `hideInNav: true` tabs are reachable only via `navigate(route)`.
|
||||||
- Styling uses React Native `StyleSheet` throughout — no CSS or Tailwind.
|
- Styling uses React Native `StyleSheet` — no CSS or Tailwind.
|
||||||
|
|
||||||
## TV App Conventions
|
## TV App Conventions
|
||||||
|
|
||||||
- Uses **Tailwind CSS v4** via `@tailwindcss/vite` (no `tailwind.config.js` — config lives in `vite.config.ts`).
|
- **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"`).
|
- Polling only starts when fullscreen (`screenStatus === "fullscreen"`).
|
||||||
- State shape mirrors `DEFAULT_STATE` from the Python function exactly.
|
- 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
|
## Dev Workflows
|
||||||
|
|
||||||
@@ -57,4 +89,4 @@ cd mobile && pnpm android # Android emulator
|
|||||||
cd mobile && pnpm ios # iOS simulator
|
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.
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ DEFAULT_STATE = {
|
|||||||
"image_url": "",
|
"image_url": "",
|
||||||
"caption": ""
|
"caption": ""
|
||||||
},
|
},
|
||||||
"data_cards": []
|
"data_cards": [],
|
||||||
|
"settings": {
|
||||||
|
"background_url": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MINIO_CLIENT = Minio(
|
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]
|
current["data_cards"] = [c for c in current.get("data_cards", []) if c["id"] != card_id]
|
||||||
_write_state(current)
|
_write_state(current)
|
||||||
return {"status": "success"}
|
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":
|
elif route == "push_upload_images":
|
||||||
# Upload multiple images to MinIO; returns their public URLs (no state change)
|
# Upload multiple images to MinIO; returns their public URLs (no state change)
|
||||||
images = body.get("images", [])
|
images = body.get("images", [])
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { BottomNav } from "./src/components/BottomNav";
|
import { BottomNav } from "./src/components/BottomNav";
|
||||||
|
import { colors } from "./src/styles";
|
||||||
import { NotFoundPage } from "./src/pages/NotFound";
|
import { NotFoundPage } from "./src/pages/NotFound";
|
||||||
import { DataCardsPage } from "./src/pages/datacards";
|
import { DataCardsPage } from "./src/pages/datacards";
|
||||||
import { ImagePage } from "./src/pages/image";
|
import { ImagePage } from "./src/pages/image";
|
||||||
import { IndexPage } from "./src/pages/index";
|
import { IndexPage } from "./src/pages/index";
|
||||||
|
import { SettingsPage } from "./src/pages/settings";
|
||||||
import { TextPage } from "./src/pages/text";
|
import { TextPage } from "./src/pages/text";
|
||||||
import { Route, RouterProvider, useRouter } from "./src/router";
|
import { Route, RouterProvider, useRouter } from "./src/router";
|
||||||
interface Tab {
|
interface Tab {
|
||||||
@@ -17,7 +19,13 @@ const TABS: Tab[] = [
|
|||||||
{ label: "Home", route: "home", page: IndexPage },
|
{ label: "Home", route: "home", page: IndexPage },
|
||||||
{ label: "Text", route: "text", page: TextPage, hideInNav: true },
|
{ label: "Text", route: "text", page: TextPage, hideInNav: true },
|
||||||
{ label: "Image", route: "image", page: ImagePage, 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 };
|
export { TABS, type Tab };
|
||||||
|
|
||||||
@@ -41,8 +49,10 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<RouterProvider>
|
<RouterProvider>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="light" />
|
||||||
<Screen />
|
<View style={{ flex: 1, marginTop: 12 }}>
|
||||||
|
<Screen />
|
||||||
|
</View>
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
</View>
|
</View>
|
||||||
</RouterProvider>
|
</RouterProvider>
|
||||||
@@ -52,6 +62,6 @@ export default function App() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#fff",
|
backgroundColor: colors.bg,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-native-community/datetimepicker": "^8.6.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-image-picker": "^55.0.10",
|
"expo-image-picker": "^55.0.10",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
|||||||
28
mobile/pnpm-lock.yaml
generated
28
mobile/pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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:
|
expo:
|
||||||
specifier: ~54.0.33
|
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)
|
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':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
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':
|
'@react-native/assets-registry@0.81.5':
|
||||||
resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==}
|
resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==}
|
||||||
engines: {node: '>= 20.19.4'}
|
engines: {node: '>= 20.19.4'}
|
||||||
@@ -1630,28 +1646,24 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.31.1:
|
lightningcss-linux-arm64-musl@1.31.1:
|
||||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.31.1:
|
lightningcss-linux-x64-gnu@1.31.1:
|
||||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.31.1:
|
lightningcss-linux-x64-musl@1.31.1:
|
||||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.31.1:
|
lightningcss-win32-arm64-msvc@1.31.1:
|
||||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||||
@@ -3546,6 +3558,14 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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/assets-registry@0.81.5': {}
|
||||||
|
|
||||||
'@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)':
|
'@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)':
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
import { useRouter } from "../router";
|
import { useRouter } from "../router";
|
||||||
import { TABS } from "../../App";
|
import { TABS } from "../../App";
|
||||||
|
import { colors } from "../styles";
|
||||||
|
|
||||||
|
|
||||||
export function BottomNav() {
|
export function BottomNav() {
|
||||||
@@ -32,9 +33,9 @@ const styles = StyleSheet.create({
|
|||||||
bar: {
|
bar: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
height: 74,
|
height: 74,
|
||||||
borderTopWidth:1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: "#e5e5e5",
|
borderTopColor: colors.border,
|
||||||
backgroundColor: "#fff",
|
backgroundColor: colors.surface,
|
||||||
},
|
},
|
||||||
tab: {
|
tab: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -43,11 +44,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: "#aaa",
|
color: colors.textMuted,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
labelActive: {
|
labelActive: {
|
||||||
color: "#1a1a1a",
|
color: colors.textPrimary,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
},
|
},
|
||||||
indicator: {
|
indicator: {
|
||||||
@@ -56,6 +57,6 @@ const styles = StyleSheet.create({
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 3,
|
height: 3,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
backgroundColor: "#1a1a1a",
|
backgroundColor: colors.accent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { StyleSheet, Text, View } from "react-native";
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
import { colors } from "../styles";
|
||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
@@ -15,12 +16,12 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "#f9f9f9",
|
backgroundColor: colors.bg,
|
||||||
},
|
},
|
||||||
code: {
|
code: {
|
||||||
fontSize: 72,
|
fontSize: 72,
|
||||||
fontWeight: "800",
|
fontWeight: "800",
|
||||||
color: "#ccc",
|
color: colors.border,
|
||||||
lineHeight: 80,
|
lineHeight: 80,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
@@ -28,9 +29,11 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
|
color: colors.textPrimary,
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: "#999",
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import { useState } from "react";
|
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 =
|
const BASE_URL =
|
||||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
||||||
@@ -8,27 +9,12 @@ const BASE_URL =
|
|||||||
export function ImagePage() {
|
export function ImagePage() {
|
||||||
const [caption, setCaption] = useState("");
|
const [caption, setCaption] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dismissing, setDismissing] = useState(false);
|
||||||
const [previewUri, setPreviewUri] = useState<string | null>(null);
|
const [previewUri, setPreviewUri] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleTakePhoto = async () => {
|
const uploadAsset = async (asset: ImagePicker.ImagePickerAsset) => {
|
||||||
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];
|
|
||||||
setPreviewUri(asset.uri);
|
setPreviewUri(asset.uri);
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BASE_URL}/push_image`, {
|
const res = await fetch(`${BASE_URL}/push_image`, {
|
||||||
method: "POST",
|
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 = () => {
|
const handleDismiss = () => {
|
||||||
|
setDismissing(true);
|
||||||
fetch(`${BASE_URL}/push_dismiss_image`, { method: "POST" })
|
fetch(`${BASE_URL}/push_dismiss_image`, { method: "POST" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -54,19 +75,21 @@ export function ImagePage() {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error dismissing image:", error);
|
console.error("Error dismissing image:", error);
|
||||||
alert("Error dismissing image.");
|
alert("Error dismissing image.");
|
||||||
});
|
})
|
||||||
|
.finally(() => setDismissing(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={shared.screenPadded}>
|
||||||
<Text style={styles.title}>Image Popup</Text>
|
<Text style={shared.pageTitle}>Image Popup</Text>
|
||||||
<Text style={styles.subtitle}>Show a photo on the TV with an optional caption.</Text>
|
<Text style={shared.subtitle}>Show a photo on the TV with an optional caption.</Text>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={shared.field}>
|
||||||
<Text style={styles.label}>Caption (optional)</Text>
|
<Text style={shared.label}>Caption (optional)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={shared.input}
|
||||||
placeholder="Add a caption..."
|
placeholder="Add a caption..."
|
||||||
|
placeholderTextColor={colors.placeholderColor}
|
||||||
value={caption}
|
value={caption}
|
||||||
onChangeText={setCaption}
|
onChangeText={setCaption}
|
||||||
/>
|
/>
|
||||||
@@ -77,69 +100,50 @@ export function ImagePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<ActivityIndicator size="large" style={{ marginTop: 8 }} />
|
<ActivityIndicator size="large" color={colors.accent} style={{ marginTop: 8 }} />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.actions}>
|
<>
|
||||||
<View style={styles.actionBtn}>
|
<View style={shared.actionsRow}>
|
||||||
<Button title="Take Photo & Show" onPress={handleTakePhoto} />
|
<TouchableOpacity
|
||||||
|
style={[shared.btnPrimary, shared.actionFlex]}
|
||||||
|
onPress={handleTakePhoto}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={shared.btnPrimaryText}>Take Photo & Show</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[shared.btnPrimary, shared.actionFlex]}
|
||||||
|
onPress={handlePickFromGallery}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={shared.btnPrimaryText}>Pick from Gallery</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.actionBtn}>
|
<View style={shared.actionsRow}>
|
||||||
<Button title="Dismiss" onPress={handleDismiss} color="#e55" />
|
<TouchableOpacity
|
||||||
|
style={[shared.btnDanger, shared.actionFlex, dismissing && shared.btnDisabled]}
|
||||||
|
onPress={handleDismiss}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={dismissing}
|
||||||
|
>
|
||||||
|
{dismissing ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={shared.btnDangerText}>Dismiss</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 24,
|
|
||||||
backgroundColor: "#f9f9f9",
|
|
||||||
gap: 20,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: "700",
|
|
||||||
color: "#111",
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: "#888",
|
|
||||||
marginTop: -12,
|
|
||||||
},
|
|
||||||
field: {
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#555",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#ddd",
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
},
|
|
||||||
preview: {
|
preview: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 200,
|
height: 200,
|
||||||
borderRadius: 10,
|
borderRadius: 12,
|
||||||
resizeMode: "cover",
|
resizeMode: "cover",
|
||||||
},
|
},
|
||||||
actions: {
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 12,
|
|
||||||
marginTop: 4,
|
|
||||||
},
|
|
||||||
actionBtn: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
import { useRouter } from "../router";
|
import { useRouter } from "../router";
|
||||||
|
import { colors, shared } from "../styles";
|
||||||
|
|
||||||
export function IndexPage() {
|
export function IndexPage() {
|
||||||
const { navigate } = useRouter();
|
const { navigate } = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={shared.screenPadded}>
|
||||||
<Text style={styles.title}>TV Control</Text>
|
<Text style={shared.pageTitleLarge}>TV Control</Text>
|
||||||
<Text style={styles.subtitle}>Choose what to send to the TV.</Text>
|
<Text style={shared.subtitle}>Your TV, Your Control.</Text>
|
||||||
|
|
||||||
<View style={styles.cards}>
|
<View style={styles.cards}>
|
||||||
<TouchableOpacity style={styles.card} onPress={() => navigate("text")} activeOpacity={0.8}>
|
<TouchableOpacity style={shared.card} onPress={() => navigate("text")} activeOpacity={0.7}>
|
||||||
<Text style={styles.cardIcon}>💬</Text>
|
<Text style={shared.cardIcon}>💬</Text>
|
||||||
<Text style={styles.cardTitle}>Text Popup</Text>
|
<Text style={shared.cardTitle}>Text Popup</Text>
|
||||||
<Text style={styles.cardDesc}>Display a message on the TV screen.</Text>
|
<Text style={shared.cardDesc}>Display a message on the TV screen.</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.card} onPress={() => navigate("image")} activeOpacity={0.8}>
|
<TouchableOpacity style={shared.card} onPress={() => navigate("image")} activeOpacity={0.7}>
|
||||||
<Text style={styles.cardIcon}>📷</Text>
|
<Text style={shared.cardIcon}>📷</Text>
|
||||||
<Text style={styles.cardTitle}>Image Popup</Text>
|
<Text style={shared.cardTitle}>Image Popup</Text>
|
||||||
<Text style={styles.cardDesc}>Take a photo and show it on the TV.</Text>
|
<Text style={shared.cardDesc}>Take a photo and show it on the TV.</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.card} onPress={() => navigate("datacards")} activeOpacity={0.8}>
|
<TouchableOpacity style={shared.card} onPress={() => navigate("datacards")} activeOpacity={0.7}>
|
||||||
<Text style={styles.cardIcon}>📊</Text>
|
<Text style={shared.cardIcon}>📊</Text>
|
||||||
<Text style={styles.cardTitle}>Data Cards</Text>
|
<Text style={shared.cardTitle}>Data Cards</Text>
|
||||||
<Text style={styles.cardDesc}>Display live data from custom JSON sources on the TV.</Text>
|
<Text style={shared.cardDesc}>Display live data from custom JSON sources on the TV.</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -33,50 +34,9 @@ export function IndexPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 24,
|
|
||||||
backgroundColor: "#f9f9f9",
|
|
||||||
gap: 20,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 30,
|
|
||||||
fontWeight: "700",
|
|
||||||
color: "#111",
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 15,
|
|
||||||
color: "#888",
|
|
||||||
marginTop: -12,
|
|
||||||
},
|
|
||||||
cards: {
|
cards: {
|
||||||
gap: 16,
|
gap: 14,
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
card: {
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
borderRadius: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#e8e8e8",
|
|
||||||
padding: 20,
|
|
||||||
gap: 6,
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.06,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
cardIcon: {
|
|
||||||
fontSize: 28,
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#111",
|
|
||||||
},
|
|
||||||
cardDesc: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: "#888",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
286
mobile/src/pages/settings.tsx
Normal file
286
mobile/src/pages/settings.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
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";
|
||||||
|
|
||||||
|
// ─── Settings page ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const [currentBgUrl, setCurrentBgUrl] = useState<string>("");
|
||||||
|
const [pendingUri, setPendingUri] = useState<string | null>(null);
|
||||||
|
const [pendingBase64, setPendingBase64] = useState<string | null>(null);
|
||||||
|
const [pendingExt, setPendingExt] = useState<string>("jpg");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── Load current settings on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`${BASE_URL}/pull_full`, { method: "POST" })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
const bg = data.state?.settings?.background_url ?? "";
|
||||||
|
setCurrentBgUrl(bg);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Pick a portrait image from the gallery
|
||||||
|
const handlePickBackground = async () => {
|
||||||
|
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!granted) {
|
||||||
|
setStatus("Media library permission is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: "images",
|
||||||
|
quality: 0.85,
|
||||||
|
base64: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
|
||||||
|
// Enforce portrait orientation (height must exceed width)
|
||||||
|
if (asset.width >= asset.height) {
|
||||||
|
setStatus("Please pick a portrait image (height must be greater than width).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = (asset.uri.split(".").pop() ?? "jpg").toLowerCase();
|
||||||
|
setPendingUri(asset.uri);
|
||||||
|
setPendingBase64(asset.base64 ?? null);
|
||||||
|
setPendingExt(ext);
|
||||||
|
setStatus(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Upload pending image and save as background
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!pendingBase64) return;
|
||||||
|
setSaving(true);
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
// Upload to MinIO via push_upload_images
|
||||||
|
const uploadRes = await fetch(`${BASE_URL}/push_upload_images`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ images: [{ image_b64: pendingBase64, ext: pendingExt }] }),
|
||||||
|
});
|
||||||
|
const uploadData = await uploadRes.json();
|
||||||
|
if (uploadData.status !== "success" || !uploadData.urls?.[0]) {
|
||||||
|
throw new Error(uploadData.message ?? "Upload failed");
|
||||||
|
}
|
||||||
|
const url: string = uploadData.urls[0];
|
||||||
|
|
||||||
|
// Persist as background URL
|
||||||
|
const settingsRes = await fetch(`${BASE_URL}/push_settings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ background_url: url }),
|
||||||
|
});
|
||||||
|
const settingsData = await settingsRes.json();
|
||||||
|
if (settingsData.status !== "success") {
|
||||||
|
throw new Error(settingsData.message ?? "Save failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentBgUrl(url);
|
||||||
|
setPendingUri(null);
|
||||||
|
setPendingBase64(null);
|
||||||
|
setStatus("Background saved!");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Clear the TV background
|
||||||
|
const handleClear = async () => {
|
||||||
|
setClearing(true);
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/push_settings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ background_url: "" }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status !== "success") throw new Error(data.message ?? "Clear failed");
|
||||||
|
setCurrentBgUrl("");
|
||||||
|
setPendingUri(null);
|
||||||
|
setPendingBase64(null);
|
||||||
|
setStatus("Background cleared.");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewUri = pendingUri ?? (currentBgUrl || null);
|
||||||
|
const hasPending = !!pendingUri;
|
||||||
|
const hasCurrent = !!currentBgUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={shared.screen}
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Text style={shared.pageTitle}>Settings</Text>
|
||||||
|
<Text style={shared.subtitle}>Configure global TV display options.</Text>
|
||||||
|
|
||||||
|
{/* ── Background Image ── */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={shared.sectionLabel}>TV Background</Text>
|
||||||
|
<Text style={shared.hint}>
|
||||||
|
Displayed behind all content on the TV. Must be a portrait image (taller than wide).
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={colors.accent} style={{ marginTop: 12 }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Preview */}
|
||||||
|
{previewUri && (
|
||||||
|
<View style={styles.previewWrap}>
|
||||||
|
<Image source={{ uri: previewUri }} style={styles.preview} resizeMode="cover" />
|
||||||
|
{hasPending && (
|
||||||
|
<View style={styles.pendingBadge}>
|
||||||
|
<Text style={styles.pendingBadgeText}>Unsaved</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!previewUri && (
|
||||||
|
<View style={styles.emptyPreview}>
|
||||||
|
<Text style={styles.emptyPreviewText}>No background set</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View style={shared.actionsRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[shared.btnSecondary, shared.actionFlex]}
|
||||||
|
onPress={handlePickBackground}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={shared.btnSecondaryText}>
|
||||||
|
{hasCurrent || hasPending ? "Replace" : "Pick Image"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{hasPending && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[shared.btnPrimary, shared.actionFlex, saving && shared.btnDisabled]}
|
||||||
|
onPress={handleSave}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={shared.btnPrimaryText}>Save</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{(hasCurrent || hasPending) && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[shared.btnDanger, clearing && shared.btnDisabled]}
|
||||||
|
onPress={handleClear}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={clearing}
|
||||||
|
>
|
||||||
|
{clearing ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={shared.btnDangerText}>Clear Background</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<Text style={[shared.hint, styles.statusText]}>{status}</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 24,
|
||||||
|
gap: 20,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
previewWrap: {
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: "hidden",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
width: "100%",
|
||||||
|
aspectRatio: 9 / 16,
|
||||||
|
},
|
||||||
|
pendingBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
pendingBadgeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "700",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
emptyPreview: {
|
||||||
|
width: "100%",
|
||||||
|
aspectRatio: 9 / 16,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderStyle: "dashed",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
emptyPreviewText: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
marginTop: 4,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button, StyleSheet, Text, TextInput, View } from "react-native";
|
import { ActivityIndicator, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||||
|
import { colors, shared } from "../styles";
|
||||||
|
|
||||||
const BASE_URL =
|
const BASE_URL =
|
||||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
||||||
|
|
||||||
export function TextPage() {
|
export function TextPage() {
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [dismissing, setDismissing] = useState(false);
|
||||||
|
|
||||||
const handleShow = () => {
|
const handleShow = () => {
|
||||||
if (title.trim() === "") {
|
if (title.trim() === "") {
|
||||||
alert("Please enter some text before sending.");
|
alert("Please enter some text before sending.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSending(true);
|
||||||
fetch(`${BASE_URL}/push_text`, {
|
fetch(`${BASE_URL}/push_text`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -25,10 +28,12 @@ export function TextPage() {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error sending text:", error);
|
console.error("Error sending text:", error);
|
||||||
alert("Error sending text.");
|
alert("Error sending text.");
|
||||||
});
|
})
|
||||||
|
.finally(() => setSending(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDismiss = () => {
|
const handleDismiss = () => {
|
||||||
|
setDismissing(true);
|
||||||
fetch(`${BASE_URL}/push_dismiss_text`, { method: "POST" })
|
fetch(`${BASE_URL}/push_dismiss_text`, { method: "POST" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -37,81 +42,53 @@ export function TextPage() {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error dismissing text:", error);
|
console.error("Error dismissing text:", error);
|
||||||
alert("Error dismissing text.");
|
alert("Error dismissing text.");
|
||||||
});
|
})
|
||||||
|
.finally(() => setDismissing(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={shared.screenPadded}>
|
||||||
<Text style={styles.title}>Text Popup</Text>
|
<Text style={shared.pageTitle}>Text Popup</Text>
|
||||||
<Text style={styles.subtitle}>Display a text message on the TV.</Text>
|
<Text style={shared.subtitle}>Display a text message on the TV.</Text>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={shared.field}>
|
||||||
<Text style={styles.label}>Message</Text>
|
<Text style={shared.label}>Message</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={[shared.input, shared.inputMultiline]}
|
||||||
placeholder="Type something..."
|
placeholder="Type something..."
|
||||||
|
placeholderTextColor={colors.placeholderColor}
|
||||||
value={title}
|
value={title}
|
||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
multiline
|
multiline
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={shared.actionsRow}>
|
||||||
<View style={styles.actionBtn}>
|
<TouchableOpacity
|
||||||
<Button title="Show on TV" onPress={handleShow} />
|
style={[shared.btnPrimary, shared.actionFlex, sending && shared.btnDisabled]}
|
||||||
</View>
|
onPress={handleShow}
|
||||||
<View style={styles.actionBtn}>
|
activeOpacity={0.8}
|
||||||
<Button title="Dismiss" onPress={handleDismiss} color="#e55" />
|
disabled={sending}
|
||||||
</View>
|
>
|
||||||
|
{sending ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={shared.btnPrimaryText}>Show on TV</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[shared.btnDanger, shared.actionFlex, dismissing && shared.btnDisabled]}
|
||||||
|
onPress={handleDismiss}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={dismissing}
|
||||||
|
>
|
||||||
|
{dismissing ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={shared.btnDangerText}>Dismiss</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 24,
|
|
||||||
backgroundColor: "#f9f9f9",
|
|
||||||
gap: 20,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: "700",
|
|
||||||
color: "#111",
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: "#888",
|
|
||||||
marginTop: -12,
|
|
||||||
},
|
|
||||||
field: {
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: "600",
|
|
||||||
color: "#555",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "#ddd",
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
minHeight: 80,
|
|
||||||
textAlignVertical: "top",
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 12,
|
|
||||||
marginTop: 4,
|
|
||||||
},
|
|
||||||
actionBtn: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,19 +1,43 @@
|
|||||||
import React, { createContext, useContext, useState } from "react";
|
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import { BackHandler } from "react-native";
|
||||||
|
|
||||||
export type Route = "home" | "text" | "image" | "datacards";
|
export type Route = "home" | "text" | "image" | "datacards" | "settings";
|
||||||
|
|
||||||
interface RouterContextValue {
|
interface RouterContextValue {
|
||||||
route: Route;
|
route: Route;
|
||||||
navigate: (to: Route) => void;
|
navigate: (to: Route) => void;
|
||||||
|
goBack: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RouterContext = createContext<RouterContextValue | null>(null);
|
const RouterContext = createContext<RouterContextValue | null>(null);
|
||||||
|
|
||||||
export function RouterProvider({ children }: { children: React.ReactNode }) {
|
export function RouterProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [route, setRoute] = useState<Route>("home");
|
const [route, setRoute] = useState<Route>("home");
|
||||||
|
const history = useRef<Route[]>([]);
|
||||||
|
|
||||||
|
const navigate = useCallback((to: Route) => {
|
||||||
|
setRoute((prev) => {
|
||||||
|
history.current.push(prev);
|
||||||
|
return to;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goBack = useCallback((): boolean => {
|
||||||
|
const prev = history.current.pop();
|
||||||
|
if (prev !== undefined) {
|
||||||
|
setRoute(prev);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = BackHandler.addEventListener("hardwareBackPress", goBack);
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [goBack]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterContext.Provider value={{ route, navigate: setRoute }}>
|
<RouterContext.Provider value={{ route, navigate, goBack }}>
|
||||||
{children}
|
{children}
|
||||||
</RouterContext.Provider>
|
</RouterContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
203
mobile/src/styles.ts
Normal file
203
mobile/src/styles.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
// ─── Design Tokens ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const colors = {
|
||||||
|
bg: "#0d0d0d",
|
||||||
|
surface: "#1a1a1a",
|
||||||
|
surfaceElevated: "#222",
|
||||||
|
border: "#2e2e2e",
|
||||||
|
borderSubtle: "#232323",
|
||||||
|
textPrimary: "#f0f0f0",
|
||||||
|
textSecondary: "#888",
|
||||||
|
textMuted: "#505050",
|
||||||
|
placeholderColor: "#666",
|
||||||
|
accent: "#7c6fff",
|
||||||
|
accentDim: "#3a3570",
|
||||||
|
danger: "#ff453a",
|
||||||
|
dangerBg: "#2a1111",
|
||||||
|
dangerBorder: "#5a2020",
|
||||||
|
dangerText: "#ff6b63",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Shared Styles ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const shared = StyleSheet.create({
|
||||||
|
// ── Screens
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
},
|
||||||
|
screenPadded: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
padding: 24,
|
||||||
|
gap: 20,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Typography
|
||||||
|
pageTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.textPrimary,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
pageTitleLarge: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: colors.textPrimary,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: -12,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textMuted,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
},
|
||||||
|
sectionLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: colors.textMuted,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: -4,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
lineHeight: 17,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.accent,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Form
|
||||||
|
field: {
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 11,
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
inputMultiline: {
|
||||||
|
minHeight: 80,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
flex1: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Buttons
|
||||||
|
btnPrimary: {
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
btnPrimaryText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
btnDanger: {
|
||||||
|
backgroundColor: colors.dangerBg,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.dangerBorder,
|
||||||
|
},
|
||||||
|
btnDangerText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.dangerText,
|
||||||
|
},
|
||||||
|
btnSecondary: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
btnSecondaryText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
btnDisabled: {
|
||||||
|
opacity: 0.45,
|
||||||
|
},
|
||||||
|
actionsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
actionFlex: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Page header (with back button)
|
||||||
|
pageHeader: {
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingBottom: 4,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
backBtnText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.accent,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Cards
|
||||||
|
card: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
padding: 20,
|
||||||
|
gap: 6,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
cardIcon: {
|
||||||
|
fontSize: 28,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: colors.textPrimary,
|
||||||
|
},
|
||||||
|
cardDesc: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
338
tv/src/App.tsx
338
tv/src/App.tsx
@@ -1,149 +1,76 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { CardLayout, DataCard, ImagePopupState, SettingsState, TextState } from "./types";
|
||||||
|
import { DataCardWidget } from "./components/DataCardWidget";
|
||||||
|
import { TextPopup } from "./components/TextPopup";
|
||||||
|
import { ImagePopup } from "./components/ImagePopup";
|
||||||
|
import { NotFullscreen } from "./components/NotFullscreen";
|
||||||
|
|
||||||
interface TextState {
|
const GRID_COLS = 4;
|
||||||
showing: boolean;
|
const GRID_ROWS = 4;
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImagePopupState {
|
/** Deterministic hash of a string → non-negative integer */
|
||||||
showing: boolean;
|
function hashStr(s: string): number {
|
||||||
image_url: string;
|
let h = 0;
|
||||||
caption: string;
|
for (let i = 0; i < s.length; i++) {
|
||||||
}
|
h = Math.imul(31, h) + s.charCodeAt(i) | 0;
|
||||||
|
|
||||||
interface DisplayOptions {
|
|
||||||
font_size: number;
|
|
||||||
text_color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataCardConfig {
|
|
||||||
url: string;
|
|
||||||
refresh_interval: number;
|
|
||||||
display_options: DisplayOptions;
|
|
||||||
take?: string;
|
|
||||||
additional_headers?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataCard {
|
|
||||||
id: string;
|
|
||||||
type: "custom_json";
|
|
||||||
name: string;
|
|
||||||
config: DataCardConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Path evaluator for "$out.foo.bar[0].baz" ────────────────────────────────
|
|
||||||
|
|
||||||
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;
|
return Math.abs(h);
|
||||||
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<string, unknown>)[token];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Data Card Widget ─────────────────────────────────────────────────────────
|
/**
|
||||||
|
* Process cards in insertion order.
|
||||||
|
* If two cards share the same starting (grid_col, grid_row), the later one
|
||||||
|
* is relocated to a free starting position chosen deterministically via its id.
|
||||||
|
*/
|
||||||
|
function assignLayouts(cards: DataCard[]): Array<{ card: DataCard; resolvedLayout: CardLayout }> {
|
||||||
|
const occupied = new Set<string>();
|
||||||
|
|
||||||
function DataCardWidget({ card }: { card: DataCard }) {
|
return cards.map((card) => {
|
||||||
const [value, setValue] = useState<string>("…");
|
const layout: CardLayout = card.layout ?? {
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
grid_col: 1,
|
||||||
const [error, setError] = useState<string | null>(null);
|
grid_row: 4,
|
||||||
|
col_span: 1,
|
||||||
useEffect(() => {
|
row_span: 1,
|
||||||
const fetchData = () => {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
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());
|
|
||||||
setError(null);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setError(String(err));
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
const key = `${layout.grid_col},${layout.grid_row}`;
|
||||||
|
|
||||||
fetchData();
|
if (!occupied.has(key)) {
|
||||||
const ms = Math.max(5000, (card.config.refresh_interval ?? 60) * 1000);
|
occupied.add(key);
|
||||||
const interval = setInterval(fetchData, ms);
|
return { card, resolvedLayout: layout };
|
||||||
return () => clearInterval(interval);
|
}
|
||||||
}, [card.id, card.config.url, card.config.refresh_interval, card.config.take]);
|
|
||||||
|
|
||||||
const fontSize = card.config.display_options?.font_size ?? 16;
|
// Collect all free starting positions
|
||||||
const textColor = card.config.display_options?.text_color ?? "#ffffff";
|
const free: Array<[number, number]> = [];
|
||||||
|
for (let r = 1; r <= GRID_ROWS; r++) {
|
||||||
|
for (let c = 1; c <= GRID_COLS; c++) {
|
||||||
|
if (!occupied.has(`${c},${r}`)) free.push([c, r]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
if (free.length === 0) {
|
||||||
<div className="flex flex-col gap-1 bg-black/40 backdrop-blur-sm rounded-2xl border border-white/10 px-5 py-4 min-w-[160px] max-w-[320px]">
|
// Every slot taken – let it overlap at its original position
|
||||||
<span className="text-gray-400 text-xs font-semibold uppercase tracking-wider">
|
return { card, resolvedLayout: layout };
|
||||||
{card.name}
|
}
|
||||||
</span>
|
|
||||||
{error ? (
|
const [rc, rr] = free[hashStr(card.id) % free.length];
|
||||||
<span className="text-red-400 text-sm break-all">⚠ {error}</span>
|
occupied.add(`${rc},${rr}`);
|
||||||
) : (
|
return { card, resolvedLayout: { ...layout, grid_col: rc, grid_row: rr } };
|
||||||
<span
|
});
|
||||||
className="font-mono break-all whitespace-pre-wrap leading-snug"
|
|
||||||
style={{ fontSize, color: textColor }}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{lastUpdated && (
|
|
||||||
<span className="text-gray-600 text-xs mt-1">
|
|
||||||
{lastUpdated.toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [screenStatus, setScreenStatus] = useState<
|
const [screenStatus, setScreenStatus] = useState<"notfullscreen" | "fullscreen">("notfullscreen");
|
||||||
"notfullscreen" | "fullscreen"
|
|
||||||
>("notfullscreen");
|
|
||||||
const [textState, setTextState] = useState<TextState>({ showing: false, title: "" });
|
const [textState, setTextState] = useState<TextState>({ showing: false, title: "" });
|
||||||
const [imagePopup, setImagePopup] = useState<ImagePopupState>({ showing: false, image_url: "", caption: "" });
|
const [imagePopup, setImagePopup] = useState<ImagePopupState>({ showing: false, image_url: "", caption: "" });
|
||||||
const [dataCards, setDataCards] = useState<DataCard[]>([]);
|
const [dataCards, setDataCards] = useState<DataCard[]>([]);
|
||||||
|
const [settings, setSettings] = useState<SettingsState>({ background_url: "" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFullscreenChange = () => {
|
const handleFullscreenChange = () => {
|
||||||
if (!document.fullscreenElement) {
|
setScreenStatus(document.fullscreenElement ? "fullscreen" : "notfullscreen");
|
||||||
setScreenStatus("notfullscreen");
|
|
||||||
} else {
|
|
||||||
setScreenStatus("fullscreen");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
return () => {
|
|
||||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -158,6 +85,7 @@ function App() {
|
|||||||
setTextState(state.text ?? { showing: false, title: "" });
|
setTextState(state.text ?? { showing: false, title: "" });
|
||||||
setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" });
|
setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" });
|
||||||
setDataCards(state.data_cards ?? []);
|
setDataCards(state.data_cards ?? []);
|
||||||
|
setSettings(state.settings ?? { background_url: "" });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error pulling state:", error);
|
console.error("Error pulling state:", error);
|
||||||
@@ -166,116 +94,72 @@ function App() {
|
|||||||
|
|
||||||
handlePullState();
|
handlePullState();
|
||||||
const interval = setInterval(handlePullState, 5000);
|
const interval = setInterval(handlePullState, 5000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [screenStatus]);
|
}, [screenStatus]);
|
||||||
|
|
||||||
|
// Stable: only recompute when the serialised card list changes
|
||||||
|
const resolvedCards = useMemo(
|
||||||
|
() => assignLayouts(dataCards),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[JSON.stringify(dataCards)],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (screenStatus === "notfullscreen") {
|
||||||
|
return <NotFullscreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIdle = !textState.showing && !imagePopup.showing && dataCards.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="w-screen h-screen relative">
|
||||||
{screenStatus === "notfullscreen" ? (
|
{settings.background_url && (
|
||||||
<div className="flex flex-col items-center gap-8 px-8 text-center">
|
<img
|
||||||
<div className="flex flex-col items-center gap-2">
|
src={settings.background_url}
|
||||||
<div className="w-16 h-16 rounded-2xl bg-gray-800 border border-gray-700 flex items-center justify-center mb-2">
|
className="absolute inset-0 w-full h-full object-cover z-0"
|
||||||
<svg
|
alt=""
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
/>
|
||||||
className="w-8 h-8 text-gray-400"
|
)}
|
||||||
fill="none"
|
<TextPopup state={textState} />
|
||||||
viewBox="0 0 24 24"
|
<ImagePopup state={imagePopup} />
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M6 8V6a2 2 0 012-2h8a2 2 0 012 2v2M6 16v2a2 2 0 002 2h8a2 2 0 002-2v-2M3 12h18"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
|
||||||
TV View
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 text-sm max-w-xs">
|
|
||||||
Enter fullscreen mode to start displaying content.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
{isIdle && (
|
||||||
onClick={() => document.documentElement.requestFullscreen()}
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
className="group flex items-center gap-2 bg-white text-gray-900 font-medium px-6 py-3 rounded-xl hover:bg-gray-200 active:scale-95 transition-all duration-150 cursor-pointer"
|
<p className="text-gray-600 text-lg">
|
||||||
>
|
Waiting for content
|
||||||
<svg
|
<span className="dot-1">.</span>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<span className="dot-2">.</span>
|
||||||
className="w-4 h-4"
|
<span className="dot-3">.</span>
|
||||||
fill="none"
|
</p>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M4 8V4m0 0h4M4 4l5 5m11-5h-4m4 0v4m0-4l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Go Fullscreen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-screen h-screen relative">
|
|
||||||
{/* Text popup modal */}
|
|
||||||
{textState.showing && (
|
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-3xl px-16 py-12 shadow-2xl max-w-3xl w-full mx-8">
|
|
||||||
<h1 className="text-6xl font-bold tracking-tight text-white text-center">
|
|
||||||
{textState.title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Image popup modal */}
|
|
||||||
{imagePopup.showing && imagePopup.image_url && (
|
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/75 backdrop-blur-sm p-12">
|
|
||||||
<div className="flex flex-col items-center gap-5 p-6 bg-gray-950/80 rounded-3xl border border-white/10 shadow-2xl max-w-[82vw]">
|
|
||||||
{imagePopup.caption && (
|
|
||||||
<h2 className="text-4xl font-bold text-white tracking-tight text-center">
|
|
||||||
{imagePopup.caption}
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
<img
|
|
||||||
src={imagePopup.image_url}
|
|
||||||
alt="TV display"
|
|
||||||
className="max-w-full max-h-[72vh] rounded-2xl object-contain shadow-xl"
|
|
||||||
style={{ display: "block" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Background idle state */}
|
|
||||||
{!textState.showing && !imagePopup.showing && dataCards.length === 0 && (
|
|
||||||
<div className="flex items-center justify-center w-full h-full">
|
|
||||||
<p className="text-gray-600 text-lg">
|
|
||||||
Waiting for content
|
|
||||||
<span className="dot-1">.</span>
|
|
||||||
<span className="dot-2">.</span>
|
|
||||||
<span className="dot-3">.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data cards — persistent widgets at the bottom */}
|
|
||||||
{dataCards.length > 0 && (
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 z-0 flex flex-row flex-wrap gap-4 p-6 items-end">
|
|
||||||
{dataCards.map((card) => (
|
|
||||||
<DataCardWidget key={card.id} card={card} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
|
{resolvedCards.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-0 p-4"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${GRID_COLS}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${GRID_ROWS}, 1fr)`,
|
||||||
|
gap: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resolvedCards.map(({ card, resolvedLayout }) => (
|
||||||
|
<div
|
||||||
|
key={card.id}
|
||||||
|
style={{
|
||||||
|
gridColumn: `${resolvedLayout.grid_col} / span ${resolvedLayout.col_span}`,
|
||||||
|
gridRow: `${resolvedLayout.grid_row} / span ${resolvedLayout.row_span}`,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DataCardWidget card={card} layout={resolvedLayout} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
301
tv/src/components/DataCardWidget.tsx
Normal file
301
tv/src/components/DataCardWidget.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-1 bg-black/40 backdrop-blur-sm rounded-2xl border border-white/10 px-5 py-4 w-full h-full overflow-hidden">
|
||||||
|
<span className="text-gray-400 text-xs font-semibold uppercase tracking-wider truncate shrink-0">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 overflow-hidden min-h-0">{children}</div>
|
||||||
|
{footer && <div className="shrink-0">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── custom_json widget ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CustomJsonWidget({ card, layout }: { card: CustomJsonCard; layout?: CardLayout }) {
|
||||||
|
const [value, setValue] = useState<string>("…");
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [now, setNow] = useState<Date>(() => new Date());
|
||||||
|
const [pulling, setPulling] = useState(false);
|
||||||
|
const [dots, setDots] = useState(0);
|
||||||
|
const [responseMs, setResponseMs] = useState<number | null>(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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<CardShell
|
||||||
|
name={card.name}
|
||||||
|
footer={
|
||||||
|
pulling ? (
|
||||||
|
<span className="text-gray-500 text-xs">pulling{"." .repeat(dots)}</span>
|
||||||
|
) : secAgo !== null && nextIn !== null ? (
|
||||||
|
<span className="text-gray-600 text-xs">
|
||||||
|
{secAgo}s ago · next in {nextIn}s{responseMs !== null ? ` · ${responseMs}ms` : ""}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<span className="text-red-400 text-sm break-all">⚠ {error}</span>
|
||||||
|
) : (
|
||||||
|
<div className={`flex h-full overflow-hidden${centered ? " items-center justify-center" : ""}`}>
|
||||||
|
<span
|
||||||
|
className={`font-mono break-all whitespace-pre-wrap leading-snug${centered ? " text-center" : " block"}`}
|
||||||
|
style={{ fontSize, color: textColor }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<CardShell name={card.name}>
|
||||||
|
<div className={`flex h-full overflow-hidden${centered ? " items-center justify-center" : ""}`}>
|
||||||
|
<span
|
||||||
|
className={`whitespace-pre-wrap break-words leading-snug${centered ? " text-center" : " block"}`}
|
||||||
|
style={{ fontSize, color: textColor }}
|
||||||
|
>
|
||||||
|
{card.config.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<Date>(() => 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 (
|
||||||
|
<CardShell name={card.name}>
|
||||||
|
<div className={`flex flex-col justify-center h-full gap-1${centered ? " items-center" : ""}`}>
|
||||||
|
<span
|
||||||
|
className={`font-mono tabular-nums leading-none${centered ? " text-center" : ""}`}
|
||||||
|
style={{ fontSize, color: textColor }}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
|
{subtitle && (
|
||||||
|
<span className={`text-gray-500 text-xs mt-1${centered ? " text-center" : ""}`}>{subtitle}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<CardShell name={card.name}>
|
||||||
|
<span className="text-gray-500 text-sm">No images configured.</span>
|
||||||
|
</CardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fit = card.config.fit === "contain" ? "contain" : "cover";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full overflow-hidden rounded-2xl">
|
||||||
|
<img
|
||||||
|
key={images[index]}
|
||||||
|
src={images[index]}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: fit,
|
||||||
|
transition: "opacity 0.4s ease",
|
||||||
|
opacity: fade ? 1 : 0,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Name overlay */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 px-4 py-2 bg-gradient-to-t from-black/70 to-transparent">
|
||||||
|
<span className="text-white text-xs font-semibold truncate">{card.name}</span>
|
||||||
|
{images.length > 1 && (
|
||||||
|
<span className="text-white/50 text-xs ml-2">
|
||||||
|
{index + 1}/{images.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dispatcher ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DataCardWidget({ card, layout }: { card: DataCard; layout?: CardLayout }) {
|
||||||
|
if (card.type === "static_text") return <StaticTextWidget card={card} layout={layout} />;
|
||||||
|
if (card.type === "clock") return <ClockWidget card={card} layout={layout} />;
|
||||||
|
if (card.type === "image_rotator") return <ImageRotatorWidget card={card} />;
|
||||||
|
// default: custom_json
|
||||||
|
return <CustomJsonWidget card={card as CustomJsonCard} layout={layout} />;
|
||||||
|
}
|
||||||
23
tv/src/components/ImagePopup.tsx
Normal file
23
tv/src/components/ImagePopup.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { type ImagePopupState } from "../types";
|
||||||
|
|
||||||
|
export function ImagePopup({ state }: { state: ImagePopupState }) {
|
||||||
|
if (!state.showing || !state.image_url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/75 backdrop-blur-sm p-12">
|
||||||
|
<div className="flex flex-col items-center gap-5 p-6 bg-gray-950/80 rounded-3xl border border-white/10 shadow-2xl max-w-[82vw]">
|
||||||
|
{state.caption && (
|
||||||
|
<h2 className="text-4xl font-bold text-white tracking-tight text-center">
|
||||||
|
{state.caption}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={state.image_url}
|
||||||
|
alt="TV display"
|
||||||
|
className="max-w-full max-h-[72vh] rounded-2xl object-contain shadow-xl"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
tv/src/components/NotFullscreen.tsx
Normal file
51
tv/src/components/NotFullscreen.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export function NotFullscreen() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-8 px-8 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gray-800 border border-gray-700 flex items-center justify-center mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="w-8 h-8 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6 8V6a2 2 0 012-2h8a2 2 0 012 2v2M6 16v2a2 2 0 002 2h8a2 2 0 002-2v-2M3 12h18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
||||||
|
TV View
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 text-sm max-w-xs">
|
||||||
|
Enter fullscreen mode to start displaying content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => document.documentElement.requestFullscreen()}
|
||||||
|
className="group flex items-center gap-2 bg-white text-gray-900 font-medium px-6 py-3 rounded-xl hover:bg-gray-200 active:scale-95 transition-all duration-150 cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4 8V4m0 0h4M4 4l5 5m11-5h-4m4 0v4m0-4l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Go Fullscreen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
tv/src/components/TextPopup.tsx
Normal file
15
tv/src/components/TextPopup.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { type TextState } from "../types";
|
||||||
|
|
||||||
|
export function TextPopup({ state }: { state: TextState }) {
|
||||||
|
if (!state.showing) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-3xl px-16 py-12 shadow-2xl max-w-3xl mx-8">
|
||||||
|
<h1 className="text-6xl font-bold tracking-tight text-white text-center">
|
||||||
|
{state.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
tv/src/types.ts
Normal file
106
tv/src/types.ts
Normal file
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
21
tv/src/utils/evaluatePath.ts
Normal file
21
tv/src/utils/evaluatePath.ts
Normal file
@@ -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<string, unknown>)[token];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user