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:
|
||||
|
||||
- **`mobile/`** — Expo React Native app (the "remote control"). Sends commands to the backend.
|
||||
- **`tv/`** — Vite + React web app (the TV display). Runs in a browser, polls backend every 5s when fullscreen.
|
||||
- **`functions/control/`** — Python serverless function deployed on `shsf-api.reversed.dev`. Acts as state store; persists to `/app/state.json` on the container.
|
||||
- **`tv/`** — Vite + React web app (the TV display). Runs in a browser on the TV, polls backend every 5s when fullscreen.
|
||||
- **`functions/control/`** — Python serverless function on `shsf-api.reversed.dev`. Single state store; persists to `/app/state.json` on the container.
|
||||
- **`functions/data-sources/`** — Second serverless function (stub); currently unused beyond a `test` route.
|
||||
|
||||
## API / Backend
|
||||
|
||||
All communication goes through a single endpoint:
|
||||
All communication goes through one base URL:
|
||||
```
|
||||
https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329/{route}
|
||||
```
|
||||
|
||||
Routes (all `POST` except pull routes which are `GET` implicitly via `fetch`):
|
||||
- `push_text` — body: `{ title: string }`
|
||||
- `push_dismiss_text`
|
||||
- `push_image` — body: `{ image_b64: string, caption: string }`; uploads to MinIO, stores public URL
|
||||
- `push_dismiss_image`
|
||||
- `pull_full` — returns full state `{ text, image_popup }`
|
||||
All routes use `POST` (fetch with no body is still POST). `pull_full` returns `{ status, state }` — the state object is under the `state` key, not the root.
|
||||
|
||||
Images are stored in MinIO at `content2.reversed.dev`, bucket `tv-control`.
|
||||
| Route | Body | Notes |
|
||||
|---|---|---|
|
||||
| `push_text` | `{ title }` | |
|
||||
| `push_dismiss_text` | — | |
|
||||
| `push_image` | `{ image_b64, caption }` | Uploads to MinIO, stores URL |
|
||||
| `push_dismiss_image` | — | |
|
||||
| `push_data_card` | `{ card: DataCard }` | Upserts by `card.id` |
|
||||
| `push_delete_data_card` | `{ id }` | |
|
||||
| `push_upload_images` | `{ images: [{image_b64, ext}] }` | Returns `{ urls[] }`, no state change |
|
||||
| `push_settings` | `{ background_url }` | Persists global TV settings |
|
||||
| `pull_full` | — | Returns `{ state: { text, image_popup, data_cards[], settings } }` |
|
||||
| `pull_data_cards` | — | Returns `{ data_cards[] }` |
|
||||
|
||||
Images/rotator assets stored in MinIO at `content2.reversed.dev`, bucket `tv-control`.
|
||||
|
||||
## Global Settings
|
||||
|
||||
`settings` is a persistent object with global TV display configuration (typed as `SettingsState` in `tv/src/types.ts`).
|
||||
|
||||
- **`background_url`** — a portrait image (height > width) rendered as a full-screen `object-cover` background behind all TV content. Empty string = no background.
|
||||
- Set via `push_settings { background_url }`, cleared by passing `""`.
|
||||
- The constraint (portrait orientation) is enforced in the mobile picker (`mobile/src/pages/settings.tsx`) — `asset.width >= asset.height` is rejected.
|
||||
- Images are uploaded first via `push_upload_images` to get a MinIO URL, then saved via `push_settings`.
|
||||
- The Settings tab is visible in the mobile bottom nav (no `hideInNav`).
|
||||
|
||||
## Data Cards System
|
||||
|
||||
`data_cards` is a persistent array of typed card objects displayed on the TV in a **4-column × 4-row CSS grid**. Each card has `{ id, type, name, config, layout? }`.
|
||||
|
||||
Four card types (defined in `tv/src/types.ts` and mirrored in `mobile/src/pages/datacards.tsx`):
|
||||
- **`custom_json`** — polls an external URL; `config.take` uses `$out.path[0].field` syntax (handled by `tv/src/utils/evaluatePath.ts`) to extract a value
|
||||
- **`static_text`** — fixed text with font size/color
|
||||
- **`clock`** — live clock (`mode: "time"`) or countdown (`mode: "timer"`) with optional `timezone` (IANA) and `target_iso`
|
||||
- **`image_rotator`** — cycles through an array of image URLs
|
||||
|
||||
`layout` defaults to `{ grid_col: 1, grid_row: 4, col_span: 1, row_span: 1 }` when absent. `assignLayouts()` in `tv/src/App.tsx` resolves collisions deterministically via a string hash of `card.id`.
|
||||
|
||||
## Adding a New Content Type
|
||||
|
||||
1. Add state shape to `DEFAULT_STATE` in `functions/control/main.py`
|
||||
2. Add `push_*` / `pull_*` handlers in `main(args)` in the same file
|
||||
3. Add a TypeScript interface and `useState` in `tv/src/App.tsx`; render the popup in the fullscreen branch
|
||||
1. Add state to `DEFAULT_STATE` in `functions/control/main.py`; add `push_*`/`pull_*` handlers in `main(args)`
|
||||
2. Add TypeScript types to `tv/src/types.ts`
|
||||
3. Add `useState` and rendering in `tv/src/App.tsx` (fullscreen branch)
|
||||
4. Add a page in `mobile/src/pages/`
|
||||
5. Register it in the `TABS` array in `mobile/App.tsx` with `hideInNav: true` (prevents it appearing in `BottomNav`, navigation is triggered programmatically via `navigate()`)
|
||||
5. Add a tab to `TABS` in `mobile/App.tsx` with `hideInNav: true`; update the `Route` union in `mobile/src/router.tsx`
|
||||
|
||||
## Mobile App Conventions
|
||||
|
||||
- Uses a **custom in-memory router** (`mobile/src/router.tsx`) — no expo-router or react-navigation. `Route` type is a string union: `"home" | "text" | "image"`.
|
||||
- `TABS` in `mobile/App.tsx` is the single source of truth for routes and pages. `BottomNav` imports `TABS` directly from `App.tsx`.
|
||||
- Tabs with `hideInNav: true` are reachable only via `navigate(route)`, not from the bottom bar.
|
||||
- Styling uses React Native `StyleSheet` throughout — no CSS or Tailwind.
|
||||
- **Custom in-memory router** (`mobile/src/router.tsx`) — no expo-router or react-navigation. `Route = "home" | "text" | "image" | "datacards"`. History stack backed by a `useRef`; Android back button wired via `BackHandler`.
|
||||
- `TABS` in `mobile/App.tsx` is the single source of truth for routes/pages. `BottomNav` imports it directly.
|
||||
- `hideInNav: true` tabs are reachable only via `navigate(route)`.
|
||||
- Styling uses React Native `StyleSheet` — no CSS or Tailwind.
|
||||
|
||||
## TV App Conventions
|
||||
|
||||
- Uses **Tailwind CSS v4** via `@tailwindcss/vite` (no `tailwind.config.js` — config lives in `vite.config.ts`).
|
||||
- Polling only starts when the browser enters fullscreen (`screenStatus === "fullscreen"`).
|
||||
- State shape mirrors `DEFAULT_STATE` from the Python function exactly.
|
||||
- **Tailwind CSS v4** via `@tailwindcss/vite` — no `tailwind.config.js`; config lives in `vite.config.ts`.
|
||||
- Polling only starts when fullscreen (`screenStatus === "fullscreen"`).
|
||||
- Data cards grid uses **inline CSS grid styles** (not Tailwind) because `gridColumn`/`gridRow` values are dynamic. All card widgets wrap in the `CardShell` component (`tv/src/components/DataCardWidget.tsx`).
|
||||
- State shape in `tv/src/App.tsx` must exactly mirror `DEFAULT_STATE` in the Python function.
|
||||
|
||||
## Dev Workflows
|
||||
|
||||
@@ -57,4 +89,4 @@ cd mobile && pnpm android # Android emulator
|
||||
cd mobile && pnpm ios # iOS simulator
|
||||
```
|
||||
|
||||
Package manager: **pnpm** for both `tv/` and `mobile/`. Python function has no local runner — deploy changes directly.
|
||||
Package manager: **pnpm** for both `tv/` and `mobile/`. Python functions have no local runner — deploy changes directly.
|
||||
|
||||
@@ -14,7 +14,10 @@ DEFAULT_STATE = {
|
||||
"image_url": "",
|
||||
"caption": ""
|
||||
},
|
||||
"data_cards": []
|
||||
"data_cards": [],
|
||||
"settings": {
|
||||
"background_url": ""
|
||||
}
|
||||
}
|
||||
|
||||
MINIO_CLIENT = Minio(
|
||||
@@ -97,6 +100,12 @@ def main(args):
|
||||
current["data_cards"] = [c for c in current.get("data_cards", []) if c["id"] != card_id]
|
||||
_write_state(current)
|
||||
return {"status": "success"}
|
||||
elif route == "push_settings":
|
||||
bg_url = body.get("background_url", "")
|
||||
current = _read_state()
|
||||
current.setdefault("settings", {})["background_url"] = bg_url
|
||||
_write_state(current)
|
||||
return {"status": "success"}
|
||||
elif route == "push_upload_images":
|
||||
# Upload multiple images to MinIO; returns their public URLs (no state change)
|
||||
images = body.get("images", [])
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { BottomNav } from "./src/components/BottomNav";
|
||||
import { colors } from "./src/styles";
|
||||
import { NotFoundPage } from "./src/pages/NotFound";
|
||||
import { DataCardsPage } from "./src/pages/datacards";
|
||||
import { ImagePage } from "./src/pages/image";
|
||||
import { IndexPage } from "./src/pages/index";
|
||||
import { SettingsPage } from "./src/pages/settings";
|
||||
import { TextPage } from "./src/pages/text";
|
||||
import { Route, RouterProvider, useRouter } from "./src/router";
|
||||
interface Tab {
|
||||
@@ -17,7 +19,13 @@ const TABS: Tab[] = [
|
||||
{ label: "Home", route: "home", page: IndexPage },
|
||||
{ label: "Text", route: "text", page: TextPage, hideInNav: true },
|
||||
{ label: "Image", route: "image", page: ImagePage, hideInNav: true },
|
||||
{ label: "Data Cards", route: "datacards", page: DataCardsPage, hideInNav: true },
|
||||
{
|
||||
label: "Data Cards",
|
||||
route: "datacards",
|
||||
page: DataCardsPage,
|
||||
hideInNav: true,
|
||||
},
|
||||
{ label: "Settings", route: "settings", page: SettingsPage },
|
||||
];
|
||||
export { TABS, type Tab };
|
||||
|
||||
@@ -41,8 +49,10 @@ export default function App() {
|
||||
return (
|
||||
<RouterProvider>
|
||||
<View style={styles.container}>
|
||||
<StatusBar style="auto" />
|
||||
<Screen />
|
||||
<StatusBar style="light" />
|
||||
<View style={{ flex: 1, marginTop: 12 }}>
|
||||
<Screen />
|
||||
</View>
|
||||
<BottomNav />
|
||||
</View>
|
||||
</RouterProvider>
|
||||
@@ -52,6 +62,6 @@ export default function App() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
backgroundColor: colors.bg,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-community/datetimepicker": "^8.6.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
|
||||
28
mobile/pnpm-lock.yaml
generated
28
mobile/pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@react-native-community/datetimepicker':
|
||||
specifier: ^8.6.0
|
||||
version: 8.6.0(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
expo:
|
||||
specifier: ~54.0.33
|
||||
version: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -702,6 +705,19 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@react-native-community/datetimepicker@8.6.0':
|
||||
resolution: {integrity: sha512-yxPSqNfxgpGaqHQIpatqe6ykeBdU/1pdsk/G3x01mY2bpTflLpmVTLqFSJYd3MiZzxNZcMs/j1dQakUczSjcYA==}
|
||||
peerDependencies:
|
||||
expo: '>=52.0.0'
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
react-native-windows: '*'
|
||||
peerDependenciesMeta:
|
||||
expo:
|
||||
optional: true
|
||||
react-native-windows:
|
||||
optional: true
|
||||
|
||||
'@react-native/assets-registry@0.81.5':
|
||||
resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==}
|
||||
engines: {node: '>= 20.19.4'}
|
||||
@@ -1630,28 +1646,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.31.1:
|
||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.31.1:
|
||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||
@@ -3546,6 +3558,14 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@react-native-community/datetimepicker@8.6.0(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
invariant: 2.2.4
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
optionalDependencies:
|
||||
expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
|
||||
'@react-native/assets-registry@0.81.5': {}
|
||||
|
||||
'@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)':
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useRouter } from "../router";
|
||||
import { TABS } from "../../App";
|
||||
import { colors } from "../styles";
|
||||
|
||||
|
||||
export function BottomNav() {
|
||||
@@ -32,9 +33,9 @@ const styles = StyleSheet.create({
|
||||
bar: {
|
||||
flexDirection: "row",
|
||||
height: 74,
|
||||
borderTopWidth:1,
|
||||
borderTopColor: "#e5e5e5",
|
||||
backgroundColor: "#fff",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
@@ -43,11 +44,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
color: "#aaa",
|
||||
color: colors.textMuted,
|
||||
fontWeight: "500",
|
||||
},
|
||||
labelActive: {
|
||||
color: "#1a1a1a",
|
||||
color: colors.textPrimary,
|
||||
fontWeight: "700",
|
||||
},
|
||||
indicator: {
|
||||
@@ -56,6 +57,6 @@ const styles = StyleSheet.create({
|
||||
width: 32,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: "#1a1a1a",
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { colors } from "../styles";
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
@@ -15,12 +16,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9f9f9",
|
||||
backgroundColor: colors.bg,
|
||||
},
|
||||
code: {
|
||||
fontSize: 72,
|
||||
fontWeight: "800",
|
||||
color: "#ccc",
|
||||
color: colors.border,
|
||||
lineHeight: 80,
|
||||
},
|
||||
title: {
|
||||
@@ -28,9 +29,11 @@ const styles = StyleSheet.create({
|
||||
fontWeight: "600",
|
||||
marginTop: 8,
|
||||
marginBottom: 6,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: "#999",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useState } from "react";
|
||||
import { ActivityIndicator, Button, Image, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { ActivityIndicator, Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import { colors, shared } from "../styles";
|
||||
|
||||
const BASE_URL =
|
||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
||||
@@ -8,27 +9,12 @@ const BASE_URL =
|
||||
export function ImagePage() {
|
||||
const [caption, setCaption] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dismissing, setDismissing] = useState(false);
|
||||
const [previewUri, setPreviewUri] = useState<string | null>(null);
|
||||
|
||||
const handleTakePhoto = async () => {
|
||||
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!granted) {
|
||||
alert("Camera permission is required to take photos.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: "images",
|
||||
quality: 1,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (result.canceled) return;
|
||||
|
||||
const asset = result.assets[0];
|
||||
const uploadAsset = async (asset: ImagePicker.ImagePickerAsset) => {
|
||||
setPreviewUri(asset.uri);
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/push_image`, {
|
||||
method: "POST",
|
||||
@@ -45,7 +31,42 @@ export function ImagePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTakePhoto = async () => {
|
||||
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!granted) {
|
||||
alert("Camera permission is required to take photos.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: "images",
|
||||
quality: 1,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (result.canceled) return;
|
||||
await uploadAsset(result.assets[0]);
|
||||
};
|
||||
|
||||
const handlePickFromGallery = async () => {
|
||||
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!granted) {
|
||||
alert("Media library permission is required to pick photos.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: "images",
|
||||
quality: 1,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (result.canceled) return;
|
||||
await uploadAsset(result.assets[0]);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissing(true);
|
||||
fetch(`${BASE_URL}/push_dismiss_image`, { method: "POST" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
@@ -54,19 +75,21 @@ export function ImagePage() {
|
||||
.catch((error) => {
|
||||
console.error("Error dismissing image:", error);
|
||||
alert("Error dismissing image.");
|
||||
});
|
||||
})
|
||||
.finally(() => setDismissing(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Image Popup</Text>
|
||||
<Text style={styles.subtitle}>Show a photo on the TV with an optional caption.</Text>
|
||||
<View style={shared.screenPadded}>
|
||||
<Text style={shared.pageTitle}>Image Popup</Text>
|
||||
<Text style={shared.subtitle}>Show a photo on the TV with an optional caption.</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Caption (optional)</Text>
|
||||
<View style={shared.field}>
|
||||
<Text style={shared.label}>Caption (optional)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={shared.input}
|
||||
placeholder="Add a caption..."
|
||||
placeholderTextColor={colors.placeholderColor}
|
||||
value={caption}
|
||||
onChangeText={setCaption}
|
||||
/>
|
||||
@@ -77,69 +100,50 @@ export function ImagePage() {
|
||||
)}
|
||||
|
||||
{uploading ? (
|
||||
<ActivityIndicator size="large" style={{ marginTop: 8 }} />
|
||||
<ActivityIndicator size="large" color={colors.accent} style={{ marginTop: 8 }} />
|
||||
) : (
|
||||
<View style={styles.actions}>
|
||||
<View style={styles.actionBtn}>
|
||||
<Button title="Take Photo & Show" onPress={handleTakePhoto} />
|
||||
<>
|
||||
<View style={shared.actionsRow}>
|
||||
<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 style={styles.actionBtn}>
|
||||
<Button title="Dismiss" onPress={handleDismiss} color="#e55" />
|
||||
<View style={shared.actionsRow}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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: {
|
||||
width: "100%",
|
||||
height: 200,
|
||||
borderRadius: 10,
|
||||
borderRadius: 12,
|
||||
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 { useRouter } from "../router";
|
||||
import { colors, shared } from "../styles";
|
||||
|
||||
export function IndexPage() {
|
||||
const { navigate } = useRouter();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>TV Control</Text>
|
||||
<Text style={styles.subtitle}>Choose what to send to the TV.</Text>
|
||||
<View style={shared.screenPadded}>
|
||||
<Text style={shared.pageTitleLarge}>TV Control</Text>
|
||||
<Text style={shared.subtitle}>Your TV, Your Control.</Text>
|
||||
|
||||
<View style={styles.cards}>
|
||||
<TouchableOpacity style={styles.card} onPress={() => navigate("text")} activeOpacity={0.8}>
|
||||
<Text style={styles.cardIcon}>💬</Text>
|
||||
<Text style={styles.cardTitle}>Text Popup</Text>
|
||||
<Text style={styles.cardDesc}>Display a message on the TV screen.</Text>
|
||||
<TouchableOpacity style={shared.card} onPress={() => navigate("text")} activeOpacity={0.7}>
|
||||
<Text style={shared.cardIcon}>💬</Text>
|
||||
<Text style={shared.cardTitle}>Text Popup</Text>
|
||||
<Text style={shared.cardDesc}>Display a message on the TV screen.</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.card} onPress={() => navigate("image")} activeOpacity={0.8}>
|
||||
<Text style={styles.cardIcon}>📷</Text>
|
||||
<Text style={styles.cardTitle}>Image Popup</Text>
|
||||
<Text style={styles.cardDesc}>Take a photo and show it on the TV.</Text>
|
||||
<TouchableOpacity style={shared.card} onPress={() => navigate("image")} activeOpacity={0.7}>
|
||||
<Text style={shared.cardIcon}>📷</Text>
|
||||
<Text style={shared.cardTitle}>Image Popup</Text>
|
||||
<Text style={shared.cardDesc}>Take a photo and show it on the TV.</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.card} onPress={() => navigate("datacards")} activeOpacity={0.8}>
|
||||
<Text style={styles.cardIcon}>📊</Text>
|
||||
<Text style={styles.cardTitle}>Data Cards</Text>
|
||||
<Text style={styles.cardDesc}>Display live data from custom JSON sources on the TV.</Text>
|
||||
<TouchableOpacity style={shared.card} onPress={() => navigate("datacards")} activeOpacity={0.7}>
|
||||
<Text style={shared.cardIcon}>📊</Text>
|
||||
<Text style={shared.cardTitle}>Data Cards</Text>
|
||||
<Text style={shared.cardDesc}>Display live data from custom JSON sources on the TV.</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -33,50 +34,9 @@ export function IndexPage() {
|
||||
}
|
||||
|
||||
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: {
|
||||
gap: 16,
|
||||
gap: 14,
|
||||
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 { 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 =
|
||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
||||
|
||||
export function TextPage() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [dismissing, setDismissing] = useState(false);
|
||||
|
||||
const handleShow = () => {
|
||||
if (title.trim() === "") {
|
||||
alert("Please enter some text before sending.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
fetch(`${BASE_URL}/push_text`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -25,10 +28,12 @@ export function TextPage() {
|
||||
.catch((error) => {
|
||||
console.error("Error sending text:", error);
|
||||
alert("Error sending text.");
|
||||
});
|
||||
})
|
||||
.finally(() => setSending(false));
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissing(true);
|
||||
fetch(`${BASE_URL}/push_dismiss_text`, { method: "POST" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
@@ -37,81 +42,53 @@ export function TextPage() {
|
||||
.catch((error) => {
|
||||
console.error("Error dismissing text:", error);
|
||||
alert("Error dismissing text.");
|
||||
});
|
||||
})
|
||||
.finally(() => setDismissing(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Text Popup</Text>
|
||||
<Text style={styles.subtitle}>Display a text message on the TV.</Text>
|
||||
<View style={shared.screenPadded}>
|
||||
<Text style={shared.pageTitle}>Text Popup</Text>
|
||||
<Text style={shared.subtitle}>Display a text message on the TV.</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Message</Text>
|
||||
<View style={shared.field}>
|
||||
<Text style={shared.label}>Message</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={[shared.input, shared.inputMultiline]}
|
||||
placeholder="Type something..."
|
||||
placeholderTextColor={colors.placeholderColor}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<View style={styles.actionBtn}>
|
||||
<Button title="Show on TV" onPress={handleShow} />
|
||||
</View>
|
||||
<View style={styles.actionBtn}>
|
||||
<Button title="Dismiss" onPress={handleDismiss} color="#e55" />
|
||||
</View>
|
||||
<View style={shared.actionsRow}>
|
||||
<TouchableOpacity
|
||||
style={[shared.btnPrimary, shared.actionFlex, sending && shared.btnDisabled]}
|
||||
onPress={handleShow}
|
||||
activeOpacity={0.8}
|
||||
disabled={sending}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
route: Route;
|
||||
navigate: (to: Route) => void;
|
||||
goBack: () => boolean;
|
||||
}
|
||||
|
||||
const RouterContext = createContext<RouterContextValue | null>(null);
|
||||
|
||||
export function RouterProvider({ children }: { children: React.ReactNode }) {
|
||||
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 (
|
||||
<RouterContext.Provider value={{ route, navigate: setRoute }}>
|
||||
<RouterContext.Provider value={{ route, navigate, goBack }}>
|
||||
{children}
|
||||
</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 {
|
||||
showing: boolean;
|
||||
title: string;
|
||||
}
|
||||
const GRID_COLS = 4;
|
||||
const GRID_ROWS = 4;
|
||||
|
||||
interface ImagePopupState {
|
||||
showing: boolean;
|
||||
image_url: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
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]);
|
||||
/** Deterministic hash of a string → non-negative integer */
|
||||
function hashStr(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = Math.imul(31, h) + s.charCodeAt(i) | 0;
|
||||
}
|
||||
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;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
// ─── 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 }) {
|
||||
const [value, setValue] = useState<string>("…");
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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));
|
||||
});
|
||||
return cards.map((card) => {
|
||||
const layout: CardLayout = card.layout ?? {
|
||||
grid_col: 1,
|
||||
grid_row: 4,
|
||||
col_span: 1,
|
||||
row_span: 1,
|
||||
};
|
||||
const key = `${layout.grid_col},${layout.grid_row}`;
|
||||
|
||||
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]);
|
||||
if (!occupied.has(key)) {
|
||||
occupied.add(key);
|
||||
return { card, resolvedLayout: layout };
|
||||
}
|
||||
|
||||
const fontSize = card.config.display_options?.font_size ?? 16;
|
||||
const textColor = card.config.display_options?.text_color ?? "#ffffff";
|
||||
// Collect all free starting positions
|
||||
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 (
|
||||
<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]">
|
||||
<span className="text-gray-400 text-xs font-semibold uppercase tracking-wider">
|
||||
{card.name}
|
||||
</span>
|
||||
{error ? (
|
||||
<span className="text-red-400 text-sm break-all">⚠ {error}</span>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
if (free.length === 0) {
|
||||
// Every slot taken – let it overlap at its original position
|
||||
return { card, resolvedLayout: layout };
|
||||
}
|
||||
|
||||
const [rc, rr] = free[hashStr(card.id) % free.length];
|
||||
occupied.add(`${rc},${rr}`);
|
||||
return { card, resolvedLayout: { ...layout, grid_col: rc, grid_row: rr } };
|
||||
});
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [screenStatus, setScreenStatus] = useState<
|
||||
"notfullscreen" | "fullscreen"
|
||||
>("notfullscreen");
|
||||
const [screenStatus, setScreenStatus] = useState<"notfullscreen" | "fullscreen">("notfullscreen");
|
||||
const [textState, setTextState] = useState<TextState>({ showing: false, title: "" });
|
||||
const [imagePopup, setImagePopup] = useState<ImagePopupState>({ showing: false, image_url: "", caption: "" });
|
||||
const [dataCards, setDataCards] = useState<DataCard[]>([]);
|
||||
const [settings, setSettings] = useState<SettingsState>({ background_url: "" });
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
setScreenStatus("notfullscreen");
|
||||
} else {
|
||||
setScreenStatus("fullscreen");
|
||||
}
|
||||
setScreenStatus(document.fullscreenElement ? "fullscreen" : "notfullscreen");
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
};
|
||||
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -158,6 +85,7 @@ function App() {
|
||||
setTextState(state.text ?? { showing: false, title: "" });
|
||||
setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" });
|
||||
setDataCards(state.data_cards ?? []);
|
||||
setSettings(state.settings ?? { background_url: "" });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error pulling state:", error);
|
||||
@@ -166,116 +94,72 @@ function App() {
|
||||
|
||||
handlePullState();
|
||||
const interval = setInterval(handlePullState, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [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 (
|
||||
<>
|
||||
{screenStatus === "notfullscreen" ? (
|
||||
<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>
|
||||
<div className="w-screen h-screen relative">
|
||||
{settings.background_url && (
|
||||
<img
|
||||
src={settings.background_url}
|
||||
className="absolute inset-0 w-full h-full object-cover z-0"
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<TextPopup state={textState} />
|
||||
<ImagePopup state={imagePopup} />
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{isIdle && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
|
||||
{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