@tomorrowos/sdk 0.1.6 → 0.1.8
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 +85 -44
- package/templates/cms-starter/public/methods.js +288 -69
- 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.8",
|
|
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.8",
|
|
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.8"
|
|
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,98 @@
|
|
|
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
|
-
|
|
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>
|
|
10
14
|
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
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>
|
|
15
25
|
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
<section class="card">
|
|
27
|
+
<h2>When this playlist plays</h2>
|
|
28
|
+
<p style="margin: 0 0 0.75rem; font-size: 0.8rem; color: #666">
|
|
29
|
+
Leave blank for always on (device local time). All set fields must match.
|
|
30
|
+
</p>
|
|
31
|
+
<div class="schedule-grid">
|
|
32
|
+
<label>
|
|
33
|
+
Start date
|
|
34
|
+
<input type="date" id="scheduleStartDate" />
|
|
35
|
+
</label>
|
|
36
|
+
<label>
|
|
37
|
+
End date
|
|
38
|
+
<input type="date" id="scheduleEndDate" />
|
|
39
|
+
</label>
|
|
40
|
+
<label>
|
|
41
|
+
From
|
|
42
|
+
<input type="time" id="scheduleStartTime" />
|
|
43
|
+
</label>
|
|
44
|
+
<label>
|
|
45
|
+
Until
|
|
46
|
+
<input type="time" id="scheduleEndTime" />
|
|
47
|
+
</label>
|
|
48
|
+
<div class="days-row">
|
|
49
|
+
<span style="width: 100%; color: #444">Days</span>
|
|
50
|
+
<label><input type="checkbox" class="day-checkbox" value="0" /> Sun</label>
|
|
51
|
+
<label><input type="checkbox" class="day-checkbox" value="1" /> Mon</label>
|
|
52
|
+
<label><input type="checkbox" class="day-checkbox" value="2" /> Tue</label>
|
|
53
|
+
<label><input type="checkbox" class="day-checkbox" value="3" /> Wed</label>
|
|
54
|
+
<label><input type="checkbox" class="day-checkbox" value="4" /> Thu</label>
|
|
55
|
+
<label><input type="checkbox" class="day-checkbox" value="5" /> Fri</label>
|
|
56
|
+
<label><input type="checkbox" class="day-checkbox" value="6" /> Sat</label>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</section>
|
|
18
60
|
|
|
19
|
-
|
|
20
|
-
|
|
61
|
+
<section class="card">
|
|
62
|
+
<h2>Device actions</h2>
|
|
63
|
+
<div class="row">
|
|
64
|
+
<button type="button" onclick="getInfo()">Get info</button>
|
|
65
|
+
<button type="button" onclick="getCapabilities()">Capabilities</button>
|
|
66
|
+
<button type="button" onclick="reboot()">Reboot</button>
|
|
67
|
+
<button type="button" class="danger" onclick="clearContent()">Clear content</button>
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
21
70
|
|
|
22
|
-
|
|
23
|
-
|
|
71
|
+
<section class="publish-bar">
|
|
72
|
+
<button type="button" class="primary" style="width: 100%; padding: 0.75rem" onclick="publish()">
|
|
73
|
+
Publish playlist
|
|
74
|
+
</button>
|
|
75
|
+
</section>
|
|
24
76
|
|
|
25
|
-
|
|
26
|
-
|
|
77
|
+
<pre id="result" aria-live="polite"></pre>
|
|
78
|
+
</main>
|
|
27
79
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"fallback": { "type": "brand" }
|
|
45
|
-
}
|
|
46
|
-
}'></textarea>
|
|
47
|
-
|
|
48
|
-
<h2>Clear Content</h2>
|
|
49
|
-
<button onclick="clearContent()">Clear</button>
|
|
50
|
-
|
|
51
|
-
<br />
|
|
52
|
-
<br />
|
|
53
|
-
|
|
54
|
-
<pre id="result"></pre>
|
|
80
|
+
<aside class="panel-playlist">
|
|
81
|
+
<div class="playlist-header">
|
|
82
|
+
<h2>Playlist</h2>
|
|
83
|
+
<button type="button" class="primary" id="addAssetBtn" title="Add image or video">+</button>
|
|
84
|
+
</div>
|
|
85
|
+
<input
|
|
86
|
+
type="file"
|
|
87
|
+
id="fileInput"
|
|
88
|
+
class="hidden"
|
|
89
|
+
accept="image/*,video/*,.wgt,.zip"
|
|
90
|
+
/>
|
|
91
|
+
<ul id="playlistList" class="playlist-list">
|
|
92
|
+
<li class="playlist-empty" id="playlistEmpty">No assets yet. Tap + to upload.</li>
|
|
93
|
+
</ul>
|
|
94
|
+
</aside>
|
|
95
|
+
</div>
|
|
55
96
|
|
|
56
97
|
<script src="./methods.js" defer></script>
|
|
57
98
|
</body>
|
|
@@ -1,4 +1,9 @@
|
|
|
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
|
+
|
|
5
|
+
/** @type {{ id: string, url: string, name: string, type: string, durationMs: number }[]} */
|
|
6
|
+
let playlistItems = [];
|
|
2
7
|
|
|
3
8
|
function getPanelDeviceId() {
|
|
4
9
|
return (
|
|
@@ -16,132 +21,346 @@ function setPanelDeviceId(deviceId) {
|
|
|
16
21
|
else localStorage.removeItem(PANEL_DEVICE_ID_KEY);
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
24
|
+
function showResult(data) {
|
|
25
|
+
document.getElementById("result").textContent = JSON.stringify(data, null, 2);
|
|
26
|
+
}
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
const
|
|
28
|
+
function absoluteMediaUrl(path) {
|
|
29
|
+
const p = String(path || "").trim();
|
|
30
|
+
if (!p) return "";
|
|
31
|
+
if (/^https?:\/\//i.test(p)) return p;
|
|
32
|
+
return `${window.location.origin}${p.startsWith("/") ? p : `/${p}`}`;
|
|
33
|
+
}
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
function inferMediaType(filename, mime) {
|
|
36
|
+
const lower = String(filename || "").toLowerCase();
|
|
37
|
+
const m = String(mime || "").toLowerCase();
|
|
38
|
+
if (m.startsWith("video/")) return "video";
|
|
39
|
+
if (m.startsWith("image/")) return "image";
|
|
40
|
+
if (lower.endsWith(".wgt") || lower.endsWith(".zip")) return "widget";
|
|
41
|
+
if (/\.(mp4|webm|mov|m4v)$/.test(lower)) return "video";
|
|
42
|
+
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(lower)) return "image";
|
|
43
|
+
return "image";
|
|
44
|
+
}
|
|
32
45
|
|
|
33
|
-
|
|
46
|
+
function defaultDurationMs(type) {
|
|
47
|
+
if (type === "video") return 30000;
|
|
48
|
+
if (type === "widget") return 20000;
|
|
49
|
+
return 10000;
|
|
50
|
+
}
|
|
34
51
|
|
|
35
|
-
|
|
52
|
+
function savePlaylistDraft() {
|
|
53
|
+
localStorage.setItem(PANEL_PLAYLIST_KEY, JSON.stringify(playlistItems));
|
|
54
|
+
}
|
|
36
55
|
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
function saveScheduleDraft() {
|
|
57
|
+
const draft = {
|
|
58
|
+
startDate: document.getElementById("scheduleStartDate")?.value || "",
|
|
59
|
+
endDate: document.getElementById("scheduleEndDate")?.value || "",
|
|
60
|
+
startTime: document.getElementById("scheduleStartTime")?.value || "",
|
|
61
|
+
endTime: document.getElementById("scheduleEndTime")?.value || "",
|
|
62
|
+
days: [...document.querySelectorAll(".day-checkbox:checked")].map((el) => Number(el.value))
|
|
63
|
+
};
|
|
64
|
+
localStorage.setItem(PANEL_SCHEDULE_KEY, JSON.stringify(draft));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadScheduleDraft() {
|
|
68
|
+
try {
|
|
69
|
+
const raw = localStorage.getItem(PANEL_SCHEDULE_KEY);
|
|
70
|
+
if (!raw) return;
|
|
71
|
+
const draft = JSON.parse(raw);
|
|
72
|
+
if (draft.startDate) document.getElementById("scheduleStartDate").value = draft.startDate;
|
|
73
|
+
if (draft.endDate) document.getElementById("scheduleEndDate").value = draft.endDate;
|
|
74
|
+
if (draft.startTime) document.getElementById("scheduleStartTime").value = draft.startTime;
|
|
75
|
+
if (draft.endTime) document.getElementById("scheduleEndTime").value = draft.endTime;
|
|
76
|
+
document.querySelectorAll(".day-checkbox").forEach((el) => {
|
|
77
|
+
el.checked = Array.isArray(draft.days) && draft.days.includes(Number(el.value));
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
/* ignore */
|
|
39
81
|
}
|
|
40
82
|
}
|
|
41
83
|
|
|
42
|
-
|
|
43
|
-
|
|
84
|
+
function loadPlaylistDraft() {
|
|
85
|
+
try {
|
|
86
|
+
const raw = localStorage.getItem(PANEL_PLAYLIST_KEY);
|
|
87
|
+
if (!raw) return;
|
|
88
|
+
const parsed = JSON.parse(raw);
|
|
89
|
+
if (Array.isArray(parsed)) {
|
|
90
|
+
playlistItems = parsed;
|
|
91
|
+
renderPlaylist();
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
playlistItems = [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
44
97
|
|
|
45
|
-
|
|
46
|
-
|
|
98
|
+
function renderPlaylist() {
|
|
99
|
+
const list = document.getElementById("playlistList");
|
|
100
|
+
const empty = document.getElementById("playlistEmpty");
|
|
101
|
+
list.querySelectorAll(".playlist-item").forEach((el) => el.remove());
|
|
102
|
+
|
|
103
|
+
if (playlistItems.length === 0) {
|
|
104
|
+
empty.classList.remove("hidden");
|
|
47
105
|
return;
|
|
48
106
|
}
|
|
49
107
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
108
|
+
empty.classList.add("hidden");
|
|
109
|
+
|
|
110
|
+
for (const item of playlistItems) {
|
|
111
|
+
const li = document.createElement("li");
|
|
112
|
+
li.className = "playlist-item";
|
|
113
|
+
li.dataset.id = item.id;
|
|
114
|
+
|
|
115
|
+
if (item.type === "image" || item.type === "video") {
|
|
116
|
+
const thumb = document.createElement(item.type === "video" ? "video" : "img");
|
|
117
|
+
thumb.className = "playlist-item-thumb";
|
|
118
|
+
thumb.src = absoluteMediaUrl(item.url);
|
|
119
|
+
if (item.type === "video") {
|
|
120
|
+
thumb.muted = true;
|
|
121
|
+
thumb.playsInline = true;
|
|
122
|
+
} else {
|
|
123
|
+
thumb.alt = item.name;
|
|
124
|
+
}
|
|
125
|
+
li.appendChild(thumb);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const name = document.createElement("div");
|
|
129
|
+
name.className = "playlist-item-name";
|
|
130
|
+
name.textContent = item.name;
|
|
131
|
+
|
|
132
|
+
const meta = document.createElement("div");
|
|
133
|
+
meta.className = "playlist-item-meta";
|
|
134
|
+
meta.textContent = `${item.type} · ${(item.durationMs / 1000).toFixed(0)}s`;
|
|
135
|
+
|
|
136
|
+
const actions = document.createElement("div");
|
|
137
|
+
actions.className = "playlist-item-actions";
|
|
138
|
+
|
|
139
|
+
const durLabel = document.createElement("label");
|
|
140
|
+
durLabel.style.fontSize = "0.75rem";
|
|
141
|
+
durLabel.textContent = "sec ";
|
|
142
|
+
const durInput = document.createElement("input");
|
|
143
|
+
durInput.type = "number";
|
|
144
|
+
durInput.min = "1";
|
|
145
|
+
durInput.max = "3600";
|
|
146
|
+
durInput.value = String(Math.round(item.durationMs / 1000));
|
|
147
|
+
durInput.addEventListener("change", () => {
|
|
148
|
+
item.durationMs = Math.max(1000, Number(durInput.value) || 10) * 1000;
|
|
149
|
+
savePlaylistDraft();
|
|
150
|
+
renderPlaylist();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const removeBtn = document.createElement("button");
|
|
154
|
+
removeBtn.type = "button";
|
|
155
|
+
removeBtn.className = "danger";
|
|
156
|
+
removeBtn.textContent = "Remove";
|
|
157
|
+
removeBtn.addEventListener("click", () => {
|
|
158
|
+
playlistItems = playlistItems.filter((x) => x.id !== item.id);
|
|
159
|
+
savePlaylistDraft();
|
|
160
|
+
renderPlaylist();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
actions.appendChild(durLabel);
|
|
164
|
+
actions.appendChild(durInput);
|
|
165
|
+
actions.appendChild(removeBtn);
|
|
166
|
+
|
|
167
|
+
li.appendChild(name);
|
|
168
|
+
li.appendChild(meta);
|
|
169
|
+
li.appendChild(actions);
|
|
170
|
+
list.appendChild(li);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
53
173
|
|
|
174
|
+
async function uploadFile(file) {
|
|
175
|
+
const q = new URLSearchParams({ filename: file.name });
|
|
176
|
+
const res = await fetch(`/media/upload?${q.toString()}`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
179
|
+
body: file
|
|
180
|
+
});
|
|
54
181
|
const data = await res.json();
|
|
182
|
+
if (!res.ok || data.status === "failed") {
|
|
183
|
+
throw new Error(data.error || `Upload failed (${res.status})`);
|
|
184
|
+
}
|
|
185
|
+
return data;
|
|
186
|
+
}
|
|
55
187
|
|
|
56
|
-
|
|
188
|
+
async function addAssetFromFile(file) {
|
|
189
|
+
showResult({ status: "uploading", filename: file.name });
|
|
190
|
+
const data = await uploadFile(file);
|
|
191
|
+
const type = inferMediaType(file.name, file.type);
|
|
192
|
+
playlistItems.push({
|
|
193
|
+
id: crypto.randomUUID(),
|
|
194
|
+
url: data.url,
|
|
195
|
+
name: file.name,
|
|
196
|
+
type,
|
|
197
|
+
durationMs: defaultDurationMs(type)
|
|
198
|
+
});
|
|
199
|
+
savePlaylistDraft();
|
|
200
|
+
renderPlaylist();
|
|
201
|
+
showResult({ status: "uploaded", ...data });
|
|
57
202
|
}
|
|
58
203
|
|
|
59
|
-
|
|
60
|
-
const
|
|
204
|
+
function buildSchedule() {
|
|
205
|
+
const schedule = {};
|
|
206
|
+
const startDate = document.getElementById("scheduleStartDate")?.value?.trim();
|
|
207
|
+
const endDate = document.getElementById("scheduleEndDate")?.value?.trim();
|
|
208
|
+
const startTime = document.getElementById("scheduleStartTime")?.value?.trim();
|
|
209
|
+
const endTime = document.getElementById("scheduleEndTime")?.value?.trim();
|
|
210
|
+
const days = [...document.querySelectorAll(".day-checkbox:checked")].map((el) =>
|
|
211
|
+
Number(el.value)
|
|
212
|
+
);
|
|
61
213
|
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
214
|
+
if (startDate) schedule.startDate = startDate;
|
|
215
|
+
if (endDate) schedule.endDate = endDate;
|
|
216
|
+
if (startTime) schedule.start = startTime;
|
|
217
|
+
if (endTime) schedule.end = endTime;
|
|
218
|
+
if (days.length > 0) schedule.daysOfWeek = days;
|
|
219
|
+
|
|
220
|
+
return Object.keys(schedule).length > 0 ? schedule : undefined;
|
|
221
|
+
}
|
|
66
222
|
|
|
67
|
-
|
|
68
|
-
|
|
223
|
+
function buildPolicyPayload() {
|
|
224
|
+
const schedule = buildSchedule();
|
|
225
|
+
const playlist = {
|
|
226
|
+
id: "main",
|
|
227
|
+
name: "Playlist",
|
|
228
|
+
items: playlistItems.map((item) => ({
|
|
229
|
+
url: absoluteMediaUrl(item.url),
|
|
230
|
+
type: item.type,
|
|
231
|
+
durationMs: item.durationMs
|
|
232
|
+
}))
|
|
233
|
+
};
|
|
234
|
+
if (schedule) playlist.schedule = schedule;
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
policy: {
|
|
238
|
+
playlists: [playlist],
|
|
239
|
+
fallback: { type: "brand" }
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function verify() {
|
|
245
|
+
const code = document.getElementById("code").value;
|
|
246
|
+
|
|
247
|
+
const res = await fetch("/pairing/verify", {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "Content-Type": "application/json" },
|
|
250
|
+
body: JSON.stringify({ code })
|
|
69
251
|
});
|
|
70
252
|
|
|
71
253
|
const data = await res.json();
|
|
72
|
-
|
|
254
|
+
showResult(data);
|
|
255
|
+
|
|
256
|
+
if (data.deviceId) {
|
|
257
|
+
setPanelDeviceId(data.deviceId);
|
|
258
|
+
}
|
|
73
259
|
}
|
|
74
260
|
|
|
75
|
-
async function
|
|
261
|
+
async function publish() {
|
|
76
262
|
const deviceId = getPanelDeviceId();
|
|
77
263
|
|
|
78
264
|
if (!deviceId) {
|
|
79
|
-
alert("
|
|
265
|
+
alert("Pair a device first (enter code and Verify).");
|
|
80
266
|
return;
|
|
81
267
|
}
|
|
82
268
|
|
|
83
|
-
if (
|
|
269
|
+
if (playlistItems.length === 0) {
|
|
270
|
+
alert("Add at least one asset to the playlist.");
|
|
84
271
|
return;
|
|
85
272
|
}
|
|
86
273
|
|
|
87
|
-
|
|
88
|
-
|
|
274
|
+
saveScheduleDraft();
|
|
275
|
+
const payload = buildPolicyPayload();
|
|
276
|
+
|
|
277
|
+
const res = await fetch(`/device/${deviceId}/content/set-policy`, {
|
|
278
|
+
method: "POST",
|
|
279
|
+
headers: { "Content-Type": "application/json" },
|
|
280
|
+
body: JSON.stringify(payload)
|
|
89
281
|
});
|
|
90
282
|
|
|
91
283
|
const data = await res.json();
|
|
92
|
-
|
|
93
|
-
document.getElementById("result").textContent = JSON.stringify(data, null, 2);
|
|
284
|
+
showResult({ publish: payload, response: data });
|
|
94
285
|
}
|
|
95
286
|
|
|
96
|
-
async function
|
|
287
|
+
async function getInfo() {
|
|
97
288
|
const deviceId = getPanelDeviceId();
|
|
98
|
-
const policyJsonText = document.getElementById("policyJson")?.value?.trim();
|
|
99
|
-
|
|
100
289
|
if (!deviceId) {
|
|
101
290
|
alert("Please pair a device first.");
|
|
102
291
|
return;
|
|
103
292
|
}
|
|
293
|
+
const res = await fetch(`/device/${deviceId}/get-info`, { method: "POST" });
|
|
294
|
+
showResult(await res.json());
|
|
295
|
+
}
|
|
104
296
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
let payload = null;
|
|
111
|
-
try {
|
|
112
|
-
payload = JSON.parse(policyJsonText);
|
|
113
|
-
} catch (err) {
|
|
114
|
-
alert("Policy JSON is invalid: " + err.message);
|
|
297
|
+
async function getCapabilities() {
|
|
298
|
+
const deviceId = getPanelDeviceId();
|
|
299
|
+
if (!deviceId) {
|
|
300
|
+
alert("Please pair a device first.");
|
|
115
301
|
return;
|
|
116
302
|
}
|
|
303
|
+
const res = await fetch(`/device/${deviceId}/get-capabilities`, { method: "POST" });
|
|
304
|
+
showResult(await res.json());
|
|
305
|
+
}
|
|
117
306
|
|
|
118
|
-
|
|
119
|
-
|
|
307
|
+
async function reboot() {
|
|
308
|
+
const deviceId = getPanelDeviceId();
|
|
309
|
+
if (!deviceId) {
|
|
310
|
+
alert("Please pair a device first.");
|
|
120
311
|
return;
|
|
121
312
|
}
|
|
122
|
-
|
|
123
|
-
const res = await fetch(`/device/${deviceId}/
|
|
124
|
-
|
|
125
|
-
headers: { "Content-Type": "application/json" },
|
|
126
|
-
body: JSON.stringify(payload)
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
const data = await res.json();
|
|
130
|
-
document.getElementById("result").textContent = JSON.stringify(data, null, 2);
|
|
313
|
+
if (!confirm("Reboot this device?")) return;
|
|
314
|
+
const res = await fetch(`/device/${deviceId}/reboot`, { method: "POST" });
|
|
315
|
+
showResult(await res.json());
|
|
131
316
|
}
|
|
132
317
|
|
|
133
318
|
async function clearContent() {
|
|
134
319
|
const deviceId = getPanelDeviceId();
|
|
135
|
-
|
|
136
320
|
if (!deviceId) {
|
|
137
321
|
alert("Please pair a device first.");
|
|
138
322
|
return;
|
|
139
323
|
}
|
|
324
|
+
if (!confirm("Clear content on this device?")) return;
|
|
325
|
+
const res = await fetch(`/device/${deviceId}/content/clear`, { method: "POST" });
|
|
326
|
+
showResult(await res.json());
|
|
327
|
+
}
|
|
140
328
|
|
|
141
|
-
|
|
142
|
-
|
|
329
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
330
|
+
const saved = localStorage.getItem(PANEL_DEVICE_ID_KEY);
|
|
331
|
+
if (saved) setPanelDeviceId(saved);
|
|
332
|
+
|
|
333
|
+
loadPlaylistDraft();
|
|
334
|
+
loadScheduleDraft();
|
|
335
|
+
|
|
336
|
+
document.getElementById("addAssetBtn").addEventListener("click", () => {
|
|
337
|
+
document.getElementById("fileInput").click();
|
|
143
338
|
});
|
|
144
339
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
340
|
+
document.getElementById("fileInput").addEventListener("change", async (ev) => {
|
|
341
|
+
const files = ev.target.files;
|
|
342
|
+
if (!files?.length) return;
|
|
343
|
+
try {
|
|
344
|
+
for (const file of files) {
|
|
345
|
+
await addAssetFromFile(file);
|
|
346
|
+
}
|
|
347
|
+
} catch (err) {
|
|
348
|
+
alert(err.message);
|
|
349
|
+
showResult({ status: "failed", error: err.message });
|
|
350
|
+
}
|
|
351
|
+
ev.target.value = "";
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
[
|
|
355
|
+
"scheduleStartDate",
|
|
356
|
+
"scheduleEndDate",
|
|
357
|
+
"scheduleStartTime",
|
|
358
|
+
"scheduleEndTime"
|
|
359
|
+
].forEach((id) => {
|
|
360
|
+
document.getElementById(id)?.addEventListener("change", saveScheduleDraft);
|
|
361
|
+
});
|
|
362
|
+
document.querySelectorAll(".day-checkbox").forEach((el) => {
|
|
363
|
+
el.addEventListener("change", saveScheduleDraft);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
});
|
|
@@ -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
|