93 lines
4.9 KiB
Markdown
93 lines
4.9 KiB
Markdown
# Copilot Instructions
|
||
|
||
## Architecture Overview
|
||
|
||
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 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 one base URL:
|
||
```
|
||
https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329/{route}
|
||
```
|
||
|
||
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.
|
||
|
||
| 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 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. Add a tab to `TABS` in `mobile/App.tsx` with `hideInNav: true`; update the `Route` union in `mobile/src/router.tsx`
|
||
|
||
## Mobile App Conventions
|
||
|
||
- **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
|
||
|
||
- **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
|
||
|
||
```bash
|
||
# TV display
|
||
cd tv && pnpm dev
|
||
|
||
# Mobile app
|
||
cd mobile && pnpm start # Expo Go / dev server
|
||
cd mobile && pnpm android # Android emulator
|
||
cd mobile && pnpm ios # iOS simulator
|
||
```
|
||
|
||
Package manager: **pnpm** for both `tv/` and `mobile/`. Python functions have no local runner — deploy changes directly.
|