@tomorrowos/sdk 0.1.6 → 0.1.9
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/BUILD_GUARDRAILS.md +11 -9
- package/README.md +11 -0
- package/dist/tomorrowos.d.ts +1 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +56 -5
- package/package.json +1 -1
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/policy.example.json +30 -0
- package/templates/cms-starter/public/index.html +108 -50
- package/templates/cms-starter/public/methods.js +366 -64
- package/templates/cms-starter/public/panel.css +232 -0
- package/templates/cms-starter/public/uploads/.gitkeep +0 -0
package/BUILD_GUARDRAILS.md
CHANGED
|
@@ -139,20 +139,22 @@ When the user enters a URL to deploy, the SDK call must wrap it as a content pol
|
|
|
139
139
|
```typescript
|
|
140
140
|
await device.sendCommand('device.content.setPolicy', {
|
|
141
141
|
policy: {
|
|
142
|
-
revision: Date.now(), // monotonic
|
|
143
142
|
playlists: [{
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
id: 'default',
|
|
144
|
+
schedule: {
|
|
145
|
+
startDate: '2026-05-01', // YYYY-MM-DD, optional
|
|
146
|
+
endDate: '2026-05-31',
|
|
147
|
+
daysOfWeek: [1, 2, 3, 4, 5], // 0=Sun … 6=Sat, optional
|
|
148
|
+
start: '09:00', // HH:MM daily window, optional
|
|
149
|
+
end: '17:00',
|
|
150
|
+
},
|
|
146
151
|
items: [{
|
|
147
|
-
contentId: crypto.randomUUID(),
|
|
148
152
|
url: userProvidedUrl,
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
type: 'image',
|
|
154
|
+
durationMs: 30000,
|
|
151
155
|
}],
|
|
152
|
-
loop: true,
|
|
153
|
-
schedule: { startDate: null, endDate: null },
|
|
154
156
|
}],
|
|
155
|
-
|
|
157
|
+
fallback: { type: 'brand' },
|
|
156
158
|
},
|
|
157
159
|
});
|
|
158
160
|
```
|
package/README.md
CHANGED
|
@@ -63,6 +63,16 @@ TomorrowOS has no required cloud service. Your CMS talks to your screens over yo
|
|
|
63
63
|
|
|
64
64
|
**Player branding:** `GET /brand.json` returns the `brand` object passed to `new TomorrowOS({ brand })` (same host/port as `listen`). TomorrowOS players can fetch it to apply `backgroundColor`, logos, etc., without a separate static file server.
|
|
65
65
|
|
|
66
|
+
**Content policy (`device.content.setPolicy`):** POST `/device/{deviceId}/content/set-policy` with `{ "policy": { "playlists": [...], "fallback": { "type": "brand" } } }`. Each playlist may include optional `schedule` (device local time):
|
|
67
|
+
|
|
68
|
+
| Field | Format | Notes |
|
|
69
|
+
|-------|--------|--------|
|
|
70
|
+
| `startDate` / `endDate` | `YYYY-MM-DD` | Inclusive calendar range; omit either for open-ended |
|
|
71
|
+
| `daysOfWeek` | `[0–6]` | `0` = Sunday … `6` = Saturday |
|
|
72
|
+
| `start` / `end` | `HH:MM` | Daily window; supports overnight (e.g. `22:00`–`06:00`) |
|
|
73
|
+
|
|
74
|
+
All provided constraints must match for the playlist to play. See `templates/cms-starter/policy.example.json`.
|
|
75
|
+
|
|
66
76
|
---
|
|
67
77
|
|
|
68
78
|
## Supported platforms (roadmap)
|
|
@@ -87,6 +97,7 @@ See `PLAYER_INSTALL.md` for installation notes.
|
|
|
87
97
|
| `brand.schema.json` | JSON Schema for `brand.json` |
|
|
88
98
|
| `brand.example.json` | Full example `brand.json` |
|
|
89
99
|
| `templates/cms-starter/` | Minimal Node + TypeScript server seed |
|
|
100
|
+
| `templates/cms-starter/policy.example.json` | Example `setPolicy` payload with date schedule |
|
|
90
101
|
| `templates/style-tokens/` | CSS tokens and UI pattern notes |
|
|
91
102
|
|
|
92
103
|
---
|
package/dist/tomorrowos.d.ts
CHANGED
package/dist/tomorrowos.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,IAAI,MAAM,MAAM,CAAC;AAKxB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAGxD,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,eAAe,CAAC;IACvB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,eAAe,CAAC;CACzB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;
|
|
1
|
+
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,IAAI,MAAM,MAAM,CAAC;AAKxB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAGxD,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,eAAe,CAAC;IACvB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,eAAe,CAAC;CACzB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAwED,qBAAa,UAAW,SAAQ,YAAY;IAC1C,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAkB;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;IAC3D,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,eAAe,CAAgB;gBAE3B,OAAO,EAAE,iBAAiB;IAMtC,oEAAoE;IAC9D,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAiChE,OAAO;uBACU,MAAM;sBAlCgC,MAAM;;MAmC3D;IAEF,MAAM,CAAC,QAAQ,EAAE,MAAM;oBAGD,CAAC,oBACT,MAAM,WACN,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;;IAU5E,OAAO,CAAC,mBAAmB;IA6D3B,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI,CAAC,MAAM;YAkD7B,iBAAiB;YAqCjB,cAAc;YAmCd,UAAU;YAqDV,gBAAgB;IAgE9B,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,gBAAgB;CAgFzB"}
|
package/dist/tomorrowos.js
CHANGED
|
@@ -8,12 +8,10 @@ import { MemoryStore } from "./store/memory-store.js";
|
|
|
8
8
|
function createSixDigitCode() {
|
|
9
9
|
return Math.floor(100000 + Math.random() * 900000).toString();
|
|
10
10
|
}
|
|
11
|
+
const MAX_MEDIA_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
11
12
|
async function readJsonBody(req) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
15
|
-
}
|
|
16
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
13
|
+
const buf = await readRawBody(req, MAX_MEDIA_UPLOAD_BYTES);
|
|
14
|
+
const raw = buf.toString("utf8");
|
|
17
15
|
if (!raw.trim())
|
|
18
16
|
return {};
|
|
19
17
|
try {
|
|
@@ -23,6 +21,23 @@ async function readJsonBody(req) {
|
|
|
23
21
|
throw new Error("Invalid JSON body");
|
|
24
22
|
}
|
|
25
23
|
}
|
|
24
|
+
async function readRawBody(req, maxBytes) {
|
|
25
|
+
const chunks = [];
|
|
26
|
+
let total = 0;
|
|
27
|
+
for await (const chunk of req) {
|
|
28
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
29
|
+
total += buf.length;
|
|
30
|
+
if (total > maxBytes) {
|
|
31
|
+
throw new Error(`Body too large (max ${maxBytes} bytes)`);
|
|
32
|
+
}
|
|
33
|
+
chunks.push(buf);
|
|
34
|
+
}
|
|
35
|
+
return Buffer.concat(chunks);
|
|
36
|
+
}
|
|
37
|
+
function sanitizeUploadFilename(name) {
|
|
38
|
+
const base = path.basename(String(name || "upload")).replace(/[^\w.\-()+ ]/g, "_");
|
|
39
|
+
return base.slice(0, 180) || "upload";
|
|
40
|
+
}
|
|
26
41
|
function sendJson(res, status, body) {
|
|
27
42
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
28
43
|
res.end(JSON.stringify(body));
|
|
@@ -148,6 +163,8 @@ export class TomorrowOS extends EventEmitter {
|
|
|
148
163
|
throw new Error("staticIndex must not contain '..'");
|
|
149
164
|
}
|
|
150
165
|
this.staticIndexFile = idx;
|
|
166
|
+
const uploadsDir = path.join(path.resolve(this.staticRoot), "uploads");
|
|
167
|
+
void fs.mkdir(uploadsDir, { recursive: true });
|
|
151
168
|
}
|
|
152
169
|
else {
|
|
153
170
|
this.staticIndexFile = "index.html";
|
|
@@ -182,6 +199,36 @@ export class TomorrowOS extends EventEmitter {
|
|
|
182
199
|
this.wss = wss;
|
|
183
200
|
return server;
|
|
184
201
|
}
|
|
202
|
+
async handleMediaUpload(req, res, url) {
|
|
203
|
+
if (!this.staticRoot) {
|
|
204
|
+
sendJson(res, 400, { status: "failed", error: "staticRoot is not configured" });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const body = await readRawBody(req, MAX_MEDIA_UPLOAD_BYTES);
|
|
209
|
+
if (body.length === 0) {
|
|
210
|
+
sendJson(res, 400, { status: "failed", error: "Empty upload body" });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const rawName = url.searchParams.get("filename") || "upload";
|
|
214
|
+
const safeName = sanitizeUploadFilename(rawName);
|
|
215
|
+
const storedName = `${randomUUID()}-${safeName}`;
|
|
216
|
+
const uploadsDir = path.join(path.resolve(this.staticRoot), "uploads");
|
|
217
|
+
await fs.mkdir(uploadsDir, { recursive: true });
|
|
218
|
+
const filePath = path.join(uploadsDir, storedName);
|
|
219
|
+
await fs.writeFile(filePath, body);
|
|
220
|
+
sendJson(res, 200, {
|
|
221
|
+
status: "success",
|
|
222
|
+
url: `/uploads/${storedName}`,
|
|
223
|
+
filename: storedName,
|
|
224
|
+
size: body.length
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
const msg = e instanceof Error ? e.message : "Upload failed";
|
|
229
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
185
232
|
async tryServeStatic(pathname, res) {
|
|
186
233
|
if (!this.staticRoot)
|
|
187
234
|
return false;
|
|
@@ -222,6 +269,10 @@ export class TomorrowOS extends EventEmitter {
|
|
|
222
269
|
res.end(JSON.stringify(this.brand));
|
|
223
270
|
return;
|
|
224
271
|
}
|
|
272
|
+
if (req.method === "POST" && pathname === "/media/upload") {
|
|
273
|
+
await this.handleMediaUpload(req, res, url);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
225
276
|
if (req.method === "GET" && this.staticRoot) {
|
|
226
277
|
const served = await this.tryServeStatic(pathname, res);
|
|
227
278
|
if (served)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-cms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"build-player": "tomorrowos build --platform tizen"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@tomorrowos/sdk": "^0.1.
|
|
13
|
+
"@tomorrowos/sdk": "^0.1.9"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^20.0.0",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"policy": {
|
|
3
|
+
"playlists": [
|
|
4
|
+
{
|
|
5
|
+
"id": "weekday-promo",
|
|
6
|
+
"name": "Weekday promo",
|
|
7
|
+
"schedule": {
|
|
8
|
+
"startDate": "2026-05-01",
|
|
9
|
+
"endDate": "2026-05-31",
|
|
10
|
+
"daysOfWeek": [1, 2, 3, 4, 5],
|
|
11
|
+
"start": "09:00",
|
|
12
|
+
"end": "17:00"
|
|
13
|
+
},
|
|
14
|
+
"items": [
|
|
15
|
+
{
|
|
16
|
+
"url": "https://example.com/hero.jpg",
|
|
17
|
+
"type": "image",
|
|
18
|
+
"durationMs": 10000
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"url": "https://example.com/spot.mp4",
|
|
22
|
+
"type": "video",
|
|
23
|
+
"durationMs": 30000
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"fallback": { "type": "brand" }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -1,57 +1,115 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html>
|
|
2
|
+
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
|
-
<
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>TomorrowOS Control Panel</title>
|
|
7
|
+
<link rel="stylesheet" href="./panel.css" />
|
|
6
8
|
</head>
|
|
7
|
-
<body
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
9
|
+
<body>
|
|
10
|
+
<header class="app-header">
|
|
11
|
+
<h1>TomorrowOS Control Panel</h1>
|
|
12
|
+
<p>Build a playlist, set schedule, then publish to paired screens.</p>
|
|
13
|
+
</header>
|
|
14
|
+
|
|
15
|
+
<div class="layout">
|
|
16
|
+
<main class="panel-main">
|
|
17
|
+
<section class="card">
|
|
18
|
+
<h2>Pair device</h2>
|
|
19
|
+
<div class="row">
|
|
20
|
+
<input id="code" maxlength="6" placeholder="6-digit code" />
|
|
21
|
+
<button type="button" onclick="verify()">Verify</button>
|
|
22
|
+
</div>
|
|
23
|
+
<input type="hidden" id="deviceId" />
|
|
24
|
+
</section>
|
|
25
|
+
|
|
26
|
+
<section class="card">
|
|
27
|
+
<h2>CMS URL for screens</h2>
|
|
28
|
+
<p style="margin: 0 0 0.5rem; font-size: 0.8rem; color: #666">
|
|
29
|
+
HTTP(S) base URL your TV uses to download uploads (same host as <code>ws://</code> on the
|
|
30
|
+
device, not <code>localhost</code>).
|
|
31
|
+
</p>
|
|
32
|
+
<div class="row">
|
|
33
|
+
<input
|
|
34
|
+
id="cmsDeviceBaseUrl"
|
|
35
|
+
type="text"
|
|
36
|
+
style="flex: 1; min-width: 16rem"
|
|
37
|
+
placeholder="http://192.168.1.105:3000"
|
|
38
|
+
/>
|
|
39
|
+
<button type="button" onclick="saveCmsDeviceBaseUrl()">Save</button>
|
|
40
|
+
</div>
|
|
41
|
+
</section>
|
|
42
|
+
|
|
43
|
+
<section class="card">
|
|
44
|
+
<h2>When this playlist plays</h2>
|
|
45
|
+
<p style="margin: 0 0 0.75rem; font-size: 0.8rem; color: #666">
|
|
46
|
+
Leave blank for always on (device local time). All set fields must match.
|
|
47
|
+
</p>
|
|
48
|
+
<div class="schedule-grid">
|
|
49
|
+
<label>
|
|
50
|
+
Start date
|
|
51
|
+
<input type="date" id="scheduleStartDate" />
|
|
52
|
+
</label>
|
|
53
|
+
<label>
|
|
54
|
+
End date
|
|
55
|
+
<input type="date" id="scheduleEndDate" />
|
|
56
|
+
</label>
|
|
57
|
+
<label>
|
|
58
|
+
From
|
|
59
|
+
<input type="time" id="scheduleStartTime" />
|
|
60
|
+
</label>
|
|
61
|
+
<label>
|
|
62
|
+
Until
|
|
63
|
+
<input type="time" id="scheduleEndTime" />
|
|
64
|
+
</label>
|
|
65
|
+
<div class="days-row">
|
|
66
|
+
<span style="width: 100%; color: #444">Days</span>
|
|
67
|
+
<label><input type="checkbox" class="day-checkbox" value="0" /> Sun</label>
|
|
68
|
+
<label><input type="checkbox" class="day-checkbox" value="1" /> Mon</label>
|
|
69
|
+
<label><input type="checkbox" class="day-checkbox" value="2" /> Tue</label>
|
|
70
|
+
<label><input type="checkbox" class="day-checkbox" value="3" /> Wed</label>
|
|
71
|
+
<label><input type="checkbox" class="day-checkbox" value="4" /> Thu</label>
|
|
72
|
+
<label><input type="checkbox" class="day-checkbox" value="5" /> Fri</label>
|
|
73
|
+
<label><input type="checkbox" class="day-checkbox" value="6" /> Sat</label>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<section class="card">
|
|
79
|
+
<h2>Device actions</h2>
|
|
80
|
+
<div class="row">
|
|
81
|
+
<button type="button" onclick="getInfo()">Get info</button>
|
|
82
|
+
<button type="button" onclick="getCapabilities()">Capabilities</button>
|
|
83
|
+
<button type="button" onclick="reboot()">Reboot</button>
|
|
84
|
+
<button type="button" class="danger" onclick="clearContent()">Clear content</button>
|
|
85
|
+
</div>
|
|
86
|
+
</section>
|
|
87
|
+
|
|
88
|
+
<section class="publish-bar">
|
|
89
|
+
<button type="button" class="primary" style="width: 100%; padding: 0.75rem" onclick="publish()">
|
|
90
|
+
Publish playlist
|
|
91
|
+
</button>
|
|
92
|
+
</section>
|
|
93
|
+
|
|
94
|
+
<pre id="result" aria-live="polite"></pre>
|
|
95
|
+
</main>
|
|
96
|
+
|
|
97
|
+
<aside class="panel-playlist">
|
|
98
|
+
<div class="playlist-header">
|
|
99
|
+
<h2>Playlist</h2>
|
|
100
|
+
<button type="button" class="primary" id="addAssetBtn" title="Add image or video">+</button>
|
|
101
|
+
</div>
|
|
102
|
+
<input
|
|
103
|
+
type="file"
|
|
104
|
+
id="fileInput"
|
|
105
|
+
class="hidden"
|
|
106
|
+
accept="image/*,video/*,.wgt,.zip"
|
|
107
|
+
/>
|
|
108
|
+
<ul id="playlistList" class="playlist-list">
|
|
109
|
+
<li class="playlist-empty" id="playlistEmpty">No assets yet. Tap + to upload.</li>
|
|
110
|
+
</ul>
|
|
111
|
+
</aside>
|
|
112
|
+
</div>
|
|
55
113
|
|
|
56
114
|
<script src="./methods.js" defer></script>
|
|
57
115
|
</body>
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
const PANEL_DEVICE_ID_KEY = "tomorrowos.panel.deviceId";
|
|
2
|
+
const PANEL_PLAYLIST_KEY = "tomorrowos.panel.playlistDraft";
|
|
3
|
+
const PANEL_SCHEDULE_KEY = "tomorrowos.panel.scheduleDraft";
|
|
4
|
+
const PANEL_MEDIA_BASE_KEY = "tomorrowos.panel.mediaBaseUrl";
|
|
5
|
+
|
|
6
|
+
/** @type {{ id: string, url: string, name: string, type: string, durationMs: number }[]} */
|
|
7
|
+
let playlistItems = [];
|
|
2
8
|
|
|
3
9
|
function getPanelDeviceId() {
|
|
4
10
|
return (
|
|
@@ -16,107 +22,330 @@ function setPanelDeviceId(deviceId) {
|
|
|
16
22
|
else localStorage.removeItem(PANEL_DEVICE_ID_KEY);
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
25
|
+
function showResult(data) {
|
|
26
|
+
document.getElementById("result").textContent = JSON.stringify(data, null, 2);
|
|
27
|
+
}
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
const
|
|
29
|
+
function isLocalPanelHost(hostname) {
|
|
30
|
+
const h = String(hostname || "").toLowerCase();
|
|
31
|
+
return h === "localhost" || h === "127.0.0.1" || h === "[::1]";
|
|
32
|
+
}
|
|
26
33
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
function normalizeMediaBaseUrl(raw) {
|
|
35
|
+
let s = String(raw || "").trim();
|
|
36
|
+
if (!s) return "";
|
|
37
|
+
if (!/^https?:\/\//i.test(s)) {
|
|
38
|
+
s = `http://${s}`;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const u = new URL(s);
|
|
42
|
+
return u.origin;
|
|
43
|
+
} catch {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
32
47
|
|
|
33
|
-
|
|
48
|
+
function getMediaBaseOrigin() {
|
|
49
|
+
const fromInput = normalizeMediaBaseUrl(
|
|
50
|
+
document.getElementById("cmsDeviceBaseUrl")?.value ||
|
|
51
|
+
localStorage.getItem(PANEL_MEDIA_BASE_KEY) ||
|
|
52
|
+
""
|
|
53
|
+
);
|
|
54
|
+
if (fromInput) return fromInput;
|
|
34
55
|
|
|
35
|
-
|
|
56
|
+
if (!isLocalPanelHost(window.location.hostname)) {
|
|
57
|
+
return window.location.origin;
|
|
58
|
+
}
|
|
36
59
|
|
|
37
|
-
|
|
38
|
-
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function saveCmsDeviceBaseUrl() {
|
|
64
|
+
const normalized = normalizeMediaBaseUrl(
|
|
65
|
+
document.getElementById("cmsDeviceBaseUrl")?.value
|
|
66
|
+
);
|
|
67
|
+
if (!normalized) {
|
|
68
|
+
alert("Enter a valid URL, e.g. http://192.168.1.105:3000");
|
|
69
|
+
return;
|
|
39
70
|
}
|
|
71
|
+
document.getElementById("cmsDeviceBaseUrl").value = normalized;
|
|
72
|
+
localStorage.setItem(PANEL_MEDIA_BASE_KEY, normalized);
|
|
73
|
+
showResult({ status: "saved", mediaBaseUrl: normalized });
|
|
40
74
|
}
|
|
41
75
|
|
|
42
|
-
|
|
43
|
-
const
|
|
76
|
+
function absoluteMediaUrl(path) {
|
|
77
|
+
const p = String(path || "").trim();
|
|
78
|
+
if (!p) return "";
|
|
79
|
+
if (/^https?:\/\//i.test(p)) return p;
|
|
44
80
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
81
|
+
const base = getMediaBaseOrigin();
|
|
82
|
+
if (!base) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
"Set CMS URL for screens (use your PC LAN IP or Replit https URL, not localhost)."
|
|
85
|
+
);
|
|
48
86
|
}
|
|
87
|
+
return `${base}${p.startsWith("/") ? p : `/${p}`}`;
|
|
88
|
+
}
|
|
49
89
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
90
|
+
function inferMediaType(filename, mime) {
|
|
91
|
+
const lower = String(filename || "").toLowerCase();
|
|
92
|
+
const m = String(mime || "").toLowerCase();
|
|
93
|
+
if (m.startsWith("video/")) return "video";
|
|
94
|
+
if (m.startsWith("image/")) return "image";
|
|
95
|
+
if (lower.endsWith(".wgt") || lower.endsWith(".zip")) return "widget";
|
|
96
|
+
if (/\.(mp4|webm|mov|m4v)$/.test(lower)) return "video";
|
|
97
|
+
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(lower)) return "image";
|
|
98
|
+
return "image";
|
|
99
|
+
}
|
|
53
100
|
|
|
54
|
-
|
|
101
|
+
function defaultDurationMs(type) {
|
|
102
|
+
if (type === "video") return 30000;
|
|
103
|
+
if (type === "widget") return 20000;
|
|
104
|
+
return 10000;
|
|
105
|
+
}
|
|
55
106
|
|
|
56
|
-
|
|
107
|
+
function savePlaylistDraft() {
|
|
108
|
+
localStorage.setItem(PANEL_PLAYLIST_KEY, JSON.stringify(playlistItems));
|
|
57
109
|
}
|
|
58
110
|
|
|
59
|
-
|
|
60
|
-
const
|
|
111
|
+
function saveScheduleDraft() {
|
|
112
|
+
const draft = {
|
|
113
|
+
startDate: document.getElementById("scheduleStartDate")?.value || "",
|
|
114
|
+
endDate: document.getElementById("scheduleEndDate")?.value || "",
|
|
115
|
+
startTime: document.getElementById("scheduleStartTime")?.value || "",
|
|
116
|
+
endTime: document.getElementById("scheduleEndTime")?.value || "",
|
|
117
|
+
days: [...document.querySelectorAll(".day-checkbox:checked")].map((el) => Number(el.value))
|
|
118
|
+
};
|
|
119
|
+
localStorage.setItem(PANEL_SCHEDULE_KEY, JSON.stringify(draft));
|
|
120
|
+
}
|
|
61
121
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
122
|
+
function loadScheduleDraft() {
|
|
123
|
+
try {
|
|
124
|
+
const raw = localStorage.getItem(PANEL_SCHEDULE_KEY);
|
|
125
|
+
if (!raw) return;
|
|
126
|
+
const draft = JSON.parse(raw);
|
|
127
|
+
if (draft.startDate) document.getElementById("scheduleStartDate").value = draft.startDate;
|
|
128
|
+
if (draft.endDate) document.getElementById("scheduleEndDate").value = draft.endDate;
|
|
129
|
+
if (draft.startTime) document.getElementById("scheduleStartTime").value = draft.startTime;
|
|
130
|
+
if (draft.endTime) document.getElementById("scheduleEndTime").value = draft.endTime;
|
|
131
|
+
document.querySelectorAll(".day-checkbox").forEach((el) => {
|
|
132
|
+
el.checked = Array.isArray(draft.days) && draft.days.includes(Number(el.value));
|
|
133
|
+
});
|
|
134
|
+
} catch {
|
|
135
|
+
/* ignore */
|
|
65
136
|
}
|
|
137
|
+
}
|
|
66
138
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
139
|
+
function loadPlaylistDraft() {
|
|
140
|
+
try {
|
|
141
|
+
const raw = localStorage.getItem(PANEL_PLAYLIST_KEY);
|
|
142
|
+
if (!raw) return;
|
|
143
|
+
const parsed = JSON.parse(raw);
|
|
144
|
+
if (Array.isArray(parsed)) {
|
|
145
|
+
playlistItems = parsed;
|
|
146
|
+
renderPlaylist();
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
playlistItems = [];
|
|
150
|
+
}
|
|
73
151
|
}
|
|
74
152
|
|
|
75
|
-
|
|
76
|
-
const
|
|
153
|
+
function renderPlaylist() {
|
|
154
|
+
const list = document.getElementById("playlistList");
|
|
155
|
+
const empty = document.getElementById("playlistEmpty");
|
|
156
|
+
list.querySelectorAll(".playlist-item").forEach((el) => el.remove());
|
|
77
157
|
|
|
78
|
-
if (
|
|
79
|
-
|
|
158
|
+
if (playlistItems.length === 0) {
|
|
159
|
+
empty.classList.remove("hidden");
|
|
80
160
|
return;
|
|
81
161
|
}
|
|
82
162
|
|
|
83
|
-
|
|
84
|
-
|
|
163
|
+
empty.classList.add("hidden");
|
|
164
|
+
|
|
165
|
+
for (const item of playlistItems) {
|
|
166
|
+
const li = document.createElement("li");
|
|
167
|
+
li.className = "playlist-item";
|
|
168
|
+
li.dataset.id = item.id;
|
|
169
|
+
|
|
170
|
+
if (item.type === "image" || item.type === "video") {
|
|
171
|
+
const thumb = document.createElement(item.type === "video" ? "video" : "img");
|
|
172
|
+
thumb.className = "playlist-item-thumb";
|
|
173
|
+
thumb.src = absoluteMediaUrl(item.url);
|
|
174
|
+
if (item.type === "video") {
|
|
175
|
+
thumb.muted = true;
|
|
176
|
+
thumb.playsInline = true;
|
|
177
|
+
} else {
|
|
178
|
+
thumb.alt = item.name;
|
|
179
|
+
}
|
|
180
|
+
li.appendChild(thumb);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const name = document.createElement("div");
|
|
184
|
+
name.className = "playlist-item-name";
|
|
185
|
+
name.textContent = item.name;
|
|
186
|
+
|
|
187
|
+
const meta = document.createElement("div");
|
|
188
|
+
meta.className = "playlist-item-meta";
|
|
189
|
+
meta.textContent = `${item.type} · ${(item.durationMs / 1000).toFixed(0)}s`;
|
|
190
|
+
|
|
191
|
+
const actions = document.createElement("div");
|
|
192
|
+
actions.className = "playlist-item-actions";
|
|
193
|
+
|
|
194
|
+
const durLabel = document.createElement("label");
|
|
195
|
+
durLabel.style.fontSize = "0.75rem";
|
|
196
|
+
durLabel.textContent = "sec ";
|
|
197
|
+
const durInput = document.createElement("input");
|
|
198
|
+
durInput.type = "number";
|
|
199
|
+
durInput.min = "1";
|
|
200
|
+
durInput.max = "3600";
|
|
201
|
+
durInput.value = String(Math.round(item.durationMs / 1000));
|
|
202
|
+
durInput.addEventListener("change", () => {
|
|
203
|
+
item.durationMs = Math.max(1000, Number(durInput.value) || 10) * 1000;
|
|
204
|
+
savePlaylistDraft();
|
|
205
|
+
renderPlaylist();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const removeBtn = document.createElement("button");
|
|
209
|
+
removeBtn.type = "button";
|
|
210
|
+
removeBtn.className = "danger";
|
|
211
|
+
removeBtn.textContent = "Remove";
|
|
212
|
+
removeBtn.addEventListener("click", () => {
|
|
213
|
+
playlistItems = playlistItems.filter((x) => x.id !== item.id);
|
|
214
|
+
savePlaylistDraft();
|
|
215
|
+
renderPlaylist();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
actions.appendChild(durLabel);
|
|
219
|
+
actions.appendChild(durInput);
|
|
220
|
+
actions.appendChild(removeBtn);
|
|
221
|
+
|
|
222
|
+
li.appendChild(name);
|
|
223
|
+
li.appendChild(meta);
|
|
224
|
+
li.appendChild(actions);
|
|
225
|
+
list.appendChild(li);
|
|
85
226
|
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function uploadFile(file) {
|
|
230
|
+
const q = new URLSearchParams({ filename: file.name });
|
|
231
|
+
const res = await fetch(`/media/upload?${q.toString()}`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
234
|
+
body: file
|
|
235
|
+
});
|
|
236
|
+
const data = await res.json();
|
|
237
|
+
if (!res.ok || data.status === "failed") {
|
|
238
|
+
throw new Error(data.error || `Upload failed (${res.status})`);
|
|
239
|
+
}
|
|
240
|
+
return data;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function addAssetFromFile(file) {
|
|
244
|
+
showResult({ status: "uploading", filename: file.name });
|
|
245
|
+
const data = await uploadFile(file);
|
|
246
|
+
const type = inferMediaType(file.name, file.type);
|
|
247
|
+
playlistItems.push({
|
|
248
|
+
id: crypto.randomUUID(),
|
|
249
|
+
url: data.url,
|
|
250
|
+
name: file.name,
|
|
251
|
+
type,
|
|
252
|
+
durationMs: defaultDurationMs(type)
|
|
253
|
+
});
|
|
254
|
+
savePlaylistDraft();
|
|
255
|
+
renderPlaylist();
|
|
256
|
+
showResult({ status: "uploaded", ...data });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function buildSchedule() {
|
|
260
|
+
const schedule = {};
|
|
261
|
+
const startDate = document.getElementById("scheduleStartDate")?.value?.trim();
|
|
262
|
+
const endDate = document.getElementById("scheduleEndDate")?.value?.trim();
|
|
263
|
+
const startTime = document.getElementById("scheduleStartTime")?.value?.trim();
|
|
264
|
+
const endTime = document.getElementById("scheduleEndTime")?.value?.trim();
|
|
265
|
+
const days = [...document.querySelectorAll(".day-checkbox:checked")].map((el) =>
|
|
266
|
+
Number(el.value)
|
|
267
|
+
);
|
|
86
268
|
|
|
87
|
-
|
|
88
|
-
|
|
269
|
+
if (startDate) schedule.startDate = startDate;
|
|
270
|
+
if (endDate) schedule.endDate = endDate;
|
|
271
|
+
if (startTime) schedule.start = startTime;
|
|
272
|
+
if (endTime) schedule.end = endTime;
|
|
273
|
+
if (days.length > 0) schedule.daysOfWeek = days;
|
|
274
|
+
|
|
275
|
+
return Object.keys(schedule).length > 0 ? schedule : undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function buildPolicyPayload() {
|
|
279
|
+
const schedule = buildSchedule();
|
|
280
|
+
const playlist = {
|
|
281
|
+
id: "main",
|
|
282
|
+
name: "Playlist",
|
|
283
|
+
items: playlistItems.map((item) => ({
|
|
284
|
+
url: absoluteMediaUrl(item.url),
|
|
285
|
+
type: item.type,
|
|
286
|
+
durationMs: item.durationMs
|
|
287
|
+
}))
|
|
288
|
+
};
|
|
289
|
+
if (schedule) playlist.schedule = schedule;
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
policy: {
|
|
293
|
+
playlists: [playlist],
|
|
294
|
+
fallback: { type: "brand" }
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function verify() {
|
|
300
|
+
const code = document.getElementById("code").value;
|
|
301
|
+
|
|
302
|
+
const res = await fetch("/pairing/verify", {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: { "Content-Type": "application/json" },
|
|
305
|
+
body: JSON.stringify({ code })
|
|
89
306
|
});
|
|
90
307
|
|
|
91
308
|
const data = await res.json();
|
|
309
|
+
showResult(data);
|
|
92
310
|
|
|
93
|
-
|
|
311
|
+
if (data.deviceId) {
|
|
312
|
+
setPanelDeviceId(data.deviceId);
|
|
313
|
+
}
|
|
94
314
|
}
|
|
95
315
|
|
|
96
|
-
async function
|
|
316
|
+
async function publish() {
|
|
97
317
|
const deviceId = getPanelDeviceId();
|
|
98
|
-
const policyJsonText = document.getElementById("policyJson")?.value?.trim();
|
|
99
318
|
|
|
100
319
|
if (!deviceId) {
|
|
101
|
-
alert("
|
|
320
|
+
alert("Pair a device first (enter code and Verify).");
|
|
102
321
|
return;
|
|
103
322
|
}
|
|
104
323
|
|
|
105
|
-
if (
|
|
106
|
-
alert("
|
|
324
|
+
if (playlistItems.length === 0) {
|
|
325
|
+
alert("Add at least one asset to the playlist.");
|
|
107
326
|
return;
|
|
108
327
|
}
|
|
109
328
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
329
|
+
const mediaBase = getMediaBaseOrigin();
|
|
330
|
+
if (!mediaBase) {
|
|
331
|
+
alert(
|
|
332
|
+
"Set CMS URL for screens first (e.g. http://192.168.1.105:3000 — same machine as the TV ws:// address)."
|
|
333
|
+
);
|
|
115
334
|
return;
|
|
116
335
|
}
|
|
336
|
+
if (isLocalPanelHost(new URL(mediaBase).hostname)) {
|
|
337
|
+
const ok = confirm(
|
|
338
|
+
"Media URLs use localhost. TVs cannot download from localhost unless the player runs on this PC. Continue anyway?"
|
|
339
|
+
);
|
|
340
|
+
if (!ok) return;
|
|
341
|
+
}
|
|
117
342
|
|
|
118
|
-
|
|
119
|
-
|
|
343
|
+
saveScheduleDraft();
|
|
344
|
+
let payload;
|
|
345
|
+
try {
|
|
346
|
+
payload = buildPolicyPayload();
|
|
347
|
+
} catch (err) {
|
|
348
|
+
alert(err.message);
|
|
120
349
|
return;
|
|
121
350
|
}
|
|
122
351
|
|
|
@@ -127,21 +356,94 @@ async function setContent() {
|
|
|
127
356
|
});
|
|
128
357
|
|
|
129
358
|
const data = await res.json();
|
|
130
|
-
|
|
359
|
+
showResult({ publish: payload, response: data });
|
|
131
360
|
}
|
|
132
361
|
|
|
133
|
-
async function
|
|
362
|
+
async function getInfo() {
|
|
363
|
+
const deviceId = getPanelDeviceId();
|
|
364
|
+
if (!deviceId) {
|
|
365
|
+
alert("Please pair a device first.");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const res = await fetch(`/device/${deviceId}/get-info`, { method: "POST" });
|
|
369
|
+
showResult(await res.json());
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function getCapabilities() {
|
|
134
373
|
const deviceId = getPanelDeviceId();
|
|
374
|
+
if (!deviceId) {
|
|
375
|
+
alert("Please pair a device first.");
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const res = await fetch(`/device/${deviceId}/get-capabilities`, { method: "POST" });
|
|
379
|
+
showResult(await res.json());
|
|
380
|
+
}
|
|
135
381
|
|
|
382
|
+
async function reboot() {
|
|
383
|
+
const deviceId = getPanelDeviceId();
|
|
384
|
+
if (!deviceId) {
|
|
385
|
+
alert("Please pair a device first.");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (!confirm("Reboot this device?")) return;
|
|
389
|
+
const res = await fetch(`/device/${deviceId}/reboot`, { method: "POST" });
|
|
390
|
+
showResult(await res.json());
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function clearContent() {
|
|
394
|
+
const deviceId = getPanelDeviceId();
|
|
136
395
|
if (!deviceId) {
|
|
137
396
|
alert("Please pair a device first.");
|
|
138
397
|
return;
|
|
139
398
|
}
|
|
399
|
+
if (!confirm("Clear content on this device?")) return;
|
|
400
|
+
const res = await fetch(`/device/${deviceId}/content/clear`, { method: "POST" });
|
|
401
|
+
showResult(await res.json());
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
405
|
+
const saved = localStorage.getItem(PANEL_DEVICE_ID_KEY);
|
|
406
|
+
if (saved) setPanelDeviceId(saved);
|
|
140
407
|
|
|
141
|
-
const
|
|
142
|
-
|
|
408
|
+
const savedMediaBase = localStorage.getItem(PANEL_MEDIA_BASE_KEY);
|
|
409
|
+
const cmsBaseInput = document.getElementById("cmsDeviceBaseUrl");
|
|
410
|
+
if (savedMediaBase && cmsBaseInput) {
|
|
411
|
+
cmsBaseInput.value = savedMediaBase;
|
|
412
|
+
} else if (cmsBaseInput && !isLocalPanelHost(window.location.hostname)) {
|
|
413
|
+
cmsBaseInput.value = window.location.origin;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
loadPlaylistDraft();
|
|
417
|
+
loadScheduleDraft();
|
|
418
|
+
|
|
419
|
+
document.getElementById("addAssetBtn").addEventListener("click", () => {
|
|
420
|
+
document.getElementById("fileInput").click();
|
|
143
421
|
});
|
|
144
422
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
423
|
+
document.getElementById("fileInput").addEventListener("change", async (ev) => {
|
|
424
|
+
const files = ev.target.files;
|
|
425
|
+
if (!files?.length) return;
|
|
426
|
+
try {
|
|
427
|
+
for (const file of files) {
|
|
428
|
+
await addAssetFromFile(file);
|
|
429
|
+
}
|
|
430
|
+
} catch (err) {
|
|
431
|
+
alert(err.message);
|
|
432
|
+
showResult({ status: "failed", error: err.message });
|
|
433
|
+
}
|
|
434
|
+
ev.target.value = "";
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
[
|
|
438
|
+
"scheduleStartDate",
|
|
439
|
+
"scheduleEndDate",
|
|
440
|
+
"scheduleStartTime",
|
|
441
|
+
"scheduleEndTime"
|
|
442
|
+
].forEach((id) => {
|
|
443
|
+
document.getElementById(id)?.addEventListener("change", saveScheduleDraft);
|
|
444
|
+
});
|
|
445
|
+
document.querySelectorAll(".day-checkbox").forEach((el) => {
|
|
446
|
+
el.addEventListener("change", saveScheduleDraft);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
* {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
body {
|
|
6
|
+
margin: 0;
|
|
7
|
+
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
8
|
+
background: #f4f3f0;
|
|
9
|
+
color: #0a0908;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.app-header {
|
|
13
|
+
padding: 1rem 1.5rem;
|
|
14
|
+
background: #0a0908;
|
|
15
|
+
color: #fafaf9;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.app-header h1 {
|
|
19
|
+
margin: 0;
|
|
20
|
+
font-size: 1.25rem;
|
|
21
|
+
font-weight: 600;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.app-header p {
|
|
25
|
+
margin: 0.35rem 0 0;
|
|
26
|
+
font-size: 0.875rem;
|
|
27
|
+
opacity: 0.8;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.layout {
|
|
31
|
+
display: flex;
|
|
32
|
+
gap: 0;
|
|
33
|
+
min-height: calc(100vh - 4.5rem);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.panel-main {
|
|
37
|
+
flex: 1;
|
|
38
|
+
padding: 1.25rem 1.5rem 2rem;
|
|
39
|
+
overflow: auto;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.panel-playlist {
|
|
43
|
+
width: 320px;
|
|
44
|
+
flex-shrink: 0;
|
|
45
|
+
background: #fff;
|
|
46
|
+
border-left: 1px solid #e4e1dc;
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.card {
|
|
52
|
+
background: #fff;
|
|
53
|
+
border: 1px solid #e4e1dc;
|
|
54
|
+
border-radius: 10px;
|
|
55
|
+
padding: 1rem 1.1rem;
|
|
56
|
+
margin-bottom: 1rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.card h2 {
|
|
60
|
+
margin: 0 0 0.75rem;
|
|
61
|
+
font-size: 0.95rem;
|
|
62
|
+
font-weight: 600;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.row {
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-wrap: wrap;
|
|
68
|
+
gap: 0.5rem;
|
|
69
|
+
align-items: center;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
input[type="text"],
|
|
73
|
+
input[type="date"],
|
|
74
|
+
input[type="time"],
|
|
75
|
+
select {
|
|
76
|
+
font: inherit;
|
|
77
|
+
padding: 0.45rem 0.6rem;
|
|
78
|
+
border: 1px solid #d6d2cb;
|
|
79
|
+
border-radius: 6px;
|
|
80
|
+
background: #fff;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
button {
|
|
84
|
+
font: inherit;
|
|
85
|
+
padding: 0.45rem 0.85rem;
|
|
86
|
+
border-radius: 6px;
|
|
87
|
+
border: 1px solid #d6d2cb;
|
|
88
|
+
background: #fff;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
button:hover {
|
|
93
|
+
background: #f5f3ef;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
button.primary {
|
|
97
|
+
background: #ff8a3d;
|
|
98
|
+
border-color: #e67320;
|
|
99
|
+
color: #0a0908;
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
button.primary:hover {
|
|
104
|
+
background: #ff9a57;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
button.danger {
|
|
108
|
+
color: #b42318;
|
|
109
|
+
border-color: #fecdca;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.schedule-grid {
|
|
113
|
+
display: grid;
|
|
114
|
+
grid-template-columns: 1fr 1fr;
|
|
115
|
+
gap: 0.65rem 1rem;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.schedule-grid label {
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
gap: 0.25rem;
|
|
122
|
+
font-size: 0.8rem;
|
|
123
|
+
color: #444;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.days-row {
|
|
127
|
+
grid-column: 1 / -1;
|
|
128
|
+
display: flex;
|
|
129
|
+
flex-wrap: wrap;
|
|
130
|
+
gap: 0.5rem 0.75rem;
|
|
131
|
+
font-size: 0.8rem;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.days-row label {
|
|
135
|
+
flex-direction: row;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: 0.35rem;
|
|
138
|
+
color: #0a0908;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.playlist-header {
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
justify-content: space-between;
|
|
145
|
+
padding: 1rem 1rem 0.75rem;
|
|
146
|
+
border-bottom: 1px solid #e4e1dc;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.playlist-header h2 {
|
|
150
|
+
margin: 0;
|
|
151
|
+
font-size: 0.95rem;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.playlist-list {
|
|
155
|
+
list-style: none;
|
|
156
|
+
margin: 0;
|
|
157
|
+
padding: 0.5rem;
|
|
158
|
+
flex: 1;
|
|
159
|
+
overflow-y: auto;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.playlist-empty {
|
|
163
|
+
padding: 1.5rem 1rem;
|
|
164
|
+
text-align: center;
|
|
165
|
+
color: #888;
|
|
166
|
+
font-size: 0.875rem;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.playlist-item {
|
|
170
|
+
border: 1px solid #e4e1dc;
|
|
171
|
+
border-radius: 8px;
|
|
172
|
+
padding: 0.65rem;
|
|
173
|
+
margin-bottom: 0.5rem;
|
|
174
|
+
background: #fafaf9;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.playlist-item-thumb {
|
|
178
|
+
width: 100%;
|
|
179
|
+
aspect-ratio: 16 / 9;
|
|
180
|
+
object-fit: cover;
|
|
181
|
+
border-radius: 4px;
|
|
182
|
+
background: #e4e1dc;
|
|
183
|
+
display: block;
|
|
184
|
+
margin-bottom: 0.5rem;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.playlist-item-name {
|
|
188
|
+
font-size: 0.8rem;
|
|
189
|
+
font-weight: 600;
|
|
190
|
+
word-break: break-all;
|
|
191
|
+
margin-bottom: 0.35rem;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.playlist-item-meta {
|
|
195
|
+
font-size: 0.75rem;
|
|
196
|
+
color: #666;
|
|
197
|
+
margin-bottom: 0.45rem;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.playlist-item-actions {
|
|
201
|
+
display: flex;
|
|
202
|
+
gap: 0.35rem;
|
|
203
|
+
align-items: center;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.playlist-item-actions input[type="number"] {
|
|
207
|
+
width: 5rem;
|
|
208
|
+
font: inherit;
|
|
209
|
+
padding: 0.25rem 0.4rem;
|
|
210
|
+
border: 1px solid #d6d2cb;
|
|
211
|
+
border-radius: 4px;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.publish-bar {
|
|
215
|
+
padding: 1rem 0 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#result {
|
|
219
|
+
margin: 0;
|
|
220
|
+
padding: 0.75rem;
|
|
221
|
+
background: #0a0908;
|
|
222
|
+
color: #e8e6e3;
|
|
223
|
+
border-radius: 8px;
|
|
224
|
+
font-size: 0.75rem;
|
|
225
|
+
max-height: 10rem;
|
|
226
|
+
overflow: auto;
|
|
227
|
+
white-space: pre-wrap;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.hidden {
|
|
231
|
+
display: none !important;
|
|
232
|
+
}
|
|
File without changes
|