refactor: update TABS to include hideInNav property and filter in BottomNav; enhance image popup styling and loading indicator
This commit is contained in:
60
.github/copilot-instructions.md
vendored
Normal file
60
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 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, 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.
|
||||||
|
|
||||||
|
## API / Backend
|
||||||
|
|
||||||
|
All communication goes through a single endpoint:
|
||||||
|
```
|
||||||
|
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 }`
|
||||||
|
|
||||||
|
Images are stored in MinIO at `content2.reversed.dev`, bucket `tv-control`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
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()`)
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 function has no local runner — deploy changes directly.
|
||||||
@@ -10,11 +10,12 @@ interface Tab {
|
|||||||
label: string;
|
label: string;
|
||||||
route: Route;
|
route: Route;
|
||||||
page: React.ComponentType;
|
page: React.ComponentType;
|
||||||
|
hideInNav?: boolean;
|
||||||
}
|
}
|
||||||
const TABS: Tab[] = [
|
const TABS: Tab[] = [
|
||||||
{ label: "Home", route: "home", page: IndexPage },
|
{ label: "Home", route: "home", page: IndexPage },
|
||||||
{ label: "Text", route: "text", page: TextPage },
|
{ label: "Text", route: "text", page: TextPage, hideInNav: true },
|
||||||
{ label: "Image", route: "image", page: ImagePage },
|
{ label: "Image", route: "image", page: ImagePage, hideInNav: true },
|
||||||
];
|
];
|
||||||
export { TABS, type Tab };
|
export { TABS, type Tab };
|
||||||
|
|
||||||
@@ -52,39 +53,3 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: "#fff",
|
backgroundColor: "#fff",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { route } = useRouter();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{TABS.map((tab) => {
|
|
||||||
if (tab.route === route) {
|
|
||||||
const Page = tab.page;
|
|
||||||
return <Page key={tab.route} />;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
{!TABS.some((tab) => tab.route === route) && <NotFoundPage />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<RouterProvider>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<StatusBar style="auto" />
|
|
||||||
<Screen />
|
|
||||||
<BottomNav />
|
|
||||||
</View>
|
|
||||||
</RouterProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function BottomNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.bar}>
|
<View style={styles.bar}>
|
||||||
{TABS.map((tab) => {
|
{TABS.filter((tab) => !tab.hideInNav).map((tab) => {
|
||||||
const active = tab.route === route;
|
const active = tab.route === route;
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ function App() {
|
|||||||
|
|
||||||
{/* Image popup modal */}
|
{/* Image popup modal */}
|
||||||
{imagePopup.showing && imagePopup.image_url && (
|
{imagePopup.showing && imagePopup.image_url && (
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/75 backdrop-blur-sm">
|
<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-[90vw] max-h-[92vh]">
|
<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 && (
|
{imagePopup.caption && (
|
||||||
<h2 className="text-4xl font-bold text-white tracking-tight text-center">
|
<h2 className="text-4xl font-bold text-white tracking-tight text-center">
|
||||||
{imagePopup.caption}
|
{imagePopup.caption}
|
||||||
@@ -133,7 +133,8 @@ function App() {
|
|||||||
<img
|
<img
|
||||||
src={imagePopup.image_url}
|
src={imagePopup.image_url}
|
||||||
alt="TV display"
|
alt="TV display"
|
||||||
className="max-w-full max-h-[78vh] rounded-2xl object-contain shadow-xl"
|
className="max-w-full max-h-[72vh] rounded-2xl object-contain shadow-xl"
|
||||||
|
style={{ display: "block" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,7 +143,12 @@ function App() {
|
|||||||
{/* Background idle state */}
|
{/* Background idle state */}
|
||||||
{!textState.showing && !imagePopup.showing && (
|
{!textState.showing && !imagePopup.showing && (
|
||||||
<div className="flex items-center justify-center w-full h-full">
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
<p className="text-gray-600 text-lg">Waiting for content...</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,4 +2,13 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-900 text-white h-screen w-screen flex items-center justify-center;
|
@apply bg-gray-900 text-white h-screen w-screen flex items-center justify-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes dot-blink {
|
||||||
|
0%, 80%, 100% { opacity: 0; }
|
||||||
|
40% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-1 { animation: dot-blink 1.4s infinite 0s; }
|
||||||
|
.dot-2 { animation: dot-blink 1.4s infinite 0.2s; }
|
||||||
|
.dot-3 { animation: dot-blink 1.4s infinite 0.4s; }
|
||||||
Reference in New Issue
Block a user