@tomorrowos/sdk 0.3.5 → 0.3.7
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/dist/brand-assets.d.ts +8 -0
- package/dist/brand-assets.d.ts.map +1 -0
- package/dist/brand-assets.js +103 -0
- package/dist/tomorrowos.d.ts +1 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +51 -3
- package/package.json +1 -1
- package/templates/cms-starter/brand.json +0 -1
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/methods.js +10 -2
- package/templates/cms-starter/public/panel.css +458 -458
- package/templates/cms-starter/server.ts +55 -55
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Copy project-root `assets/` into `staticRoot/assets/` when files are missing (common init layout). */
|
|
2
|
+
export declare function syncProjectAssetsToStaticRoot(staticRoot: string): Promise<void>;
|
|
3
|
+
/**
|
|
4
|
+
* Resolve logoPath for players: honor brand.json when the file exists, otherwise
|
|
5
|
+
* pick the best image under `staticRoot/assets/` (any filename).
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveBrandLogoPath(staticRoot: string, configuredPath: unknown): Promise<string | undefined>;
|
|
8
|
+
//# sourceMappingURL=brand-assets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"brand-assets.d.ts","sourceRoot":"","sources":["../src/brand-assets.ts"],"names":[],"mappings":"AA+BA,yGAAyG;AACzG,wBAAsB,6BAA6B,CACjD,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CA8Bf;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,MAAM,EAClB,cAAc,EAAE,OAAO,GACtB,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAmC7B"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif"]);
|
|
4
|
+
function isImageFilename(name) {
|
|
5
|
+
return IMAGE_EXT.has(path.extname(name).toLowerCase());
|
|
6
|
+
}
|
|
7
|
+
function scoreLogoCandidate(name) {
|
|
8
|
+
const base = path.basename(name, path.extname(name)).toLowerCase();
|
|
9
|
+
if (base === "logo")
|
|
10
|
+
return 100;
|
|
11
|
+
if (base === "brand" || base === "brand-logo")
|
|
12
|
+
return 90;
|
|
13
|
+
if (base === "icon" || base === "favicon")
|
|
14
|
+
return 80;
|
|
15
|
+
if (base.includes("logo"))
|
|
16
|
+
return 70;
|
|
17
|
+
if (base.includes("brand"))
|
|
18
|
+
return 60;
|
|
19
|
+
if (base.includes("icon"))
|
|
20
|
+
return 50;
|
|
21
|
+
return 10;
|
|
22
|
+
}
|
|
23
|
+
function pickBestLogoFile(files) {
|
|
24
|
+
const images = files.filter(isImageFilename);
|
|
25
|
+
if (images.length === 0)
|
|
26
|
+
return null;
|
|
27
|
+
images.sort((a, b) => {
|
|
28
|
+
const diff = scoreLogoCandidate(b) - scoreLogoCandidate(a);
|
|
29
|
+
if (diff !== 0)
|
|
30
|
+
return diff;
|
|
31
|
+
return a.localeCompare(b);
|
|
32
|
+
});
|
|
33
|
+
return images[0];
|
|
34
|
+
}
|
|
35
|
+
/** Copy project-root `assets/` into `staticRoot/assets/` when files are missing (common init layout). */
|
|
36
|
+
export async function syncProjectAssetsToStaticRoot(staticRoot) {
|
|
37
|
+
const resolvedRoot = path.resolve(staticRoot);
|
|
38
|
+
const publicAssets = path.join(resolvedRoot, "assets");
|
|
39
|
+
const projectAssets = path.join(path.dirname(resolvedRoot), "assets");
|
|
40
|
+
await fs.mkdir(publicAssets, { recursive: true });
|
|
41
|
+
let names = [];
|
|
42
|
+
try {
|
|
43
|
+
names = await fs.readdir(projectAssets);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
if (!isImageFilename(name))
|
|
50
|
+
continue;
|
|
51
|
+
const src = path.join(projectAssets, name);
|
|
52
|
+
const dest = path.join(publicAssets, name);
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(dest);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
try {
|
|
59
|
+
await fs.copyFile(src, dest);
|
|
60
|
+
console.log(`[TomorrowOS] synced brand asset: assets/${name} → public/assets/${name}`);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.warn(`[TomorrowOS] could not sync assets/${name}:`, err);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve logoPath for players: honor brand.json when the file exists, otherwise
|
|
70
|
+
* pick the best image under `staticRoot/assets/` (any filename).
|
|
71
|
+
*/
|
|
72
|
+
export async function resolveBrandLogoPath(staticRoot, configuredPath) {
|
|
73
|
+
const resolvedRoot = path.resolve(staticRoot);
|
|
74
|
+
const assetsDir = path.join(resolvedRoot, "assets");
|
|
75
|
+
await fs.mkdir(assetsDir, { recursive: true });
|
|
76
|
+
const configured = typeof configuredPath === "string" && configuredPath.trim()
|
|
77
|
+
? configuredPath.trim()
|
|
78
|
+
: "";
|
|
79
|
+
if (configured) {
|
|
80
|
+
const rel = configured.replace(/^\.\//, "").replace(/^[\\/]+/, "");
|
|
81
|
+
const onDisk = path.join(resolvedRoot, rel);
|
|
82
|
+
try {
|
|
83
|
+
const st = await fs.stat(onDisk);
|
|
84
|
+
if (st.isFile()) {
|
|
85
|
+
return rel.startsWith("assets/") ? `./${rel}` : `./assets/${path.basename(rel)}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* fall through to auto-detect */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
let files = [];
|
|
93
|
+
try {
|
|
94
|
+
files = await fs.readdir(assetsDir);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return configured || undefined;
|
|
98
|
+
}
|
|
99
|
+
const picked = pickBestLogoFile(files);
|
|
100
|
+
if (!picked)
|
|
101
|
+
return configured || undefined;
|
|
102
|
+
return `./assets/${picked}`;
|
|
103
|
+
}
|
package/dist/tomorrowos.d.ts
CHANGED
|
@@ -111,6 +111,7 @@ export declare class TomorrowOS extends EventEmitter {
|
|
|
111
111
|
private sendCommandToSocket;
|
|
112
112
|
listen(options: ListenOptions): http.Server;
|
|
113
113
|
private handleMediaUpload;
|
|
114
|
+
private refreshResolvedBrandLogo;
|
|
114
115
|
private tryServeStatic;
|
|
115
116
|
private handleHttp;
|
|
116
117
|
private handleDeviceHttp;
|
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;AAUxB,OAAO,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAChF,OAAO,KAAK,EAIV,eAAe,EAChB,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
|
package/dist/tomorrowos.js
CHANGED
|
@@ -7,6 +7,7 @@ import { WebSocket, WebSocketServer } from "ws";
|
|
|
7
7
|
import { generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode } from "./pairing-code.js";
|
|
8
8
|
import { PlaylistCatalog } from "./playlist-catalog.js";
|
|
9
9
|
import { MemoryStore } from "./store/memory-store.js";
|
|
10
|
+
import { resolveBrandLogoPath, syncProjectAssetsToStaticRoot } from "./brand-assets.js";
|
|
10
11
|
function formatDurationMs(ms) {
|
|
11
12
|
if (!Number.isFinite(ms) || ms < 0)
|
|
12
13
|
return "0s";
|
|
@@ -25,6 +26,29 @@ function formatDurationMs(ms) {
|
|
|
25
26
|
parts.push(`${seconds}s`);
|
|
26
27
|
return parts.join(" ");
|
|
27
28
|
}
|
|
29
|
+
function parseBootIsoMs(iso) {
|
|
30
|
+
if (!iso)
|
|
31
|
+
return null;
|
|
32
|
+
const ms = new Date(iso).getTime();
|
|
33
|
+
return Number.isFinite(ms) ? ms : null;
|
|
34
|
+
}
|
|
35
|
+
/** Merge device-reported boot time with stored value (ignore page-reload noise, accept reboots). */
|
|
36
|
+
function resolveLastBootAt(incoming, existing) {
|
|
37
|
+
const inMs = parseBootIsoMs(incoming);
|
|
38
|
+
const exMs = parseBootIsoMs(existing);
|
|
39
|
+
if (inMs == null && exMs == null)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (inMs == null)
|
|
42
|
+
return existing;
|
|
43
|
+
if (exMs == null)
|
|
44
|
+
return incoming;
|
|
45
|
+
const gapMs = inMs - exMs;
|
|
46
|
+
if (gapMs < 0)
|
|
47
|
+
return incoming;
|
|
48
|
+
if (gapMs > 120_000)
|
|
49
|
+
return incoming;
|
|
50
|
+
return new Date(Math.min(inMs, exMs)).toISOString();
|
|
51
|
+
}
|
|
28
52
|
function resolveHelloDeviceId(msg) {
|
|
29
53
|
const serial = typeof msg.serialNumber === "string" && msg.serialNumber.trim()
|
|
30
54
|
? msg.serialNumber.trim()
|
|
@@ -144,13 +168,14 @@ export class TomorrowOS extends EventEmitter {
|
|
|
144
168
|
const pairedAt = new Date().toISOString();
|
|
145
169
|
const meta = this.pendingDeviceMeta.get(deviceId);
|
|
146
170
|
const now = pairedAt;
|
|
171
|
+
const lastBootAt = resolveLastBootAt(meta?.bootedAt, undefined);
|
|
147
172
|
await this.store.setPairedDevice(deviceId, {
|
|
148
173
|
pairingToken,
|
|
149
174
|
pairedAt,
|
|
150
175
|
deviceName: meta?.deviceName,
|
|
151
176
|
platform: meta?.platform,
|
|
152
177
|
system: meta?.system,
|
|
153
|
-
lastBootAt
|
|
178
|
+
...(lastBootAt ? { lastBootAt } : {}),
|
|
154
179
|
lastOnlineAt: this.isDeviceConnected(deviceId) ? now : undefined,
|
|
155
180
|
lastOfflineAt: this.isDeviceConnected(deviceId) ? undefined : now
|
|
156
181
|
});
|
|
@@ -292,6 +317,7 @@ export class TomorrowOS extends EventEmitter {
|
|
|
292
317
|
const existing = await this.store.getPairedDevice(deviceId);
|
|
293
318
|
if (!existing)
|
|
294
319
|
return;
|
|
320
|
+
const lastBootAt = resolveLastBootAt(bootedAt, existing.lastBootAt);
|
|
295
321
|
await this.store.setPairedDevice(deviceId, {
|
|
296
322
|
...existing,
|
|
297
323
|
deviceName: (typeof msg.deviceName === "string" ? msg.deviceName : undefined) ??
|
|
@@ -300,7 +326,7 @@ export class TomorrowOS extends EventEmitter {
|
|
|
300
326
|
existing.platform,
|
|
301
327
|
system: (typeof msg.system === "string" ? msg.system : undefined) ??
|
|
302
328
|
existing.system,
|
|
303
|
-
lastBootAt
|
|
329
|
+
...(lastBootAt ? { lastBootAt } : {}),
|
|
304
330
|
lastOnlineAt: now,
|
|
305
331
|
lastOfflineAt: existing.lastOfflineAt
|
|
306
332
|
});
|
|
@@ -440,6 +466,7 @@ export class TomorrowOS extends EventEmitter {
|
|
|
440
466
|
this.staticIndexFile = idx;
|
|
441
467
|
const uploadsDir = path.join(path.resolve(this.staticRoot), "uploads");
|
|
442
468
|
void fs.mkdir(uploadsDir, { recursive: true });
|
|
469
|
+
void this.refreshResolvedBrandLogo();
|
|
443
470
|
}
|
|
444
471
|
else {
|
|
445
472
|
this.staticIndexFile = "index.html";
|
|
@@ -504,6 +531,21 @@ export class TomorrowOS extends EventEmitter {
|
|
|
504
531
|
sendJson(res, 400, { status: "failed", error: msg });
|
|
505
532
|
}
|
|
506
533
|
}
|
|
534
|
+
async refreshResolvedBrandLogo() {
|
|
535
|
+
if (!this.staticRoot)
|
|
536
|
+
return;
|
|
537
|
+
try {
|
|
538
|
+
await syncProjectAssetsToStaticRoot(this.staticRoot);
|
|
539
|
+
const logoPath = await resolveBrandLogoPath(this.staticRoot, this.brand.logoPath);
|
|
540
|
+
if (logoPath) {
|
|
541
|
+
this.brand.logoPath = logoPath;
|
|
542
|
+
console.log(`[TomorrowOS] brand logo: ${logoPath}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
console.warn("[TomorrowOS] brand logo resolve failed:", err);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
507
549
|
async tryServeStatic(pathname, res) {
|
|
508
550
|
if (!this.staticRoot)
|
|
509
551
|
return false;
|
|
@@ -523,7 +565,13 @@ export class TomorrowOS extends EventEmitter {
|
|
|
523
565
|
".html": "text/html; charset=utf-8",
|
|
524
566
|
".js": "application/javascript; charset=utf-8",
|
|
525
567
|
".css": "text/css; charset=utf-8",
|
|
526
|
-
".json": "application/json; charset=utf-8"
|
|
568
|
+
".json": "application/json; charset=utf-8",
|
|
569
|
+
".svg": "image/svg+xml",
|
|
570
|
+
".png": "image/png",
|
|
571
|
+
".jpg": "image/jpeg",
|
|
572
|
+
".jpeg": "image/jpeg",
|
|
573
|
+
".webp": "image/webp",
|
|
574
|
+
".gif": "image/gif"
|
|
527
575
|
};
|
|
528
576
|
const ctype = types[ext] ?? "application/octet-stream";
|
|
529
577
|
res.writeHead(200, { "Content-Type": ctype });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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.3.
|
|
13
|
+
"@tomorrowos/sdk": "^0.3.7"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^20.0.0",
|
|
@@ -47,7 +47,15 @@ function formatDateTimeSeconds(iso) {
|
|
|
47
47
|
if (!iso) return "—";
|
|
48
48
|
const d = new Date(iso);
|
|
49
49
|
if (Number.isNaN(d.getTime())) return "—";
|
|
50
|
-
return d.toLocaleString(
|
|
50
|
+
return d.toLocaleString(undefined, {
|
|
51
|
+
year: "numeric",
|
|
52
|
+
month: "short",
|
|
53
|
+
day: "numeric",
|
|
54
|
+
hour: "2-digit",
|
|
55
|
+
minute: "2-digit",
|
|
56
|
+
second: "2-digit",
|
|
57
|
+
hour12: false
|
|
58
|
+
});
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
function formatDeviceOnlineLabel(device) {
|
|
@@ -217,7 +225,7 @@ async function fetchPlaylists() {
|
|
|
217
225
|
showResult({
|
|
218
226
|
status: "failed",
|
|
219
227
|
error:
|
|
220
|
-
"CMS server is missing /playlists. Restart CMS with @tomorrowos/sdk 0.3.
|
|
228
|
+
"CMS server is missing /playlists. Restart CMS with @tomorrowos/sdk 0.3.7 or newer."
|
|
221
229
|
});
|
|
222
230
|
}
|
|
223
231
|
return;
|
|
@@ -1,458 +1,458 @@
|
|
|
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-nav {
|
|
43
|
-
width: 220px;
|
|
44
|
-
flex-shrink: 0;
|
|
45
|
-
background: #fff;
|
|
46
|
-
border-right: 1px solid #e4e1dc;
|
|
47
|
-
display: flex;
|
|
48
|
-
flex-direction: column;
|
|
49
|
-
padding: 0.75rem;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
.playlist-nav-header {
|
|
53
|
-
display: flex;
|
|
54
|
-
align-items: center;
|
|
55
|
-
justify-content: space-between;
|
|
56
|
-
margin-bottom: 0.5rem;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
.playlist-nav-header h2 {
|
|
60
|
-
margin: 0;
|
|
61
|
-
font-size: 0.95rem;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
.playlist-catalog {
|
|
65
|
-
list-style: none;
|
|
66
|
-
margin: 0;
|
|
67
|
-
padding: 0;
|
|
68
|
-
overflow: auto;
|
|
69
|
-
flex: 1;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
.playlist-catalog-item {
|
|
73
|
-
border: 1px solid #e4e1dc;
|
|
74
|
-
border-radius: 6px;
|
|
75
|
-
padding: 0.5rem 0.6rem;
|
|
76
|
-
margin-bottom: 0.4rem;
|
|
77
|
-
cursor: pointer;
|
|
78
|
-
font-size: 0.85rem;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
.playlist-catalog-item--active {
|
|
82
|
-
border-color: #ff8a3d;
|
|
83
|
-
background: #fff5ee;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
.playlist-catalog-item small {
|
|
87
|
-
display: block;
|
|
88
|
-
color: #666;
|
|
89
|
-
margin-top: 0.2rem;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
.panel-playlist {
|
|
93
|
-
width: 300px;
|
|
94
|
-
flex-shrink: 0;
|
|
95
|
-
background: #fff;
|
|
96
|
-
border-left: 1px solid #e4e1dc;
|
|
97
|
-
display: flex;
|
|
98
|
-
flex-direction: column;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
.card {
|
|
102
|
-
background: #fff;
|
|
103
|
-
border: 1px solid #e4e1dc;
|
|
104
|
-
border-radius: 10px;
|
|
105
|
-
padding: 1rem 1.1rem;
|
|
106
|
-
margin-bottom: 1rem;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
.card h2 {
|
|
110
|
-
margin: 0 0 0.75rem;
|
|
111
|
-
font-size: 0.95rem;
|
|
112
|
-
font-weight: 600;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
.hint {
|
|
116
|
-
margin: 0 0 0.75rem;
|
|
117
|
-
font-size: 0.8rem;
|
|
118
|
-
color: #666;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
.devices-grid {
|
|
122
|
-
display: grid;
|
|
123
|
-
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
124
|
-
gap: 0.85rem;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
.devices-empty {
|
|
128
|
-
margin: 0;
|
|
129
|
-
color: #666;
|
|
130
|
-
font-size: 0.875rem;
|
|
131
|
-
grid-column: 1 / -1;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
.device-card {
|
|
135
|
-
border: 1px solid #e4e1dc;
|
|
136
|
-
border-radius: 8px;
|
|
137
|
-
padding: 0.85rem 0.95rem;
|
|
138
|
-
background: #fafaf9;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
.device-card-header {
|
|
142
|
-
display: flex;
|
|
143
|
-
align-items: center;
|
|
144
|
-
gap: 0.5rem;
|
|
145
|
-
margin-bottom: 0.65rem;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
.device-card-title {
|
|
149
|
-
margin: 0;
|
|
150
|
-
font-size: 0.95rem;
|
|
151
|
-
font-weight: 600;
|
|
152
|
-
line-height: 1.2;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
.status-led {
|
|
156
|
-
width: 11px;
|
|
157
|
-
height: 11px;
|
|
158
|
-
border-radius: 50%;
|
|
159
|
-
flex-shrink: 0;
|
|
160
|
-
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
.status-led--online {
|
|
164
|
-
background: #12b76a;
|
|
165
|
-
box-shadow: 0 0 8px rgba(18, 183, 106, 0.55);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
.status-led--offline {
|
|
169
|
-
background: #f04438;
|
|
170
|
-
box-shadow: 0 0 8px rgba(240, 68, 56, 0.45);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
.device-meta {
|
|
174
|
-
display: grid;
|
|
175
|
-
gap: 0.35rem;
|
|
176
|
-
font-size: 0.78rem;
|
|
177
|
-
color: #444;
|
|
178
|
-
margin-bottom: 0.75rem;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
.device-meta dt {
|
|
182
|
-
font-weight: 600;
|
|
183
|
-
color: #666;
|
|
184
|
-
display: inline;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
.device-meta dt::after {
|
|
188
|
-
content: ": ";
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
.device-meta dd {
|
|
192
|
-
display: inline;
|
|
193
|
-
margin: 0;
|
|
194
|
-
word-break: break-all;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
.device-meta-row {
|
|
198
|
-
display: block;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
.device-card-actions {
|
|
202
|
-
display: flex;
|
|
203
|
-
flex-wrap: wrap;
|
|
204
|
-
gap: 0.4rem;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
.device-card-actions button {
|
|
208
|
-
font-size: 0.78rem;
|
|
209
|
-
padding: 0.35rem 0.6rem;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
.row {
|
|
213
|
-
display: flex;
|
|
214
|
-
flex-wrap: wrap;
|
|
215
|
-
gap: 0.5rem;
|
|
216
|
-
align-items: center;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
input[type="text"],
|
|
220
|
-
input[type="date"],
|
|
221
|
-
input[type="time"],
|
|
222
|
-
select {
|
|
223
|
-
font: inherit;
|
|
224
|
-
padding: 0.45rem 0.6rem;
|
|
225
|
-
border: 1px solid #d6d2cb;
|
|
226
|
-
border-radius: 6px;
|
|
227
|
-
background: #fff;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
button {
|
|
231
|
-
font: inherit;
|
|
232
|
-
padding: 0.45rem 0.85rem;
|
|
233
|
-
border-radius: 6px;
|
|
234
|
-
border: 1px solid #d6d2cb;
|
|
235
|
-
background: #fff;
|
|
236
|
-
cursor: pointer;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
button:hover {
|
|
240
|
-
background: #f5f3ef;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
button.primary {
|
|
244
|
-
background: #ff8a3d;
|
|
245
|
-
border-color: #e67320;
|
|
246
|
-
color: #0a0908;
|
|
247
|
-
font-weight: 600;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
button.primary:hover {
|
|
251
|
-
background: #ff9a57;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
button.danger {
|
|
255
|
-
color: #b42318;
|
|
256
|
-
border-color: #fecdca;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
.schedule-grid {
|
|
260
|
-
display: grid;
|
|
261
|
-
grid-template-columns: 1fr 1fr;
|
|
262
|
-
gap: 0.65rem 1rem;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
.schedule-grid label {
|
|
266
|
-
display: flex;
|
|
267
|
-
flex-direction: column;
|
|
268
|
-
gap: 0.25rem;
|
|
269
|
-
font-size: 0.8rem;
|
|
270
|
-
color: #444;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
.days-row {
|
|
274
|
-
grid-column: 1 / -1;
|
|
275
|
-
display: flex;
|
|
276
|
-
flex-wrap: wrap;
|
|
277
|
-
gap: 0.5rem 0.75rem;
|
|
278
|
-
font-size: 0.8rem;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
.days-row label {
|
|
282
|
-
flex-direction: row;
|
|
283
|
-
align-items: center;
|
|
284
|
-
gap: 0.35rem;
|
|
285
|
-
color: #0a0908;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
.playlist-header {
|
|
289
|
-
display: flex;
|
|
290
|
-
align-items: center;
|
|
291
|
-
justify-content: space-between;
|
|
292
|
-
padding: 1rem 1rem 0.75rem;
|
|
293
|
-
border-bottom: 1px solid #e4e1dc;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
.playlist-header h2 {
|
|
297
|
-
margin: 0;
|
|
298
|
-
font-size: 0.95rem;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
.playlist-list {
|
|
302
|
-
list-style: none;
|
|
303
|
-
margin: 0;
|
|
304
|
-
padding: 0.5rem;
|
|
305
|
-
flex: 1;
|
|
306
|
-
overflow-y: auto;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
.playlist-empty {
|
|
310
|
-
padding: 1.5rem 1rem;
|
|
311
|
-
text-align: center;
|
|
312
|
-
color: #888;
|
|
313
|
-
font-size: 0.875rem;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
.playlist-item {
|
|
317
|
-
border: 1px solid #e4e1dc;
|
|
318
|
-
border-radius: 8px;
|
|
319
|
-
padding: 0.65rem;
|
|
320
|
-
margin-bottom: 0.5rem;
|
|
321
|
-
background: #fafaf9;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
.playlist-item-thumb {
|
|
325
|
-
width: 100%;
|
|
326
|
-
aspect-ratio: 16 / 9;
|
|
327
|
-
object-fit: cover;
|
|
328
|
-
border-radius: 4px;
|
|
329
|
-
background: #e4e1dc;
|
|
330
|
-
display: block;
|
|
331
|
-
margin-bottom: 0.5rem;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
.playlist-item-name {
|
|
335
|
-
font-size: 0.8rem;
|
|
336
|
-
font-weight: 600;
|
|
337
|
-
word-break: break-all;
|
|
338
|
-
margin-bottom: 0.35rem;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
.playlist-item-meta {
|
|
342
|
-
font-size: 0.75rem;
|
|
343
|
-
color: #666;
|
|
344
|
-
margin-bottom: 0.45rem;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
.playlist-item-actions {
|
|
348
|
-
display: flex;
|
|
349
|
-
gap: 0.35rem;
|
|
350
|
-
align-items: center;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
.playlist-item-actions input[type="number"] {
|
|
354
|
-
width: 5rem;
|
|
355
|
-
font: inherit;
|
|
356
|
-
padding: 0.25rem 0.4rem;
|
|
357
|
-
border: 1px solid #d6d2cb;
|
|
358
|
-
border-radius: 4px;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
.publish-bar {
|
|
362
|
-
padding: 1rem 0 0;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
#result {
|
|
366
|
-
margin: 0;
|
|
367
|
-
padding: 0.75rem;
|
|
368
|
-
background: #0a0908;
|
|
369
|
-
color: #e8e6e3;
|
|
370
|
-
border-radius: 8px;
|
|
371
|
-
font-size: 0.75rem;
|
|
372
|
-
max-height: 10rem;
|
|
373
|
-
overflow: auto;
|
|
374
|
-
white-space: pre-wrap;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
.hidden {
|
|
378
|
-
display: none !important;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
.field-label {
|
|
382
|
-
display: block;
|
|
383
|
-
margin-bottom: 0.75rem;
|
|
384
|
-
font-size: 0.85rem;
|
|
385
|
-
font-weight: 600;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
.field-label input {
|
|
389
|
-
display: block;
|
|
390
|
-
width: 100%;
|
|
391
|
-
margin-top: 0.35rem;
|
|
392
|
-
font-weight: 400;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
.subheading {
|
|
396
|
-
margin: 0.5rem 0 0.35rem;
|
|
397
|
-
font-size: 0.9rem;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
.editor-actions {
|
|
401
|
-
display: flex;
|
|
402
|
-
flex-wrap: wrap;
|
|
403
|
-
gap: 0.5rem;
|
|
404
|
-
margin-top: 0.75rem;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
.device-published-list {
|
|
408
|
-
margin: 0 0 0.65rem;
|
|
409
|
-
padding: 0;
|
|
410
|
-
list-style: none;
|
|
411
|
-
font-size: 0.78rem;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
.device-published-list li {
|
|
415
|
-
display: flex;
|
|
416
|
-
align-items: center;
|
|
417
|
-
justify-content: space-between;
|
|
418
|
-
gap: 0.35rem;
|
|
419
|
-
margin-bottom: 0.25rem;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
.modal {
|
|
423
|
-
position: fixed;
|
|
424
|
-
inset: 0;
|
|
425
|
-
z-index: 1000;
|
|
426
|
-
display: flex;
|
|
427
|
-
align-items: center;
|
|
428
|
-
justify-content: center;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
.modal-backdrop {
|
|
432
|
-
position: absolute;
|
|
433
|
-
inset: 0;
|
|
434
|
-
background: rgba(0, 0, 0, 0.45);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
.modal-card {
|
|
438
|
-
position: relative;
|
|
439
|
-
background: #fff;
|
|
440
|
-
border-radius: 10px;
|
|
441
|
-
padding: 1.25rem;
|
|
442
|
-
width: min(92vw, 420px);
|
|
443
|
-
max-height: 80vh;
|
|
444
|
-
overflow: auto;
|
|
445
|
-
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
.publish-checklist label {
|
|
449
|
-
display: block;
|
|
450
|
-
margin-bottom: 0.45rem;
|
|
451
|
-
font-size: 0.9rem;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
.modal-actions {
|
|
455
|
-
display: flex;
|
|
456
|
-
gap: 0.5rem;
|
|
457
|
-
margin-top: 1rem;
|
|
458
|
-
}
|
|
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-nav {
|
|
43
|
+
width: 220px;
|
|
44
|
+
flex-shrink: 0;
|
|
45
|
+
background: #fff;
|
|
46
|
+
border-right: 1px solid #e4e1dc;
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
padding: 0.75rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.playlist-nav-header {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: space-between;
|
|
56
|
+
margin-bottom: 0.5rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.playlist-nav-header h2 {
|
|
60
|
+
margin: 0;
|
|
61
|
+
font-size: 0.95rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.playlist-catalog {
|
|
65
|
+
list-style: none;
|
|
66
|
+
margin: 0;
|
|
67
|
+
padding: 0;
|
|
68
|
+
overflow: auto;
|
|
69
|
+
flex: 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.playlist-catalog-item {
|
|
73
|
+
border: 1px solid #e4e1dc;
|
|
74
|
+
border-radius: 6px;
|
|
75
|
+
padding: 0.5rem 0.6rem;
|
|
76
|
+
margin-bottom: 0.4rem;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
font-size: 0.85rem;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.playlist-catalog-item--active {
|
|
82
|
+
border-color: #ff8a3d;
|
|
83
|
+
background: #fff5ee;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.playlist-catalog-item small {
|
|
87
|
+
display: block;
|
|
88
|
+
color: #666;
|
|
89
|
+
margin-top: 0.2rem;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.panel-playlist {
|
|
93
|
+
width: 300px;
|
|
94
|
+
flex-shrink: 0;
|
|
95
|
+
background: #fff;
|
|
96
|
+
border-left: 1px solid #e4e1dc;
|
|
97
|
+
display: flex;
|
|
98
|
+
flex-direction: column;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.card {
|
|
102
|
+
background: #fff;
|
|
103
|
+
border: 1px solid #e4e1dc;
|
|
104
|
+
border-radius: 10px;
|
|
105
|
+
padding: 1rem 1.1rem;
|
|
106
|
+
margin-bottom: 1rem;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.card h2 {
|
|
110
|
+
margin: 0 0 0.75rem;
|
|
111
|
+
font-size: 0.95rem;
|
|
112
|
+
font-weight: 600;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.hint {
|
|
116
|
+
margin: 0 0 0.75rem;
|
|
117
|
+
font-size: 0.8rem;
|
|
118
|
+
color: #666;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.devices-grid {
|
|
122
|
+
display: grid;
|
|
123
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
124
|
+
gap: 0.85rem;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.devices-empty {
|
|
128
|
+
margin: 0;
|
|
129
|
+
color: #666;
|
|
130
|
+
font-size: 0.875rem;
|
|
131
|
+
grid-column: 1 / -1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.device-card {
|
|
135
|
+
border: 1px solid #e4e1dc;
|
|
136
|
+
border-radius: 8px;
|
|
137
|
+
padding: 0.85rem 0.95rem;
|
|
138
|
+
background: #fafaf9;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.device-card-header {
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
gap: 0.5rem;
|
|
145
|
+
margin-bottom: 0.65rem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.device-card-title {
|
|
149
|
+
margin: 0;
|
|
150
|
+
font-size: 0.95rem;
|
|
151
|
+
font-weight: 600;
|
|
152
|
+
line-height: 1.2;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.status-led {
|
|
156
|
+
width: 11px;
|
|
157
|
+
height: 11px;
|
|
158
|
+
border-radius: 50%;
|
|
159
|
+
flex-shrink: 0;
|
|
160
|
+
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.status-led--online {
|
|
164
|
+
background: #12b76a;
|
|
165
|
+
box-shadow: 0 0 8px rgba(18, 183, 106, 0.55);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.status-led--offline {
|
|
169
|
+
background: #f04438;
|
|
170
|
+
box-shadow: 0 0 8px rgba(240, 68, 56, 0.45);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.device-meta {
|
|
174
|
+
display: grid;
|
|
175
|
+
gap: 0.35rem;
|
|
176
|
+
font-size: 0.78rem;
|
|
177
|
+
color: #444;
|
|
178
|
+
margin-bottom: 0.75rem;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.device-meta dt {
|
|
182
|
+
font-weight: 600;
|
|
183
|
+
color: #666;
|
|
184
|
+
display: inline;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.device-meta dt::after {
|
|
188
|
+
content: ": ";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.device-meta dd {
|
|
192
|
+
display: inline;
|
|
193
|
+
margin: 0;
|
|
194
|
+
word-break: break-all;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.device-meta-row {
|
|
198
|
+
display: block;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.device-card-actions {
|
|
202
|
+
display: flex;
|
|
203
|
+
flex-wrap: wrap;
|
|
204
|
+
gap: 0.4rem;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.device-card-actions button {
|
|
208
|
+
font-size: 0.78rem;
|
|
209
|
+
padding: 0.35rem 0.6rem;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.row {
|
|
213
|
+
display: flex;
|
|
214
|
+
flex-wrap: wrap;
|
|
215
|
+
gap: 0.5rem;
|
|
216
|
+
align-items: center;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
input[type="text"],
|
|
220
|
+
input[type="date"],
|
|
221
|
+
input[type="time"],
|
|
222
|
+
select {
|
|
223
|
+
font: inherit;
|
|
224
|
+
padding: 0.45rem 0.6rem;
|
|
225
|
+
border: 1px solid #d6d2cb;
|
|
226
|
+
border-radius: 6px;
|
|
227
|
+
background: #fff;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
button {
|
|
231
|
+
font: inherit;
|
|
232
|
+
padding: 0.45rem 0.85rem;
|
|
233
|
+
border-radius: 6px;
|
|
234
|
+
border: 1px solid #d6d2cb;
|
|
235
|
+
background: #fff;
|
|
236
|
+
cursor: pointer;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
button:hover {
|
|
240
|
+
background: #f5f3ef;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
button.primary {
|
|
244
|
+
background: #ff8a3d;
|
|
245
|
+
border-color: #e67320;
|
|
246
|
+
color: #0a0908;
|
|
247
|
+
font-weight: 600;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
button.primary:hover {
|
|
251
|
+
background: #ff9a57;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
button.danger {
|
|
255
|
+
color: #b42318;
|
|
256
|
+
border-color: #fecdca;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.schedule-grid {
|
|
260
|
+
display: grid;
|
|
261
|
+
grid-template-columns: 1fr 1fr;
|
|
262
|
+
gap: 0.65rem 1rem;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.schedule-grid label {
|
|
266
|
+
display: flex;
|
|
267
|
+
flex-direction: column;
|
|
268
|
+
gap: 0.25rem;
|
|
269
|
+
font-size: 0.8rem;
|
|
270
|
+
color: #444;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.days-row {
|
|
274
|
+
grid-column: 1 / -1;
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-wrap: wrap;
|
|
277
|
+
gap: 0.5rem 0.75rem;
|
|
278
|
+
font-size: 0.8rem;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.days-row label {
|
|
282
|
+
flex-direction: row;
|
|
283
|
+
align-items: center;
|
|
284
|
+
gap: 0.35rem;
|
|
285
|
+
color: #0a0908;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.playlist-header {
|
|
289
|
+
display: flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
justify-content: space-between;
|
|
292
|
+
padding: 1rem 1rem 0.75rem;
|
|
293
|
+
border-bottom: 1px solid #e4e1dc;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.playlist-header h2 {
|
|
297
|
+
margin: 0;
|
|
298
|
+
font-size: 0.95rem;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.playlist-list {
|
|
302
|
+
list-style: none;
|
|
303
|
+
margin: 0;
|
|
304
|
+
padding: 0.5rem;
|
|
305
|
+
flex: 1;
|
|
306
|
+
overflow-y: auto;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.playlist-empty {
|
|
310
|
+
padding: 1.5rem 1rem;
|
|
311
|
+
text-align: center;
|
|
312
|
+
color: #888;
|
|
313
|
+
font-size: 0.875rem;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.playlist-item {
|
|
317
|
+
border: 1px solid #e4e1dc;
|
|
318
|
+
border-radius: 8px;
|
|
319
|
+
padding: 0.65rem;
|
|
320
|
+
margin-bottom: 0.5rem;
|
|
321
|
+
background: #fafaf9;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.playlist-item-thumb {
|
|
325
|
+
width: 100%;
|
|
326
|
+
aspect-ratio: 16 / 9;
|
|
327
|
+
object-fit: cover;
|
|
328
|
+
border-radius: 4px;
|
|
329
|
+
background: #e4e1dc;
|
|
330
|
+
display: block;
|
|
331
|
+
margin-bottom: 0.5rem;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.playlist-item-name {
|
|
335
|
+
font-size: 0.8rem;
|
|
336
|
+
font-weight: 600;
|
|
337
|
+
word-break: break-all;
|
|
338
|
+
margin-bottom: 0.35rem;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.playlist-item-meta {
|
|
342
|
+
font-size: 0.75rem;
|
|
343
|
+
color: #666;
|
|
344
|
+
margin-bottom: 0.45rem;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.playlist-item-actions {
|
|
348
|
+
display: flex;
|
|
349
|
+
gap: 0.35rem;
|
|
350
|
+
align-items: center;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.playlist-item-actions input[type="number"] {
|
|
354
|
+
width: 5rem;
|
|
355
|
+
font: inherit;
|
|
356
|
+
padding: 0.25rem 0.4rem;
|
|
357
|
+
border: 1px solid #d6d2cb;
|
|
358
|
+
border-radius: 4px;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.publish-bar {
|
|
362
|
+
padding: 1rem 0 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#result {
|
|
366
|
+
margin: 0;
|
|
367
|
+
padding: 0.75rem;
|
|
368
|
+
background: #0a0908;
|
|
369
|
+
color: #e8e6e3;
|
|
370
|
+
border-radius: 8px;
|
|
371
|
+
font-size: 0.75rem;
|
|
372
|
+
max-height: 10rem;
|
|
373
|
+
overflow: auto;
|
|
374
|
+
white-space: pre-wrap;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.hidden {
|
|
378
|
+
display: none !important;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.field-label {
|
|
382
|
+
display: block;
|
|
383
|
+
margin-bottom: 0.75rem;
|
|
384
|
+
font-size: 0.85rem;
|
|
385
|
+
font-weight: 600;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.field-label input {
|
|
389
|
+
display: block;
|
|
390
|
+
width: 100%;
|
|
391
|
+
margin-top: 0.35rem;
|
|
392
|
+
font-weight: 400;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.subheading {
|
|
396
|
+
margin: 0.5rem 0 0.35rem;
|
|
397
|
+
font-size: 0.9rem;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.editor-actions {
|
|
401
|
+
display: flex;
|
|
402
|
+
flex-wrap: wrap;
|
|
403
|
+
gap: 0.5rem;
|
|
404
|
+
margin-top: 0.75rem;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.device-published-list {
|
|
408
|
+
margin: 0 0 0.65rem;
|
|
409
|
+
padding: 0;
|
|
410
|
+
list-style: none;
|
|
411
|
+
font-size: 0.78rem;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.device-published-list li {
|
|
415
|
+
display: flex;
|
|
416
|
+
align-items: center;
|
|
417
|
+
justify-content: space-between;
|
|
418
|
+
gap: 0.35rem;
|
|
419
|
+
margin-bottom: 0.25rem;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.modal {
|
|
423
|
+
position: fixed;
|
|
424
|
+
inset: 0;
|
|
425
|
+
z-index: 1000;
|
|
426
|
+
display: flex;
|
|
427
|
+
align-items: center;
|
|
428
|
+
justify-content: center;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.modal-backdrop {
|
|
432
|
+
position: absolute;
|
|
433
|
+
inset: 0;
|
|
434
|
+
background: rgba(0, 0, 0, 0.45);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.modal-card {
|
|
438
|
+
position: relative;
|
|
439
|
+
background: #fff;
|
|
440
|
+
border-radius: 10px;
|
|
441
|
+
padding: 1.25rem;
|
|
442
|
+
width: min(92vw, 420px);
|
|
443
|
+
max-height: 80vh;
|
|
444
|
+
overflow: auto;
|
|
445
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.publish-checklist label {
|
|
449
|
+
display: block;
|
|
450
|
+
margin-bottom: 0.45rem;
|
|
451
|
+
font-size: 0.9rem;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.modal-actions {
|
|
455
|
+
display: flex;
|
|
456
|
+
gap: 0.5rem;
|
|
457
|
+
margin-top: 1rem;
|
|
458
|
+
}
|
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TomorrowOS CMS server — minimal starter.
|
|
3
|
-
* Add routes, database, or serve a React build via listen({ staticRoot }).
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readFileSync } from "fs";
|
|
7
|
-
import { fileURLToPath } from "url";
|
|
8
|
-
import { dirname, join } from "path";
|
|
9
|
-
import { TomorrowOS } from "@tomorrowos/sdk";
|
|
10
|
-
|
|
11
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
const brand = JSON.parse(readFileSync(join(__dirname, "brand.json"), "utf8"));
|
|
13
|
-
|
|
14
|
-
const tomorrowos = new TomorrowOS({ brand });
|
|
15
|
-
|
|
16
|
-
tomorrowos.listen({
|
|
17
|
-
port: Number(process.env.PORT) || 3000,
|
|
18
|
-
host: "0.0.0.0",
|
|
19
|
-
staticRoot: join(__dirname,"public"),
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
tomorrowos.on("device.paired", (event) => {
|
|
23
|
-
console.log(`[TomorrowOS] device paired: ${event.deviceId}`);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
tomorrowos.on("device.online", (event) => {
|
|
27
|
-
console.log(`[TomorrowOS] device online: ${event.deviceId}`);
|
|
28
|
-
void tomorrowos.pushLatestPolicyToDevice(event.deviceId).then((r) => {
|
|
29
|
-
if (r.pushed) {
|
|
30
|
-
console.log(`[TomorrowOS] pushed latest policy to ${event.deviceId}`);
|
|
31
|
-
}
|
|
32
|
-
}).catch((err) => {
|
|
33
|
-
console.error(`[TomorrowOS] latest policy push failed for ${event.deviceId}:`, err);
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
tomorrowos.on("device.offline", (event) => {
|
|
38
|
-
console.log(
|
|
39
|
-
`[TomorrowOS] device offline: ${event.deviceId} (lastSeen: ${event.lastSeen})`
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
tomorrowos.on("command.verified", (event) => {
|
|
44
|
-
console.log(
|
|
45
|
-
`[TomorrowOS] command verified: ${event.commandId} (${event.method})`
|
|
46
|
-
);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
tomorrowos.on("command.failed", (event) => {
|
|
50
|
-
console.error(
|
|
51
|
-
`[TomorrowOS] command failed: ${event.commandId} (${event.method}) — ${event.error.message}`
|
|
52
|
-
);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
export default tomorrowos;
|
|
1
|
+
/**
|
|
2
|
+
* TomorrowOS CMS server — minimal starter.
|
|
3
|
+
* Add routes, database, or serve a React build via listen({ staticRoot }).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { dirname, join } from "path";
|
|
9
|
+
import { TomorrowOS } from "@tomorrowos/sdk";
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const brand = JSON.parse(readFileSync(join(__dirname, "brand.json"), "utf8"));
|
|
13
|
+
|
|
14
|
+
const tomorrowos = new TomorrowOS({ brand });
|
|
15
|
+
|
|
16
|
+
tomorrowos.listen({
|
|
17
|
+
port: Number(process.env.PORT) || 3000,
|
|
18
|
+
host: "0.0.0.0",
|
|
19
|
+
staticRoot: join(__dirname,"public"),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
tomorrowos.on("device.paired", (event) => {
|
|
23
|
+
console.log(`[TomorrowOS] device paired: ${event.deviceId}`);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
tomorrowos.on("device.online", (event) => {
|
|
27
|
+
console.log(`[TomorrowOS] device online: ${event.deviceId}`);
|
|
28
|
+
void tomorrowos.pushLatestPolicyToDevice(event.deviceId).then((r) => {
|
|
29
|
+
if (r.pushed) {
|
|
30
|
+
console.log(`[TomorrowOS] pushed latest policy to ${event.deviceId}`);
|
|
31
|
+
}
|
|
32
|
+
}).catch((err) => {
|
|
33
|
+
console.error(`[TomorrowOS] latest policy push failed for ${event.deviceId}:`, err);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
tomorrowos.on("device.offline", (event) => {
|
|
38
|
+
console.log(
|
|
39
|
+
`[TomorrowOS] device offline: ${event.deviceId} (lastSeen: ${event.lastSeen})`
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
tomorrowos.on("command.verified", (event) => {
|
|
44
|
+
console.log(
|
|
45
|
+
`[TomorrowOS] command verified: ${event.commandId} (${event.method})`
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
tomorrowos.on("command.failed", (event) => {
|
|
50
|
+
console.error(
|
|
51
|
+
`[TomorrowOS] command failed: ${event.commandId} (${event.method}) — ${event.error.message}`
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export default tomorrowos;
|