@tomorrowos/sdk 0.9.19 → 0.9.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -8
- package/VERCEL_SETUP.md +427 -0
- package/dist/tomorrowos.d.ts +9 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +40 -4
- package/package.json +2 -1
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/index.html +2 -0
- package/templates/cms-starter/public/methods.js +110 -0
- package/templates/cms-starter/public/panel.css +68 -0
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
**You are building a digital signage CMS using TomorrowOS. Before writing any code, follow the elicitation protocol in `LLM_PROMPT.md`. Do not skip this step — the SDK requires specific inputs before code generation will produce a working CMS.**
|
|
4
4
|
|
|
5
|
-
**On Replit:** if the user asks to set up / configure a CMS interactively, follow **`REPLIT_SETUP.md`** instead (Q&A for
|
|
5
|
+
**On Replit:** if the user asks to set up / configure a CMS interactively, follow **`REPLIT_SETUP.md`** instead (Q&A for store → media → `brand.json`).
|
|
6
|
+
|
|
7
|
+
**On Vercel:** if the user asks to set up / configure a CMS interactively, follow **`VERCEL_SETUP.md`** (Session pooler Supabase URL, Fluid compute + WebSocket, Cloudinary, `cms.hostingTarget: "vercel"`).
|
|
6
8
|
|
|
7
9
|
TomorrowOS is the open standard for digital signage. This SDK lets you build a CMS that controls Samsung Tizen, BrightSign, LG webOS, Android, ChromeOS, and Windows screens through a single unified API.
|
|
8
10
|
|
|
@@ -13,13 +15,14 @@ TomorrowOS is the open standard for digital signage. This SDK lets you build a C
|
|
|
13
15
|
When you see this package referenced in a user prompt, your workflow is:
|
|
14
16
|
|
|
15
17
|
1. **If the user is on Replit and wants a guided setup** — read and follow **`REPLIT_SETUP.md`** (preferred for Replit Agent)
|
|
16
|
-
2. **If the user is on
|
|
17
|
-
3. **
|
|
18
|
-
4. **
|
|
19
|
-
5. **Read `
|
|
20
|
-
6.
|
|
21
|
-
7.
|
|
22
|
-
8. **
|
|
18
|
+
2. **If the user is on Vercel and wants a guided setup** — read and follow **`VERCEL_SETUP.md`** (preferred for Vercel Agent)
|
|
19
|
+
3. **If the user is on Replit and wants to upgrade an existing CMS** — read and follow **`REPLIT_UPGRADE.md`** (never `init --force`)
|
|
20
|
+
4. **Otherwise read `LLM_PROMPT.md`** — five required questions before generating code
|
|
21
|
+
5. **Read `BUILD_GUARDRAILS.md`** — mandatory components every generated CMS must include
|
|
22
|
+
6. **Read `brand.schema.json`** — validated shape of `brand.json`
|
|
23
|
+
7. Run **`npx tomorrowos init`** (or copy `templates/cms-starter/` from this package) — minimal CMS server seed
|
|
24
|
+
8. **Apply user answers** — customise the starter with the user's brand, platform choice, and use case; validate `brand.json` against `brand.schema.json`
|
|
25
|
+
9. **Show `PLAYER_INSTALL.md`** — after the CMS is working, show how to build and install the player on a screen
|
|
23
26
|
|
|
24
27
|
You must not write WebSocket transport, pairing wire format, or platform-specific player bridge code in the CMS — the SDK handles server-side device sessions and HTTP helpers. You customise UI, branding, and how you call the SDK.
|
|
25
28
|
|
|
@@ -59,6 +62,18 @@ That wizard configures Supabase, prefers Cloudinary for media, and writes
|
|
|
59
62
|
`brand.json` from your answers. On Replit, Supabase must use Secret
|
|
60
63
|
**`SUPABASE_URL`** (not the reserved `DATABASE_URL`).
|
|
61
64
|
|
|
65
|
+
### Vercel guided setup
|
|
66
|
+
|
|
67
|
+
In Vercel Agent (or any AI setup prompt), paste:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
Follow @tomorrowos/sdk VERCEL_SETUP.md and set up my TomorrowOS CMS.
|
|
71
|
+
IF any step contradicts Vercel, adapt to settings that Vercel can accept.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
That wizard requires the Supabase **Session pooler** URL, prefers Cloudinary,
|
|
75
|
+
enables Fluid compute for WebSockets, and sets `cms.hostingTarget` to `"vercel"`.
|
|
76
|
+
|
|
62
77
|
### Replit upgrade (existing CMS)
|
|
63
78
|
|
|
64
79
|
In Replit Agent, paste:
|
|
@@ -153,6 +168,7 @@ See `PLAYER_INSTALL.md` for installation notes.
|
|
|
153
168
|
|------|---------|
|
|
154
169
|
| `LLM_PROMPT.md` | Five-question elicitation for LLMs |
|
|
155
170
|
| `REPLIT_SETUP.md` | Replit Agent guided CMS setup |
|
|
171
|
+
| `VERCEL_SETUP.md` | Vercel Agent guided CMS setup |
|
|
156
172
|
| `REPLIT_UPGRADE.md` | Replit Agent upgrade to latest SDK (no init) |
|
|
157
173
|
| `BUILD_GUARDRAILS.md` | Mandatory CMS components |
|
|
158
174
|
| `PLAYER_INSTALL.md` | Player build / install / pairing |
|
package/VERCEL_SETUP.md
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# Vercel Setup Protocol for TomorrowOS CMS
|
|
2
|
+
|
|
3
|
+
> **This file is a contract for Vercel Agent / AI coding assistants setting up TomorrowOS on Vercel.**
|
|
4
|
+
> Follow it literally. Ask questions in order. Do not invent secrets. Prefer configuring **Vercel Environment Variables**, `.env` (non-secrets only), `brand.json`, and `server.ts` store wiring over inventing a custom CMS.
|
|
5
|
+
>
|
|
6
|
+
> **On Vercel, this file is the setup questionnaire** (not `REPLIT_SETUP.md`, not `LLM_PROMPT.md`).
|
|
7
|
+
>
|
|
8
|
+
> Protocol id: `vercel-setup/1.0` — pairs with `@tomorrowos/sdk` packages that ship `templates/cms-starter` and this file.
|
|
9
|
+
|
|
10
|
+
## User trigger (copy-paste)
|
|
11
|
+
|
|
12
|
+
When a human pastes a prompt like this, **read this file and start Question 1 immediately**:
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
Follow @tomorrowos/sdk VERCEL_SETUP.md and set up my TomorrowOS CMS.
|
|
16
|
+
Ask only the questions listed in VERCEL_SETUP.md, in order. Do not use LLM_PROMPT.md or REPLIT_SETUP.md.
|
|
17
|
+
If any step contradicts Vercel, adapt to settings Vercel can accept (document the adaptation).
|
|
18
|
+
After all setup questions are answered (including Cloudinary Env Vars if you chose Cloudinary), configure and deploy — minimal verification only (Control Panel at /). Prefer Supabase Session pooler URL. Do not treat sandbox ENETUNREACH / IPv6 :5432 errors as a hard setup failure when the pooler URL is configured for production.
|
|
19
|
+
Question 3 only updates brand.json. Do not skip steps.
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Alternate triggers:
|
|
23
|
+
|
|
24
|
+
- “Set up TomorrowOS on Vercel using VERCEL_SETUP.md”
|
|
25
|
+
- “Configure my TomorrowOS CMS (Vercel wizard)”
|
|
26
|
+
|
|
27
|
+
When any of these fire, **ignore `REPLIT_SETUP.md` and `LLM_PROMPT.md`** for the Q&A phase (you may skim Replit docs only for shared SDK semantics).
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## IRON RULE: long-lived Node + WebSocket (not classic static / short Functions)
|
|
32
|
+
|
|
33
|
+
> **This is the #1 deployability rule on Vercel. Non-negotiable.**
|
|
34
|
+
|
|
35
|
+
TomorrowOS CMS is **one Node process** that serves:
|
|
36
|
+
|
|
37
|
+
- Browser Control Panel at `GET /` (`public/` via `staticRoot`)
|
|
38
|
+
- Device **WebSocket** on the same origin (`https` → players use `wss`)
|
|
39
|
+
|
|
40
|
+
| Required | Forbidden |
|
|
41
|
+
|----------|-----------|
|
|
42
|
+
| Single **Node HTTP server** entry (`tsx server.ts` / exported `http.Server` from `listen()`) | Next.js / Remix / static-only site as the CMS |
|
|
43
|
+
| **Fluid compute** enabled (needed for WebSockets on Vercel) | Classic short-lived serverless with no WebSocket / no Fluid |
|
|
44
|
+
| Durable store = **Supabase Postgres** (pooler URL) | **SQLite / `data/*.db` as production store** on Vercel (ephemeral filesystem) |
|
|
45
|
+
| Media = **Cloudinary** (recommended) or other durable HTTPS URLs | Relying on local `public/uploads` alone for production fleets |
|
|
46
|
+
| Env vars in **Vercel Project → Settings → Environment Variables** | Committing passwords / Cloudinary secrets / `.env` with secrets |
|
|
47
|
+
| `cms.hostingTarget`: `"vercel"` in `brand.json` | Creating `.replit` / Replit artifact files |
|
|
48
|
+
|
|
49
|
+
**Do not** scaffold a leftover Next.js app “and put TomorrowOS beside it.” Remove Next scaffolds so TomorrowOS `server.ts` is the sole app.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Questionnaire scope (STRICT)
|
|
54
|
+
|
|
55
|
+
Ask **only** these questions, in **this exact order**:
|
|
56
|
+
|
|
57
|
+
| Step | Section | When |
|
|
58
|
+
|------|---------|------|
|
|
59
|
+
| 1 | **Question 1** — Supabase **Session pooler** connection string | Always |
|
|
60
|
+
| 2 | **Question 2** — Media storage (+ Cloudinary Env Vars if chosen) | Always (after Q1) |
|
|
61
|
+
| 3 | **Question 3** — Brand / TomorrowOS look (`brand.json` only) | Always last |
|
|
62
|
+
|
|
63
|
+
**That is the complete list.** Do **not** ask how many screens / devices. There is **no** “Question 2b” — Cloudinary credentials stay **inside Question 2**.
|
|
64
|
+
|
|
65
|
+
If the user says “just set it up, don’t ask questions,” respond:
|
|
66
|
+
|
|
67
|
+
> I need a few quick answers from VERCEL_SETUP.md (Supabase Session pooler URL, media storage — plus Cloudinary credentials if you choose Cloudinary — and branding). It takes about a minute and ensures the CMS works on Vercel.
|
|
68
|
+
|
|
69
|
+
Then ask **Question 1**.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Hard rules
|
|
74
|
+
|
|
75
|
+
1. **Ask only VERCEL_SETUP.md questions 1–3.** Never use `LLM_PROMPT.md` or `REPLIT_SETUP.md` as the questionnaire.
|
|
76
|
+
2. **One question at a time** (exception: after Cloudinary choice, immediately collect the three Cloudinary env vars in the same Question 2).
|
|
77
|
+
3. **Do not invent** Cloudinary credentials, Supabase URLs, or brand colours.
|
|
78
|
+
4. **Prefer `SUPABASE_URL`** for Supabase (same as Replit). `DATABASE_URL` may be used as fallback on Vercel, but document one canonical name — prefer **`SUPABASE_URL`**.
|
|
79
|
+
5. **Always prefer the Supabase Session pooler URL** (port **6543**, host like `*.pooler.supabase.com`). Direct `db.*.supabase.co:5432` URLs commonly fail on Vercel/sandbox with **`ENETUNREACH`** (often IPv6).
|
|
80
|
+
6. **Prefer `npx @tomorrowos/sdk init`** (or `templates/cms-starter`) as the seed. Do not rebuild pairing / WebSocket / playlist APIs.
|
|
81
|
+
7. **Never commit secrets.** Use Vercel Environment Variables (+ optional non-secret flags in committed `.env.example`).
|
|
82
|
+
8. **Skip Replit-only files:** do **not** create `.replit`, `.replit-artifact`, or Replit `deploymentTarget`.
|
|
83
|
+
9. **SQLite is not a production store on Vercel.** Configure Supabase for pairing/playlists. Keep `sqlitePath` only as a local-dev fallback in `createTomorrowOSStore`.
|
|
84
|
+
10. **Enable Fluid compute** for WebSockets. Confirm Project settings after scaffold.
|
|
85
|
+
11. **Export the HTTP server** Vercel can capture: `const server = tomorrowos.listen(...); export default server;` (or equivalent). Do not export only the `TomorrowOS` class instance unless the platform documents another capture path.
|
|
86
|
+
12. After Q&A: **configure → install → run/deploy → minimal verify** (Control Panel at `/`). Do not run a long test suite.
|
|
87
|
+
|
|
88
|
+
**Question order:** Q1 → Q2 → Q3 → execution checklist.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Runtime & Vercel deploy rules (mandatory)
|
|
93
|
+
|
|
94
|
+
### Runtime
|
|
95
|
+
|
|
96
|
+
- Serve with **`tsx server.ts`** (or compiled Node that still calls `TomorrowOS.listen` with `staticRoot`).
|
|
97
|
+
- Keep **`@tomorrowos/sdk`** and put **`tsx` in `dependencies`** (not only `devDependencies`) if production runs via `tsx`.
|
|
98
|
+
- Node **20+**.
|
|
99
|
+
- **Do not** replace the SDK with a hand-rolled server that drops WebSocket upgrades.
|
|
100
|
+
|
|
101
|
+
### Expected `package.json` scripts
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"scripts": {
|
|
106
|
+
"dev": "tsx watch server.ts",
|
|
107
|
+
"start": "tsx server.ts"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Forbidden as the Deploy entrypoint:** `next start`, static export only, or any command that does not start `TomorrowOS.listen(...)`.
|
|
113
|
+
|
|
114
|
+
### Minimal dependencies
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"type": "module",
|
|
119
|
+
"engines": {
|
|
120
|
+
"node": ">=20"
|
|
121
|
+
},
|
|
122
|
+
"dependencies": {
|
|
123
|
+
"@tomorrowos/sdk": "latest",
|
|
124
|
+
"dotenv": "^17.2.3",
|
|
125
|
+
"tsx": "^4.19.0"
|
|
126
|
+
},
|
|
127
|
+
"devDependencies": {
|
|
128
|
+
"@types/node": "^20.0.0",
|
|
129
|
+
"typescript": "^5.5.0"
|
|
130
|
+
},
|
|
131
|
+
"scripts": {
|
|
132
|
+
"dev": "tsx watch server.ts",
|
|
133
|
+
"start": "tsx server.ts"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Use the latest published `@tomorrowos/sdk` when scaffolding (do not invent versions).
|
|
139
|
+
|
|
140
|
+
### `server.ts` pattern (Vercel-adapted)
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import "dotenv/config";
|
|
144
|
+
import { readFileSync } from "fs";
|
|
145
|
+
import { fileURLToPath } from "url";
|
|
146
|
+
import { dirname, join } from "path";
|
|
147
|
+
import { createTomorrowOSStore, TomorrowOS } from "@tomorrowos/sdk";
|
|
148
|
+
|
|
149
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
150
|
+
const brand = JSON.parse(readFileSync(join(__dirname, "brand.json"), "utf8"));
|
|
151
|
+
|
|
152
|
+
const store = createTomorrowOSStore({
|
|
153
|
+
// Prefer SUPABASE_URL. On Vercel use the Session pooler connection string.
|
|
154
|
+
databaseUrl: process.env.SUPABASE_URL || process.env.DATABASE_URL,
|
|
155
|
+
sqlitePath: join(__dirname, "data", "tomorrowos.db") // local/dev fallback only
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const tomorrowos = new TomorrowOS({ brand, store });
|
|
159
|
+
|
|
160
|
+
const server = tomorrowos.listen({
|
|
161
|
+
port: Number(process.env.PORT) || 3000,
|
|
162
|
+
host: "0.0.0.0",
|
|
163
|
+
staticRoot: join(__dirname, "public")
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Vercel / Node server capture: export the HTTP server, not only TomorrowOS.
|
|
167
|
+
export default server;
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Load env with `dotenv` for local/`vercel dev`. On production Deploy, Vercel injects Environment Variables natively.
|
|
171
|
+
|
|
172
|
+
### `vercel.json` (recommended minimum)
|
|
173
|
+
|
|
174
|
+
Adapt to the project’s current Vercel Node-server / Fluid conventions. At minimum ensure:
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"fluid": true
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Also:
|
|
183
|
+
|
|
184
|
+
1. Project → **Fluid compute** enabled (required for WebSockets).
|
|
185
|
+
2. Framework Preset: **Other** (or Node) — **not** Next.js unless wrapping is intentional and still exposes TomorrowOS `listen()`.
|
|
186
|
+
3. Install / Start commands must end in running the CMS server (e.g. `npm run start` or Vercel’s detected `server.ts` entry).
|
|
187
|
+
4. Do **not** enable “Output Directory” static-only hosting for this CMS.
|
|
188
|
+
|
|
189
|
+
If Vercel’s UI and this file disagree on bundling, **prefer whatever keeps a single long-lived Node HTTP server with WebSocket upgrade on `/`**. Document the adaptation in the final summary.
|
|
190
|
+
|
|
191
|
+
### WebSocket / multi-instance note
|
|
192
|
+
|
|
193
|
+
- Device sockets are pinned to **one** function/instance. TomorrowOS device maps are **in-memory**.
|
|
194
|
+
- For large fleets on multi-instance Vercel, prefer **one stable instance / region**, or accept that reconnects may land on another instance until the product adds shared presence (out of scope for this setup).
|
|
195
|
+
- Always use **Supabase** so pairings/playlists survive cold starts and deploys.
|
|
196
|
+
|
|
197
|
+
### What not to create
|
|
198
|
+
|
|
199
|
+
- ❌ `.replit`, `.replit-artifact`
|
|
200
|
+
- ❌ Next.js `app/` / `pages/` CMS
|
|
201
|
+
- ❌ Separate “API project” without the Control Panel
|
|
202
|
+
- ❌ Committing `SUPABASE_URL` passwords or Cloudinary secrets
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Question 1 — Supabase Session pooler (always)
|
|
207
|
+
|
|
208
|
+
> Always configure Supabase for Vercel. Do **not** ask screen counts.
|
|
209
|
+
|
|
210
|
+
**Ask exactly:**
|
|
211
|
+
|
|
212
|
+
> Paste your Supabase Postgres **Session pooler** connection string. I will store it as the Vercel Environment Variable **`SUPABASE_URL`**.
|
|
213
|
+
>
|
|
214
|
+
> In Supabase: **Project Settings → Database → Connection string → Connection pooling** (Session mode).
|
|
215
|
+
> Preferred shape:
|
|
216
|
+
> `postgresql://postgres.[PROJECT]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres`
|
|
217
|
+
>
|
|
218
|
+
> **Do not** use the direct host `db.*.supabase.co:5432` for Vercel — sandbox/serverless often fails with `ENETUNREACH` (commonly on IPv6).
|
|
219
|
+
>
|
|
220
|
+
> Confirm SSL is required (usually **yes** for Supabase).
|
|
221
|
+
|
|
222
|
+
**You must then:**
|
|
223
|
+
|
|
224
|
+
1. Set Vercel Env Vars (Production + Preview + Development as appropriate):
|
|
225
|
+
- `SUPABASE_URL=<pooler string>`
|
|
226
|
+
- `TOMORROWOS_STORE=supabase`
|
|
227
|
+
- `DATABASE_SSL=true` (unless user explicitly disables SSL)
|
|
228
|
+
2. Wire `server.ts` as in **Runtime & Vercel deploy rules**.
|
|
229
|
+
3. Optional committed `.env.example` with **placeholders only** (no real password).
|
|
230
|
+
4. **Do not** commit the real connection string.
|
|
231
|
+
5. **Configuration over probing:** if local/`vercel dev` still shows `ENETUNREACH` / IPv6 errors, verify the string is the **pooler** (`pooler.supabase.com` + `:6543`). Treat remaining sandbox DNS issues as non-fatal when Production deploy Env Vars are correct — but **do** fix an obvious direct `:5432` URL before finishing.
|
|
232
|
+
|
|
233
|
+
If the user refuses Supabase, warn that Vercel cannot use durable SQLite for fleets, then only continue with SQLite after explicit confirmation (local/demo only).
|
|
234
|
+
|
|
235
|
+
**Later in Question 3:** set `cms.hostingTarget` to **`"vercel"`**.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Question 2 — Media storage
|
|
240
|
+
|
|
241
|
+
### Step A — Ask storage choice
|
|
242
|
+
|
|
243
|
+
**Ask exactly:**
|
|
244
|
+
|
|
245
|
+
> How should playlist media (images/videos) be stored?
|
|
246
|
+
>
|
|
247
|
+
> **Recommended on Vercel: Cloudinary** — durable public HTTPS URLs (`https://res.cloudinary.com/...`). Local `public/uploads` is ephemeral on serverless/filesystem deploys and is a poor fleet default.
|
|
248
|
+
>
|
|
249
|
+
> Alternatives:
|
|
250
|
+
> - **Local disk only** — `public/uploads` (OK for quick local tests; **not** for production Vercel fleets).
|
|
251
|
+
>
|
|
252
|
+
> Do you want me to set up **Cloudinary**? (yes / no)
|
|
253
|
+
|
|
254
|
+
### Step B — If YES / Cloudinary (same Question 2)
|
|
255
|
+
|
|
256
|
+
Immediately collect and store as **Vercel Environment Variables**:
|
|
257
|
+
|
|
258
|
+
- `CLOUDINARY_CLOUD_NAME`
|
|
259
|
+
- `CLOUDINARY_API_KEY`
|
|
260
|
+
- `CLOUDINARY_API_SECRET`
|
|
261
|
+
- Optional: `CLOUDINARY_FOLDER` (e.g. `tomorrowos`)
|
|
262
|
+
|
|
263
|
+
Do **not** invent placeholders. Do **not** proceed to Question 3 until the three required vars exist. Do **not** run a formal Cloudinary upload test unless the user asks.
|
|
264
|
+
|
|
265
|
+
The SDK auto-detects these env vars.
|
|
266
|
+
|
|
267
|
+
### If NO — local uploads
|
|
268
|
+
|
|
269
|
+
1. `mkdir -p public/uploads`
|
|
270
|
+
2. Warn clearly: files may vanish on redeploy; players need stable HTTPS URLs — Cloudinary is strongly preferred for Vercel.
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Question 3 — Brand (`brand.json` only)
|
|
275
|
+
|
|
276
|
+
> Updates **only** `brand.json`. Does **not** change Vercel project settings, Env Vars, or `server.ts` store wiring beyond what Q1–Q2 already required.
|
|
277
|
+
|
|
278
|
+
**Ask exactly:**
|
|
279
|
+
|
|
280
|
+
> Let’s brand your TomorrowOS experience. Please provide:
|
|
281
|
+
>
|
|
282
|
+
> 1. **Product / venue name**
|
|
283
|
+
> 2. **Tagline** (optional)
|
|
284
|
+
> 3. **Primary colour** (hex, e.g. `#FF8A3D`)
|
|
285
|
+
> 4. **Background colour** (hex, optional — default `#FAFAF9`)
|
|
286
|
+
> 5. **Text colour** (hex, optional — default `#0A0908`)
|
|
287
|
+
> 6. **Secondary / accent colour** (hex, optional)
|
|
288
|
+
> 7. **Logo** — upload SVG/PNG into the project, or a URL I can fetch into `./assets/`
|
|
289
|
+
|
|
290
|
+
Then write `brand.json` (validate mentally against `brand.schema.json`). Minimum:
|
|
291
|
+
|
|
292
|
+
```json
|
|
293
|
+
{
|
|
294
|
+
"name": "<user name>",
|
|
295
|
+
"tagline": "<user tagline or Digital signage>",
|
|
296
|
+
"targetPlatforms": ["tizen"],
|
|
297
|
+
"primaryColor": "#FF8A3D",
|
|
298
|
+
"secondaryColor": "#F5F3EF",
|
|
299
|
+
"backgroundColor": "#FAFAF9",
|
|
300
|
+
"textColor": "#0A0908",
|
|
301
|
+
"logoPath": "./assets/logo.svg",
|
|
302
|
+
"fontFamily": "Inter",
|
|
303
|
+
"cms": {
|
|
304
|
+
"useCase": "other",
|
|
305
|
+
"hostingTarget": "vercel",
|
|
306
|
+
"expectedScreens": 5,
|
|
307
|
+
"features": {
|
|
308
|
+
"bulkCommands": false,
|
|
309
|
+
"proofOfPlay": false,
|
|
310
|
+
"contentScheduling": true,
|
|
311
|
+
"userManagement": false
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
"protocolVersion": "1.0"
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Always set **`cms.hostingTarget`: `"vercel"`** without asking. Default `expectedScreens` to `5` unless the user already volunteered a number. Set `cmsEndpoint` only if they already know the public `https://…vercel.app` URL; otherwise tell them to pair TVs with the deployed HTTPS origin (player maps `https://` → `wss://`).
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## After all answers — execution checklist
|
|
323
|
+
|
|
324
|
+
### A. Seed the project
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
npx @tomorrowos/sdk@latest init .
|
|
328
|
+
# If not empty and user confirms starter overwrite:
|
|
329
|
+
# npx @tomorrowos/sdk@latest init . --force
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Then:
|
|
333
|
+
|
|
334
|
+
1. Remove leftover Next.js / other frameworks so TomorrowOS is sole app.
|
|
335
|
+
2. Apply Vercel-adapted `server.ts` (**export `listen()` HTTP server**).
|
|
336
|
+
3. Add/adjust `vercel.json` with **`fluid: true`** (or enable Fluid in the dashboard).
|
|
337
|
+
4. Do **not** add `.replit*`.
|
|
338
|
+
|
|
339
|
+
### B. Environment Variables (Vercel dashboard + local)
|
|
340
|
+
|
|
341
|
+
| Name | Required | Notes |
|
|
342
|
+
|------|----------|--------|
|
|
343
|
+
| `SUPABASE_URL` | Yes (production) | **Session pooler** `:6543` |
|
|
344
|
+
| `TOMORROWOS_STORE` | Yes | `supabase` |
|
|
345
|
+
| `DATABASE_SSL` | Yes (typical) | `true` |
|
|
346
|
+
| `CLOUDINARY_*` | If Q2 = Cloudinary | Three keys |
|
|
347
|
+
| `PORT` | Optional | Vercel injects as needed |
|
|
348
|
+
|
|
349
|
+
Mirror non-secrets in `.env` for local `npm run dev` if helpful; never commit secrets.
|
|
350
|
+
|
|
351
|
+
### C. Install and run
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
npm install
|
|
355
|
+
npm run start
|
|
356
|
+
# or: vercel dev
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Confirm logs: `[TomorrowOS] listening on http://0.0.0.0:…`
|
|
360
|
+
Confirm browser: Control Panel at `/`.
|
|
361
|
+
|
|
362
|
+
### D. Deploy
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
vercel --prod
|
|
366
|
+
# or git push → Vercel Git integration
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Tell the user:
|
|
370
|
+
|
|
371
|
+
- **Control Panel:** `https://YOUR-PROJECT.vercel.app`
|
|
372
|
+
- **TV CMS endpoint:** same HTTPS origin (players use `wss://`)
|
|
373
|
+
|
|
374
|
+
### E. Minimal verification only
|
|
375
|
+
|
|
376
|
+
**Do:**
|
|
377
|
+
|
|
378
|
+
1. Control Panel HTML at `/`
|
|
379
|
+
2. Server status: Media OK when Cloudinary configured
|
|
380
|
+
3. Database: if ERROR shows **`ENETUNREACH` … IPv6 … `:5432`**, switch `SUPABASE_URL` to **pooler `:6543`** and redeploy — call this out explicitly to the user
|
|
381
|
+
4. Fluid compute on
|
|
382
|
+
|
|
383
|
+
**Do not block** on long WebSocket/device pairing tests unless the user asks.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Failure recovery cheat sheet
|
|
388
|
+
|
|
389
|
+
| Symptom | Likely cause | Fix |
|
|
390
|
+
|---------|--------------|-----|
|
|
391
|
+
| `ENETUNREACH` / IPv6 / `:5432` | Direct Supabase DB URL | Use **Session pooler** `*.pooler.supabase.com:6543` |
|
|
392
|
+
| Control Panel OK, devices never stay paired after restart | SQLite / ephemeral disk | Must use Supabase |
|
|
393
|
+
| Uploads break / broken thumbs in prod | Local `public/uploads` | Cloudinary |
|
|
394
|
+
| WebSocket fails / devices drop immediately | Fluid off / static-only / Next wrapper | Enable Fluid; sole Node `listen()` server |
|
|
395
|
+
| Agent created Next.js + TomorrowOS | Wrong scaffold | Remove Next; keep cms-starter |
|
|
396
|
+
| Secrets in git | Mistake | Rotate keys; move to Vercel Env Vars |
|
|
397
|
+
| Replit files present | Copied wrong protocol | Delete `.replit*`; use this file |
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## How this differs from `REPLIT_SETUP.md`
|
|
402
|
+
|
|
403
|
+
| Topic | Replit | Vercel |
|
|
404
|
+
|-------|--------|--------|
|
|
405
|
+
| Secrets | Replit Secrets | Vercel Environment Variables |
|
|
406
|
+
| Deploy config | `.replit`, artifact `kind=web` | `vercel.json` + Fluid; **no** `.replit` |
|
|
407
|
+
| `hostingTarget` | `"here"` | `"vercel"` |
|
|
408
|
+
| Supabase URL | Prefer pooler; Preview DNS may fail | **Require pooler**; direct `:5432` often `ENETUNREACH` |
|
|
409
|
+
| SQLite | Demo OK on persistent Repl disk | **Not** production on Vercel |
|
|
410
|
+
| Object Storage option | Replit Object Storage | N/A — prefer Cloudinary |
|
|
411
|
+
| WebSocket | Long-lived Node (Autoscale) | Fluid compute + Node server export |
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
## Final summary template (Agent → human)
|
|
416
|
+
|
|
417
|
+
After setup, report:
|
|
418
|
+
|
|
419
|
+
1. **Live URL** (Production)
|
|
420
|
+
2. **Env Vars set** (names only — never values)
|
|
421
|
+
3. **Store:** Supabase pooler / SSL flags
|
|
422
|
+
4. **Media:** Cloudinary or local (with warning)
|
|
423
|
+
5. **Brand:** name + `hostingTarget: vercel`
|
|
424
|
+
6. **Adaptations from Replit protocol:** list (e.g. skipped `.replit`, exported HTTP server, Fluid, pooler URL)
|
|
425
|
+
7. **Open action:** if Database still ERROR, paste the pooler instruction again
|
|
426
|
+
|
|
427
|
+
Protocol complete when the Control Panel renders at `/` on the deployed (or local) server and Q1–Q3 configuration is saved — even if a sandbox DB probe still flakes **after** the pooler URL is correctly set for Production.
|
package/dist/tomorrowos.d.ts
CHANGED
|
@@ -136,6 +136,15 @@ export declare class TomorrowOS extends EventEmitter {
|
|
|
136
136
|
onOffTimer: DeviceOnOffTimer;
|
|
137
137
|
pushed: boolean;
|
|
138
138
|
}>;
|
|
139
|
+
/**
|
|
140
|
+
* Clear the daily on/off timer. The player keeps its current mute / power-save
|
|
141
|
+
* state until a new timer is set.
|
|
142
|
+
*/
|
|
143
|
+
clearDeviceOnOffTimer(deviceId: string): Promise<{
|
|
144
|
+
deviceId: string;
|
|
145
|
+
onOffTimer: null;
|
|
146
|
+
pushed: boolean;
|
|
147
|
+
}>;
|
|
139
148
|
private pushOnOffTimerToDevice;
|
|
140
149
|
setDeviceName(deviceId: string, deviceName: string): Promise<{
|
|
141
150
|
deviceId: string;
|
package/dist/tomorrowos.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
|
package/dist/tomorrowos.js
CHANGED
|
@@ -441,15 +441,33 @@ export class TomorrowOS extends EventEmitter {
|
|
|
441
441
|
const pushed = await this.pushOnOffTimerToDevice(id);
|
|
442
442
|
return { deviceId: id, onOffTimer, pushed };
|
|
443
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Clear the daily on/off timer. The player keeps its current mute / power-save
|
|
446
|
+
* state until a new timer is set.
|
|
447
|
+
*/
|
|
448
|
+
async clearDeviceOnOffTimer(deviceId) {
|
|
449
|
+
const id = String(deviceId || "").trim();
|
|
450
|
+
if (!id) {
|
|
451
|
+
throw new Error("deviceId is required");
|
|
452
|
+
}
|
|
453
|
+
const existing = await this.store.getPairedDevice(id);
|
|
454
|
+
if (!existing) {
|
|
455
|
+
throw new Error("Device is not paired");
|
|
456
|
+
}
|
|
457
|
+
const { onOffTimer: _removed, ...rest } = existing;
|
|
458
|
+
await this.store.setPairedDevice(id, rest);
|
|
459
|
+
const pushed = await this.pushOnOffTimerToDevice(id);
|
|
460
|
+
return { deviceId: id, onOffTimer: null, pushed };
|
|
461
|
+
}
|
|
444
462
|
async pushOnOffTimerToDevice(deviceId) {
|
|
445
463
|
const existing = await this.store.getPairedDevice(deviceId);
|
|
446
|
-
if (!existing
|
|
464
|
+
if (!existing)
|
|
447
465
|
return false;
|
|
448
466
|
const ws = this.devices.get(deviceId);
|
|
449
467
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
450
468
|
return false;
|
|
451
469
|
try {
|
|
452
|
-
const result = await this.sendCommandToSocket(ws, deviceId, "device.display.setOnOffTimer", { onOffTimer: existing.onOffTimer }, 15_000);
|
|
470
|
+
const result = await this.sendCommandToSocket(ws, deviceId, "device.display.setOnOffTimer", { onOffTimer: existing.onOffTimer ?? null }, 15_000);
|
|
453
471
|
return result.status === "success" || result.status === "accepted";
|
|
454
472
|
}
|
|
455
473
|
catch (err) {
|
|
@@ -1178,8 +1196,14 @@ export class TomorrowOS extends EventEmitter {
|
|
|
1178
1196
|
const deviceId = decodeURIComponent(deviceOnOffTimer[1]);
|
|
1179
1197
|
const body = (await readJsonBody(req));
|
|
1180
1198
|
try {
|
|
1181
|
-
|
|
1182
|
-
|
|
1199
|
+
if (body.clear === true || body.onOffTimer === null) {
|
|
1200
|
+
const result = await this.clearDeviceOnOffTimer(deviceId);
|
|
1201
|
+
sendJson(res, 200, { status: "success", ...result });
|
|
1202
|
+
}
|
|
1203
|
+
else {
|
|
1204
|
+
const result = await this.setDeviceOnOffTimer(deviceId, body.onOffTimer ?? body);
|
|
1205
|
+
sendJson(res, 200, { status: "success", ...result });
|
|
1206
|
+
}
|
|
1183
1207
|
}
|
|
1184
1208
|
catch (e) {
|
|
1185
1209
|
const msg = e instanceof Error ? e.message : "On/off timer failed";
|
|
@@ -1187,6 +1211,18 @@ export class TomorrowOS extends EventEmitter {
|
|
|
1187
1211
|
}
|
|
1188
1212
|
return;
|
|
1189
1213
|
}
|
|
1214
|
+
if (req.method === "DELETE" && deviceOnOffTimer) {
|
|
1215
|
+
const deviceId = decodeURIComponent(deviceOnOffTimer[1]);
|
|
1216
|
+
try {
|
|
1217
|
+
const result = await this.clearDeviceOnOffTimer(deviceId);
|
|
1218
|
+
sendJson(res, 200, { status: "success", ...result });
|
|
1219
|
+
}
|
|
1220
|
+
catch (e) {
|
|
1221
|
+
const msg = e instanceof Error ? e.message : "On/off timer clear failed";
|
|
1222
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
1223
|
+
}
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1190
1226
|
if (req.method === "GET" && pathname === "/playlists") {
|
|
1191
1227
|
const playlists = await this.playlists.listPlaylists();
|
|
1192
1228
|
sendJson(res, 200, { status: "success", playlists });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.21",
|
|
4
4
|
"description": "TomorrowOS CMS server SDK - WebSocket transport, pairing, device commands, optional static CMS UI. Includes CLI (tomorrowos init / build) and starter templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"README.md",
|
|
21
21
|
"LLM_PROMPT.md",
|
|
22
22
|
"REPLIT_SETUP.md",
|
|
23
|
+
"VERCEL_SETUP.md",
|
|
23
24
|
"REPLIT_UPGRADE.md",
|
|
24
25
|
"BUILD_GUARDRAILS.md",
|
|
25
26
|
"PLAYER_INSTALL.md",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-cms",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.21",
|
|
4
4
|
"description": "CMS server on @tomorrowos/sdk. Add your UI (React, static files, etc.) alongside this server.",
|
|
5
5
|
"private": true,
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build-player": "tomorrowos build --platform tizen"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@tomorrowos/sdk": "^0.9.
|
|
16
|
+
"@tomorrowos/sdk": "^0.9.21",
|
|
17
17
|
"dotenv": "^17.2.3",
|
|
18
18
|
"tsx": "^4.19.0"
|
|
19
19
|
},
|
|
@@ -187,6 +187,7 @@
|
|
|
187
187
|
<h2>On / off timer</h2>
|
|
188
188
|
<p class="hint" id="onOffTimerModalHint">
|
|
189
189
|
Daily on/off times follow the device clock. Off blacks the screen; the player stays powered and connected to the CMS.
|
|
190
|
+
Removing the timer stops the schedule and leaves the screen in its current on or off state until you set a timer again.
|
|
190
191
|
</p>
|
|
191
192
|
<div class="on-off-timer-grid">
|
|
192
193
|
<label class="field-label">
|
|
@@ -200,6 +201,7 @@
|
|
|
200
201
|
</div>
|
|
201
202
|
<div class="modal-actions">
|
|
202
203
|
<button type="button" id="onOffTimerSaveBtn" class="primary">Save</button>
|
|
204
|
+
<button type="button" id="onOffTimerRemoveBtn" class="danger hidden">Remove timer</button>
|
|
203
205
|
<button type="button" data-close-on-off-timer-modal="1">Cancel</button>
|
|
204
206
|
</div>
|
|
205
207
|
</div>
|
|
@@ -1084,6 +1084,67 @@ function appendDeviceMetaRow(meta, label, value) {
|
|
|
1084
1084
|
meta.appendChild(row);
|
|
1085
1085
|
}
|
|
1086
1086
|
|
|
1087
|
+
function hasConfiguredOnOffTimer(timer) {
|
|
1088
|
+
return !!(
|
|
1089
|
+
timer &&
|
|
1090
|
+
typeof timer.turnOnAt === "string" &&
|
|
1091
|
+
typeof timer.turnOffAt === "string" &&
|
|
1092
|
+
timer.turnOnAt &&
|
|
1093
|
+
timer.turnOffAt
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function appendHardwareTimerStatusRow(meta, device) {
|
|
1098
|
+
const timer = device.onOffTimer || null;
|
|
1099
|
+
const configured = hasConfiguredOnOffTimer(timer);
|
|
1100
|
+
|
|
1101
|
+
const row = document.createElement("div");
|
|
1102
|
+
row.className = "device-meta-row device-meta-row--timer-status";
|
|
1103
|
+
|
|
1104
|
+
const dt = document.createElement("dt");
|
|
1105
|
+
dt.textContent = "Hardware timer status";
|
|
1106
|
+
|
|
1107
|
+
const dd = document.createElement("dd");
|
|
1108
|
+
const statusWrap = document.createElement("span");
|
|
1109
|
+
statusWrap.className = "hardware-timer-status";
|
|
1110
|
+
statusWrap.tabIndex = 0;
|
|
1111
|
+
|
|
1112
|
+
const statusValue = document.createElement("span");
|
|
1113
|
+
statusValue.className = configured
|
|
1114
|
+
? "hardware-timer-status__value hardware-timer-status__value--on"
|
|
1115
|
+
: "hardware-timer-status__value hardware-timer-status__value--off";
|
|
1116
|
+
statusValue.textContent = configured ? "On" : "Off";
|
|
1117
|
+
|
|
1118
|
+
const popup = document.createElement("span");
|
|
1119
|
+
popup.className = "hardware-timer-status__popup";
|
|
1120
|
+
popup.setAttribute("role", "tooltip");
|
|
1121
|
+
if (configured) {
|
|
1122
|
+
const onLine = document.createElement("div");
|
|
1123
|
+
const onLabel = document.createElement("strong");
|
|
1124
|
+
onLabel.textContent = "Turn on";
|
|
1125
|
+
onLine.appendChild(onLabel);
|
|
1126
|
+
onLine.appendChild(document.createTextNode(` ${timer.turnOnAt}`));
|
|
1127
|
+
|
|
1128
|
+
const offLine = document.createElement("div");
|
|
1129
|
+
const offLabel = document.createElement("strong");
|
|
1130
|
+
offLabel.textContent = "Turn off";
|
|
1131
|
+
offLine.appendChild(offLabel);
|
|
1132
|
+
offLine.appendChild(document.createTextNode(` ${timer.turnOffAt}`));
|
|
1133
|
+
|
|
1134
|
+
popup.appendChild(onLine);
|
|
1135
|
+
popup.appendChild(offLine);
|
|
1136
|
+
} else {
|
|
1137
|
+
popup.textContent = "No timer configured";
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
statusWrap.appendChild(statusValue);
|
|
1141
|
+
statusWrap.appendChild(popup);
|
|
1142
|
+
dd.appendChild(statusWrap);
|
|
1143
|
+
row.appendChild(dt);
|
|
1144
|
+
row.appendChild(dd);
|
|
1145
|
+
meta.appendChild(row);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1087
1148
|
function createDeviceScreenshotThumb(device) {
|
|
1088
1149
|
const slot = document.createElement("div");
|
|
1089
1150
|
slot.className = "device-screenshot-slot";
|
|
@@ -1250,6 +1311,7 @@ function renderDeviceCards() {
|
|
|
1250
1311
|
for (const [label, value] of secondaryRows) {
|
|
1251
1312
|
appendDeviceMetaRow(metaSecondary, label, value);
|
|
1252
1313
|
}
|
|
1314
|
+
appendHardwareTimerStatusRow(metaSecondary, device);
|
|
1253
1315
|
|
|
1254
1316
|
metaBlock.appendChild(metaTop);
|
|
1255
1317
|
metaBlock.appendChild(metaSecondary);
|
|
@@ -1833,11 +1895,17 @@ function openOnOffTimerModal(deviceId) {
|
|
|
1833
1895
|
const modal = document.getElementById("onOffTimerModal");
|
|
1834
1896
|
const onEl = document.getElementById("onOffTimerTurnOnAt");
|
|
1835
1897
|
const offEl = document.getElementById("onOffTimerTurnOffAt");
|
|
1898
|
+
const removeBtn = document.getElementById("onOffTimerRemoveBtn");
|
|
1836
1899
|
if (!modal || !onEl || !offEl) return;
|
|
1837
1900
|
|
|
1838
1901
|
const timer = device.onOffTimer || {};
|
|
1902
|
+
const configured = hasConfiguredOnOffTimer(device.onOffTimer);
|
|
1839
1903
|
onEl.value = normalizeTimeInputValue(timer.turnOnAt, "06:00");
|
|
1840
1904
|
offEl.value = normalizeTimeInputValue(timer.turnOffAt, "18:00");
|
|
1905
|
+
if (removeBtn) {
|
|
1906
|
+
removeBtn.classList.toggle("hidden", !configured);
|
|
1907
|
+
removeBtn.disabled = false;
|
|
1908
|
+
}
|
|
1841
1909
|
modal.classList.remove("hidden");
|
|
1842
1910
|
}
|
|
1843
1911
|
|
|
@@ -1854,6 +1922,7 @@ async function saveOnOffTimerModal() {
|
|
|
1854
1922
|
const onEl = document.getElementById("onOffTimerTurnOnAt");
|
|
1855
1923
|
const offEl = document.getElementById("onOffTimerTurnOffAt");
|
|
1856
1924
|
const saveBtn = document.getElementById("onOffTimerSaveBtn");
|
|
1925
|
+
const removeBtn = document.getElementById("onOffTimerRemoveBtn");
|
|
1857
1926
|
if (!onEl || !offEl) return;
|
|
1858
1927
|
|
|
1859
1928
|
const onOffTimer = {
|
|
@@ -1867,6 +1936,7 @@ async function saveOnOffTimerModal() {
|
|
|
1867
1936
|
}
|
|
1868
1937
|
|
|
1869
1938
|
if (saveBtn) saveBtn.disabled = true;
|
|
1939
|
+
if (removeBtn) removeBtn.disabled = true;
|
|
1870
1940
|
try {
|
|
1871
1941
|
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/on-off-timer`, {
|
|
1872
1942
|
method: "POST",
|
|
@@ -1885,6 +1955,43 @@ async function saveOnOffTimerModal() {
|
|
|
1885
1955
|
alert(err?.message || "Could not save on/off timer");
|
|
1886
1956
|
} finally {
|
|
1887
1957
|
if (saveBtn) saveBtn.disabled = false;
|
|
1958
|
+
if (removeBtn) removeBtn.disabled = false;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
async function removeOnOffTimerModal() {
|
|
1963
|
+
const deviceId = onOffTimerModalDeviceId;
|
|
1964
|
+
if (!deviceId) return;
|
|
1965
|
+
|
|
1966
|
+
if (
|
|
1967
|
+
!confirm(
|
|
1968
|
+
"Remove the on/off timer? The screen will stay in its current on or off state until you set a timer again."
|
|
1969
|
+
)
|
|
1970
|
+
) {
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
const saveBtn = document.getElementById("onOffTimerSaveBtn");
|
|
1975
|
+
const removeBtn = document.getElementById("onOffTimerRemoveBtn");
|
|
1976
|
+
if (saveBtn) saveBtn.disabled = true;
|
|
1977
|
+
if (removeBtn) removeBtn.disabled = true;
|
|
1978
|
+
try {
|
|
1979
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/on-off-timer`, {
|
|
1980
|
+
method: "DELETE"
|
|
1981
|
+
});
|
|
1982
|
+
const data = await res.json();
|
|
1983
|
+
if (!res.ok || data.status === "failed") {
|
|
1984
|
+
alert(data.error || "Could not remove on/off timer");
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
showResult({ deviceId, onOffTimer: null, pushed: data.pushed });
|
|
1988
|
+
closeOnOffTimerModal();
|
|
1989
|
+
await fetchDevices();
|
|
1990
|
+
} catch (err) {
|
|
1991
|
+
alert(err?.message || "Could not remove on/off timer");
|
|
1992
|
+
} finally {
|
|
1993
|
+
if (saveBtn) saveBtn.disabled = false;
|
|
1994
|
+
if (removeBtn) removeBtn.disabled = false;
|
|
1888
1995
|
}
|
|
1889
1996
|
}
|
|
1890
1997
|
|
|
@@ -2213,6 +2320,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
2213
2320
|
document
|
|
2214
2321
|
.getElementById("onOffTimerSaveBtn")
|
|
2215
2322
|
?.addEventListener("click", () => void saveOnOffTimerModal());
|
|
2323
|
+
document
|
|
2324
|
+
.getElementById("onOffTimerRemoveBtn")
|
|
2325
|
+
?.addEventListener("click", () => void removeOnOffTimerModal());
|
|
2216
2326
|
document.querySelectorAll("[data-close-download-failed-modal]").forEach((el) => {
|
|
2217
2327
|
el.addEventListener("click", closeDownloadFailedModal);
|
|
2218
2328
|
});
|
|
@@ -283,6 +283,7 @@ body {
|
|
|
283
283
|
border-radius: 8px;
|
|
284
284
|
padding: 0.85rem 0.95rem;
|
|
285
285
|
background: #fafaf9;
|
|
286
|
+
overflow: visible;
|
|
286
287
|
}
|
|
287
288
|
|
|
288
289
|
.device-card-header {
|
|
@@ -439,6 +440,73 @@ body {
|
|
|
439
440
|
display: block;
|
|
440
441
|
}
|
|
441
442
|
|
|
443
|
+
.device-meta-row--timer-status {
|
|
444
|
+
position: relative;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.hardware-timer-status {
|
|
448
|
+
position: relative;
|
|
449
|
+
display: inline-block;
|
|
450
|
+
cursor: default;
|
|
451
|
+
outline: none;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.hardware-timer-status__value--on {
|
|
455
|
+
color: #027a48;
|
|
456
|
+
font-weight: 600;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.hardware-timer-status__value--off {
|
|
460
|
+
color: #667085;
|
|
461
|
+
font-weight: 600;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.hardware-timer-status__popup {
|
|
465
|
+
position: absolute;
|
|
466
|
+
left: 0;
|
|
467
|
+
bottom: calc(100% + 0.45rem);
|
|
468
|
+
z-index: 20;
|
|
469
|
+
min-width: 9.5rem;
|
|
470
|
+
padding: 0.55rem 0.7rem;
|
|
471
|
+
border: 1px solid #e4e1dc;
|
|
472
|
+
border-radius: 6px;
|
|
473
|
+
background: #fff;
|
|
474
|
+
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
|
|
475
|
+
color: #333;
|
|
476
|
+
font-size: 0.75rem;
|
|
477
|
+
font-weight: 400;
|
|
478
|
+
line-height: 1.45;
|
|
479
|
+
white-space: nowrap;
|
|
480
|
+
opacity: 0;
|
|
481
|
+
visibility: hidden;
|
|
482
|
+
pointer-events: none;
|
|
483
|
+
transform: translateY(4px);
|
|
484
|
+
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.hardware-timer-status__popup::after {
|
|
488
|
+
content: "";
|
|
489
|
+
position: absolute;
|
|
490
|
+
top: 100%;
|
|
491
|
+
left: 0.85rem;
|
|
492
|
+
border: 6px solid transparent;
|
|
493
|
+
border-top-color: #fff;
|
|
494
|
+
filter: drop-shadow(0 1px 0 #e4e1dc);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.hardware-timer-status__popup strong {
|
|
498
|
+
font-weight: 600;
|
|
499
|
+
color: #555;
|
|
500
|
+
margin-right: 0.35rem;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.hardware-timer-status:hover .hardware-timer-status__popup,
|
|
504
|
+
.hardware-timer-status:focus-within .hardware-timer-status__popup {
|
|
505
|
+
opacity: 1;
|
|
506
|
+
visibility: visible;
|
|
507
|
+
transform: translateY(0);
|
|
508
|
+
}
|
|
509
|
+
|
|
442
510
|
.device-card-actions {
|
|
443
511
|
display: flex;
|
|
444
512
|
flex-wrap: wrap;
|