@tomorrowos/sdk 0.2.3 → 0.2.5
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/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/pairing-code.d.ts +8 -0
- package/dist/pairing-code.d.ts.map +1 -0
- package/dist/pairing-code.js +23 -0
- package/dist/store/memory-store.d.ts +9 -1
- package/dist/store/memory-store.d.ts.map +1 -1
- package/dist/store/memory-store.js +22 -0
- package/dist/store/types.d.ts +14 -0
- package/dist/store/types.d.ts.map +1 -1
- package/dist/tomorrowos.d.ts +5 -1
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +101 -38
- package/package.json +1 -1
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/policy.example.json +30 -30
- package/templates/cms-starter/public/index.html +2 -2
- package/templates/cms-starter/public/methods.js +11 -18
- package/templates/cms-starter/public/panel.css +329 -329
- package/templates/cms-starter/public/uploads/.gitkeep +1 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { TomorrowOS } from "./tomorrowos.js";
|
|
2
2
|
export type { DeviceListItem, ListenOptions, TomorrowOSBrand, TomorrowOSOptions } from "./tomorrowos.js";
|
|
3
|
-
export type { PairedDeviceRecord, PendingCodeRecord, TomorrowOSStore } from "./store/types.js";
|
|
3
|
+
export type { DeviceRegistryRecord, PairedDeviceRecord, PendingCodeRecord, TomorrowOSStore } from "./store/types.js";
|
|
4
|
+
export { generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH } from "./pairing-code.js";
|
|
4
5
|
export { MemoryStore } from "./store/memory-store.js";
|
|
5
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,YAAY,EACV,cAAc,EACd,aAAa,EACb,eAAe,EACf,iBAAiB,EAClB,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EACV,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,YAAY,EACV,cAAc,EACd,aAAa,EACb,eAAe,EACf,iBAAiB,EAClB,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EACV,oBAAoB,EACpB,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,yBAAyB,EACzB,wBAAwB,EACxB,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** 0-9 and A-Z — 36 characters per digit position. */
|
|
2
|
+
export declare const PAIRING_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
3
|
+
export declare const PAIRING_CODE_LENGTH = 6;
|
|
4
|
+
export declare function normalizePairingCode(raw: string): string;
|
|
5
|
+
export declare function isValidPairingCodeFormat(code: string): boolean;
|
|
6
|
+
/** Random 6-character code; each position is digit or uppercase letter. */
|
|
7
|
+
export declare function generateRandomPairingCode(): string;
|
|
8
|
+
//# sourceMappingURL=pairing-code.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pairing-code.d.ts","sourceRoot":"","sources":["../src/pairing-code.ts"],"names":[],"mappings":"AAEA,sDAAsD;AACtD,eAAO,MAAM,qBAAqB,yCAAyC,CAAC;AAE5E,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKxD;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAK9D;AAED,2EAA2E;AAC3E,wBAAgB,yBAAyB,IAAI,MAAM,CAOlD"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
/** 0-9 and A-Z — 36 characters per digit position. */
|
|
3
|
+
export const PAIRING_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
4
|
+
export const PAIRING_CODE_LENGTH = 6;
|
|
5
|
+
export function normalizePairingCode(raw) {
|
|
6
|
+
return String(raw || "")
|
|
7
|
+
.trim()
|
|
8
|
+
.toUpperCase()
|
|
9
|
+
.replace(/[^0-9A-Z]/g, "");
|
|
10
|
+
}
|
|
11
|
+
export function isValidPairingCodeFormat(code) {
|
|
12
|
+
return (code.length === PAIRING_CODE_LENGTH &&
|
|
13
|
+
[...code].every((ch) => PAIRING_CODE_ALPHABET.includes(ch)));
|
|
14
|
+
}
|
|
15
|
+
/** Random 6-character code; each position is digit or uppercase letter. */
|
|
16
|
+
export function generateRandomPairingCode() {
|
|
17
|
+
const bytes = randomBytes(PAIRING_CODE_LENGTH);
|
|
18
|
+
let out = "";
|
|
19
|
+
for (let i = 0; i < PAIRING_CODE_LENGTH; i += 1) {
|
|
20
|
+
out += PAIRING_CODE_ALPHABET[bytes[i] % PAIRING_CODE_ALPHABET.length];
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import type { PairedDeviceEntry, PairedDeviceRecord, PendingCodeRecord, TomorrowOSStore } from "./types.js";
|
|
1
|
+
import type { DeviceRegistryRecord, PairedDeviceEntry, PairedDeviceRecord, PendingCodeRecord, TomorrowOSStore } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Default store for development / single-node demos.
|
|
4
4
|
* Data is lost on process restart — not for multi-instance production.
|
|
5
5
|
*/
|
|
6
6
|
export declare class MemoryStore implements TomorrowOSStore {
|
|
7
7
|
private readonly pendingCodes;
|
|
8
|
+
private readonly deviceRegistry;
|
|
9
|
+
private readonly codeToDeviceId;
|
|
8
10
|
private readonly pairedDevices;
|
|
9
11
|
setPendingCode(code: string, record: PendingCodeRecord): Promise<void>;
|
|
10
12
|
getPendingCode(code: string): Promise<PendingCodeRecord | undefined>;
|
|
11
13
|
deletePendingCode(code: string): Promise<void>;
|
|
14
|
+
getDeviceRegistry(deviceId: string): Promise<DeviceRegistryRecord | undefined>;
|
|
15
|
+
setDeviceRegistry(deviceId: string, record: DeviceRegistryRecord): Promise<void>;
|
|
16
|
+
getDeviceRegistryByCode(code: string): Promise<{
|
|
17
|
+
deviceId: string;
|
|
18
|
+
record: DeviceRegistryRecord;
|
|
19
|
+
} | undefined>;
|
|
12
20
|
setPairedDevice(deviceId: string, record: PairedDeviceRecord): Promise<void>;
|
|
13
21
|
getPairedDevice(deviceId: string): Promise<PairedDeviceRecord | undefined>;
|
|
14
22
|
deletePairedDevice(deviceId: string): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"memory-store.d.ts","sourceRoot":"","sources":["../../src/store/memory-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,qBAAa,WAAY,YAAW,eAAe;IACjD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAwC;IACrE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAyC;IAEjE,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAItE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIpE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,kBAAkB,GACzB,OAAO,CAAC,IAAI,CAAC;IAIV,eAAe,CACnB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;IAIpC,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAInD,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;CAMxD"}
|
|
1
|
+
{"version":3,"file":"memory-store.d.ts","sourceRoot":"","sources":["../../src/store/memory-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,oBAAoB,EACpB,iBAAiB,EACjB,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EAChB,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,qBAAa,WAAY,YAAW,eAAe;IACjD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAwC;IACrE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2C;IAC1E,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA6B;IAC5D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAyC;IAEjE,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAItE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC;IAIpE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C,iBAAiB,CACrB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC;IAItC,iBAAiB,CACrB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,IAAI,CAAC;IASV,uBAAuB,CAC3B,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,oBAAoB,CAAA;KAAE,GAAG,SAAS,CAAC;IAQpE,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,kBAAkB,GACzB,OAAO,CAAC,IAAI,CAAC;IAIV,eAAe,CACnB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC;IAIpC,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAInD,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;CAMxD"}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export class MemoryStore {
|
|
6
6
|
pendingCodes = new Map();
|
|
7
|
+
deviceRegistry = new Map();
|
|
8
|
+
codeToDeviceId = new Map();
|
|
7
9
|
pairedDevices = new Map();
|
|
8
10
|
async setPendingCode(code, record) {
|
|
9
11
|
this.pendingCodes.set(code, record);
|
|
@@ -14,6 +16,26 @@ export class MemoryStore {
|
|
|
14
16
|
async deletePendingCode(code) {
|
|
15
17
|
this.pendingCodes.delete(code);
|
|
16
18
|
}
|
|
19
|
+
async getDeviceRegistry(deviceId) {
|
|
20
|
+
return this.deviceRegistry.get(deviceId);
|
|
21
|
+
}
|
|
22
|
+
async setDeviceRegistry(deviceId, record) {
|
|
23
|
+
const existing = this.deviceRegistry.get(deviceId);
|
|
24
|
+
if (existing?.permanentPairingCode) {
|
|
25
|
+
this.codeToDeviceId.delete(existing.permanentPairingCode);
|
|
26
|
+
}
|
|
27
|
+
this.deviceRegistry.set(deviceId, record);
|
|
28
|
+
this.codeToDeviceId.set(record.permanentPairingCode, deviceId);
|
|
29
|
+
}
|
|
30
|
+
async getDeviceRegistryByCode(code) {
|
|
31
|
+
const deviceId = this.codeToDeviceId.get(code);
|
|
32
|
+
if (!deviceId)
|
|
33
|
+
return undefined;
|
|
34
|
+
const record = this.deviceRegistry.get(deviceId);
|
|
35
|
+
if (!record)
|
|
36
|
+
return undefined;
|
|
37
|
+
return { deviceId, record };
|
|
38
|
+
}
|
|
17
39
|
async setPairedDevice(deviceId, record) {
|
|
18
40
|
this.pairedDevices.set(deviceId, record);
|
|
19
41
|
}
|
package/dist/store/types.d.ts
CHANGED
|
@@ -6,6 +6,14 @@ export interface PendingCodeRecord {
|
|
|
6
6
|
deviceId: string;
|
|
7
7
|
createdAt: number;
|
|
8
8
|
}
|
|
9
|
+
/** Permanent pairing code bound to a device (serial) on first hello — never rotated. */
|
|
10
|
+
export interface DeviceRegistryRecord {
|
|
11
|
+
permanentPairingCode: string;
|
|
12
|
+
codeCreatedAt: number;
|
|
13
|
+
serialNumber?: string;
|
|
14
|
+
firstSeenAt?: number;
|
|
15
|
+
lastHelloAt?: number;
|
|
16
|
+
}
|
|
9
17
|
export interface PairedDeviceRecord {
|
|
10
18
|
pairingToken: string;
|
|
11
19
|
pairedAt: string;
|
|
@@ -29,6 +37,12 @@ export interface TomorrowOSStore {
|
|
|
29
37
|
setPendingCode(code: string, record: PendingCodeRecord): Promise<void>;
|
|
30
38
|
getPendingCode(code: string): Promise<PendingCodeRecord | undefined>;
|
|
31
39
|
deletePendingCode(code: string): Promise<void>;
|
|
40
|
+
getDeviceRegistry(deviceId: string): Promise<DeviceRegistryRecord | undefined>;
|
|
41
|
+
setDeviceRegistry(deviceId: string, record: DeviceRegistryRecord): Promise<void>;
|
|
42
|
+
getDeviceRegistryByCode(code: string): Promise<{
|
|
43
|
+
deviceId: string;
|
|
44
|
+
record: DeviceRegistryRecord;
|
|
45
|
+
} | undefined>;
|
|
32
46
|
setPairedDevice(deviceId: string, record: PairedDeviceRecord): Promise<void>;
|
|
33
47
|
getPairedDevice(deviceId: string): Promise<PairedDeviceRecord | undefined>;
|
|
34
48
|
deletePairedDevice(deviceId: string): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/store/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,kBAAkB,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IACrE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;IAC3E,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;CACnD"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/store/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wFAAwF;AACxF,MAAM,WAAW,oBAAoB;IACnC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,kBAAkB,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAAC;IACrE,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAAC;IAC/E,iBAAiB,CACf,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAAC;IACjB,uBAAuB,CACrB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,oBAAoB,CAAA;KAAE,GAAG,SAAS,CAAC,CAAC;IAC3E,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;IAC3E,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;CACnD"}
|
package/dist/tomorrowos.d.ts
CHANGED
|
@@ -29,10 +29,13 @@ export interface ListenOptions {
|
|
|
29
29
|
}
|
|
30
30
|
export interface DeviceListItem {
|
|
31
31
|
deviceId: string;
|
|
32
|
+
/** Permanent 6-character alphanumeric pairing code (same after unpair). */
|
|
33
|
+
pairingCode: string | null;
|
|
32
34
|
connected: boolean;
|
|
33
35
|
deviceName: string | null;
|
|
34
36
|
platform: string | null;
|
|
35
37
|
system: string | null;
|
|
38
|
+
serialNumber: string | null;
|
|
36
39
|
pairedAt: string;
|
|
37
40
|
lastBootAt: string | null;
|
|
38
41
|
lastOnlineAt: string | null;
|
|
@@ -53,7 +56,7 @@ export declare class TomorrowOS extends EventEmitter {
|
|
|
53
56
|
private staticRoot;
|
|
54
57
|
private staticIndexFile;
|
|
55
58
|
constructor(options: TomorrowOSOptions);
|
|
56
|
-
/** Verify a 6-
|
|
59
|
+
/** Verify a 6-character alphanumeric pairing code (POST /pairing/verify). */
|
|
57
60
|
pairingVerify(code: string): Promise<{
|
|
58
61
|
deviceId: string;
|
|
59
62
|
}>;
|
|
@@ -75,6 +78,7 @@ export declare class TomorrowOS extends EventEmitter {
|
|
|
75
78
|
listDevices(): Promise<DeviceListItem[]>;
|
|
76
79
|
private isDeviceConnected;
|
|
77
80
|
private captureHelloMeta;
|
|
81
|
+
private getOrCreatePermanentPairingCode;
|
|
78
82
|
private mergePairedRecord;
|
|
79
83
|
private touchPairedOnline;
|
|
80
84
|
private touchPairedOffline;
|
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;
|
|
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,KAAK,EAGV,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAG1B,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;AAuBD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,kBAAkB,EAAE,OAAO,CAAC;IAC5B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAsFD,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,QAAQ,CAAC,iBAAiB,CAAsC;IACxE,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,eAAe,CAAgB;gBAE3B,OAAO,EAAE,iBAAiB;IAMtC,6EAA6E;IACvE,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAyDhE,6EAA6E;IACvE,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC;IA4BvF,OAAO;uBACU,MAAM;sBAvFgC,MAAM;;2BAwFxC,MAAM;sBA9BgC,MAAM;sBAAY,OAAO;;MA+BlF;IAEF,kFAAkF;IAC5E,WAAW,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IA2C9C,OAAO,CAAC,iBAAiB;IAKzB,OAAO,CAAC,gBAAgB;YAaV,+BAA+B;YAiC/B,iBAAiB;YASjB,iBAAiB;YA6BjB,kBAAkB;IAUhC,uFAAuF;IACvF,OAAO,CAAC,kBAAkB;YAmBZ,gBAAgB;YAMhB,uBAAuB;IAoCrC,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;YAwEV,gBAAgB;IA2E9B,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,gBAAgB;CAoGzB"}
|
package/dist/tomorrowos.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from "fs/promises";
|
|
|
4
4
|
import http from "http";
|
|
5
5
|
import path from "path";
|
|
6
6
|
import { WebSocket, WebSocketServer } from "ws";
|
|
7
|
+
import { generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode } from "./pairing-code.js";
|
|
7
8
|
import { MemoryStore } from "./store/memory-store.js";
|
|
8
9
|
function formatDurationMs(ms) {
|
|
9
10
|
if (!Number.isFinite(ms) || ms < 0)
|
|
@@ -23,8 +24,16 @@ function formatDurationMs(ms) {
|
|
|
23
24
|
parts.push(`${seconds}s`);
|
|
24
25
|
return parts.join(" ");
|
|
25
26
|
}
|
|
26
|
-
function
|
|
27
|
-
|
|
27
|
+
function resolveHelloDeviceId(msg) {
|
|
28
|
+
const serial = typeof msg.serialNumber === "string" && msg.serialNumber.trim()
|
|
29
|
+
? msg.serialNumber.trim()
|
|
30
|
+
: null;
|
|
31
|
+
if (serial)
|
|
32
|
+
return serial;
|
|
33
|
+
const deviceId = typeof msg.deviceId === "string" && msg.deviceId.trim()
|
|
34
|
+
? msg.deviceId.trim()
|
|
35
|
+
: null;
|
|
36
|
+
return deviceId;
|
|
28
37
|
}
|
|
29
38
|
const MAX_MEDIA_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
30
39
|
async function readJsonBody(req) {
|
|
@@ -82,41 +91,55 @@ export class TomorrowOS extends EventEmitter {
|
|
|
82
91
|
this.brand = options.brand;
|
|
83
92
|
this.store = options.store ?? new MemoryStore();
|
|
84
93
|
}
|
|
85
|
-
/** Verify a 6-
|
|
94
|
+
/** Verify a 6-character alphanumeric pairing code (POST /pairing/verify). */
|
|
86
95
|
async pairingVerify(code) {
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
const err = new Error("Invalid
|
|
96
|
+
const normalized = normalizePairingCode(code);
|
|
97
|
+
if (!isValidPairingCodeFormat(normalized)) {
|
|
98
|
+
const err = new Error("Invalid pairing code format");
|
|
99
|
+
err.code = "PAIRING_INVALID";
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
let deviceId;
|
|
103
|
+
const registry = await this.store.getDeviceRegistryByCode(normalized);
|
|
104
|
+
if (registry) {
|
|
105
|
+
deviceId = registry.deviceId;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const pending = await this.store.getPendingCode(normalized);
|
|
109
|
+
deviceId = pending?.deviceId;
|
|
110
|
+
}
|
|
111
|
+
if (!deviceId) {
|
|
112
|
+
const err = new Error("Invalid or unknown pairing code");
|
|
90
113
|
err.code = "PAIRING_INVALID";
|
|
91
114
|
throw err;
|
|
92
115
|
}
|
|
93
116
|
const pairingToken = randomBytes(32).toString("hex");
|
|
94
117
|
const pairedAt = new Date().toISOString();
|
|
95
|
-
const meta = this.pendingDeviceMeta.get(
|
|
118
|
+
const meta = this.pendingDeviceMeta.get(deviceId);
|
|
96
119
|
const now = pairedAt;
|
|
97
|
-
await this.store.setPairedDevice(
|
|
120
|
+
await this.store.setPairedDevice(deviceId, {
|
|
98
121
|
pairingToken,
|
|
99
122
|
pairedAt,
|
|
100
123
|
deviceName: meta?.deviceName,
|
|
101
124
|
platform: meta?.platform,
|
|
102
125
|
system: meta?.system,
|
|
103
126
|
lastBootAt: meta?.bootedAt ?? now,
|
|
104
|
-
lastOnlineAt: this.isDeviceConnected(
|
|
105
|
-
lastOfflineAt: this.isDeviceConnected(
|
|
127
|
+
lastOnlineAt: this.isDeviceConnected(deviceId) ? now : undefined,
|
|
128
|
+
lastOfflineAt: this.isDeviceConnected(deviceId) ? undefined : now
|
|
106
129
|
});
|
|
107
|
-
await this.store.deletePendingCode(
|
|
108
|
-
const ws = this.devices.get(
|
|
130
|
+
await this.store.deletePendingCode(normalized);
|
|
131
|
+
const ws = this.devices.get(deviceId);
|
|
109
132
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
110
133
|
ws.send(JSON.stringify({
|
|
111
134
|
type: "pairing.verified",
|
|
112
135
|
method: "tomorrowos.pairing.verify",
|
|
113
|
-
deviceId
|
|
136
|
+
deviceId,
|
|
114
137
|
pairingToken
|
|
115
138
|
}));
|
|
116
|
-
void this.refreshPairedDeviceInfo(
|
|
139
|
+
void this.refreshPairedDeviceInfo(deviceId);
|
|
117
140
|
}
|
|
118
|
-
this.emit("device.paired", { deviceId
|
|
119
|
-
return { deviceId
|
|
141
|
+
this.emit("device.paired", { deviceId });
|
|
142
|
+
return { deviceId };
|
|
120
143
|
}
|
|
121
144
|
/** Remove pairing for a device and notify it over WebSocket if connected. */
|
|
122
145
|
async pairingUnpair(deviceId) {
|
|
@@ -149,8 +172,9 @@ export class TomorrowOS extends EventEmitter {
|
|
|
149
172
|
async listDevices() {
|
|
150
173
|
const entries = await this.store.listPairedDevices();
|
|
151
174
|
const now = Date.now();
|
|
152
|
-
return entries.map(({ deviceId, record }) => {
|
|
175
|
+
return Promise.all(entries.map(async ({ deviceId, record }) => {
|
|
153
176
|
const connected = this.isDeviceConnected(deviceId);
|
|
177
|
+
const reg = await this.store.getDeviceRegistry(deviceId);
|
|
154
178
|
let screenOnlineActive = false;
|
|
155
179
|
let screenOnlineLabel = "Not active";
|
|
156
180
|
if (connected && record.lastBootAt) {
|
|
@@ -163,10 +187,12 @@ export class TomorrowOS extends EventEmitter {
|
|
|
163
187
|
const screenOnlineSince = connected && record.lastBootAt ? record.lastBootAt : null;
|
|
164
188
|
return {
|
|
165
189
|
deviceId,
|
|
190
|
+
pairingCode: reg?.permanentPairingCode ?? null,
|
|
166
191
|
connected,
|
|
167
192
|
deviceName: record.deviceName ?? null,
|
|
168
193
|
platform: record.platform ?? null,
|
|
169
194
|
system: record.system ?? null,
|
|
195
|
+
serialNumber: reg?.serialNumber ?? deviceId,
|
|
170
196
|
pairedAt: record.pairedAt,
|
|
171
197
|
lastBootAt: record.lastBootAt ?? null,
|
|
172
198
|
lastOnlineAt: record.lastOnlineAt ?? null,
|
|
@@ -176,7 +202,7 @@ export class TomorrowOS extends EventEmitter {
|
|
|
176
202
|
screenOnlineLabel,
|
|
177
203
|
screenOnlineSince
|
|
178
204
|
};
|
|
179
|
-
});
|
|
205
|
+
}));
|
|
180
206
|
}
|
|
181
207
|
isDeviceConnected(deviceId) {
|
|
182
208
|
const ws = this.devices.get(deviceId);
|
|
@@ -188,9 +214,37 @@ export class TomorrowOS extends EventEmitter {
|
|
|
188
214
|
deviceName: typeof msg.deviceName === "string" ? msg.deviceName : undefined,
|
|
189
215
|
system: typeof msg.system === "string" ? msg.system : undefined,
|
|
190
216
|
bootedAt: typeof msg.bootedAt === "string" ? msg.bootedAt : undefined,
|
|
191
|
-
playerVersion: typeof msg.playerVersion === "string" ? msg.playerVersion : undefined
|
|
217
|
+
playerVersion: typeof msg.playerVersion === "string" ? msg.playerVersion : undefined,
|
|
218
|
+
serialNumber: typeof msg.serialNumber === "string" ? msg.serialNumber : deviceId
|
|
192
219
|
});
|
|
193
220
|
}
|
|
221
|
+
async getOrCreatePermanentPairingCode(deviceId, serialNumber) {
|
|
222
|
+
const existing = await this.store.getDeviceRegistry(deviceId);
|
|
223
|
+
if (existing?.permanentPairingCode) {
|
|
224
|
+
await this.store.setDeviceRegistry(deviceId, {
|
|
225
|
+
...existing,
|
|
226
|
+
serialNumber: serialNumber ?? existing.serialNumber ?? deviceId,
|
|
227
|
+
lastHelloAt: Date.now()
|
|
228
|
+
});
|
|
229
|
+
return existing.permanentPairingCode;
|
|
230
|
+
}
|
|
231
|
+
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
232
|
+
const code = generateRandomPairingCode();
|
|
233
|
+
const collision = await this.store.getDeviceRegistryByCode(code);
|
|
234
|
+
if (collision && collision.deviceId !== deviceId)
|
|
235
|
+
continue;
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
await this.store.setDeviceRegistry(deviceId, {
|
|
238
|
+
permanentPairingCode: code,
|
|
239
|
+
codeCreatedAt: now,
|
|
240
|
+
serialNumber: serialNumber ?? deviceId,
|
|
241
|
+
firstSeenAt: now,
|
|
242
|
+
lastHelloAt: now
|
|
243
|
+
});
|
|
244
|
+
return code;
|
|
245
|
+
}
|
|
246
|
+
throw new Error("Failed to allocate unique pairing code");
|
|
247
|
+
}
|
|
194
248
|
async mergePairedRecord(deviceId, patch) {
|
|
195
249
|
const existing = await this.store.getPairedDevice(deviceId);
|
|
196
250
|
if (!existing)
|
|
@@ -593,25 +647,34 @@ export class TomorrowOS extends EventEmitter {
|
|
|
593
647
|
}
|
|
594
648
|
const type = msg.type;
|
|
595
649
|
if (type === "device.hello") {
|
|
596
|
-
const deviceId =
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
650
|
+
const deviceId = resolveHelloDeviceId(msg) ?? randomUUID();
|
|
651
|
+
const serialNumber = typeof msg.serialNumber === "string" && msg.serialNumber.trim()
|
|
652
|
+
? msg.serialNumber.trim()
|
|
653
|
+
: deviceId;
|
|
654
|
+
void (async () => {
|
|
655
|
+
try {
|
|
656
|
+
const code = await this.getOrCreatePermanentPairingCode(deviceId, serialNumber);
|
|
657
|
+
this.captureHelloMeta(deviceId, msg);
|
|
658
|
+
this.devices.set(deviceId, ws);
|
|
659
|
+
void this.store.setPendingCode(code, {
|
|
660
|
+
deviceId,
|
|
661
|
+
createdAt: Date.now()
|
|
662
|
+
});
|
|
663
|
+
ws.deviceId = deviceId;
|
|
664
|
+
ws.send(JSON.stringify({
|
|
665
|
+
type: "pairing.code",
|
|
666
|
+
method: "tomorrowos.pairing.createCode",
|
|
667
|
+
code,
|
|
668
|
+
deviceId,
|
|
669
|
+
serialNumber
|
|
670
|
+
}));
|
|
671
|
+
this.sendBrandSnapshot(ws);
|
|
672
|
+
this.emit("device.online", { deviceId });
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
console.error("[TomorrowOS] device.hello failed:", err);
|
|
676
|
+
}
|
|
677
|
+
})();
|
|
615
678
|
return;
|
|
616
679
|
}
|
|
617
680
|
if (type === "device.resume") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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.2.
|
|
13
|
+
"@tomorrowos/sdk": "^0.2.4"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^20.0.0",
|
|
@@ -1,30 +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
|
+
{
|
|
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
|
+
}
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
<main class="panel-main">
|
|
17
17
|
<section class="card">
|
|
18
18
|
<h2>Pair device</h2>
|
|
19
|
-
<p class="hint">Enter the 6-
|
|
19
|
+
<p class="hint">Enter the 6-character code on the screen (A–Z or 0–9). Each device keeps the same code after unpair.</p>
|
|
20
20
|
<div class="row">
|
|
21
|
-
<input id="code" maxlength="6" placeholder="
|
|
21
|
+
<input id="code" maxlength="6" placeholder="e.g. A3K9Z1" autocapitalize="characters" />
|
|
22
22
|
<button type="button" onclick="verify()">Verify</button>
|
|
23
23
|
</div>
|
|
24
24
|
</section>
|
|
@@ -9,7 +9,6 @@ let playlistItems = [];
|
|
|
9
9
|
let devicesCache = [];
|
|
10
10
|
|
|
11
11
|
let devicePollTimer = null;
|
|
12
|
-
let deviceUptimeTimer = null;
|
|
13
12
|
|
|
14
13
|
function escapeHtml(value) {
|
|
15
14
|
return String(value ?? "")
|
|
@@ -58,16 +57,6 @@ function formatDeviceOnlineLabel(device) {
|
|
|
58
57
|
return formatDurationMs(Date.now() - bootMs);
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
function updateDeviceUptimeLabels() {
|
|
62
|
-
document.querySelectorAll(".device-card[data-device-id]").forEach((card) => {
|
|
63
|
-
const deviceId = card.dataset.deviceId;
|
|
64
|
-
const device = devicesCache.find((d) => d.deviceId === deviceId);
|
|
65
|
-
if (!device) return;
|
|
66
|
-
const el = card.querySelector(".device-online-time");
|
|
67
|
-
if (el) el.textContent = formatDeviceOnlineLabel(device);
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
60
|
async function fetchDevices() {
|
|
72
61
|
try {
|
|
73
62
|
const res = await fetch("/devices");
|
|
@@ -136,8 +125,7 @@ function renderDeviceCards() {
|
|
|
136
125
|
const dd = document.createElement("dd");
|
|
137
126
|
if (label === "Device online") {
|
|
138
127
|
dd.className = "device-online-time";
|
|
139
|
-
dd.title =
|
|
140
|
-
"Current time minus this TV last boot time (refreshes every 30 seconds)";
|
|
128
|
+
dd.title = "Current time minus this TV last boot time";
|
|
141
129
|
}
|
|
142
130
|
dd.textContent = value;
|
|
143
131
|
row.appendChild(dt);
|
|
@@ -198,14 +186,11 @@ function renderDeviceCards() {
|
|
|
198
186
|
|
|
199
187
|
function startDevicePolling() {
|
|
200
188
|
if (devicePollTimer) clearInterval(devicePollTimer);
|
|
201
|
-
if (deviceUptimeTimer) clearInterval(deviceUptimeTimer);
|
|
202
189
|
|
|
203
190
|
void fetchDevices();
|
|
204
191
|
devicePollTimer = setInterval(() => {
|
|
205
192
|
void fetchDevices();
|
|
206
193
|
}, 8000);
|
|
207
|
-
|
|
208
|
-
deviceUptimeTimer = setInterval(updateDeviceUptimeLabels, 30_000);
|
|
209
194
|
}
|
|
210
195
|
|
|
211
196
|
function showResult(data) {
|
|
@@ -501,7 +486,15 @@ function buildPolicyPayload() {
|
|
|
501
486
|
}
|
|
502
487
|
|
|
503
488
|
async function verify() {
|
|
504
|
-
const code = document.getElementById("code").value
|
|
489
|
+
const code = String(document.getElementById("code").value || "")
|
|
490
|
+
.trim()
|
|
491
|
+
.toUpperCase()
|
|
492
|
+
.replace(/[^0-9A-Z]/g, "");
|
|
493
|
+
|
|
494
|
+
if (code.length !== 6) {
|
|
495
|
+
showResult({ status: "failed", error: "Enter the 6-character code from the screen." });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
505
498
|
|
|
506
499
|
const res = await fetch("/pairing/verify", {
|
|
507
500
|
method: "POST",
|
|
@@ -522,7 +515,7 @@ async function verify() {
|
|
|
522
515
|
async function unpairDevice(deviceId) {
|
|
523
516
|
if (
|
|
524
517
|
!confirm(
|
|
525
|
-
"Unpair this device? Its card will be removed
|
|
518
|
+
"Unpair this device? Its card will be removed; the screen keeps the same permanent pairing code."
|
|
526
519
|
)
|
|
527
520
|
) {
|
|
528
521
|
return;
|
|
@@ -1,329 +1,329 @@
|
|
|
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
|
-
.hint {
|
|
66
|
-
margin: 0 0 0.75rem;
|
|
67
|
-
font-size: 0.8rem;
|
|
68
|
-
color: #666;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
.devices-grid {
|
|
72
|
-
display: grid;
|
|
73
|
-
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
74
|
-
gap: 0.85rem;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
.devices-empty {
|
|
78
|
-
margin: 0;
|
|
79
|
-
color: #666;
|
|
80
|
-
font-size: 0.875rem;
|
|
81
|
-
grid-column: 1 / -1;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.device-card {
|
|
85
|
-
border: 1px solid #e4e1dc;
|
|
86
|
-
border-radius: 8px;
|
|
87
|
-
padding: 0.85rem 0.95rem;
|
|
88
|
-
background: #fafaf9;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
.device-card-header {
|
|
92
|
-
display: flex;
|
|
93
|
-
align-items: center;
|
|
94
|
-
gap: 0.5rem;
|
|
95
|
-
margin-bottom: 0.65rem;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
.device-card-title {
|
|
99
|
-
margin: 0;
|
|
100
|
-
font-size: 0.95rem;
|
|
101
|
-
font-weight: 600;
|
|
102
|
-
line-height: 1.2;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
.status-led {
|
|
106
|
-
width: 11px;
|
|
107
|
-
height: 11px;
|
|
108
|
-
border-radius: 50%;
|
|
109
|
-
flex-shrink: 0;
|
|
110
|
-
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
.status-led--online {
|
|
114
|
-
background: #12b76a;
|
|
115
|
-
box-shadow: 0 0 8px rgba(18, 183, 106, 0.55);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
.status-led--offline {
|
|
119
|
-
background: #f04438;
|
|
120
|
-
box-shadow: 0 0 8px rgba(240, 68, 56, 0.45);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
.device-meta {
|
|
124
|
-
display: grid;
|
|
125
|
-
gap: 0.35rem;
|
|
126
|
-
font-size: 0.78rem;
|
|
127
|
-
color: #444;
|
|
128
|
-
margin-bottom: 0.75rem;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
.device-meta dt {
|
|
132
|
-
font-weight: 600;
|
|
133
|
-
color: #666;
|
|
134
|
-
display: inline;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
.device-meta dt::after {
|
|
138
|
-
content: ": ";
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
.device-meta dd {
|
|
142
|
-
display: inline;
|
|
143
|
-
margin: 0;
|
|
144
|
-
word-break: break-all;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
.device-meta-row {
|
|
148
|
-
display: block;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
.device-card-actions {
|
|
152
|
-
display: flex;
|
|
153
|
-
flex-wrap: wrap;
|
|
154
|
-
gap: 0.4rem;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
.device-card-actions button {
|
|
158
|
-
font-size: 0.78rem;
|
|
159
|
-
padding: 0.35rem 0.6rem;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
.row {
|
|
163
|
-
display: flex;
|
|
164
|
-
flex-wrap: wrap;
|
|
165
|
-
gap: 0.5rem;
|
|
166
|
-
align-items: center;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
input[type="text"],
|
|
170
|
-
input[type="date"],
|
|
171
|
-
input[type="time"],
|
|
172
|
-
select {
|
|
173
|
-
font: inherit;
|
|
174
|
-
padding: 0.45rem 0.6rem;
|
|
175
|
-
border: 1px solid #d6d2cb;
|
|
176
|
-
border-radius: 6px;
|
|
177
|
-
background: #fff;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
button {
|
|
181
|
-
font: inherit;
|
|
182
|
-
padding: 0.45rem 0.85rem;
|
|
183
|
-
border-radius: 6px;
|
|
184
|
-
border: 1px solid #d6d2cb;
|
|
185
|
-
background: #fff;
|
|
186
|
-
cursor: pointer;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
button:hover {
|
|
190
|
-
background: #f5f3ef;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
button.primary {
|
|
194
|
-
background: #ff8a3d;
|
|
195
|
-
border-color: #e67320;
|
|
196
|
-
color: #0a0908;
|
|
197
|
-
font-weight: 600;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
button.primary:hover {
|
|
201
|
-
background: #ff9a57;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
button.danger {
|
|
205
|
-
color: #b42318;
|
|
206
|
-
border-color: #fecdca;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
.schedule-grid {
|
|
210
|
-
display: grid;
|
|
211
|
-
grid-template-columns: 1fr 1fr;
|
|
212
|
-
gap: 0.65rem 1rem;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
.schedule-grid label {
|
|
216
|
-
display: flex;
|
|
217
|
-
flex-direction: column;
|
|
218
|
-
gap: 0.25rem;
|
|
219
|
-
font-size: 0.8rem;
|
|
220
|
-
color: #444;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
.days-row {
|
|
224
|
-
grid-column: 1 / -1;
|
|
225
|
-
display: flex;
|
|
226
|
-
flex-wrap: wrap;
|
|
227
|
-
gap: 0.5rem 0.75rem;
|
|
228
|
-
font-size: 0.8rem;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
.days-row label {
|
|
232
|
-
flex-direction: row;
|
|
233
|
-
align-items: center;
|
|
234
|
-
gap: 0.35rem;
|
|
235
|
-
color: #0a0908;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
.playlist-header {
|
|
239
|
-
display: flex;
|
|
240
|
-
align-items: center;
|
|
241
|
-
justify-content: space-between;
|
|
242
|
-
padding: 1rem 1rem 0.75rem;
|
|
243
|
-
border-bottom: 1px solid #e4e1dc;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
.playlist-header h2 {
|
|
247
|
-
margin: 0;
|
|
248
|
-
font-size: 0.95rem;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
.playlist-list {
|
|
252
|
-
list-style: none;
|
|
253
|
-
margin: 0;
|
|
254
|
-
padding: 0.5rem;
|
|
255
|
-
flex: 1;
|
|
256
|
-
overflow-y: auto;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
.playlist-empty {
|
|
260
|
-
padding: 1.5rem 1rem;
|
|
261
|
-
text-align: center;
|
|
262
|
-
color: #888;
|
|
263
|
-
font-size: 0.875rem;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
.playlist-item {
|
|
267
|
-
border: 1px solid #e4e1dc;
|
|
268
|
-
border-radius: 8px;
|
|
269
|
-
padding: 0.65rem;
|
|
270
|
-
margin-bottom: 0.5rem;
|
|
271
|
-
background: #fafaf9;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
.playlist-item-thumb {
|
|
275
|
-
width: 100%;
|
|
276
|
-
aspect-ratio: 16 / 9;
|
|
277
|
-
object-fit: cover;
|
|
278
|
-
border-radius: 4px;
|
|
279
|
-
background: #e4e1dc;
|
|
280
|
-
display: block;
|
|
281
|
-
margin-bottom: 0.5rem;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
.playlist-item-name {
|
|
285
|
-
font-size: 0.8rem;
|
|
286
|
-
font-weight: 600;
|
|
287
|
-
word-break: break-all;
|
|
288
|
-
margin-bottom: 0.35rem;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
.playlist-item-meta {
|
|
292
|
-
font-size: 0.75rem;
|
|
293
|
-
color: #666;
|
|
294
|
-
margin-bottom: 0.45rem;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
.playlist-item-actions {
|
|
298
|
-
display: flex;
|
|
299
|
-
gap: 0.35rem;
|
|
300
|
-
align-items: center;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
.playlist-item-actions input[type="number"] {
|
|
304
|
-
width: 5rem;
|
|
305
|
-
font: inherit;
|
|
306
|
-
padding: 0.25rem 0.4rem;
|
|
307
|
-
border: 1px solid #d6d2cb;
|
|
308
|
-
border-radius: 4px;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
.publish-bar {
|
|
312
|
-
padding: 1rem 0 0;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
#result {
|
|
316
|
-
margin: 0;
|
|
317
|
-
padding: 0.75rem;
|
|
318
|
-
background: #0a0908;
|
|
319
|
-
color: #e8e6e3;
|
|
320
|
-
border-radius: 8px;
|
|
321
|
-
font-size: 0.75rem;
|
|
322
|
-
max-height: 10rem;
|
|
323
|
-
overflow: auto;
|
|
324
|
-
white-space: pre-wrap;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
.hidden {
|
|
328
|
-
display: none !important;
|
|
329
|
-
}
|
|
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
|
+
.hint {
|
|
66
|
+
margin: 0 0 0.75rem;
|
|
67
|
+
font-size: 0.8rem;
|
|
68
|
+
color: #666;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.devices-grid {
|
|
72
|
+
display: grid;
|
|
73
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
74
|
+
gap: 0.85rem;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.devices-empty {
|
|
78
|
+
margin: 0;
|
|
79
|
+
color: #666;
|
|
80
|
+
font-size: 0.875rem;
|
|
81
|
+
grid-column: 1 / -1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.device-card {
|
|
85
|
+
border: 1px solid #e4e1dc;
|
|
86
|
+
border-radius: 8px;
|
|
87
|
+
padding: 0.85rem 0.95rem;
|
|
88
|
+
background: #fafaf9;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.device-card-header {
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: 0.5rem;
|
|
95
|
+
margin-bottom: 0.65rem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.device-card-title {
|
|
99
|
+
margin: 0;
|
|
100
|
+
font-size: 0.95rem;
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
line-height: 1.2;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.status-led {
|
|
106
|
+
width: 11px;
|
|
107
|
+
height: 11px;
|
|
108
|
+
border-radius: 50%;
|
|
109
|
+
flex-shrink: 0;
|
|
110
|
+
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.status-led--online {
|
|
114
|
+
background: #12b76a;
|
|
115
|
+
box-shadow: 0 0 8px rgba(18, 183, 106, 0.55);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.status-led--offline {
|
|
119
|
+
background: #f04438;
|
|
120
|
+
box-shadow: 0 0 8px rgba(240, 68, 56, 0.45);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.device-meta {
|
|
124
|
+
display: grid;
|
|
125
|
+
gap: 0.35rem;
|
|
126
|
+
font-size: 0.78rem;
|
|
127
|
+
color: #444;
|
|
128
|
+
margin-bottom: 0.75rem;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.device-meta dt {
|
|
132
|
+
font-weight: 600;
|
|
133
|
+
color: #666;
|
|
134
|
+
display: inline;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.device-meta dt::after {
|
|
138
|
+
content: ": ";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.device-meta dd {
|
|
142
|
+
display: inline;
|
|
143
|
+
margin: 0;
|
|
144
|
+
word-break: break-all;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.device-meta-row {
|
|
148
|
+
display: block;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.device-card-actions {
|
|
152
|
+
display: flex;
|
|
153
|
+
flex-wrap: wrap;
|
|
154
|
+
gap: 0.4rem;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.device-card-actions button {
|
|
158
|
+
font-size: 0.78rem;
|
|
159
|
+
padding: 0.35rem 0.6rem;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.row {
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-wrap: wrap;
|
|
165
|
+
gap: 0.5rem;
|
|
166
|
+
align-items: center;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
input[type="text"],
|
|
170
|
+
input[type="date"],
|
|
171
|
+
input[type="time"],
|
|
172
|
+
select {
|
|
173
|
+
font: inherit;
|
|
174
|
+
padding: 0.45rem 0.6rem;
|
|
175
|
+
border: 1px solid #d6d2cb;
|
|
176
|
+
border-radius: 6px;
|
|
177
|
+
background: #fff;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
button {
|
|
181
|
+
font: inherit;
|
|
182
|
+
padding: 0.45rem 0.85rem;
|
|
183
|
+
border-radius: 6px;
|
|
184
|
+
border: 1px solid #d6d2cb;
|
|
185
|
+
background: #fff;
|
|
186
|
+
cursor: pointer;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
button:hover {
|
|
190
|
+
background: #f5f3ef;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
button.primary {
|
|
194
|
+
background: #ff8a3d;
|
|
195
|
+
border-color: #e67320;
|
|
196
|
+
color: #0a0908;
|
|
197
|
+
font-weight: 600;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
button.primary:hover {
|
|
201
|
+
background: #ff9a57;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
button.danger {
|
|
205
|
+
color: #b42318;
|
|
206
|
+
border-color: #fecdca;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.schedule-grid {
|
|
210
|
+
display: grid;
|
|
211
|
+
grid-template-columns: 1fr 1fr;
|
|
212
|
+
gap: 0.65rem 1rem;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.schedule-grid label {
|
|
216
|
+
display: flex;
|
|
217
|
+
flex-direction: column;
|
|
218
|
+
gap: 0.25rem;
|
|
219
|
+
font-size: 0.8rem;
|
|
220
|
+
color: #444;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.days-row {
|
|
224
|
+
grid-column: 1 / -1;
|
|
225
|
+
display: flex;
|
|
226
|
+
flex-wrap: wrap;
|
|
227
|
+
gap: 0.5rem 0.75rem;
|
|
228
|
+
font-size: 0.8rem;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.days-row label {
|
|
232
|
+
flex-direction: row;
|
|
233
|
+
align-items: center;
|
|
234
|
+
gap: 0.35rem;
|
|
235
|
+
color: #0a0908;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.playlist-header {
|
|
239
|
+
display: flex;
|
|
240
|
+
align-items: center;
|
|
241
|
+
justify-content: space-between;
|
|
242
|
+
padding: 1rem 1rem 0.75rem;
|
|
243
|
+
border-bottom: 1px solid #e4e1dc;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.playlist-header h2 {
|
|
247
|
+
margin: 0;
|
|
248
|
+
font-size: 0.95rem;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.playlist-list {
|
|
252
|
+
list-style: none;
|
|
253
|
+
margin: 0;
|
|
254
|
+
padding: 0.5rem;
|
|
255
|
+
flex: 1;
|
|
256
|
+
overflow-y: auto;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.playlist-empty {
|
|
260
|
+
padding: 1.5rem 1rem;
|
|
261
|
+
text-align: center;
|
|
262
|
+
color: #888;
|
|
263
|
+
font-size: 0.875rem;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.playlist-item {
|
|
267
|
+
border: 1px solid #e4e1dc;
|
|
268
|
+
border-radius: 8px;
|
|
269
|
+
padding: 0.65rem;
|
|
270
|
+
margin-bottom: 0.5rem;
|
|
271
|
+
background: #fafaf9;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.playlist-item-thumb {
|
|
275
|
+
width: 100%;
|
|
276
|
+
aspect-ratio: 16 / 9;
|
|
277
|
+
object-fit: cover;
|
|
278
|
+
border-radius: 4px;
|
|
279
|
+
background: #e4e1dc;
|
|
280
|
+
display: block;
|
|
281
|
+
margin-bottom: 0.5rem;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.playlist-item-name {
|
|
285
|
+
font-size: 0.8rem;
|
|
286
|
+
font-weight: 600;
|
|
287
|
+
word-break: break-all;
|
|
288
|
+
margin-bottom: 0.35rem;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.playlist-item-meta {
|
|
292
|
+
font-size: 0.75rem;
|
|
293
|
+
color: #666;
|
|
294
|
+
margin-bottom: 0.45rem;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.playlist-item-actions {
|
|
298
|
+
display: flex;
|
|
299
|
+
gap: 0.35rem;
|
|
300
|
+
align-items: center;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.playlist-item-actions input[type="number"] {
|
|
304
|
+
width: 5rem;
|
|
305
|
+
font: inherit;
|
|
306
|
+
padding: 0.25rem 0.4rem;
|
|
307
|
+
border: 1px solid #d6d2cb;
|
|
308
|
+
border-radius: 4px;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.publish-bar {
|
|
312
|
+
padding: 1rem 0 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#result {
|
|
316
|
+
margin: 0;
|
|
317
|
+
padding: 0.75rem;
|
|
318
|
+
background: #0a0908;
|
|
319
|
+
color: #e8e6e3;
|
|
320
|
+
border-radius: 8px;
|
|
321
|
+
font-size: 0.75rem;
|
|
322
|
+
max-height: 10rem;
|
|
323
|
+
overflow: auto;
|
|
324
|
+
white-space: pre-wrap;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.hidden {
|
|
328
|
+
display: none !important;
|
|
329
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|