mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-13 01:32:41 -06:00
Compare commits
310 commits
pre-phase2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f794ff7b4 | ||
|
|
6add29bf6a | ||
|
|
5bcaca7c51 | ||
|
|
8d03741713 | ||
|
|
f06a87f4be | ||
|
|
3305e79e61 | ||
|
|
538f4a7b03 | ||
|
|
33eaef826c | ||
|
|
2ad9f54b8e | ||
|
|
c1b9c27f3a | ||
|
|
dce0d22763 | ||
|
|
fab4ae909a | ||
|
|
73ca3cf258 | ||
|
|
300d331562 | ||
|
|
11e339dd89 | ||
|
|
bd732f4c48 | ||
|
|
68367cb3a3 | ||
|
|
4b688fcfb1 | ||
|
|
ba8a71c4f5 | ||
|
|
22376710ee | ||
|
|
3ddc209d19 | ||
|
|
2ccf3264a9 | ||
|
|
c8e664e66c | ||
|
|
e8a318e5fb | ||
|
|
4d81bb112f | ||
|
|
53e32d31e2 | ||
|
|
3545830ea6 | ||
|
|
c237a6fb27 | ||
|
|
10884ad87a | ||
|
|
e9c89343d7 | ||
|
|
5530d6cfcd | ||
|
|
fb17b242ce | ||
|
|
4f56199bc7 | ||
|
|
4771f62623 | ||
|
|
e2cd64054a | ||
|
|
52b10408be | ||
|
|
26cd29c530 | ||
|
|
dfc8a4e358 | ||
|
|
f98bb57ab9 | ||
|
|
3ac81a4206 | ||
|
|
2cdf483f59 | ||
|
|
5396cf9896 | ||
|
|
6bcd193e45 | ||
|
|
0cfa09046c | ||
|
|
c20b5b9b6f | ||
|
|
d9d7a8ae0f | ||
|
|
acd93377e7 | ||
|
|
5e3408be9a | ||
|
|
91cf7ebee6 | ||
|
|
f392292b9e | ||
|
|
64975fec88 | ||
|
|
4572963175 | ||
|
|
b88150c115 | ||
|
|
09f543fb8b | ||
|
|
4a64053d66 | ||
|
|
1a4397ad24 | ||
|
|
c1aee36326 | ||
|
|
dc6424a3cc | ||
|
|
c23e8ca289 | ||
|
|
303c83e86a | ||
|
|
df4110d9ca | ||
|
|
734795f20b | ||
|
|
958f5683e4 | ||
|
|
4cc8ccb67e | ||
|
|
f7f78a7486 | ||
|
|
1420a0d2b7 | ||
|
|
d117016f2d | ||
|
|
0ba36949cf | ||
|
|
bcdffd4f56 | ||
|
|
2de99a12e9 | ||
|
|
97c52408de | ||
|
|
6760f61fb8 | ||
|
|
61279e9bea | ||
|
|
020f0bfea7 | ||
|
|
3f429aec85 | ||
|
|
cb21b8e34a | ||
|
|
e2460855d9 | ||
|
|
bae70e9154 | ||
|
|
7ef3e2eb93 | ||
|
|
7ab19adcea | ||
|
|
9deccf0a2f | ||
|
|
0d14db97a6 | ||
|
|
36d1578794 | ||
|
|
ae595a208d | ||
|
|
69b46647c5 | ||
|
|
8fd971405e | ||
|
|
7af9f7a057 | ||
|
|
397aedf2d8 | ||
|
|
8de15465ad | ||
|
|
00964e90a8 | ||
|
|
ccee032740 | ||
|
|
4fe8e87416 | ||
|
|
67d2eae2cf | ||
|
|
8e7d599170 | ||
|
|
8dce93d4dc | ||
|
|
827b1c4c87 | ||
|
|
d13ac58e74 | ||
|
|
ac1b24fe43 | ||
|
|
68fb6a985e | ||
|
|
546fcdc105 | ||
|
|
d4f71bbf3a | ||
|
|
6ef2cb548c | ||
|
|
5c0721b77f | ||
|
|
3510670ce1 | ||
|
|
c184b94602 | ||
|
|
c94757fc97 | ||
|
|
73912d5f58 | ||
|
|
50d7dbe222 | ||
|
|
c1fbe165e7 | ||
|
|
2e14de2069 | ||
|
|
c7bbc4f815 | ||
|
|
171b69233c | ||
|
|
911cd07951 | ||
|
|
60cda97b1d | ||
|
|
86340caf9d | ||
|
|
06c6c3214b | ||
|
|
d41bd1f27d | ||
|
|
401c4b00b5 | ||
|
|
50ad1f670b | ||
|
|
ba3e2cc785 | ||
|
|
66ef47239f | ||
|
|
d6e85b1745 | ||
|
|
eb13f716d0 | ||
|
|
5433a97bc9 | ||
|
|
3bf108d0fb | ||
|
|
c1f2f0a637 | ||
|
|
1f62ffbc3b | ||
|
|
0f84cac440 | ||
|
|
2872b883c7 | ||
|
|
66c95bb331 | ||
|
|
ec44cb785a | ||
|
|
05f9c20ecf | ||
|
|
7615eabdd5 | ||
|
|
65691e26da | ||
|
|
400872f8ea | ||
|
|
9aae64c47a | ||
|
|
406f481a57 | ||
|
|
212170eb88 | ||
|
|
5502a3eaa8 | ||
|
|
7674f6dc9f | ||
|
|
54549420e7 | ||
|
|
6e31770cee | ||
|
|
48902f6807 | ||
|
|
797eab7c8d | ||
|
|
0fec335e75 | ||
|
|
890ec5790f | ||
|
|
cbe00d6c85 | ||
|
|
2f78fa1106 | ||
|
|
c0b220836a | ||
|
|
a0abdc01ed | ||
|
|
b67fbaa1b6 | ||
|
|
d7e3ae6076 | ||
|
|
fe36c8c4b9 | ||
|
|
159a36ed99 | ||
|
|
caa9fd0f40 | ||
|
|
399af54839 | ||
|
|
8db171d979 | ||
|
|
c4fbd2ba5c | ||
|
|
d2a3bdfd15 | ||
|
|
3294525f4c | ||
|
|
2725ea9152 | ||
|
|
19f434d05a | ||
|
|
12fe0e43eb | ||
|
|
73f41c3288 | ||
|
|
cdd29d5e3b | ||
|
|
f6ef75549b | ||
|
|
98e742c612 | ||
|
|
d5e4e4d927 | ||
|
|
8439f2bf18 | ||
|
|
f5ca26ae2d | ||
|
|
742d8c4b09 | ||
|
|
3da49ec79c | ||
|
|
1aee4f2d5b | ||
|
|
c4ac81c7a6 | ||
|
|
1e23335356 | ||
|
|
3dfec5d2f9 | ||
|
|
4b2a5c51ea | ||
|
|
f4d2a0330b | ||
|
|
dddc48440b | ||
|
|
c71c4016ca | ||
|
|
f115cb454f | ||
|
|
ce332ead67 | ||
|
|
42966da973 | ||
|
|
766f02ae5d | ||
|
|
d679ca8d14 | ||
|
|
b67775283b | ||
|
|
1e142d9644 | ||
|
|
fc29843035 | ||
|
|
56da64d0cd | ||
|
|
0c91390e56 | ||
|
|
bc445a0a7c | ||
|
|
92e26aafcb | ||
|
|
88d91b10af | ||
|
|
f88805f36d | ||
|
|
0b9aa56e75 | ||
|
|
a77ab365dd | ||
|
|
b6c90d3421 | ||
|
|
52e68ac490 | ||
|
|
833e84578e | ||
|
|
90fe6e0f9a | ||
|
|
e17538b186 | ||
|
|
c7f9d014ca | ||
|
|
f17d757ba0 | ||
|
|
0d642e4d80 | ||
|
|
806c931e43 | ||
|
|
efce13e05d | ||
|
|
a4610e8d0d | ||
|
|
a5dbc5d665 | ||
|
|
afd2a10df2 | ||
|
|
ac3eb74122 | ||
|
|
2954fd1a84 | ||
|
|
d8492f3720 | ||
|
|
fc84ab8d8b | ||
|
|
9f1ca2e177 | ||
|
|
45a6800621 | ||
|
|
2068bc8833 | ||
|
|
388e9e6ab8 | ||
|
|
dec56506f9 | ||
|
|
aebaacf2c1 | ||
|
|
6d6f901ef4 | ||
|
|
7a17bb5079 | ||
|
|
f4a81d7be2 | ||
|
|
457a2e4dd4 | ||
|
|
04891bccee | ||
|
|
103803fb92 | ||
|
|
0743901e48 | ||
|
|
eccf4b7af1 | ||
|
|
8e7a093150 | ||
|
|
a2c8ab4336 | ||
|
|
a273e5b2b6 | ||
|
|
8bfb4584a1 | ||
|
|
a27738120a | ||
|
|
19b62fdc1b | ||
|
|
25ab1c485b | ||
|
|
b2aa7fab54 | ||
|
|
a3551a2654 | ||
|
|
63dcc2b656 | ||
|
|
9b26b4930b | ||
|
|
66a137cffe | ||
|
|
a4c85eaabc | ||
|
|
fb0a7f48dd | ||
|
|
ed46011ae4 | ||
|
|
fb58256b1c | ||
|
|
f951d51214 | ||
|
|
06ba054898 | ||
|
|
f8cc62308f | ||
|
|
8ec33721f7 | ||
|
|
c105a5941e | ||
|
|
76a0076b65 | ||
|
|
fcecf805ed | ||
|
|
8866e305f0 | ||
|
|
cd6e39a4a7 | ||
|
|
ee6888e737 | ||
|
|
05f70b7910 | ||
|
|
c2b1bb20ae | ||
|
|
261f74e1e4 | ||
|
|
846d61a1b0 | ||
|
|
2959eaa149 | ||
|
|
281a735e84 | ||
|
|
4392bb460a | ||
|
|
ea86d70475 | ||
|
|
6a0e5a28a9 | ||
|
|
4e4664b603 | ||
|
|
08a83c9ba9 | ||
|
|
a981171c94 | ||
|
|
ea80d3aca5 | ||
|
|
3476f2b7e7 | ||
|
|
e0bfa76545 | ||
|
|
87a935cb74 | ||
|
|
25f3870472 | ||
|
|
52297ec618 | ||
|
|
772ead28a2 | ||
|
|
8da0e60c20 | ||
|
|
481ae0209a | ||
|
|
0bd34544e5 | ||
|
|
8cd5dd518a | ||
|
|
06d3e93e21 | ||
|
|
b45d81cfaa | ||
|
|
7c8504d593 | ||
|
|
09dbb4b199 | ||
|
|
2d3bb55db4 | ||
|
|
52dd44a3e8 | ||
|
|
2104c9cc9f | ||
|
|
ad3095cdf5 | ||
|
|
d73abc809d | ||
|
|
b4ac2fb821 | ||
|
|
dc7450b6a7 | ||
|
|
470197d203 | ||
|
|
f30d8b82cd | ||
|
|
436a3be7f6 | ||
|
|
1cf6b93512 | ||
|
|
1d253c4cae | ||
|
|
afbe113acf | ||
|
|
b87904c326 | ||
|
|
df7919f84f | ||
|
|
1483500458 | ||
|
|
aee6766c4c | ||
|
|
aca1558702 | ||
|
|
af03615ec0 | ||
|
|
a66feab53e | ||
|
|
40fcbbc32a | ||
|
|
6f01d319f5 | ||
|
|
33a5be39ed | ||
|
|
19a08ef5bc | ||
|
|
f35894d17a | ||
|
|
a9aaebc08d | ||
|
|
55c8e354b4 | ||
|
|
6d4d39c2d8 | ||
|
|
c8dffab5ad | ||
|
|
2af3cec8a6 |
38
.dockerignore
Normal file
38
.dockerignore
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# git + CI
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# deps - reinstalled fresh in the build
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# mutable state (lives on the /data volume, never baked into the image).
|
||||||
|
# Note: the *.db* patterns drop the database FILES; we must NOT exclude the
|
||||||
|
# server/db/ directory itself - it holds database.js + schema.sql.
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.db.*
|
||||||
|
server/uploads
|
||||||
|
server/certs
|
||||||
|
|
||||||
|
# secrets
|
||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
|
||||||
|
# not needed at runtime (NOTE: scripts/ IS needed - database.js requires
|
||||||
|
# scripts/migrate-multitenancy at boot - so it must stay in the image)
|
||||||
|
server/test
|
||||||
|
android
|
||||||
|
tizen
|
||||||
|
video
|
||||||
|
*.apk
|
||||||
|
*.wgt
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# docs / editor cruft
|
||||||
|
*.md
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
47
.env.example
Normal file
47
.env.example
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# ScreenTinker server configuration — process environment variables.
|
||||||
|
#
|
||||||
|
# NOTE: the app reads these from the *process environment* (your systemd unit's
|
||||||
|
# Environment=/EnvironmentFile=, your container runtime, or your shell). It does
|
||||||
|
# NOT auto-load this file. Copy the values you need into your process manager.
|
||||||
|
# This file exists only to document the available options.
|
||||||
|
|
||||||
|
# --- Self-hosting ---
|
||||||
|
# Set to "true" on your own instance. When true, the hosted signup emails
|
||||||
|
# (welcome to the user + admin notification) are disabled, so a self-hosted
|
||||||
|
# instance never emits mail from a domain that isn't yours.
|
||||||
|
SELF_HOSTED=true
|
||||||
|
|
||||||
|
# Close public self-service registration — for instances where all accounts are
|
||||||
|
# provisioned by your team (admin "Add user" / invites). When true, the public
|
||||||
|
# signup route is blocked (OAuth auto-signup with it) AND the login page hides
|
||||||
|
# its "Create account" button so the UI matches the backend. First-user setup on
|
||||||
|
# an empty DB is still allowed so a fresh install can be initialized.
|
||||||
|
# DISABLE_REGISTRATION=true
|
||||||
|
|
||||||
|
# Redirect "/" to the app (/app) instead of serving the marketing landing page.
|
||||||
|
# For internal-only deployments that don't want the public homepage shown.
|
||||||
|
# DISABLE_HOMEPAGE=true
|
||||||
|
|
||||||
|
# Where new-signup admin notifications are sent. Leave UNSET to disable admin
|
||||||
|
# notifications entirely — the user's welcome email is unaffected. Self-hosters
|
||||||
|
# who want to be notified of signups set this to their own address.
|
||||||
|
# ADMIN_NOTIFY_EMAIL=you@example.com
|
||||||
|
|
||||||
|
# Marks THIS deployment as the hosted (screentinker.com) instance. Gates the
|
||||||
|
# daily activation-nudge sweep (the T+3 "haven't paired a screen yet?" email).
|
||||||
|
# Leave UNSET on self-hosted instances so a daily bulk sweep never emails your
|
||||||
|
# user base with our onboarding mail. Only the hosted instance sets this true.
|
||||||
|
# HOSTED_INSTANCE=true
|
||||||
|
|
||||||
|
# --- Outbound email (Microsoft Graph, client-credentials flow) ---
|
||||||
|
# Required for ANY email (welcome, offline alerts, admin notify) to actually
|
||||||
|
# send. Leave blank and the app logs "[EMAIL] not configured" instead of sending.
|
||||||
|
# GRAPH_TENANT_ID=
|
||||||
|
# GRAPH_CLIENT_ID=
|
||||||
|
# GRAPH_CLIENT_SECRET=
|
||||||
|
# GRAPH_SENDER_EMAIL=signage@example.com
|
||||||
|
# GRAPH_SENDER_NAME=ScreenTinker
|
||||||
|
# Dev safety net: comma-separated allow-list of recipients. When set, mail to
|
||||||
|
# any address NOT in the list is suppressed (logged, not sent). Leave UNSET in
|
||||||
|
# production. Useful locally so test signups can't email real users.
|
||||||
|
# GRAPH_DEV_RESTRICT_TO=me@example.com
|
||||||
126
.github/workflows/ci.yml
vendored
Normal file
126
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# main gets frequent pushes - cancel an in-flight run when a newer commit
|
||||||
|
# (or rerun) supersedes it, per ref.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Unit tests (node --test)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: server
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: server/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
|
openapi:
|
||||||
|
name: OpenAPI spec lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- name: Lint the public API spec
|
||||||
|
run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
|
||||||
|
# Contract integrity: the spec documents ONLY the token-reachable public surface.
|
||||||
|
# A JWT-only router (admin/auth/provision/...) appearing here is a security flag,
|
||||||
|
# not a convenience - fail loudly. (The runtime partition test is a separate suite
|
||||||
|
# that will cross-check the spec against the live mount list.)
|
||||||
|
- name: Assert spec is public-only
|
||||||
|
run: |
|
||||||
|
BAD=$(grep -oE '^ /(admin|auth|workspaces|ai|provision|white-label|status|subscription|stripe|teams|player-debug|contact|tokens)\b' docs/openapi.yaml || true)
|
||||||
|
if [ -n "$BAD" ]; then echo "::error::JWT-only path(s) leaked into the public spec:"; echo "$BAD"; exit 1; fi
|
||||||
|
if grep -qE 'unassigned|/prune' docs/openapi.yaml; then echo "::error::token-denied endpoint present in public spec"; exit 1; fi
|
||||||
|
echo "OK: spec is public-only"
|
||||||
|
|
||||||
|
android-test:
|
||||||
|
name: Android unit tests (Kotlin schedule evaluator vectors)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
- uses: android-actions/setup-android@v3
|
||||||
|
# ScheduleEvalTest reads the SHARED shared/schedule-vectors.json (wired via
|
||||||
|
# the test task in app/build.gradle.kts), so a ScheduleEval.kt change that
|
||||||
|
# breaks the contract fails here.
|
||||||
|
- name: Kotlin evaluator vector conformance
|
||||||
|
working-directory: android
|
||||||
|
run: ./gradlew :app:testDebugUnitTest --no-daemon
|
||||||
|
|
||||||
|
smoke:
|
||||||
|
name: Boot smoke + version check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: server/package-lock.json
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: server
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# Boot against a fresh SQLite db (clean checkout = no db yet). SELF_HOSTED
|
||||||
|
# makes the first user an admin with no billing. No certs present, so the
|
||||||
|
# server listens on plain HTTP at :3001. Background it and wait until it
|
||||||
|
# answers.
|
||||||
|
- name: Boot server
|
||||||
|
working-directory: server
|
||||||
|
env:
|
||||||
|
SELF_HOSTED: 'true'
|
||||||
|
run: |
|
||||||
|
node server.js > "$RUNNER_TEMP/server.log" 2>&1 &
|
||||||
|
echo $! > "$RUNNER_TEMP/server.pid"
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -sf http://localhost:3001/api/status >/dev/null && exit 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "server did not come up within 30s:"; cat "$RUNNER_TEMP/server.log"; exit 1
|
||||||
|
|
||||||
|
# Assert the public status endpoint is healthy and reports exactly the
|
||||||
|
# VERSION file - this is what proves the single-source-of-truth wiring.
|
||||||
|
- name: Assert /api/status ok and version matches VERSION
|
||||||
|
run: |
|
||||||
|
STATUS="$(curl -sf http://localhost:3001/api/status)"
|
||||||
|
echo "status: $STATUS"
|
||||||
|
EXPECTED="$(cat VERSION)"
|
||||||
|
REPORTED="$(echo "$STATUS" | jq -r .version)"
|
||||||
|
echo "VERSION file: $EXPECTED reported: $REPORTED"
|
||||||
|
test "$(echo "$STATUS" | jq -r .status)" = "ok"
|
||||||
|
test "$REPORTED" = "$EXPECTED"
|
||||||
|
echo "OK: status ok, version $REPORTED matches VERSION"
|
||||||
|
|
||||||
|
- name: Stop server
|
||||||
|
if: always()
|
||||||
|
run: kill "$(cat "$RUNNER_TEMP/server.pid")" 2>/dev/null || true
|
||||||
|
|
||||||
|
# TODO (deferred - needs a tag earlier than HEAD, so meaningful from v1.8.0 on):
|
||||||
|
# upgrade-path job. Restore a db created by the previous tagged release, boot
|
||||||
|
# the current code against it, and assert migrations complete and /api/status
|
||||||
|
# is healthy. Add once a prior release tag exists.
|
||||||
173
.github/workflows/release.yml
vendored
Normal file
173
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
# Fires when a version tag is pushed (e.g. v1.8.0). Builds + publishes artifacts
|
||||||
|
# only - nothing here deploys to production.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # create the GitHub Release
|
||||||
|
packages: write # push the image to ghcr.io
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref }}
|
||||||
|
cancel-in-progress: false # never cancel a release mid-publish
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Fail-fast: a hand-pushed tag that disagrees with VERSION must not publish
|
||||||
|
# anything (the artifacts would report the wrong version). Gates everything.
|
||||||
|
verify:
|
||||||
|
name: Verify tag matches VERSION
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Assert pushed tag equals VERSION
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
FILE="$(cat VERSION)"
|
||||||
|
echo "pushed tag: ${GITHUB_REF_NAME} (stripped: $TAG) VERSION file: $FILE"
|
||||||
|
if [ "$TAG" != "$FILE" ]; then
|
||||||
|
echo "::error::Tag ${GITHUB_REF_NAME} does not match VERSION ($FILE) - refusing to publish."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: tag matches VERSION ($FILE)"
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Tests
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: server
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: server/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
|
artifacts:
|
||||||
|
name: Tarball + Tizen .wgt + GitHub Release
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # full history, for release notes
|
||||||
|
|
||||||
|
- name: Resolve version + previous tag
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
VERSION="$(cat VERSION)"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
PREV="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
|
||||||
|
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
|
||||||
|
# #80: a version carrying a -suffix (e.g. 1.9.0-rc1) is a pre-release.
|
||||||
|
case "$VERSION" in *-*) PRE=true ;; *) PRE=false ;; esac
|
||||||
|
echo "prerelease=$PRE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Releasing ${GITHUB_REF_NAME} (version $VERSION, prerelease=$PRE); previous tag: ${PREV:-<none>}"
|
||||||
|
|
||||||
|
- name: Build Tizen .wgt (unsigned in CI)
|
||||||
|
run: |
|
||||||
|
chmod +x tizen/build-wgt.sh
|
||||||
|
( cd tizen && ./build-wgt.sh ) # no Tizen CLI on the runner => unsigned zip
|
||||||
|
cp tizen/ScreenTinker.wgt ScreenTinker.wgt
|
||||||
|
ls -la ScreenTinker.wgt
|
||||||
|
|
||||||
|
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
|
||||||
|
run: |
|
||||||
|
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
|
||||||
|
tar czf "$OUT" \
|
||||||
|
--exclude='node_modules' --exclude='.git' --exclude='.github' \
|
||||||
|
--exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' --exclude='*.db.*' \
|
||||||
|
--exclude='server/uploads' --exclude='server/certs' --exclude='server/test' \
|
||||||
|
--exclude='*.apk' \
|
||||||
|
server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt
|
||||||
|
echo "TARBALL=$OUT" >> "$GITHUB_ENV"
|
||||||
|
ls -la "$OUT"
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
run: |
|
||||||
|
PREV="${{ steps.ver.outputs.prev }}"
|
||||||
|
{
|
||||||
|
echo "## ScreenTinker ${{ steps.ver.outputs.tag }}"
|
||||||
|
echo
|
||||||
|
echo "### Changes"
|
||||||
|
if [ -n "$PREV" ]; then
|
||||||
|
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
|
||||||
|
else
|
||||||
|
echo "_First tagged release. Most recent changes:_"
|
||||||
|
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "### Artifacts"
|
||||||
|
echo "- \`${TARBALL}\` - bundle: server + frontend source + the Tizen .wgt (the signed Android APK is added at the root during release finalization)."
|
||||||
|
echo "- \`ScreenTinker.wgt\` - Tizen TV web app, **unsigned - for inspection only**."
|
||||||
|
echo " Sign it with your own Samsung certificate (Tizen Studio + a profile that includes"
|
||||||
|
echo " your TV's DUID) to install, or - easiest - point a Tizen TV browser / URL Launcher"
|
||||||
|
echo " at \`https://<your-instance>/player\` (no signing needed)."
|
||||||
|
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
|
||||||
|
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (pre-release - \`:latest\` is NOT moved)."
|
||||||
|
else
|
||||||
|
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
|
||||||
|
fi
|
||||||
|
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
|
||||||
|
} > RELEASE_NOTES.md
|
||||||
|
cat RELEASE_NOTES.md
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# #80: pre-release tags publish as a GitHub *pre-release* (not "Latest"),
|
||||||
|
# which also keeps the /releases/latest API pointing at the last stable.
|
||||||
|
PRERELEASE_FLAG=""
|
||||||
|
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && PRERELEASE_FLAG="--prerelease"
|
||||||
|
gh release create "${{ steps.ver.outputs.tag }}" \
|
||||||
|
$PRERELEASE_FLAG \
|
||||||
|
--title "ScreenTinker ${{ steps.ver.outputs.tag }}" \
|
||||||
|
--notes-file RELEASE_NOTES.md \
|
||||||
|
"${TARBALL}" \
|
||||||
|
tizen/ScreenTinker.wgt
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Docker image (amd64 + arm64) -> ghcr
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- id: ver
|
||||||
|
run: |
|
||||||
|
VERSION="$(cat VERSION)"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
# #80: move :latest only for final releases - a pre-release (1.9.0-rc1) must
|
||||||
|
# not repoint :latest onto untested code (anyone on :latest pulls it on restart).
|
||||||
|
TAGS="ghcr.io/screentinker/screentinker:$VERSION"
|
||||||
|
case "$VERSION" in
|
||||||
|
*-*) echo "Pre-release $VERSION: :latest will NOT be moved" ;;
|
||||||
|
*) TAGS="${TAGS}"$'\n'"ghcr.io/screentinker/screentinker:latest" ;;
|
||||||
|
esac
|
||||||
|
{ echo "tags<<__EOF__"; printf '%s\n' "$TAGS"; echo "__EOF__"; } >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.ver.outputs.tags }}
|
||||||
|
|
||||||
|
# TODO (deferred): build + sign the Android APK in CI. Requires the release
|
||||||
|
# keystore + passwords as encrypted Actions secrets. For now the maintainer
|
||||||
|
# attaches a signed APK out-of-band (and self-hosters mount one at
|
||||||
|
# /data/ScreenTinker.apk).
|
||||||
17
.gitignore
vendored
17
.gitignore
vendored
|
|
@ -1,10 +1,12 @@
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Database
|
# Databases: SQLite files, WAL/SHM sidecars, and any .db.<suffix> backups
|
||||||
server/db/*.db
|
# (e.g. .db.devbak), anywhere in the tree - never commit a database.
|
||||||
server/db/*.db-wal
|
*.db
|
||||||
server/db/*.db-shm
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.db.*
|
||||||
|
|
||||||
# Uploads (user content)
|
# Uploads (user content)
|
||||||
server/uploads/
|
server/uploads/
|
||||||
|
|
@ -24,6 +26,8 @@ android/local.properties
|
||||||
android/release-key.jks
|
android/release-key.jks
|
||||||
*.apk
|
*.apk
|
||||||
*.aab
|
*.aab
|
||||||
|
*.wgt
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
# IDE / Editor
|
# IDE / Editor
|
||||||
.claude/
|
.claude/
|
||||||
|
|
@ -40,3 +44,8 @@ Thumbs.db
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
# ...but DO track the documented template (placeholders only, no secrets)
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Local-only marketing assets
|
||||||
|
video/
|
||||||
|
|
|
||||||
59
CHANGELOG.md
Normal file
59
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.9.0 — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Per-playlist-item schedules.** Each playlist item can carry one or more schedule
|
||||||
|
blocks — active days, a start/end time-of-day, and optional start/end dates. An item
|
||||||
|
plays when the screen's local "now" matches at least one block; an item with no
|
||||||
|
blocks always plays. Edit per item via the clock icon in the playlist editor (a badge
|
||||||
|
summarises the schedule on each row).
|
||||||
|
- **#74 dayparting:** time-of-day + day-of-week windows, including overnight windows
|
||||||
|
that cross midnight (a Fri 22:00–02:00 block is active Sat 01:00).
|
||||||
|
- **#75 auto-expire:** inclusive start/end dates; an item past its end date stops
|
||||||
|
showing automatically — even on offline screens, because evaluation is on-device.
|
||||||
|
- All three players (web, Android, Tizen) evaluate schedules client-side against their
|
||||||
|
own clock, so dayparting and expiry work offline. They share one evaluator contract,
|
||||||
|
`shared/schedule-vectors.json` — 39 conformance vectors covering DST (US + AU),
|
||||||
|
overnight-wrap day anchoring, timezone correctness, and date boundaries. CI runs the
|
||||||
|
vectors against the JS evaluator (node) and the Kotlin port (Gradle/JUnit); the Tizen
|
||||||
|
copy is byte-identical to the JS source and checked under node.
|
||||||
|
- Device detail now shows the screen's reported timezone and clock, with a **clock-skew
|
||||||
|
warning** when the device clock differs from the server by more than 2 minutes (a bad
|
||||||
|
device clock makes schedules fire at the wrong local time).
|
||||||
|
|
||||||
|
### Changed — device-level schedule timezone (behaviour change)
|
||||||
|
- Device/group **schedule overrides** (the existing calendar feature) are now evaluated
|
||||||
|
in each device's effective timezone instead of the server's local time. Previously the
|
||||||
|
`schedules.timezone` field was never applied and "07:00" meant the *server's* 07:00.
|
||||||
|
Now "07:00" means the *screen's* 07:00 — which is what was intended.
|
||||||
|
- **Who is affected:** self-hosters whose server timezone differs from their screens'
|
||||||
|
timezone — their existing device schedules will shift to fire at the screens' local
|
||||||
|
time. Single-timezone deployments (server and screens in the same zone) are
|
||||||
|
unaffected. A device with no timezone set and not reporting one falls back to the
|
||||||
|
server clock (unchanged from before).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **#81 — release APK is now v1 + v2 + v3 signed.** With `minSdk 26`, the Android Gradle
|
||||||
|
Plugin defaulted the v1 (JAR) signature *off*, producing a v2-only APK that some
|
||||||
|
MDM-managed commercial signage (e.g. MAXHUB via the Pivot MDM) silently removes on the
|
||||||
|
next reboot — so screens that power-cycle nightly lost the app and fell back to the
|
||||||
|
setup screen. Setting `enableV1Signing = true` had no effect at minSdk ≥ 24; the release
|
||||||
|
build now re-signs with `apksigner` and a low `--min-sdk-version` to emit the JAR
|
||||||
|
signature alongside v2/v3. Verified to install and run on Android 14+/API 36 as well.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- **Scheduling fails open.** If the on-device evaluator ever errors (bad timezone id,
|
||||||
|
malformed block), the item **plays** rather than being hidden. A blank screen is worse
|
||||||
|
than an over-running promo — this is a guarantee, enforced in all three players.
|
||||||
|
- Windows are enforced at **item boundaries**: a long item finishes before the schedule
|
||||||
|
is re-checked, so it can overshoot its window by up to its own duration.
|
||||||
|
- **A single video *with a schedule* now re-renders at each loop boundary** so its window
|
||||||
|
can be re-evaluated; seamless native looping still applies to unscheduled single videos.
|
||||||
|
Deliberate tradeoff — a brief seam each loop for a scheduled lone video, in exchange for
|
||||||
|
its daypart/expiry actually being honoured.
|
||||||
|
- **Re-publish required:** editing a schedule puts the playlist into draft; publish to
|
||||||
|
push schedules to devices. Existing published playlists keep playing unchanged until
|
||||||
|
re-published.
|
||||||
|
- Players that predate this release ignore the new fields and keep playing everything
|
||||||
|
(graceful degradation) — update players to honour schedules.
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# ScreenTinker server image: serves the dashboard, the web player, and the
|
||||||
|
# device API. All mutable state (db, uploads, jwt secret) lives under /data so it
|
||||||
|
# survives container restarts - mount a volume there. A built ScreenTinker.apk
|
||||||
|
# can be mounted at /data/ScreenTinker.apk to enable OTA APK downloads.
|
||||||
|
#
|
||||||
|
# No TLS in the image: it listens on plain HTTP :3001. Front it with a
|
||||||
|
# TLS-terminating reverse proxy / Cloudflare in production.
|
||||||
|
|
||||||
|
# --- builder: install production deps (native: better-sqlite3, sharp) ---
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
WORKDIR /app/server
|
||||||
|
# build toolchain in case a native prebuild is missing for the target arch
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY server/package.json server/package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# --- runtime ---
|
||||||
|
FROM node:20-slim
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Relocate all state onto the volume (config.js reads DATA_DIR; unset would use
|
||||||
|
# the in-repo paths, which we do not want in a container).
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
WORKDIR /app/server
|
||||||
|
# App source (node_modules/test/db/uploads/certs are excluded via .dockerignore),
|
||||||
|
# then the built deps, the frontend the server serves, and the VERSION file it
|
||||||
|
# reads as ../VERSION.
|
||||||
|
COPY server/ /app/server/
|
||||||
|
COPY --from=builder /app/server/node_modules /app/server/node_modules
|
||||||
|
COPY frontend/ /app/frontend/
|
||||||
|
COPY VERSION /app/VERSION
|
||||||
|
# database.js requires scripts/migrate-multitenancy at boot
|
||||||
|
COPY scripts/ /app/scripts/
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "server.js"]
|
||||||
290
README.md
290
README.md
|
|
@ -1,25 +1,78 @@
|
||||||
# ScreenTinker
|
# ScreenTinker
|
||||||
|
|
||||||
Open-source digital signage management software. Control content on TVs, displays, and kiosks from anywhere.
|
ScreenTinker is self-hosted digital signage software. Manage screens across multiple locations from one dashboard — built for retail, offices, lobbies, and any environment where you need centralized control over what's displayed on remote screens. Open source, multi-tenant, single-developer maintained with direct contact access.
|
||||||
|
|
||||||
**Hosted version:** [screentinker.com](https://screentinker.com) — free tier available, no credit card required.
|
**Hosted version:** [screentinker.com](https://screentinker.com) — free tier available, no credit card required.
|
||||||
|
**Community:** [Discord](https://discord.gg/utTdsrqq4Z)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multi-zone layouts** — split screens into zones with drag-and-drop editor
|
- **Playlists** — first-class playlist objects: create, reorder, set per-item duration, share one playlist across multiple displays; draft/publish workflow with revert-to-published
|
||||||
- **Video walls** — combine multiple displays into one screen with bezel compensation
|
- **Device groups** — organize displays into groups, assign a playlist to an entire group, send bulk commands (reboot, screen on/off, launch, update, shutdown), schedule content group-wide
|
||||||
- **Remote control** — live view, key input, power on/off
|
- **Multi-zone layouts** — split screens into zones with drag-and-drop editor; 7 built-in templates (fullscreen, split, L-bar, PiP, grid)
|
||||||
- **Scheduling** — visual weekly calendar with recurrence rules
|
- **Video walls** — combine multiple displays into one screen with bezel compensation, device rotation, and leader-based sync
|
||||||
- **Content designer** — clocks, weather, RSS tickers, countdowns, QR codes
|
- **Remote control** — live view, touch injection, key input, power on/off
|
||||||
|
- **Scheduling** — visual weekly calendar with recurrence rules (daily/weekly/monthly), priority-based conflict resolution, both device-level and group-level schedules (device-level overrides win over group-level), timezone support
|
||||||
|
- **Widgets** — clocks, weather, RSS tickers, text/HTML, webpages, social feeds, and Directory Board (scrolling lobby tenant/room/staff directories with dark/light themes, category management, and anti-burn-in motion)
|
||||||
- **Kiosk mode** — interactive touchscreen interfaces
|
- **Kiosk mode** — interactive touchscreen interfaces
|
||||||
- **Proof-of-play** — analytics and CSV export for ad verification
|
- **Proof-of-play** — per-content and per-device analytics, hourly/daily breakdowns, CSV export for ad verification
|
||||||
- **Alerts** — email notifications when devices go offline
|
- **Device telemetry** — battery, storage, RAM, CPU, WiFi signal strength, and uptime reported by Android players
|
||||||
- **Teams** — multi-user with owner, editor, and viewer roles
|
- **Offline resilience** — both web and Android players keep displaying cached content during server or internet outages (Android ContentCache, web player Service Worker); state syncs when connectivity returns
|
||||||
- **White-label** — custom branding, colors, logo, domain
|
- **Mobile-responsive** — full management dashboard and landing page work on phones and tablets
|
||||||
- **YouTube support** — embed YouTube videos as content, no storage needed
|
- **Workspaces** — multi-tenant data model: organizations contain workspaces, workspaces contain devices/content/playlists/schedules; users can be members of multiple workspaces and switch via a dropdown in the sidebar
|
||||||
- **Export/Import** — migrate or back up devices, content, layouts, and widgets with optional media bundling (ZIP)
|
- **Member roles** — six-level hierarchy (platform_admin / org_owner / org_admin / workspace_admin / workspace_editor / workspace_viewer) gated at every API route
|
||||||
|
- **Alerts** — email notifications via Microsoft Graph when devices go offline; built-in spam protection (2h dedup, 24h long-offline cutoff, sequential send pattern); per-user opt-out via Settings → Account
|
||||||
|
- **White-label** — custom branding, colors, logo, favicon, CSS, and domain
|
||||||
|
- **Content management** — folder organization, remote URL content (no upload needed), YouTube embeds, video duration detection via ffprobe, automatic thumbnail generation, Unicode-safe filenames (NFC normalization + UTF-8 multipart decoding)
|
||||||
|
- **Export/Import** — v2 format with playlists, device groups, schedules, and optional media bundling (ZIP); backward-compatible v1 import with automatic playlist migration
|
||||||
|
- **Device authentication** — per-device tokens for secure WebSocket connections; devices authenticate on every reconnect
|
||||||
|
- **Account management** — in-app password change, profile editing, email-based password reset
|
||||||
|
- **Security** — JWT auth, bcrypt hashing, parameterized SQL, rate-limited endpoints, per-user ownership checks on all resources, ongoing auth/IDOR/XSS audits
|
||||||
- **Built-in billing** — Stripe integration for SaaS subscriptions (optional)
|
- **Built-in billing** — Stripe integration for SaaS subscriptions (optional)
|
||||||
- **Auto-update** — OTA updates pushed to devices automatically
|
- **Auto-update** — OTA updates pushed to devices automatically
|
||||||
|
- **Activity log** — full audit trail of user and system actions
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Multi-tenancy model
|
||||||
|
|
||||||
|
Three nested primitives:
|
||||||
|
|
||||||
|
```
|
||||||
|
organizations (billing + branding container)
|
||||||
|
workspaces (resource scope: devices, content, playlists, schedules, walls, layouts, widgets, groups)
|
||||||
|
members (users with a role on that workspace)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every resource (device, content row, playlist, schedule, etc.) carries a `workspace_id`. Every API route filters by it. Cross-workspace access requires switching workspaces via the sidebar dropdown — there are no magic role-based "see everything" bypasses on individual resource routes.
|
||||||
|
|
||||||
|
### Role hierarchy
|
||||||
|
|
||||||
|
Six roles, top wins:
|
||||||
|
|
||||||
|
| Role | Scope | Cap |
|
||||||
|
|---|---|---|
|
||||||
|
| `platform_admin` | every workspace in the system | full read/write (via acting-as on workspaces they're not a direct member of) |
|
||||||
|
| `org_owner` | one organization | billing + delete + admin within all workspaces in the org |
|
||||||
|
| `org_admin` | one organization | admin within all workspaces in the org (no billing) |
|
||||||
|
| `workspace_admin` | one workspace | manage members, rename, full read/write |
|
||||||
|
| `workspace_editor` | one workspace | create/edit content, devices, playlists, schedules; no member changes |
|
||||||
|
| `workspace_viewer` | one workspace | read-only |
|
||||||
|
|
||||||
|
### Workspace switcher
|
||||||
|
|
||||||
|
Users who are members of more than one workspace see a dropdown in the sidebar header. Switching mints a fresh JWT with the new `current_workspace_id` claim and reloads the page. Platform admins see every workspace in the system.
|
||||||
|
|
||||||
|
### Auto-migration on boot
|
||||||
|
|
||||||
|
Schema migrations run automatically the first time the server starts after a git pull. **Self-hosters never need to run a manual migration command.** On detecting a pre-multi-tenancy database, the server takes a timestamped snapshot (`server/db/remote_display.pre-migration-<timestamp>.db`), runs the Phase 1 migration (creates `organizations` / `workspaces` / `workspace_members` tables, backfills `workspace_id` on every resource, one auto-created Default workspace per existing user), then continues startup. If the migration fails the server prints the restore command and exits.
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
- **Android / web players** → device-namespace WebSocket → server. Authenticated per-device with a long-lived device token. Each device joins a room keyed on its `device_id`.
|
||||||
|
- **Admin dashboard** → dashboard-namespace WebSocket → server. Authenticated with the user's JWT. Each socket joins one room per accessible workspace so outbound events (device status, screenshots, playback progress) only reach dashboards that should see them.
|
||||||
|
- **Admin REST** → `/api/*` HTTPS → Express → SQLite. Everything scoped by `workspace_id` from JWT `current_workspace_id` claim.
|
||||||
|
- **Email** → Microsoft Graph `sendMail` via client-credentials OAuth flow. In-memory token cache. Sequential send pattern through alert backlogs to respect Graph's per-app concurrency limits.
|
||||||
|
|
||||||
## Supported Platforms
|
## Supported Platforms
|
||||||
|
|
||||||
|
|
@ -29,8 +82,9 @@ Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, a
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js 20+
|
- Node.js **20.6+** (the npm scripts use the built-in `--env-file-if-exists` flag, added in 20.6)
|
||||||
- Linux, macOS, or Windows
|
- Linux, macOS, or Windows
|
||||||
|
- SQLite (bundled via `better-sqlite3`; no separate install needed — `npm install` handles the native bindings)
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
|
|
@ -38,26 +92,47 @@ Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, a
|
||||||
git clone https://github.com/screentinker/screentinker.git
|
git clone https://github.com/screentinker/screentinker.git
|
||||||
cd screentinker/server
|
cd screentinker/server
|
||||||
npm install
|
npm install
|
||||||
SELF_HOSTED=true node server.js
|
SELF_HOSTED=true npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
The server starts on port 3001. Open `http://localhost:3001` in your browser. The first registered user gets full access with all features unlocked.
|
The server starts on port 3001 (HTTP). If SSL certificates are present in `server/certs/`, it starts on port 3443 (HTTPS) with automatic HTTP-to-HTTPS redirect. Open the URL shown in the startup banner. The first registered user gets full access with all features unlocked.
|
||||||
|
|
||||||
|
Schema migrations run automatically on first boot — no manual migration commands at any point in the lifecycle.
|
||||||
|
|
||||||
|
`npm start` is preferred over `node server.js` directly because the script invokes Node with `--env-file-if-exists=.env` so a `server/.env` file (gitignored) is loaded automatically for local dev.
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `PORT` | HTTP port | `3001` |
|
| `PORT` | HTTP port | `3001` |
|
||||||
|
| `HTTPS_PORT` | HTTPS port (used when SSL certs are present) | `3443` |
|
||||||
|
| `NODE_ENV` | Runtime env (`production` enables Express production optimizations + stricter error handling) | _(none)_ |
|
||||||
| `SELF_HOSTED` | First user gets all features unlocked | `false` |
|
| `SELF_HOSTED` | First user gets all features unlocked | `false` |
|
||||||
| `APP_URL` | Your public URL (used for Stripe callbacks) | _(none)_ |
|
| `DISABLE_REGISTRATION` | Block new account creation (including OAuth auto-signup). First-user setup on an empty DB is still allowed. | `false` |
|
||||||
|
| `DISABLE_HOMEPAGE` | Redirect `/` to `/app` instead of serving the marketing landing page. For internal-only self-hosted deployments. | `false` |
|
||||||
|
| `APP_URL` | Your public URL (used for Stripe callbacks and invite-accept URLs in emailed invites) | _(none)_ |
|
||||||
| `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ |
|
| `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ |
|
||||||
| `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` |
|
| `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` |
|
||||||
| `SSL_KEY` | Path to SSL private key | `server/certs/key.pem` |
|
| `SSL_KEY` | Path to SSL private key | `server/certs/key.pem` |
|
||||||
|
| `PING_INTERVAL` | Socket.IO Engine.IO ping interval (ms). Raise for slow TV WebKits that miss pongs under decode load. | `30000` |
|
||||||
|
| `PING_TIMEOUT` | Socket.IO Engine.IO pong wait (ms). Lower = faster dead-socket detection; higher = more forgiving of laggy clients. | `30000` |
|
||||||
|
| `HEARTBEAT_INTERVAL` | App-level offline-checker frequency (ms). How often the server sweeps the device list looking for stale heartbeats. | `10000` |
|
||||||
|
| `HEARTBEAT_TIMEOUT` | How long without an app-level heartbeat (ms) before marking a device offline. Raise for slow/jittery networks. | `45000` |
|
||||||
|
| `COMMAND_QUEUE_TTL_MS` | How long the server holds commands and playlist-updates for a device that's offline at emit time (ms). Flushed in order on reconnect within this window; dropped past TTL. | `30000` |
|
||||||
|
|
||||||
### Optional Integrations
|
### Optional Integrations
|
||||||
|
|
||||||
All integrations are optional. The app works fully without any of them.
|
All integrations are optional. The app works fully without any of them.
|
||||||
|
|
||||||
|
#### AI Content Design (local or cloud)
|
||||||
|
|
||||||
|
The Content Designer can turn a prompt into a finished sign — layout + copy from
|
||||||
|
an LLM, and optional background/foreground imagery from an image model. Each
|
||||||
|
workspace brings its own **OpenAI-compatible** endpoints (cloud, or fully local
|
||||||
|
and free via Ollama + stable-diffusion.cpp). See
|
||||||
|
**[docs/local-ai-setup.md](docs/local-ai-setup.md)**.
|
||||||
|
|
||||||
#### Stripe (Billing)
|
#### Stripe (Billing)
|
||||||
|
|
||||||
If you want to charge your users, plug in your own Stripe keys. Without them, all features are free for all users.
|
If you want to charge your users, plug in your own Stripe keys. Without them, all features are free for all users.
|
||||||
|
|
@ -81,7 +156,7 @@ If you want to charge your users, plug in your own Stripe keys. Without them, al
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Webhook signing secret (`whsec_...`) |
|
| `STRIPE_WEBHOOK_SECRET` | Webhook signing secret (`whsec_...`) |
|
||||||
| `APP_URL` | Your public URL (e.g. `https://signage.yourcompany.com`) |
|
| `APP_URL` | Your public URL (e.g. `https://signage.yourcompany.com`) |
|
||||||
|
|
||||||
The default plans are: Free (1 device), Starter ($39/mo, 5 devices), Pro ($99/mo, 15 devices), Business ($199/mo, 50 devices), and Custom (unlimited). Edit the `plans` table to change pricing, limits, or add/remove tiers.
|
The default plans are: Free (2 devices), Starter (8 devices), Pro (25 devices), and Enterprise (unlimited). Edit the `plans` table to change pricing, limits, or add/remove tiers. In self-hosted mode, the first user gets Enterprise automatically.
|
||||||
|
|
||||||
#### Google OAuth
|
#### Google OAuth
|
||||||
|
|
||||||
|
|
@ -109,15 +184,42 @@ Let users sign in with Microsoft/Azure AD.
|
||||||
| `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID |
|
| `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID |
|
||||||
| `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) |
|
| `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) |
|
||||||
|
|
||||||
#### Email Alerts
|
#### Email Alerts (Microsoft Graph)
|
||||||
|
|
||||||
Send email notifications when devices go offline.
|
Send email notifications when devices go offline. Backed by Microsoft Graph Mail.Send via the client-credentials flow.
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `EMAIL_WEBHOOK_URL` | POST endpoint that sends emails. Receives JSON: `{ to, subject, body }` |
|
| `GRAPH_TENANT_ID` | Microsoft Azure AD tenant ID |
|
||||||
|
| `GRAPH_CLIENT_ID` | Azure AD app registration client ID |
|
||||||
|
| `GRAPH_CLIENT_SECRET` | Azure AD app registration client secret |
|
||||||
|
| `GRAPH_SENDER_EMAIL` | Mailbox to send from (must be a valid mailbox or alias in the tenant) |
|
||||||
|
| `GRAPH_SENDER_NAME` | Display name shown in the email `From` field (defaults to `ScreenTinker`) |
|
||||||
|
|
||||||
You can point this at any email sending service (SendGrid, Mailgun, a simple SMTP relay, etc.) via a small webhook adapter.
|
**Azure AD app setup:**
|
||||||
|
|
||||||
|
1. Register a new app in Azure AD (single-tenant)
|
||||||
|
2. Under **API permissions**, add an **Application** permission: Microsoft Graph → `Mail.Send`
|
||||||
|
3. Click **Grant admin consent** for the tenant
|
||||||
|
4. Under **Certificates & secrets**, generate a new **Client secret** and capture the value (it is only shown once)
|
||||||
|
5. Capture the **Directory (tenant) ID** and **Application (client) ID** from the Overview page
|
||||||
|
6. Set the five env vars above in your deployment (systemd unit, `.env` file, etc.)
|
||||||
|
|
||||||
|
**Local dev fallback:** if any of `GRAPH_TENANT_ID`, `GRAPH_CLIENT_ID`, `GRAPH_CLIENT_SECRET`, or `GRAPH_SENDER_EMAIL` is unset, `sendEmail()` short-circuits and logs `[EMAIL] not configured - would send to ...` to stdout instead of calling Graph. The app keeps running normally; only delivery is suppressed. This means a minimal local-dev install with no M365 access works fine — email-triggering features (device-offline alerts, future invite emails) just won't deliver anything externally.
|
||||||
|
|
||||||
|
**Dev safety allow-list:**
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GRAPH_DEV_RESTRICT_TO` | Comma-separated allow-list of recipient emails. When set, sends to addresses **not** in the list are suppressed (logged but never posted to Graph). |
|
||||||
|
|
||||||
|
Use this in local dev when running against a fresh production database clone to prevent accidental emails to real users. Leave it **unset in production** so emails flow to everyone normally.
|
||||||
|
|
||||||
|
**Alert spam protections** (also live, no configuration needed):
|
||||||
|
- **2-hour dedup window** per (alert-type, target-id) pair — the same device won't trigger repeated alerts within two hours
|
||||||
|
- **24-hour long-offline cutoff** — devices that have been offline for more than 24 hours stop generating alerts (the user already knows or the device is abandoned; further alerts are noise)
|
||||||
|
- **Sequential send pattern** through the offline-alert backlog — avoids Graph's per-app concurrent-send throttling (HTTP 429 `ApplicationThrottled`)
|
||||||
|
- **Per-user opt-out** via the `email_alerts` toggle in Settings → Account; respects user preference before any Graph call
|
||||||
|
|
||||||
### Production Deployment
|
### Production Deployment
|
||||||
|
|
||||||
|
|
@ -149,9 +251,22 @@ Restart=always
|
||||||
Environment=PORT=3001
|
Environment=PORT=3001
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=SELF_HOSTED=true
|
Environment=SELF_HOSTED=true
|
||||||
|
# Lock down an internal / provisioned-only instance (all accounts created by your
|
||||||
|
# team). DISABLE_REGISTRATION closes self-service signup — first-user setup on an
|
||||||
|
# empty DB is still allowed, and the login page hides its "Create account" button
|
||||||
|
# to match. DISABLE_HOMEPAGE sends `/` straight to the app instead of the
|
||||||
|
# marketing landing page.
|
||||||
|
# Environment=DISABLE_REGISTRATION=true
|
||||||
|
# Environment=DISABLE_HOMEPAGE=true
|
||||||
# Environment=APP_URL=https://signage.yourcompany.com
|
# Environment=APP_URL=https://signage.yourcompany.com
|
||||||
# Environment=STRIPE_SECRET_KEY=sk_live_...
|
# Environment=STRIPE_SECRET_KEY=sk_live_...
|
||||||
# Environment=STRIPE_WEBHOOK_SECRET=whsec_...
|
# Environment=STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
# Email alerts via Microsoft Graph - see Email Alerts section above for setup
|
||||||
|
# Environment=GRAPH_TENANT_ID=...
|
||||||
|
# Environment=GRAPH_CLIENT_ID=...
|
||||||
|
# Environment=GRAPH_CLIENT_SECRET=...
|
||||||
|
# Environment=GRAPH_SENDER_EMAIL=support@yourcompany.com
|
||||||
|
# Environment=GRAPH_SENDER_NAME=Your Brand
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|
@ -199,43 +314,64 @@ To update a running instance to the latest version:
|
||||||
```bash
|
```bash
|
||||||
cd /opt/screentinker
|
cd /opt/screentinker
|
||||||
|
|
||||||
# Back up the database first
|
# Upgrade to the latest tagged release. Backs up the db (a .backup snapshot under
|
||||||
sqlite3 server/db/remote_display.db ".backup server/db/backup-$(date +%F).db"
|
# ./backups), checks out the tag, runs npm ci --omit=dev, restarts the service,
|
||||||
|
# and reports the running version.
|
||||||
|
scripts/upgrade.sh
|
||||||
|
|
||||||
# Pull latest code
|
# ...or pin a specific release:
|
||||||
git pull origin main
|
scripts/upgrade.sh v1.8.0
|
||||||
|
|
||||||
# Install any new dependencies
|
|
||||||
cd server && npm install --production
|
|
||||||
|
|
||||||
# Restart the service
|
|
||||||
sudo systemctl restart screentinker
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you deployed without git, you can initialize it:
|
Set `SERVICE_NAME` if your systemd unit is not named `screentinker`.
|
||||||
|
|
||||||
|
If you deployed without git, initialize it once so `upgrade.sh` can resolve tags:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/screentinker
|
cd /opt/screentinker
|
||||||
git init
|
git init
|
||||||
git remote add origin https://github.com/screentinker/screentinker.git
|
git remote add origin https://github.com/screentinker/screentinker.git
|
||||||
git fetch origin main
|
git fetch origin --tags
|
||||||
git checkout origin/main -- .
|
git checkout -f main
|
||||||
cd server && npm install --production
|
cd server && npm install --production
|
||||||
sudo systemctl restart screentinker
|
sudo systemctl restart screentinker
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Track bleeding edge (`main`)** instead of tagged releases - newest code, less tested:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/screentinker && git checkout main && git pull origin main
|
||||||
|
cd server && npm install --production && sudo systemctl restart screentinker
|
||||||
|
```
|
||||||
|
|
||||||
Your database, uploads, and configuration are preserved — only code files are updated.
|
Your database, uploads, and configuration are preserved — only code files are updated.
|
||||||
|
|
||||||
|
**Schema migrations run automatically.** No manual migration commands at any point. On detecting a database that hasn't been through Phase 1 multi-tenancy migration yet, the server takes a timestamped snapshot first (`server/db/remote_display.pre-migration-<timestamp>.db`) and only continues startup once migration commits cleanly. If migration fails, the server logs the snapshot's path and exits — restore it with `cp` and investigate before retrying.
|
||||||
|
|
||||||
### Backups
|
### Backups
|
||||||
|
|
||||||
The SQLite database is at `server/db/remote_display.db`. Back it up regularly:
|
The SQLite database is at `server/db/remote_display.db` and uploaded content is in
|
||||||
|
`server/uploads/`. For a one-off DB copy (safe while the server runs):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Safe backup (works even while the server is running)
|
|
||||||
sqlite3 server/db/remote_display.db ".backup /path/to/backup.db"
|
sqlite3 server/db/remote_display.db ".backup /path/to/backup.db"
|
||||||
```
|
```
|
||||||
|
|
||||||
Uploaded content is in `server/uploads/`. Back that up too.
|
**Recommended: nightly automated backups** via `scripts/backup.sh`. It takes an
|
||||||
|
atomic DB snapshot plus a hard-linked, point-in-time copy of your content (durable
|
||||||
|
images/videos; ephemeral per-device screenshots are excluded), with daily + monthly
|
||||||
|
retention and an error log. Add a cron entry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# as root (or your service user) — adjust the path to your install
|
||||||
|
0 3 * * * /opt/screentinker/scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Override defaults with env vars if your layout differs:
|
||||||
|
`SCREENTINKER_DIR` (default `/opt/screentinker`), `BACKUP_DIR`, `DB`, `UPLOADS`,
|
||||||
|
`DAILY_KEEP` (7), `MONTHLY_KEEP` (12), `DB_KEEP_DAYS` (30). Backups land in
|
||||||
|
`$BACKUP_DIR` (`remote_display-<ts>.db`, `content-latest/`, `content-<ts>/`,
|
||||||
|
`content-monthly-<YYYYMM>/`) and each run appends to `$BACKUP_DIR/backup.log`.
|
||||||
|
|
||||||
### Admin Recovery
|
### Admin Recovery
|
||||||
|
|
||||||
|
|
@ -267,6 +403,13 @@ The APK will be at `android/app/build/outputs/apk/debug/app-debug.apk`. Copy it
|
||||||
cp android/app/build/outputs/apk/debug/app-debug.apk ScreenTinker.apk
|
cp android/app/build/outputs/apk/debug/app-debug.apk ScreenTinker.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Release builds & MDM signage (#81):** `./gradlew assembleRelease` is automatically
|
||||||
|
> re-signed to carry a **v1 (JAR) signature alongside v2/v3** (the `resignReleaseV1` task in
|
||||||
|
> `app/build.gradle.kts`). At `minSdk 26` the Gradle plugin omits v1, and some MDM-managed
|
||||||
|
> commercial displays (e.g. MAXHUB/Pivot) **strip a v2-only APK on reboot** — screens that
|
||||||
|
> power-cycle nightly then lose the app. v1+v2+v3 installs everywhere from API 19 to the
|
||||||
|
> latest Android. (`enableV1Signing = true` alone does not work at minSdk ≥ 24.)
|
||||||
|
|
||||||
To generate a new signing keystore:
|
To generate a new signing keystore:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -283,36 +426,91 @@ keytool -genkey -v -keystore android/release-key.jks -keyalg RSA -keysize 2048 -
|
||||||
- **Android TV / tablets**: Download the APK from your instance (`/download/apk`) or build it from source (see above)
|
- **Android TV / tablets**: Download the APK from your instance (`/download/apk`) or build it from source (see above)
|
||||||
- **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | bash`
|
- **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | bash`
|
||||||
- **Windows**: Run the setup script from `scripts/windows-setup.bat`
|
- **Windows**: Run the setup script from `scripts/windows-setup.bat`
|
||||||
|
- **Samsung Tizen TV / signage**: point the TV's URL Launcher (or browser) at `https://your-instance/player` - no signing needed. For an installed native app, see [tizen/README.md](tizen/README.md)
|
||||||
- **Any browser**: Open `https://your-instance/player` in kiosk/fullscreen mode
|
- **Any browser**: Open `https://your-instance/player` in kiosk/fullscreen mode
|
||||||
4. Enter the pairing code shown on the device
|
4. Enter the pairing code shown on the device
|
||||||
|
|
||||||
|
> **Troubleshooting a player** (stuck on "Connecting to server", re-pointing a
|
||||||
|
> device to a different server, or connecting adb over Wi-Fi): see
|
||||||
|
> [docs/android-troubleshooting.md](docs/android-troubleshooting.md).
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
Working on ScreenTinker itself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/screentinker/screentinker.git
|
||||||
|
cd screentinker/server
|
||||||
|
npm install
|
||||||
|
npm start # starts in dev with --env-file-if-exists=.env
|
||||||
|
# or:
|
||||||
|
npm run dev # same as start, plus --watch for auto-restart
|
||||||
|
```
|
||||||
|
|
||||||
|
**`.env` file (gitignored):** create `server/.env` for local configuration. Anything documented in the env var tables above works. Common starting set:
|
||||||
|
|
||||||
|
```
|
||||||
|
SELF_HOSTED=true
|
||||||
|
APP_URL=https://localhost:3443
|
||||||
|
# Optional: Microsoft Graph email config for testing real delivery
|
||||||
|
# GRAPH_TENANT_ID=...
|
||||||
|
# GRAPH_CLIENT_ID=...
|
||||||
|
# GRAPH_CLIENT_SECRET=...
|
||||||
|
# GRAPH_SENDER_EMAIL=you@yourcompany.com
|
||||||
|
# Optional: dev safety - only let these recipient emails through to Graph
|
||||||
|
# GRAPH_DEV_RESTRICT_TO=you@yourcompany.com,colleague@yourcompany.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**No M365 access?** That's fine. With `GRAPH_*` env vars unset, `sendEmail()` short-circuits and logs `[EMAIL] not configured - would send to ...` to stdout. Everything else runs normally; only outbound email is suppressed. Useful for backend work that touches the email path without setting up an Azure app.
|
||||||
|
|
||||||
|
**Running against a fresh prod DB clone?** Set `GRAPH_DEV_RESTRICT_TO=your-email@example.com` to keep accidental sends from reaching real users in the cloned database. Sends to anyone outside the list are logged but never posted to Graph.
|
||||||
|
|
||||||
|
**Reporting issues:** [GitHub Issues](https://github.com/screentinker/screentinker/issues) for bugs and feature requests, or drop into [Discord](https://discord.gg/utTdsrqq4Z) for quick questions and feedback.
|
||||||
|
|
||||||
|
**Contributions welcome.** Fork → branch → PR. There are no formal style guides yet beyond what you can pick up from reading the existing code. Tests aren't required but smoke-test against your local server before opening a PR.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
server/ Node.js/Express backend
|
server/ Node.js/Express backend
|
||||||
config.js Configuration and environment variables
|
config.js Configuration and environment variables
|
||||||
server.js Main entry point
|
server.js Main entry point
|
||||||
db/ SQLite database and schema
|
db/ SQLite database, schema, and migrations
|
||||||
routes/ API route handlers
|
routes/ API route handlers (devices, playlists, groups, schedules, etc.)
|
||||||
middleware/ Auth, rate limiting, file upload
|
middleware/ Auth (JWT + device tokens), rate limiting, file upload, sanitization
|
||||||
services/ Background services (heartbeat, scheduler, alerts)
|
services/ Background services (heartbeat, scheduler, alerts, activity logging)
|
||||||
ws/ WebSocket handlers (device + dashboard)
|
ws/ WebSocket handlers (device namespace + dashboard namespace)
|
||||||
player/ Web-based display player
|
player/ Web-based display player
|
||||||
frontend/ Static SPA dashboard
|
frontend/ Static SPA dashboard
|
||||||
js/views/ View components
|
js/views/ View components (dashboard, playlists, groups, schedules, etc.)
|
||||||
|
js/utils.js Shared utilities (HTML escaping)
|
||||||
css/ Stylesheets
|
css/ Stylesheets
|
||||||
legal/ Terms, privacy, licenses
|
legal/ Terms, privacy, licenses
|
||||||
android/ Android TV/tablet player app
|
android/ Android TV/tablet player app (Kotlin, ExoPlayer)
|
||||||
scripts/ Device setup scripts + admin recovery
|
scripts/ Device setup scripts + admin recovery
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend:** Node.js, Express, Socket.IO, SQLite (better-sqlite3)
|
- **Backend:** Node.js 20.6+, Express, Socket.IO, SQLite (better-sqlite3)
|
||||||
- **Frontend:** Vanilla JS SPA (no framework, no build step)
|
- **Frontend:** Vanilla JS SPA (no framework, no build step), ES modules, Service Worker for offline support
|
||||||
- **Android:** Kotlin, ExoPlayer, Socket.IO client
|
- **Android:** Kotlin, ExoPlayer, Socket.IO client
|
||||||
- **Auth:** JWT with bcrypt, Google/Microsoft OAuth (optional)
|
- **Auth:** JWT with bcrypt, Google/Microsoft OAuth (optional)
|
||||||
|
- **Email:** Microsoft Graph via `@azure/msal-node` client-credentials (optional)
|
||||||
- **Payments:** Stripe (optional)
|
- **Payments:** Stripe (optional)
|
||||||
|
- **Data model:** multi-tenant — organizations contain workspaces contain resources; six-level role hierarchy gated server-side at every API route
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
ScreenTinker is built and maintained by one developer. If the project is useful to you and you want to support continued development:
|
||||||
|
|
||||||
|
- **[Donate via Wise](https://wise.com/pay/business/bytetinkerllc?utm_source=quick_pay)** — directly help fund continued development (ByteTinker LLC)
|
||||||
|
- Star the repo on GitHub
|
||||||
|
- Open [issues](https://github.com/screentinker/screentinker/issues) with feedback or bug reports
|
||||||
|
- Drop into the [Discord](https://discord.gg/utTdsrqq4Z) and say hi
|
||||||
|
- Contribute back if you've extended something useful
|
||||||
|
|
||||||
|
GitHub Sponsors integration is also planned. Direct contact: [dan@bytetinker.net](mailto:dan@bytetinker.net) or via Discord.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
64
RELEASING.md
Normal file
64
RELEASING.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Releasing ScreenTinker
|
||||||
|
|
||||||
|
`VERSION` (repo root) is the single source of truth the server reports at runtime.
|
||||||
|
Cutting a release is three steps.
|
||||||
|
|
||||||
|
## 1. Bump + tag
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/bump-version.sh X.Y.Z # or: major | minor | patch
|
||||||
|
```
|
||||||
|
|
||||||
|
Syncs `VERSION`, `server/package.json` (+lockfile), the android `versionName` /
|
||||||
|
`versionCode`, and the tizen widget version in one commit, then creates an
|
||||||
|
annotated tag `vX.Y.Z`. It does NOT push - it prints the push command. Requires a
|
||||||
|
clean working tree.
|
||||||
|
|
||||||
|
## 2. Push (this publishes the release)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main && git push origin vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
Pushing the tag fires `.github/workflows/release.yml`:
|
||||||
|
|
||||||
|
- **verify** - refuses to publish if the tag does not match `VERSION`.
|
||||||
|
- **test** - the unit suite.
|
||||||
|
- **artifacts** - builds the source tarball (bundling the unsigned Tizen `.wgt`)
|
||||||
|
and creates the GitHub Release with generated notes.
|
||||||
|
- **docker** - builds a multi-arch (amd64 + arm64) image and pushes
|
||||||
|
`ghcr.io/screentinker/screentinker:X.Y.Z` and `:latest`.
|
||||||
|
|
||||||
|
`artifacts` and `docker` are independent jobs: a docker (arm64/QEMU) failure does
|
||||||
|
not block the GitHub Release and can be re-run on its own. Nothing here deploys to
|
||||||
|
production.
|
||||||
|
|
||||||
|
## 3. Finalize (adds the signed APK)
|
||||||
|
|
||||||
|
The Android signing keystore stays off CI, so the signed apk and the complete
|
||||||
|
(apk + wgt) tarball are assembled locally, then uploaded to the release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEYSTORE_PASSWORD=... KEY_PASSWORD=... scripts/finalize-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It builds the signed APK, pulls the CI-built unsigned `.wgt` back from the
|
||||||
|
release, assembles a complete tarball (source + `ScreenTinker.apk` +
|
||||||
|
`ScreenTinker.wgt` at the root, where `/download/apk` resolves the apk after
|
||||||
|
extraction), and uploads the apk + complete tarball.
|
||||||
|
|
||||||
|
## What a release contains
|
||||||
|
|
||||||
|
Each release carries these as standalone assets AND bundled in the tarball:
|
||||||
|
|
||||||
|
- `screentinker-X.Y.Z.tar.gz` - server + frontend source + apk + wgt at the root
|
||||||
|
- `ScreenTinker.apk` - signed Android player
|
||||||
|
- `ScreenTinker.wgt` - Tizen TV web app (unsigned; see [tizen/README.md](tizen/README.md))
|
||||||
|
- `ghcr.io/screentinker/screentinker:X.Y.Z` + `:latest` - Docker image
|
||||||
|
|
||||||
|
## One-time / occasional
|
||||||
|
|
||||||
|
- **ghcr visibility:** new packages default to private. Set the package Public
|
||||||
|
once (Repo -> Packages -> `screentinker` -> Package settings -> Change
|
||||||
|
visibility -> Public) so anonymous `docker pull` works.
|
||||||
|
- **Self-hosters upgrade** with `scripts/upgrade.sh [vX.Y.Z]` (see the README).
|
||||||
97
SECURITY.md
Normal file
97
SECURITY.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
Thanks for taking the time to look at ScreenTinker's security. The project
|
||||||
|
is a one-person open-source effort, so response times reflect that — but
|
||||||
|
reports are taken seriously and handled in good faith.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Primary channel — GitHub Security Advisories (preferred):**
|
||||||
|
|
||||||
|
[github.com/screentinker/screentinker/security/advisories/new](https://github.com/screentinker/screentinker/security/advisories/new)
|
||||||
|
|
||||||
|
GitHub's private advisory flow keeps the report off public issues, lets us
|
||||||
|
draft a fix collaboratively, and produces a CVE if appropriate. Use this
|
||||||
|
unless you have a reason not to.
|
||||||
|
|
||||||
|
**Fallback — email:**
|
||||||
|
|
||||||
|
`support@bytetinker.net` (the maintainer's consultancy inbox; the domain
|
||||||
|
intentionally differs from `screentinker.com` — it's the actively-monitored
|
||||||
|
business address rather than a project-domain alias that might not have
|
||||||
|
working mail delivery).
|
||||||
|
|
||||||
|
Please include:
|
||||||
|
- A description of the issue and its impact
|
||||||
|
- Steps to reproduce (the more concrete, the better)
|
||||||
|
- The commit SHA or release tag you observed it on
|
||||||
|
- Any proof-of-concept code or payload, if you have one
|
||||||
|
|
||||||
|
## Response timeline
|
||||||
|
|
||||||
|
I aim to acknowledge reports within **3–5 business days** and update with a
|
||||||
|
triage assessment within **10 business days**. If you haven't heard back
|
||||||
|
in that window, please feel free to nudge — life happens, and reports
|
||||||
|
occasionally slip past.
|
||||||
|
|
||||||
|
Fix timelines depend on severity, complexity, and whether the issue is on
|
||||||
|
the hosted instance (screentinker.com) or affects self-hosted deployments
|
||||||
|
too. Critical issues affecting the hosted instance generally get same-week
|
||||||
|
turnaround.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
Reports about the following are welcome and treated as security issues:
|
||||||
|
|
||||||
|
- **Authentication / session bypass** (e.g. JWT forgery, login bypass,
|
||||||
|
privilege escalation)
|
||||||
|
- **Multi-tenancy boundary violations** (one workspace's data leaking into
|
||||||
|
another, organization-level isolation breaks)
|
||||||
|
- **XSS in widget rendering or admin UI** (e.g. unsandboxed widget content,
|
||||||
|
unescaped user input in dashboard surfaces)
|
||||||
|
- **CSRF** on state-changing endpoints
|
||||||
|
- **SQL injection** (deviations from parameterized queries are reportable)
|
||||||
|
- **Server-side request forgery** (SSRF) via widget URLs, content uploads,
|
||||||
|
webhook handlers, or similar
|
||||||
|
- **Insecure direct object reference** (accessing a resource by ID without
|
||||||
|
the proper tenancy gate)
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
The following are acknowledged but not treated as in-scope security
|
||||||
|
issues for this project:
|
||||||
|
|
||||||
|
- **Denial of service via excessive resource usage** (uploading large
|
||||||
|
files, opening many sockets, etc.) — operational concerns, not security
|
||||||
|
vulnerabilities. Rate limits exist where it matters most.
|
||||||
|
- **Social engineering** of the maintainer or other users
|
||||||
|
- **Misconfigurations of self-hosted instances** (e.g. exposing the server
|
||||||
|
to the internet without TLS, weak JWT secrets, default passwords). The
|
||||||
|
README documents recommended configuration; deviations are the operator's
|
||||||
|
responsibility.
|
||||||
|
- **Vulnerabilities in third-party dependencies** (Express, better-sqlite3,
|
||||||
|
socket.io, etc.) — please report those upstream. If a dependency CVE
|
||||||
|
affects ScreenTinker in a non-obvious way, that's worth flagging here too.
|
||||||
|
- **Reports generated by automated scanners** with no manual triage or
|
||||||
|
proof-of-concept (e.g. "your /robots.txt is missing" — not what this
|
||||||
|
project worries about)
|
||||||
|
|
||||||
|
## Coordinated disclosure
|
||||||
|
|
||||||
|
Please **wait until a fix has shipped to the hosted instance and
|
||||||
|
origin/main before public disclosure**. I'll keep you in the loop on
|
||||||
|
timing and confirm when it's safe to publish. For most issues that
|
||||||
|
window is a few weeks at most; if it stretches longer, that's a signal
|
||||||
|
something is more complex than expected and we'll coordinate.
|
||||||
|
|
||||||
|
If you find a critical issue that's being actively exploited (or you
|
||||||
|
believe might be), please say so in the report — I'll prioritize
|
||||||
|
accordingly.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
If you'd like to be credited for a report, I'm happy to acknowledge you
|
||||||
|
by name in release notes and (when applicable) in the GitHub advisory
|
||||||
|
itself. Let me know in your report whether you'd like credit and how
|
||||||
|
you'd like to be named. Anonymous reports are also welcome — no credit
|
||||||
|
is required.
|
||||||
|
|
@ -11,8 +11,8 @@ android {
|
||||||
applicationId = "com.remotedisplay.player"
|
applicationId = "com.remotedisplay.player"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 10
|
versionCode = 21
|
||||||
versionName = "1.7.7"
|
versionName = "1.9.1-beta1"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
@ -21,6 +21,9 @@ android {
|
||||||
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: findProperty("KEYSTORE_PASSWORD") as String? ?: ""
|
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: findProperty("KEYSTORE_PASSWORD") as String? ?: ""
|
||||||
keyAlias = System.getenv("KEY_ALIAS") ?: findProperty("KEY_ALIAS") as String? ?: "remotedisplay"
|
keyAlias = System.getenv("KEY_ALIAS") ?: findProperty("KEY_ALIAS") as String? ?: "remotedisplay"
|
||||||
keyPassword = System.getenv("KEY_PASSWORD") ?: findProperty("KEY_PASSWORD") as String? ?: ""
|
keyPassword = System.getenv("KEY_PASSWORD") ?: findProperty("KEY_PASSWORD") as String? ?: ""
|
||||||
|
// #81: AGP ignores enableV1Signing at minSdk>=24, so assembleRelease emits a
|
||||||
|
// v2-only APK. The v1 (JAR) signature that some MDM-managed signage (MAXHUB)
|
||||||
|
// requires is added by the `resignReleaseV1` task below (apksigner re-sign).
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,4 +78,50 @@ dependencies {
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
|
||||||
|
// #74/#75: unit tests for the Kotlin schedule evaluator (vector drift guard)
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #74/#75: point the evaluator drift-guard test at the SHARED vector contract
|
||||||
|
// (shared/schedule-vectors.json, the single source - no snapshot). rootProject is
|
||||||
|
// the android/ Gradle root; its parent is the repo root. Any ScheduleEval.kt edit
|
||||||
|
// that breaks a vector fails ScheduleEvalTest in CI.
|
||||||
|
tasks.withType<Test> {
|
||||||
|
systemProperty("scheduleVectors", File(rootProject.projectDir.parentFile, "shared/schedule-vectors.json").absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #81: AGP ignores enableV1Signing at minSdk>=24, so `assembleRelease` produces a
|
||||||
|
// v2-only APK - and some MDM-managed signage (MAXHUB/Pivot) silently removes a v2-only
|
||||||
|
// app on the next reboot because its boot integrity check expects a v1 (JAR) signature.
|
||||||
|
// Re-sign the assembled release APK with apksigner, forcing a low --min-sdk-version so
|
||||||
|
// the v1 signature is emitted alongside v2/v3. v1+v2+v3 verifies on every Android
|
||||||
|
// version (legacy MDM hardware via v1, modern Android via v2/v3).
|
||||||
|
tasks.register<Exec>("resignReleaseV1") {
|
||||||
|
val apk = layout.buildDirectory.file("outputs/apk/release/app-release.apk").get().asFile
|
||||||
|
onlyIf { apk.exists() }
|
||||||
|
doFirst {
|
||||||
|
val sdkDir = System.getenv("ANDROID_HOME")
|
||||||
|
?: System.getenv("ANDROID_SDK_ROOT")
|
||||||
|
?: rootProject.file("local.properties").takeIf { it.exists() }
|
||||||
|
?.readLines()?.firstOrNull { it.startsWith("sdk.dir=") }?.substringAfter("=")?.trim()
|
||||||
|
?: throw GradleException("#81 resign: set ANDROID_HOME or sdk.dir in local.properties")
|
||||||
|
val buildTools = File(sdkDir, "build-tools").listFiles()
|
||||||
|
?.filter { it.isDirectory }?.maxByOrNull { it.name }
|
||||||
|
?: throw GradleException("#81 resign: no build-tools found under $sdkDir")
|
||||||
|
commandLine(
|
||||||
|
File(buildTools, "apksigner").absolutePath, "sign",
|
||||||
|
"--ks", file("../release-key.jks").absolutePath,
|
||||||
|
"--ks-key-alias", (System.getenv("KEY_ALIAS") ?: "remotedisplay"),
|
||||||
|
"--ks-pass", "pass:" + (System.getenv("KEYSTORE_PASSWORD") ?: ""),
|
||||||
|
"--key-pass", "pass:" + (System.getenv("KEY_PASSWORD") ?: ""),
|
||||||
|
"--v1-signing-enabled", "true",
|
||||||
|
"--v2-signing-enabled", "true",
|
||||||
|
"--v3-signing-enabled", "true",
|
||||||
|
"--min-sdk-version", "19",
|
||||||
|
apk.absolutePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// AGP registers assembleRelease lazily, so match it when/after it's created.
|
||||||
|
tasks.matching { it.name == "assembleRelease" }.configureEach { finalizedBy("resignReleaseV1") }
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,15 @@
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".RemoteDisplayApp"
|
android:name=".RemoteDisplayApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:icon="@android:drawable/ic_media_play"
|
android:icon="@android:drawable/ic_media_play"
|
||||||
android:label="RemoteDisplay"
|
android:label="RemoteDisplay"
|
||||||
|
android:largeHeap="true"
|
||||||
android:theme="@style/Theme.RemoteDisplay"
|
android:theme="@style/Theme.RemoteDisplay"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true">
|
||||||
|
|
@ -66,11 +69,21 @@
|
||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
android:theme="@style/Theme.RemoteDisplay.Fullscreen" />
|
android:theme="@style/Theme.RemoteDisplay.Fullscreen" />
|
||||||
|
|
||||||
<!-- WebSocket foreground service -->
|
<!-- WebSocket foreground service. #5: declares ONLY mediaPlayback - the
|
||||||
|
always-on service must not claim the mediaProjection FGS type, which
|
||||||
|
Android 14+ rejects unless a projection consent token is held. -->
|
||||||
<service
|
<service
|
||||||
android:name=".service.WebSocketService"
|
android:name=".service.WebSocketService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaPlayback|mediaProjection" />
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
|
||||||
|
<!-- #5: dedicated MediaProjection foreground service for system screen
|
||||||
|
capture. Started only after the user grants consent, so claiming the
|
||||||
|
mediaProjection FGS type is valid on Android 14+. -->
|
||||||
|
<service
|
||||||
|
android:name=".service.MediaProjectionService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaProjection" />
|
||||||
|
|
||||||
<!-- Accessibility service for power controls -->
|
<!-- Accessibility service for power controls -->
|
||||||
<service
|
<service
|
||||||
|
|
@ -97,6 +110,18 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- #96: relaunch the player after a self-update (OTA). MY_PACKAGE_REPLACED is
|
||||||
|
delivered to the freshly-installed app; the receiver relaunches via the same
|
||||||
|
cascade as boot so the screen doesn't drop to the launcher after an update. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".service.PackageReplacedReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- FileProvider for APK updates -->
|
<!-- FileProvider for APK updates -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
private lateinit var statusOverlay: View
|
private lateinit var statusOverlay: View
|
||||||
private lateinit var statusText: TextView
|
private lateinit var statusText: TextView
|
||||||
private lateinit var rootView: View
|
private lateinit var rootView: View
|
||||||
|
private var currentOrientation: String? = null
|
||||||
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var remoteStreaming = false
|
private var remoteStreaming = false
|
||||||
|
|
@ -100,6 +101,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
// The display is up now — clear the boot "Starting display…" notification.
|
||||||
|
(getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager)?.cancel(999)
|
||||||
|
|
||||||
// Fullscreen immersive
|
// Fullscreen immersive
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
window.decorView.systemUiVisibility = (
|
window.decorView.systemUiVisibility = (
|
||||||
|
|
@ -133,8 +137,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
// Setup playlist controller
|
// Setup playlist controller
|
||||||
playlistController = PlaylistController(
|
playlistController = PlaylistController(
|
||||||
onItemChanged = { item -> item?.let { playItem(it) } },
|
onItemChanged = { item -> item?.let { playItem(it) } },
|
||||||
onPlaylistEmpty = { showStatus("Waiting for content...") },
|
// #74/#75: clear the last frame when going idle (else a now-filtered item lingers on screen)
|
||||||
onRequestRefresh = { wsService?.requestPlaylistRefresh() }
|
onPlaylistEmpty = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) },
|
||||||
|
onRequestRefresh = { wsService?.requestPlaylistRefresh() },
|
||||||
|
onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Setup media player
|
// Setup media player
|
||||||
|
|
@ -144,10 +150,38 @@ class MainActivity : AppCompatActivity() {
|
||||||
playerView = playerView,
|
playerView = playerView,
|
||||||
imageView = imageView,
|
imageView = imageView,
|
||||||
youtubeWebView = youtubeWebView,
|
youtubeWebView = youtubeWebView,
|
||||||
onVideoComplete = { playlistController.onVideoComplete() }
|
onVideoComplete = { playlistController.onVideoComplete() },
|
||||||
|
onImageError = {
|
||||||
|
Log.w("MainActivity", "Image failed to load, skipping to next item")
|
||||||
|
handler.postDelayed({ playlistController.next() }, 500)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Restore cached playlist for offline cold-start (play immediately from disk cache).
|
||||||
|
// Catch Throwable (not just Exception) so an OOM or corrupt entry can't kill the app
|
||||||
|
// before the WebSocket connects — that's the crash-loop scenario. If the cache is
|
||||||
|
// unusable for any reason, drop it and continue; the server will resend on connect.
|
||||||
|
val cachedJson = config.cachedPlaylist
|
||||||
|
if (cachedJson.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val cached = JSONObject(cachedJson)
|
||||||
|
val assignments = cached.getJSONArray("assignments")
|
||||||
|
if (assignments.length() > 0) {
|
||||||
|
Log.i("MainActivity", "Restoring cached playlist: ${assignments.length()} items")
|
||||||
|
// #74/#75: restore the cached effective timezone too (offline schedules)
|
||||||
|
playlistController.setTimezone(if (cached.isNull("timezone")) null else cached.optString("timezone", "").ifEmpty { null })
|
||||||
|
playlistController.updatePlaylist(assignments)
|
||||||
|
playlistController.startIfNeeded()
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w("MainActivity", "Failed to restore cached playlist, clearing cache: ${e.message}")
|
||||||
|
try { config.clearPlaylistCache() } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playlistController.isPlaying) {
|
||||||
showStatus("Connecting to server...")
|
showStatus("Connecting to server...")
|
||||||
|
}
|
||||||
|
|
||||||
// Start and bind to WebSocket service
|
// Start and bind to WebSocket service
|
||||||
try {
|
try {
|
||||||
|
|
@ -169,9 +203,40 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rotate the whole stage in software so portrait / flipped signage works even on
|
||||||
|
// fixed-landscape hardware (Fire TV, Android TV and most signage sticks ignore
|
||||||
|
// setRequestedOrientation - they can't physically rotate the panel). Resizes
|
||||||
|
// rootView to the rotated dimensions, recenters, and rotates. Covers single-zone
|
||||||
|
// (playerView/imageView/youtubeWebView) and multi-zone (ZoneManager renders into
|
||||||
|
// the same rootView). Values mirror the dashboard: landscape / portrait /
|
||||||
|
// landscape-flipped / portrait-flipped.
|
||||||
|
private fun applyOrientation(orientation: String) {
|
||||||
|
if (orientation == currentOrientation) return
|
||||||
|
currentOrientation = orientation
|
||||||
|
val m = resources.displayMetrics
|
||||||
|
val w = m.widthPixels.toFloat()
|
||||||
|
val h = m.heightPixels.toFloat()
|
||||||
|
val (rot, swap) = when (orientation) {
|
||||||
|
"portrait" -> 90f to true
|
||||||
|
"portrait-flipped" -> 270f to true
|
||||||
|
"landscape-flipped" -> 180f to false
|
||||||
|
else -> 0f to false // landscape
|
||||||
|
}
|
||||||
|
val lp = rootView.layoutParams
|
||||||
|
lp.width = (if (swap) h else w).toInt()
|
||||||
|
lp.height = (if (swap) w else h).toInt()
|
||||||
|
rootView.layoutParams = lp
|
||||||
|
rootView.translationX = if (swap) (w - h) / 2f else 0f
|
||||||
|
rootView.translationY = if (swap) (h - w) / 2f else 0f
|
||||||
|
rootView.rotation = rot
|
||||||
|
rootView.requestLayout()
|
||||||
|
Log.i("MainActivity", "Applied orientation: $orientation (rotation=$rot, swap=$swap)")
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupServiceCallbacks() {
|
private fun setupServiceCallbacks() {
|
||||||
wsService?.onPlaylistUpdate = { data ->
|
wsService?.onPlaylistUpdate = { data ->
|
||||||
try {
|
try {
|
||||||
|
applyOrientation(data.optString("orientation", "landscape"))
|
||||||
// Check if device is suspended (trial expired / over limit)
|
// Check if device is suspended (trial expired / over limit)
|
||||||
if (data.optBoolean("suspended", false)) {
|
if (data.optBoolean("suspended", false)) {
|
||||||
val message = data.optString("message", "Account Suspended")
|
val message = data.optString("message", "Account Suspended")
|
||||||
|
|
@ -184,6 +249,14 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
val assignments = data.getJSONArray("assignments")
|
val assignments = data.getJSONArray("assignments")
|
||||||
|
|
||||||
|
// #74/#75: device-effective IANA timezone for per-item schedule evaluation
|
||||||
|
val effectiveTz = if (data.isNull("timezone")) null else data.optString("timezone", "").ifEmpty { null }
|
||||||
|
playlistController.setTimezone(effectiveTz)
|
||||||
|
zoneManager?.setTimezone(effectiveTz)
|
||||||
|
|
||||||
|
// Cache playlist JSON for offline cold-start
|
||||||
|
config.cachedPlaylist = data.toString()
|
||||||
|
|
||||||
// Check for multi-zone layout
|
// Check for multi-zone layout
|
||||||
val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout")
|
val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout")
|
||||||
val layoutZones = layoutObj?.optJSONArray("zones")
|
val layoutZones = layoutObj?.optJSONArray("zones")
|
||||||
|
|
@ -200,6 +273,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
}.sorted().joinToString("|")
|
}.sorted().joinToString("|")
|
||||||
val changed = assignmentSig != zoneManager?.lastAssignmentSig
|
val changed = assignmentSig != zoneManager?.lastAssignmentSig
|
||||||
|
|
||||||
|
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: MULTI-ZONE (${layoutZones.length()} zones, layout=$layoutId), ${assignments.length()} assignments")
|
||||||
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) {
|
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) {
|
||||||
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
|
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
|
||||||
handler.post {
|
handler.post {
|
||||||
|
|
@ -223,6 +297,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single-zone mode - use PlaylistController (existing behavior)
|
// Single-zone mode - use PlaylistController (existing behavior)
|
||||||
|
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: SINGLE/FULLSCREEN (${layoutZones?.length() ?: 0} zones), ${assignments.length()} assignments")
|
||||||
if (zoneManager?.hasZones() == true) handler.post { zoneManager?.cleanup() }
|
if (zoneManager?.hasZones() == true) handler.post { zoneManager?.cleanup() }
|
||||||
playlistController.updatePlaylist(assignments)
|
playlistController.updatePlaylist(assignments)
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +306,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
thread {
|
thread {
|
||||||
for (i in 0 until assignments.length()) {
|
for (i in 0 until assignments.length()) {
|
||||||
val item = assignments.getJSONObject(i)
|
val item = assignments.getJSONObject(i)
|
||||||
val contentId = item.getString("content_id")
|
// Widget assignments have no downloadable content file - skip
|
||||||
|
// (also avoids getString throwing on a null content_id).
|
||||||
|
val widgetId = if (item.isNull("widget_id")) "" else item.optString("widget_id", "")
|
||||||
|
if (widgetId.isNotEmpty()) continue
|
||||||
|
val contentId = if (item.isNull("content_id")) "" else item.optString("content_id", "")
|
||||||
|
if (contentId.isEmpty()) continue
|
||||||
val filename = item.optString("filename", "content")
|
val filename = item.optString("filename", "content")
|
||||||
val remoteUrl = item.optString("remote_url", null)
|
val remoteUrl = item.optString("remote_url", null)
|
||||||
|
|
||||||
|
|
@ -258,9 +338,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start or resume playback after downloads complete
|
// Start or resume playback after downloads complete — but ONLY in
|
||||||
|
// single-zone/fullscreen mode. In multi-zone, ZoneManager drives each
|
||||||
|
// zone; restarting the fullscreen controller here made it keep playing
|
||||||
|
// items behind the zones (wasted work + phantom audio for videos).
|
||||||
handler.post {
|
handler.post {
|
||||||
playlistController.startIfNeeded()
|
if (zoneManager?.hasZones() != true) playlistController.startIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // end else (not suspended)
|
} // end else (not suspended)
|
||||||
|
|
@ -272,6 +355,20 @@ class MainActivity : AppCompatActivity() {
|
||||||
wsService?.onContentDelete = { contentId ->
|
wsService?.onContentDelete = { contentId ->
|
||||||
contentCache.deleteContent(contentId)
|
contentCache.deleteContent(contentId)
|
||||||
playlistController.removeContent(contentId)
|
playlistController.removeContent(contentId)
|
||||||
|
// Update cached playlist to reflect deletion
|
||||||
|
try {
|
||||||
|
val cached = JSONObject(config.cachedPlaylist)
|
||||||
|
val arr = cached.optJSONArray("assignments")
|
||||||
|
if (arr != null) {
|
||||||
|
val filtered = org.json.JSONArray()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val item = arr.getJSONObject(i)
|
||||||
|
if (item.optString("content_id") != contentId) filtered.put(item)
|
||||||
|
}
|
||||||
|
cached.put("assignments", filtered)
|
||||||
|
config.cachedPlaylist = cached.toString()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
wsService?.onScreenshotRequest = {
|
wsService?.onScreenshotRequest = {
|
||||||
|
|
@ -360,6 +457,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
wsService?.onUnpaired = {
|
wsService?.onUnpaired = {
|
||||||
Log.w("MainActivity", "Device removed from server, going to provisioning")
|
Log.w("MainActivity", "Device removed from server, going to provisioning")
|
||||||
|
config.clearPlaylistCache()
|
||||||
handler.post {
|
handler.post {
|
||||||
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
|
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
@ -371,6 +469,18 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private fun playItem(item: PlaylistItem) {
|
private fun playItem(item: PlaylistItem) {
|
||||||
hideStatus()
|
hideStatus()
|
||||||
|
com.remotedisplay.player.util.DebugLog.i("Player", "playItem: ${item.filename} mime=${item.mimeType} widget=${item.widgetId ?: "-"} zone=fullscreen")
|
||||||
|
|
||||||
|
// Widget content - render fullscreen in a WebView (single-zone / fullscreen
|
||||||
|
// layouts; multi-zone widgets go through ZoneManager). Previously unhandled,
|
||||||
|
// so widgets were blank/broken in default-fullscreen and the fullscreen template.
|
||||||
|
if (item.isWidget) {
|
||||||
|
val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render"
|
||||||
|
Log.i("MainActivity", "Playing widget fullscreen: $url")
|
||||||
|
mediaPlayer.showWidget(url)
|
||||||
|
wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// YouTube content - play in WebView
|
// YouTube content - play in WebView
|
||||||
if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) {
|
if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class ProvisioningActivity : AppCompatActivity() {
|
||||||
private lateinit var statusText: TextView
|
private lateinit var statusText: TextView
|
||||||
private lateinit var progressBar: ProgressBar
|
private lateinit var progressBar: ProgressBar
|
||||||
private lateinit var pairingSection: View
|
private lateinit var pairingSection: View
|
||||||
|
private lateinit var serverSection: View
|
||||||
|
|
||||||
private val connection = object : ServiceConnection {
|
private val connection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
|
@ -73,6 +74,7 @@ class ProvisioningActivity : AppCompatActivity() {
|
||||||
statusText = findViewById(R.id.statusText)
|
statusText = findViewById(R.id.statusText)
|
||||||
progressBar = findViewById(R.id.progressBar)
|
progressBar = findViewById(R.id.progressBar)
|
||||||
pairingSection = findViewById(R.id.pairingSection)
|
pairingSection = findViewById(R.id.pairingSection)
|
||||||
|
serverSection = findViewById(R.id.serverSection)
|
||||||
|
|
||||||
// Pre-fill if previously entered
|
// Pre-fill if previously entered
|
||||||
if (config.serverUrl.isNotEmpty()) {
|
if (config.serverUrl.isNotEmpty()) {
|
||||||
|
|
@ -135,9 +137,15 @@ class ProvisioningActivity : AppCompatActivity() {
|
||||||
wsService?.onRegistered = { deviceId ->
|
wsService?.onRegistered = { deviceId ->
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
progressBar.visibility = View.GONE
|
progressBar.visibility = View.GONE
|
||||||
|
// Hide the server/connect controls so the pairing code has the
|
||||||
|
// whole screen and stays visible on short/landscape phones.
|
||||||
|
serverSection.visibility = View.GONE
|
||||||
|
connectBtn.visibility = View.GONE
|
||||||
pairingSection.visibility = View.VISIBLE
|
pairingSection.visibility = View.VISIBLE
|
||||||
pairingCodeText.text = wsService?.getPairingCode() ?: "------"
|
pairingCodeText.text = wsService?.getPairingCode() ?: "------"
|
||||||
statusText.text = "Enter this code in the dashboard to pair this display"
|
// The instruction is shown once, inside the pairing section; don't
|
||||||
|
// duplicate it in statusText.
|
||||||
|
statusText.text = ""
|
||||||
connectBtn.isEnabled = false
|
connectBtn.isEnabled = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ class RemoteDisplayApp : Application() {
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "remote_display_service"
|
const val CHANNEL_ID = "remote_display_service"
|
||||||
const val CHANNEL_NAME = "ScreenTinker Service"
|
const val CHANNEL_NAME = "ScreenTinker Service"
|
||||||
|
// Separate HIGH-importance channel for the boot full-screen-intent launch.
|
||||||
|
// A full-screen intent is only honored from a high-importance channel.
|
||||||
|
const val BOOT_CHANNEL_ID = "remote_display_boot"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
|
@ -19,16 +22,19 @@ class RemoteDisplayApp : Application() {
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
CHANNEL_ID,
|
manager.createNotificationChannel(
|
||||||
CHANNEL_NAME,
|
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
).apply {
|
|
||||||
description = "ScreenTinker background service"
|
description = "ScreenTinker background service"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
}
|
}
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
)
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(BOOT_CHANNEL_ID, "ScreenTinker Startup", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||||
|
description = "Launches the display on boot"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import android.content.Intent
|
||||||
import android.media.projection.MediaProjectionManager
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.remotedisplay.player.service.ScreenCaptureService
|
import com.remotedisplay.player.service.MediaProjectionService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transparent activity that requests MediaProjection permission.
|
* Transparent activity that requests MediaProjection permission.
|
||||||
|
|
@ -50,8 +50,11 @@ class ScreenCapturePermissionActivity : Activity() {
|
||||||
Companion.resultData = data?.clone() as? Intent
|
Companion.resultData = data?.clone() as? Intent
|
||||||
Companion.hasPermission = true
|
Companion.hasPermission = true
|
||||||
|
|
||||||
// Tell the service to start the projection
|
// #5: hand the consent to the dedicated mediaProjection foreground
|
||||||
ScreenCaptureService.startProjection(this, resultCode, data)
|
// service. It must enter the foreground with the mediaProjection FGS
|
||||||
|
// type BEFORE getMediaProjection() on Android 14+ - an Activity can't
|
||||||
|
// do that, so we can't call getMediaProjection() directly here.
|
||||||
|
MediaProjectionService.start(this, resultCode, data)
|
||||||
|
|
||||||
getSharedPreferences("remote_display", MODE_PRIVATE)
|
getSharedPreferences("remote_display", MODE_PRIVATE)
|
||||||
.edit().putBoolean("screen_capture_granted", true).apply()
|
.edit().putBoolean("screen_capture_granted", true).apply()
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ package com.remotedisplay.player
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.accessibilityservice.AccessibilityServiceInfo
|
import android.accessibilityservice.AccessibilityServiceInfo
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.NotificationManager
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
|
@ -26,8 +30,15 @@ class SetupActivity : AppCompatActivity() {
|
||||||
private lateinit var notificationStatus: TextView
|
private lateinit var notificationStatus: TextView
|
||||||
private lateinit var enableAccessibilityBtn: Button
|
private lateinit var enableAccessibilityBtn: Button
|
||||||
private lateinit var enableInstallBtn: Button
|
private lateinit var enableInstallBtn: Button
|
||||||
|
private lateinit var fullscreenStatus: TextView
|
||||||
|
private lateinit var enableFullscreenBtn: Button
|
||||||
|
private lateinit var batteryStatus: TextView
|
||||||
|
private lateinit var enableBatteryBtn: Button
|
||||||
|
private lateinit var overlayStatus: TextView
|
||||||
|
private lateinit var enableOverlayBtn: Button
|
||||||
private lateinit var continueBtn: Button
|
private lateinit var continueBtn: Button
|
||||||
|
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
|
@ -40,6 +51,9 @@ class SetupActivity : AppCompatActivity() {
|
||||||
|
|
||||||
setContentView(R.layout.activity_setup)
|
setContentView(R.layout.activity_setup)
|
||||||
|
|
||||||
|
// App's UI is up — clear the boot "Starting display…" notification.
|
||||||
|
getSystemService(NotificationManager::class.java)?.cancel(999)
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
window.decorView.systemUiVisibility = (
|
window.decorView.systemUiVisibility = (
|
||||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||||
|
|
@ -75,11 +89,60 @@ class SetupActivity : AppCompatActivity() {
|
||||||
enableInstallBtn.setOnClickListener {
|
enableInstallBtn.setOnClickListener {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
data = android.net.Uri.parse("package:$packageName")
|
data = Uri.parse("package:$packageName")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fullscreenStatus = findViewById(R.id.fullscreenStatus)
|
||||||
|
enableFullscreenBtn = findViewById(R.id.enableFullscreenBtn)
|
||||||
|
batteryStatus = findViewById(R.id.batteryStatus)
|
||||||
|
enableBatteryBtn = findViewById(R.id.enableBatteryBtn)
|
||||||
|
overlayStatus = findViewById(R.id.overlayStatus)
|
||||||
|
enableOverlayBtn = findViewById(R.id.enableOverlayBtn)
|
||||||
|
|
||||||
|
// Display-over-other-apps: alternate boot-launch path. With this granted the
|
||||||
|
// boot receiver can directly start the activity from the background, which
|
||||||
|
// works where you can't set a launcher (e.g. Android TV).
|
||||||
|
enableOverlayBtn.setOnClickListener {
|
||||||
|
startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply {
|
||||||
|
data = Uri.parse("package:$packageName")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch-on-boot needs USE_FULL_SCREEN_INTENT, which Android 14+ auto-revokes
|
||||||
|
// for non-calling apps — so the boot full-screen launcher silently fails until
|
||||||
|
// the user grants it. Older versions auto-grant it, so only show the row where
|
||||||
|
// it can actually be off.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
// USE_FULL_SCREEN_INTENT is auto-granted before Android 14 — hide the row.
|
||||||
|
findViewById<View>(R.id.fullscreenRow).visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
enableFullscreenBtn.setOnClickListener {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply {
|
||||||
|
data = Uri.parse("package:$packageName")
|
||||||
|
})
|
||||||
|
} catch (e: Exception) {
|
||||||
|
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||||
|
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery-optimization exemption keeps the boot receiver from being deferred
|
||||||
|
// and the app from being killed in standby (esp. on OEM / TV boxes).
|
||||||
|
enableBatteryBtn.setOnClickListener {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
|
data = Uri.parse("package:$packageName")
|
||||||
|
})
|
||||||
|
} catch (e: Exception) {
|
||||||
|
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
continueBtn.setOnClickListener {
|
continueBtn.setOnClickListener {
|
||||||
prefs.edit().putBoolean("setup_complete", true).apply()
|
prefs.edit().putBoolean("setup_complete", true).apply()
|
||||||
proceedToNext()
|
proceedToNext()
|
||||||
|
|
@ -130,6 +193,27 @@ class SetupActivity : AppCompatActivity() {
|
||||||
if (hasNotif) View.GONE else View.VISIBLE
|
if (hasNotif) View.GONE else View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Launch on boot (full-screen intent — only restrictable on Android 14+)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
val canFsi = getSystemService(NotificationManager::class.java).canUseFullScreenIntent()
|
||||||
|
fullscreenStatus.text = if (canFsi) "ON" else "OFF"
|
||||||
|
fullscreenStatus.setTextColor(if (canFsi) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
|
||||||
|
enableFullscreenBtn.visibility = if (canFsi) View.GONE else View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery optimization exemption
|
||||||
|
val ignoringBattery = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
|
.isIgnoringBatteryOptimizations(packageName)
|
||||||
|
batteryStatus.text = if (ignoringBattery) "ON" else "OFF"
|
||||||
|
batteryStatus.setTextColor(if (ignoringBattery) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
|
||||||
|
enableBatteryBtn.visibility = if (ignoringBattery) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
// Display over other apps
|
||||||
|
val canOverlay = Settings.canDrawOverlays(this)
|
||||||
|
overlayStatus.text = if (canOverlay) "ON" else "OFF"
|
||||||
|
overlayStatus.setTextColor(if (canOverlay) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
|
||||||
|
enableOverlayBtn.visibility = if (canOverlay) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
// Update continue button text
|
// Update continue button text
|
||||||
val allGood = accessibilityEnabled && canInstall
|
val allGood = accessibilityEnabled && canInstall
|
||||||
continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway"
|
continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway"
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ class ServerConfig(context: Context) {
|
||||||
get() = prefs.getString("device_id", "") ?: ""
|
get() = prefs.getString("device_id", "") ?: ""
|
||||||
set(value) = prefs.edit().putString("device_id", value).apply()
|
set(value) = prefs.edit().putString("device_id", value).apply()
|
||||||
|
|
||||||
|
var deviceToken: String
|
||||||
|
get() = prefs.getString("device_token", "") ?: ""
|
||||||
|
set(value) = prefs.edit().putString("device_token", value).apply()
|
||||||
|
|
||||||
var deviceName: String
|
var deviceName: String
|
||||||
get() = prefs.getString("device_name", "Unnamed Display") ?: "Unnamed Display"
|
get() = prefs.getString("device_name", "Unnamed Display") ?: "Unnamed Display"
|
||||||
set(value) = prefs.edit().putString("device_name", value).apply()
|
set(value) = prefs.edit().putString("device_name", value).apply()
|
||||||
|
|
@ -47,7 +51,24 @@ class ServerConfig(context: Context) {
|
||||||
prefs.edit().putBoolean("is_paired", paired).apply()
|
prefs.edit().putBoolean("is_paired", paired).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearDeviceCredentials() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove("device_id")
|
||||||
|
.remove("device_token")
|
||||||
|
.remove("is_paired")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
prefs.edit().clear().apply()
|
prefs.edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playlist cache for offline cold-start
|
||||||
|
var cachedPlaylist: String
|
||||||
|
get() = prefs.getString("cached_playlist", "") ?: ""
|
||||||
|
set(value) = prefs.edit().putString("cached_playlist", value).apply()
|
||||||
|
|
||||||
|
fun clearPlaylistCache() {
|
||||||
|
prefs.edit().remove("cached_playlist").apply()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
|
import com.remotedisplay.player.util.ImageLoader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class MediaPlayerManager(
|
class MediaPlayerManager(
|
||||||
|
|
@ -18,12 +19,13 @@ class MediaPlayerManager(
|
||||||
private val playerView: PlayerView,
|
private val playerView: PlayerView,
|
||||||
private val imageView: ImageView,
|
private val imageView: ImageView,
|
||||||
private val youtubeWebView: WebView? = null,
|
private val youtubeWebView: WebView? = null,
|
||||||
private val onVideoComplete: () -> Unit
|
private val onVideoComplete: () -> Unit,
|
||||||
|
private val onImageError: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
private var exoPlayer: ExoPlayer? = null
|
private var exoPlayer: ExoPlayer? = null
|
||||||
private var currentType: MediaType = MediaType.NONE
|
private var currentType: MediaType = MediaType.NONE
|
||||||
|
|
||||||
enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE }
|
enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE, WIDGET }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setupExoPlayer()
|
setupExoPlayer()
|
||||||
|
|
@ -53,13 +55,30 @@ class MediaPlayerManager(
|
||||||
exoPlayer?.stop()
|
exoPlayer?.stop()
|
||||||
|
|
||||||
youtubeWebView?.apply {
|
youtubeWebView?.apply {
|
||||||
settings.javaScriptEnabled = true
|
com.remotedisplay.player.util.WebViewSupport.configure(this, "YouTube")
|
||||||
settings.domStorageEnabled = true
|
|
||||||
settings.mediaPlaybackRequiresUserGesture = false
|
|
||||||
webViewClient = WebViewClient()
|
|
||||||
webChromeClient = WebChromeClient()
|
|
||||||
setBackgroundColor(android.graphics.Color.BLACK)
|
setBackgroundColor(android.graphics.Color.BLACK)
|
||||||
loadUrl(embedUrl)
|
// Load via an embed wrapper with a valid youtube.com origin (Error 153 fix).
|
||||||
|
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl)
|
||||||
|
if (html != null) loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
|
||||||
|
else loadUrl(embedUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
|
||||||
|
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
|
||||||
|
fun showWidget(url: String) {
|
||||||
|
Log.i("MediaPlayerManager", "Showing widget: $url")
|
||||||
|
currentType = MediaType.WIDGET
|
||||||
|
|
||||||
|
playerView.visibility = android.view.View.GONE
|
||||||
|
imageView.visibility = android.view.View.GONE
|
||||||
|
youtubeWebView?.visibility = android.view.View.VISIBLE
|
||||||
|
|
||||||
|
exoPlayer?.stop()
|
||||||
|
|
||||||
|
youtubeWebView?.apply {
|
||||||
|
com.remotedisplay.player.util.WebViewSupport.configure(this, "Widget")
|
||||||
|
loadUrl(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,20 +108,16 @@ class MediaPlayerManager(
|
||||||
|
|
||||||
exoPlayer?.stop()
|
exoPlayer?.stop()
|
||||||
|
|
||||||
// Load image from URL in background
|
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
|
||||||
val connection = java.net.URL(url).openConnection()
|
|
||||||
connection.connectTimeout = 10000
|
|
||||||
connection.readTimeout = 30000
|
|
||||||
val input = connection.getInputStream()
|
|
||||||
val bitmap = android.graphics.BitmapFactory.decodeStream(input)
|
|
||||||
input.close()
|
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
imageView.post { imageView.setImageBitmap(bitmap) }
|
imageView.post {
|
||||||
|
try { imageView.setImageBitmap(bitmap) }
|
||||||
|
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} else {
|
||||||
Log.e("MediaPlayerManager", "Remote image load failed: ${e.message}")
|
Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url")
|
||||||
|
imageView.post { onImageError?.invoke() }
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
@ -128,24 +143,23 @@ class MediaPlayerManager(
|
||||||
Log.i("MediaPlayerManager", "Showing image: ${file.absolutePath}")
|
Log.i("MediaPlayerManager", "Showing image: ${file.absolutePath}")
|
||||||
currentType = MediaType.IMAGE
|
currentType = MediaType.IMAGE
|
||||||
|
|
||||||
// Show image, hide player
|
|
||||||
playerView.visibility = android.view.View.GONE
|
playerView.visibility = android.view.View.GONE
|
||||||
imageView.visibility = android.view.View.VISIBLE
|
imageView.visibility = android.view.View.VISIBLE
|
||||||
youtubeWebView?.visibility = android.view.View.GONE
|
youtubeWebView?.visibility = android.view.View.GONE
|
||||||
|
|
||||||
// Stop video if playing
|
|
||||||
exoPlayer?.stop()
|
exoPlayer?.stop()
|
||||||
|
|
||||||
// Load image
|
val bitmap = ImageLoader.decodeFile(file, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
|
||||||
try {
|
if (bitmap == null) {
|
||||||
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
|
Log.w("MediaPlayerManager", "Skipping unloadable image: ${file.name}")
|
||||||
if (bitmap != null) {
|
onImageError?.invoke()
|
||||||
imageView.setImageBitmap(bitmap)
|
return
|
||||||
} else {
|
|
||||||
Log.e("MediaPlayerManager", "Failed to decode image: ${file.absolutePath}")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
try {
|
||||||
Log.e("MediaPlayerManager", "Error loading image: ${e.message}")
|
imageView.setImageBitmap(bitmap)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}")
|
||||||
|
onImageError?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,24 +17,36 @@ data class PlaylistItem(
|
||||||
val sortOrder: Int,
|
val sortOrder: Int,
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
val remoteUrl: String? = null,
|
val remoteUrl: String? = null,
|
||||||
val muted: Boolean = false
|
val muted: Boolean = false,
|
||||||
|
val widgetId: String? = null,
|
||||||
|
val widgetType: String? = null,
|
||||||
|
val schedules: List<ScheduleEval.Block> = emptyList()
|
||||||
) {
|
) {
|
||||||
val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty()
|
val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty()
|
||||||
|
// Widget assignments have a widget_id and no downloadable content file.
|
||||||
|
val isWidget: Boolean get() = !widgetId.isNullOrEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlaylistController(
|
class PlaylistController(
|
||||||
private val onItemChanged: (PlaylistItem?) -> Unit,
|
private val onItemChanged: (PlaylistItem?) -> Unit,
|
||||||
private val onPlaylistEmpty: () -> Unit,
|
private val onPlaylistEmpty: () -> Unit,
|
||||||
private val onRequestRefresh: (() -> Unit)? = null
|
private val onRequestRefresh: (() -> Unit)? = null,
|
||||||
|
private val onNothingScheduled: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
private val items = mutableListOf<PlaylistItem>()
|
private val items = mutableListOf<PlaylistItem>()
|
||||||
private var currentIndex = -1
|
private var currentIndex = -1
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var advanceRunnable: Runnable? = null
|
private var advanceRunnable: Runnable? = null
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
|
// #74/#75: per-item scheduling state
|
||||||
|
@Volatile private var effectiveTimezone: String? = null
|
||||||
|
private var retryRunnable: Runnable? = null
|
||||||
|
|
||||||
val isPlaying: Boolean get() = isRunning && currentIndex >= 0
|
val isPlaying: Boolean get() = isRunning && currentIndex >= 0
|
||||||
|
|
||||||
|
/** #74/#75: device-effective IANA timezone for per-item schedule evaluation. */
|
||||||
|
fun setTimezone(tz: String?) { effectiveTimezone = tz }
|
||||||
|
|
||||||
val currentItem: PlaylistItem?
|
val currentItem: PlaylistItem?
|
||||||
get() = if (currentIndex in items.indices) items[currentIndex] else null
|
get() = if (currentIndex in items.indices) items[currentIndex] else null
|
||||||
|
|
||||||
|
|
@ -51,7 +63,8 @@ class PlaylistController(
|
||||||
newItems.add(
|
newItems.add(
|
||||||
PlaylistItem(
|
PlaylistItem(
|
||||||
assignmentId = obj.optInt("id", 0),
|
assignmentId = obj.optInt("id", 0),
|
||||||
contentId = obj.getString("content_id"),
|
// Tolerant: widget assignments have no content_id (getString threw).
|
||||||
|
contentId = if (obj.isNull("content_id")) "" else obj.optString("content_id", ""),
|
||||||
filename = obj.optString("filename", "unknown"),
|
filename = obj.optString("filename", "unknown"),
|
||||||
mimeType = obj.optString("mime_type", "video/mp4"),
|
mimeType = obj.optString("mime_type", "video/mp4"),
|
||||||
filepath = obj.optString("filepath", ""),
|
filepath = obj.optString("filepath", ""),
|
||||||
|
|
@ -60,14 +73,24 @@ class PlaylistController(
|
||||||
sortOrder = obj.optInt("sort_order", 0),
|
sortOrder = obj.optInt("sort_order", 0),
|
||||||
enabled = obj.optInt("enabled", 1) == 1,
|
enabled = obj.optInt("enabled", 1) == 1,
|
||||||
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
|
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
|
||||||
muted = obj.optInt("muted", 0) == 1
|
muted = obj.optInt("muted", 0) == 1,
|
||||||
|
widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null },
|
||||||
|
widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null },
|
||||||
|
schedules = parseSchedules(obj.optJSONArray("schedules"))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if playlist actually changed
|
// Check if playlist actually changed (key on content OR widget id, since
|
||||||
val oldContentIds = items.map { it.contentId }
|
// widget items share an empty contentId).
|
||||||
val newContentIds = newItems.map { it.contentId }
|
// #74/#75: a schedule edit changes playback even when content is identical, so
|
||||||
|
// the change signature must include schedules (else updated blocks are dropped).
|
||||||
|
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" +
|
||||||
|
it.schedules.joinToString(";") { b ->
|
||||||
|
b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "")
|
||||||
|
}
|
||||||
|
val oldContentIds = items.map(::sig)
|
||||||
|
val newContentIds = newItems.map(::sig)
|
||||||
val playlistChanged = oldContentIds != newContentIds
|
val playlistChanged = oldContentIds != newContentIds
|
||||||
|
|
||||||
if (!playlistChanged && items.isNotEmpty()) {
|
if (!playlistChanged && items.isNotEmpty()) {
|
||||||
|
|
@ -98,9 +121,10 @@ class PlaylistController(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Current item was removed or nothing was playing - start from beginning
|
// Current item was removed or nothing was playing - start from the first
|
||||||
currentIndex = 0
|
// schedule-active item; idle if none are active right now.
|
||||||
playCurrentItem()
|
val idx = firstActiveIndex()
|
||||||
|
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled()
|
||||||
} else {
|
} else {
|
||||||
currentIndex = 0
|
currentIndex = 0
|
||||||
}
|
}
|
||||||
|
|
@ -122,12 +146,12 @@ class PlaylistController(
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
isRunning = true
|
isRunning = true
|
||||||
if (items.isNotEmpty()) {
|
if (items.isEmpty()) { onPlaylistEmpty(); return }
|
||||||
if (currentIndex < 0) currentIndex = 0
|
// #74/#75: begin on the first schedule-active item; idle if none.
|
||||||
|
val idx = firstActiveIndex()
|
||||||
|
if (idx < 0) { showNothingScheduled(); return }
|
||||||
|
currentIndex = idx
|
||||||
playCurrentItem()
|
playCurrentItem()
|
||||||
} else {
|
|
||||||
onPlaylistEmpty()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startIfNeeded() {
|
fun startIfNeeded() {
|
||||||
|
|
@ -148,13 +172,17 @@ class PlaylistController(
|
||||||
fun stop() {
|
fun stop() {
|
||||||
isRunning = false
|
isRunning = false
|
||||||
cancelAdvance()
|
cancelAdvance()
|
||||||
|
cancelRetry()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun next() {
|
fun next() {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
currentIndex = (currentIndex + 1) % items.size
|
|
||||||
// Request a playlist refresh between plays so new content gets picked up
|
// Request a playlist refresh between plays so new content gets picked up
|
||||||
onRequestRefresh?.invoke()
|
onRequestRefresh?.invoke()
|
||||||
|
// #74/#75: advance to the next item the schedule allows now; idle if none.
|
||||||
|
val idx = nextActiveIndex(currentIndex)
|
||||||
|
if (idx < 0) { showNothingScheduled(); return }
|
||||||
|
currentIndex = idx
|
||||||
playCurrentItem()
|
playCurrentItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,12 +193,14 @@ class PlaylistController(
|
||||||
|
|
||||||
private fun playCurrentItem() {
|
private fun playCurrentItem() {
|
||||||
cancelAdvance()
|
cancelAdvance()
|
||||||
|
cancelRetry()
|
||||||
val item = currentItem ?: return
|
val item = currentItem ?: return
|
||||||
Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)")
|
Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)")
|
||||||
onItemChanged(item)
|
onItemChanged(item)
|
||||||
|
|
||||||
// For images, auto-advance after duration. For videos, wait for completion callback.
|
// For images and widgets, auto-advance after duration. For videos, wait
|
||||||
if (item.mimeType.startsWith("image/")) {
|
// for the completion callback.
|
||||||
|
if (item.mimeType.startsWith("image/") || item.isWidget) {
|
||||||
scheduleAdvance(item.durationSec * 1000L)
|
scheduleAdvance(item.durationSec * 1000L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -185,4 +215,64 @@ class PlaylistController(
|
||||||
advanceRunnable?.let { handler.removeCallbacks(it) }
|
advanceRunnable?.let { handler.removeCallbacks(it) }
|
||||||
advanceRunnable = null
|
advanceRunnable = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cancelRetry() {
|
||||||
|
retryRunnable?.let { handler.removeCallbacks(it) }
|
||||||
|
retryRunnable = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// #74/#75 schedule helpers ---------------------------------------------------
|
||||||
|
private fun scheduleAllows(item: PlaylistItem): Boolean =
|
||||||
|
item.schedules.isEmpty() ||
|
||||||
|
ScheduleEval.isItemActiveNow(item.schedules, System.currentTimeMillis(), effectiveTimezone)
|
||||||
|
|
||||||
|
private fun firstActiveIndex(): Int {
|
||||||
|
for (i in items.indices) if (scheduleAllows(items[i])) return i
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextActiveIndex(from: Int): Int {
|
||||||
|
if (items.isEmpty()) return -1
|
||||||
|
for (i in 1..items.size) {
|
||||||
|
val idx = (from + i) % items.size
|
||||||
|
if (scheduleAllows(items[idx])) return idx
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every item filtered out: show the idle screen and re-check shortly, since a
|
||||||
|
// daypart may open. (Boundary re-evaluation otherwise happens on advance.)
|
||||||
|
private fun showNothingScheduled() {
|
||||||
|
cancelAdvance()
|
||||||
|
(onNothingScheduled ?: onPlaylistEmpty)()
|
||||||
|
cancelRetry()
|
||||||
|
retryRunnable = Runnable {
|
||||||
|
if (isRunning && items.isNotEmpty()) {
|
||||||
|
val idx = firstActiveIndex()
|
||||||
|
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.postDelayed(retryRunnable!!, 30_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSchedules(arr: JSONArray?): List<ScheduleEval.Block> {
|
||||||
|
if (arr == null) return emptyList()
|
||||||
|
val out = ArrayList<ScheduleEval.Block>(arr.length())
|
||||||
|
for (j in 0 until arr.length()) {
|
||||||
|
val s = arr.getJSONObject(j)
|
||||||
|
val d = s.getJSONArray("days")
|
||||||
|
val days = HashSet<Int>(d.length())
|
||||||
|
for (k in 0 until d.length()) days.add(d.getInt(k))
|
||||||
|
out.add(
|
||||||
|
ScheduleEval.Block(
|
||||||
|
days = days,
|
||||||
|
start = s.getString("start"),
|
||||||
|
end = s.getString("end"),
|
||||||
|
startDate = if (s.isNull("start_date")) null else s.optString("start_date").ifEmpty { null },
|
||||||
|
endDate = if (s.isNull("end_date")) null else s.optString("end_date").ifEmpty { null }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.remotedisplay.player.player
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical per-playlist-item schedule evaluator (#74 dayparting + #75 expiry) -
|
||||||
|
* Kotlin port of server/lib/schedule-eval.js.
|
||||||
|
*
|
||||||
|
* CONTRACT: shared/schedule-vectors.json. This must agree with the JS evaluator
|
||||||
|
* (server/web/Tizen) on every vector. If it disagrees with a vector, this is wrong.
|
||||||
|
*
|
||||||
|
* Time model: instants are UTC; blocks are LOCAL wall-clock rules interpreted in
|
||||||
|
* the device's effective IANA timezone (DST handled by java.time). Blocks are never
|
||||||
|
* converted to UTC.
|
||||||
|
*
|
||||||
|
* Block semantics:
|
||||||
|
* - within a block, day AND date AND time must all pass; blocks OR together
|
||||||
|
* - zero blocks = always on ("no schedule = always plays")
|
||||||
|
* - time window is [start, end): start inclusive, end exclusive ("24:00" = end of day)
|
||||||
|
* - start > end crosses midnight; the day/date test anchors to the day the window STARTED
|
||||||
|
*
|
||||||
|
* FAILS OPEN: any error (bad timezone, malformed block) returns true so the item
|
||||||
|
* PLAYS. A blank screen is worse than an over-running promo.
|
||||||
|
*/
|
||||||
|
object ScheduleEval {
|
||||||
|
|
||||||
|
data class Block(
|
||||||
|
val days: Set<Int>, // 0=Sun .. 6=Sat
|
||||||
|
val start: String, // "HH:MM"
|
||||||
|
val end: String, // "HH:MM" or "24:00"
|
||||||
|
val startDate: String?, // "YYYY-MM-DD" or null = no lower bound
|
||||||
|
val endDate: String? // "YYYY-MM-DD" or null = no upper bound
|
||||||
|
)
|
||||||
|
|
||||||
|
fun isItemActiveNow(blocks: List<Block>?, utcNowMs: Long, ianaTz: String?): Boolean {
|
||||||
|
if (blocks.isNullOrEmpty()) return true
|
||||||
|
return try {
|
||||||
|
val zone = if (ianaTz.isNullOrBlank()) ZoneId.systemDefault() else ZoneId.of(ianaTz)
|
||||||
|
val zdt = Instant.ofEpochMilli(utcNowMs).atZone(zone)
|
||||||
|
val dow = zdt.dayOfWeek.value % 7 // java Mon=1..Sun=7 -> Sun=0..Sat=6
|
||||||
|
val nowMin = zdt.hour * 60 + zdt.minute
|
||||||
|
val date = zdt.toLocalDate()
|
||||||
|
blocks.any { blockMatches(it, dow, nowMin, date) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
true // fail open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hm(s: String): Int { val p = s.split(":"); return p[0].toInt() * 60 + p[1].toInt() } // "24:00" -> 1440
|
||||||
|
|
||||||
|
private fun dayOk(dow: Int, days: Set<Int>): Boolean = days.contains(dow)
|
||||||
|
|
||||||
|
private fun dateOk(date: LocalDate, startDate: String?, endDate: String?): Boolean {
|
||||||
|
if (startDate != null && date.isBefore(LocalDate.parse(startDate))) return false
|
||||||
|
if (endDate != null && date.isAfter(LocalDate.parse(endDate))) return false // inclusive
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun blockMatches(b: Block, dow: Int, nowMin: Int, date: LocalDate): Boolean {
|
||||||
|
val s = hm(b.start); val e = hm(b.end)
|
||||||
|
if (s <= e) {
|
||||||
|
// same-day window [s, e), anchored to today
|
||||||
|
if (nowMin < s || nowMin >= e) return false
|
||||||
|
return dayOk(dow, b.days) && dateOk(date, b.startDate, b.endDate)
|
||||||
|
}
|
||||||
|
// overnight wrap
|
||||||
|
if (nowMin >= s) {
|
||||||
|
// before-midnight portion: anchor = today
|
||||||
|
return dayOk(dow, b.days) && dateOk(date, b.startDate, b.endDate)
|
||||||
|
}
|
||||||
|
if (nowMin < e) {
|
||||||
|
// after-midnight portion: anchor = the day it started = yesterday
|
||||||
|
val y = date.minusDays(1)
|
||||||
|
return dayOk((dow + 6) % 7, b.days) && dateOk(y, b.startDate, b.endDate)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ package com.remotedisplay.player.player
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
|
@ -35,16 +37,24 @@ class ZoneManager(
|
||||||
private val onAllVideosComplete: () -> Unit
|
private val onAllVideosComplete: () -> Unit
|
||||||
) {
|
) {
|
||||||
private val TAG = "ZoneManager"
|
private val TAG = "ZoneManager"
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private val zoneViews = mutableMapOf<String, View>()
|
private val zoneViews = mutableMapOf<String, View>()
|
||||||
private val zoneExoPlayers = mutableMapOf<String, ExoPlayer>()
|
private val zoneExoPlayers = mutableMapOf<String, ExoPlayer>()
|
||||||
|
// Per-zone rotation timers: each zone cycles its own list of assignments.
|
||||||
|
private val zoneRotators = mutableMapOf<String, Runnable>()
|
||||||
private var zones = listOf<Zone>()
|
private var zones = listOf<Zone>()
|
||||||
private var activeVideoCount = 0
|
// Render context kept for rotation re-renders.
|
||||||
private var completedVideoCount = 0
|
private var renderServerUrl = ""
|
||||||
|
private var renderCache: com.remotedisplay.player.data.ContentCache? = null
|
||||||
|
|
||||||
var currentLayoutId: String? = null
|
var currentLayoutId: String? = null
|
||||||
private set
|
private set
|
||||||
var lastAssignmentSig: String? = null
|
var lastAssignmentSig: String? = null
|
||||||
|
|
||||||
|
// #74/#75: device-effective IANA timezone for per-item schedule evaluation.
|
||||||
|
@Volatile private var effectiveTimezone: String? = null
|
||||||
|
fun setTimezone(tz: String?) { effectiveTimezone = tz }
|
||||||
|
|
||||||
fun hasZones(): Boolean = zones.isNotEmpty()
|
fun hasZones(): Boolean = zones.isNotEmpty()
|
||||||
|
|
||||||
fun setupZones(zonesJson: JSONArray, layoutId: String? = null) {
|
fun setupZones(zonesJson: JSONArray, layoutId: String? = null) {
|
||||||
|
|
@ -68,86 +78,140 @@ class ZoneManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun renderAssignments(assignments: JSONArray, serverUrl: String, contentCache: com.remotedisplay.player.data.ContentCache) {
|
fun renderAssignments(assignments: JSONArray, serverUrl: String, contentCache: com.remotedisplay.player.data.ContentCache) {
|
||||||
// Clear existing zone views
|
// Clear ONLY our own zone views/timers. `container` is the activity root and
|
||||||
container.removeAllViews()
|
// also holds the static playerView/imageView/youtubeWebView/statusOverlay -
|
||||||
|
// removeAllViews() here would detach those and black the screen on switch-back.
|
||||||
|
cancelAllRotations()
|
||||||
|
zoneViews.values.forEach { container.removeView(it) }
|
||||||
zoneViews.clear()
|
zoneViews.clear()
|
||||||
releaseExoPlayers()
|
releaseExoPlayers()
|
||||||
activeVideoCount = 0
|
renderServerUrl = serverUrl
|
||||||
completedVideoCount = 0
|
renderCache = contentCache
|
||||||
|
|
||||||
val containerWidth = container.width
|
val containerWidth = container.width
|
||||||
val containerHeight = container.height
|
val containerHeight = container.height
|
||||||
|
|
||||||
if (containerWidth == 0 || containerHeight == 0) {
|
if (containerWidth == 0 || containerHeight == 0) {
|
||||||
// Container not laid out yet, post delayed
|
// Container not laid out yet, retry after layout.
|
||||||
container.post { renderAssignments(assignments, serverUrl, contentCache) }
|
container.post { renderAssignments(assignments, serverUrl, contentCache) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map assignments by zone_id
|
// Group assignments by zone_id, ordered by sort_order so rotation is stable.
|
||||||
val assignmentsByZone = mutableMapOf<String?, MutableList<JSONObject>>()
|
val assignmentsByZone = mutableMapOf<String?, MutableList<JSONObject>>()
|
||||||
for (i in 0 until assignments.length()) {
|
for (i in 0 until assignments.length()) {
|
||||||
val a = assignments.getJSONObject(i)
|
val a = assignments.getJSONObject(i)
|
||||||
val zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null)
|
val zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null)
|
||||||
assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a)
|
assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a)
|
||||||
}
|
}
|
||||||
|
assignmentsByZone.values.forEach { list -> list.sortBy { it.optInt("sort_order", 0) } }
|
||||||
|
|
||||||
// Render each zone - only show content specifically assigned to this zone
|
// Unassigned content (zone_id=null) goes to the FIRST zone only.
|
||||||
// Unassigned content (zone_id=null) goes to the FIRST zone only
|
|
||||||
var unassignedUsed = false
|
var unassignedUsed = false
|
||||||
for (zone in zones.sortedBy { it.zIndex }) {
|
for (zone in zones.sortedBy { it.zIndex }) {
|
||||||
val zoneAssignments: List<JSONObject> = assignmentsByZone[zone.id]
|
val zoneAssignments: List<JSONObject> = assignmentsByZone[zone.id]
|
||||||
?: if (!unassignedUsed) { unassignedUsed = true; assignmentsByZone[null] ?: emptyList() } else emptyList()
|
?: if (!unassignedUsed) { unassignedUsed = true; assignmentsByZone[null] ?: emptyList() } else emptyList()
|
||||||
val firstAssignment = zoneAssignments.firstOrNull() ?: continue
|
if (zoneAssignments.isEmpty()) continue
|
||||||
|
|
||||||
// Calculate pixel position
|
|
||||||
val x = (zone.xPercent / 100f * containerWidth).toInt()
|
val x = (zone.xPercent / 100f * containerWidth).toInt()
|
||||||
val y = (zone.yPercent / 100f * containerHeight).toInt()
|
val y = (zone.yPercent / 100f * containerHeight).toInt()
|
||||||
val w = (zone.widthPercent / 100f * containerWidth).toInt()
|
val w = (zone.widthPercent / 100f * containerWidth).toInt()
|
||||||
val h = (zone.heightPercent / 100f * containerHeight).toInt()
|
val h = (zone.heightPercent / 100f * containerHeight).toInt()
|
||||||
|
val params = FrameLayout.LayoutParams(w, h).apply { leftMargin = x; topMargin = y }
|
||||||
|
|
||||||
val params = FrameLayout.LayoutParams(w, h).apply {
|
com.remotedisplay.player.util.DebugLog.i("Zone", "Zone '${zone.name}' (${zone.widthPercent.toInt()}x${zone.heightPercent.toInt()}%) -> ${zoneAssignments.size} item(s)")
|
||||||
leftMargin = x
|
showZoneItem(zone, zoneAssignments, 0, params)
|
||||||
topMargin = y
|
}
|
||||||
|
Log.i(TAG, "Rendered ${zoneViews.size} zone views")
|
||||||
}
|
}
|
||||||
|
|
||||||
val mimeType = firstAssignment.optString("mime_type", "")
|
// #74/#75 zone schedule helpers.
|
||||||
val remoteUrl = if (firstAssignment.isNull("remote_url")) null else firstAssignment.optString("remote_url", null)
|
private fun assignmentAllows(a: JSONObject): Boolean {
|
||||||
val widgetType = if (firstAssignment.isNull("widget_type")) null else firstAssignment.optString("widget_type", null)
|
val arr = a.optJSONArray("schedules") ?: return true
|
||||||
val widgetConfig = if (firstAssignment.isNull("widget_config")) null else firstAssignment.optString("widget_config", null)
|
if (arr.length() == 0) return true
|
||||||
val contentId = if (firstAssignment.isNull("content_id")) null else firstAssignment.optString("content_id", null)
|
val blocks = ArrayList<ScheduleEval.Block>(arr.length())
|
||||||
val filepath = firstAssignment.optString("filepath", "")
|
for (j in 0 until arr.length()) {
|
||||||
val isMuted = firstAssignment.optInt("muted", 0) == 1
|
val s = arr.getJSONObject(j)
|
||||||
|
val d = s.getJSONArray("days")
|
||||||
|
val days = HashSet<Int>(d.length())
|
||||||
|
for (k in 0 until d.length()) days.add(d.getInt(k))
|
||||||
|
blocks.add(
|
||||||
|
ScheduleEval.Block(
|
||||||
|
days, s.getString("start"), s.getString("end"),
|
||||||
|
if (s.isNull("start_date")) null else s.optString("start_date").ifEmpty { null },
|
||||||
|
if (s.isNull("end_date")) null else s.optString("end_date").ifEmpty { null }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ScheduleEval.isItemActiveNow(blocks, System.currentTimeMillis(), effectiveTimezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zoneNextActive(assignments: List<JSONObject>, from: Int): Int {
|
||||||
|
for (i in assignments.indices) {
|
||||||
|
val idx = (from + i) % assignments.size
|
||||||
|
if (assignmentAllows(assignments[idx])) return idx
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render assignment[index] in a zone, replacing its current view. If the zone
|
||||||
|
// has more than one assignment it rotates: images/widgets advance on a duration
|
||||||
|
// timer; videos advance when they end (single-item zones loop the video).
|
||||||
|
private fun showZoneItem(zone: Zone, assignments: List<JSONObject>, index: Int, params: FrameLayout.LayoutParams) {
|
||||||
|
cancelZoneRotation(zone.id)
|
||||||
|
zoneViews.remove(zone.id)?.let { container.removeView(it) }
|
||||||
|
zoneExoPlayers.remove(zone.id)?.release()
|
||||||
|
|
||||||
|
// #74/#75: skip items whose schedule excludes them now; blank-idle the zone
|
||||||
|
// and re-check shortly (a daypart may open) if none are active.
|
||||||
|
val activeIdx = zoneNextActive(assignments, index)
|
||||||
|
if (activeIdx < 0) {
|
||||||
|
scheduleZoneAdvance(zone.id, 30_000L) { showZoneItem(zone, assignments, 0, params) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val a = assignments[activeIdx]
|
||||||
|
// Scheduled zones cycle even with one active item so windows re-evaluate.
|
||||||
|
val multi = assignments.size > 1 || assignments.any { (it.optJSONArray("schedules")?.length() ?: 0) > 0 }
|
||||||
|
val advance: () -> Unit = { showZoneItem(zone, assignments, activeIdx + 1, params) }
|
||||||
|
|
||||||
|
val mimeType = a.optString("mime_type", "")
|
||||||
|
val remoteUrl = if (a.isNull("remote_url")) null else a.optString("remote_url", null)
|
||||||
|
val widgetType = if (a.isNull("widget_type")) null else a.optString("widget_type", null)
|
||||||
|
val contentId = if (a.isNull("content_id")) null else a.optString("content_id", null)
|
||||||
|
val filepath = a.optString("filepath", "")
|
||||||
|
val isMuted = a.optInt("muted", 0) == 1
|
||||||
|
val durationMs = a.optInt("duration_sec", 10).coerceAtLeast(3) * 1000L
|
||||||
|
|
||||||
|
// Per-zone content switch log (fires on initial render AND each rotation), so
|
||||||
|
// the live debug panel shows each zone advancing on its own interval.
|
||||||
|
val label = a.optString("filename", "").ifEmpty { widgetType?.let { "widget:$it" } ?: mimeType.ifEmpty { "item" } }
|
||||||
|
com.remotedisplay.player.util.DebugLog.i("Zone", "'${zone.name}' [${activeIdx + 1}/${assignments.size}] -> $label (${durationMs / 1000}s)")
|
||||||
|
|
||||||
when {
|
when {
|
||||||
// Widget - render in WebView
|
// Widget - render in WebView
|
||||||
widgetType != null -> {
|
widgetType != null -> {
|
||||||
val widgetId = firstAssignment.optString("widget_id", "")
|
val widgetId = a.optString("widget_id", "")
|
||||||
val webView = createWebView()
|
val webView = createWebView()
|
||||||
webView.loadUrl("$serverUrl/api/widgets/$widgetId/render")
|
webView.loadUrl("$renderServerUrl/api/widgets/$widgetId/render")
|
||||||
webView.layoutParams = params
|
webView.layoutParams = params
|
||||||
container.addView(webView)
|
container.addView(webView); zoneViews[zone.id] = webView
|
||||||
zoneViews[zone.id] = webView
|
if (multi) scheduleZoneAdvance(zone.id, durationMs, advance)
|
||||||
Log.i(TAG, "Zone ${zone.name}: widget $widgetType")
|
|
||||||
}
|
}
|
||||||
|
// YouTube - render via an embed wrapper with a valid origin (Error 153 fix)
|
||||||
// YouTube - render in WebView
|
|
||||||
mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> {
|
mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> {
|
||||||
val webView = createWebView()
|
val webView = createWebView()
|
||||||
webView.loadUrl(remoteUrl)
|
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl)
|
||||||
|
if (html != null) webView.loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
|
||||||
|
else webView.loadUrl(remoteUrl)
|
||||||
webView.layoutParams = params
|
webView.layoutParams = params
|
||||||
container.addView(webView)
|
container.addView(webView); zoneViews[zone.id] = webView
|
||||||
zoneViews[zone.id] = webView
|
if (multi) scheduleZoneAdvance(zone.id, durationMs, advance)
|
||||||
Log.i(TAG, "Zone ${zone.name}: youtube $remoteUrl")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video
|
// Video
|
||||||
mimeType.startsWith("video/") -> {
|
mimeType.startsWith("video/") -> {
|
||||||
val src = if (!remoteUrl.isNullOrEmpty()) remoteUrl
|
val src = if (!remoteUrl.isNullOrEmpty()) remoteUrl
|
||||||
else if (contentId != null) contentCache.getCachedFile(contentId)?.let { Uri.fromFile(it).toString() }
|
else if (contentId != null) renderCache?.getCachedFile(contentId)?.let { Uri.fromFile(it).toString() }
|
||||||
?: "$serverUrl/uploads/content/$filepath"
|
?: "$renderServerUrl/uploads/content/$filepath"
|
||||||
else continue
|
else { if (multi) scheduleZoneAdvance(zone.id, durationMs, advance); return }
|
||||||
|
|
||||||
val playerView = (android.view.LayoutInflater.from(context)
|
val playerView = (android.view.LayoutInflater.from(context)
|
||||||
.inflate(com.remotedisplay.player.R.layout.zone_player, null) as PlayerView).apply {
|
.inflate(com.remotedisplay.player.R.layout.zone_player, null) as PlayerView).apply {
|
||||||
useController = false
|
useController = false
|
||||||
|
|
@ -155,20 +219,19 @@ class ZoneManager(
|
||||||
}
|
}
|
||||||
val exoPlayer = ExoPlayer.Builder(context).build().apply {
|
val exoPlayer = ExoPlayer.Builder(context).build().apply {
|
||||||
setMediaItem(MediaItem.fromUri(src))
|
setMediaItem(MediaItem.fromUri(src))
|
||||||
repeatMode = Player.REPEAT_MODE_ALL
|
repeatMode = if (multi) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ALL
|
||||||
// Use muted flag from assignment, default unmuted for first video
|
|
||||||
volume = if (isMuted) 0f else 1f
|
volume = if (isMuted) 0f else 1f
|
||||||
|
if (multi) addListener(object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(state: Int) {
|
||||||
|
if (state == Player.STATE_ENDED) handler.post { advance() }
|
||||||
|
}
|
||||||
|
})
|
||||||
prepare()
|
prepare()
|
||||||
playWhenReady = true
|
playWhenReady = true
|
||||||
}
|
}
|
||||||
playerView.player = exoPlayer
|
playerView.player = exoPlayer
|
||||||
container.addView(playerView)
|
container.addView(playerView); zoneViews[zone.id] = playerView; zoneExoPlayers[zone.id] = exoPlayer
|
||||||
zoneViews[zone.id] = playerView
|
|
||||||
zoneExoPlayers[zone.id] = exoPlayer
|
|
||||||
activeVideoCount++
|
|
||||||
Log.i(TAG, "Zone ${zone.name}: video $src")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image
|
// Image
|
||||||
mimeType.startsWith("image/") -> {
|
mimeType.startsWith("image/") -> {
|
||||||
val imageView = ImageView(context).apply {
|
val imageView = ImageView(context).apply {
|
||||||
|
|
@ -179,44 +242,63 @@ class ZoneManager(
|
||||||
}
|
}
|
||||||
layoutParams = params
|
layoutParams = params
|
||||||
}
|
}
|
||||||
|
val targetW = if (params.width > 0) params.width else com.remotedisplay.player.util.ImageLoader.screenWidth(context)
|
||||||
// Load image
|
val targetH = if (params.height > 0) params.height else com.remotedisplay.player.util.ImageLoader.screenHeight(context)
|
||||||
val file = contentId?.let { contentCache.getCachedFile(it) }
|
val file = contentId?.let { renderCache?.getCachedFile(it) }
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
|
val bitmap = com.remotedisplay.player.util.ImageLoader.decodeFile(file, targetW, targetH)
|
||||||
if (bitmap != null) imageView.setImageBitmap(bitmap)
|
if (bitmap != null) {
|
||||||
} else if (!remoteUrl.isNullOrEmpty()) {
|
try { imageView.setImageBitmap(bitmap) } catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") }
|
||||||
// Load from URL in background
|
} else {
|
||||||
|
Log.w(TAG, "Zone ${zone.name}: skipping unloadable image $contentId")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// #78: not in the local cache yet (first-sync download still in flight, or a
|
||||||
|
// zone whose content the preloader hasn't fetched). Load straight from the
|
||||||
|
// server - mirrors how the video branch above falls back to a server URL -
|
||||||
|
// so the zone isn't blank until a restart populates the cache.
|
||||||
|
val imgUrl = if (!remoteUrl.isNullOrEmpty()) remoteUrl
|
||||||
|
else if (contentId != null) "$renderServerUrl/api/content/$contentId/file"
|
||||||
|
else null
|
||||||
|
if (imgUrl != null) {
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
val bitmap = com.remotedisplay.player.util.ImageLoader.decodeUrl(imgUrl, targetW, targetH)
|
||||||
val connection = java.net.URL(remoteUrl).openConnection()
|
if (bitmap != null) {
|
||||||
val input = connection.getInputStream()
|
imageView.post {
|
||||||
val bitmap = android.graphics.BitmapFactory.decodeStream(input)
|
try { imageView.setImageBitmap(bitmap) } catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") }
|
||||||
input.close()
|
}
|
||||||
imageView.post { if (bitmap != null) imageView.setImageBitmap(bitmap) }
|
} else {
|
||||||
} catch (e: Exception) {
|
Log.w(TAG, "Zone ${zone.name}: unloadable image $contentId via $imgUrl")
|
||||||
Log.e(TAG, "Image load failed: ${e.message}")
|
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
container.addView(imageView)
|
|
||||||
zoneViews[zone.id] = imageView
|
|
||||||
Log.i(TAG, "Zone ${zone.name}: image")
|
|
||||||
}
|
}
|
||||||
|
container.addView(imageView); zoneViews[zone.id] = imageView
|
||||||
|
if (multi) scheduleZoneAdvance(zone.id, durationMs, advance)
|
||||||
|
}
|
||||||
|
// Unknown / empty assignment - keep rotating so it doesn't get stuck.
|
||||||
|
else -> { if (multi) scheduleZoneAdvance(zone.id, durationMs, advance) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.i(TAG, "Rendered ${zoneViews.size} zone views")
|
private fun scheduleZoneAdvance(zoneId: String, delayMs: Long, advance: () -> Unit) {
|
||||||
|
val r = Runnable { advance() }
|
||||||
|
zoneRotators[zoneId] = r
|
||||||
|
handler.postDelayed(r, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelZoneRotation(zoneId: String) {
|
||||||
|
zoneRotators.remove(zoneId)?.let { handler.removeCallbacks(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelAllRotations() {
|
||||||
|
zoneRotators.values.forEach { handler.removeCallbacks(it) }
|
||||||
|
zoneRotators.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createWebView(): WebView {
|
private fun createWebView(): WebView {
|
||||||
return WebView(context).apply {
|
return WebView(context).apply {
|
||||||
settings.javaScriptEnabled = true
|
com.remotedisplay.player.util.WebViewSupport.configure(this, "Zone")
|
||||||
settings.domStorageEnabled = true
|
|
||||||
settings.mediaPlaybackRequiresUserGesture = false
|
|
||||||
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
|
||||||
webViewClient = WebViewClient()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,8 +308,12 @@ class ZoneManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
|
cancelAllRotations()
|
||||||
releaseExoPlayers()
|
releaseExoPlayers()
|
||||||
container.removeAllViews()
|
// Remove ONLY the views we added for zones; the activity's static views live
|
||||||
|
// in this same container and must NOT be removed (else single-zone/fullscreen
|
||||||
|
// playback, which reuses them, renders black).
|
||||||
|
zoneViews.values.forEach { container.removeView(it) }
|
||||||
zoneViews.clear()
|
zoneViews.clear()
|
||||||
zones = listOf()
|
zones = listOf()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,9 @@
|
||||||
package com.remotedisplay.player.service
|
package com.remotedisplay.player.service
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import com.remotedisplay.player.MainActivity
|
|
||||||
import com.remotedisplay.player.RemoteDisplayApp
|
|
||||||
|
|
||||||
class BootReceiver : BroadcastReceiver() {
|
class BootReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
|
@ -19,57 +13,9 @@ class BootReceiver : BroadcastReceiver() {
|
||||||
action == "com.htc.intent.action.QUICKBOOT_POWERON") {
|
action == "com.htc.intent.action.QUICKBOOT_POWERON") {
|
||||||
|
|
||||||
Log.i("BootReceiver", "Boot completed (action=$action), launching ScreenTinker")
|
Log.i("BootReceiver", "Boot completed (action=$action), launching ScreenTinker")
|
||||||
|
// #96: boot + post-update relaunch share one cascade (overlay-direct -> FSI/
|
||||||
// Start the foreground service
|
// tap-to-resume). See Relauncher.
|
||||||
try {
|
Relauncher.relaunch(context, Relauncher.BOOT)
|
||||||
val serviceIntent = Intent(context, WebSocketService::class.java)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
context.startForegroundService(serviceIntent)
|
|
||||||
} else {
|
|
||||||
context.startService(serviceIntent)
|
|
||||||
}
|
|
||||||
Log.i("BootReceiver", "WebSocket service started")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("BootReceiver", "Failed to start service: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a full-screen intent to launch the activity (bypasses Android 12+ restrictions)
|
|
||||||
try {
|
|
||||||
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
||||||
}
|
|
||||||
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
|
||||||
context, 0, launchIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, RemoteDisplayApp.CHANNEL_ID)
|
|
||||||
.setContentTitle("ScreenTinker")
|
|
||||||
.setContentText("Starting display...")
|
|
||||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
|
||||||
.setFullScreenIntent(pendingIntent, true)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
nm.notify(999, notification)
|
|
||||||
|
|
||||||
Log.i("BootReceiver", "Full-screen intent notification sent")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("BootReceiver", "Failed to launch via notification: ${e.message}")
|
|
||||||
// Fallback: try direct launch
|
|
||||||
try {
|
|
||||||
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
||||||
}
|
|
||||||
context.startActivity(launchIntent)
|
|
||||||
} catch (e2: Exception) {
|
|
||||||
Log.e("BootReceiver", "Direct launch also failed: ${e2.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
package com.remotedisplay.player.service
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.remotedisplay.player.RemoteDisplayApp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #5: Foreground service that owns the MediaProjection FGS type for system-wide
|
||||||
|
* screen capture (the `enable_system_capture` command).
|
||||||
|
*
|
||||||
|
* Android 14+ requires an FGS of type `mediaProjection` to be running - started
|
||||||
|
* AFTER the user grants consent - before MediaProjectionManager.getMediaProjection()
|
||||||
|
* may be called. An Activity can't enter that foreground state, so the consent
|
||||||
|
* Activity hands the result here. Kept separate from WebSocketService so the
|
||||||
|
* always-on service never claims the mediaProjection type at boot.
|
||||||
|
*/
|
||||||
|
class MediaProjectionService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MediaProjectionSvc"
|
||||||
|
private const val NOTIF_ID = 2
|
||||||
|
private const val EXTRA_RESULT_CODE = "result_code"
|
||||||
|
private const val EXTRA_RESULT_DATA = "result_data"
|
||||||
|
|
||||||
|
/** Start the projection FGS with the user's consent result. */
|
||||||
|
fun start(context: Context, resultCode: Int, data: Intent) {
|
||||||
|
val intent = Intent(context, MediaProjectionService::class.java).apply {
|
||||||
|
putExtra(EXTRA_RESULT_CODE, resultCode)
|
||||||
|
putExtra(EXTRA_RESULT_DATA, data)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, MediaProjectionService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
// Enter the foreground with the mediaProjection type FIRST (required on
|
||||||
|
// Android 14+ before getMediaProjection()).
|
||||||
|
startForegroundCompat()
|
||||||
|
|
||||||
|
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED)
|
||||||
|
?: Activity.RESULT_CANCELED
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val data: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
|
||||||
|
} else {
|
||||||
|
intent?.getParcelableExtra(EXTRA_RESULT_DATA)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultCode != Activity.RESULT_OK || data == null) {
|
||||||
|
Log.e(TAG, "Missing/invalid projection consent; stopping service")
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
ScreenCaptureService.startProjection(this, resultCode, data)
|
||||||
|
START_STICKY
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "startProjection failed: ${e.message}", e)
|
||||||
|
stopSelf()
|
||||||
|
START_NOT_STICKY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundCompat() {
|
||||||
|
val notif = NotificationCompat.Builder(this, RemoteDisplayApp.CHANNEL_ID)
|
||||||
|
.setContentTitle("ScreenTinker")
|
||||||
|
.setContentText("Screen capture active")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_camera)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIF_ID, notif)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
// Release the projection when the service goes away.
|
||||||
|
try { ScreenCaptureService.stop() } catch (_: Throwable) {}
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.remotedisplay.player.service
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #96: fires after the player updates itself via the OTA. When the app installs a new APK of
|
||||||
|
* its own package, the system sends ACTION_MY_PACKAGE_REPLACED to the freshly-installed app
|
||||||
|
* (in a new process). Without this, PACKAGE_REPLACED kills the old process and nothing brings
|
||||||
|
* MainActivity back - the screen drops to the launcher, which is the 1.9.0 fleet bug.
|
||||||
|
*
|
||||||
|
* Relaunch through the exact same cascade as boot (see [Relauncher]).
|
||||||
|
*/
|
||||||
|
class PackageReplacedReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||||
|
Log.i("PackageReplaced", "App updated (MY_PACKAGE_REPLACED) - relaunching")
|
||||||
|
Relauncher.relaunch(context, Relauncher.UPDATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import android.util.DisplayMetrics
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.accessibility.AccessibilityEvent
|
import android.view.accessibility.AccessibilityEvent
|
||||||
|
import android.view.accessibility.AccessibilityNodeInfo
|
||||||
|
|
||||||
class PowerAccessibilityService : AccessibilityService() {
|
class PowerAccessibilityService : AccessibilityService() {
|
||||||
|
|
||||||
|
|
@ -22,7 +23,53 @@ class PowerAccessibilityService : AccessibilityService() {
|
||||||
Log.i(TAG, "Service connected")
|
Log.i(TAG, "Service connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
|
private var lastConfirm = 0L
|
||||||
|
|
||||||
|
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||||
|
val pkg = event?.packageName?.toString() ?: return
|
||||||
|
// Auto-confirm the system app-update dialog so OTA updates apply unattended
|
||||||
|
// on kiosk screens (no one is there to tap "Update"). Scoped to the package
|
||||||
|
// installer only, so this never touches anything else.
|
||||||
|
if (!pkg.contains("packageinstaller", ignoreCase = true)) return
|
||||||
|
if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED &&
|
||||||
|
event.eventType != AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) return
|
||||||
|
autoConfirmInstall()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun autoConfirmInstall() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastConfirm < 1500) return // debounce repeated content events
|
||||||
|
val root = rootInActiveWindow ?: return
|
||||||
|
// Positive button by resource id first (locale-independent), then by label.
|
||||||
|
val ids = listOf(
|
||||||
|
"com.google.android.packageinstaller:id/ok_button",
|
||||||
|
"com.android.packageinstaller:id/ok_button",
|
||||||
|
"android:id/button1"
|
||||||
|
)
|
||||||
|
for (id in ids) {
|
||||||
|
for (n in root.findAccessibilityNodeInfosByViewId(id)) {
|
||||||
|
if (clickButton(n)) { lastConfirm = now; Log.i(TAG, "Auto-confirmed install via $id"); return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (label in listOf("Update", "Install", "Reinstall", "Continue")) {
|
||||||
|
for (n in root.findAccessibilityNodeInfosByText(label)) {
|
||||||
|
if (clickButton(n)) { lastConfirm = now; Log.i(TAG, "Auto-confirmed install via '$label'"); return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click the node or its nearest clickable+enabled ancestor (the button).
|
||||||
|
private fun clickButton(node: AccessibilityNodeInfo?): Boolean {
|
||||||
|
var cur = node
|
||||||
|
var depth = 0
|
||||||
|
while (cur != null && depth < 4) {
|
||||||
|
if (cur.isClickable && cur.isEnabled) return cur.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||||
|
cur = cur.parent
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
override fun onInterrupt() {}
|
override fun onInterrupt() {}
|
||||||
|
|
||||||
// Global actions
|
// Global actions
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
package com.remotedisplay.player.service
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.remotedisplay.player.MainActivity
|
||||||
|
import com.remotedisplay.player.RemoteDisplayApp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings the player back to the foreground after a trigger (device boot or a self-update).
|
||||||
|
* Shared by [BootReceiver] and [PackageReplacedReceiver] so both relaunch through the SAME
|
||||||
|
* cascade (#96).
|
||||||
|
*
|
||||||
|
* A BroadcastReceiver runs in the background, and Android 10+ blocks a bare startActivity
|
||||||
|
* from the background. The cascade, most-reliable first:
|
||||||
|
*
|
||||||
|
* 1. Overlay-direct startActivity — legal on EVERY version IF SYSTEM_ALERT_WINDOW is
|
||||||
|
* granted (the documented background-activity-launch exemption). Covers MAXHUB
|
||||||
|
* (elevated), any properly-onboarded device, and Fire OS 7 (Android 9, no restriction).
|
||||||
|
* 2. Notification — on Android <14 a full-screen intent AUTO-LAUNCHES the activity (covers
|
||||||
|
* FireOS, which is Android 9–11); on 14+, where USE_FULL_SCREEN_INTENT is auto-revoked,
|
||||||
|
* it degrades to a VISIBLE, tappable "tap to resume" prompt. That is the requirement
|
||||||
|
* (a) fail-loud path: human-recoverable, never a silent dark screen. The server sees
|
||||||
|
* the device's next check-in — or its absence — via the #96 update logging.
|
||||||
|
*
|
||||||
|
* The only device class with no path here is vanilla Android 14+ with neither the overlay
|
||||||
|
* granted nor the app set as home launcher — for those it stops at the visible prompt.
|
||||||
|
*/
|
||||||
|
object Relauncher {
|
||||||
|
private const val TAG = "Relauncher"
|
||||||
|
const val UPDATE = "update"
|
||||||
|
const val BOOT = "boot"
|
||||||
|
|
||||||
|
fun relaunch(context: Context, reason: String) {
|
||||||
|
// Keep the WS foreground service alive (it drives playback + reconnect).
|
||||||
|
try {
|
||||||
|
val svc = Intent(context, WebSocketService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(svc)
|
||||||
|
else context.startService(svc)
|
||||||
|
Log.i(TAG, "[$reason] WebSocket service started")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "[$reason] Failed to start service: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Overlay-direct: the most reliable bg-launch path when the overlay is granted.
|
||||||
|
var launched = false
|
||||||
|
if (Settings.canDrawOverlays(context)) {
|
||||||
|
try {
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
launched = true
|
||||||
|
Log.i(TAG, "[$reason] Direct launch (overlay permission)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "[$reason] Direct launch failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Notification: <14 full-screen-intent auto-launch; 14+/no-overlay the visible
|
||||||
|
// tap-to-resume prompt. Posted even if (1) launched, so a 14+ device that could
|
||||||
|
// not auto-launch always has a tappable way back (fail loud, never dark).
|
||||||
|
postRelaunchNotification(context, launchIntent, reason, launched)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postRelaunchNotification(context: Context, launchIntent: Intent, reason: String, alreadyLaunched: Boolean) {
|
||||||
|
try {
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
context, 0, launchIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val isUpdate = reason == UPDATE
|
||||||
|
val builder = NotificationCompat.Builder(context, RemoteDisplayApp.BOOT_CHANNEL_ID)
|
||||||
|
.setContentTitle(if (isUpdate) "ScreenTinker updated" else "ScreenTinker")
|
||||||
|
.setContentText(if (isUpdate) "Tap to resume the display" else "Starting display...")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||||
|
.setContentIntent(pi) // tap -> launch (the path on 14+ where FSI is revoked)
|
||||||
|
.setFullScreenIntent(pi, true) // <14: auto-launch
|
||||||
|
.setAutoCancel(true)
|
||||||
|
// Fail-loud: if we could not auto-launch (14+, no overlay), keep the prompt
|
||||||
|
// sticky until the operator taps it to resume.
|
||||||
|
if (isUpdate && !alreadyLaunched) builder.setOngoing(true)
|
||||||
|
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.notify(999, builder.build())
|
||||||
|
Log.i(TAG, "[$reason] Relaunch notification posted (fullScreenIntent + tappable, ongoing=${isUpdate && !alreadyLaunched})")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "[$reason] Notification failed: ${e.message}")
|
||||||
|
if (!alreadyLaunched) {
|
||||||
|
// last-ditch: try a direct launch even though bg-launch may be blocked.
|
||||||
|
try { context.startActivity(launchIntent) } catch (e2: Exception) { Log.e(TAG, "[$reason] Last-ditch launch failed: ${e2.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,13 +54,9 @@ object ScreenCaptureService {
|
||||||
|
|
||||||
imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4)
|
imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4)
|
||||||
|
|
||||||
virtualDisplay = projection.createVirtualDisplay(
|
// #5: Android 14+ requires a Callback registered BEFORE createVirtualDisplay,
|
||||||
"ScreenTinker",
|
// otherwise createVirtualDisplay throws IllegalStateException. (Was registered
|
||||||
captureWidth, captureHeight, density,
|
// after, which broke system capture on Android 14+.)
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
|
||||||
imageReader!!.surface, null, null
|
|
||||||
)
|
|
||||||
|
|
||||||
projection.registerCallback(object : MediaProjection.Callback() {
|
projection.registerCallback(object : MediaProjection.Callback() {
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
Log.i(TAG, "MediaProjection stopped by system")
|
Log.i(TAG, "MediaProjection stopped by system")
|
||||||
|
|
@ -68,6 +64,13 @@ object ScreenCaptureService {
|
||||||
}
|
}
|
||||||
}, null)
|
}, null)
|
||||||
|
|
||||||
|
virtualDisplay = projection.createVirtualDisplay(
|
||||||
|
"ScreenTinker",
|
||||||
|
captureWidth, captureHeight, density,
|
||||||
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||||
|
imageReader!!.surface, null, null
|
||||||
|
)
|
||||||
|
|
||||||
Log.i(TAG, "MediaProjection started: ${captureWidth}x${captureHeight}")
|
Log.i(TAG, "MediaProjection started: ${captureWidth}x${captureHeight}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.Signature
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
|
@ -17,6 +20,7 @@ import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class UpdateChecker(private val context: Context) {
|
class UpdateChecker(private val context: Context) {
|
||||||
|
|
@ -33,8 +37,45 @@ class UpdateChecker(private val context: Context) {
|
||||||
// Check every 30 minutes
|
// Check every 30 minutes
|
||||||
private val CHECK_INTERVAL = 30 * 60 * 1000L
|
private val CHECK_INTERVAL = 30 * 60 * 1000L
|
||||||
|
|
||||||
|
private var installReceiverRegistered = false
|
||||||
|
|
||||||
|
// The PackageInstaller session reports its status (incl. STATUS_PENDING_USER_ACTION,
|
||||||
|
// which Android 13+ returns for non-device-owner installers) via this broadcast.
|
||||||
|
// Without handling it the committed session just stalls and the update never
|
||||||
|
// installs. On the action prompt we launch the confirm dialog; the accessibility
|
||||||
|
// service auto-confirms it on kiosks.
|
||||||
|
private fun ensureInstallReceiver() {
|
||||||
|
if (installReceiverRegistered) return
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(ctx: Context, intent: Intent) {
|
||||||
|
when (intent.getIntExtra(android.content.pm.PackageInstaller.EXTRA_STATUS, -999)) {
|
||||||
|
android.content.pm.PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||||
|
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||||
|
if (confirm != null) {
|
||||||
|
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
try { context.startActivity(confirm); Log.i(TAG, "Launched install confirmation") }
|
||||||
|
catch (e: Exception) { Log.e(TAG, "Confirm launch failed: ${e.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
android.content.pm.PackageInstaller.STATUS_SUCCESS -> Log.i(TAG, "Update installed successfully")
|
||||||
|
else -> Log.w(TAG, "Install status: ${intent.getStringExtra(android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val filter = IntentFilter("com.remotedisplay.player.INSTALL_COMPLETE")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
@Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter)
|
||||||
|
}
|
||||||
|
installReceiverRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
fun startPeriodicCheck() {
|
fun startPeriodicCheck() {
|
||||||
stopPeriodicCheck()
|
stopPeriodicCheck()
|
||||||
|
ensureInstallReceiver()
|
||||||
checkTimer = object : Runnable {
|
checkTimer = object : Runnable {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
checkForUpdate()
|
checkForUpdate()
|
||||||
|
|
@ -107,6 +148,20 @@ class UpdateChecker(private val context: Context) {
|
||||||
|
|
||||||
Log.i(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
|
Log.i(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
|
||||||
|
|
||||||
|
// SECURITY (#5 review): never install an APK we didn't sign. The update
|
||||||
|
// is fetched from a server-supplied URL, often over cleartext with no
|
||||||
|
// pinning - a MITM or compromised server could otherwise return a
|
||||||
|
// malicious APK and get it silently installed (REQUEST_INSTALL_PACKAGES).
|
||||||
|
// Verify the downloaded APK is our package AND signed by the same key as
|
||||||
|
// the currently-installed app before installing. An attacker can't forge
|
||||||
|
// our signature, so this holds even over an untrusted transport.
|
||||||
|
if (!verifyApkSignature(apkFile)) {
|
||||||
|
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
|
||||||
|
apkFile.delete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.i(TAG, "APK signature verified against installed app - proceeding to install")
|
||||||
|
|
||||||
// Install the APK
|
// Install the APK
|
||||||
handler.post {
|
handler.post {
|
||||||
installApk(apkFile)
|
installApk(apkFile)
|
||||||
|
|
@ -166,9 +221,15 @@ class UpdateChecker(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #96 (install bug): the status PendingIntent must stay FLAG_MUTABLE so
|
||||||
|
// PackageInstaller can write EXTRA_STATUS back into it - but on Android 14+
|
||||||
|
// (target SDK 34+) a FLAG_MUTABLE PendingIntent with an *implicit* intent is
|
||||||
|
// disallowed and getBroadcast() throws, silently aborting every OTA on 14+.
|
||||||
|
// Make the intent explicit (setPackage) so mutable is allowed; it also keeps
|
||||||
|
// the broadcast to our own RECEIVER_NOT_EXPORTED receiver.
|
||||||
val pendingIntent = android.app.PendingIntent.getBroadcast(
|
val pendingIntent = android.app.PendingIntent.getBroadcast(
|
||||||
context, sessionId,
|
context, sessionId,
|
||||||
Intent("com.remotedisplay.player.INSTALL_COMPLETE"),
|
Intent("com.remotedisplay.player.INSTALL_COMPLETE").setPackage(context.packageName),
|
||||||
android.app.PendingIntent.FLAG_MUTABLE
|
android.app.PendingIntent.FLAG_MUTABLE
|
||||||
)
|
)
|
||||||
session.commit(pendingIntent.intentSender)
|
session.commit(pendingIntent.intentSender)
|
||||||
|
|
@ -179,6 +240,56 @@ class UpdateChecker(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True only if the downloaded APK is this same package and shares a signing
|
||||||
|
// certificate with the installed app. Fail-closed on any error.
|
||||||
|
private fun verifyApkSignature(apkFile: File): Boolean {
|
||||||
|
return try {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||||
|
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||||
|
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, flags)
|
||||||
|
if (downloaded == null) {
|
||||||
|
Log.e(TAG, "Could not parse downloaded APK")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (downloaded.packageName != context.packageName) {
|
||||||
|
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val installed = pm.getPackageInfo(context.packageName, flags)
|
||||||
|
val downloadedSigs = signingCertHashes(downloaded)
|
||||||
|
val installedSigs = signingCertHashes(installed)
|
||||||
|
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) {
|
||||||
|
Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Share at least one current signing certificate.
|
||||||
|
val match = downloadedSigs.any { it in installedSigs }
|
||||||
|
if (!match) Log.e(TAG, "APK signing certificate does not match installed app")
|
||||||
|
match
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Signature verification error: ${e.message}", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signingCertHashes(info: PackageInfo): Set<String> {
|
||||||
|
val sigs: Array<Signature>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
info.signingInfo?.apkContentsSigners
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") info.signatures
|
||||||
|
}
|
||||||
|
return sigs?.mapNotNull { sha256(it.toByteArray()) }?.toSet() ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256(bytes: ByteArray): String? {
|
||||||
|
return try {
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getAppVersion(): String {
|
private fun getAppVersion(): String {
|
||||||
return try {
|
return try {
|
||||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,16 @@ class WebSocketService : Service() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
config = ServerConfig(this)
|
config = ServerConfig(this)
|
||||||
deviceInfo = DeviceInfo(this)
|
deviceInfo = DeviceInfo(this)
|
||||||
|
// #5: claim ONLY the mediaPlayback FGS type. The 2-arg startForeground
|
||||||
|
// claims every manifest-declared type, and on Android 14+ claiming
|
||||||
|
// mediaProjection without a consent token throws and kills the service at
|
||||||
|
// boot (the "app won't run on newer Android" symptom). Screen capture has
|
||||||
|
// its own mediaProjection-typed service (MediaProjectionService).
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(1, createNotification(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||||
|
} else {
|
||||||
startForeground(1, createNotification())
|
startForeground(1, createNotification())
|
||||||
|
}
|
||||||
|
|
||||||
// Keep CPU alive so the WebSocket connection stays alive in background
|
// Keep CPU alive so the WebSocket connection stays alive in background
|
||||||
val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager
|
val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager
|
||||||
|
|
@ -65,6 +74,21 @@ class WebSocketService : Service() {
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrap every Socket.IO listener body in try/catch. A malformed payload from the server
|
||||||
|
// (or a transient state error during disconnect) used to surface as an unhandled
|
||||||
|
// exception on the Socket.IO IO thread and crash the whole app.
|
||||||
|
private fun Socket.safeOn(event: String, handler: (Array<Any?>) -> Unit): Socket {
|
||||||
|
on(event) { args ->
|
||||||
|
try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
handler(args as Array<Any?>)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("WebSocketService", "Listener for '$event' failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(serverUrl: String? = null) {
|
fun connect(serverUrl: String? = null) {
|
||||||
val url = serverUrl ?: config.serverUrl
|
val url = serverUrl ?: config.serverUrl
|
||||||
if (url.isEmpty()) {
|
if (url.isEmpty()) {
|
||||||
|
|
@ -79,174 +103,221 @@ class WebSocketService : Service() {
|
||||||
forceNew = true
|
forceNew = true
|
||||||
reconnection = true
|
reconnection = true
|
||||||
reconnectionAttempts = Integer.MAX_VALUE
|
reconnectionAttempts = Integer.MAX_VALUE
|
||||||
reconnectionDelay = 2000
|
// Exponential backoff: starts at 1s, doubles each attempt, capped at 60s,
|
||||||
reconnectionDelayMax = 10000
|
// ±50% jitter so a fleet doesn't reconnect in lockstep after a server blip.
|
||||||
|
reconnectionDelay = 1000
|
||||||
|
reconnectionDelayMax = 60_000
|
||||||
|
randomizationFactor = 0.5
|
||||||
timeout = 20000
|
timeout = 20000
|
||||||
}
|
}
|
||||||
|
|
||||||
socket = IO.socket(URI.create("$url/device"), options).apply {
|
socket = IO.socket(URI.create("$url/device"), options).apply {
|
||||||
on(Socket.EVENT_CONNECT) {
|
safeOn(Socket.EVENT_CONNECT) {
|
||||||
Log.i("WebSocketService", "Connected to server")
|
Log.i("WebSocketService", "Connected to server")
|
||||||
register()
|
register()
|
||||||
}
|
}
|
||||||
|
|
||||||
on(Socket.EVENT_DISCONNECT) {
|
safeOn(Socket.EVENT_DISCONNECT) { args ->
|
||||||
Log.w("WebSocketService", "Disconnected from server")
|
val reason = args.firstOrNull()?.toString() ?: "unknown"
|
||||||
|
Log.w("WebSocketService", "Disconnected from server: $reason")
|
||||||
|
// Stop heartbeat while disconnected; player keeps showing cached content.
|
||||||
|
// Socket.IO will reconnect automatically per the options above.
|
||||||
|
stopHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
on(Socket.EVENT_CONNECT_ERROR) { args ->
|
safeOn(Socket.EVENT_CONNECT_ERROR) { args ->
|
||||||
Log.e("WebSocketService", "Connection error: ${args.firstOrNull()}")
|
Log.e("WebSocketService", "Connection error: ${args.firstOrNull()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:registered") { args ->
|
safeOn("device:registered") { args ->
|
||||||
val data = args[0] as JSONObject
|
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
|
||||||
val newDeviceId = data.getString("device_id")
|
val newDeviceId = data.optString("device_id", "")
|
||||||
|
if (newDeviceId.isEmpty()) {
|
||||||
|
Log.w("WebSocketService", "device:registered missing device_id")
|
||||||
|
return@safeOn
|
||||||
|
}
|
||||||
config.deviceId = newDeviceId
|
config.deviceId = newDeviceId
|
||||||
|
// Persist device_token (issued on first register, or refreshed on reconnect)
|
||||||
|
if (data.has("device_token")) {
|
||||||
|
config.deviceToken = data.optString("device_token", "")
|
||||||
|
}
|
||||||
Log.i("WebSocketService", "Registered as: $newDeviceId")
|
Log.i("WebSocketService", "Registered as: $newDeviceId")
|
||||||
handler.post { onRegistered?.invoke(newDeviceId) }
|
handler.post { try { onRegistered?.invoke(newDeviceId) } catch (e: Throwable) { Log.e("WebSocketService", "onRegistered cb: ${e.message}") } }
|
||||||
startHeartbeat()
|
startHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:unpaired") {
|
safeOn("device:unpaired") {
|
||||||
Log.w("WebSocketService", "Device not found on server - clearing config")
|
Log.w("WebSocketService", "Device not found on server - clearing credentials")
|
||||||
config.setPaired(false)
|
config.clearDeviceCredentials()
|
||||||
config.deviceId = ""
|
handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
|
||||||
handler.post { onUnpaired?.invoke() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:paired") { args ->
|
safeOn("device:auth-error") { args ->
|
||||||
val data = args[0] as JSONObject
|
val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed"
|
||||||
val id = data.getString("device_id")
|
Log.w("WebSocketService", "Device auth rejected: $msg — clearing credentials for re-pair")
|
||||||
|
config.clearDeviceCredentials()
|
||||||
|
handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
|
||||||
|
}
|
||||||
|
|
||||||
|
safeOn("device:paired") { args ->
|
||||||
|
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
|
||||||
|
val id = data.optString("device_id", "")
|
||||||
val name = data.optString("name", "Display")
|
val name = data.optString("name", "Display")
|
||||||
config.setPaired(true)
|
config.setPaired(true)
|
||||||
config.deviceName = name
|
config.deviceName = name
|
||||||
Log.i("WebSocketService", "Paired as: $name")
|
Log.i("WebSocketService", "Paired as: $name")
|
||||||
handler.post { onPaired?.invoke(id, name) }
|
handler.post { try { onPaired?.invoke(id, name) } catch (e: Throwable) { Log.e("WebSocketService", "onPaired cb: ${e.message}") } }
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:playlist-update") { args ->
|
safeOn("device:playlist-update") { args ->
|
||||||
Log.i("WebSocketService", "Playlist raw args: ${args.size} items, type=${args[0]?.javaClass?.name}, data=${args[0]}")
|
val data = args.firstOrNull() as? JSONObject ?: run {
|
||||||
val data = args[0] as JSONObject
|
Log.w("WebSocketService", "playlist-update with non-JSONObject payload: ${args.firstOrNull()}")
|
||||||
Log.i("WebSocketService", "Playlist update received, keys=${data.keys().asSequence().toList()}, assignments=${data.optJSONArray("assignments")?.length() ?: "null"}")
|
return@safeOn
|
||||||
handler.post { onPlaylistUpdate?.invoke(data) }
|
}
|
||||||
|
Log.i("WebSocketService", "Playlist update received, assignments=${data.optJSONArray("assignments")?.length() ?: "null"}")
|
||||||
|
handler.post { try { onPlaylistUpdate?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onPlaylistUpdate cb: ${e.message}") } }
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:content-delete") { args ->
|
safeOn("device:content-delete") { args ->
|
||||||
val data = args[0] as JSONObject
|
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
|
||||||
val contentId = data.getString("content_id")
|
val contentId = data.optString("content_id", "")
|
||||||
handler.post { onContentDelete?.invoke(contentId) }
|
if (contentId.isNotEmpty()) {
|
||||||
|
handler.post { try { onContentDelete?.invoke(contentId) } catch (e: Throwable) { Log.e("WebSocketService", "onContentDelete cb: ${e.message}") } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:screenshot-request") {
|
safeOn("device:screenshot-request") {
|
||||||
captureAndSendScreenshot()
|
captureAndSendScreenshot()
|
||||||
handler.post { onScreenshotRequest?.invoke() }
|
handler.post { try { onScreenshotRequest?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onScreenshotRequest cb: ${e.message}") } }
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:remote-start") {
|
safeOn("device:remote-start") {
|
||||||
startScreenshotStream()
|
startScreenshotStream()
|
||||||
handler.post { onRemoteStart?.invoke() }
|
handler.post { try { onRemoteStart?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteStart cb: ${e.message}") } }
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:remote-stop") {
|
safeOn("device:remote-stop") {
|
||||||
stopScreenshotStream()
|
stopScreenshotStream()
|
||||||
handler.post { onRemoteStop?.invoke() }
|
handler.post { try { onRemoteStop?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteStop cb: ${e.message}") } }
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:remote-touch") { args ->
|
safeOn("device:remote-touch") { args ->
|
||||||
val data = args[0] as JSONObject
|
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
|
||||||
val x = data.getDouble("x").toFloat()
|
val x = data.optDouble("x", 0.0).toFloat()
|
||||||
val y = data.getDouble("y").toFloat()
|
val y = data.optDouble("y", 0.0).toFloat()
|
||||||
val action = data.optString("action", "tap")
|
val action = data.optString("action", "tap")
|
||||||
// Use AccessibilityService for system-wide touch (works on dialogs too)
|
|
||||||
val svc = PowerAccessibilityService.instance
|
val svc = PowerAccessibilityService.instance
|
||||||
if (svc != null && action == "tap") {
|
if (svc != null && action == "tap") {
|
||||||
handler.post { svc.injectTap(x, y) }
|
handler.post { try { svc.injectTap(x, y) } catch (e: Throwable) { Log.e("WebSocketService", "injectTap: ${e.message}") } }
|
||||||
} else {
|
} else {
|
||||||
handler.post { onRemoteTouch?.invoke(x, y, action) }
|
handler.post { try { onRemoteTouch?.invoke(x, y, action) } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteTouch cb: ${e.message}") } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:remote-key") { args ->
|
safeOn("device:remote-key") { args ->
|
||||||
val data = args[0] as JSONObject
|
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
|
||||||
val keycode = data.getString("keycode")
|
val keycode = data.optString("keycode", "")
|
||||||
// Always inject via shell (works even when app not in foreground)
|
if (keycode.isEmpty()) return@safeOn
|
||||||
injectKey(keycode)
|
injectKey(keycode)
|
||||||
handler.post { onRemoteKey?.invoke(keycode) }
|
handler.post { try { onRemoteKey?.invoke(keycode) } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteKey cb: ${e.message}") } }
|
||||||
}
|
}
|
||||||
|
|
||||||
on("device:command") { args ->
|
safeOn("device:command") { args ->
|
||||||
val data = args[0] as JSONObject
|
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
|
||||||
val type = data.getString("type")
|
val type = data.optString("type", "")
|
||||||
|
if (type.isEmpty()) return@safeOn
|
||||||
val payload = data.optJSONObject("payload")
|
val payload = data.optJSONObject("payload")
|
||||||
Log.i("WebSocketService", "Command received: $type")
|
Log.i("WebSocketService", "Command received: $type")
|
||||||
|
|
||||||
// Handle system commands directly in the service
|
|
||||||
when (type) {
|
when (type) {
|
||||||
"launch" -> {
|
"launch" -> {
|
||||||
handler.post {
|
handler.post {
|
||||||
|
try {
|
||||||
val intent = Intent(this@WebSocketService, MainActivity::class.java).apply {
|
val intent = Intent(this@WebSocketService, MainActivity::class.java).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
}
|
}
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
Log.i("WebSocketService", "Launched MainActivity from service")
|
Log.i("WebSocketService", "Launched MainActivity from service")
|
||||||
|
} catch (e: Throwable) { Log.e("WebSocketService", "launch cmd: ${e.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"settings" -> {
|
"settings" -> {
|
||||||
handler.post {
|
handler.post {
|
||||||
|
try {
|
||||||
val intent = Intent(android.provider.Settings.ACTION_SETTINGS).apply {
|
val intent = Intent(android.provider.Settings.ACTION_SETTINGS).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
Log.i("WebSocketService", "Opened system settings")
|
} catch (e: Throwable) { Log.e("WebSocketService", "settings cmd: ${e.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"enable_system_capture" -> {
|
"enable_system_capture" -> {
|
||||||
// Trigger MediaProjection permission request on device
|
|
||||||
handler.post {
|
handler.post {
|
||||||
|
try {
|
||||||
com.remotedisplay.player.ScreenCapturePermissionActivity.requestPermission(this@WebSocketService)
|
com.remotedisplay.player.ScreenCapturePermissionActivity.requestPermission(this@WebSocketService)
|
||||||
Log.i("WebSocketService", "Requesting system capture permission")
|
} catch (e: Throwable) { Log.e("WebSocketService", "enable_system_capture: ${e.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"screen_off" -> {
|
"screen_off" -> {
|
||||||
val a11y = PowerAccessibilityService.instance
|
val a11y = PowerAccessibilityService.instance
|
||||||
if (a11y != null) {
|
if (a11y != null) {
|
||||||
handler.post { a11y.lockScreen() }
|
handler.post { try { a11y.lockScreen() } catch (e: Throwable) { Log.e("WebSocketService", "lockScreen: ${e.message}") } }
|
||||||
} else {
|
} else {
|
||||||
Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() } catch (_: Exception) {} }.start()
|
Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() } catch (_: Exception) {} }.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"screen_on" -> {
|
"screen_on" -> {
|
||||||
// WAKEUP keyevent works from shell on most devices
|
|
||||||
Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() } catch (_: Exception) {} }.start()
|
Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() } catch (_: Exception) {} }.start()
|
||||||
}
|
}
|
||||||
else -> handler.post { onCommand?.invoke(type, payload) }
|
"set_debug" -> {
|
||||||
|
val on = payload?.optBoolean("enabled", false) ?: false
|
||||||
|
// Point the sink at this socket, then flip the flag. When on,
|
||||||
|
// DebugLog.* mirrors player/zone lines to the dashboard.
|
||||||
|
com.remotedisplay.player.util.DebugLog.sink = { tag, level, msg ->
|
||||||
|
try {
|
||||||
|
socket?.emit("device:log", JSONObject().apply {
|
||||||
|
put("tag", tag); put("level", level); put("message", msg)
|
||||||
|
})
|
||||||
|
} catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
com.remotedisplay.player.util.DebugLog.enabled = on
|
||||||
|
Log.i("WebSocketService", "Remote debug logging ${if (on) "ENABLED" else "disabled"}")
|
||||||
|
com.remotedisplay.player.util.DebugLog.i("Debug", "Remote debug logging ${if (on) "ON" else "OFF"}")
|
||||||
|
}
|
||||||
|
else -> handler.post { try { onCommand?.invoke(type, payload) } catch (e: Throwable) { Log.e("WebSocketService", "onCommand cb: ${e.message}") } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
Log.e("WebSocketService", "Socket setup error: ${e.message}")
|
Log.e("WebSocketService", "Socket setup error: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun register() {
|
private fun register() {
|
||||||
|
try {
|
||||||
val data = JSONObject().apply {
|
val data = JSONObject().apply {
|
||||||
if (config.isProvisioned && config.isPaired) {
|
if (config.isProvisioned && config.isPaired) {
|
||||||
put("device_id", config.deviceId)
|
put("device_id", config.deviceId)
|
||||||
|
val token = config.deviceToken
|
||||||
|
if (token.isNotEmpty()) {
|
||||||
|
put("device_token", token)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Generate a pairing code if we don't have one
|
|
||||||
val pairingCode = (100000..999999).random().toString()
|
val pairingCode = (100000..999999).random().toString()
|
||||||
put("pairing_code", pairingCode)
|
put("pairing_code", pairingCode)
|
||||||
config.deviceId = "" // Will be set on registered event
|
config.deviceId = ""
|
||||||
// Store pairing code temporarily
|
|
||||||
getSharedPreferences("remote_display", MODE_PRIVATE)
|
getSharedPreferences("remote_display", MODE_PRIVATE)
|
||||||
.edit().putString("pairing_code", pairingCode).apply()
|
.edit().putString("pairing_code", pairingCode).apply()
|
||||||
}
|
}
|
||||||
put("device_info", deviceInfo.getDeviceInfo())
|
try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") }
|
||||||
put("fingerprint", deviceInfo.getFingerprint())
|
try { put("fingerprint", deviceInfo.getFingerprint()) } catch (e: Throwable) { Log.w("WebSocketService", "fingerprint: ${e.message}") }
|
||||||
}
|
}
|
||||||
socket?.emit("device:register", data)
|
socket?.emit("device:register", data)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("WebSocketService", "register failed: ${e.message}", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getPairingCode(): String {
|
fun getPairingCode(): String {
|
||||||
|
|
@ -276,12 +347,17 @@ class WebSocketService : Service() {
|
||||||
fun requestPlaylistRefresh() {
|
fun requestPlaylistRefresh() {
|
||||||
if (socket?.connected() != true || config.deviceId.isEmpty()) return
|
if (socket?.connected() != true || config.deviceId.isEmpty()) return
|
||||||
Log.i("WebSocketService", "Requesting playlist refresh")
|
Log.i("WebSocketService", "Requesting playlist refresh")
|
||||||
// Re-register triggers the server to send current playlist
|
try {
|
||||||
val data = org.json.JSONObject().apply {
|
val data = org.json.JSONObject().apply {
|
||||||
put("device_id", config.deviceId)
|
put("device_id", config.deviceId)
|
||||||
put("device_info", deviceInfo.getDeviceInfo())
|
val token = config.deviceToken
|
||||||
|
if (token.isNotEmpty()) put("device_token", token)
|
||||||
|
try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") }
|
||||||
}
|
}
|
||||||
socket?.emit("device:register", data)
|
socket?.emit("device:register", data)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("WebSocketService", "requestPlaylistRefresh failed: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopHeartbeat() {
|
private fun stopHeartbeat() {
|
||||||
|
|
@ -291,11 +367,15 @@ class WebSocketService : Service() {
|
||||||
|
|
||||||
private fun sendHeartbeat() {
|
private fun sendHeartbeat() {
|
||||||
if (socket?.connected() != true) return
|
if (socket?.connected() != true) return
|
||||||
|
try {
|
||||||
val data = JSONObject().apply {
|
val data = JSONObject().apply {
|
||||||
put("device_id", config.deviceId)
|
put("device_id", config.deviceId)
|
||||||
put("telemetry", deviceInfo.getTelemetry())
|
try { put("telemetry", deviceInfo.getTelemetry()) } catch (e: Throwable) { Log.w("WebSocketService", "telemetry: ${e.message}") }
|
||||||
}
|
}
|
||||||
socket?.emit("device:heartbeat", data)
|
socket?.emit("device:heartbeat", data)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e("WebSocketService", "sendHeartbeat failed: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Screenshot streaming from the service (works even when activity is paused)
|
// Screenshot streaming from the service (works even when activity is paused)
|
||||||
|
|
@ -362,11 +442,13 @@ class WebSocketService : Service() {
|
||||||
|
|
||||||
fun sendScreenshot(imageBase64: String) {
|
fun sendScreenshot(imageBase64: String) {
|
||||||
if (socket?.connected() != true) return
|
if (socket?.connected() != true) return
|
||||||
|
try {
|
||||||
val data = JSONObject().apply {
|
val data = JSONObject().apply {
|
||||||
put("device_id", config.deviceId)
|
put("device_id", config.deviceId)
|
||||||
put("image_b64", imageBase64)
|
put("image_b64", imageBase64)
|
||||||
}
|
}
|
||||||
socket?.emit("device:screenshot", data)
|
socket?.emit("device:screenshot", data)
|
||||||
|
} catch (e: Throwable) { Log.w("WebSocketService", "sendScreenshot: ${e.message}") }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun injectKey(keycode: String) {
|
private fun injectKey(keycode: String) {
|
||||||
|
|
@ -421,28 +503,32 @@ class WebSocketService : Service() {
|
||||||
|
|
||||||
fun sendContentAck(contentId: String, status: String) {
|
fun sendContentAck(contentId: String, status: String) {
|
||||||
if (socket?.connected() != true) return
|
if (socket?.connected() != true) return
|
||||||
|
try {
|
||||||
val data = JSONObject().apply {
|
val data = JSONObject().apply {
|
||||||
put("device_id", config.deviceId)
|
put("device_id", config.deviceId)
|
||||||
put("content_id", contentId)
|
put("content_id", contentId)
|
||||||
put("status", status)
|
put("status", status)
|
||||||
}
|
}
|
||||||
socket?.emit("device:content-ack", data)
|
socket?.emit("device:content-ack", data)
|
||||||
|
} catch (e: Throwable) { Log.w("WebSocketService", "sendContentAck: ${e.message}") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendPlaybackState(contentId: String, positionSec: Float) {
|
fun sendPlaybackState(contentId: String, positionSec: Float) {
|
||||||
if (socket?.connected() != true) return
|
if (socket?.connected() != true) return
|
||||||
|
try {
|
||||||
val data = JSONObject().apply {
|
val data = JSONObject().apply {
|
||||||
put("device_id", config.deviceId)
|
put("device_id", config.deviceId)
|
||||||
put("current_content_id", contentId)
|
put("current_content_id", contentId)
|
||||||
put("position_sec", positionSec)
|
put("position_sec", positionSec)
|
||||||
}
|
}
|
||||||
socket?.emit("device:playback-state", data)
|
socket?.emit("device:playback-state", data)
|
||||||
|
} catch (e: Throwable) { Log.w("WebSocketService", "sendPlaybackState: ${e.message}") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
socket?.disconnect()
|
try { socket?.disconnect() } catch (e: Throwable) { Log.w("WebSocketService", "disconnect: ${e.message}") }
|
||||||
socket?.off()
|
try { socket?.off() } catch (e: Throwable) { Log.w("WebSocketService", "off: ${e.message}") }
|
||||||
socket = null
|
socket = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ class DeviceInfo(private val context: Context) {
|
||||||
put("wifi_ssid", getWifiSSID())
|
put("wifi_ssid", getWifiSSID())
|
||||||
put("wifi_rssi", getWifiRSSI())
|
put("wifi_rssi", getWifiRSSI())
|
||||||
put("uptime_seconds", getUptimeSeconds())
|
put("uptime_seconds", getUptimeSeconds())
|
||||||
|
// #74/#75: OS timezone + UTC clock (effective-tz resolution + dashboard skew indicator)
|
||||||
|
put("timezone", java.util.TimeZone.getDefault().id)
|
||||||
|
put("device_utc", System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.remotedisplay.player.util
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight player debug logger. Always writes to logcat; when remote debug is
|
||||||
|
* enabled (toggled from the dashboard device-detail screen via a `set_debug`
|
||||||
|
* command), it ALSO streams the line to the server over the device socket so it
|
||||||
|
* can be watched live without adb. Off by default; no network when disabled.
|
||||||
|
*/
|
||||||
|
object DebugLog {
|
||||||
|
@Volatile var enabled = false
|
||||||
|
// Set by WebSocketService: (tag, level, message) -> emit over the device socket.
|
||||||
|
@Volatile var sink: ((String, String, String) -> Unit)? = null
|
||||||
|
|
||||||
|
fun d(tag: String, msg: String) { Log.d(tag, msg); send(tag, "d", msg) }
|
||||||
|
fun i(tag: String, msg: String) { Log.i(tag, msg); send(tag, "i", msg) }
|
||||||
|
fun w(tag: String, msg: String) { Log.w(tag, msg); send(tag, "w", msg) }
|
||||||
|
fun e(tag: String, msg: String) { Log.e(tag, msg); send(tag, "e", msg) }
|
||||||
|
|
||||||
|
private fun send(tag: String, level: String, msg: String) {
|
||||||
|
if (!enabled) return
|
||||||
|
try { sink?.invoke(tag, level, msg) } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
package com.remotedisplay.player.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe bitmap loader. Reads dimensions first via inJustDecodeBounds, then decodes
|
||||||
|
* with an inSampleSize that scales the image down to the device's screen resolution.
|
||||||
|
* A 4K source image on a 1080p screen ends up as 1920x1080, not 3840x2160 — keeps
|
||||||
|
* the bitmap under ~8 MB instead of ~33 MB.
|
||||||
|
*
|
||||||
|
* All exceptions, including OutOfMemoryError, return null so the caller can skip the
|
||||||
|
* item rather than crashing the whole app.
|
||||||
|
*/
|
||||||
|
object ImageLoader {
|
||||||
|
private const val TAG = "ImageLoader"
|
||||||
|
|
||||||
|
fun screenWidth(ctx: Context): Int = ctx.resources.displayMetrics.widthPixels
|
||||||
|
fun screenHeight(ctx: Context): Int = ctx.resources.displayMetrics.heightPixels
|
||||||
|
|
||||||
|
fun decodeFile(file: File, maxW: Int, maxH: Int): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
|
||||||
|
Log.w(TAG, "Invalid image dimensions for ${file.name}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val opts = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = calcSampleSize(bounds.outWidth, bounds.outHeight, maxW, maxH)
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath, opts)
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Log.e(TAG, "OOM decoding ${file.name}: ${e.message}")
|
||||||
|
null
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "Failed to decode ${file.name}: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeUrl(url: String, maxW: Int, maxH: Int): Bitmap? {
|
||||||
|
// Reject anything that isn't HTTP/HTTPS. URL.openConnection() otherwise
|
||||||
|
// happily handles file://, jar:, ftp:, etc. — which would let a server-supplied
|
||||||
|
// remote_url read local files off the device or talk to internal services.
|
||||||
|
val scheme = try { URL(url).protocol?.lowercase() } catch (_: Throwable) { null }
|
||||||
|
if (scheme != "http" && scheme != "https") {
|
||||||
|
Log.w(TAG, "Rejecting non-http(s) URL scheme: $scheme")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val bytes = URL(url).openConnection().apply {
|
||||||
|
connectTimeout = 10_000
|
||||||
|
readTimeout = 30_000
|
||||||
|
}.getInputStream().use { it.readBytes() }
|
||||||
|
decodeBytes(bytes, maxW, maxH)
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Log.e(TAG, "OOM downloading $url: ${e.message}")
|
||||||
|
null
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "Failed to download $url: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeBytes(bytes: ByteArray, maxW: Int, maxH: Int): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
val opts = BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = calcSampleSize(bounds.outWidth, bounds.outHeight, maxW, maxH)
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Log.e(TAG, "OOM decoding ${bytes.size} bytes: ${e.message}")
|
||||||
|
null
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "Failed to decode ${bytes.size} bytes: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calcSampleSize(srcW: Int, srcH: Int, maxW: Int, maxH: Int): Int {
|
||||||
|
if (maxW <= 0 || maxH <= 0) return 1
|
||||||
|
var sample = 1
|
||||||
|
while (srcW / sample > maxW || srcH / sample > maxH) sample *= 2
|
||||||
|
return sample
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package com.remotedisplay.player.util
|
||||||
|
|
||||||
|
import android.webkit.ConsoleMessage
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceError
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared setup + helpers for the player's WebViews (zone widgets, fullscreen
|
||||||
|
* widgets, YouTube). Centralizes:
|
||||||
|
* - JS / DOM storage / autoplay-without-gesture,
|
||||||
|
* - mixed-content ALLOW (self-hosted servers are often http on the LAN; without
|
||||||
|
* this an https page embedding http - or vice versa - is silently blocked into
|
||||||
|
* a black broken-frame),
|
||||||
|
* - error/console logging piped to DebugLog so a failing web frame shows the
|
||||||
|
* real reason in the live debug panel instead of just a black broken-page view,
|
||||||
|
* - a YouTube embed that loads with a valid youtube.com origin (fixes Error 153).
|
||||||
|
*/
|
||||||
|
object WebViewSupport {
|
||||||
|
|
||||||
|
const val YT_BASE = "https://www.youtube.com"
|
||||||
|
// Base URL the embed page is loaded under (its referrer to YouTube). It must be
|
||||||
|
// a normal embedding site, NOT youtube.com itself — a page claiming to be
|
||||||
|
// youtube.com embedding a youtube.com iframe is rejected as an invalid embed
|
||||||
|
// context ("This video is unavailable / Error 152"). A real third-party domain
|
||||||
|
// is what legitimate embeds use.
|
||||||
|
const val EMBED_BASE = "https://screentinker.com"
|
||||||
|
|
||||||
|
fun configure(webView: WebView, tag: String) {
|
||||||
|
webView.settings.apply {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
mediaPlaybackRequiresUserGesture = false
|
||||||
|
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||||
|
}
|
||||||
|
webView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||||
|
webView.webViewClient = object : WebViewClient() {
|
||||||
|
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
|
||||||
|
if (request?.isForMainFrame == true) {
|
||||||
|
DebugLog.e(tag, "WebView load error ${error?.errorCode} ${error?.description} url=${request.url}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) {
|
||||||
|
if (request?.isForMainFrame == true) {
|
||||||
|
DebugLog.e(tag, "WebView HTTP ${errorResponse?.statusCode} url=${request.url}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onConsoleMessage(msg: ConsoleMessage?): Boolean {
|
||||||
|
if (msg?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
|
||||||
|
DebugLog.w(tag, "JS error: ${msg.message()} @${msg.sourceId()}:${msg.lineNumber()}")
|
||||||
|
}
|
||||||
|
return super.onConsoleMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extractYoutubeId(url: String): String? {
|
||||||
|
val patterns = listOf(
|
||||||
|
Regex("""embed/([A-Za-z0-9_-]{6,})"""),
|
||||||
|
Regex("""[?&]v=([A-Za-z0-9_-]{6,})"""),
|
||||||
|
Regex("""youtu\.be/([A-Za-z0-9_-]{6,})""")
|
||||||
|
)
|
||||||
|
for (p in patterns) p.find(url)?.let { return it.groupValues[1] }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML wrapper for a YouTube embed. Loaded via loadDataWithBaseURL(YT_BASE, ...)
|
||||||
|
* so the iframe has a valid youtube.com origin/referer (a bare loadUrl of the
|
||||||
|
* embed gives Error 153 "player misconfigured"). Returns null if no video id.
|
||||||
|
*/
|
||||||
|
fun youtubeEmbedHtml(url: String): String? {
|
||||||
|
val id = extractYoutubeId(url) ?: return null
|
||||||
|
val src = "$YT_BASE/embed/$id?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=$id&playsinline=1"
|
||||||
|
return "<!DOCTYPE html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
|
||||||
|
"<style>html,body{margin:0;padding:0;height:100%;background:#000;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style>" +
|
||||||
|
"</head><body><iframe src=\"$src\" allow=\"autoplay; encrypted-media\" allowfullscreen></iframe></body></html>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout
|
<!-- Scrolls so content is always reachable on short screens; the pairing code
|
||||||
|
auto-sizes to fit any screen width (phones, TVs, HD sticks). -->
|
||||||
|
<ScrollView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:gravity="center"
|
android:fillViewport="true"
|
||||||
|
android:background="#111827">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_horizontal|top"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="#111827"
|
android:paddingHorizontal="32dp"
|
||||||
android:padding="48dp"
|
android:paddingTop="24dp"
|
||||||
|
android:paddingBottom="24dp"
|
||||||
android:keepScreenOn="true">
|
android:keepScreenOn="true">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|
@ -14,20 +24,21 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="RemoteDisplay"
|
android:text="RemoteDisplay"
|
||||||
android:textColor="#3B82F6"
|
android:textColor="#3B82F6"
|
||||||
android:textSize="36sp"
|
android:textSize="22sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginBottom="8dp" />
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Digital Signage Player"
|
android:text="Digital Signage Player"
|
||||||
android:textColor="#94A3B8"
|
android:textColor="#94A3B8"
|
||||||
android:textSize="16sp"
|
android:textSize="14sp"
|
||||||
android:layout_marginBottom="48dp" />
|
android:layout_marginBottom="20dp" />
|
||||||
|
|
||||||
<!-- Server URL Section -->
|
<!-- Server URL Section (hidden once paired so the code has room) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/serverSection"
|
||||||
android:layout_width="400dp"
|
android:layout_width="400dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
|
|
@ -77,7 +88,7 @@
|
||||||
<!-- Pairing Section (shown after connection) -->
|
<!-- Pairing Section (shown after connection) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/pairingSection"
|
android:id="@+id/pairingSection"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
|
|
@ -91,19 +102,28 @@
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- FIXED-height box: autosize fits the text inside the bounded box and
|
||||||
|
gravity center vertically centers it, so the digits are never
|
||||||
|
clipped (the earlier wrap_content height clipped the glyph bottoms).
|
||||||
|
24-64sp fills the width on phones/TVs/sticks. -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/pairingCodeText"
|
android:id="@+id/pairingCodeText"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="96dp"
|
||||||
android:text="------"
|
android:text="------"
|
||||||
android:textColor="#3B82F6"
|
android:textColor="#3B82F6"
|
||||||
android:textSize="64sp"
|
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:fontFamily="monospace"
|
android:fontFamily="monospace"
|
||||||
android:letterSpacing="0.3" />
|
android:letterSpacing="0.3"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:gravity="center"
|
||||||
|
app:autoSizeTextType="uniform"
|
||||||
|
app:autoSizeMinTextSize="24sp"
|
||||||
|
app:autoSizeMaxTextSize="64sp"
|
||||||
|
app:autoSizeStepGranularity="2sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Enter this code in the dashboard to pair this display"
|
android:text="Enter this code in the dashboard to pair this display"
|
||||||
android:textColor="#64748B"
|
android:textColor="#64748B"
|
||||||
|
|
@ -118,6 +138,8 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="#94A3B8"
|
android:textColor="#94A3B8"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
|
android:gravity="center"
|
||||||
android:layout_marginTop="16dp" />
|
android:layout_marginTop="16dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
|
||||||
|
|
@ -6,31 +6,42 @@
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="#111827"
|
android:background="#111827"
|
||||||
android:padding="48dp"
|
android:padding="12dp"
|
||||||
android:keepScreenOn="true">
|
android:keepScreenOn="true">
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="RemoteDisplay Setup"
|
android:text="RemoteDisplay Setup"
|
||||||
android:textColor="#3B82F6"
|
android:textColor="#3B82F6"
|
||||||
android:textSize="32sp"
|
android:textSize="12sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginBottom="8dp" />
|
android:layout_marginBottom="3dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Enable these permissions for full remote control"
|
android:text="Enable these permissions for full remote control"
|
||||||
android:textColor="#94A3B8"
|
android:textColor="#94A3B8"
|
||||||
android:textSize="16sp"
|
android:textSize="9sp"
|
||||||
android:layout_marginBottom="40dp" />
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="500dp"
|
android:layout_width="420dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:layout_marginBottom="32dp">
|
android:layout_marginBottom="8dp">
|
||||||
|
|
||||||
<!-- Accessibility Service -->
|
<!-- Accessibility Service -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|
@ -38,7 +49,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:layout_marginBottom="16dp">
|
android:layout_marginBottom="5dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|
@ -51,7 +62,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Accessibility Service"
|
android:text="Accessibility Service"
|
||||||
android:textColor="#F1F5F9"
|
android:textColor="#F1F5F9"
|
||||||
android:textSize="18sp"
|
android:textSize="11sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|
@ -59,7 +70,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Required for remote control (Home, Back, touch, gestures)"
|
android:text="Required for remote control (Home, Back, touch, gestures)"
|
||||||
android:textColor="#64748B"
|
android:textColor="#64748B"
|
||||||
android:textSize="13sp" />
|
android:textSize="8sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|
@ -68,20 +79,24 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="OFF"
|
android:text="OFF"
|
||||||
android:textColor="#EF4444"
|
android:textColor="#EF4444"
|
||||||
android:textSize="14sp"
|
android:textSize="9sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginEnd="12dp" />
|
android:layout_marginEnd="12dp" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/enableAccessibilityBtn"
|
android:id="@+id/enableAccessibilityBtn"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="40dp"
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:minWidth="0dp"
|
||||||
android:text="Enable"
|
android:text="Enable"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#FFFFFF"
|
||||||
android:textSize="14sp"
|
android:textSize="9sp"
|
||||||
android:background="@drawable/button_primary"
|
android:background="@drawable/button_primary"
|
||||||
android:paddingStart="20dp"
|
android:paddingStart="12dp"
|
||||||
android:paddingEnd="20dp" />
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Display Over Other Apps -->
|
<!-- Display Over Other Apps -->
|
||||||
|
|
@ -90,7 +105,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:layout_marginBottom="16dp">
|
android:layout_marginBottom="5dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|
@ -103,15 +118,15 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Install Unknown Apps"
|
android:text="Install Unknown Apps"
|
||||||
android:textColor="#F1F5F9"
|
android:textColor="#F1F5F9"
|
||||||
android:textSize="18sp"
|
android:textSize="11sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Required for automatic OTA updates"
|
android:text="OTA updates — only our signature-verified builds install"
|
||||||
android:textColor="#64748B"
|
android:textColor="#64748B"
|
||||||
android:textSize="13sp" />
|
android:textSize="8sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|
@ -120,20 +135,24 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="OFF"
|
android:text="OFF"
|
||||||
android:textColor="#EF4444"
|
android:textColor="#EF4444"
|
||||||
android:textSize="14sp"
|
android:textSize="9sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginEnd="12dp" />
|
android:layout_marginEnd="12dp" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/enableInstallBtn"
|
android:id="@+id/enableInstallBtn"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="40dp"
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:minWidth="0dp"
|
||||||
android:text="Enable"
|
android:text="Enable"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#FFFFFF"
|
||||||
android:textSize="14sp"
|
android:textSize="9sp"
|
||||||
android:background="@drawable/button_primary"
|
android:background="@drawable/button_primary"
|
||||||
android:paddingStart="20dp"
|
android:paddingStart="12dp"
|
||||||
android:paddingEnd="20dp" />
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Notification Permission (Android 13+) -->
|
<!-- Notification Permission (Android 13+) -->
|
||||||
|
|
@ -143,7 +162,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="5dp"
|
||||||
android:visibility="gone">
|
android:visibility="gone">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|
@ -157,7 +176,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Notifications"
|
android:text="Notifications"
|
||||||
android:textColor="#F1F5F9"
|
android:textColor="#F1F5F9"
|
||||||
android:textSize="18sp"
|
android:textSize="11sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|
@ -165,7 +184,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Required for background service"
|
android:text="Required for background service"
|
||||||
android:textColor="#64748B"
|
android:textColor="#64748B"
|
||||||
android:textSize="13sp" />
|
android:textSize="8sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|
@ -174,33 +193,215 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="OFF"
|
android:text="OFF"
|
||||||
android:textColor="#EF4444"
|
android:textColor="#EF4444"
|
||||||
android:textSize="14sp"
|
android:textSize="9sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginEnd="12dp" />
|
android:layout_marginEnd="12dp" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/enableNotificationBtn"
|
android:id="@+id/enableNotificationBtn"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="40dp"
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:minWidth="0dp"
|
||||||
android:text="Enable"
|
android:text="Enable"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#FFFFFF"
|
||||||
android:textSize="14sp"
|
android:textSize="9sp"
|
||||||
android:background="@drawable/button_primary"
|
android:background="@drawable/button_primary"
|
||||||
android:paddingStart="20dp"
|
android:paddingStart="12dp"
|
||||||
android:paddingEnd="20dp" />
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Launch on boot / full-screen (Android 14+ restricts USE_FULL_SCREEN_INTENT) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/fullscreenRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="5dp"
|
||||||
|
android:visibility="visible">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Launch on Boot"
|
||||||
|
android:textColor="#F1F5F9"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Required to auto-start the display after power-on"
|
||||||
|
android:textColor="#64748B"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fullscreenStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="OFF"
|
||||||
|
android:textColor="#EF4444"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginEnd="12dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/enableFullscreenBtn"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:minWidth="0dp"
|
||||||
|
android:text="Enable"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:background="@drawable/button_primary"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Battery optimization exemption (boot + run reliability on OEM/TV boxes) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/batteryRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="5dp"
|
||||||
|
android:visibility="visible">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Background Activity"
|
||||||
|
android:textColor="#F1F5F9"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Keep running and auto-start reliably"
|
||||||
|
android:textColor="#64748B"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/batteryStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="OFF"
|
||||||
|
android:textColor="#EF4444"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginEnd="12dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/enableBatteryBtn"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:minWidth="0dp"
|
||||||
|
android:text="Enable"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:background="@drawable/button_primary"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Display over other apps: alternate boot-launch path (works where you
|
||||||
|
can't set a launcher, e.g. Android TV). -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/overlayRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="5dp"
|
||||||
|
android:visibility="visible">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Display Over Apps"
|
||||||
|
android:textColor="#F1F5F9"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Lets the display launch itself on boot (TV boxes)"
|
||||||
|
android:textColor="#64748B"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/overlayStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="OFF"
|
||||||
|
android:textColor="#EF4444"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginEnd="12dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/enableOverlayBtn"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:minWidth="0dp"
|
||||||
|
android:text="Enable"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:background="@drawable/button_primary"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/continueBtn"
|
android:id="@+id/continueBtn"
|
||||||
android:layout_width="500dp"
|
android:layout_width="420dp"
|
||||||
android:layout_height="48dp"
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="0dp"
|
||||||
android:text="Continue to Setup"
|
android:text="Continue to Setup"
|
||||||
android:textColor="#FFFFFF"
|
android:textColor="#FFFFFF"
|
||||||
android:textSize="16sp"
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:background="@drawable/button_primary" />
|
android:background="@drawable/button_primary"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="10dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/skipText"
|
android:id="@+id/skipText"
|
||||||
|
|
@ -208,8 +409,11 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Skip (remote control features will be limited)"
|
android:text="Skip (remote control features will be limited)"
|
||||||
android:textColor="#64748B"
|
android:textColor="#64748B"
|
||||||
android:textSize="13sp"
|
android:textSize="8sp"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="6dp"
|
||||||
android:padding="8dp" />
|
android:padding="8dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
|
||||||
7
android/app/src/main/res/values-de/strings.xml
Normal file
7
android/app/src/main/res/values-de/strings.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">RemoteDisplay</string>
|
||||||
|
<string name="accessibility_description">RemoteDisplay nutzt die Bedienungshilfen, um Fernsteuerung der Stromzufuhr und Systemnavigation zu ermöglichen.</string>
|
||||||
|
<string name="nothing_scheduled">Derzeit ist nichts geplant</string>
|
||||||
|
<string name="waiting_for_content">Warte auf Inhalte…</string>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values-es/strings.xml
Normal file
7
android/app/src/main/res/values-es/strings.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">RemoteDisplay</string>
|
||||||
|
<string name="accessibility_description">RemoteDisplay usa accesibilidad para habilitar el control remoto de encendido y la navegación del sistema.</string>
|
||||||
|
<string name="nothing_scheduled">No hay nada programado en este momento</string>
|
||||||
|
<string name="waiting_for_content">Esperando contenido…</string>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values-fr/strings.xml
Normal file
7
android/app/src/main/res/values-fr/strings.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">RemoteDisplay</string>
|
||||||
|
<string name="accessibility_description">RemoteDisplay utilise l\'accessibilité pour activer les contrôles d\'alimentation à distance et la navigation système.</string>
|
||||||
|
<string name="nothing_scheduled">Rien de programmé pour le moment</string>
|
||||||
|
<string name="waiting_for_content">En attente de contenu…</string>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values-hi/strings.xml
Normal file
7
android/app/src/main/res/values-hi/strings.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Hindi: same as English by design (matches web hi.js skeleton).
|
||||||
|
Replace with native-reviewed translations before publicizing. -->
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">RemoteDisplay</string>
|
||||||
|
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values-pt/strings.xml
Normal file
7
android/app/src/main/res/values-pt/strings.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">RemoteDisplay</string>
|
||||||
|
<string name="accessibility_description">RemoteDisplay usa acessibilidade para habilitar controles remotos de energia e navegação do sistema.</string>
|
||||||
|
<string name="nothing_scheduled">Nada programado no momento</string>
|
||||||
|
<string name="waiting_for_content">Aguardando conteúdo…</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -2,4 +2,6 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">RemoteDisplay</string>
|
<string name="app_name">RemoteDisplay</string>
|
||||||
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string>
|
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string>
|
||||||
|
<string name="nothing_scheduled">Nothing scheduled right now</string>
|
||||||
|
<string name="waiting_for_content">Waiting for content…</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package com.remotedisplay.player.player
|
||||||
|
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drift guard (#74/#75): the Kotlin evaluator must agree with the SHARED contract
|
||||||
|
* at shared/schedule-vectors.json - the SAME file the JS server, web player, and
|
||||||
|
* Tizen player are held to. No snapshot is taken: the test task points
|
||||||
|
* `scheduleVectors` at the single source (see app/build.gradle.kts), so any future
|
||||||
|
* ScheduleEval.kt change that breaks a vector fails CI.
|
||||||
|
*/
|
||||||
|
class ScheduleEvalTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun conformsToSharedVectors() {
|
||||||
|
val path = System.getProperty("scheduleVectors")
|
||||||
|
?: error("scheduleVectors system property not set (configured in app/build.gradle.kts)")
|
||||||
|
val vectors = JsonParser.parseString(File(path).readText()).asJsonObject.getAsJsonArray("vectors")
|
||||||
|
|
||||||
|
val failures = StringBuilder()
|
||||||
|
var count = 0
|
||||||
|
for (el in vectors) {
|
||||||
|
val v = el.asJsonObject
|
||||||
|
val blocks = v.getAsJsonArray("blocks").map { b ->
|
||||||
|
val o = b.asJsonObject
|
||||||
|
ScheduleEval.Block(
|
||||||
|
days = o.getAsJsonArray("days").map { it.asInt }.toSet(),
|
||||||
|
start = o.get("start").asString,
|
||||||
|
end = o.get("end").asString,
|
||||||
|
startDate = o.get("start_date").let { if (it.isJsonNull) null else it.asString },
|
||||||
|
endDate = o.get("end_date").let { if (it.isJsonNull) null else it.asString }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val utcMs = Instant.parse(v.get("utc_now").asString).toEpochMilli()
|
||||||
|
val got = ScheduleEval.isItemActiveNow(blocks, utcMs, v.get("timezone").asString)
|
||||||
|
val expected = v.get("expected").asBoolean
|
||||||
|
count++
|
||||||
|
if (got != expected) {
|
||||||
|
failures.append("\n[${v.get("utc_now").asString} ${v.get("timezone").asString}] " +
|
||||||
|
"expected $expected got $got :: ${v.get("description").asString}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println("Kotlin JUnit schedule vectors: ${count - failures.count { it == '\n' }}/$count passed")
|
||||||
|
assertEquals("schedule vectors failed:$failures", 0, failures.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
docker-compose.example.yml
Normal file
33
docker-compose.example.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Example docker-compose for self-hosting ScreenTinker.
|
||||||
|
# cp docker-compose.example.yml docker-compose.yml # then edit to taste
|
||||||
|
# The container serves plain HTTP on 3001 - front it with a TLS-terminating
|
||||||
|
# reverse proxy or Cloudflare in production.
|
||||||
|
services:
|
||||||
|
screentinker:
|
||||||
|
image: ghcr.io/screentinker/screentinker:latest
|
||||||
|
# ...or build from source instead of pulling:
|
||||||
|
# build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
environment:
|
||||||
|
SELF_HOSTED: "true" # first registered user becomes admin, no billing
|
||||||
|
# JWT_SECRET: "set-a-long-random-string" # else one is generated under /data/certs
|
||||||
|
# DISABLE_HOMEPAGE: "true" # redirect / to the app instead of the landing page
|
||||||
|
volumes:
|
||||||
|
- st-data:/data # db, uploads, and the jwt secret persist here
|
||||||
|
# To enable OTA APK downloads, mount a built APK read-only:
|
||||||
|
# - ./ScreenTinker.apk:/data/ScreenTinker.apk:ro
|
||||||
|
healthcheck:
|
||||||
|
# image is node:20-slim (no curl) - use node's built-in fetch
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3001/api/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s # first boot runs db migrations
|
||||||
|
|
||||||
|
# Locked out of the admin account? Reset it with:
|
||||||
|
# docker exec -it screentinker node scripts/reset-admin.js
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
st-data:
|
||||||
137
docs/android-troubleshooting.md
Normal file
137
docs/android-troubleshooting.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# Android Player — Troubleshooting & Recovery
|
||||||
|
|
||||||
|
Practical runbook for the RemoteDisplay / ScreenTinker Android player
|
||||||
|
(package `com.remotedisplay.player`, shown on the device as **RemoteDisplay**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptom: player stuck on "Connecting to server"
|
||||||
|
|
||||||
|
The UI sits on **"Connecting to server…"** and never pairs/plays. In `logcat`
|
||||||
|
you'll see this repeating every few seconds:
|
||||||
|
|
||||||
|
```
|
||||||
|
E WebSocketService: Connection error: io.socket.engineio.client.EngineIOException: xhr poll error
|
||||||
|
```
|
||||||
|
|
||||||
|
`xhr poll error` is a **transport-level** failure — the Socket.IO client can't
|
||||||
|
even open an HTTP connection to the configured server. It is **not** an auth
|
||||||
|
rejection and **not** a code crash (those happen *after* the socket connects).
|
||||||
|
|
||||||
|
### What it almost always means
|
||||||
|
The player's stored **server URL points at a host it can no longer reach.**
|
||||||
|
Most common causes, in order:
|
||||||
|
|
||||||
|
1. **Server moved / IP changed.** The device was provisioned against a local
|
||||||
|
dev box (`http://192.168.x.x:3000`) and that machine's IP changed or it's
|
||||||
|
on a different network now.
|
||||||
|
2. **Local dev server is down.** `remotedisplay.service` isn't running.
|
||||||
|
3. **No internet route.** The device's Wi-Fi genuinely can't reach the
|
||||||
|
internet (only relevant if it points at `https://screentinker.com`).
|
||||||
|
|
||||||
|
### Quick triage (no device access needed)
|
||||||
|
```bash
|
||||||
|
# Is the intended server even up?
|
||||||
|
curl -s -m 8 -o /dev/null -w "%{http_code}\n" https://screentinker.com/ # expect 200
|
||||||
|
|
||||||
|
# Local dev server running?
|
||||||
|
systemctl is-active remotedisplay.service
|
||||||
|
```
|
||||||
|
If the target server is up and on the **same LAN** as the device, the player
|
||||||
|
*should* connect once it's pointed there — so the fix is re-pointing the device.
|
||||||
|
|
||||||
|
> An APK upgrade does **not** cause this. `adb install -r` preserves app data,
|
||||||
|
> so the stored server URL survives the upgrade. Cleartext (`http://`) is
|
||||||
|
> allowed (`usesCleartextTraffic="true"` in the manifest), so upgrading does
|
||||||
|
> not block local servers either.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix: re-point the player to a different server
|
||||||
|
|
||||||
|
The app only shows its **setup screen** when it is *not provisioned/paired*
|
||||||
|
(`MainActivity`: `if (!config.isProvisioned || !config.isPaired) -> ProvisioningActivity`).
|
||||||
|
So to change servers you must reset that state. Two ways:
|
||||||
|
|
||||||
|
### A. On the phone, no tools (most reliable)
|
||||||
|
1. **Settings → Apps → RemoteDisplay → Storage → Clear data.**
|
||||||
|
This wipes the stale server URL and pairing. (Cached content is cleared too;
|
||||||
|
it re-downloads after pairing — no harm.)
|
||||||
|
2. Reopen **RemoteDisplay** → the setup screen appears.
|
||||||
|
3. Enter the server URL, e.g. **`https://screentinker.com`** → tap **Connect**.
|
||||||
|
4. It shows a **6-digit pairing code**.
|
||||||
|
5. In the dashboard (e.g. screentinker.com), pair a device with that code.
|
||||||
|
The phone flips to "Paired as: …" and starts playing.
|
||||||
|
|
||||||
|
> After **Clear data**, the **Accessibility** permission the app uses for
|
||||||
|
> remote power/navigation is also reset. Re-enable it if you need remote
|
||||||
|
> reboot/screen control: Settings → Accessibility → RemoteDisplay → On.
|
||||||
|
|
||||||
|
### B. Via adb (if you have a working connection)
|
||||||
|
```bash
|
||||||
|
D=<ip:port>
|
||||||
|
# Option 1: reset provisioning the same way "Clear data" does
|
||||||
|
adb -s $D shell pm clear com.remotedisplay.player
|
||||||
|
adb -s $D shell monkey -p com.remotedisplay.player -c android.intent.category.LAUNCHER 1
|
||||||
|
|
||||||
|
# Option 2 (inspect first): read the currently-configured server URL
|
||||||
|
# NOTE: release builds are NOT debuggable, so `run-as` returns nothing and
|
||||||
|
# you cannot read /data/data/.../shared_prefs without root. Prefer Clear data.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Connecting adb over Wi-Fi (Android 11+ Wireless Debugging)
|
||||||
|
|
||||||
|
Used to drive the device for installs/log capture. Ports here are **per-session
|
||||||
|
and change** when wireless debugging is toggled or the device reboots.
|
||||||
|
|
||||||
|
1. On device: **Developer options → Wireless debugging → On.**
|
||||||
|
2. **Pair** (one-time per host): tap *"Pair device with pairing code"*. It shows
|
||||||
|
a **pairing port** (different from the connect port) and a **6-digit code**:
|
||||||
|
```bash
|
||||||
|
adb pair <ip>:<pairing-port> <6-digit-code>
|
||||||
|
```
|
||||||
|
3. **Connect** using the **"IP address & Port"** from the *main* Wireless
|
||||||
|
debugging screen (the *connect* port, not the pairing port):
|
||||||
|
```bash
|
||||||
|
adb connect <ip>:<connect-port>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finding the ports when the UI/mDNS won't tell you
|
||||||
|
mDNS discovery (`adb mdns services`) **only works on the same L2 subnet**; it
|
||||||
|
won't cross a router. If the device is a hop away, scan for the open ports:
|
||||||
|
```bash
|
||||||
|
nmap -p 30000-50000 --open -T4 <ip> | grep open
|
||||||
|
```
|
||||||
|
The **connect** and **pairing** ports are random in the high range and churn;
|
||||||
|
the pairing port only exists while the pairing dialog is open.
|
||||||
|
|
||||||
|
### Gotchas learned the hard way
|
||||||
|
- **Be on the same subnet.** A wireless-debug *connect* port that is TCP-open
|
||||||
|
from across a router can still refuse the adb/TLS handshake. Pairing tolerates
|
||||||
|
routing; connecting often does not. Put your machine on the **same /24** as
|
||||||
|
the device.
|
||||||
|
- **Do NOT run `adb root` over a wireless connection.** It restarts `adbd` in
|
||||||
|
root mode, which **drops the TLS connection and stops re-binding the connect
|
||||||
|
port** — the phone keeps *displaying* the old port but it's refused. Recovery
|
||||||
|
is a **phone reboot** (or `adb unroot`, which you can't reach because you're
|
||||||
|
disconnected). Release builds aren't debuggable anyway, so root buys you
|
||||||
|
little here — prefer **Clear data** for config resets.
|
||||||
|
- After a reboot or a wireless-debugging toggle, the connect port **changes** —
|
||||||
|
re-read it from the device and reconnect (pairing usually persists).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference: where things live
|
||||||
|
|
||||||
|
| Thing | Location |
|
||||||
|
|---|---|
|
||||||
|
| Package id | `com.remotedisplay.player` |
|
||||||
|
| Display name | RemoteDisplay |
|
||||||
|
| Server URL entry | `ProvisioningActivity` (`R.id.serverUrlInput`) |
|
||||||
|
| Routing to setup | `MainActivity` → `if (!isProvisioned || !isPaired)` |
|
||||||
|
| Connection client | `service/WebSocketService.kt` (Socket.IO) |
|
||||||
|
| Cleartext allowed | `AndroidManifest.xml` → `usesCleartextTraffic="true"` |
|
||||||
|
| Build a signed APK | `KEYSTORE_PASSWORD=… KEY_PASSWORD=… ./gradlew assembleRelease` |
|
||||||
|
| APK output | `android/app/build/outputs/apk/release/app-release.apk` |
|
||||||
175
docs/local-ai-setup.md
Normal file
175
docs/local-ai-setup.md
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
# Local AI for the Content Designer
|
||||||
|
|
||||||
|
The **Content Designer → ✨ AI generate** feature turns a text prompt into a finished
|
||||||
|
sign: the layout and copy come from an LLM, and (optionally) the background /
|
||||||
|
foreground imagery comes from an image model. ScreenTinker is **bring-your-own**:
|
||||||
|
you point each workspace at an **OpenAI-compatible** text endpoint and an image
|
||||||
|
endpoint of your choice. Nothing is sent to us, and the operator pays no AI costs.
|
||||||
|
|
||||||
|
This guide sets up a fully **local, free** stack:
|
||||||
|
|
||||||
|
- **Text / layout** → [Ollama](https://ollama.com) (OpenAI-compatible)
|
||||||
|
- **Images** → [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp) server (OpenAI-compatible)
|
||||||
|
|
||||||
|
Prefer the cloud? Skip to [Using OpenAI instead](#using-openai-instead).
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> To use **localhost / LAN** AI endpoints, your instance must run with
|
||||||
|
> **`SELF_HOSTED=true`**. ScreenTinker blocks private/internal addresses for the
|
||||||
|
> AI endpoints (SSRF protection) unless it is in self-hosted mode. See
|
||||||
|
> [Enable self-hosted mode](#1-enable-self-hosted-mode).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Enable self-hosted mode
|
||||||
|
|
||||||
|
The AI endpoint config is gated by an SSRF guard. On a self-hosted box this guard
|
||||||
|
is relaxed so you can point at `localhost`. Set the env var:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# systemd: drop-in (recommended)
|
||||||
|
sudo mkdir -p /etc/systemd/system/screentinker.service.d
|
||||||
|
printf '[Service]\nEnvironment=SELF_HOSTED=true\n' | sudo tee /etc/systemd/system/screentinker.service.d/selfhosted.conf
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl restart screentinker
|
||||||
|
```
|
||||||
|
|
||||||
|
(Or `SELF_HOSTED=true npm start` for a manual run.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Text / layout model — Ollama
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install (use a recent build — 0.30+ is required for NVIDIA 50-series / Blackwell)
|
||||||
|
curl -fsSL https://ollama.com/install.sh | sh
|
||||||
|
|
||||||
|
# Pull a model. 8B is a good size/quality balance for signage copy.
|
||||||
|
ollama pull llama3.1:8b
|
||||||
|
|
||||||
|
# Confirm it's loaded on the GPU
|
||||||
|
ollama ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Ollama exposes an OpenAI-compatible API at **`http://localhost:11434/v1`**. No API
|
||||||
|
key is required (any value works).
|
||||||
|
|
||||||
|
In **Designer → ⚙ AI settings**:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Endpoint base URL | `http://localhost:11434/v1` |
|
||||||
|
| Model | `llama3.1:8b` (or click **Load models**) |
|
||||||
|
| API key | *(leave blank)* |
|
||||||
|
|
||||||
|
That alone enables AI generation (text + shapes). Add images below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Image model — stable-diffusion.cpp (Vulkan)
|
||||||
|
|
||||||
|
We use the prebuilt **stable-diffusion.cpp** server. Its `--backend` runs on
|
||||||
|
**Vulkan**, which works on modern NVIDIA GPUs even where CUDA/PyTorch (ComfyUI)
|
||||||
|
fails to initialize — see [GPU notes](#gpu-notes--troubleshooting).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Grab the prebuilt server from the releases page and pick the variant for
|
||||||
|
# your GPU (…-vulkan.zip works broadly; cuda / rocm builds also exist):
|
||||||
|
# https://github.com/leejet/stable-diffusion.cpp/releases
|
||||||
|
mkdir -p ~/sd-server && cd ~/sd-server
|
||||||
|
unzip ~/Downloads/sd-*-vulkan.zip # -> sd-server, sd-cli, libstable-diffusion.so
|
||||||
|
|
||||||
|
# 2. A checkpoint. SDXL base is a solid default (~6.5 GB):
|
||||||
|
mkdir -p models
|
||||||
|
curl -L -o models/sd_xl_base_1.0.safetensors \
|
||||||
|
https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors
|
||||||
|
|
||||||
|
# 3. Find your GPU's Vulkan device index, then run the server.
|
||||||
|
# The startup log prints "Found N Vulkan devices" — note the index of your
|
||||||
|
# discrete GPU (an Intel/AMD iGPU is often device 0, the dGPU device 1).
|
||||||
|
LD_LIBRARY_PATH=~/sd-server ~/sd-server/sd-server \
|
||||||
|
-m ~/sd-server/models/sd_xl_base_1.0.safetensors \
|
||||||
|
--backend vulkan1 --listen-port 7860
|
||||||
|
```
|
||||||
|
|
||||||
|
The server is OpenAI-compatible at **`http://localhost:7860/v1`**
|
||||||
|
(`POST /v1/images/generations`). Smoke test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:7860/v1/images/generations \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"prompt":"a cozy cafe interior, no text","size":"1024x576","response_format":"b64_json"}' \
|
||||||
|
| head -c 80
|
||||||
|
```
|
||||||
|
|
||||||
|
In **Designer → ⚙ AI settings → AI images**:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Image provider | **Stable Diffusion — local (sd.cpp)** |
|
||||||
|
| Image endpoint URL | `http://localhost:7860/v1` |
|
||||||
|
| Image model | *(leave blank — uses the loaded checkpoint)* |
|
||||||
|
| Image API key | *(leave blank)* |
|
||||||
|
|
||||||
|
Now a prompt produces a full sign: an atmospheric background, crisp text on top,
|
||||||
|
and an optional foreground graphic.
|
||||||
|
|
||||||
|
### Run it as a service (recommended)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/sd-server.service
|
||||||
|
[Unit]
|
||||||
|
Description=stable-diffusion.cpp image server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=youruser
|
||||||
|
Environment=LD_LIBRARY_PATH=/home/youruser/sd-server
|
||||||
|
ExecStart=/home/youruser/sd-server/sd-server -m /home/youruser/sd-server/models/sd_xl_base_1.0.safetensors --backend vulkan1 --listen-port 7860
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl enable --now sd-server
|
||||||
|
```
|
||||||
|
|
||||||
|
> **VRAM:** the server keeps the checkpoint resident (~6.5 GB for SDXL). The app
|
||||||
|
> requests modest sizes (1024×576 background, 768×768 foreground) so it fits
|
||||||
|
> alongside the LLM on a single ~16 GB+ GPU. Larger sizes need a tiled VAE
|
||||||
|
> (`--vae-tiling`) or more VRAM. ComfyUI works too — set the provider to
|
||||||
|
> **ComfyUI** and point at `http://localhost:8188`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using OpenAI instead
|
||||||
|
|
||||||
|
No local hardware? Use the cloud (you pay OpenAI directly):
|
||||||
|
|
||||||
|
- **Text:** endpoint `https://api.openai.com/v1`, model e.g. `gpt-4o-mini`, paste your key.
|
||||||
|
- **Images:** provider **OpenAI / OpenAI-compatible**, endpoint `https://api.openai.com/v1`,
|
||||||
|
model e.g. `gpt-image-1`.
|
||||||
|
|
||||||
|
If your **text** endpoint is local (no key) but **images** are OpenAI, put the
|
||||||
|
OpenAI key in the separate **Image API key** field. When that field is blank, the
|
||||||
|
image endpoint reuses the main API key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPU notes / troubleshooting
|
||||||
|
|
||||||
|
- **NVIDIA 50-series (Blackwell):** CUDA compute can fail to initialize for
|
||||||
|
PyTorch-based tools (ComfyUI) with `CUDA unknown error`, even though
|
||||||
|
`nvidia-smi` works. **Vulkan** does work — which is why this guide uses Ollama
|
||||||
|
(Vulkan) and stable-diffusion.cpp (Vulkan). Use a recent Ollama (0.30+).
|
||||||
|
- **Wrong/slow device:** if generation is CPU-slow, the tool picked the wrong
|
||||||
|
Vulkan device. Check the startup log's device list and set `--backend vulkanN`
|
||||||
|
(sd.cpp) accordingly; Ollama honours `GGML_VK_VISIBLE_DEVICES`.
|
||||||
|
- **`Endpoint URL not allowed`** when saving AI settings → the instance is not in
|
||||||
|
self-hosted mode. See [step 1](#1-enable-self-hosted-mode).
|
||||||
|
- **Images time out** → a cold or under-powered model. Try a smaller checkpoint
|
||||||
|
(e.g. SD 1.5) or fewer steps; first request also pays the model-load cost.
|
||||||
|
- **Publishing a sign with images** embeds the generated images in the widget,
|
||||||
|
so configs can be a few MB each. That's expected today.
|
||||||
615
docs/multi-tenancy-design.md
Normal file
615
docs/multi-tenancy-design.md
Normal file
|
|
@ -0,0 +1,615 @@
|
||||||
|
# ScreenTinker Multi-Tenancy / Reseller Design (V1)
|
||||||
|
|
||||||
|
Status: design approved 2026-05-11. Implementation begins Phase 1 on approval of this doc.
|
||||||
|
|
||||||
|
## 1. Mental model
|
||||||
|
|
||||||
|
Today every user is the root of their own data. Teams give shared scope inside one user. There is no layer above that.
|
||||||
|
|
||||||
|
V1 adds two layers:
|
||||||
|
|
||||||
|
```
|
||||||
|
platform (the hosted screentinker.com instance, or one self-hosted install)
|
||||||
|
organization (a reseller or a customer paying us; owns a Stripe sub)
|
||||||
|
workspace (a client of the reseller; what was previously a Team)
|
||||||
|
device | content | playlist | layout | widget | schedule | video_wall | ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- An **organization** is a billing/admin entity. Resellers run an org with many workspaces. Direct customers run an org with one workspace.
|
||||||
|
- A **workspace** is a tenant. Data inside is isolated from siblings. Equivalent to today's `teams` row, just parented by an org.
|
||||||
|
- Workspaces are the unit of UI tenancy: when you log in, you are "in" exactly one workspace at a time. The workspace picker switches context.
|
||||||
|
|
||||||
|
`teams` collapses into `workspaces`. `team_members` collapses into `workspace_members`. No nested teams inside workspaces in V1.
|
||||||
|
|
||||||
|
## 2. Roles
|
||||||
|
|
||||||
|
| Role | Scope | Powers |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `platform_admin` | platform (one or two rows) | sees everything across all orgs. Replaces today's `superadmin`. Hosted operator only. |
|
||||||
|
| `org_owner` | one org | full control of the org and every workspace inside, owns the Stripe subscription, can delete the org. |
|
||||||
|
| `org_admin` | one org | same as `org_owner` minus billing and delete-org. Suitable for reseller staff. |
|
||||||
|
| `workspace_admin` | one workspace | full control of one workspace: users, devices, content, playlists, branding. |
|
||||||
|
| `workspace_editor` | one workspace | create/edit content, devices, playlists, layouts, schedules. No user invites, no branding. |
|
||||||
|
| `workspace_viewer` | one workspace | read-only. |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Today's `users.role = 'admin'` (intermediate hosted role) is dropped. Existing rows get migrated to `org_admin` of their migrated org. See section 7.
|
||||||
|
- `workspace_owner` and `workspace_admin` collapse into a single `workspace_admin` role.
|
||||||
|
- A single user can hold roles in multiple orgs and multiple workspaces (multi-org membership). Memberships are stored in two join tables (see section 3).
|
||||||
|
|
||||||
|
### Permission check layering
|
||||||
|
|
||||||
|
Resolution order on every request, top wins:
|
||||||
|
|
||||||
|
1. `platform_admin` on the user row -> allow.
|
||||||
|
2. `org_owner` or `org_admin` on the user-in-this-org membership -> allow within that org's workspaces.
|
||||||
|
3. `workspace_admin` / `editor` / `viewer` on the user-in-this-workspace membership -> allow within that one workspace at the role level.
|
||||||
|
4. Otherwise -> 403.
|
||||||
|
|
||||||
|
Code shape (pseudocode, not code):
|
||||||
|
|
||||||
|
```
|
||||||
|
function can(user, action, target) {
|
||||||
|
if (user.role === 'platform_admin') return true;
|
||||||
|
const orgRole = orgRoleOf(user.id, target.organization_id);
|
||||||
|
if (orgRole === 'org_owner') return true;
|
||||||
|
if (orgRole === 'org_admin' && !ORG_OWNER_ONLY.has(action)) return true;
|
||||||
|
const wsRole = workspaceRoleOf(user.id, target.workspace_id);
|
||||||
|
return roleAllows(wsRole, action);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ORG_OWNER_ONLY = { 'billing.write', 'org.delete', 'workspace.delete' }`.
|
||||||
|
|
||||||
|
## 3. Schema
|
||||||
|
|
||||||
|
### 3.1 New tables
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT UNIQUE, -- v2 subdomain hook
|
||||||
|
owner_user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
plan_id TEXT DEFAULT 'free' REFERENCES plans(id),
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
stripe_subscription_id TEXT,
|
||||||
|
subscription_status TEXT DEFAULT 'active',
|
||||||
|
subscription_ends INTEGER,
|
||||||
|
-- subscription lifecycle (section 8)
|
||||||
|
grace_period_ends INTEGER, -- nullable; set when sub fails or cancels at period end
|
||||||
|
locked_at INTEGER, -- nullable; set when grace expires
|
||||||
|
-- branding defaults applied to new workspaces in this org
|
||||||
|
default_brand_name TEXT,
|
||||||
|
default_logo_url TEXT,
|
||||||
|
default_primary_color TEXT,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS organization_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'org_admin', -- 'org_owner' | 'org_admin'
|
||||||
|
invited_by TEXT REFERENCES users(id),
|
||||||
|
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
UNIQUE(organization_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT, -- v2 subdomain hook; unique within org
|
||||||
|
created_by TEXT REFERENCES users(id),
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
UNIQUE(organization_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'workspace_viewer', -- 'workspace_admin' | 'workspace_editor' | 'workspace_viewer'
|
||||||
|
invited_by TEXT REFERENCES users(id),
|
||||||
|
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
UNIQUE(workspace_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_invites (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'workspace_viewer',
|
||||||
|
invited_by TEXT NOT NULL REFERENCES users(id),
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Existing-table changes
|
||||||
|
|
||||||
|
Every per-tenant resource gets a `workspace_id`. The legacy `user_id` column stays (nullable) and represents "created by"; the legacy `team_id` column stays for one release as a compatibility shim, then drops in V2.
|
||||||
|
|
||||||
|
| Table | Adds | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `devices` | `workspace_id TEXT REFERENCES workspaces(id)` | required for new rows; legacy `user_id` becomes nullable created_by. |
|
||||||
|
| `content` | `workspace_id` | same. |
|
||||||
|
| `playlists` | `workspace_id` | same. |
|
||||||
|
| `layouts` | `workspace_id` | same. |
|
||||||
|
| `widgets` | `workspace_id` | same. `user_id IS NULL` ("public") rows stay platform-level templates owned by `platform_admin`. |
|
||||||
|
| `schedules` | `workspace_id` | same. |
|
||||||
|
| `video_walls` | `workspace_id` | same. |
|
||||||
|
| `device_groups` | `workspace_id` | same. |
|
||||||
|
| `white_labels` | `workspace_id TEXT REFERENCES workspaces(id)` (keyed by workspace, not user). | Org-level defaults live on `organizations.default_*`. |
|
||||||
|
| `activity_log` | `organization_id`, `workspace_id`, `acting_user_id`, `was_acting_as` | both org and workspace since some actions are org-scoped (billing). `acting_user_id` records the reseller when an action was performed via acting-as; `was_acting_as INTEGER DEFAULT 0` is the boolean flag. When not acting-as, `acting_user_id` is NULL and `was_acting_as = 0`. |
|
||||||
|
| `kiosk_pages` | `workspace_id` | same. |
|
||||||
|
| `alert_configs` | `workspace_id` | same. |
|
||||||
|
| `device_fingerprints` | (none) | platform-wide reinstall guard, stays user-keyed by intent. |
|
||||||
|
|
||||||
|
### 3.3 Stripe columns
|
||||||
|
|
||||||
|
`users.plan_id`, `users.stripe_customer_id`, `users.stripe_subscription_id`, `users.subscription_status`, `users.subscription_ends` -> move to `organizations`. Columns stay on `users` as nullable for one release (see Q9 default), then drop in V2.
|
||||||
|
|
||||||
|
### 3.3.1 Workspace billing metadata (add D)
|
||||||
|
|
||||||
|
The `workspaces` table also carries reseller-side annotation columns. These are visible and editable only to `org_owner` and `org_admin`. `workspace_admin` and below cannot see them. They never affect Stripe, never affect device caps, and ScreenTinker never emails the addresses stored in them.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE workspaces ADD COLUMN billing_type TEXT DEFAULT 'client_billable';
|
||||||
|
ALTER TABLE workspaces ADD COLUMN billing_notes TEXT;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN billing_contact_email TEXT;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN billing_contract_ref TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
| Column | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `billing_type` | One of `client_billable` (default - workspace is a paying client of the reseller), `client_complimentary` (client the reseller is comping - demo, charity, freebie), `internal` (the reseller's own usage - test bed, sales demo, their own signage). |
|
||||||
|
| `billing_notes` | Free-text reseller memory of the deal: "Acme - $50/mo, net-30, started 2025-09-01". |
|
||||||
|
| `billing_contact_email` | Whom at the client the reseller invoices. Stored only; never receives platform email. |
|
||||||
|
| `billing_contract_ref` | Reseller's internal cross-reference (contract id, CRM ticket, whatever). |
|
||||||
|
|
||||||
|
How a reseller actually charges these clients (full retail, discounted, comped, not at all) is the reseller's business and never modeled or enforced by the platform. See §8.1.
|
||||||
|
|
||||||
|
### 3.4 What stays user-scoped
|
||||||
|
|
||||||
|
- `users` table itself: identity, password, auth_provider, name, avatar.
|
||||||
|
- `device_fingerprints`: reinstall guard, no tenancy concept.
|
||||||
|
- `team_invites` / `workspace_invites`: scoped to the inviting workspace.
|
||||||
|
|
||||||
|
### 3.5 What gets both org and workspace IDs
|
||||||
|
|
||||||
|
Only `activity_log`. Some entries (billing, workspace create/delete) need to live at the org level even if no workspace context applies; others (device pair, content upload) carry both for filtering.
|
||||||
|
|
||||||
|
## 4. Migration
|
||||||
|
|
||||||
|
### 4.1 Strategy
|
||||||
|
|
||||||
|
Every existing user with any owned data becomes an `organizations` row plus a default `workspaces` row plus optional additional workspaces (their existing teams).
|
||||||
|
|
||||||
|
```
|
||||||
|
For each user U with owned data:
|
||||||
|
org_id = new uuid
|
||||||
|
insert organizations(id=org_id, name="<U.email>'s organization",
|
||||||
|
owner_user_id=U.id,
|
||||||
|
plan_id=U.plan_id,
|
||||||
|
stripe_*=U.stripe_*,
|
||||||
|
subscription_*=U.subscription_*)
|
||||||
|
insert organization_members(org_id, U.id, role='org_owner')
|
||||||
|
|
||||||
|
if U owns any teams T1..Tn:
|
||||||
|
for each Ti:
|
||||||
|
insert workspaces(id=Ti.id, organization_id=org_id, name=Ti.name, created_by=Ti.owner_id)
|
||||||
|
-- workspace.id reuses team.id so referencing rows continue to resolve
|
||||||
|
for each team_members row M of Ti:
|
||||||
|
ws_role = map(M.role) -- owner -> workspace_admin, editor -> workspace_editor, viewer -> workspace_viewer
|
||||||
|
insert workspace_members(workspace_id=Ti.id, user_id=M.user_id, role=ws_role)
|
||||||
|
-- pick a default workspace for U: the team they own with the most data (or first by created_at)
|
||||||
|
|
||||||
|
else:
|
||||||
|
ws_id = new uuid
|
||||||
|
insert workspaces(id=ws_id, organization_id=org_id, name='Default', created_by=U.id)
|
||||||
|
insert workspace_members(workspace_id=ws_id, user_id=U.id, role='workspace_admin')
|
||||||
|
|
||||||
|
for each user-scoped table (devices, content, etc):
|
||||||
|
UPDATE table SET workspace_id = (
|
||||||
|
-- if team_id is set on the row, use it as the workspace_id (team and workspace share id)
|
||||||
|
-- otherwise use U's default workspace
|
||||||
|
COALESCE(table.team_id, U_default_ws_id)
|
||||||
|
)
|
||||||
|
WHERE user_id = U.id
|
||||||
|
|
||||||
|
For each user U with users.role IN ('superadmin'):
|
||||||
|
UPDATE users SET role='platform_admin' WHERE id=U.id
|
||||||
|
|
||||||
|
For each user U with users.role = 'admin':
|
||||||
|
-- legacy intermediate role is dropped. Their migrated org gets them as org_admin.
|
||||||
|
-- if they already became org_owner via the loop above, leave as org_owner.
|
||||||
|
UPDATE users SET role='user' WHERE id=U.id
|
||||||
|
-- (org_admin row is added by the per-org loop above for any team-membered admins)
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-using `team.id` as the new `workspace.id` is intentional: every existing FK that points at a team continues to resolve without rewriting. Sockets, JWTs, and bookmarked URLs survive.
|
||||||
|
|
||||||
|
### 4.2 Migration SQL (high level)
|
||||||
|
|
||||||
|
Lives in `server/db/database.js` migrations array, idempotent, runs on next boot:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- New tables (4x CREATE TABLE IF NOT EXISTS, shown in 3.1).
|
||||||
|
|
||||||
|
-- Additive columns. Each wrapped in try/catch in the migration runner so re-runs are safe.
|
||||||
|
ALTER TABLE devices ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE content ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE playlists ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE layouts ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE widgets ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE schedules ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE video_walls ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE device_groups ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE white_labels ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE kiosk_pages ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE alert_configs ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE activity_log ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
|
||||||
|
ALTER TABLE activity_log ADD COLUMN organization_id TEXT REFERENCES organizations(id);
|
||||||
|
ALTER TABLE activity_log ADD COLUMN acting_user_id TEXT REFERENCES users(id);
|
||||||
|
ALTER TABLE activity_log ADD COLUMN was_acting_as INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- Reseller-side workspace annotations (add D).
|
||||||
|
ALTER TABLE workspaces ADD COLUMN billing_type TEXT DEFAULT 'client_billable';
|
||||||
|
ALTER TABLE workspaces ADD COLUMN billing_notes TEXT;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN billing_contact_email TEXT;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN billing_contract_ref TEXT;
|
||||||
|
|
||||||
|
-- Indexes for the new lookup paths.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_workspace ON devices(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_workspace ON content(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playlists_workspace ON playlists(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_walls_workspace ON video_walls(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspaces_organization ON workspaces(organization_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_organization_members_user ON organization_members(user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
Backfill runs as a one-shot in a transaction inside the migration runner, behind a `schema_migrations` row keyed `2026-05-11-multitenancy-backfill` so it only runs once. Pseudocode in 4.1; concrete script ships in Phase 1.
|
||||||
|
|
||||||
|
### 4.3 Down-migration
|
||||||
|
|
||||||
|
We do NOT auto-rollback. On failure during Phase 1 testing:
|
||||||
|
|
||||||
|
1. Take a pre-migration backup (the migration runner snapshots the SQLite file to `data/screentinker.pre-multitenancy.sqlite` before applying anything).
|
||||||
|
2. Manual rollback: `cp data/screentinker.pre-multitenancy.sqlite data/screentinker.sqlite && systemctl restart`.
|
||||||
|
3. No partial-migration state is allowed: the backfill runs inside `BEGIN TRANSACTION ... COMMIT`. Any error rolls the whole batch.
|
||||||
|
|
||||||
|
Phase 1 ships with a `node scripts/rollback-multitenancy.js` that drops the new tables and ALTER columns for completeness. It is NEVER auto-invoked.
|
||||||
|
|
||||||
|
### 4.4 Validation gate
|
||||||
|
|
||||||
|
Before Phase 2 begins, Phase 1 must produce a passing local test:
|
||||||
|
|
||||||
|
- Clone the production SQLite backup to dev.
|
||||||
|
- Run migrations.
|
||||||
|
- For every user U, run a diff:
|
||||||
|
- count(devices WHERE user_id=U) before == count(devices WHERE workspace_id IN ws_of_U) after.
|
||||||
|
- same for content, playlists, layouts, widgets, schedules, video_walls.
|
||||||
|
- Existing JWTs still resolve to a valid current_workspace_id.
|
||||||
|
- Existing API calls still return the same shape (Phase 2 changes the shape; Phase 1 only adds columns).
|
||||||
|
|
||||||
|
## 5. API surface
|
||||||
|
|
||||||
|
### 5.1 New endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/orgs create org (platform_admin or self-host bootstrap)
|
||||||
|
GET /api/orgs list orgs the caller can see
|
||||||
|
GET /api/orgs/:id org detail (incl. workspaces, members, billing summary)
|
||||||
|
PUT /api/orgs/:id update org (name, branding defaults)
|
||||||
|
DELETE /api/orgs/:id delete org (org_owner only)
|
||||||
|
GET /api/orgs/:id/usage rollup: per-workspace device counts (add B)
|
||||||
|
POST /api/orgs/:id/members invite org member (org_owner)
|
||||||
|
DELETE /api/orgs/:id/members/:user_id remove org member
|
||||||
|
|
||||||
|
POST /api/orgs/:id/workspaces create workspace
|
||||||
|
GET /api/workspaces list workspaces the caller can access
|
||||||
|
GET /api/workspaces/:id workspace detail
|
||||||
|
PUT /api/workspaces/:id update (name, branding override)
|
||||||
|
DELETE /api/workspaces/:id delete (org_owner)
|
||||||
|
POST /api/workspaces/:id/members invite member to a workspace
|
||||||
|
DELETE /api/workspaces/:id/members/:user_id remove member
|
||||||
|
|
||||||
|
POST /api/auth/switch-workspace session swap: { workspace_id } -> new JWT
|
||||||
|
GET /api/auth/me now returns { user, current_workspace, accessible_workspaces[], current_org_role }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Existing endpoints
|
||||||
|
|
||||||
|
V1 keeps every existing path operational. Scoping happens implicitly:
|
||||||
|
|
||||||
|
- JWT carries `current_workspace_id`. Set on login (last-used or first available). Updated on `/api/auth/switch-workspace`.
|
||||||
|
- Every existing route resolves `workspace_id` from JWT and filters by it instead of `user_id`.
|
||||||
|
- Optional `?workspace_id=` query param overrides per-request (used by org_owner tooling).
|
||||||
|
- No 308 redirects in V1. Path-versioned `/api/workspaces/:wid/...` form is deferred to V2.
|
||||||
|
|
||||||
|
The result is that frontend code in V1 continues to call `/api/devices`, `/api/content`, etc., unchanged. The middleware does the work.
|
||||||
|
|
||||||
|
### 5.3 Auth flow
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login -> { token, user, accessible_workspaces[], current_workspace_id }
|
||||||
|
```
|
||||||
|
|
||||||
|
If `accessible_workspaces.length === 1`, frontend auto-enters it.
|
||||||
|
If `accessible_workspaces.length > 1`, frontend shows the picker.
|
||||||
|
If `accessible_workspaces.length === 0`, account is dormant (org but no workspace memberships) -> show "No workspace yet" landing.
|
||||||
|
|
||||||
|
## 6. Workspace switching UX
|
||||||
|
|
||||||
|
- **Picker** at `#/select-workspace` shown after login when count > 1. Two columns:
|
||||||
|
- "My workspaces" (workspaces where user is a member).
|
||||||
|
- "Acting as" (for org_owner / org_admin: every workspace inside their org they aren't a direct member of). Visible only if user is org-level.
|
||||||
|
- **Persistent header indicator**: workspace name + dropdown arrow at the top-left of the dashboard. Click opens the same picker as a popover.
|
||||||
|
- **Acting-as ribbon**: when a reseller is inside a workspace they aren't a direct workspace_member of, a yellow bar pinned below the header reads `Acting as workspace: <name>. <Return to my workspace>`. Clicking the link switches back to the user's default workspace.
|
||||||
|
- **Audit log**: every action recorded in an acting-as session has `acting_user_id = reseller, target_workspace_id = client_workspace, was_acting_as = true`. UI in the audit log filters surfaces these distinctly.
|
||||||
|
|
||||||
|
## 7. White-label
|
||||||
|
|
||||||
|
- `white_labels.workspace_id` replaces `white_labels.user_id`. Branding belongs to the workspace.
|
||||||
|
- `organizations.default_*` columns hold the org's default brand. On workspace create, the workspace's `white_labels` row is initialized from these defaults; the workspace_admin can override any field.
|
||||||
|
- `branding.js` resolution order: per-workspace `white_labels` row -> org defaults -> platform defaults.
|
||||||
|
- Custom domain per workspace: V2. The `white_labels.custom_domain` column stays unused in V1.
|
||||||
|
|
||||||
|
## 8. Billing model (rollup) and lifecycle (add A)
|
||||||
|
|
||||||
|
### 8.1 Model
|
||||||
|
|
||||||
|
**The org_owner is the sole billable entity.** A workspace under a paid org has:
|
||||||
|
- NO Stripe customer.
|
||||||
|
- NO Stripe subscription.
|
||||||
|
- NO billing portal access.
|
||||||
|
- NO platform-level billing relationship of any kind.
|
||||||
|
|
||||||
|
The platform sees one customer per org: the org_owner. Stripe knows nothing about workspaces.
|
||||||
|
|
||||||
|
How a reseller charges their own clients (full price, discounted, complimentary, comped, internal-only) is **entirely the reseller's business**. The platform does not model it, enforce it, or contact the client. The `workspaces.billing_type` / `billing_notes` / `billing_contact_email` / `billing_contract_ref` columns (see §3.3.1) exist purely as the reseller's own memory and are never read by any platform code path that touches money or email.
|
||||||
|
|
||||||
|
- One Stripe subscription per **organization**, attached to `org_owner`.
|
||||||
|
- `plans.max_devices` is the org-wide cap. Sum of devices across all workspaces of the org is checked.
|
||||||
|
- Workspaces inside a paid org have no individual plan or Stripe relationship (see above).
|
||||||
|
- Self-hosted: Stripe enforcement off regardless.
|
||||||
|
|
||||||
|
### 8.2 Device-count enforcement at pairing time
|
||||||
|
|
||||||
|
```
|
||||||
|
on POST /api/provision/pair:
|
||||||
|
org = orgOf(caller)
|
||||||
|
total_devices = sum(devices WHERE workspace_id IN workspaces_of(org.id))
|
||||||
|
plan = plan_of(org)
|
||||||
|
if total_devices >= plan.max_devices and plan.id != 'enterprise':
|
||||||
|
return 402 { error: 'Org device limit reached', current: total_devices, limit: plan.max_devices }
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`device_status_log` shows the user a clear error: which org, which limit, which plan.
|
||||||
|
|
||||||
|
### 8.3 Subscription lifecycle (add A)
|
||||||
|
|
||||||
|
States on the `organizations` row: `active`, `past_due`, `grace`, `read_only`, `locked`. Driven by the existing Stripe webhook plus a daily cron.
|
||||||
|
|
||||||
|
Transitions:
|
||||||
|
|
||||||
|
| Event | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `invoice.payment_failed` | set `subscription_status = 'past_due'`, set `grace_period_ends = now + 7d`. Send email to org_owner + org_admins. |
|
||||||
|
| `invoice.payment_succeeded` while past_due | clear `grace_period_ends`, set `subscription_status = 'active'`. |
|
||||||
|
| daily cron, state == `past_due` AND `grace_period_ends < now` | enter `read_only`. **Reset `grace_period_ends = now + 30d`** so the read_only -> locked transition has a fresh 30-day clock and does not fire on the very next cron run. Send email. |
|
||||||
|
| `customer.subscription.deleted` (explicit cancel) | move to `read_only` immediately; set `grace_period_ends = now + 30d`. |
|
||||||
|
| daily cron, state == `read_only` AND `grace_period_ends < now` | move to `locked`. Set `locked_at = now`. |
|
||||||
|
| `checkout.session.completed` while in any non-active state | clear `grace_period_ends` and `locked_at`, set `active`. |
|
||||||
|
|
||||||
|
Behavior per state:
|
||||||
|
|
||||||
|
| State | Devices play content | Dashboard read | Dashboard write | New device pairing | Stripe portal |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `active` | yes | yes | yes | yes | yes |
|
||||||
|
| `past_due` | yes | yes | yes | yes | yes (banner: "payment failed, update card by <date>") |
|
||||||
|
| `read_only` | **yes** (devices keep playing what they already have) | yes | **no** (locked banner, all write routes return 423) | no | yes |
|
||||||
|
| `locked` | **no** (devices receive empty playlist, fall back to a "subscription expired" splash card with org-owner email) | yes (so org_owner can see what they have) | no | no | yes |
|
||||||
|
|
||||||
|
Why this shape:
|
||||||
|
- Resellers can't tolerate "we missed a payment and 80 displays went black at 2am." Devices keep playing in `read_only`.
|
||||||
|
- 7-day grace covers most payment-method-update lag.
|
||||||
|
- 30-day grace on explicit cancel matches stripe-customer-portal cancel-at-period-end semantics.
|
||||||
|
- `locked` is the only state where devices visibly degrade. By then we've sent 4+ notifications across ~37 days.
|
||||||
|
|
||||||
|
Recovery from any state by paying invoice or re-subscribing is automatic via webhook.
|
||||||
|
|
||||||
|
#### Player and write-path mechanism in `read_only`
|
||||||
|
|
||||||
|
The `read_only` state is implemented by two surgical changes, neither of which touches what's already on the displays:
|
||||||
|
|
||||||
|
1. **Existing playlist delivery keeps working.** The device sync path (`buildPlaylistPayload`, the `device:playlist-update` socket emission, and `GET /api/provision/sync`) ignore org subscription state entirely. They read whatever is already assigned to the device's workspace and return it as today. Devices keep receiving the same content, schedules, layouts, and playlists they had at the moment the org entered `read_only`. Reconnects, screenshot push, telemetry heartbeat: all unchanged.
|
||||||
|
2. **Write routes are blocked at the middleware level.** A new `requireWritableOrg` middleware runs on every mutating route (POST/PUT/PATCH/DELETE that creates or edits workspace-scoped resources). It looks up the caller's org subscription state. If state is `read_only` or `locked`, it returns `423 Locked` with a body explaining which org and how to recover (link to Stripe portal). GET routes are unaffected.
|
||||||
|
|
||||||
|
Blocked routes in `read_only` (non-exhaustive):
|
||||||
|
`/api/devices` (POST/PUT/DELETE), `/api/provision/pair`, `/api/content` (upload, edit, delete, folder ops), `/api/playlists` (create/update/publish/items), `/api/schedules` (any write), `/api/layouts` (write), `/api/widgets` (write), `/api/video-walls` (any write), `/api/device-groups` (any write), `/api/teams`/`/api/workspaces` member changes other than the org_owner removing themselves.
|
||||||
|
|
||||||
|
Routes that stay open in `read_only`:
|
||||||
|
all GETs, Stripe billing portal/checkout (so the customer can pay and recover), `/api/auth/*` (login, switch-workspace, logout), `/api/orgs/:id/usage` (visibility), `/api/activity` (visibility), platform_admin endpoints.
|
||||||
|
|
||||||
|
In `locked`, the same write-routes stay blocked AND `buildPlaylistPayload` returns `{ assignments: [], suspended: true, message: 'Subscription expired', detail: '<org_owner email>' }`. The existing "suspended" branch in the web player already renders this splash; we just wire it to org state.
|
||||||
|
|
||||||
|
#### Uniform application to every workspace (add D)
|
||||||
|
|
||||||
|
When an org enters `read_only` or `locked`, **all of its workspaces are affected identically, regardless of `billing_type`**. There is no special protection for `internal` or `client_complimentary` workspaces. The reseller's payment problem affects every workspace under them. This is intentional: the platform has exactly one billable customer (the org_owner), and managing client expectations during a payment lapse is the reseller's responsibility, not the platform's.
|
||||||
|
|
||||||
|
### 8.4 Free tier
|
||||||
|
|
||||||
|
Free tier = `plans.id = 'free'`, `max_devices = 1`. Behaves identically to a paid plan that happens to have a low cap. Trial-expiry behavior in `deviceSocket.js` already exists and stays; it now keys off org state instead of user state.
|
||||||
|
|
||||||
|
## 9. Per-workspace usage rollup (add B)
|
||||||
|
|
||||||
|
Read-only visibility, no enforcement.
|
||||||
|
|
||||||
|
`GET /api/orgs/:id/usage` returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"organization_id": "org_abc",
|
||||||
|
"plan_id": "pro",
|
||||||
|
"max_devices": 100,
|
||||||
|
"total_devices": 95,
|
||||||
|
"subscription_status": "active",
|
||||||
|
"workspaces": [
|
||||||
|
{ "workspace_id": "ws_acme", "name": "AcmeClient", "device_count": 80, "online": 78, "offline": 2, "billing_type": "client_billable" },
|
||||||
|
{ "workspace_id": "ws_foo", "name": "FooClient", "device_count": 15, "online": 15, "offline": 0, "billing_type": "client_complimentary" },
|
||||||
|
{ "workspace_id": "ws_demo", "name": "Sales Demo", "device_count": 2, "online": 2, "offline": 0, "billing_type": "internal" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`billing_type` is included so the reseller can see their mix at a glance (paying clients vs comped vs internal use) without opening each workspace. The org_owner UI may use it for a stacked summary (e.g. "92 client_billable, 15 client_complimentary, 2 internal of 100 cap").
|
||||||
|
|
||||||
|
UI: in the org_owner / org_admin org-settings view, a stacked horizontal bar shows each workspace's slice of the org's cap, plus a row table with raw counts. Click a workspace name to switch into it (acting-as). No allocation UI - resellers eyeball the bar and add devices wherever they want.
|
||||||
|
|
||||||
|
`workspace_admin` and below cannot call this endpoint (their `org_id` doesn't resolve, returns 403).
|
||||||
|
|
||||||
|
## 10. Device pairing while acting-as (add C)
|
||||||
|
|
||||||
|
Pairing flow is workspace-scoped: a paired device's `workspace_id` is whatever workspace the user is currently in at the moment of confirmation.
|
||||||
|
|
||||||
|
### 10.1 Reseller acting inside a client workspace
|
||||||
|
|
||||||
|
1. The acting-as ribbon is showing (`Acting as workspace: Acme`).
|
||||||
|
2. Reseller clicks "Add display" on the dashboard.
|
||||||
|
3. The "Pair Display" modal opens. Top of modal:
|
||||||
|
```
|
||||||
|
New display will be added to: Acme (you are acting as this workspace)
|
||||||
|
```
|
||||||
|
with a button `Change target workspace` that opens a workspace dropdown limited to workspaces of the current org (resellers cannot pair a device into a workspace outside their org).
|
||||||
|
4. Reseller enters pairing code, clicks "Pair".
|
||||||
|
5. Device row is inserted with:
|
||||||
|
- `workspace_id = ws_acme` (the acting-as workspace, or the target from step 3 if changed)
|
||||||
|
- `user_id = reseller.id` (created_by record)
|
||||||
|
- `team_id = ws_acme` (legacy column for compatibility shim)
|
||||||
|
6. Org-wide device count enforcement runs (section 8.2). If over cap, return 402 BEFORE inserting the row.
|
||||||
|
7. Activity log: `acting_user_id = reseller, workspace_id = ws_acme, action = 'device.paired', was_acting_as = true`.
|
||||||
|
|
||||||
|
### 10.2 Reseller NOT acting-as (in their own context)
|
||||||
|
|
||||||
|
Two sub-cases. We pick one for V1.
|
||||||
|
|
||||||
|
**V1 default: force a workspace pick at pairing time.**
|
||||||
|
|
||||||
|
When `org_owner` / `org_admin` is in their org-level context (no specific workspace selected, e.g. on the org settings page), the "Add display" CTA is disabled with a tooltip `Enter a workspace first to pair a device`. They cannot pair from the org settings page.
|
||||||
|
|
||||||
|
When they are in their personal default workspace (which is just one of the org's workspaces), pairing works as in 10.1 with that workspace as the target.
|
||||||
|
|
||||||
|
Why force the pick rather than land in personal default:
|
||||||
|
- Resellers consistently report: "I paired five devices into the wrong workspace because I forgot to switch first." Forcing the explicit choice prevents this footgun.
|
||||||
|
- Personal-default workspace concept is fragile for resellers who have no personal use case (they only manage clients).
|
||||||
|
|
||||||
|
**Alternative (rejected for V1):** Allow pairing from org-level context and require a workspace selector inside the pairing modal. Adds an extra step for every single-workspace customer (the majority of self-hosted users). Reconsidered if real-world feedback contradicts.
|
||||||
|
|
||||||
|
### 10.3 Workspace_admin / editor / viewer
|
||||||
|
|
||||||
|
Pairing target is always the workspace they're in. No selector shown. Their session has exactly one workspace; the modal just says `New display will be added to: <workspace name>`.
|
||||||
|
|
||||||
|
## 11. Self-hosted bootstrap
|
||||||
|
|
||||||
|
On a fresh self-hosted install (`SELF_HOSTED=true`, empty database):
|
||||||
|
|
||||||
|
1. First registrant becomes `users.role = 'platform_admin'`.
|
||||||
|
2. Same registrant becomes the `org_owner` of an auto-created organization named `<name>'s organization`.
|
||||||
|
3. Same registrant becomes `workspace_admin` of an auto-created workspace named `Default`.
|
||||||
|
4. `plans.id = 'enterprise'` is force-assigned to the org with `max_devices = 999999`. No Stripe lookup.
|
||||||
|
|
||||||
|
Subsequent registrants when `DISABLE_REGISTRATION=false`:
|
||||||
|
- Lands as `users.role = 'user'`, no org or workspace memberships.
|
||||||
|
- The platform_admin must invite them to a workspace (or grant org_admin).
|
||||||
|
- Frontend shows "No workspace yet. Ask your administrator for access."
|
||||||
|
|
||||||
|
When `DISABLE_REGISTRATION=true`: registration is closed at the route level. Bootstrap user is the only auto-created identity; others must arrive via invite.
|
||||||
|
|
||||||
|
Self-hosted instances may create multiple organizations. The `platform_admin` UI exposes a "create new organization" button. No Stripe involvement.
|
||||||
|
|
||||||
|
## 12. Socket.IO scoping
|
||||||
|
|
||||||
|
- **Device sockets** (`/device`): unchanged. They join the `device_id` room as today.
|
||||||
|
- **Dashboard sockets** (`/dashboard`): join `ws:<current_workspace_id>` instead of an implicit per-user room.
|
||||||
|
- When the user switches workspace, the socket leaves the old room and joins the new one. Frontend emits `dashboard:switch-workspace` with the new id; server validates membership/acting-as and updates rooms.
|
||||||
|
- Server emits `dashboard:device-status`, `dashboard:screenshot-ready`, `dashboard:playback-progress`, `dashboard:wall-changed` to `ws:<workspace_id>` of the affected resource, not globally.
|
||||||
|
- The existing audience filter (every dashboard reloads after `dashboard:wall-changed` and re-fetches via the access-controlled GET) means even if a stray broadcast reaches a wrong workspace, the GET would 403; for V1 we tighten the broadcast at emit time anyway.
|
||||||
|
|
||||||
|
## 13. Phase-by-phase rollout
|
||||||
|
|
||||||
|
### Phase 0 - design (THIS DOC). Done on approval.
|
||||||
|
|
||||||
|
### Phase 1 - database and migration
|
||||||
|
- Add the four new tables.
|
||||||
|
- Add `workspace_id` / `organization_id` columns on existing tables.
|
||||||
|
- Backfill: every existing user becomes an org + workspace(s) per section 4.
|
||||||
|
- Snapshot pre-migration DB before any ALTER.
|
||||||
|
- Validation script: row-count parity per user before vs after.
|
||||||
|
- No route changes yet. Frontend unchanged. Existing logins still work because middleware reads `team_id` as before in V0 paths.
|
||||||
|
- Gate: visual test - log in as three different existing users, see exactly the same dashboard as before migration.
|
||||||
|
|
||||||
|
### Phase 2 - backend permissions and scoping
|
||||||
|
- Org and workspace models in `server/models/` (or wherever the repo wants them).
|
||||||
|
- Auth middleware resolves `current_workspace_id`. JWT gets `current_workspace_id`. `/api/auth/me` returns memberships.
|
||||||
|
- `/api/auth/switch-workspace` endpoint.
|
||||||
|
- Permission helpers (`can()` per section 2.5).
|
||||||
|
- Every existing route: replace `user_id` filter with `workspace_id` filter. Keep `user_id` writes as created_by.
|
||||||
|
- Socket.IO room scoping (section 12).
|
||||||
|
- Gate: regression test of every route under the new scoping. Existing client unchanged, all functionality works.
|
||||||
|
|
||||||
|
### Phase 3 - frontend
|
||||||
|
- Workspace picker view at `#/select-workspace`.
|
||||||
|
- Header workspace indicator + dropdown.
|
||||||
|
- Acting-as ribbon.
|
||||||
|
- Org settings page with: members, workspaces list, branding defaults, usage rollup (add B). Rollup table includes a `billing_type` column.
|
||||||
|
- Workspace settings page: members, branding override, delete-workspace (org_owner only).
|
||||||
|
- Workspace settings "Billing (reseller use)" section (add D), visible only to `org_owner` and `org_admin`:
|
||||||
|
- `billing_type` dropdown (client_billable / client_complimentary / internal)
|
||||||
|
- `billing_notes` textarea
|
||||||
|
- `billing_contact_email` field
|
||||||
|
- `billing_contract_ref` field
|
||||||
|
- Help text: "This information is for your own records. ScreenTinker does not bill or contact clients - that is between you and them."
|
||||||
|
- The whole section is gated server-side and hidden client-side from `workspace_admin` and below.
|
||||||
|
- Updated pairing modal per section 10 (target workspace banner / selector).
|
||||||
|
|
||||||
|
### Phase 4 - billing
|
||||||
|
- Move Stripe customer/subscription writes to the org row.
|
||||||
|
- Device-count enforcement at pair time queries the org rollup.
|
||||||
|
- Webhook handlers update the org's lifecycle state machine (section 8.3).
|
||||||
|
- `read_only` and `locked` banners on dashboard chrome.
|
||||||
|
- Daily cron job for grace-period expiry transitions.
|
||||||
|
|
||||||
|
### Phase 5 - self-hosted validation
|
||||||
|
- Fresh `SELF_HOSTED=true` install on a clean SQLite DB.
|
||||||
|
- First registrant becomes platform_admin + org_owner + workspace_admin.
|
||||||
|
- `DISABLE_REGISTRATION=true` still works.
|
||||||
|
- Multi-org creation works (platform_admin can spin up multiple orgs for separate resellers).
|
||||||
|
- Stripe routes return `{ enabled: false }` and the billing UI hides.
|
||||||
|
|
||||||
|
## 14. Decisions deferred to V2
|
||||||
|
|
||||||
|
- Subdomain-per-workspace (`client.screentinker.com`) and per-workspace custom domain via CNAME. Requires nginx automation + cert lifecycle (likely a sidecar like caddy or acme.sh integration).
|
||||||
|
- Per-workspace device-count caps (allocation). V1 shows the rollup view (add B); allocation UI follows.
|
||||||
|
- **Per-client invoicing reports (add D)**: per-workspace soft caps combined with `billing_type` metadata enables a future "invoicing CSV" - V2 could render, for each `client_billable` workspace, a device-month consumption summary the reseller can import into their own invoicing system. Purely a reseller convenience; no money flows through ScreenTinker. Flagged here, deferred.
|
||||||
|
- Path-versioned `/api/workspaces/:wid/...` form with 308 redirects from legacy paths.
|
||||||
|
- Drop the now-unused `users.plan_id`, `users.stripe_*`, `users.subscription_*` columns. Stay nullable in V1, drop in V2.
|
||||||
|
- Drop the `team_id` compatibility column on resource tables.
|
||||||
|
- Nested teams inside a workspace. Not asked for. Don't add without a concrete request.
|
||||||
|
- "Transfer workspace between organizations" - rare; defer until requested.
|
||||||
|
|
||||||
|
## 15. Open questions still on the table
|
||||||
|
|
||||||
|
None blocking Phase 1. The following are nice-to-have clarifications you can answer at any time before Phase 3:
|
||||||
|
|
||||||
|
- **Default workspace name format**: current proposal is `Default`. Resellers might prefer `<client name>` only with no `Default` workspace at all. We can confirm during Phase 3 when the workspace-create UX lands.
|
||||||
|
- **Email notifications for invites**: today's team invite email template gets reused for both org-member and workspace-member invites with subject lines that distinguish them. Confirm copy in Phase 3.
|
||||||
|
- **Activity log retention**: currently unlimited. With orgs, do we want a per-org retention cap (90 days default, configurable on enterprise)? Defer to V2.
|
||||||
|
|
||||||
|
End of design doc.
|
||||||
1728
docs/openapi.yaml
Normal file
1728
docs/openapi.yaml
Normal file
File diff suppressed because it is too large
Load diff
17
frontend/api-docs.html
Normal file
17
frontend/api-docs.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>ScreenTinker API Reference</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<meta name="description" content="ScreenTinker public API reference"/>
|
||||||
|
<style>body { margin: 0; padding: 0; }</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Self-hosted Redoc: the spec is served at /openapi.yaml and the Redoc bundle is
|
||||||
|
vendored locally (no CDN) so the docs work on an offline/air-gapped instance.
|
||||||
|
The <redoc> element auto-initialises from the standalone bundle. -->
|
||||||
|
<redoc spec-url="/openapi.yaml"></redoc>
|
||||||
|
<script src="/vendor/redoc.standalone.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
frontend/assets/dashboard-preview.png
Normal file
BIN
frontend/assets/dashboard-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
158
frontend/compare/optisigns-alternative.html
Normal file
158
frontend/compare/optisigns-alternative.html
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Best OptiSigns Alternative (2026) - Free & Open Source | ScreenTinker</title>
|
||||||
|
<meta name="description" content="Looking for an OptiSigns alternative? ScreenTinker is open source, MIT licensed, self-hostable, and costs less at scale. Honest feature and pricing comparison.">
|
||||||
|
<meta name="keywords" content="optisigns alternative, free optisigns alternative, open source digital signage, self hosted digital signage, digital signage cms">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="https://screentinker.com/compare/optisigns-alternative.html">
|
||||||
|
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:url" content="https://screentinker.com/compare/optisigns-alternative.html">
|
||||||
|
<meta property="og:title" content="Best OptiSigns Alternative (2026) | ScreenTinker">
|
||||||
|
<meta property="og:description" content="ScreenTinker vs OptiSigns. Open source, self-hostable, lower cost at scale.">
|
||||||
|
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
<meta property="og:site_name" content="ScreenTinker">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Best OptiSigns Alternative (2026)">
|
||||||
|
<meta name="twitter:description" content="ScreenTinker vs OptiSigns. Open source, self-hostable, lower cost at scale.">
|
||||||
|
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#111827">
|
||||||
|
<link rel="icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/css/seo-page.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<div class="nav-logo">
|
||||||
|
<a href="/" style="display:flex;align-items:center;gap:10px">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
<span class="nav-logo-text">ScreenTinker</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/#features">Features</a>
|
||||||
|
<a href="/#pricing">Pricing</a>
|
||||||
|
<a href="/#compare">Compare</a>
|
||||||
|
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="article">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/#compare">Compare</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>OptiSigns Alternative</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Best OptiSigns Alternative (2026): ScreenTinker vs OptiSigns</h1>
|
||||||
|
<p class="lead">OptiSigns has built a strong reputation in restaurants, retail, and small business signage. Here is an honest comparison with ScreenTinker covering features, pricing, and where each fits best.</p>
|
||||||
|
|
||||||
|
<h2>The short answer</h2>
|
||||||
|
<p><strong>OptiSigns</strong> is a well-marketed cloud signage product with a deep template library and good documentation. It targets non-technical buyers and works particularly well for restaurants and retail menus.</p>
|
||||||
|
<p><strong>ScreenTinker</strong> is open source (MIT licensed), self-hostable, runs on hardware you already own with no lock-in, and is meaningfully cheaper at higher screen counts. It is a better fit if you have any technical capacity, you care about data sovereignty, or you operate at a scale where per-screen pricing hurts.</p>
|
||||||
|
|
||||||
|
<h2>Quick comparison</h2>
|
||||||
|
<div class="compare-table-wrap">
|
||||||
|
<table class="compare-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Feature</th><th>ScreenTinker</th><th>OptiSigns</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Open source</td><td class="yes">Yes (MIT)</td><td class="no">No</td></tr>
|
||||||
|
<tr><td>Self-host option</td><td class="yes">Yes</td><td class="no">No (cloud only)</td></tr>
|
||||||
|
<tr><td>Free plan</td><td class="yes">1 device, 500MB</td><td class="yes">Free, up to 3 screens</td></tr>
|
||||||
|
<tr><td>Android TV / Fire TV</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Raspberry Pi</td><td class="yes">Free setup script</td><td class="partial">Limited support<!-- VERIFY: OptiSigns markets 10+ platforms incl. Pi; exact Pi support level not confirmed --></td></tr>
|
||||||
|
<tr><td>Windows / ChromeOS</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Web browser player</td><td class="yes">Yes</td><td class="partial">Limited</td></tr>
|
||||||
|
<tr><td>Video walls</td><td class="yes">Yes (with sync)</td><td class="partial">Paid add-on</td></tr>
|
||||||
|
<tr><td>Multi-zone layouts</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Template library</td><td class="partial">Custom designer</td><td class="yes">Large library</td></tr>
|
||||||
|
<tr><td>Live remote control*</td><td class="yes">Yes</td><td class="partial">Limited</td></tr>
|
||||||
|
<tr><td>White-label / reseller</td><td class="yes">Yes</td><td class="paid">Paid tier</td></tr>
|
||||||
|
<tr><td>Pricing for 15 devices</td><td>$99/mo Pro</td><td>~$165/mo (11 USD/screen)</td></tr>
|
||||||
|
<tr><td>Self-host cost</td><td>Free (your server)</td><td>Not available</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--dim);line-height:1.6;margin-top:12px">
|
||||||
|
* Live remote control is Android only and requires granting the on-device accessibility permission.<br>
|
||||||
|
Comparison as of June 2026, based on each vendor's publicly listed pricing and documentation. Spot an error? <a href="https://github.com/screentinker/screentinker/issues" target="_blank" rel="noopener">Open an issue on GitHub</a> and we'll fix it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Where OptiSigns does well</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Templates.</strong> Hundreds of pre-built templates for menus, real estate listings, gym schedules, and more. Best-in-class for non-designers who need to ship fast.</li>
|
||||||
|
<li><strong>Niche features.</strong> POS integrations for restaurants, MLS feeds for real estate, fitness class schedule integrations.</li>
|
||||||
|
<li><strong>Documentation and support.</strong> Extensive tutorial library, responsive support team.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Where ScreenTinker is the better choice</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Cost at scale.</strong> OptiSigns is around $11/screen/month on the Pro plan. At 15 devices that is $165/mo; ScreenTinker Pro is $99/mo. The gap widens as you add screens.</li>
|
||||||
|
<li><strong>Self-hosting.</strong> If you cannot or will not put your signage data in a third-party cloud, ScreenTinker is one of the few real options. OptiSigns does not offer this.</li>
|
||||||
|
<li><strong>Source access.</strong> MIT licensed on <a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>. Read the code, modify it, fork it.</li>
|
||||||
|
<li><strong>Live remote control.</strong> Stream a live view of any display and inject taps or key events. Most cloud signage tools only show occasional screenshots.</li>
|
||||||
|
<li><strong>Runs on hardware you already own.</strong> Native Android APK, web player works on any browser, Pi setup script, Windows-friendly, macOS-friendly - no proprietary player to buy.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Pricing example: 25 devices for one year</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>OptiSigns Pro:</strong> ~$3,300/year (25 x $11/mo)</li>
|
||||||
|
<li><strong>ScreenTinker:</strong> Custom Enterprise plan or self-host at server cost only</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="related">
|
||||||
|
<h2>Related guides</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/guides/raspberry-pi-digital-signage.html">How to set up digital signage on a Raspberry Pi</a></li>
|
||||||
|
<li><a href="/guides/digital-signage-android-tv.html">Free digital signage for Android TV and Fire TV</a></li>
|
||||||
|
<li><a href="/guides/self-hosted-digital-signage.html">Self-hosted digital signage: complete guide</a></li>
|
||||||
|
<li><a href="/compare/yodeck-alternative.html">Compare: ScreenTinker vs Yodeck</a></li>
|
||||||
|
<li><a href="/compare/screencloud-alternative.html">Compare: ScreenTinker vs ScreenCloud</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta">
|
||||||
|
<h2>Try ScreenTinker free</h2>
|
||||||
|
<p>Start a 14-day Pro trial. No credit card required.</p>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free Trial</a>
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">View on GitHub</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div style="color:var(--dim);font-size:13px">© 2026 ScreenTinker. All rights reserved.</div>
|
||||||
|
<div class="links">
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
|
||||||
|
<a href="/legal/terms.html">Terms</a>
|
||||||
|
<a href="/legal/privacy.html">Privacy</a>
|
||||||
|
<a href="/legal/third-party.html">Licenses</a>
|
||||||
|
<a href="/app#/login">Sign In</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "Compare", "item": "https://screentinker.com/#compare" },
|
||||||
|
{ "@type": "ListItem", "position": 3, "name": "OptiSigns Alternative", "item": "https://screentinker.com/compare/optisigns-alternative.html" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
159
frontend/compare/screencloud-alternative.html
Normal file
159
frontend/compare/screencloud-alternative.html
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Best ScreenCloud Alternative (2026) - Free & Open Source | ScreenTinker</title>
|
||||||
|
<meta name="description" content="ScreenCloud is great but expensive at scale. ScreenTinker is open source, MIT licensed, self-hostable, and a fraction of the price for the same screen count. Compare features, pricing, and platform support.">
|
||||||
|
<meta name="keywords" content="screencloud alternative, free screencloud alternative, open source digital signage, self hosted digital signage, digital signage cms">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="https://screentinker.com/compare/screencloud-alternative.html">
|
||||||
|
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:url" content="https://screentinker.com/compare/screencloud-alternative.html">
|
||||||
|
<meta property="og:title" content="Best ScreenCloud Alternative (2026) | ScreenTinker">
|
||||||
|
<meta property="og:description" content="ScreenTinker vs ScreenCloud. Open source, self-hostable, dramatically cheaper at scale.">
|
||||||
|
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
<meta property="og:site_name" content="ScreenTinker">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Best ScreenCloud Alternative (2026)">
|
||||||
|
<meta name="twitter:description" content="ScreenTinker vs ScreenCloud. Open source, self-hostable, dramatically cheaper at scale.">
|
||||||
|
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#111827">
|
||||||
|
<link rel="icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/css/seo-page.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<div class="nav-logo">
|
||||||
|
<a href="/" style="display:flex;align-items:center;gap:10px">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
<span class="nav-logo-text">ScreenTinker</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/#features">Features</a>
|
||||||
|
<a href="/#pricing">Pricing</a>
|
||||||
|
<a href="/#compare">Compare</a>
|
||||||
|
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="article">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/#compare">Compare</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>ScreenCloud Alternative</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Best ScreenCloud Alternative (2026): ScreenTinker vs ScreenCloud</h1>
|
||||||
|
<p class="lead">ScreenCloud is a polished enterprise digital signage platform - but pricing scales fast. Here is an honest comparison covering features, pricing, and where each fits best.</p>
|
||||||
|
|
||||||
|
<h2>The short answer</h2>
|
||||||
|
<p><strong>ScreenCloud</strong> is a mature, well-designed cloud signage product targeted at mid-market and enterprise customers. It has strong app integrations (Slack, Power BI, Google Drive) and excellent support. It is also one of the most expensive options on the market.</p>
|
||||||
|
<p><strong>ScreenTinker</strong> is open source (MIT licensed), self-hostable, and dramatically cheaper at scale. It is a better fit if you want to keep data on your own infrastructure, you have budget pressure, or your screen count makes ScreenCloud's per-screen pricing untenable.</p>
|
||||||
|
|
||||||
|
<h2>Quick comparison</h2>
|
||||||
|
<div class="compare-table-wrap">
|
||||||
|
<table class="compare-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Feature</th><th>ScreenTinker</th><th>ScreenCloud</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Open source</td><td class="yes">Yes (MIT)</td><td class="no">No</td></tr>
|
||||||
|
<tr><td>Self-host option</td><td class="yes">Yes</td><td class="no">No (cloud only)</td></tr>
|
||||||
|
<tr><td>Free plan</td><td class="yes">1 device, 500MB</td><td class="no">14-day trial only</td></tr>
|
||||||
|
<tr><td>Android TV / Fire TV</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Raspberry Pi</td><td class="yes">Free setup script</td><td class="yes">ScreenCloud OS</td></tr>
|
||||||
|
<tr><td>Web browser player</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Video walls</td><td class="yes">Yes (with sync)</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Multi-zone layouts</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>App integrations</td><td class="partial">Custom widgets</td><td class="yes">Built-in (Slack, Power BI, etc.)</td></tr>
|
||||||
|
<tr><td>Live remote control*</td><td class="yes">Yes</td><td class="partial">Limited</td></tr>
|
||||||
|
<tr><td>White-label / reseller</td><td class="yes">Yes</td><td class="partial">Enterprise only</td></tr>
|
||||||
|
<tr><td>Pricing for 5 devices</td><td>$39/mo Starter</td><td>~$108/mo</td></tr>
|
||||||
|
<tr><td>Pricing for 15 devices</td><td>$99/mo Pro</td><td>~$300+/mo</td></tr>
|
||||||
|
<tr><td>Self-host cost</td><td>Free (your server)</td><td>Not available</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--dim);line-height:1.6;margin-top:12px">
|
||||||
|
* Live remote control is Android only and requires granting the on-device accessibility permission.<br>
|
||||||
|
Comparison as of June 2026, based on each vendor's publicly listed pricing and documentation. Spot an error? <a href="https://github.com/screentinker/screentinker/issues" target="_blank" rel="noopener">Open an issue on GitHub</a> and we'll fix it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Where ScreenCloud does well</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Native app integrations.</strong> Slack channels, Power BI dashboards, Google Drive, OneDrive, and dozens of others ship as built-in apps. If your displays show live business dashboards, this matters.</li>
|
||||||
|
<li><strong>Enterprise polish.</strong> SOC 2 audited, dedicated account management, mature SAML/SSO support.</li>
|
||||||
|
<li><strong>Studio (their content designer).</strong> Best-in-class WYSIWYG editor for non-designers.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Where ScreenTinker is the better choice</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Cost.</strong> At 15 screens ScreenCloud runs roughly $300/mo and up depending on plan; ScreenTinker Pro is $99/mo. Over a year that is more than $2,000 in savings on a single deployment.</li>
|
||||||
|
<li><strong>Self-hosting.</strong> ScreenCloud is cloud-only with no on-prem path. If your security team or compliance posture won't allow a third-party cloud, ScreenTinker is one of the few real options.</li>
|
||||||
|
<li><strong>Source access.</strong> MIT licensed on <a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>. Audit, extend, fork - all permitted.</li>
|
||||||
|
<li><strong>No hardware lock-in.</strong> ScreenCloud sells "ScreenCloud OS" hardware; ScreenTinker runs on whatever you have - Pi, Android TV, Fire Stick, kiosk PC, browser.</li>
|
||||||
|
<li><strong>Live remote control.</strong> Stream a live view of any display and inject taps or key events from the dashboard. Useful for remote troubleshooting without a site visit.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Pricing example: 15 devices over 12 months</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>ScreenCloud (Pro plan):</strong> ~$3,600/year</li>
|
||||||
|
<li><strong>ScreenTinker (Pro plan):</strong> $1,188/year</li>
|
||||||
|
<li><strong>ScreenTinker (self-hosted):</strong> Server cost only, typically $5-50/month for a small VPS</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="related">
|
||||||
|
<h2>Related guides</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/guides/self-hosted-digital-signage.html">Self-hosted digital signage: complete guide</a></li>
|
||||||
|
<li><a href="/guides/raspberry-pi-digital-signage.html">How to set up digital signage on a Raspberry Pi</a></li>
|
||||||
|
<li><a href="/guides/digital-signage-android-tv.html">Free digital signage for Android TV and Fire TV</a></li>
|
||||||
|
<li><a href="/compare/yodeck-alternative.html">Compare: ScreenTinker vs Yodeck</a></li>
|
||||||
|
<li><a href="/compare/optisigns-alternative.html">Compare: ScreenTinker vs OptiSigns</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta">
|
||||||
|
<h2>Try ScreenTinker free</h2>
|
||||||
|
<p>Start a 14-day Pro trial. No credit card required.</p>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free Trial</a>
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">View on GitHub</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div style="color:var(--dim);font-size:13px">© 2026 ScreenTinker. All rights reserved.</div>
|
||||||
|
<div class="links">
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
|
||||||
|
<a href="/legal/terms.html">Terms</a>
|
||||||
|
<a href="/legal/privacy.html">Privacy</a>
|
||||||
|
<a href="/legal/third-party.html">Licenses</a>
|
||||||
|
<a href="/app#/login">Sign In</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "Compare", "item": "https://screentinker.com/#compare" },
|
||||||
|
{ "@type": "ListItem", "position": 3, "name": "ScreenCloud Alternative", "item": "https://screentinker.com/compare/screencloud-alternative.html" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
155
frontend/compare/yodeck-alternative.html
Normal file
155
frontend/compare/yodeck-alternative.html
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Best Yodeck Alternative (2026) - Free & Open Source | ScreenTinker</title>
|
||||||
|
<meta name="description" content="Looking for a Yodeck alternative? ScreenTinker is open source, MIT licensed, self-hostable, and runs on any screen with no hardware lock-in. Free plan included. Compare features, pricing, and platform support.">
|
||||||
|
<meta name="keywords" content="yodeck alternative, free yodeck alternative, open source digital signage, self hosted digital signage, digital signage cms">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="https://screentinker.com/compare/yodeck-alternative.html">
|
||||||
|
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:url" content="https://screentinker.com/compare/yodeck-alternative.html">
|
||||||
|
<meta property="og:title" content="Best Yodeck Alternative (2026) - Free & Open Source | ScreenTinker">
|
||||||
|
<meta property="og:description" content="ScreenTinker vs Yodeck. Open source, self-hostable, no hardware lock-in. Free plan included.">
|
||||||
|
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
<meta property="og:site_name" content="ScreenTinker">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Best Yodeck Alternative (2026) - Free & Open Source">
|
||||||
|
<meta name="twitter:description" content="ScreenTinker vs Yodeck. Open source, self-hostable, no hardware lock-in. Free plan included.">
|
||||||
|
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#111827">
|
||||||
|
<link rel="icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/css/seo-page.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<div class="nav-logo">
|
||||||
|
<a href="/" style="display:flex;align-items:center;gap:10px">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
<span class="nav-logo-text">ScreenTinker</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/#features">Features</a>
|
||||||
|
<a href="/#pricing">Pricing</a>
|
||||||
|
<a href="/#compare">Compare</a>
|
||||||
|
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="article">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/#compare">Compare</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>Yodeck Alternative</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Best Yodeck Alternative (2026): ScreenTinker vs Yodeck</h1>
|
||||||
|
<p class="lead">Looking for an open-source, self-hostable alternative to Yodeck? Here is an honest comparison covering pricing, features, platform support, and where each tool fits best.</p>
|
||||||
|
|
||||||
|
<h2>The short answer</h2>
|
||||||
|
<p><strong>Yodeck</strong> is a polished, easy-to-use cloud digital signage product with a Pi player included on paid plans. It is a great fit if you want to plug in and go and you are happy with cloud-only hosting and per-screen pricing.</p>
|
||||||
|
<p><strong>ScreenTinker</strong> is open source (MIT licensed), self-hostable, and runs on any screen you already own with no hardware lock-in. It is a better fit if you want to keep your data on your own infrastructure, avoid per-screen lock-in, or you have more than a handful of screens and want to control the cost curve.</p>
|
||||||
|
|
||||||
|
<h2>Quick comparison</h2>
|
||||||
|
<div class="compare-table-wrap">
|
||||||
|
<table class="compare-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Feature</th><th>ScreenTinker</th><th>Yodeck</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Open source</td><td class="yes">Yes (MIT)</td><td class="no">No</td></tr>
|
||||||
|
<tr><td>Self-host option</td><td class="yes">Yes</td><td class="no">No (cloud only)</td></tr>
|
||||||
|
<tr><td>Free plan</td><td class="yes">1 device, 500MB</td><td class="yes">1 device</td></tr>
|
||||||
|
<tr><td>Android TV / Fire TV</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Raspberry Pi</td><td class="yes">Free setup script</td><td class="yes">Player included on paid plans</td></tr>
|
||||||
|
<tr><td>Windows / ChromeOS</td><td class="yes">Yes (web player)</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Web browser player</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Video walls (multi-screen sync)</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Multi-zone layouts</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>Live remote control*</td><td class="yes">Yes</td><td class="partial">Screenshot only</td></tr>
|
||||||
|
<tr><td>Kiosk / interactive mode</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
|
||||||
|
<tr><td>White-label / reseller</td><td class="yes">Yes</td><td class="partial">Enterprise tier</td></tr>
|
||||||
|
<tr><td>Pricing for 15 devices</td><td>$99/mo Pro</td><td>~$120/mo (8 USD/screen)</td></tr>
|
||||||
|
<tr><td>Self-host cost</td><td>Free (your server)</td><td>Not available</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--dim);line-height:1.6;margin-top:12px">
|
||||||
|
* Live remote control is Android only and requires granting the on-device accessibility permission.<br>
|
||||||
|
Comparison as of June 2026, based on each vendor's publicly listed pricing and documentation. Spot an error? <a href="https://github.com/screentinker/screentinker/issues" target="_blank" rel="noopener">Open an issue on GitHub</a> and we'll fix it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Where Yodeck does well</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Onboarding.</strong> Yodeck ships pre-configured Pi players on paid plans, which removes a real setup step for non-technical buyers.</li>
|
||||||
|
<li><strong>Polish.</strong> The product has been around since 2014, and the cloud experience is mature.</li>
|
||||||
|
<li><strong>Templates.</strong> A large pre-built template library for menus, lobby boards, and announcements.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Where ScreenTinker is the better choice</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>You need data sovereignty.</strong> If your content includes PII, internal documents, or you operate in regulated industries (healthcare, government, finance), self-hosting is the only way to keep data off a third-party cloud. Yodeck cannot do this.</li>
|
||||||
|
<li><strong>You have more than a handful of screens.</strong> Per-screen pricing scales linearly. ScreenTinker Pro is flat at $99/mo for 15 devices, and self-hosters pay nothing per device. At 50+ screens the total cost difference is significant.</li>
|
||||||
|
<li><strong>You want platform flexibility.</strong> ScreenTinker runs on any device with a browser - Smart TVs, ChromeOS, kiosk PCs, even old Macs. You are not locked into a specific Pi SKU.</li>
|
||||||
|
<li><strong>You want to read or modify the source.</strong> ScreenTinker is MIT licensed on <a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>. Audit the code, extend it, or fork it.</li>
|
||||||
|
<li><strong>You want live remote control.</strong> ScreenTinker streams a live screenshot feed and forwards touches and key events back to the device. Yodeck only takes occasional screenshots.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Pricing snapshot</h2>
|
||||||
|
<p>Yodeck charges per screen per month, typically <strong>$8/screen/mo</strong> on the standard plan with annual billing. ScreenTinker Pro is a flat <strong>$99/mo for 15 devices</strong>. Crossover happens around 12-13 screens; above that ScreenTinker is meaningfully cheaper. Self-hosters pay nothing per device.</p>
|
||||||
|
|
||||||
|
<div class="related">
|
||||||
|
<h2>Related guides</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/guides/raspberry-pi-digital-signage.html">How to set up digital signage on a Raspberry Pi</a></li>
|
||||||
|
<li><a href="/guides/digital-signage-android-tv.html">Free digital signage for Android TV and Fire TV</a></li>
|
||||||
|
<li><a href="/guides/self-hosted-digital-signage.html">Self-hosted digital signage: complete guide</a></li>
|
||||||
|
<li><a href="/compare/screencloud-alternative.html">Compare: ScreenTinker vs ScreenCloud</a></li>
|
||||||
|
<li><a href="/compare/optisigns-alternative.html">Compare: ScreenTinker vs OptiSigns</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta">
|
||||||
|
<h2>Try ScreenTinker free</h2>
|
||||||
|
<p>Start a 14-day Pro trial. No credit card required.</p>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free Trial</a>
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">View on GitHub</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div style="color:var(--dim);font-size:13px">© 2026 ScreenTinker. All rights reserved.</div>
|
||||||
|
<div class="links">
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
|
||||||
|
<a href="/legal/terms.html">Terms</a>
|
||||||
|
<a href="/legal/privacy.html">Privacy</a>
|
||||||
|
<a href="/legal/third-party.html">Licenses</a>
|
||||||
|
<a href="/app#/login">Sign In</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "Compare", "item": "https://screentinker.com/#compare" },
|
||||||
|
{ "@type": "ListItem", "position": 3, "name": "Yodeck Alternative", "item": "https://screentinker.com/compare/yodeck-alternative.html" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -32,9 +32,191 @@ body {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Workspace switcher (Phase 3 MVP). Sits in sidebar-header below the logo.
|
||||||
|
Three render modes via JS: dropdown (>1 ws), static text (1 ws),
|
||||||
|
muted empty state (0 ws). */
|
||||||
|
.workspace-switcher { position: relative; margin-top: 12px; }
|
||||||
|
.workspace-switcher-button {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
width: 100%; padding: 8px 10px;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); color: var(--text-primary);
|
||||||
|
font-size: 13px; cursor: pointer; transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.workspace-switcher-button:hover { border-color: var(--accent); }
|
||||||
|
.workspace-switcher-static {
|
||||||
|
display: block; padding: 4px 2px;
|
||||||
|
color: var(--text-primary); font-size: 13px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.workspace-switcher-static::before {
|
||||||
|
content: 'Workspace';
|
||||||
|
display: block;
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted); margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.workspace-switcher-empty {
|
||||||
|
display: block; padding: 8px 10px;
|
||||||
|
color: var(--text-muted); font-size: 12px; font-style: italic;
|
||||||
|
}
|
||||||
|
/* #19: single-workspace view - name + always-visible manage icons (no dropdown). */
|
||||||
|
.workspace-switcher-single { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.workspace-switcher-single .workspace-switcher-static { flex: 1; min-width: 0; }
|
||||||
|
.workspace-switcher-single .workspace-switcher-members,
|
||||||
|
.workspace-switcher-single .workspace-switcher-pencil { visibility: visible; align-self: end; }
|
||||||
|
.workspace-switcher-button .chev {
|
||||||
|
flex-shrink: 0; margin-left: 8px; color: var(--text-muted);
|
||||||
|
transition: transform var(--transition);
|
||||||
|
}
|
||||||
|
.workspace-switcher.open .chev { transform: rotate(180deg); }
|
||||||
|
.workspace-switcher-menu {
|
||||||
|
display: none;
|
||||||
|
/* Width: detach from the narrow sidebar-header (188px content width). The
|
||||||
|
sidebar is z-indexed and the dropdown is free to extend beyond the
|
||||||
|
sidebar into the main content area. min/max bounds keep it readable
|
||||||
|
for normal-length names without sprawling on extreme cases. */
|
||||||
|
position: absolute; top: calc(100% + 4px); left: 0;
|
||||||
|
min-width: 280px; max-width: 360px;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
max-height: 360px; padding: 4px 0; overflow-y: auto; z-index: 100;
|
||||||
|
}
|
||||||
|
.workspace-switcher.open .workspace-switcher-menu { display: block; }
|
||||||
|
/* #16: sticky type-to-filter search header inside the (scrolling) menu. */
|
||||||
|
.workspace-switcher-search {
|
||||||
|
position: sticky; top: 0; z-index: 1;
|
||||||
|
background: var(--bg-card); padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.workspace-switcher-search input {
|
||||||
|
width: 100%; box-sizing: border-box; padding: 6px 8px;
|
||||||
|
background: var(--bg-input); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); color: var(--text-primary); font-size: 13px;
|
||||||
|
}
|
||||||
|
.workspace-switcher-search input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.workspace-switcher-noresults {
|
||||||
|
padding: 12px; color: var(--text-muted); font-size: 13px; text-align: center;
|
||||||
|
}
|
||||||
|
.workspace-switcher-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 12px; cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-primary); font-size: 13px;
|
||||||
|
}
|
||||||
|
.workspace-switcher-item:last-child { border-bottom: none; }
|
||||||
|
.workspace-switcher-item:hover { background: var(--bg-input); }
|
||||||
|
/* keyboard-cursor highlight (arrow keys) - same surface as hover */
|
||||||
|
.workspace-switcher-item.highlighted { background: var(--bg-input); }
|
||||||
|
.workspace-switcher-item.current { font-weight: 600; }
|
||||||
|
.workspace-switcher-item .check {
|
||||||
|
flex-shrink: 0; color: var(--accent); width: 14px;
|
||||||
|
}
|
||||||
|
.workspace-switcher-item .ws-meta { flex: 1; min-width: 0; }
|
||||||
|
.workspace-switcher-item .ws-name {
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.workspace-switcher-item .ws-org {
|
||||||
|
font-size: 11px; color: var(--text-muted); margin-top: 1px;
|
||||||
|
/* nowrap + ellipsis: long "Org Name . N devices" lines truncate cleanly
|
||||||
|
instead of wrapping onto a second line that doubles row height. */
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.workspace-switcher-pencil {
|
||||||
|
flex-shrink: 0; visibility: hidden;
|
||||||
|
background: none; border: none; padding: 4px;
|
||||||
|
color: var(--text-muted); cursor: pointer;
|
||||||
|
border-radius: 4px; transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.workspace-switcher-item:hover .workspace-switcher-pencil { visibility: visible; }
|
||||||
|
.workspace-switcher-pencil:hover { color: var(--accent); background: var(--bg-input); }
|
||||||
|
/* Members icon - same shape as the pencil; navigates to #/workspace/:id/members. */
|
||||||
|
.workspace-switcher-members {
|
||||||
|
flex-shrink: 0; visibility: hidden;
|
||||||
|
background: none; border: none; padding: 4px;
|
||||||
|
color: var(--text-muted); cursor: pointer;
|
||||||
|
border-radius: 4px; transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.workspace-switcher-item:hover .workspace-switcher-members { visibility: visible; }
|
||||||
|
.workspace-switcher-members:hover { color: var(--accent); background: var(--bg-input); }
|
||||||
|
|
||||||
|
/* Workspace members page (Phase 2 user-mgmt, slice 2A read-only). Three
|
||||||
|
sections render via JS: direct members, via_org access, pending invites.
|
||||||
|
Row layout mirrors the sidebar user card's avatar pattern for visual
|
||||||
|
continuity. via_org rows are opacity-reduced and invite rows use the
|
||||||
|
input-bg shade so the three states are distinguishable at a glance. */
|
||||||
|
.members-list { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.member-row {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg-card);
|
||||||
|
}
|
||||||
|
.member-row--via-org { opacity: 0.75; }
|
||||||
|
.member-row--invited { background: var(--bg-input); }
|
||||||
|
.member-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
background: var(--accent); color: white;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 13px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.member-avatar--muted { background: var(--text-muted); }
|
||||||
|
.member-meta { flex: 1; min-width: 0; }
|
||||||
|
.member-name {
|
||||||
|
font-size: 13px; font-weight: 500; color: var(--text-primary);
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.member-email {
|
||||||
|
font-size: 11px; color: var(--text-muted); margin-top: 1px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.member-role {
|
||||||
|
flex-shrink: 0; font-size: 12px; color: var(--text-secondary);
|
||||||
|
padding: 4px 8px; background: var(--bg-input);
|
||||||
|
border-radius: 4px; min-width: 60px; text-align: center;
|
||||||
|
}
|
||||||
|
.member-detail {
|
||||||
|
flex-shrink: 0; font-size: 11px; color: var(--text-muted);
|
||||||
|
min-width: 110px; text-align: right;
|
||||||
|
}
|
||||||
|
.member-via-org { font-style: italic; }
|
||||||
|
.member-badge {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
padding: 2px 6px; background: var(--bg-input); color: var(--text-muted);
|
||||||
|
border-radius: 3px; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slice 2B - mutation affordances. .member-actions is the cell that holds
|
||||||
|
per-row admin buttons (remove on direct-member rows, cancel on invited
|
||||||
|
rows). via_org rows omit the cell entirely. The role select replaces the
|
||||||
|
.member-role div for admins on direct-member rows. */
|
||||||
|
.member-role-select {
|
||||||
|
flex-shrink: 0; font-size: 12px;
|
||||||
|
background: var(--bg-input); color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
padding: 4px 8px; min-width: 90px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.member-role-select:hover { border-color: var(--accent); }
|
||||||
|
.member-actions {
|
||||||
|
flex-shrink: 0; display: flex; align-items: center; gap: 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.member-action-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: none; border: none; padding: 6px;
|
||||||
|
color: var(--text-muted); cursor: pointer;
|
||||||
|
border-radius: 4px; transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.member-action-btn:hover { background: var(--bg-input); }
|
||||||
|
.member-action-btn--danger:hover { color: var(--danger); }
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px 8px;
|
padding: 12px 8px;
|
||||||
|
/* Scroll the nav when it's taller than the viewport (short screens, e.g.
|
||||||
|
1366x768) so items below the fold (Settings) stay reachable. min-height:0
|
||||||
|
is required for a flex child to shrink and scroll instead of overflowing. */
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
|
|
@ -237,6 +419,322 @@ body {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-card-select {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 5;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.device-card:hover .device-card-select,
|
||||||
|
.device-card.selected .device-card-select { opacity: 1; }
|
||||||
|
.device-card-select input { cursor: pointer; margin: 0; }
|
||||||
|
.device-card.selected { outline: 2px solid var(--primary, #3B82F6); outline-offset: -2px; }
|
||||||
|
|
||||||
|
/* Wall editor — free-form pan/zoom canvas */
|
||||||
|
.wall-viewport {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px) 0 0 / 40px 40px,
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px) 0 0 / 40px 40px,
|
||||||
|
var(--bg-primary);
|
||||||
|
}
|
||||||
|
.wall-viewport.panning { cursor: grabbing; }
|
||||||
|
/* Inner canvas: a 0×0 anchor whose CSS transform supplies pan + zoom.
|
||||||
|
All rect children are absolutely positioned in canvas-data coordinates
|
||||||
|
and inherit the parent transform. transform-origin is the canvas's
|
||||||
|
top-left so pan offsets map cleanly to data → screen pixels. */
|
||||||
|
.wall-canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
/* Disable transition so panning doesn't lag behind the cursor */
|
||||||
|
}
|
||||||
|
.wall-zoom-readout {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 12px;
|
||||||
|
background: rgba(0,0,0,0.65);
|
||||||
|
color: #fff;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
pointer-events: none;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-screen {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(59,130,246,0.08);
|
||||||
|
border: 2px solid var(--primary, #3B82F6);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.wall-screen-overlap {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(96,165,250,0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
display: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.wall-screen-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 6px;
|
||||||
|
right: 24px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.wall-screen-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.wall-screen-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
.wall-screen-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 3;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.wall-screen-remove:hover { background: var(--danger, #ef4444); }
|
||||||
|
|
||||||
|
.wall-player {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(96,165,250,0.18);
|
||||||
|
border: 2px dashed #60a5fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 5;
|
||||||
|
box-shadow: 0 0 0 9999px transparent; /* keeps stacking explicit */
|
||||||
|
}
|
||||||
|
.wall-player-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
color: #dbeafe;
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected rect highlight (works for both screens and the player) */
|
||||||
|
.wall-screen.selected,
|
||||||
|
.wall-player.selected {
|
||||||
|
outline: 3px solid #facc15;
|
||||||
|
outline-offset: 1px;
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fine-position panel inputs */
|
||||||
|
.wall-pos-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto 1fr;
|
||||||
|
gap: 6px 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.wall-pos-grid label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.wall-pos-grid input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
font: inherit;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.wall-pos-grid input:focus { outline: 1px solid var(--primary); outline-offset: 0; border-color: var(--primary); }
|
||||||
|
|
||||||
|
/* Eight resize handles, used by both screens and the player */
|
||||||
|
.wall-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #1d4ed8;
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
.wall-player .wall-handle { border-color: #60a5fa; }
|
||||||
|
.wall-handle-nw { top: -5px; left: -5px; cursor: nw-resize; }
|
||||||
|
.wall-handle-n { top: -5px; left: 50%; transform: translateX(-50%); cursor: n-resize; }
|
||||||
|
.wall-handle-ne { top: -5px; right: -5px; cursor: ne-resize; }
|
||||||
|
.wall-handle-e { top: 50%; right: -5px; transform: translateY(-50%); cursor: e-resize; }
|
||||||
|
.wall-handle-se { bottom: -5px; right: -5px; cursor: se-resize; }
|
||||||
|
.wall-handle-s { bottom: -5px; left: 50%; transform: translateX(-50%); cursor: s-resize; }
|
||||||
|
.wall-handle-sw { bottom: -5px; left: -5px; cursor: sw-resize; }
|
||||||
|
.wall-handle-w { top: 50%; left: -5px; transform: translateY(-50%); cursor: w-resize; }
|
||||||
|
|
||||||
|
/* Wall editor — legacy cells (kept for migration; new editor uses wall-canvas) */
|
||||||
|
.wall-cell {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.wall-cell.occupied {
|
||||||
|
background: rgba(59,130,246,0.15);
|
||||||
|
border: 2px solid var(--primary, #3B82F6);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.wall-cell.occupied:active { cursor: grabbing; }
|
||||||
|
.wall-cell.drag-over {
|
||||||
|
border-color: var(--success, #10b981);
|
||||||
|
box-shadow: 0 0 0 2px rgba(16,185,129,0.25) inset;
|
||||||
|
}
|
||||||
|
.wall-cell-name { font-weight: 500; padding: 0 6px; text-align: center; }
|
||||||
|
.wall-cell-pos {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.wall-cell-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px; right: 4px;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.wall-cell-remove:hover { background: var(--danger, #ef4444); }
|
||||||
|
|
||||||
|
.wall-card .wall-card-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, rgba(139,92,246,0.15), rgba(59,130,246,0.1));
|
||||||
|
}
|
||||||
|
.wall-card-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
width: 65%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.wall-card-cell {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(139,92,246,0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.wall-card-cell.filled {
|
||||||
|
background: rgba(139,92,246,0.5);
|
||||||
|
border-color: rgba(139,92,246,0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card-progress {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 6px 10px 8px;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.85), rgba(0,0,0,0));
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.device-card-progress-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
.device-card-progress-label .dcp-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.device-card-progress-label .dcp-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.device-card-progress-track {
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.device-card-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: var(--primary, #3B82F6);
|
||||||
|
transition: width 0.9s linear;
|
||||||
|
}
|
||||||
|
.device-card-progress-fill.indeterminate {
|
||||||
|
background: linear-gradient(90deg, transparent, var(--primary, #3B82F6), transparent);
|
||||||
|
background-size: 50% 100%;
|
||||||
|
animation: dcp-indeterminate 1.4s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes dcp-indeterminate {
|
||||||
|
0% { background-position: -50% 0; }
|
||||||
|
100% { background-position: 150% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
.device-card-body {
|
.device-card-body {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
|
|
@ -878,6 +1376,12 @@ body {
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Table wrapper: enables horizontal scroll when table min-width exceeds viewport */
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile hamburger toggle */
|
/* Mobile hamburger toggle */
|
||||||
.mobile-menu-btn {
|
.mobile-menu-btn {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -885,8 +1389,8 @@ body {
|
||||||
top: 12px;
|
top: 12px;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
|
@ -915,14 +1419,74 @@ body {
|
||||||
z-index: 140;
|
z-index: 140;
|
||||||
}
|
}
|
||||||
.sidebar-backdrop.open { display: block; }
|
.sidebar-backdrop.open { display: block; }
|
||||||
.content { margin-left: 0; padding: 16px; padding-top: 60px; }
|
.nav-link { min-height: 44px; padding: 10px 14px; }
|
||||||
|
.content { margin-left: 0; padding: 16px; padding-top: 68px; }
|
||||||
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
||||||
.device-grid { grid-template-columns: 1fr; }
|
.device-grid { grid-template-columns: 1fr; }
|
||||||
.content-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
.content-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
||||||
.info-grid { grid-template-columns: 1fr 1fr; }
|
.info-grid { grid-template-columns: 1fr; }
|
||||||
.remote-container { flex-direction: column; }
|
.remote-container { flex-direction: column; }
|
||||||
.remote-controls { width: 100%; flex-direction: row; flex-wrap: wrap; }
|
.remote-controls { width: 100%; flex-direction: row; flex-wrap: wrap; }
|
||||||
.modal { width: 95vw; max-height: 90vh; overflow-y: auto; }
|
.modal { width: 95vw; max-height: 90vh; overflow-y: auto; }
|
||||||
.tabs { overflow-x: auto; }
|
.tabs {
|
||||||
.tab { white-space: nowrap; }
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||||
|
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
||||||
|
}
|
||||||
|
.tab { white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.playlist-item { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Dashboard stats stack to single column */
|
||||||
|
.dash-stats-row { flex-direction: column; }
|
||||||
|
.dash-stats-row .info-card { flex: none; }
|
||||||
|
|
||||||
|
/* Content-library 3-up toolbar stacks vertically */
|
||||||
|
.content-toolbar { flex-direction: column; }
|
||||||
|
.content-toolbar > div[style*="width:320px"] { width: auto !important; }
|
||||||
|
|
||||||
|
/* Schedule controls: allow wrap and widen select to full row */
|
||||||
|
.schedule-controls { gap: 8px; }
|
||||||
|
.schedule-controls > select { flex: 1 1 100%; }
|
||||||
|
.schedule-controls > button,
|
||||||
|
.schedule-controls > span { flex: 0 1 auto; }
|
||||||
|
|
||||||
|
/* Tap targets: minimum 44px height for interactive elements */
|
||||||
|
.btn { min-height: 44px; padding: 10px 16px; }
|
||||||
|
.btn-sm { min-height: 36px; padding: 8px 12px; }
|
||||||
|
.btn-icon { min-width: 40px; min-height: 40px; }
|
||||||
|
|
||||||
|
/* Form inputs: 16px font to prevent iOS focus zoom; 44px tap target */
|
||||||
|
.input,
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="search"],
|
||||||
|
input[type="tel"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-size: 16px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.pairing-input { font-size: 24px; letter-spacing: 6px; }
|
||||||
|
|
||||||
|
/* Modals: adjust padding at 95vw so content doesn't touch edges */
|
||||||
|
.modal-header,
|
||||||
|
.modal-footer { padding: 14px 16px; }
|
||||||
|
.modal-body { padding: 16px; }
|
||||||
|
|
||||||
|
/* Toast container: full-width bar instead of 280px fixed to right */
|
||||||
|
.toast-container {
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
}
|
||||||
|
.toast { min-width: 0; width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.content-grid { grid-template-columns: 1fr; }
|
||||||
|
.assign-content-grid { grid-template-columns: 1fr 1fr; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
87
frontend/css/seo-page.css
Normal file
87
frontend/css/seo-page.css
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/* Shared styles for SEO landing pages: comparison and guide pages.
|
||||||
|
Matches the dark theme of landing.html. */
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
:root { --accent:#3b82f6; --bg:#111827; --card:#1e293b; --border:#334155; --text:#f1f5f9; --muted:#94a3b8; --dim:#64748b; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.65; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Nav (matches landing.html) */
|
||||||
|
nav { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(17,24,39,0.9); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); }
|
||||||
|
.nav-inner { max-width: 1200px; margin: 0 auto; padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.nav-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; color: var(--accent); flex-shrink: 0; }
|
||||||
|
.nav-logo a { color: var(--accent); }
|
||||||
|
.nav-links { display: flex; align-items: center; flex-wrap: nowrap; }
|
||||||
|
.nav-links a { color: var(--muted); margin-left: 24px; font-size: 14px; transition: color 0.2s; }
|
||||||
|
.nav-links a:hover { color: var(--text); text-decoration: none; }
|
||||||
|
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border-radius: 8px; font-weight: 600; font-size: 14px; transition: all 0.2s; border: none; cursor: pointer; }
|
||||||
|
.btn-primary { background: var(--accent); color: white; }
|
||||||
|
.btn-primary:hover { background: #2563eb; text-decoration: none; }
|
||||||
|
.btn-outline { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
|
||||||
|
.btn-outline:hover { background: rgba(59,130,246,0.1); text-decoration: none; }
|
||||||
|
|
||||||
|
/* Article container */
|
||||||
|
.article { max-width: 880px; margin: 0 auto; padding: 120px 24px 60px; }
|
||||||
|
.breadcrumb { font-size: 13px; color: var(--muted); margin-bottom: 24px; }
|
||||||
|
.breadcrumb a { color: var(--muted); }
|
||||||
|
.breadcrumb a:hover { color: var(--text); }
|
||||||
|
.breadcrumb span { margin: 0 8px; color: var(--dim); }
|
||||||
|
|
||||||
|
.article h1 { font-size: clamp(30px, 4vw, 44px); font-weight: 800; line-height: 1.2; margin-bottom: 16px; }
|
||||||
|
.article .lead { font-size: 18px; color: var(--muted); margin-bottom: 32px; }
|
||||||
|
.article h2 { font-size: 28px; font-weight: 700; margin: 48px 0 16px; line-height: 1.3; }
|
||||||
|
.article h3 { font-size: 20px; font-weight: 600; margin: 28px 0 12px; }
|
||||||
|
.article p { margin-bottom: 16px; color: var(--text); }
|
||||||
|
.article ul, .article ol { margin: 0 0 16px 24px; color: var(--text); }
|
||||||
|
.article li { margin-bottom: 8px; }
|
||||||
|
.article strong { color: var(--text); font-weight: 600; }
|
||||||
|
.article code { background: var(--card); border: 1px solid var(--border); padding: 2px 6px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; color: #e2e8f0; }
|
||||||
|
.article pre { background: var(--card); border: 1px solid var(--border); padding: 16px; border-radius: 8px; overflow-x: auto; margin: 16px 0; }
|
||||||
|
.article pre code { background: transparent; border: none; padding: 0; font-size: 13px; }
|
||||||
|
.article blockquote { border-left: 3px solid var(--accent); padding: 8px 16px; margin: 16px 0; color: var(--muted); background: rgba(59,130,246,0.06); border-radius: 4px; }
|
||||||
|
|
||||||
|
/* Comparison table */
|
||||||
|
.compare-table-wrap { width: 100%; overflow-x: auto; margin: 24px 0; -webkit-overflow-scrolling: touch; }
|
||||||
|
.compare-table { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 640px; }
|
||||||
|
.compare-table th, .compare-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||||
|
.compare-table th { color: var(--text); font-weight: 600; background: var(--card); }
|
||||||
|
.compare-table td:first-child { color: var(--muted); }
|
||||||
|
.compare-table .yes { color: #22c55e; font-weight: 600; }
|
||||||
|
.compare-table .no { color: #ef4444; }
|
||||||
|
.compare-table .partial { color: #f59e0b; }
|
||||||
|
.compare-table tbody tr:hover { background: rgba(59,130,246,0.04); }
|
||||||
|
|
||||||
|
/* CTA */
|
||||||
|
.cta { text-align: center; padding: 60px 24px; background: linear-gradient(135deg, rgba(59,130,246,0.1), rgba(139,92,246,0.1)); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); margin: 48px 0; border-radius: 12px; }
|
||||||
|
.cta h2 { font-size: 28px; margin: 0 0 12px; }
|
||||||
|
.cta p { color: var(--muted); margin-bottom: 20px; font-size: 17px; }
|
||||||
|
|
||||||
|
/* Related links / internal linking block */
|
||||||
|
.related { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin: 32px 0; }
|
||||||
|
.related h2 { margin: 0 0 12px; font-size: 20px; }
|
||||||
|
.related ul { margin: 0; list-style: none; }
|
||||||
|
.related li { margin: 8px 0; padding-left: 0; }
|
||||||
|
.related li::before { content: '> '; color: var(--accent); font-weight: 700; }
|
||||||
|
|
||||||
|
/* Footer (matches landing.html) */
|
||||||
|
footer { max-width: 1200px; margin: 0 auto; padding: 40px 24px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; border-top: 1px solid var(--border); }
|
||||||
|
footer .links a { color: var(--dim); margin-left: 16px; font-size: 13px; }
|
||||||
|
footer .links a:hover { color: var(--text); text-decoration: none; }
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-links a:not(.btn) { display: none; }
|
||||||
|
.nav-inner { padding: 12px 14px; gap: 8px; }
|
||||||
|
.nav-links .btn { padding: 8px 12px; font-size: 13px; margin-left: 8px; flex-shrink: 0; min-height: 0; }
|
||||||
|
.btn { min-height: 44px; }
|
||||||
|
.article { padding: 100px 16px 40px; }
|
||||||
|
.cta { padding: 40px 16px; }
|
||||||
|
footer { flex-direction: column; text-align: center; }
|
||||||
|
footer .links a { margin: 4px 8px; }
|
||||||
|
.compare-table { font-size: 12px; }
|
||||||
|
.compare-table th, .compare-table td { padding: 8px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.nav-logo-text { display: none; }
|
||||||
|
}
|
||||||
157
frontend/guides/digital-signage-android-tv.html
Normal file
157
frontend/guides/digital-signage-android-tv.html
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Free Digital Signage for Android TV & Fire TV (2026) | ScreenTinker</title>
|
||||||
|
<meta name="description" content="Turn any Android TV box or Amazon Fire Stick into a digital signage display with ScreenTinker. Free APK, kiosk mode, remote control. Step-by-step guide for 2026.">
|
||||||
|
<meta name="keywords" content="digital signage android tv, fire tv signage, android tv kiosk, fire stick digital signage, free android tv signage, open source android tv signage">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="https://screentinker.com/guides/digital-signage-android-tv.html">
|
||||||
|
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:url" content="https://screentinker.com/guides/digital-signage-android-tv.html">
|
||||||
|
<meta property="og:title" content="Free Digital Signage for Android TV & Fire TV (2026)">
|
||||||
|
<meta property="og:description" content="Turn any Android TV or Fire Stick into a digital signage player. Free APK, kiosk mode, remote control.">
|
||||||
|
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
<meta property="og:site_name" content="ScreenTinker">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Free Digital Signage for Android TV & Fire TV (2026)">
|
||||||
|
<meta name="twitter:description" content="Turn any Android TV or Fire Stick into a digital signage player. Free APK, kiosk mode, remote control.">
|
||||||
|
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#111827">
|
||||||
|
<link rel="icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/css/seo-page.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<div class="nav-logo">
|
||||||
|
<a href="/" style="display:flex;align-items:center;gap:10px">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
<span class="nav-logo-text">ScreenTinker</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/#features">Features</a>
|
||||||
|
<a href="/#pricing">Pricing</a>
|
||||||
|
<a href="/#compare">Compare</a>
|
||||||
|
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="article">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/#features">Guides</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>Android TV & Fire TV Signage</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Free Digital Signage for Android TV and Fire TV (2026)</h1>
|
||||||
|
<p class="lead">Turn any Android TV box, Apolosign player, or Amazon Fire Stick into a fully managed digital signage display using the free ScreenTinker APK.</p>
|
||||||
|
|
||||||
|
<h2>What works</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Android TV</strong> (Sony, Hisense, Philips, Onn, NVIDIA Shield, generic Android TV boxes)</li>
|
||||||
|
<li><strong>Amazon Fire TV / Fire Stick</strong> (4K, 4K Max, Cube)</li>
|
||||||
|
<li><strong>Apolosign signage players</strong> (Android-based commercial signage hardware)</li>
|
||||||
|
<li><strong>Tablets running Android 8+</strong> mounted as in-store displays</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step 1: Get the APK</h2>
|
||||||
|
<p>Download the ScreenTinker APK from <a href="/download/apk">screentinker.com/download/apk</a>. The latest signed release is hosted directly so you do not need a Play Store or App Store account.</p>
|
||||||
|
|
||||||
|
<h2>Step 2: Sideload onto the device</h2>
|
||||||
|
<h3>On Android TV</h3>
|
||||||
|
<p>The easiest path is to install the <strong>Downloader</strong> app from the Google Play Store on the TV, then enter the URL <code>https://screentinker.com/download/apk</code>. Downloader fetches the APK and walks you through installing it. You will be prompted once to "allow installs from this source" - say yes.</p>
|
||||||
|
|
||||||
|
<h3>On Fire TV / Fire Stick</h3>
|
||||||
|
<p>Install <strong>Downloader</strong> from the Amazon App Store. In Settings > My Fire TV > Developer Options, enable <strong>Apps from Unknown Sources</strong>. Open Downloader, enter <code>https://screentinker.com/download/apk</code>, and install.</p>
|
||||||
|
|
||||||
|
<h3>On Apolosign / commercial Android signage hardware</h3>
|
||||||
|
<p>These devices typically expose a system file manager. Plug in a USB drive containing the APK, open the file manager, and tap the APK to install. Some Apolosign units allow direct URL install via the built-in browser.</p>
|
||||||
|
|
||||||
|
<h2>Step 3: Pair the device</h2>
|
||||||
|
<p>Launch the ScreenTinker app. The first time it runs, you will be asked to grant a few permissions:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Display over other apps</strong> - so the player can stay fullscreen</li>
|
||||||
|
<li><strong>Storage</strong> - for the local content cache</li>
|
||||||
|
<li><strong>Accessibility service</strong> (optional) - enables remote touch and key injection from the dashboard</li>
|
||||||
|
</ul>
|
||||||
|
<p>The app will then show a 6-digit pairing code. Sign in to your <a href="/app#/login">ScreenTinker dashboard</a>, click <strong>+ Add Display</strong>, and enter the code.</p>
|
||||||
|
|
||||||
|
<h2>Step 4: Push content</h2>
|
||||||
|
<p>Same as any other ScreenTinker display:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Upload media in the <strong>Content Library</strong></li>
|
||||||
|
<li>Build a <strong>Playlist</strong></li>
|
||||||
|
<li>Publish the playlist and assign it to your device</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Kiosk mode tips</h2>
|
||||||
|
<p>For unattended displays you generally want the device to boot straight into the player with no way for someone to back out:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Set ScreenTinker as the launcher.</strong> The APK declares <code>HOME</code> intent support, so on most Android TVs you can pick it as the default launcher in Settings > Apps.</li>
|
||||||
|
<li><strong>Disable updates and notifications</strong> on the device to prevent unwanted popups.</li>
|
||||||
|
<li><strong>Enable auto-power-on</strong> in TV settings if you want the display to come back after a power blip without manual intervention.</li>
|
||||||
|
<li><strong>For Fire Stick,</strong> use the Wolf Launcher or similar to replace the Amazon home screen.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Hardware recommendations</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Lowest cost:</strong> Amazon Fire TV Stick 4K. ~$50 and works fine for image and 1080p video playlists.</li>
|
||||||
|
<li><strong>Best value:</strong> Onn 4K Streaming Box. ~$30 at Walmart and runs Android TV stock.</li>
|
||||||
|
<li><strong>Commercial:</strong> Apolosign players. Ship with built-in mount, real-time clock, and HDMI-CEC for power management. Recommended for production deployments.</li>
|
||||||
|
<li><strong>Highest performance:</strong> NVIDIA Shield TV. Overkill for signage but bulletproof.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="related">
|
||||||
|
<h2>Related guides</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/guides/raspberry-pi-digital-signage.html">How to set up digital signage on a Raspberry Pi</a></li>
|
||||||
|
<li><a href="/guides/self-hosted-digital-signage.html">Self-hosted digital signage: complete guide</a></li>
|
||||||
|
<li><a href="/compare/yodeck-alternative.html">Compare: ScreenTinker vs Yodeck</a></li>
|
||||||
|
<li><a href="/compare/screencloud-alternative.html">Compare: ScreenTinker vs ScreenCloud</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta">
|
||||||
|
<h2>Ready to deploy?</h2>
|
||||||
|
<p>Free plan supports 1 device. Pro trial unlocks 15 devices for 14 days, no credit card.</p>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free</a>
|
||||||
|
<a href="/download/apk" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">Download APK</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div style="color:var(--dim);font-size:13px">© 2026 ScreenTinker. All rights reserved.</div>
|
||||||
|
<div class="links">
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
|
||||||
|
<a href="/legal/terms.html">Terms</a>
|
||||||
|
<a href="/legal/privacy.html">Privacy</a>
|
||||||
|
<a href="/legal/third-party.html">Licenses</a>
|
||||||
|
<a href="/app#/login">Sign In</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "Guides", "item": "https://screentinker.com/#features" },
|
||||||
|
{ "@type": "ListItem", "position": 3, "name": "Android TV and Fire TV Signage", "item": "https://screentinker.com/guides/digital-signage-android-tv.html" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
167
frontend/guides/raspberry-pi-digital-signage.html
Normal file
167
frontend/guides/raspberry-pi-digital-signage.html
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>How to Set Up Digital Signage on Raspberry Pi (2026) | ScreenTinker</title>
|
||||||
|
<meta name="description" content="Step-by-step guide to building a Raspberry Pi digital signage player with ScreenTinker. Covers hardware, Pi OS setup, the install script, pairing, and pushing content. Free and open source.">
|
||||||
|
<meta name="keywords" content="raspberry pi digital signage, digital signage raspberry pi, pi signage, raspberry pi tv display, free pi signage software, open source pi signage">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="https://screentinker.com/guides/raspberry-pi-digital-signage.html">
|
||||||
|
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:url" content="https://screentinker.com/guides/raspberry-pi-digital-signage.html">
|
||||||
|
<meta property="og:title" content="How to Set Up Digital Signage on Raspberry Pi (2026)">
|
||||||
|
<meta property="og:description" content="Step-by-step Pi signage guide. Hardware, OS, install script, pairing, content. Free and open source.">
|
||||||
|
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
<meta property="og:site_name" content="ScreenTinker">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="How to Set Up Digital Signage on Raspberry Pi (2026)">
|
||||||
|
<meta name="twitter:description" content="Step-by-step Pi signage guide. Hardware, OS, install script, pairing, content. Free and open source.">
|
||||||
|
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#111827">
|
||||||
|
<link rel="icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/css/seo-page.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<div class="nav-logo">
|
||||||
|
<a href="/" style="display:flex;align-items:center;gap:10px">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
<span class="nav-logo-text">ScreenTinker</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/#features">Features</a>
|
||||||
|
<a href="/#pricing">Pricing</a>
|
||||||
|
<a href="/#compare">Compare</a>
|
||||||
|
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="article">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/#features">Guides</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>Raspberry Pi Digital Signage</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>How to Set Up Digital Signage on a Raspberry Pi (2026)</h1>
|
||||||
|
<p class="lead">A step-by-step guide to turning a Raspberry Pi into a free, open-source digital signage player using ScreenTinker. Works on Pi 3, Pi 4, and Pi 5.</p>
|
||||||
|
|
||||||
|
<h2>What you will need</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Raspberry Pi 3, Pi 4, or Pi 5.</strong> Pi 4 (4GB+) is the sweet spot. Pi 3 works for static images and 1080p video. Pi 5 is overkill but futureproof.</li>
|
||||||
|
<li><strong>microSD card,</strong> 16 GB or larger. Class 10 or A1/A2 rated.</li>
|
||||||
|
<li><strong>Power supply</strong> appropriate for your model (Pi 4 uses USB-C 15W; Pi 5 uses USB-C 27W).</li>
|
||||||
|
<li><strong>HDMI cable</strong> to your TV or monitor (micro-HDMI on Pi 4/5).</li>
|
||||||
|
<li><strong>Network connection</strong> - Ethernet preferred for reliability, Wi-Fi works fine.</li>
|
||||||
|
<li><strong>A ScreenTinker account.</strong> <a href="/app#/login">Sign up free</a> if you do not have one.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Step 1: Install Raspberry Pi OS</h2>
|
||||||
|
<p>Use <a href="https://www.raspberrypi.com/software/" target="_blank" rel="noopener">Raspberry Pi Imager</a> to flash <strong>Raspberry Pi OS (64-bit)</strong> to your microSD card. Choose the standard Desktop edition (not Lite - we need a desktop environment for the browser).</p>
|
||||||
|
<p>In the Imager's advanced options (gear icon), pre-set:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Hostname (e.g. <code>signage-lobby</code>)</li>
|
||||||
|
<li>Username and password</li>
|
||||||
|
<li>Wi-Fi credentials (if not using Ethernet)</li>
|
||||||
|
<li>Enable SSH (optional but useful for remote management)</li>
|
||||||
|
</ul>
|
||||||
|
<p>Insert the SD card, plug in the Pi, and let it boot through first-time setup.</p>
|
||||||
|
|
||||||
|
<h2>Step 2: Run the ScreenTinker installer</h2>
|
||||||
|
<p>Open a terminal on the Pi and run:</p>
|
||||||
|
<pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | bash</code></pre>
|
||||||
|
<p>The script will:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Install Chromium (the kiosk browser used as the player)</li>
|
||||||
|
<li>Set up an autostart entry so the player launches in fullscreen on boot</li>
|
||||||
|
<li>Disable screen blanking and the screensaver</li>
|
||||||
|
<li>Configure HDMI to keep the display awake</li>
|
||||||
|
<li>Reboot the Pi when finished</li>
|
||||||
|
</ul>
|
||||||
|
<p>On reboot the Pi will launch directly into the ScreenTinker player and show a 6-digit pairing code.</p>
|
||||||
|
|
||||||
|
<h2>Step 3: Pair the Pi to your dashboard</h2>
|
||||||
|
<p>Sign in to <a href="/app#/login">your ScreenTinker dashboard</a> and click <strong>+ Add Display</strong>. Enter the 6-digit code shown on the Pi and give the display a name (e.g. "Lobby TV"). The Pi will switch from the pairing screen to "Waiting for content".</p>
|
||||||
|
|
||||||
|
<h2>Step 4: Push content</h2>
|
||||||
|
<p>From the dashboard:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Open <strong>Content Library</strong> and upload an image, video, or paste a remote URL.</li>
|
||||||
|
<li>Open <strong>Playlists</strong>, create a playlist, and add items.</li>
|
||||||
|
<li>Publish the playlist.</li>
|
||||||
|
<li>From the device's detail page, assign the playlist.</li>
|
||||||
|
</ol>
|
||||||
|
<p>The Pi picks up the new playlist within a few seconds and starts playing.</p>
|
||||||
|
|
||||||
|
<h2>Performance tips</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Use H.264 video.</strong> Pi GPUs accelerate H.264 in hardware. H.265/HEVC works on Pi 4/5 but uses more CPU.</li>
|
||||||
|
<li><strong>Match your resolution to the display.</strong> 1080p video on a 1080p screen avoids unnecessary scaling.</li>
|
||||||
|
<li><strong>Wired Ethernet is more reliable than Wi-Fi</strong> for video-heavy playlists. Wi-Fi is fine for image-heavy ones.</li>
|
||||||
|
<li><strong>For Pi 3,</strong> stick to images and short clips. Pi 3 can struggle with continuous 1080p video.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Troubleshooting</h2>
|
||||||
|
<h3>The Pi reboots into the desktop, not the player</h3>
|
||||||
|
<p>Check that the autostart file <code>~/.config/autostart/screentinker.desktop</code> exists. The installer creates this; if it's missing, re-run the installer.</p>
|
||||||
|
|
||||||
|
<h3>The screen goes dark after a few minutes</h3>
|
||||||
|
<p>The installer should disable screen blanking, but some monitors sleep based on their own timer. Disable sleep mode on the monitor itself, or use a dummy HDMI plug if the Pi negotiates a low-power mode.</p>
|
||||||
|
|
||||||
|
<h3>The Pi shows the pairing code but I can't see it on the dashboard</h3>
|
||||||
|
<p>The pairing code is shown on the Pi screen, not the dashboard. Sign in, click Add Display, and type the code from the Pi.</p>
|
||||||
|
|
||||||
|
<div class="related">
|
||||||
|
<h2>Related guides</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/guides/digital-signage-android-tv.html">Digital signage for Android TV and Fire TV</a></li>
|
||||||
|
<li><a href="/guides/self-hosted-digital-signage.html">Self-hosted digital signage: complete guide</a></li>
|
||||||
|
<li><a href="/compare/yodeck-alternative.html">Compare: ScreenTinker vs Yodeck</a></li>
|
||||||
|
<li><a href="/compare/screencloud-alternative.html">Compare: ScreenTinker vs ScreenCloud</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta">
|
||||||
|
<h2>Ready to set up your Pi?</h2>
|
||||||
|
<p>Start a free ScreenTinker account in under a minute.</p>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free</a>
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">View on GitHub</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div style="color:var(--dim);font-size:13px">© 2026 ScreenTinker. All rights reserved.</div>
|
||||||
|
<div class="links">
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
|
||||||
|
<a href="/legal/terms.html">Terms</a>
|
||||||
|
<a href="/legal/privacy.html">Privacy</a>
|
||||||
|
<a href="/legal/third-party.html">Licenses</a>
|
||||||
|
<a href="/app#/login">Sign In</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "Guides", "item": "https://screentinker.com/#features" },
|
||||||
|
{ "@type": "ListItem", "position": 3, "name": "Raspberry Pi Digital Signage", "item": "https://screentinker.com/guides/raspberry-pi-digital-signage.html" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
196
frontend/guides/self-hosted-digital-signage.html
Normal file
196
frontend/guides/self-hosted-digital-signage.html
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Self-Hosted Digital Signage Software - Complete Guide (2026) | ScreenTinker</title>
|
||||||
|
<meta name="description" content="Why and how to self-host your digital signage CMS. Data privacy, cost control, and no recurring fees. Complete deployment guide using ScreenTinker - open source, MIT licensed.">
|
||||||
|
<meta name="keywords" content="self hosted digital signage, on premise digital signage, self hosted signage cms, open source signage server, deploy signage on premise, private digital signage">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<link rel="canonical" href="https://screentinker.com/guides/self-hosted-digital-signage.html">
|
||||||
|
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:url" content="https://screentinker.com/guides/self-hosted-digital-signage.html">
|
||||||
|
<meta property="og:title" content="Self-Hosted Digital Signage Software - Complete Guide (2026)">
|
||||||
|
<meta property="og:description" content="Why and how to self-host your digital signage CMS. Data privacy, cost control, no recurring fees.">
|
||||||
|
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
<meta property="og:site_name" content="ScreenTinker">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Self-Hosted Digital Signage Software - Complete Guide (2026)">
|
||||||
|
<meta name="twitter:description" content="Why and how to self-host your digital signage CMS. Data privacy, cost control, no recurring fees.">
|
||||||
|
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#111827">
|
||||||
|
<link rel="icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/css/seo-page.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div class="nav-inner">
|
||||||
|
<div class="nav-logo">
|
||||||
|
<a href="/" style="display:flex;align-items:center;gap:10px">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
<span class="nav-logo-text">ScreenTinker</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/#features">Features</a>
|
||||||
|
<a href="/#pricing">Pricing</a>
|
||||||
|
<a href="/#compare">Compare</a>
|
||||||
|
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="article">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/#features">Guides</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>Self-Hosted Digital Signage</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Self-Hosted Digital Signage Software: Complete Guide (2026)</h1>
|
||||||
|
<p class="lead">Why you might want to self-host your digital signage CMS, what you need to do it well, and how to deploy ScreenTinker on your own server.</p>
|
||||||
|
|
||||||
|
<h2>Why self-host digital signage?</h2>
|
||||||
|
<p>Most digital signage products are cloud-only. That works for many businesses, but there are real reasons to keep the server in-house:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Data sovereignty.</strong> Healthcare, finance, government, and education often cannot put internal information into a third-party cloud. Self-hosting keeps content, schedules, and access logs on your network.</li>
|
||||||
|
<li><strong>Cost control.</strong> Per-screen monthly fees stack up fast. Self-hosting trades that for a fixed server cost - typically $5 to $50 per month for a small VPS that can run hundreds of screens.</li>
|
||||||
|
<li><strong>Network isolation.</strong> Some deployments live on private LANs with no internet access at all. Self-hosting is the only way to manage signage in those environments.</li>
|
||||||
|
<li><strong>No vendor lock-in.</strong> If the cloud vendor disappears, raises prices 3x, or pivots away from your use case, your deployment goes with them. Self-hosters control their own roadmap.</li>
|
||||||
|
<li><strong>Customization.</strong> Open source self-hosted means you can fork the code, add a custom widget, or wire it into your existing systems.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>What you need</h2>
|
||||||
|
<h3>Hardware / VPS</h3>
|
||||||
|
<p>A modest Linux server is enough for most deployments:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Up to 25 displays:</strong> 1 vCPU, 1 GB RAM, 20 GB disk. ~$5/month on Hetzner, DigitalOcean, or Vultr.</li>
|
||||||
|
<li><strong>25-100 displays:</strong> 2 vCPU, 2 GB RAM, 40 GB disk. ~$12-20/month.</li>
|
||||||
|
<li><strong>100+ displays:</strong> 4+ vCPU, 4+ GB RAM, faster disk. Plan for content storage at ~50-200 MB per screen depending on media volume.</li>
|
||||||
|
</ul>
|
||||||
|
<p>An on-prem VM works just as well as a cloud VPS - in fact, on-prem is often the whole point.</p>
|
||||||
|
|
||||||
|
<h3>Software prerequisites</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Ubuntu 22.04 or 24.04 LTS (Debian 12 also works)</li>
|
||||||
|
<li>Node.js 18 or newer</li>
|
||||||
|
<li>A domain name pointed at your server (or just an internal hostname / IP for LAN deployments)</li>
|
||||||
|
<li>SSL certificate (Let's Encrypt is free; or self-signed for LAN)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Deploying ScreenTinker</h2>
|
||||||
|
<p>Detailed setup is in the <a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub README</a>. Quick version:</p>
|
||||||
|
<pre><code>git clone https://github.com/screentinker/screentinker.git
|
||||||
|
cd screentinker/server
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env with your domain, JWT_SECRET, and SELF_HOSTED=true
|
||||||
|
node server.js</code></pre>
|
||||||
|
<p>Set <code>SELF_HOSTED=true</code> in the env. This unlocks the enterprise plan for your account, disables subscription expiry checks, and skips Stripe entirely. It is meant for the operator-controlled deployment case.</p>
|
||||||
|
|
||||||
|
<h2>Reverse proxy and TLS</h2>
|
||||||
|
<p>ScreenTinker listens on HTTP/HTTPS directly, but in production you typically front it with nginx or Caddy for TLS termination, gzip, and rate limiting. A minimal Caddyfile:</p>
|
||||||
|
<pre><code>signage.example.com {
|
||||||
|
reverse_proxy localhost:3001
|
||||||
|
}</code></pre>
|
||||||
|
<p>Caddy handles Let's Encrypt automatically. nginx works too if your team prefers it.</p>
|
||||||
|
|
||||||
|
<h2>Running as a service</h2>
|
||||||
|
<p>Use systemd to keep the process alive across reboots. A unit file at <code>/etc/systemd/system/screentinker.service</code>:</p>
|
||||||
|
<pre><code>[Unit]
|
||||||
|
Description=ScreenTinker Digital Signage Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=/opt/screentinker/server
|
||||||
|
ExecStart=/usr/bin/node server.js
|
||||||
|
EnvironmentFile=/opt/screentinker/.env
|
||||||
|
Restart=always
|
||||||
|
User=screentinker
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target</code></pre>
|
||||||
|
<p>Enable with <code>systemctl enable --now screentinker</code>.</p>
|
||||||
|
|
||||||
|
<h2>Backups</h2>
|
||||||
|
<p>The state lives in two places:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>server/db/remote_display.db</code> - SQLite database of users, devices, playlists, schedules</li>
|
||||||
|
<li><code>server/uploads/</code> - uploaded media (images, videos, thumbnails)</li>
|
||||||
|
</ul>
|
||||||
|
<p>A nightly tarball of those two paths gives you a full restore point. Pair with offsite sync (rclone, restic) for disaster recovery.</p>
|
||||||
|
|
||||||
|
<h2>Self-hosted vs cloud-hosted comparison</h2>
|
||||||
|
<div class="compare-table-wrap">
|
||||||
|
<table class="compare-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Concern</th><th>ScreenTinker self-hosted</th><th>Cloud-only signage products</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Data location</td><td>Your server</td><td>Vendor's cloud</td></tr>
|
||||||
|
<tr><td>Recurring per-screen cost</td><td>None</td><td>$5-15/screen/month</td></tr>
|
||||||
|
<tr><td>Server cost</td><td>$5-50/month flat</td><td>None (included)</td></tr>
|
||||||
|
<tr><td>Internet required for management</td><td>No (LAN works)</td><td>Yes</td></tr>
|
||||||
|
<tr><td>Source code access</td><td>Yes (MIT)</td><td>Closed</td></tr>
|
||||||
|
<tr><td>Air-gapped deployment</td><td>Possible</td><td>Not possible</td></tr>
|
||||||
|
<tr><td>Vendor lock-in risk</td><td>None (you own it)</td><td>High</td></tr>
|
||||||
|
<tr><td>Update / patch responsibility</td><td>Yours</td><td>Vendor</td></tr>
|
||||||
|
<tr><td>Initial setup time</td><td>~1 hour</td><td>~5 minutes</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>When the cloud is the right answer</h2>
|
||||||
|
<p>Self-hosting is not free of cost - it requires someone who can run a Linux server, monitor it, and apply security updates. If your screen count is small (under ~10) and you do not have IT capacity, the managed cloud version is probably the right choice. ScreenTinker's hosted plans start at $39/mo for 5 devices.</p>
|
||||||
|
|
||||||
|
<div class="related">
|
||||||
|
<h2>Related guides</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/guides/raspberry-pi-digital-signage.html">How to set up digital signage on a Raspberry Pi</a></li>
|
||||||
|
<li><a href="/guides/digital-signage-android-tv.html">Free digital signage for Android TV and Fire TV</a></li>
|
||||||
|
<li><a href="/compare/yodeck-alternative.html">Compare: ScreenTinker vs Yodeck</a></li>
|
||||||
|
<li><a href="/compare/screencloud-alternative.html">Compare: ScreenTinker vs ScreenCloud</a></li>
|
||||||
|
<li><a href="/compare/optisigns-alternative.html">Compare: ScreenTinker vs OptiSigns</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta">
|
||||||
|
<h2>Try the cloud version first</h2>
|
||||||
|
<p>Use the hosted version to get familiar, then deploy on your own server when you are ready.</p>
|
||||||
|
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free</a>
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">View on GitHub</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div style="color:var(--dim);font-size:13px">© 2026 ScreenTinker. All rights reserved.</div>
|
||||||
|
<div class="links">
|
||||||
|
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
|
||||||
|
<a href="/legal/terms.html">Terms</a>
|
||||||
|
<a href="/legal/privacy.html">Privacy</a>
|
||||||
|
<a href="/legal/third-party.html">Licenses</a>
|
||||||
|
<a href="/app#/login">Sign In</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "Guides", "item": "https://screentinker.com/#features" },
|
||||||
|
{ "@type": "ListItem", "position": 3, "name": "Self-Hosted Digital Signage", "item": "https://screentinker.com/guides/self-hosted-digital-signage.html" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -17,11 +17,11 @@
|
||||||
<!-- OAuth providers loaded on-demand by login.js when needed -->
|
<!-- OAuth providers loaded on-demand by login.js when needed -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<button class="mobile-menu-btn" id="mobileMenuBtn" onclick="document.querySelector('.sidebar').classList.toggle('open');document.getElementById('sidebarBackdrop').classList.toggle('open')">
|
<button class="mobile-menu-btn" id="mobileMenuBtn" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="sidebar">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="sidebar-backdrop" id="sidebarBackdrop" onclick="document.querySelector('.sidebar').classList.remove('open');this.classList.remove('open')"></div>
|
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
|
||||||
<nav class="sidebar">
|
<nav class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
|
@ -29,8 +29,11 @@
|
||||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>ScreenTinker</span>
|
<span id="brandName">ScreenTinker</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- #38: apply cached white-label before first paint (no ScreenTinker flash) -->
|
||||||
|
<script src="/js/brand-prime.js"></script>
|
||||||
|
<div class="workspace-switcher" id="workspaceSwitcher"></div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="#/" class="nav-link active" data-view="dashboard">
|
<li><a href="#/" class="nav-link active" data-view="dashboard">
|
||||||
|
|
@ -106,7 +109,10 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span>Activity</span>
|
<span>Activity</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="#/teams" class="nav-link" data-view="teams">
|
<!-- Teams nav hidden while the feature is being redesigned as a user-grouping
|
||||||
|
primitive within Workspaces. Route + view kept in place so any existing
|
||||||
|
bookmark still loads (and shows the 503 from the API). -->
|
||||||
|
<li style="display:none"><a href="#/teams" class="nav-link" data-view="teams">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
|
@ -156,45 +162,45 @@
|
||||||
<div class="modal-overlay" id="addDeviceModal" style="display:none">
|
<div class="modal-overlay" id="addDeviceModal" style="display:none">
|
||||||
<div class="modal" style="max-width:560px">
|
<div class="modal" style="max-width:560px">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Add Display</h3>
|
<h3 data-i18n="add_display.title">Add Display</h3>
|
||||||
<button class="btn-icon" onclick="document.getElementById('addDeviceModal').style.display='none'">
|
<button class="btn-icon" data-close-modal="addDeviceModal">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="modal-description" style="margin-bottom:16px">Enter the 6-digit pairing code shown on the display.</p>
|
<p class="modal-description" style="margin-bottom:16px" data-i18n="add_display.intro">Enter the 6-digit pairing code shown on the display.</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Pairing Code</label>
|
<label data-i18n="add_display.pairing_code">Pairing Code</label>
|
||||||
<input type="text" id="pairingCodeInput" maxlength="6" pattern="[0-9]{6}" placeholder="000000" class="pairing-input">
|
<input type="text" id="pairingCodeInput" maxlength="6" pattern="[0-9]{6}" placeholder="000000" class="pairing-input">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Display Name (optional)</label>
|
<label data-i18n="add_display.display_name">Display Name (optional)</label>
|
||||||
<input type="text" id="deviceNameInput" placeholder="e.g., Lobby TV" class="input">
|
<input type="text" id="deviceNameInput" data-i18n-placeholder="add_display.name_placeholder" placeholder="e.g., Lobby TV" class="input">
|
||||||
</div>
|
</div>
|
||||||
<div style="border-top:1px solid var(--border,#1e293b);margin-top:20px;padding-top:16px">
|
<div style="border-top:1px solid var(--border,#1e293b);margin-top:20px;padding-top:16px">
|
||||||
<p style="font-size:12px;color:var(--text-muted,#64748b);margin-bottom:10px;font-weight:500">Need a player app? Install one to get a pairing code:</p>
|
<p style="font-size:12px;color:var(--text-muted,#64748b);margin-bottom:10px;font-weight:500" data-i18n="add_display.need_player">Need a player app? Install one to get a pairing code:</p>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||||
<a href="/download/apk" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
<a href="/download/apk" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
||||||
🤖 Android APK
|
🤖 <span data-i18n="add_display.android_apk">Android APK</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/player" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
<a href="/player" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
||||||
🌐 Web Player
|
🌐 <span data-i18n="add_display.web_player">Web Player</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/scripts/raspberry-pi-setup.sh" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
<a href="/scripts/raspberry-pi-setup.sh" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
||||||
🥏 Raspberry Pi
|
🥏 <span data-i18n="add_display.raspberry_pi">Raspberry Pi</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/scripts/windows-setup.bat" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
<a href="/scripts/windows-setup.bat" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
||||||
💻 Windows
|
💻 <span data-i18n="add_display.windows">Windows</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:11px;color:var(--text-muted,#64748b);margin-top:8px">Smart TVs (LG/Samsung): open the built-in browser and navigate to <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code></p>
|
<p style="font-size:11px;color:var(--text-muted,#64748b);margin-top:8px" data-i18n-html="add_display.smart_tv_note">Smart TVs (LG/Samsung): open the built-in browser and navigate to <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" onclick="document.getElementById('addDeviceModal').style.display='none'">Cancel</button>
|
<button class="btn btn-secondary" data-close-modal="addDeviceModal" data-i18n="common.cancel">Cancel</button>
|
||||||
<button class="btn btn-primary" id="pairDeviceBtn">Pair Display</button>
|
<button class="btn btn-primary" id="pairDeviceBtn" data-i18n="add_display.pair_btn">Pair Display</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,34 @@ export const api = {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
getContent: () => request('/content'),
|
getContent: (folderId) => {
|
||||||
|
if (folderId === undefined) return request('/content');
|
||||||
|
const q = folderId === null ? 'root' : encodeURIComponent(folderId);
|
||||||
|
return request(`/content?folder_id=${q}`);
|
||||||
|
},
|
||||||
getContentItem: (id) => request(`/content/${id}`),
|
getContentItem: (id) => request(`/content/${id}`),
|
||||||
deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }),
|
deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }),
|
||||||
|
updateContent: (id, data) => request(`/content/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
moveContent: (id, folderId) => request(`/content/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ folder_id: folderId })
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Folders
|
||||||
|
getFolders: () => request('/folders'),
|
||||||
|
createFolder: (name, parentId) => request('/folders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, parent_id: parentId || null })
|
||||||
|
}),
|
||||||
|
renameFolder: (id, name) => request(`/folders/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
}),
|
||||||
|
moveFolder: (id, parentId) => request(`/folders/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ parent_id: parentId || null })
|
||||||
|
}),
|
||||||
|
deleteFolder: (id) => request(`/folders/${id}`, { method: 'DELETE' }),
|
||||||
uploadContent: async (file, onProgress) => {
|
uploadContent: async (file, onProgress) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
@ -103,6 +128,13 @@ export const api = {
|
||||||
removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }),
|
removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }),
|
||||||
sendGroupCommand: (groupId, type, payload) => request(`/groups/${groupId}/command`, { method: 'POST', body: JSON.stringify({ type, payload }) }),
|
sendGroupCommand: (groupId, type, payload) => request(`/groups/${groupId}/command`, { method: 'POST', body: JSON.stringify({ type, payload }) }),
|
||||||
|
|
||||||
|
// Video walls
|
||||||
|
getWalls: () => request('/walls'),
|
||||||
|
createWall: (data) => request('/walls', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
setWallDevices: (id, devices) => request(`/walls/${id}/devices`, { method: 'PUT', body: JSON.stringify({ devices }) }),
|
||||||
|
updateWall: (id, data) => request(`/walls/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteWall: (id) => request(`/walls/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// Playlists
|
// Playlists
|
||||||
getPlaylists: () => request('/playlists'),
|
getPlaylists: () => request('/playlists'),
|
||||||
createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }),
|
createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }),
|
||||||
|
|
@ -114,10 +146,71 @@ export const api = {
|
||||||
updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }),
|
deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }),
|
||||||
reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }),
|
reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }),
|
||||||
|
// #74/#75 per-item schedule blocks
|
||||||
|
getItemSchedules: (id, itemId) => request(`/playlists/${id}/items/${itemId}/schedules`),
|
||||||
|
setItemSchedules: (id, itemId, blocks) => request(`/playlists/${id}/items/${itemId}/schedules`, { method: 'PUT', body: JSON.stringify({ blocks }) }),
|
||||||
|
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }),
|
||||||
|
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
|
||||||
|
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// Device Groups - Playlist
|
||||||
|
groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }),
|
||||||
|
|
||||||
|
// API Tokens (personal access tokens, workspace-scoped)
|
||||||
|
getTokens: () => request('/tokens'),
|
||||||
|
createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
revokeToken: (id) => request('/tokens/' + id, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Current user
|
||||||
|
getMe: () => request('/auth/me'),
|
||||||
|
updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }),
|
||||||
|
renameWorkspace: (id, data) => request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
// Workspace members + invites (slice 2A read-only)
|
||||||
|
getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`),
|
||||||
|
getWorkspaceInvites: (id) => request(`/workspaces/${id}/invites`),
|
||||||
|
|
||||||
|
// Workspace member/invite mutations (slice 2B). All admin-only server-side
|
||||||
|
// (canAdminWorkspace gate). Server returns translated English error messages
|
||||||
|
// mapped to i18n keys via mapMutationError() in workspace-members.js.
|
||||||
|
inviteWorkspaceMember: (workspaceId, data) => request(`/workspaces/${workspaceId}/invites`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
cancelWorkspaceInvite: (workspaceId, inviteId) => request(`/workspaces/${workspaceId}/invites/${inviteId}`, { method: 'DELETE' }),
|
||||||
|
updateWorkspaceMemberRole: (workspaceId, userId, role) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||||
|
removeWorkspaceMember: (workspaceId, userId) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Slice 2C - accept a workspace invite by id (post-auth flow)
|
||||||
|
acceptInvite: (inviteId) => request(`/auth/accept-invite/${inviteId}`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// Admin-provisioned user creation (#10). data: { email, name, password,
|
||||||
|
// workspaceId, role, mustChangePassword }
|
||||||
|
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
adminCreateOrg: (name) => request('/admin/orgs', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
adminListOrgs: () => request('/admin/orgs'),
|
||||||
|
adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }),
|
||||||
|
adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }),
|
||||||
|
aiGetSettings: () => request('/ai/settings'),
|
||||||
|
aiSaveSettings: (data) => request('/ai/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
aiGenerateDesign: (prompt) => request('/ai/generate-design', { method: 'POST', body: JSON.stringify({ prompt }) }),
|
||||||
|
aiListModels: (base_url, api_key) => request('/ai/models', { method: 'POST', body: JSON.stringify({ base_url, api_key }) }),
|
||||||
|
|
||||||
|
// Instance-level default branding (#15, platform admin).
|
||||||
|
adminGetBranding: () => request('/admin/branding'),
|
||||||
|
adminSetBranding: (data) => request('/admin/branding', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
// Per-user workspace membership management (platform Users page modal).
|
||||||
|
adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`),
|
||||||
|
adminAddUserWorkspace: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces`, { method: 'POST', body: JSON.stringify({ workspaceId, role }) }),
|
||||||
|
adminSetUserWorkspaceRole: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||||
|
adminRemoveUserWorkspace: (id, workspaceId) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// Admin - Users
|
// Admin - Users
|
||||||
getUsers: () => request('/auth/users'),
|
getUsers: () => request('/auth/users'),
|
||||||
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
|
||||||
|
resetUserPassword: (id, password) => request(`/auth/users/${id}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
}),
|
||||||
assignPlan: (user_id, plan_id) => request('/subscription/assign', {
|
assignPlan: (user_id, plan_id) => request('/subscription/assign', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ user_id, plan_id })
|
body: JSON.stringify({ user_id, plan_id })
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,192 @@ import * as onboarding from './views/onboarding.js';
|
||||||
import * as help from './views/help.js';
|
import * as help from './views/help.js';
|
||||||
import * as teams from './views/teams.js';
|
import * as teams from './views/teams.js';
|
||||||
import * as admin from './views/admin.js';
|
import * as admin from './views/admin.js';
|
||||||
|
import * as adminPlayerDebug from './views/admin-player-debug.js';
|
||||||
import * as designer from './views/designer.js';
|
import * as designer from './views/designer.js';
|
||||||
import * as playlists from './views/playlists.js';
|
import * as playlists from './views/playlists.js';
|
||||||
|
import * as workspaceMembers from './views/workspace-members.js';
|
||||||
|
import * as forcePasswordChange from './views/force-password-change.js';
|
||||||
|
import * as noWorkspace from './views/no-workspace.js';
|
||||||
|
import { applyBranding } from './branding.js';
|
||||||
|
import { t } from './i18n.js';
|
||||||
|
import { isPlatformAdmin } from './utils.js';
|
||||||
|
import { renderWorkspaceSwitcher } from './components/workspace-switcher.js';
|
||||||
|
import { showToast } from './components/toast.js';
|
||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
const sidebar = document.querySelector('.sidebar');
|
const sidebar = document.querySelector('.sidebar');
|
||||||
let currentView = null;
|
let currentView = null;
|
||||||
|
|
||||||
|
// ==================== Slice 2C: accept-invite plumbing ====================
|
||||||
|
//
|
||||||
|
// Flow shape (covers all six auth entry points - login, register, support,
|
||||||
|
// Google, Microsoft, first-user-setup - because they all funnel through
|
||||||
|
// onAuthSuccess() in login.js which calls window.location.reload()):
|
||||||
|
//
|
||||||
|
// 1. Hash route #/accept-invite/{id}:
|
||||||
|
// - unauthed: stash inviteId in localStorage, redirect to login
|
||||||
|
// - authed: call consumeAcceptInvite() directly (no stash)
|
||||||
|
// 2. App boot (every route() call once auth checks pass): if a valid
|
||||||
|
// non-stale stash is present, fire consumeAcceptInvite. After login
|
||||||
|
// reload lands here and picks it up automatically.
|
||||||
|
// 3. consumeAcceptInvite on success: stash toast text, switch workspace,
|
||||||
|
// reload. Reload re-fires route() which picks up the toast stash and
|
||||||
|
// shows it on dashboard. Reload is needed for the new JWT/socket/
|
||||||
|
// sidebar /me to pick up the new workspace context.
|
||||||
|
// 4. consumeAcceptInvite on error: showToast directly + clear stash.
|
||||||
|
// No reload (no state change to propagate).
|
||||||
|
|
||||||
|
const PENDING_INVITE_KEY = 'pending_invite';
|
||||||
|
const PENDING_INVITE_TOAST_KEY = 'pending_invite_toast';
|
||||||
|
// Mirrors the backend INVITE_EXPIRY_DAYS default (7). If an operator changes
|
||||||
|
// the backend default, this should be updated to match - tracked in handoff.
|
||||||
|
const INVITE_EXPIRY_DAYS_FRONTEND = 7;
|
||||||
|
|
||||||
|
// Non-reentrant guard: route() can fire multiple times (hashchange events).
|
||||||
|
// Once consume is in flight, additional calls no-op until reload completes.
|
||||||
|
let _acceptInFlight = false;
|
||||||
|
|
||||||
|
function stashPendingInvite(inviteId) {
|
||||||
|
localStorage.setItem(PENDING_INVITE_KEY, JSON.stringify({
|
||||||
|
inviteId,
|
||||||
|
stashedAt: Math.floor(Date.now() / 1000),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPendingInvite() {
|
||||||
|
const raw = localStorage.getItem(PENDING_INVITE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(raw); }
|
||||||
|
catch { localStorage.removeItem(PENDING_INVITE_KEY); return null; }
|
||||||
|
if (!parsed?.inviteId || !parsed?.stashedAt) {
|
||||||
|
localStorage.removeItem(PENDING_INVITE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ageSecs = Math.floor(Date.now() / 1000) - parsed.stashedAt;
|
||||||
|
if (ageSecs > INVITE_EXPIRY_DAYS_FRONTEND * 86400) {
|
||||||
|
localStorage.removeItem(PENDING_INVITE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.inviteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingInvite() {
|
||||||
|
localStorage.removeItem(PENDING_INVITE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map backend error message text to a translated toast string. We match
|
||||||
|
// English text because api.js doesn't surface HTTP status codes today;
|
||||||
|
// refactor to err.status when that lands - tracked in handoff doc.
|
||||||
|
function mapAcceptError(err) {
|
||||||
|
const msg = err?.message || '';
|
||||||
|
if (/Invite not found/i.test(msg)) return t('accept.error.not_found');
|
||||||
|
if (/Invite has expired|Workspace no longer exists/i.test(msg)) return t('accept.error.expired');
|
||||||
|
if (/different email address/i.test(msg)) return t('accept.error.wrong_account');
|
||||||
|
return t('accept.error.generic');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeAcceptInvite(inviteId) {
|
||||||
|
if (_acceptInFlight) return;
|
||||||
|
_acceptInFlight = true;
|
||||||
|
try {
|
||||||
|
const result = await api.acceptInvite(inviteId);
|
||||||
|
|
||||||
|
// Switch to the joined workspace. New JWT carries the workspace context;
|
||||||
|
// reload picks it up for sidebar /me + socket rooms + data fetches. If
|
||||||
|
// the switch fails, log and reload anyway - the membership was created
|
||||||
|
// so the user can switch manually via the dropdown.
|
||||||
|
try {
|
||||||
|
const sw = await api.switchWorkspace(result.workspace_id);
|
||||||
|
if (sw?.token) localStorage.setItem('token', sw.token);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('switchWorkspace after accept failed (non-fatal):', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stash the toast text in a scoped key (not a generic pending-toast
|
||||||
|
// channel) so app boot below fires it after reload.
|
||||||
|
const toastKey = result.already_member ? 'accept.already_member' : 'accept.success';
|
||||||
|
localStorage.setItem(PENDING_INVITE_TOAST_KEY, JSON.stringify({
|
||||||
|
message: t(toastKey, { name: result.workspace_name }),
|
||||||
|
kind: 'success',
|
||||||
|
}));
|
||||||
|
|
||||||
|
clearPendingInvite();
|
||||||
|
// history.replaceState mutates the hash WITHOUT firing hashchange.
|
||||||
|
// Important: a plain `location.hash = '#/'` would fire hashchange
|
||||||
|
// synchronously, causing route() to fire a second time before the
|
||||||
|
// reload runs - that second route() call would consume the toast key
|
||||||
|
// and attach the toast to a DOM that's about to be destroyed by the
|
||||||
|
// reload. Using replaceState bypasses that race so the post-reload
|
||||||
|
// route() is the only one that picks up the toast.
|
||||||
|
history.replaceState(null, '', window.location.pathname + '#/');
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(mapAcceptError(err), 'error');
|
||||||
|
clearPendingInvite();
|
||||||
|
_acceptInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires once per page load (single-shot key in localStorage). If the
|
||||||
|
// previous routeApp cycle stashed a toast across reload, show it now.
|
||||||
|
function consumePendingInviteToast() {
|
||||||
|
const raw = localStorage.getItem(PENDING_INVITE_TOAST_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
localStorage.removeItem(PENDING_INVITE_TOAST_KEY);
|
||||||
|
try {
|
||||||
|
const { message, kind } = JSON.parse(raw);
|
||||||
|
if (message) showToast(message, kind || 'info');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map nav-link data-view to its translation key.
|
||||||
|
const NAV_LABEL_KEYS = {
|
||||||
|
dashboard: 'nav.displays',
|
||||||
|
content: 'nav.content',
|
||||||
|
playlists: 'nav.playlists',
|
||||||
|
layouts: 'nav.layouts',
|
||||||
|
widgets: 'nav.widgets',
|
||||||
|
schedule: 'nav.schedule',
|
||||||
|
walls: 'nav.walls',
|
||||||
|
reports: 'nav.reports',
|
||||||
|
kiosk: 'nav.kiosk',
|
||||||
|
designer: 'nav.designer',
|
||||||
|
activity: 'nav.activity',
|
||||||
|
teams: 'nav.teams',
|
||||||
|
help: 'nav.help',
|
||||||
|
settings: 'nav.settings',
|
||||||
|
billing: 'nav.subscription',
|
||||||
|
admin: 'nav.admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderNavLabels() {
|
||||||
|
document.querySelectorAll('.nav-link').forEach((link) => {
|
||||||
|
const key = NAV_LABEL_KEYS[link.dataset.view];
|
||||||
|
if (!key) return;
|
||||||
|
const span = link.querySelector('span');
|
||||||
|
if (span) span.textContent = t(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate any element marked with data-i18n / data-i18n-placeholder /
|
||||||
|
// data-i18n-html. Runs on init and on every language change. Used for static
|
||||||
|
// HTML in index.html (e.g. the Add-Display modal) where t() can't be inlined
|
||||||
|
// at template time.
|
||||||
|
function translateStaticDom(root = document) {
|
||||||
|
root.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||||
|
const key = el.getAttribute('data-i18n');
|
||||||
|
el.textContent = t(key);
|
||||||
|
});
|
||||||
|
root.querySelectorAll('[data-i18n-html]').forEach((el) => {
|
||||||
|
el.innerHTML = t(el.getAttribute('data-i18n-html'));
|
||||||
|
});
|
||||||
|
root.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
|
||||||
|
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function isAuthenticated() {
|
function isAuthenticated() {
|
||||||
return !!localStorage.getItem('token');
|
return !!localStorage.getItem('token');
|
||||||
}
|
}
|
||||||
|
|
@ -33,12 +212,69 @@ function getCurrentUser() {
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #12: true when a signed-in user provably has zero accessible workspaces and
|
||||||
|
// no platform-level reach. Requires accessible_workspaces to be present (only
|
||||||
|
// /me populates it) - undefined means "not loaded yet", so we DON'T trigger and
|
||||||
|
// fall through to the normal (workspace-empty-safe) views until /me resolves.
|
||||||
|
function hasNoAccessibleWorkspace(u) {
|
||||||
|
return !!u
|
||||||
|
&& Array.isArray(u.accessible_workspaces)
|
||||||
|
&& u.accessible_workspaces.length === 0
|
||||||
|
&& !u.current_workspace_id
|
||||||
|
&& !isPlatformAdmin(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the cached user from the server. The server reads plan_id fresh
|
||||||
|
// from the DB on every request, but the frontend only wrote `user` into
|
||||||
|
// localStorage at login — so plan/role changes made by an admin weren't
|
||||||
|
// visible until the user logged out and back in.
|
||||||
|
async function refreshCurrentUser() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const fresh = await res.json();
|
||||||
|
localStorage.setItem('user', JSON.stringify(fresh));
|
||||||
|
// Re-render the workspace switcher on every /me refresh - cheap, and keeps
|
||||||
|
// the dropdown in sync if a workspace was added/removed in another tab.
|
||||||
|
renderWorkspaceSwitcher(fresh);
|
||||||
|
window.dispatchEvent(new CustomEvent('user-refreshed', { detail: fresh }));
|
||||||
|
// #12: /me is the first place accessible_workspaces is known. If it resolves
|
||||||
|
// to zero (org-less user), send them to the empty state now - on a fresh
|
||||||
|
// load route() may have already rendered the dashboard before /me returned.
|
||||||
|
// Guard against the login / change-password / already-there screens to avoid
|
||||||
|
// a redirect loop.
|
||||||
|
const hash = window.location.hash || '#/';
|
||||||
|
if (hasNoAccessibleWorkspace(fresh)
|
||||||
|
&& hash !== '#/no-workspace' && hash !== '#/login' && hash !== '#/change-password') {
|
||||||
|
window.location.hash = '#/no-workspace';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function route() {
|
function route() {
|
||||||
// Cleanup previous view
|
// Cleanup previous view
|
||||||
if (currentView && currentView.cleanup) currentView.cleanup();
|
if (currentView && currentView.cleanup) currentView.cleanup();
|
||||||
|
|
||||||
const hash = window.location.hash || '#/';
|
const hash = window.location.hash || '#/';
|
||||||
|
|
||||||
|
// Slice 2C - direct hits on #/accept-invite/{id}. Handle BEFORE the
|
||||||
|
// auth-redirect-to-login because an unauthed visit needs to stash the
|
||||||
|
// inviteId so it survives the redirect.
|
||||||
|
if (hash.startsWith('#/accept-invite/')) {
|
||||||
|
const inviteId = hash.split('#/accept-invite/')[1].split('/')[0];
|
||||||
|
if (inviteId) {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
stashPendingInvite(inviteId);
|
||||||
|
window.location.hash = '#/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
consumeAcceptInvite(inviteId); // helper handles routing (reload to '#/')
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auth check - redirect to login if not authenticated
|
// Auth check - redirect to login if not authenticated
|
||||||
if (!isAuthenticated() && hash !== '#/login') {
|
if (!isAuthenticated() && hash !== '#/login') {
|
||||||
window.location.hash = '#/login';
|
window.location.hash = '#/login';
|
||||||
|
|
@ -51,6 +287,69 @@ function route() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slice 2C - past the auth gates. (a) Show any toast stashed across the
|
||||||
|
// accept-invite reload boundary. (b) If a stash exists (from an unauthed
|
||||||
|
// accept-invite visit + subsequent login/register), consume it now. The
|
||||||
|
// helper's in-flight guard prevents double-fire on subsequent hashchanges.
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
consumePendingInviteToast();
|
||||||
|
const stashedInviteId = readPendingInvite();
|
||||||
|
if (stashedInviteId) {
|
||||||
|
consumeAcceptInvite(stashedInviteId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #10: forced first-login password change. An admin-provisioned user carries
|
||||||
|
// must_change_password until they set their own password. Block every other
|
||||||
|
// authenticated view and force them to the change-password screen; the server
|
||||||
|
// clears the flag on a successful PUT /api/auth/me. The screen itself is the
|
||||||
|
// one exception (so they can actually change it).
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
const u = getCurrentUser();
|
||||||
|
if (u && u.must_change_password && hash !== '#/change-password') {
|
||||||
|
window.location.hash = '#/change-password';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hash === '#/change-password') {
|
||||||
|
if (!u || !u.must_change_password) {
|
||||||
|
// Not (or no longer) required - don't strand the user on a dead screen.
|
||||||
|
window.location.hash = '#/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sidebar.style.display = 'none';
|
||||||
|
app.style.marginLeft = '0';
|
||||||
|
const mb = document.getElementById('mobileMenuBtn');
|
||||||
|
if (mb) mb.style.display = 'none';
|
||||||
|
currentView = forcePasswordChange;
|
||||||
|
forcePasswordChange.render(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: a signed-in user with zero accessible workspaces (org-less self-signup
|
||||||
|
// on an AUTO_CREATE_ORG_ON_SIGNUP=false deployment) lands on a "no workspaces
|
||||||
|
// yet" empty state instead of being bounced into onboarding (whose pairing
|
||||||
|
// step needs a workspace). Only fires once /me has populated
|
||||||
|
// accessible_workspaces; until then the workspace-empty-safe dashboard shows.
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
const u = getCurrentUser();
|
||||||
|
if (hasNoAccessibleWorkspace(u) && hash !== '#/no-workspace') {
|
||||||
|
window.location.hash = '#/no-workspace';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hash === '#/no-workspace') {
|
||||||
|
if (!hasNoAccessibleWorkspace(u)) { window.location.hash = '#/'; return; }
|
||||||
|
sidebar.style.display = 'none';
|
||||||
|
app.style.marginLeft = '0';
|
||||||
|
const mb = document.getElementById('mobileMenuBtn');
|
||||||
|
if (mb) mb.style.display = 'none';
|
||||||
|
currentView = noWorkspace;
|
||||||
|
noWorkspace.render(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Onboarding for new users
|
// Onboarding for new users
|
||||||
if (hash === '#/onboarding' && isAuthenticated()) {
|
if (hash === '#/onboarding' && isAuthenticated()) {
|
||||||
sidebar.style.display = 'none';
|
sidebar.style.display = 'none';
|
||||||
|
|
@ -64,6 +363,8 @@ function route() {
|
||||||
if (hash === '#/login') {
|
if (hash === '#/login') {
|
||||||
sidebar.style.display = 'none';
|
sidebar.style.display = 'none';
|
||||||
app.style.marginLeft = '0';
|
app.style.marginLeft = '0';
|
||||||
|
const mb = document.getElementById('mobileMenuBtn');
|
||||||
|
if (mb) mb.style.display = 'none';
|
||||||
currentView = login;
|
currentView = login;
|
||||||
login.render(app);
|
login.render(app);
|
||||||
return;
|
return;
|
||||||
|
|
@ -72,6 +373,8 @@ function route() {
|
||||||
// Show sidebar for authenticated views
|
// Show sidebar for authenticated views
|
||||||
sidebar.style.display = '';
|
sidebar.style.display = '';
|
||||||
app.style.marginLeft = '';
|
app.style.marginLeft = '';
|
||||||
|
const mb = document.getElementById('mobileMenuBtn');
|
||||||
|
if (mb) mb.style.display = '';
|
||||||
|
|
||||||
// Update user info in sidebar
|
// Update user info in sidebar
|
||||||
updateSidebarUser();
|
updateSidebarUser();
|
||||||
|
|
@ -137,9 +440,17 @@ function route() {
|
||||||
} else if (hash === '#/teams' || hash.startsWith('#/team/')) {
|
} else if (hash === '#/teams' || hash.startsWith('#/team/')) {
|
||||||
currentView = teams;
|
currentView = teams;
|
||||||
teams.render(app);
|
teams.render(app);
|
||||||
|
} else if (hash.startsWith('#/workspace/') && hash.includes('/members')) {
|
||||||
|
const wsId = hash.split('#/workspace/')[1].split('/')[0];
|
||||||
|
currentView = workspaceMembers;
|
||||||
|
workspaceMembers.render(app, wsId);
|
||||||
} else if (hash === '#/help' || hash.startsWith('#/help')) {
|
} else if (hash === '#/help' || hash.startsWith('#/help')) {
|
||||||
currentView = help;
|
currentView = help;
|
||||||
help.render(app);
|
help.render(app);
|
||||||
|
} else if (hash.startsWith('#/admin/player-debug')) {
|
||||||
|
// Match prefix so query params (?page=2&ua=Tizen) route correctly.
|
||||||
|
currentView = adminPlayerDebug;
|
||||||
|
adminPlayerDebug.render(app);
|
||||||
} else if (hash === '#/admin') {
|
} else if (hash === '#/admin') {
|
||||||
currentView = admin;
|
currentView = admin;
|
||||||
admin.render(app);
|
admin.render(app);
|
||||||
|
|
@ -159,9 +470,9 @@ function updateSidebarUser() {
|
||||||
const user = getCurrentUser();
|
const user = getCurrentUser();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
// Show admin nav only for superadmins
|
// Show admin nav only for platform admins (legacy 'superadmin' or Phase 1 renamed 'platform_admin')
|
||||||
const adminNav = document.getElementById('adminNavItem');
|
const adminNav = document.getElementById('adminNavItem');
|
||||||
if (adminNav) adminNav.style.display = user.role === 'superadmin' ? '' : 'none';
|
if (adminNav) adminNav.style.display = isPlatformAdmin(user) ? '' : 'none';
|
||||||
|
|
||||||
let userEl = document.getElementById('sidebarUser');
|
let userEl = document.getElementById('sidebarUser');
|
||||||
if (!userEl) {
|
if (!userEl) {
|
||||||
|
|
@ -179,7 +490,7 @@ function updateSidebarUser() {
|
||||||
<div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${user.name || user.email}</div>
|
<div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${user.name || user.email}</div>
|
||||||
<div style="font-size:10px;color:var(--text-muted)">${user.role}</div>
|
<div style="font-size:10px;color:var(--text-muted)">${user.role}</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="logoutBtn" class="btn-icon" title="Sign out" style="flex-shrink:0">
|
<button id="logoutBtn" class="btn-icon" title="${t('auth.sign_out')}" style="flex-shrink:0">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
<polyline points="16 17 21 12 16 7"/>
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
|
|
@ -197,19 +508,47 @@ function updateSidebarUser() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
|
renderNavLabels();
|
||||||
|
translateStaticDom();
|
||||||
|
window.addEventListener('language-changed', () => {
|
||||||
|
renderNavLabels();
|
||||||
|
translateStaticDom();
|
||||||
|
});
|
||||||
|
|
||||||
if (isAuthenticated()) {
|
if (isAuthenticated()) {
|
||||||
connectSocket();
|
connectSocket();
|
||||||
|
applyBranding();
|
||||||
|
refreshCurrentUser().then(() => updateSidebarUser());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh the cached user on every route transition so plan/role changes
|
||||||
|
// made by an admin propagate without requiring a re-login.
|
||||||
|
window.addEventListener('hashchange', () => { if (isAuthenticated()) refreshCurrentUser(); });
|
||||||
|
|
||||||
// Register PWA service worker
|
// Register PWA service worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('/sw-admin.js').catch(() => {});
|
navigator.serviceWorker.register('/sw-admin.js').catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close mobile menu on navigation
|
// Mobile sidebar: open/close via hamburger, backdrop, nav tap, Escape
|
||||||
window.addEventListener('hashchange', () => {
|
const sidebarEl = document.querySelector('.sidebar');
|
||||||
document.querySelector('.sidebar')?.classList.remove('open');
|
const backdropEl = document.getElementById('sidebarBackdrop');
|
||||||
document.getElementById('sidebarBackdrop')?.classList.remove('open');
|
const menuBtn = document.getElementById('mobileMenuBtn');
|
||||||
|
|
||||||
|
function setMobileNav(open) {
|
||||||
|
if (!sidebarEl || !backdropEl) return;
|
||||||
|
sidebarEl.classList.toggle('open', open);
|
||||||
|
backdropEl.classList.toggle('open', open);
|
||||||
|
menuBtn?.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
menuBtn?.addEventListener('click', () => {
|
||||||
|
setMobileNav(!sidebarEl.classList.contains('open'));
|
||||||
|
});
|
||||||
|
backdropEl?.addEventListener('click', () => setMobileNav(false));
|
||||||
|
window.addEventListener('hashchange', () => setMobileNav(false));
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && sidebarEl?.classList.contains('open')) setMobileNav(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-reload on frontend update (no more hard refresh needed)
|
// Auto-reload on frontend update (no more hard refresh needed)
|
||||||
|
|
@ -262,3 +601,12 @@ if (isAuthenticated()) {
|
||||||
}
|
}
|
||||||
window.addEventListener('hashchange', route);
|
window.addEventListener('hashchange', route);
|
||||||
route();
|
route();
|
||||||
|
|
||||||
|
// Close-modal buttons (replaces inline onclick handlers — required for CSP).
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const closer = e.target.closest('[data-close-modal]');
|
||||||
|
if (!closer) return;
|
||||||
|
const id = closer.dataset.closeModal;
|
||||||
|
const modal = document.getElementById(id);
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
|
||||||
54
frontend/js/brand-prime.js
Normal file
54
frontend/js/brand-prime.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Render-blocking branding primer (#38). Loaded as a synchronous same-origin
|
||||||
|
// <script> right after the sidebar logo, so it runs DURING parse, before first
|
||||||
|
// paint — applying the current workspace's CACHED white-label so the page paints
|
||||||
|
// branded instead of flashing the "ScreenTinker" default. branding.js then
|
||||||
|
// refreshes it from the server and re-writes the cache. Plain script (not a
|
||||||
|
// module) so it's not deferred; keyed by workspace so a switch shows the right
|
||||||
|
// brand (or the neutral default for a workspace we haven't cached yet).
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
var ws = 'none';
|
||||||
|
try {
|
||||||
|
var seg = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
ws = (JSON.parse(atob(seg)) || {}).current_workspace_id || 'none';
|
||||||
|
} catch (e) { /* malformed token -> treat as no workspace */ }
|
||||||
|
|
||||||
|
var wl = JSON.parse(localStorage.getItem('rd_branding_' + ws) || 'null');
|
||||||
|
if (!wl) {
|
||||||
|
// #76: no per-workspace cache yet (e.g. a never-visited org). Fall back to
|
||||||
|
// the server-injected instance / custom-domain branding so the page paints
|
||||||
|
// the configured brand instead of flashing the ScreenTinker default;
|
||||||
|
// branding.js then fetches and caches the workspace-specific brand.
|
||||||
|
try {
|
||||||
|
var ssr = document.querySelector('meta[name="ssr-brand"]');
|
||||||
|
if (ssr && ssr.content) wl = JSON.parse(ssr.content);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (!wl) return;
|
||||||
|
|
||||||
|
var root = document.documentElement;
|
||||||
|
if (wl.primary_color) root.style.setProperty('--accent', wl.primary_color);
|
||||||
|
if (wl.bg_color) {
|
||||||
|
root.style.setProperty('--bg-primary', wl.bg_color);
|
||||||
|
var meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (meta) meta.setAttribute('content', wl.bg_color);
|
||||||
|
}
|
||||||
|
if (wl.brand_name) {
|
||||||
|
document.title = wl.brand_name;
|
||||||
|
var span = document.getElementById('brandName');
|
||||||
|
if (span) span.textContent = wl.brand_name;
|
||||||
|
}
|
||||||
|
if (wl.favicon_url) {
|
||||||
|
var links = document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]');
|
||||||
|
for (var i = 0; i < links.length; i++) links[i].setAttribute('href', wl.favicon_url);
|
||||||
|
}
|
||||||
|
if (wl.custom_css) {
|
||||||
|
var s = document.createElement('style');
|
||||||
|
s.id = 'wl-custom-css';
|
||||||
|
s.textContent = wl.custom_css;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
} catch (e) { /* never let branding break boot */ }
|
||||||
|
})();
|
||||||
71
frontend/js/branding.js
Normal file
71
frontend/js/branding.js
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
// Applies the current user's saved white-label config to the DOM.
|
||||||
|
// Runs once after login/route bootstrap. Without this, saved values in the
|
||||||
|
// white_labels table are read into the Settings form but never applied to
|
||||||
|
// the actual page — so users see "ScreenTinker" and default colors after
|
||||||
|
// every reload, as if their save reverted.
|
||||||
|
|
||||||
|
let applied = false;
|
||||||
|
|
||||||
|
// Current workspace id from the JWT, so the branding cache (read render-blocking by
|
||||||
|
// brand-prime.js) is keyed per workspace — a switch shows the right brand. (#38)
|
||||||
|
function currentWorkspaceId() {
|
||||||
|
try {
|
||||||
|
const seg = localStorage.getItem('token').split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
return (JSON.parse(atob(seg)) || {}).current_workspace_id || 'none';
|
||||||
|
} catch { return 'none'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyBranding() {
|
||||||
|
if (applied) return;
|
||||||
|
applied = true;
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
let wl;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/white-label', { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
if (!res.ok) return;
|
||||||
|
wl = await res.json();
|
||||||
|
} catch { return; }
|
||||||
|
if (!wl) return;
|
||||||
|
|
||||||
|
// Cache for the next load/switch so brand-prime.js can apply it before paint.
|
||||||
|
try { localStorage.setItem('rd_branding_' + currentWorkspaceId(), JSON.stringify(wl)); } catch {}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (wl.primary_color) root.style.setProperty('--accent', wl.primary_color);
|
||||||
|
if (wl.bg_color) {
|
||||||
|
root.style.setProperty('--bg-primary', wl.bg_color);
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (meta) meta.setAttribute('content', wl.bg_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wl.brand_name) {
|
||||||
|
document.title = wl.brand_name;
|
||||||
|
const span = document.getElementById('brandName');
|
||||||
|
if (span) span.textContent = wl.brand_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wl.favicon_url) {
|
||||||
|
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(l => {
|
||||||
|
l.setAttribute('href', wl.favicon_url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wl.custom_css) {
|
||||||
|
let style = document.getElementById('wl-custom-css');
|
||||||
|
if (!style) {
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = 'wl-custom-css';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
style.textContent = wl.custom_css;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force a re-apply (called from settings.js after save)
|
||||||
|
export function resetBranding() {
|
||||||
|
applied = false;
|
||||||
|
return applyBranding();
|
||||||
|
}
|
||||||
72
frontend/js/components/admin-create-org-modal.js
Normal file
72
frontend/js/components/admin-create-org-modal.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
|
// Create-Organization modal (#35). Platform-admin only (the page is gated; the
|
||||||
|
// endpoint re-checks). Creates a named org + its first "Default" workspace, owned
|
||||||
|
// by the creating admin (organizations.owner_user_id is NOT NULL). On success the
|
||||||
|
// org appears in the switcher, so we reload to refresh it — matching the
|
||||||
|
// workspace rename/switch flow. opts.onSuccess(result) fires before reload.
|
||||||
|
export function openCreateOrgModal(opts = {}) {
|
||||||
|
const { onSuccess } = opts;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>${t('admin.create_org.title')}</h3>
|
||||||
|
<button class="btn-icon" type="button" data-org-close aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="createOrgName">${t('admin.create_org.name')}</label>
|
||||||
|
<input id="createOrgName" type="text" class="input" maxlength="120" placeholder="${t('admin.create_org.placeholder')}" style="width:100%">
|
||||||
|
<div style="color:var(--text-muted);font-size:11px;margin-top:4px">${t('admin.create_org.hint')}</div>
|
||||||
|
</div>
|
||||||
|
<div id="createOrgError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" type="button" data-org-close>${t('common.cancel')}</button>
|
||||||
|
<button class="btn btn-primary" type="button" id="createOrgSave">${t('admin.create_org.submit')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const nameInput = overlay.querySelector('#createOrgName');
|
||||||
|
const errorEl = overlay.querySelector('#createOrgError');
|
||||||
|
const saveBtn = overlay.querySelector('#createOrgSave');
|
||||||
|
nameInput.focus();
|
||||||
|
|
||||||
|
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
else if (e.key === 'Enter' && e.target === nameInput) save();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelectorAll('[data-org-close]').forEach(b => b.addEventListener('click', close));
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) { showError(t('admin.create_org.err_empty')); return; }
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = t('common.saving');
|
||||||
|
try {
|
||||||
|
const result = await api.adminCreateOrg(name);
|
||||||
|
if (typeof onSuccess === 'function') onSuccess(result);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = t('admin.create_org.submit');
|
||||||
|
showError(err.message || t('admin.create_org.err_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', save);
|
||||||
|
}
|
||||||
163
frontend/js/components/admin-user-workspaces-modal.js
Normal file
163
frontend/js/components/admin-user-workspaces-modal.js
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
// "Manage workspaces" modal for the platform Users admin page. Lets a platform
|
||||||
|
// admin see/manage ALL of a user's workspace memberships: list each with an
|
||||||
|
// inline role dropdown + Remove, and add the user to more workspaces via a
|
||||||
|
// type-to-filter picker. Backed by /api/admin/users/:id/workspaces.
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
import { showToast } from '../components/toast.js';
|
||||||
|
|
||||||
|
// Display order = least-privilege first (the default for the add row). The SET
|
||||||
|
// must match the server's accepted WORKSPACE_ROLES (routes/admin.js).
|
||||||
|
const WORKSPACE_ROLES = ['workspace_viewer', 'workspace_editor', 'workspace_admin'];
|
||||||
|
const STAFF_ROLES = ['platform_admin', 'superadmin', 'platform_operator'];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
|
function roleOptions(selected) {
|
||||||
|
return WORKSPACE_ROLES.map(r => `<option value="${r}"${r === selected ? ' selected' : ''}>${esc(t('members.role.' + r))}</option>`).join('');
|
||||||
|
}
|
||||||
|
const wsLabel = w => `${w.organization_name || '—'} / ${w.name}`;
|
||||||
|
|
||||||
|
// user: { id, name, email, role }; opts.onClose fires (once) if anything changed.
|
||||||
|
export function openManageWorkspacesModal(user, opts = {}) {
|
||||||
|
const { onClose } = opts;
|
||||||
|
const isStaff = STAFF_ROLES.includes(user.role);
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>${t('manage_ws.title', { user: esc(user.name || user.email) })}</h3>
|
||||||
|
<button class="btn-icon" type="button" data-mws-close aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
${isStaff ? `<p style="font-size:12px;color:var(--text-muted);background:var(--bg-input);padding:8px 10px;border-radius:6px;margin-bottom:12px">${t('manage_ws.staff_note')}</p>` : ''}
|
||||||
|
<h4 style="font-size:14px;margin:0 0 8px">${t('manage_ws.current')}</h4>
|
||||||
|
<div id="mwsList" style="color:var(--text-muted);font-size:13px">${t('common.loading')}</div>
|
||||||
|
<h4 style="font-size:14px;margin:16px 0 8px">${t('manage_ws.add')}</h4>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||||
|
<input id="mwsFilter" type="text" class="input" placeholder="${t('manage_ws.filter')}" style="flex:1;min-width:150px" autocomplete="off">
|
||||||
|
<select id="mwsAddWs" class="input" style="flex:2;min-width:170px"></select>
|
||||||
|
<select id="mwsAddRole" class="input" style="width:auto">${roleOptions('workspace_viewer')}</select>
|
||||||
|
<button class="btn btn-secondary" type="button" id="mwsAddBtn">${t('manage_ws.add_btn')}</button>
|
||||||
|
</div>
|
||||||
|
<div id="mwsError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-primary" type="button" data-mws-close>${t('manage_ws.done')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const listEl = overlay.querySelector('#mwsList');
|
||||||
|
const filterEl = overlay.querySelector('#mwsFilter');
|
||||||
|
const addWsEl = overlay.querySelector('#mwsAddWs');
|
||||||
|
const addRoleEl = overlay.querySelector('#mwsAddRole');
|
||||||
|
const addBtn = overlay.querySelector('#mwsAddBtn');
|
||||||
|
const errorEl = overlay.querySelector('#mwsError');
|
||||||
|
|
||||||
|
let allWs = []; // assignable workspaces (from /me)
|
||||||
|
let memberships = []; // current memberships
|
||||||
|
let changed = false; // refresh the table on close only if something changed
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
if (changed && typeof onClose === 'function') { try { onClose(); } catch (e) { console.error(e); } }
|
||||||
|
}
|
||||||
|
function onKey(e) { if (e.key === 'Escape') close(); }
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelectorAll('[data-mws-close]').forEach(b => b.addEventListener('click', close));
|
||||||
|
|
||||||
|
const showError = m => { errorEl.textContent = m; errorEl.style.display = 'block'; };
|
||||||
|
const clearError = () => { errorEl.style.display = 'none'; };
|
||||||
|
|
||||||
|
function renderAddOptions() {
|
||||||
|
const memberIds = new Set(memberships.map(m => m.workspace_id));
|
||||||
|
const f = (filterEl.value || '').trim().toLowerCase();
|
||||||
|
const avail = allWs.filter(w => !memberIds.has(w.id) && (!f || wsLabel(w).toLowerCase().includes(f)));
|
||||||
|
let html = `<option value="">${esc(t('manage_ws.pick'))}</option>`;
|
||||||
|
let curOrg = null;
|
||||||
|
for (const w of avail) {
|
||||||
|
const org = w.organization_name || '—';
|
||||||
|
if (org !== curOrg) { if (curOrg !== null) html += '</optgroup>'; html += `<optgroup label="${esc(org)}">`; curOrg = org; }
|
||||||
|
html += `<option value="${esc(w.id)}">${esc(w.name)}</option>`;
|
||||||
|
}
|
||||||
|
if (curOrg !== null) html += '</optgroup>';
|
||||||
|
addWsEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (!memberships.length) {
|
||||||
|
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${t('manage_ws.empty')}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = memberships.map(m => `
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-weight:500">${esc(m.workspace_name)}</div>
|
||||||
|
<div style="font-size:11px;color:var(--text-muted)">${esc(m.organization_name || '')}</div>
|
||||||
|
</div>
|
||||||
|
<select class="input" style="width:auto;font-size:12px;padding:4px;background:var(--bg-input)" data-mws-role="${esc(m.workspace_id)}">${roleOptions(m.role)}</select>
|
||||||
|
<button class="btn btn-danger btn-sm" type="button" data-mws-remove="${esc(m.workspace_id)}">${t('manage_ws.remove')}</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
listEl.querySelectorAll('[data-mws-role]').forEach(sel => {
|
||||||
|
sel.onchange = async () => {
|
||||||
|
clearError();
|
||||||
|
try { await api.adminSetUserWorkspaceRole(user.id, sel.dataset.mwsRole, sel.value); changed = true; showToast(t('manage_ws.toast.role'), 'success'); await reload(); }
|
||||||
|
catch (e) { showError(e.message); await reload(); }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
listEl.querySelectorAll('[data-mws-remove]').forEach(btn => {
|
||||||
|
btn.onclick = async () => {
|
||||||
|
clearError();
|
||||||
|
try { await api.adminRemoveUserWorkspace(user.id, btn.dataset.mwsRemove); changed = true; showToast(t('manage_ws.toast.removed'), 'success'); await reload(); }
|
||||||
|
catch (e) { showError(e.message); await reload(); }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
memberships = await api.adminGetUserWorkspaces(user.id).catch(() => memberships);
|
||||||
|
renderList();
|
||||||
|
renderAddOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
filterEl.addEventListener('input', renderAddOptions);
|
||||||
|
addBtn.addEventListener('click', async () => {
|
||||||
|
clearError();
|
||||||
|
const wsId = addWsEl.value;
|
||||||
|
const role = addRoleEl.value;
|
||||||
|
if (!wsId) { showError(t('manage_ws.pick_required')); return; }
|
||||||
|
addBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await api.adminAddUserWorkspace(user.id, wsId, role);
|
||||||
|
changed = true;
|
||||||
|
showToast(t('manage_ws.toast.added'), 'success');
|
||||||
|
filterEl.value = '';
|
||||||
|
await reload();
|
||||||
|
} catch (e) { showError(e.message); }
|
||||||
|
finally { addBtn.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// initial load
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [mem, me] = await Promise.all([api.adminGetUserWorkspaces(user.id), api.getMe().catch(() => ({}))]);
|
||||||
|
memberships = Array.isArray(mem) ? mem : [];
|
||||||
|
allWs = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces.slice() : [];
|
||||||
|
renderList();
|
||||||
|
renderAddOptions();
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = `<p style="color:var(--danger);font-size:13px">${esc(e.message || 'Failed to load')}</p>`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ export function showToast(message, type = 'info', duration = 4000) {
|
||||||
const container = document.getElementById('toastContainer');
|
const container = document.getElementById('toastContainer');
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast ${type}`;
|
toast.className = `toast ${type}`;
|
||||||
|
toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||||
|
toast.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
|
||||||
toast.innerHTML = `
|
toast.innerHTML = `
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
${type === 'success' ? '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>' :
|
${type === 'success' ? '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>' :
|
||||||
|
|
|
||||||
75
frontend/js/components/type-to-confirm-modal.js
Normal file
75
frontend/js/components/type-to-confirm-modal.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
|
// Reusable destructive-confirmation modal (#36). The primary (danger) button stays
|
||||||
|
// disabled until the user types `expected` exactly — guards irreversible deletes
|
||||||
|
// (delete org / workspace). opts:
|
||||||
|
// title, body (HTML allowed - caller escapes), expected (string to type),
|
||||||
|
// confirmLabel, onConfirm: async () => any (throw to show an inline error)
|
||||||
|
export function openTypeToConfirmModal(opts = {}) {
|
||||||
|
const { title, body = '', expected, confirmLabel, onConfirm } = opts;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>${esc(title || '')}</h3>
|
||||||
|
<button class="btn-icon" type="button" data-ttc-close aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div style="font-size:13px;line-height:1.5;margin-bottom:12px">${body}</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ttcInput">${t('confirm_delete.type_label', { name: esc(expected) })}</label>
|
||||||
|
<input id="ttcInput" type="text" class="input" autocomplete="off" style="width:100%">
|
||||||
|
</div>
|
||||||
|
<div id="ttcError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" type="button" data-ttc-close>${t('common.cancel')}</button>
|
||||||
|
<button class="btn btn-danger" type="button" id="ttcConfirm" disabled>${esc(confirmLabel || t('common.delete'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const input = overlay.querySelector('#ttcInput');
|
||||||
|
const confirmBtn = overlay.querySelector('#ttcConfirm');
|
||||||
|
const errorEl = overlay.querySelector('#ttcError');
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
const matches = () => input.value.trim() === String(expected);
|
||||||
|
input.addEventListener('input', () => { confirmBtn.disabled = !matches(); });
|
||||||
|
|
||||||
|
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
else if (e.key === 'Enter' && matches()) confirm();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelectorAll('[data-ttc-close]').forEach(b => b.addEventListener('click', close));
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!matches()) return;
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
confirmBtn.textContent = t('common.deleting');
|
||||||
|
try {
|
||||||
|
await onConfirm?.();
|
||||||
|
close();
|
||||||
|
} catch (err) {
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
confirmBtn.textContent = confirmLabel || t('common.delete');
|
||||||
|
errorEl.textContent = err?.message || t('confirm_delete.failed');
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
confirmBtn.addEventListener('click', confirm);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
193
frontend/js/components/workspace-members-add-user-modal.js
Normal file
193
frontend/js/components/workspace-members-add-user-modal.js
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
// Add-User modal (#10). Creates a user account directly with an admin-set
|
||||||
|
// password and assigns them to a workspace + role (admin-provisioning for
|
||||||
|
// instances with no outbound email). Two open modes, ONE shared form:
|
||||||
|
//
|
||||||
|
// openAddUserModal({ id, name }, opts) -> fixed-workspace mode (members view).
|
||||||
|
// No picker; assigns into that workspace.
|
||||||
|
// openAddUserModal(null, opts) -> picker mode (platform Users admin page).
|
||||||
|
// Shows an Org/Workspace picker; the admin
|
||||||
|
// chooses the target workspace.
|
||||||
|
//
|
||||||
|
// opts.onSuccess: (result) => void - fires on 201 (server response body)
|
||||||
|
// opts.mapError: (err) => string - translates server error to display text
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
// Roles the picker offers. This is the SET POST /api/admin/users accepts
|
||||||
|
// (server: routes/admin.js WORKSPACE_ROLES) - keep them in sync so we never
|
||||||
|
// offer a value the endpoint 400s (the platform_operator dropdown/endpoint
|
||||||
|
// mismatch we already hit). Order here is display order (least-privilege first
|
||||||
|
// = the default selection); the server validates set membership, not order.
|
||||||
|
const WORKSPACE_ROLES = ['workspace_viewer', 'workspace_editor', 'workspace_admin'];
|
||||||
|
|
||||||
|
// Crockford-ish readable random password: avoids ambiguous chars (0/O, 1/l/I).
|
||||||
|
function generatePassword(len = 16) {
|
||||||
|
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||||
|
const arr = new Uint32Array(len);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < len; i++) out += alphabet[arr[i] % alphabet.length];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsLabel(w) {
|
||||||
|
return `${w.organization_name || '—'} / ${w.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openAddUserModal(workspace, opts = {}) {
|
||||||
|
const { onSuccess, mapError } = opts;
|
||||||
|
// Picker mode whenever no concrete target workspace was supplied.
|
||||||
|
const pickerMode = !(workspace && workspace.id);
|
||||||
|
|
||||||
|
const title = pickerMode
|
||||||
|
? t('members.modal.add_user_title_generic')
|
||||||
|
: t('members.modal.add_user_title', { workspace: esc(workspace.name) });
|
||||||
|
|
||||||
|
const roleOptions = WORKSPACE_ROLES
|
||||||
|
.map(r => `<option value="${r}">${esc(t('members.role.' + r))}</option>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Workspace picker block — only rendered in picker mode. A filter input above
|
||||||
|
// a <select> gives type-to-filter for the 70+ workspaces without a dependency.
|
||||||
|
const workspaceGroup = pickerMode ? `
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="addUserWs">${t('members.modal.workspace_label')}</label>
|
||||||
|
<input id="addUserWsFilter" type="text" class="input" placeholder="${t('members.modal.workspace_filter_placeholder')}" style="width:100%;margin-bottom:6px" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
<select id="addUserWs" class="input" style="width:100%">
|
||||||
|
<option value="">${t('members.modal.workspace_loading')}</option>
|
||||||
|
</select>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<button class="btn-icon" type="button" data-add-close aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="addUserEmail">${t('members.modal.email_label')}</label>
|
||||||
|
<input id="addUserEmail" type="email" class="input" placeholder="${t('members.modal.email_placeholder')}" style="width:100%" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="addUserName">${t('members.modal.name_label')}</label>
|
||||||
|
<input id="addUserName" type="text" class="input" placeholder="${t('members.modal.name_placeholder')}" style="width:100%" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="addUserPassword">${t('members.modal.password_label')}</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input id="addUserPassword" type="text" class="input" placeholder="${t('members.modal.password_placeholder')}" style="flex:1" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
<button class="btn btn-secondary" type="button" id="addUserGenerate" style="white-space:nowrap">${t('members.modal.generate')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${workspaceGroup}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="addUserRole">${t('members.modal.role_label')}</label>
|
||||||
|
<select id="addUserRole" class="input" style="width:100%">
|
||||||
|
${roleOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
||||||
|
<input id="addUserMustChange" type="checkbox" checked>
|
||||||
|
${t('members.modal.must_change_label')}
|
||||||
|
</label>
|
||||||
|
<div id="addUserError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" type="button" data-add-close>${t('members.modal.cancel')}</button>
|
||||||
|
<button class="btn btn-primary" type="button" id="addUserSubmit">${t('members.modal.create')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const emailInput = overlay.querySelector('#addUserEmail');
|
||||||
|
const nameInput = overlay.querySelector('#addUserName');
|
||||||
|
const pwInput = overlay.querySelector('#addUserPassword');
|
||||||
|
const genBtn = overlay.querySelector('#addUserGenerate');
|
||||||
|
const roleSelect = overlay.querySelector('#addUserRole');
|
||||||
|
const mustChange = overlay.querySelector('#addUserMustChange');
|
||||||
|
const errorEl = overlay.querySelector('#addUserError');
|
||||||
|
const submitBtn = overlay.querySelector('#addUserSubmit');
|
||||||
|
const wsSelect = overlay.querySelector('#addUserWs'); // null in fixed mode
|
||||||
|
const wsFilter = overlay.querySelector('#addUserWsFilter');
|
||||||
|
emailInput.focus();
|
||||||
|
|
||||||
|
// Picker mode: load the workspaces this platform_admin can assign into from
|
||||||
|
// /me's accessible_workspaces (already org+name shaped, all workspaces for a
|
||||||
|
// platform_admin). Filter input rebuilds the option list live.
|
||||||
|
let allWs = [];
|
||||||
|
function renderWsOptions(filter) {
|
||||||
|
const f = (filter || '').trim().toLowerCase();
|
||||||
|
const matches = f ? allWs.filter(w => wsLabel(w).toLowerCase().includes(f)) : allWs;
|
||||||
|
wsSelect.innerHTML = `<option value="">${esc(t('members.modal.workspace_placeholder'))}</option>`
|
||||||
|
+ matches.map(w => `<option value="${esc(w.id)}">${esc(wsLabel(w))}</option>`).join('');
|
||||||
|
}
|
||||||
|
if (pickerMode) {
|
||||||
|
api.getMe()
|
||||||
|
.then(me => {
|
||||||
|
allWs = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces.slice() : [];
|
||||||
|
if (!allWs.length) { wsSelect.innerHTML = `<option value="">${esc(t('members.modal.workspace_none'))}</option>`; return; }
|
||||||
|
renderWsOptions('');
|
||||||
|
})
|
||||||
|
.catch(() => { wsSelect.innerHTML = `<option value="">${esc(t('members.modal.workspace_load_error'))}</option>`; });
|
||||||
|
wsFilter.addEventListener('input', () => renderWsOptions(wsFilter.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||||
|
function onKey(e) { if (e.key === 'Escape') close(); }
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelectorAll('[data-add-close]').forEach(b => b.addEventListener('click', close));
|
||||||
|
genBtn.addEventListener('click', () => { pwInput.value = generatePassword(); pwInput.type = 'text'; });
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
const email = emailInput.value.trim().toLowerCase();
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
const password = pwInput.value;
|
||||||
|
const role = roleSelect.value;
|
||||||
|
const workspaceId = pickerMode ? (wsSelect.value || '') : workspace.id;
|
||||||
|
if (!email || !EMAIL_RE.test(email)) { showError(t('members.error.invalid_email')); emailInput.focus(); return; }
|
||||||
|
if (!password || password.length < 8) { showError(t('members.error.password_min_8')); pwInput.focus(); return; }
|
||||||
|
if (pickerMode && !workspaceId) { showError(t('members.modal.workspace_required')); (wsFilter || wsSelect).focus(); return; }
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = t('members.modal.creating');
|
||||||
|
try {
|
||||||
|
const result = await api.adminCreateUser({
|
||||||
|
email, name, password, role,
|
||||||
|
workspaceId,
|
||||||
|
mustChangePassword: mustChange.checked,
|
||||||
|
});
|
||||||
|
close();
|
||||||
|
if (typeof onSuccess === 'function') {
|
||||||
|
try { onSuccess(result); }
|
||||||
|
catch (e) { console.error('add-user modal onSuccess threw:', e); }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = t('members.modal.create');
|
||||||
|
const msg = (typeof mapError === 'function')
|
||||||
|
? mapError(err)
|
||||||
|
: (err?.message || t('members.error.mutation_generic', { error: '' }));
|
||||||
|
showError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
||||||
|
|
||||||
|
submitBtn.addEventListener('click', submit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
124
frontend/js/components/workspace-members-invite-modal.js
Normal file
124
frontend/js/components/workspace-members-invite-modal.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
// Invite-member modal. Mirrors workspace-rename-modal.js's structure
|
||||||
|
// (overlay + listeners + close + esc/click-outside/enter) with two key
|
||||||
|
// differences:
|
||||||
|
//
|
||||||
|
// 1. On success calls an onSuccess(result) callback instead of
|
||||||
|
// window.location.reload(). The parent view (workspace-members.js)
|
||||||
|
// re-fetches and re-renders just the pending-invites section - no
|
||||||
|
// full-page flash for a single row addition.
|
||||||
|
//
|
||||||
|
// 2. Server errors map to translated strings via a mapError callback
|
||||||
|
// passed by the parent (mapMutationError lives in workspace-members.js).
|
||||||
|
// That keeps a single error mapper for ALL slice 2B mutations rather
|
||||||
|
// than scattering modal-specific copies. Inline display below the form
|
||||||
|
// (not toast) so user can correct + resubmit without closing.
|
||||||
|
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
// open the modal.
|
||||||
|
// workspace: { id, name } - id used for the API call, name shown in title
|
||||||
|
// opts.onSuccess: (result) => void - fires on 200; result is the server
|
||||||
|
// response body { id, email, role, expires_at }
|
||||||
|
// opts.mapError: (err) => string - translates server error to display text
|
||||||
|
export function openInviteMemberModal(workspace, opts = {}) {
|
||||||
|
const { onSuccess, mapError } = opts;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>${t('members.modal.invite_title', { workspace: esc(workspace.name) })}</h3>
|
||||||
|
<button class="btn-icon" type="button" data-invite-close aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inviteEmail">${t('members.modal.email_label')}</label>
|
||||||
|
<input id="inviteEmail" type="email" class="input" placeholder="${t('members.modal.email_placeholder')}" style="width:100%" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inviteRole">${t('members.modal.role_label')}</label>
|
||||||
|
<select id="inviteRole" class="input" style="width:100%">
|
||||||
|
<option value="workspace_viewer">${t('members.role.workspace_viewer')}</option>
|
||||||
|
<option value="workspace_editor">${t('members.role.workspace_editor')}</option>
|
||||||
|
<option value="workspace_admin">${t('members.role.workspace_admin')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="inviteModalError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" type="button" data-invite-close>${t('members.modal.cancel')}</button>
|
||||||
|
<button class="btn btn-primary" type="button" id="inviteSendBtn">${t('members.modal.send')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const emailInput = overlay.querySelector('#inviteEmail');
|
||||||
|
const roleSelect = overlay.querySelector('#inviteRole');
|
||||||
|
const errorEl = overlay.querySelector('#inviteModalError');
|
||||||
|
const sendBtn = overlay.querySelector('#inviteSendBtn');
|
||||||
|
emailInput.focus();
|
||||||
|
|
||||||
|
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
else if (e.key === 'Enter' && (e.target === emailInput || e.target === roleSelect)) send();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelectorAll('[data-invite-close]').forEach(b => b.addEventListener('click', close));
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
const email = emailInput.value.trim().toLowerCase();
|
||||||
|
const role = roleSelect.value;
|
||||||
|
// Client-side email validation - server validates too, but this avoids a
|
||||||
|
// round-trip and gives immediate feedback on obvious typos.
|
||||||
|
if (!email || !EMAIL_RE.test(email)) {
|
||||||
|
showError(t('members.error.invalid_email'));
|
||||||
|
emailInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.textContent = t('members.modal.sending');
|
||||||
|
try {
|
||||||
|
const result = await api.inviteWorkspaceMember(workspace.id, { email, role });
|
||||||
|
close();
|
||||||
|
// Defensive: undefined onSuccess is a no-op; a thrown onSuccess (parent
|
||||||
|
// bug) is logged but not propagated so the modal-close still succeeded
|
||||||
|
// from the user's perspective.
|
||||||
|
if (typeof onSuccess === 'function') {
|
||||||
|
try { onSuccess(result); }
|
||||||
|
catch (e) { console.error('invite modal onSuccess threw:', e); }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.textContent = t('members.modal.send');
|
||||||
|
// Map via parent-supplied helper. Fallback to raw message if no mapper
|
||||||
|
// was provided (shouldn't happen in normal use, defensive only).
|
||||||
|
const msg = (typeof mapError === 'function')
|
||||||
|
? mapError(err)
|
||||||
|
: (err?.message || t('members.error.mutation_generic', { error: '' }));
|
||||||
|
showError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorEl.textContent = msg;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', send);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
82
frontend/js/components/workspace-rename-modal.js
Normal file
82
frontend/js/components/workspace-rename-modal.js
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
|
// Open a rename modal for the given workspace. Uses the existing .modal-overlay
|
||||||
|
// / .modal / .modal-header / .modal-body / .modal-footer CSS classes. On
|
||||||
|
// successful save, reloads the page (matches the workspace-switch flow).
|
||||||
|
export function openWorkspaceRenameModal(workspace) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Rename workspace</h3>
|
||||||
|
<button class="btn-icon" type="button" data-rename-close aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="renameWsName">Name</label>
|
||||||
|
<input id="renameWsName" type="text" class="input" maxlength="80" value="${esc(workspace.name || '')}" style="width:100%">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="renameWsSlug">Slug <span style="color:var(--text-muted);font-weight:400">(optional, URL-safe)</span></label>
|
||||||
|
<input id="renameWsSlug" type="text" class="input" maxlength="60" value="${esc(workspace.slug || '')}" placeholder="e.g. studio-a" style="width:100%">
|
||||||
|
<div style="color:var(--text-muted);font-size:11px;margin-top:4px">Lowercase letters, digits, hyphens. Must be unique within the organization.</div>
|
||||||
|
</div>
|
||||||
|
<div id="renameWsError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" type="button" data-rename-close>Cancel</button>
|
||||||
|
<button class="btn btn-primary" type="button" id="renameWsSave">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const nameInput = overlay.querySelector('#renameWsName');
|
||||||
|
const slugInput = overlay.querySelector('#renameWsSlug');
|
||||||
|
const errorEl = overlay.querySelector('#renameWsError');
|
||||||
|
const saveBtn = overlay.querySelector('#renameWsSave');
|
||||||
|
nameInput.focus();
|
||||||
|
nameInput.select();
|
||||||
|
|
||||||
|
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
else if (e.key === 'Enter' && (e.target === nameInput || e.target === slugInput)) save();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelectorAll('[data-rename-close]').forEach(b => b.addEventListener('click', close));
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
const slug = slugInput.value.trim();
|
||||||
|
if (!name) { showError('Name cannot be empty'); return; }
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
try {
|
||||||
|
await api.renameWorkspace(workspace.id, { name, slug });
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Save';
|
||||||
|
showError(err.message || 'Rename failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showError(msg) {
|
||||||
|
errorEl.textContent = msg;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', save);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
244
frontend/js/components/workspace-switcher.js
Normal file
244
frontend/js/components/workspace-switcher.js
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { showToast } from './toast.js';
|
||||||
|
import { t, tn } from '../i18n.js';
|
||||||
|
|
||||||
|
// Reusable resource-count formatter. Returns localized "1 device" / "N devices"
|
||||||
|
// / "No devices" based on n. Generic so the same shape can wire users /
|
||||||
|
// playlists / schedules counts later without refactor - caller supplies the
|
||||||
|
// i18n key bases.
|
||||||
|
// keyBase: e.g. 'switcher.devices_count' (looks up _one / _other variants via tn)
|
||||||
|
// zeroKey: e.g. 'switcher.no_devices' (direct lookup for n === 0)
|
||||||
|
function formatResourceCount(n, keyBase, zeroKey) {
|
||||||
|
if (n === undefined || n === null) return '';
|
||||||
|
if (n === 0) return t(zeroKey);
|
||||||
|
return tn(keyBase, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin affordances shown beside a workspace: manage members + rename. Returns
|
||||||
|
// '' for non-admins. Shared by the single-workspace view and the multi-workspace
|
||||||
|
// dropdown items so the two never drift - #19: the single view was missing these,
|
||||||
|
// locking single-workspace users out of org settings (invite users, perms, slug).
|
||||||
|
function adminIconsHtml(w) {
|
||||||
|
if (!w.can_admin) return '';
|
||||||
|
return `
|
||||||
|
<button class="workspace-switcher-members" type="button" data-members-id="${esc(w.id)}" aria-label="Manage members" title="Manage members">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="workspace-switcher-pencil" type="button" data-rename-id="${esc(w.id)}" aria-label="Rename workspace" title="Rename">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire the manage-members + rename buttons within `scope`. `list` resolves a
|
||||||
|
// workspace id to its object (for the rename modal). stopPropagation so a click
|
||||||
|
// on an icon never triggers the row's switch handler.
|
||||||
|
function wireAdminIcons(scope, list) {
|
||||||
|
scope.querySelectorAll('.workspace-switcher-pencil').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const ws = list.find(w => w.id === btn.dataset.renameId);
|
||||||
|
if (!ws) return;
|
||||||
|
scope.classList.remove('open');
|
||||||
|
const { openWorkspaceRenameModal } = await import('./workspace-rename-modal.js');
|
||||||
|
openWorkspaceRenameModal(ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
scope.querySelectorAll('.workspace-switcher-members').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
scope.classList.remove('open');
|
||||||
|
window.location.hash = `#/workspace/${btn.dataset.membersId}/members`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the workspace switcher inside #workspaceSwitcher based on the
|
||||||
|
// /api/auth/me response. Three modes:
|
||||||
|
// - 0 accessible workspaces: muted "No workspace" placeholder
|
||||||
|
// - 1 accessible workspace: workspace name as static text
|
||||||
|
// - >1 accessible workspaces: dropdown button + menu with click-to-switch
|
||||||
|
export function renderWorkspaceSwitcher(me) {
|
||||||
|
const container = document.getElementById('workspaceSwitcher');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const list = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces : [];
|
||||||
|
const currentId = me?.current_workspace_id || null;
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
container.classList.remove('open');
|
||||||
|
container.innerHTML = `<span class="workspace-switcher-empty">No workspace</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.length === 1) {
|
||||||
|
// #19: a single workspace still needs its admin affordances (manage members /
|
||||||
|
// rename + slug). Render the name as before, plus the inline manage icons
|
||||||
|
// when the user can administer it - no dropdown for one item.
|
||||||
|
container.classList.remove('open');
|
||||||
|
const only = list[0];
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="workspace-switcher-single">
|
||||||
|
<span class="workspace-switcher-static">${esc(only.name)}</span>
|
||||||
|
${adminIconsHtml(only)}
|
||||||
|
</div>`;
|
||||||
|
wireAdminIcons(container, [only]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// >1: dropdown. Alpha sort by workspace name for MVP (no recently-used yet).
|
||||||
|
const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const current = sorted.find(w => w.id === currentId) || sorted[0];
|
||||||
|
|
||||||
|
// Issue #16: show a type-to-filter search box once the list is big enough to
|
||||||
|
// be painful to scroll (MSPs run 100+ orgs). Below the threshold a plain list
|
||||||
|
// is fine. The full list is already loaded from /me, so filtering is client-side.
|
||||||
|
const SHOW_SEARCH_THRESHOLD = 8;
|
||||||
|
const showSearch = sorted.length >= SHOW_SEARCH_THRESHOLD;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<button class="workspace-switcher-button" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||||
|
<span class="ws-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(current.name)}</span>
|
||||||
|
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="workspace-switcher-menu" role="listbox">
|
||||||
|
${showSearch ? `
|
||||||
|
<div class="workspace-switcher-search">
|
||||||
|
<input type="text" class="ws-search-input" placeholder="${t('switcher.search_placeholder')}"
|
||||||
|
autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="${t('switcher.search_placeholder')}">
|
||||||
|
</div>` : ''}
|
||||||
|
${sorted.map(w => {
|
||||||
|
const countStr = formatResourceCount(w.device_count, 'switcher.devices_count', 'switcher.no_devices');
|
||||||
|
const orgName = w.organization_name || '';
|
||||||
|
const subtitle = orgName && countStr ? esc(orgName) + ' · ' + esc(countStr)
|
||||||
|
: orgName ? esc(orgName)
|
||||||
|
: countStr ? esc(countStr)
|
||||||
|
: '';
|
||||||
|
// Searchable haystack: org name + workspace name, lowercased.
|
||||||
|
const haystack = `${orgName} ${w.name}`.toLowerCase();
|
||||||
|
return `
|
||||||
|
<div class="workspace-switcher-item ${w.id === currentId ? 'current' : ''}" data-workspace-id="${esc(w.id)}" data-search="${esc(haystack)}" role="option">
|
||||||
|
<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" style="${w.id === currentId ? '' : 'visibility:hidden'}">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
<div class="ws-meta">
|
||||||
|
<div class="ws-name">${esc(w.name)}</div>
|
||||||
|
<div class="ws-org">${subtitle}</div>
|
||||||
|
</div>
|
||||||
|
${adminIconsHtml(w)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
<div class="workspace-switcher-noresults" style="display:none">${t('switcher.no_matches')}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const button = container.querySelector('.workspace-switcher-button');
|
||||||
|
const searchInput = container.querySelector('.ws-search-input'); // null below threshold
|
||||||
|
|
||||||
|
// Shared switch action (used by click and keyboard Enter).
|
||||||
|
async function switchTo(wsId) {
|
||||||
|
if (wsId === currentId) { container.classList.remove('open'); return; }
|
||||||
|
try {
|
||||||
|
const resp = await api.switchWorkspace(wsId);
|
||||||
|
if (resp?.token) {
|
||||||
|
localStorage.setItem('token', resp.token);
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
showToast('Switch returned no token', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || 'Failed to switch workspace', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- type-to-filter + keyboard navigation (only when the search box renders) ----
|
||||||
|
const allItems = Array.from(container.querySelectorAll('.workspace-switcher-item'));
|
||||||
|
const noResults = container.querySelector('.workspace-switcher-noresults');
|
||||||
|
let highlightIdx = -1;
|
||||||
|
const visibleItems = () => allItems.filter(it => it.style.display !== 'none');
|
||||||
|
|
||||||
|
function setHighlight(idx) {
|
||||||
|
const vis = visibleItems();
|
||||||
|
allItems.forEach(it => it.classList.remove('highlighted'));
|
||||||
|
if (!vis.length) { highlightIdx = -1; return; }
|
||||||
|
highlightIdx = Math.max(0, Math.min(idx, vis.length - 1));
|
||||||
|
const el = vis[highlightIdx];
|
||||||
|
el.classList.add('highlighted');
|
||||||
|
el.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter(q) {
|
||||||
|
const query = (q || '').trim().toLowerCase();
|
||||||
|
let anyVisible = false;
|
||||||
|
for (const it of allItems) {
|
||||||
|
const match = !query || it.dataset.search.includes(query);
|
||||||
|
it.style.display = match ? '' : 'none';
|
||||||
|
if (match) anyVisible = true;
|
||||||
|
}
|
||||||
|
if (noResults) noResults.style.display = anyVisible ? 'none' : '';
|
||||||
|
setHighlight(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', () => applyFilter(searchInput.value));
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(highlightIdx + 1); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(highlightIdx - 1); }
|
||||||
|
else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = visibleItems()[highlightIdx];
|
||||||
|
if (el) switchTo(el.dataset.workspaceId);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
container.classList.remove('open');
|
||||||
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
button.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const opening = !container.classList.contains('open');
|
||||||
|
container.classList.toggle('open');
|
||||||
|
button.setAttribute('aria-expanded', String(opening));
|
||||||
|
// On open, reset the filter and focus the search box for immediate typing.
|
||||||
|
if (opening && searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
applyFilter('');
|
||||||
|
setTimeout(() => searchInput.focus(), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manage-members + rename icons (shared with the single-workspace view).
|
||||||
|
wireAdminIcons(container, sorted);
|
||||||
|
|
||||||
|
container.querySelectorAll('.workspace-switcher-item').forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
// Ignore clicks that originated on an icon button (each has its own handler).
|
||||||
|
if (e.target.closest('.workspace-switcher-pencil, .workspace-switcher-members')) return;
|
||||||
|
switchTo(item.dataset.workspaceId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click-outside closes the menu.
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!container.contains(e.target)) {
|
||||||
|
container.classList.remove('open');
|
||||||
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
|
|
@ -1,147 +1,61 @@
|
||||||
const translations = {
|
// Lightweight i18n loader. Each language is its own file under ./i18n/ so a
|
||||||
en: {
|
// translator can edit one file without touching the others. English is the
|
||||||
// Nav
|
// canonical source — every other locale falls back to en for any missing key.
|
||||||
'nav.displays': 'Displays',
|
import en from './i18n/en.js';
|
||||||
'nav.content': 'Content',
|
import es from './i18n/es.js';
|
||||||
'nav.layouts': 'Layouts',
|
import fr from './i18n/fr.js';
|
||||||
'nav.widgets': 'Widgets',
|
import de from './i18n/de.js';
|
||||||
'nav.schedule': 'Schedule',
|
import pt from './i18n/pt.js';
|
||||||
'nav.walls': 'Video Walls',
|
import hi from './i18n/hi.js';
|
||||||
'nav.reports': 'Reports',
|
import it from './i18n/it.js';
|
||||||
'nav.designer': 'Designer',
|
|
||||||
'nav.activity': 'Activity',
|
const fallback = en;
|
||||||
'nav.settings': 'Settings',
|
const registry = { en, es, fr, de, pt, hi, it };
|
||||||
'nav.subscription': 'Subscription',
|
|
||||||
// Dashboard
|
|
||||||
'dashboard.title': 'Displays',
|
|
||||||
'dashboard.subtitle': 'Manage your remote displays',
|
|
||||||
'dashboard.add': 'Add Display',
|
|
||||||
'dashboard.search': 'Search displays...',
|
|
||||||
'dashboard.all_status': 'All Status',
|
|
||||||
'dashboard.online': 'Online',
|
|
||||||
'dashboard.offline': 'Offline',
|
|
||||||
'dashboard.no_displays': 'No displays yet',
|
|
||||||
'dashboard.no_displays_desc': 'Install the ScreenTinker app on your TV and pair it using the button above.',
|
|
||||||
// Content
|
|
||||||
'content.title': 'Content Library',
|
|
||||||
'content.subtitle': 'Upload and manage your media files',
|
|
||||||
'content.drop': 'Drop files here or click to upload',
|
|
||||||
'content.remote_url': 'Remote URL',
|
|
||||||
'content.no_content': 'No content yet',
|
|
||||||
// Common
|
|
||||||
'common.save': 'Save',
|
|
||||||
'common.cancel': 'Cancel',
|
|
||||||
'common.delete': 'Delete',
|
|
||||||
'common.edit': 'Edit',
|
|
||||||
'common.loading': 'Loading...',
|
|
||||||
'common.connected': 'Connected',
|
|
||||||
'common.disconnected': 'Disconnected',
|
|
||||||
// Auth
|
|
||||||
'auth.sign_in': 'Sign In',
|
|
||||||
'auth.create_account': 'Create Account',
|
|
||||||
'auth.email': 'Email',
|
|
||||||
'auth.password': 'Password',
|
|
||||||
'auth.name': 'Name',
|
|
||||||
'auth.sign_out': 'Sign out',
|
|
||||||
},
|
|
||||||
es: {
|
|
||||||
'nav.displays': 'Pantallas',
|
|
||||||
'nav.content': 'Contenido',
|
|
||||||
'nav.layouts': 'Diseños',
|
|
||||||
'nav.widgets': 'Widgets',
|
|
||||||
'nav.schedule': 'Horario',
|
|
||||||
'nav.walls': 'Video Walls',
|
|
||||||
'nav.reports': 'Informes',
|
|
||||||
'nav.designer': 'Diseñador',
|
|
||||||
'nav.activity': 'Actividad',
|
|
||||||
'nav.settings': 'Configuración',
|
|
||||||
'nav.subscription': 'Suscripción',
|
|
||||||
'dashboard.title': 'Pantallas',
|
|
||||||
'dashboard.subtitle': 'Administra tus pantallas remotas',
|
|
||||||
'dashboard.add': 'Agregar Pantalla',
|
|
||||||
'dashboard.search': 'Buscar pantallas...',
|
|
||||||
'dashboard.all_status': 'Todos los estados',
|
|
||||||
'dashboard.online': 'En línea',
|
|
||||||
'dashboard.offline': 'Desconectado',
|
|
||||||
'dashboard.no_displays': 'Aún no hay pantallas',
|
|
||||||
'content.title': 'Biblioteca de Contenido',
|
|
||||||
'content.subtitle': 'Sube y administra tus archivos multimedia',
|
|
||||||
'content.drop': 'Arrastra archivos aquí o haz clic para subir',
|
|
||||||
'content.remote_url': 'URL Remota',
|
|
||||||
'common.save': 'Guardar',
|
|
||||||
'common.cancel': 'Cancelar',
|
|
||||||
'common.delete': 'Eliminar',
|
|
||||||
'common.edit': 'Editar',
|
|
||||||
'common.loading': 'Cargando...',
|
|
||||||
'common.connected': 'Conectado',
|
|
||||||
'common.disconnected': 'Desconectado',
|
|
||||||
'auth.sign_in': 'Iniciar Sesión',
|
|
||||||
'auth.create_account': 'Crear Cuenta',
|
|
||||||
'auth.email': 'Correo electrónico',
|
|
||||||
'auth.password': 'Contraseña',
|
|
||||||
'auth.name': 'Nombre',
|
|
||||||
'auth.sign_out': 'Cerrar sesión',
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
'nav.displays': 'Écrans',
|
|
||||||
'nav.content': 'Contenu',
|
|
||||||
'nav.layouts': 'Mises en page',
|
|
||||||
'nav.widgets': 'Widgets',
|
|
||||||
'nav.schedule': 'Calendrier',
|
|
||||||
'nav.walls': 'Murs vidéo',
|
|
||||||
'nav.reports': 'Rapports',
|
|
||||||
'nav.designer': 'Concepteur',
|
|
||||||
'nav.activity': 'Activité',
|
|
||||||
'nav.settings': 'Paramètres',
|
|
||||||
'nav.subscription': 'Abonnement',
|
|
||||||
'dashboard.title': 'Écrans',
|
|
||||||
'dashboard.subtitle': 'Gérez vos écrans distants',
|
|
||||||
'dashboard.add': 'Ajouter un écran',
|
|
||||||
'dashboard.search': 'Rechercher des écrans...',
|
|
||||||
'common.save': 'Enregistrer',
|
|
||||||
'common.cancel': 'Annuler',
|
|
||||||
'common.delete': 'Supprimer',
|
|
||||||
'common.loading': 'Chargement...',
|
|
||||||
'auth.sign_in': 'Se connecter',
|
|
||||||
'auth.create_account': 'Créer un compte',
|
|
||||||
'auth.sign_out': 'Se déconnecter',
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
'nav.displays': 'Bildschirme',
|
|
||||||
'nav.content': 'Inhalt',
|
|
||||||
'nav.layouts': 'Layouts',
|
|
||||||
'nav.widgets': 'Widgets',
|
|
||||||
'nav.schedule': 'Zeitplan',
|
|
||||||
'nav.walls': 'Videowände',
|
|
||||||
'nav.reports': 'Berichte',
|
|
||||||
'nav.designer': 'Designer',
|
|
||||||
'nav.activity': 'Aktivität',
|
|
||||||
'nav.settings': 'Einstellungen',
|
|
||||||
'nav.subscription': 'Abonnement',
|
|
||||||
'dashboard.title': 'Bildschirme',
|
|
||||||
'dashboard.subtitle': 'Verwalten Sie Ihre Remote-Displays',
|
|
||||||
'dashboard.add': 'Bildschirm hinzufügen',
|
|
||||||
'dashboard.search': 'Bildschirme suchen...',
|
|
||||||
'common.save': 'Speichern',
|
|
||||||
'common.cancel': 'Abbrechen',
|
|
||||||
'common.delete': 'Löschen',
|
|
||||||
'common.loading': 'Laden...',
|
|
||||||
'auth.sign_in': 'Anmelden',
|
|
||||||
'auth.create_account': 'Konto erstellen',
|
|
||||||
'auth.sign_out': 'Abmelden',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentLang = localStorage.getItem('rd_lang') || navigator.language?.split('-')[0] || 'en';
|
let currentLang = localStorage.getItem('rd_lang') || navigator.language?.split('-')[0] || 'en';
|
||||||
if (!translations[currentLang]) currentLang = 'en';
|
if (!registry[currentLang]) currentLang = 'en';
|
||||||
|
|
||||||
export function t(key) {
|
function lookup(key) {
|
||||||
return translations[currentLang]?.[key] || translations.en[key] || key;
|
return registry[currentLang]?.[key] ?? fallback[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace {name} placeholders in a string with the matching property of vars.
|
||||||
|
// Unknown placeholders pass through unchanged so a missing var is visible
|
||||||
|
// during development rather than silently dropped.
|
||||||
|
function format(s, vars) {
|
||||||
|
if (!vars) return s;
|
||||||
|
return s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key, vars) {
|
||||||
|
return format(lookup(key), vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plural helper: looks up `${keyBase}_one` for n===1 else `${keyBase}_other`,
|
||||||
|
// auto-injects `{n}` into vars. Use for any string that varies on a count.
|
||||||
|
export function tn(keyBase, n, vars = {}) {
|
||||||
|
const key = keyBase + (n === 1 ? '_one' : '_other');
|
||||||
|
return format(lookup(key), { n, ...vars });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers = new Set();
|
||||||
|
|
||||||
|
// Views and the navbar subscribe so they can rebuild themselves on language
|
||||||
|
// change. Also fires a `language-changed` CustomEvent and a hashchange so the
|
||||||
|
// existing hash router naturally re-renders the current view.
|
||||||
|
export function subscribe(fn) {
|
||||||
|
subscribers.add(fn);
|
||||||
|
return () => subscribers.delete(fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLanguage(lang) {
|
export function setLanguage(lang) {
|
||||||
|
if (!registry[lang] || lang === currentLang) return;
|
||||||
currentLang = lang;
|
currentLang = lang;
|
||||||
localStorage.setItem('rd_lang', lang);
|
localStorage.setItem('rd_lang', lang);
|
||||||
|
document.documentElement.setAttribute('lang', lang);
|
||||||
|
subscribers.forEach((fn) => { try { fn(lang); } catch {} });
|
||||||
|
window.dispatchEvent(new CustomEvent('language-changed', { detail: { lang } }));
|
||||||
|
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLanguage() {
|
export function getLanguage() {
|
||||||
|
|
@ -153,6 +67,15 @@ export function getAvailableLanguages() {
|
||||||
{ code: 'en', name: 'English' },
|
{ code: 'en', name: 'English' },
|
||||||
{ code: 'es', name: 'Español' },
|
{ code: 'es', name: 'Español' },
|
||||||
{ code: 'fr', name: 'Français' },
|
{ code: 'fr', name: 'Français' },
|
||||||
|
{ code: 'it', name: 'Italiano' },
|
||||||
{ code: 'de', name: 'Deutsch' },
|
{ code: 'de', name: 'Deutsch' },
|
||||||
|
{ code: 'pt', name: 'Português' },
|
||||||
|
{ code: 'hi', name: 'हिन्दी' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply the persisted language to <html lang=...> on first load so screen
|
||||||
|
// readers and CSS :lang() selectors are accurate before any user interaction.
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.setAttribute('lang', currentLang);
|
||||||
|
}
|
||||||
|
|
|
||||||
1126
frontend/js/i18n/de.js
Normal file
1126
frontend/js/i18n/de.js
Normal file
File diff suppressed because it is too large
Load diff
1403
frontend/js/i18n/en.js
Normal file
1403
frontend/js/i18n/en.js
Normal file
File diff suppressed because it is too large
Load diff
1125
frontend/js/i18n/es.js
Normal file
1125
frontend/js/i18n/es.js
Normal file
File diff suppressed because it is too large
Load diff
1126
frontend/js/i18n/fr.js
Normal file
1126
frontend/js/i18n/fr.js
Normal file
File diff suppressed because it is too large
Load diff
18
frontend/js/i18n/hi.js
Normal file
18
frontend/js/i18n/hi.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// Hindi translations — INTENTIONALLY SKELETON.
|
||||||
|
//
|
||||||
|
// We have an active user in India. Rather than ship machine-quality Hindi that
|
||||||
|
// could read as unprofessional or get formality register / gendered verbs
|
||||||
|
// wrong, this file is empty: every key falls back to English via the t()
|
||||||
|
// loader. When a native speaker reviews and fills in keys here, those keys
|
||||||
|
// take effect immediately without any code change in views.
|
||||||
|
//
|
||||||
|
// Translation guidelines for whoever fills this in:
|
||||||
|
// - Use formal आप register (this is B2B software, not consumer chat).
|
||||||
|
// - Keep technical terms in English when borrowed (Playlist, YouTube, MIME)
|
||||||
|
// — these are familiar to Indian users in their English form.
|
||||||
|
// - Translate UI verbs (Save, Cancel, etc.) into proper Hindi.
|
||||||
|
// - Test on the dashboard and content views first; those are wired to t().
|
||||||
|
//
|
||||||
|
// To add a key: copy from en.js and translate the value. Order doesn't matter;
|
||||||
|
// the loader merges over English fallback.
|
||||||
|
export default {};
|
||||||
1108
frontend/js/i18n/it.js
Normal file
1108
frontend/js/i18n/it.js
Normal file
File diff suppressed because it is too large
Load diff
1126
frontend/js/i18n/pt.js
Normal file
1126
frontend/js/i18n/pt.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,11 @@ const listeners = new Map();
|
||||||
export function connectSocket() {
|
export function connectSocket() {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
dashboardSocket = io('/dashboard', {
|
dashboardSocket = io('/dashboard', {
|
||||||
auth: { token }
|
auth: { token },
|
||||||
|
// Prefer WebSocket; fall back to polling on the same connect attempt.
|
||||||
|
// Mirrors the player-side fix in 1aee4f2 - skips the polling->WS upgrade
|
||||||
|
// dance that was causing the dashboard socket to flicker on Apply.
|
||||||
|
transports: ['websocket', 'polling']
|
||||||
});
|
});
|
||||||
|
|
||||||
dashboardSocket.on('connect', () => {
|
dashboardSocket.on('connect', () => {
|
||||||
|
|
@ -48,6 +52,22 @@ export function connectSocket() {
|
||||||
emit('playback-state', data);
|
emit('playback-state', data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Live device debug log line (device-detail screen streams these when the
|
||||||
|
// per-device "Debug logging" checkbox is on).
|
||||||
|
dashboardSocket.on('dashboard:device-log', (data) => {
|
||||||
|
emit('device-log', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Playback progress (play_start with duration — drives device-card progress bars)
|
||||||
|
dashboardSocket.on('dashboard:playback-progress', (data) => {
|
||||||
|
emit('playback-progress', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wall changed — dashboard refreshes wall cards + device-grouping layout
|
||||||
|
dashboardSocket.on('dashboard:wall-changed', () => {
|
||||||
|
emit('wall-changed');
|
||||||
|
});
|
||||||
|
|
||||||
// Content ack
|
// Content ack
|
||||||
dashboardSocket.on('dashboard:content-ack', (data) => {
|
dashboardSocket.on('dashboard:content-ack', (data) => {
|
||||||
emit('content-ack', data);
|
emit('content-ack', data);
|
||||||
|
|
@ -109,8 +129,20 @@ export function sendKey(deviceId, keycode) {
|
||||||
if (dashboardSocket) dashboardSocket.emit('dashboard:remote-key', { device_id: deviceId, keycode });
|
if (dashboardSocket) dashboardSocket.emit('dashboard:remote-key', { device_id: deviceId, keycode });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendCommand(deviceId, type, payload) {
|
// Optional callback receives the server-side ack: { delivered, queued, reason }.
|
||||||
if (dashboardSocket) dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload });
|
// Callers without a callback keep firing-and-forgetting (no behavior change).
|
||||||
|
// With a callback, we use Socket.IO's .timeout() so the callback always fires -
|
||||||
|
// either with the ack or with an Error if the server doesn't respond in 5s.
|
||||||
|
export function sendCommand(deviceId, type, payload, callback) {
|
||||||
|
if (!dashboardSocket) return;
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
dashboardSocket.timeout(5000).emit('dashboard:device-command', { device_id: deviceId, type, payload }, (err, ack) => {
|
||||||
|
if (err) callback({ delivered: false, reason: 'no_ack' });
|
||||||
|
else callback(ack || { delivered: false, reason: 'no_ack' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSocket() { return dashboardSocket; }
|
export function getSocket() { return dashboardSocket; }
|
||||||
|
|
|
||||||
13
frontend/js/utils.js
Normal file
13
frontend/js/utils.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// HTML escape helper — prevents XSS when inserting user data into innerHTML
|
||||||
|
export function esc(str) {
|
||||||
|
if (str == null) return '';
|
||||||
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2.1: the Phase 1 schema migration renamed the legacy 'superadmin'
|
||||||
|
// role to 'platform_admin'. Existing frontend checks still match the old
|
||||||
|
// string; this helper accepts both so we don't have to splatter the array
|
||||||
|
// at every call site. Use everywhere the UI gates on platform-level access.
|
||||||
|
export function isPlatformAdmin(user) {
|
||||||
|
return !!(user && (user.role === 'superadmin' || user.role === 'platform_admin'));
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
|
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
|
||||||
|
|
||||||
export async function render(container) {
|
export async function render(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>Activity Log</h1><div class="subtitle">Audit trail of all actions</div></div>
|
<div><h1>${t('activity.title')}</h1><div class="subtitle">${t('activity.subtitle')}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="activityList"><div class="empty-state"><h3>Loading...</h3></div></div>
|
<div id="activityList"><div class="empty-state"><h3>${t('common.loading')}</h3></div></div>
|
||||||
<div style="text-align:center;margin-top:16px">
|
<div style="text-align:center;margin-top:16px">
|
||||||
<button class="btn btn-secondary btn-sm" id="loadMoreBtn" style="display:none">Load More</button>
|
<button class="btn btn-secondary btn-sm" id="loadMoreBtn" style="display:none">${t('activity.load_more')}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -24,14 +26,14 @@ export async function render(container) {
|
||||||
if (!append) list.innerHTML = '';
|
if (!append) list.innerHTML = '';
|
||||||
|
|
||||||
if (items.length === 0 && offset === 0) {
|
if (items.length === 0 && offset === 0) {
|
||||||
list.innerHTML = '<div class="empty-state"><h3>No activity yet</h3><p>Actions will appear here as you use the system.</p></div>';
|
list.innerHTML = `<div class="empty-state"><h3>${t('activity.empty_title')}</h3><p>${t('activity.empty_desc')}</p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = items.map(item => {
|
const html = items.map(item => {
|
||||||
const time = new Date(item.created_at * 1000);
|
const time = new Date(item.created_at * 1000);
|
||||||
const timeStr = time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
|
const timeStr = time.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
|
||||||
time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
const icon = getActionIcon(item.action);
|
const icon = getActionIcon(item.action);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
|
@ -39,10 +41,10 @@ export async function render(container) {
|
||||||
<div style="width:32px;height:32px;border-radius:50%;background:var(--bg-card);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:14px">${icon}</div>
|
<div style="width:32px;height:32px;border-radius:50%;background:var(--bg-card);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:14px">${icon}</div>
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-size:13px">
|
<div style="font-size:13px">
|
||||||
<strong>${item.user_name || item.user_email || 'System'}</strong>
|
<strong>${esc(item.user_name || item.user_email || t('activity.system'))}</strong>
|
||||||
<span style="color:var(--text-secondary)"> ${formatAction(item.action)}</span>
|
<span style="color:var(--text-secondary)"> ${esc(formatAction(item.action))}</span>
|
||||||
</div>
|
</div>
|
||||||
${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${item.details}</div>` : ''}
|
${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(item.details)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:11px;color:var(--text-muted);white-space:nowrap;flex-shrink:0">${timeStr}</div>
|
<div style="font-size:11px;color:var(--text-muted);white-space:nowrap;flex-shrink:0">${timeStr}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -80,22 +82,29 @@ function getActionIcon(action) {
|
||||||
return '📄';
|
return '📄';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action verbs are user-visible; translate them through t() so they switch
|
||||||
|
// languages with the rest of the UI. The mapping below preserves the original
|
||||||
|
// verb-then-noun structure of the English version.
|
||||||
function formatAction(action) {
|
function formatAction(action) {
|
||||||
return action
|
// Verbs
|
||||||
.replace('POST /api/', 'created ')
|
let s = action
|
||||||
.replace('PUT /api/', 'updated ')
|
.replace('POST /api/', t('activity.verb_created') + ' ')
|
||||||
.replace('DELETE /api/', 'deleted ')
|
.replace('PUT /api/', t('activity.verb_updated') + ' ')
|
||||||
.replace('/provision/pair', 'paired a device')
|
.replace('DELETE /api/', t('activity.verb_deleted') + ' ');
|
||||||
.replace('/content/remote', 'added remote content')
|
// Specific endpoints
|
||||||
.replace('/content', 'content')
|
s = s
|
||||||
.replace('/devices/:id', 'device')
|
.replace('/provision/pair', t('activity.action_paired_device'))
|
||||||
.replace('/assignments/device/:deviceId', 'playlist assignment')
|
.replace('/content/remote', t('activity.action_added_remote_content'))
|
||||||
.replace('/assignments/:id', 'assignment')
|
.replace('/content', t('activity.noun_content'))
|
||||||
.replace('/layouts', 'layout')
|
.replace('/devices/:id', t('activity.noun_device'))
|
||||||
.replace('/widgets', 'widget')
|
.replace('/assignments/device/:deviceId', t('activity.noun_playlist_assignment'))
|
||||||
.replace('/schedules', 'schedule')
|
.replace('/assignments/:id', t('activity.noun_assignment'))
|
||||||
.replace('/walls', 'video wall')
|
.replace('/layouts', t('activity.noun_layout'))
|
||||||
.replace('alert:device_offline', 'alert: device went offline');
|
.replace('/widgets', t('activity.noun_widget'))
|
||||||
|
.replace('/schedules', t('activity.noun_schedule'))
|
||||||
|
.replace('/walls', t('activity.noun_video_wall'))
|
||||||
|
.replace('alert:device_offline', t('activity.alert_device_offline'));
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanup() {}
|
export function cleanup() {}
|
||||||
|
|
|
||||||
336
frontend/js/views/admin-player-debug.js
Normal file
336
frontend/js/views/admin-player-debug.js
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
// Admin view for the player_debug_logs telemetry sink. Platform-admin only.
|
||||||
|
// Mounted at #/admin/player-debug. Reads from /api/player-debug/list,
|
||||||
|
// /api/player-debug/summary, /api/player-debug/older-than (DELETE).
|
||||||
|
//
|
||||||
|
// Server-side pagination - we never render all 10k rows at once. Page param
|
||||||
|
// in the URL hash so refresh preserves position.
|
||||||
|
//
|
||||||
|
// IMPORTANT: device_id is whatever the player POSTed. The submitter is
|
||||||
|
// unauthenticated by design (so unpaired players can also send), which means
|
||||||
|
// device_id is self-reported, NOT server-verified. Surfaced via column label
|
||||||
|
// "device_id (self-reported)" and the help-text caption below the filters.
|
||||||
|
|
||||||
|
import { isPlatformAdmin } from '../utils.js';
|
||||||
|
import { showToast } from '../components/toast.js';
|
||||||
|
|
||||||
|
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
|
||||||
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts });
|
||||||
|
|
||||||
|
// Parse a query string from a hash like '#/admin/player-debug?page=2&ua=Tizen'.
|
||||||
|
// Returns a plain object - no URLSearchParams since the hash format isn't
|
||||||
|
// a standard URL.
|
||||||
|
function parseHashParams() {
|
||||||
|
const h = window.location.hash || '';
|
||||||
|
const qi = h.indexOf('?');
|
||||||
|
if (qi < 0) return {};
|
||||||
|
const out = {};
|
||||||
|
const qs = h.substring(qi + 1);
|
||||||
|
for (const part of qs.split('&')) {
|
||||||
|
if (!part) continue;
|
||||||
|
const eq = part.indexOf('=');
|
||||||
|
const k = eq >= 0 ? part.substring(0, eq) : part;
|
||||||
|
const v = eq >= 0 ? part.substring(eq + 1) : '';
|
||||||
|
try { out[decodeURIComponent(k)] = decodeURIComponent(v); } catch { out[k] = v; }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHashParams(updates) {
|
||||||
|
const base = '#/admin/player-debug';
|
||||||
|
const merged = { ...parseHashParams(), ...updates };
|
||||||
|
// Strip empty values so the URL stays tidy
|
||||||
|
const pairs = [];
|
||||||
|
for (const [k, v] of Object.entries(merged)) {
|
||||||
|
if (v == null || v === '') continue;
|
||||||
|
pairs.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
|
||||||
|
}
|
||||||
|
// Replace, don't push - we don't want every filter keystroke in browser history
|
||||||
|
history.replaceState(null, '', pairs.length ? base + '?' + pairs.join('&') : base);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pretty-print JSON for the expanded-row display. Returns the original string
|
||||||
|
// if parsing fails so we don't lose data when the field isn't JSON-shaped.
|
||||||
|
function prettyJson(s) {
|
||||||
|
if (s == null || s === '') return '(empty)';
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(s), null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(unixSec) {
|
||||||
|
if (!unixSec) return '';
|
||||||
|
try { return new Date(unixSec * 1000).toLocaleString(); } catch { return String(unixSec); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function uaShort(ua) {
|
||||||
|
if (!ua) return '';
|
||||||
|
// Keep just the part most useful for at-a-glance scanning. Full UA in the
|
||||||
|
// expanded row.
|
||||||
|
return ua.length > 60 ? ua.substring(0, 60) + '...' : ua;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function render(container) {
|
||||||
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
|
if (!isPlatformAdmin(user)) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><h3>Access denied</h3><p>Platform-admin role required.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = parseHashParams();
|
||||||
|
const currentPage = parseInt(params.page) || 1;
|
||||||
|
const currentUa = params.ua || '';
|
||||||
|
const currentSince = params.since || '';
|
||||||
|
const currentUntil = params.until || '';
|
||||||
|
const currentHasError = params.has_error === '1';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Player Debug Logs</h1>
|
||||||
|
<div class="subtitle">Captured errors and state from player clients. Mostly smart TVs we can't reach with devtools.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Summary</h3>
|
||||||
|
<div id="pdSummary" style="display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--text-secondary)">Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Filters</h3>
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">User agent contains</label>
|
||||||
|
<input class="input" id="pdFilterUa" value="${esc(currentUa)}" placeholder="Tizen, WebOS, AFTS..." style="width:220px">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">Since (YYYY-MM-DD)</label>
|
||||||
|
<input class="input" id="pdFilterSince" value="${esc(currentSince)}" placeholder="2026-05-01" style="width:140px">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">Until (YYYY-MM-DD)</label>
|
||||||
|
<input class="input" id="pdFilterUntil" value="${esc(currentUntil)}" placeholder="2026-05-31" style="width:140px">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="pdFilterHasError" ${currentHasError ? 'checked' : ''}> Has error data
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="pdApplyFilters">Apply</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="pdClearFilters">Clear</button>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn btn-danger btn-sm" id="pdDeleteOld">Delete older than 30 days</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);margin-top:10px">
|
||||||
|
Note: <code>device_id</code> is self-reported by the player and is not server-verified. The submission endpoint is unauthenticated by design so unpaired players can also report errors.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Logs <span id="pdRowMeta" style="font-size:13px;color:var(--text-muted);font-weight:400"></span></h3>
|
||||||
|
<div id="pdList"><p style="color:var(--text-muted)">Loading...</p></div>
|
||||||
|
<div id="pdPagination" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:14px"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---- handlers ----
|
||||||
|
document.getElementById('pdApplyFilters').onclick = () => {
|
||||||
|
const ua = document.getElementById('pdFilterUa').value.trim();
|
||||||
|
const since = document.getElementById('pdFilterSince').value.trim();
|
||||||
|
const until = document.getElementById('pdFilterUntil').value.trim();
|
||||||
|
const hasError = document.getElementById('pdFilterHasError').checked ? '1' : '';
|
||||||
|
setHashParams({ page: 1, ua, since, until, has_error: hasError });
|
||||||
|
loadList();
|
||||||
|
};
|
||||||
|
document.getElementById('pdClearFilters').onclick = () => {
|
||||||
|
document.getElementById('pdFilterUa').value = '';
|
||||||
|
document.getElementById('pdFilterSince').value = '';
|
||||||
|
document.getElementById('pdFilterUntil').value = '';
|
||||||
|
document.getElementById('pdFilterHasError').checked = false;
|
||||||
|
setHashParams({ page: 1, ua: '', since: '', until: '', has_error: '' });
|
||||||
|
loadList();
|
||||||
|
};
|
||||||
|
document.getElementById('pdDeleteOld').onclick = async () => {
|
||||||
|
if (!confirm('Delete all logs older than 30 days? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
const res = await API('/player-debug/older-than?days=30', { method: 'DELETE' });
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(`Deleted ${data.deleted} log${data.deleted === 1 ? '' : 's'} older than 30 days`, 'success');
|
||||||
|
loadSummary();
|
||||||
|
loadList();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Delete failed: ' + (err.message || err), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSummary();
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
const el = document.getElementById('pdSummary');
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
const res = await API('/player-debug/summary');
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
const families = [
|
||||||
|
['Tizen', data.byFamily.tizen, '#3b82f6'],
|
||||||
|
['WebOS', data.byFamily.webos, '#a3e635'],
|
||||||
|
['Fire TV', data.byFamily.fire_tv, '#f97316'],
|
||||||
|
['Bravia', data.byFamily.bravia, '#a855f7'],
|
||||||
|
['Edge', data.byFamily.edge, '#06b6d4'],
|
||||||
|
['Chrome', data.byFamily.chrome, '#fbbf24'],
|
||||||
|
['Firefox', data.byFamily.firefox, '#ef4444'],
|
||||||
|
['Safari', data.byFamily.safari, '#64748b'],
|
||||||
|
['Other', data.byFamily.other, '#94a3b8'],
|
||||||
|
];
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="font-weight:600;color:var(--text-primary)">Total: ${data.total}</div>
|
||||||
|
${families.map(([name, count, color]) => `
|
||||||
|
<div style="display:flex;align-items:center;gap:6px">
|
||||||
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${color}"></span>
|
||||||
|
<span>${name}: <strong style="color:var(--text-primary)">${count}</strong></span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
`;
|
||||||
|
} catch (err) {
|
||||||
|
el.innerHTML = '<span style="color:var(--danger)">Failed to load summary: ' + esc(err.message || err) + '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ymdToUnix(s, endOfDay) {
|
||||||
|
if (!s) return '';
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||||
|
if (!m) return '';
|
||||||
|
const [, y, mo, d] = m;
|
||||||
|
const dt = new Date(Date.UTC(+y, +mo - 1, +d, endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0));
|
||||||
|
return Math.floor(dt.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
const el = document.getElementById('pdList');
|
||||||
|
const meta = document.getElementById('pdRowMeta');
|
||||||
|
const pag = document.getElementById('pdPagination');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = '<p style="color:var(--text-muted)">Loading...</p>';
|
||||||
|
|
||||||
|
const params = parseHashParams();
|
||||||
|
const page = Math.max(1, parseInt(params.page) || 1);
|
||||||
|
const limit = 50;
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('page', page);
|
||||||
|
qs.set('limit', limit);
|
||||||
|
if (params.ua) qs.set('ua_contains', params.ua);
|
||||||
|
const since = ymdToUnix(params.since, false);
|
||||||
|
const until = ymdToUnix(params.until, true);
|
||||||
|
if (since) qs.set('since', since);
|
||||||
|
if (until) qs.set('until', until);
|
||||||
|
if (params.has_error === '1') qs.set('has_error', '1');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await API('/player-debug/list?' + qs.toString());
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
const totalPages = Math.max(1, Math.ceil(data.total / data.limit));
|
||||||
|
meta.textContent = `(${data.total} total, page ${data.page} of ${totalPages})`;
|
||||||
|
|
||||||
|
if (data.rows.length === 0) {
|
||||||
|
el.innerHTML = '<p style="color:var(--text-muted);padding:14px 0">No logs match the current filters.</p>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:960px">
|
||||||
|
<thead><tr style="border-bottom:1px solid var(--border);text-align:left">
|
||||||
|
<th style="padding:8px;width:50px">ID</th>
|
||||||
|
<th style="padding:8px;width:140px">Time</th>
|
||||||
|
<th style="padding:8px;width:180px" title="Self-reported by the player; not server-verified.">device_id (self-reported)</th>
|
||||||
|
<th style="padding:8px;width:130px">IP</th>
|
||||||
|
<th style="padding:8px">User agent</th>
|
||||||
|
<th style="padding:8px;width:130px">Fingerprint</th>
|
||||||
|
<th style="padding:8px;width:80px"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${data.rows.map(r => `
|
||||||
|
<tr style="border-bottom:1px solid var(--border-light)" data-row-id="${r.id}">
|
||||||
|
<td style="padding:8px;font-family:monospace;color:var(--text-muted)">${r.id}</td>
|
||||||
|
<td style="padding:8px;font-size:12px">${esc(fmtTime(r.created_at))}</td>
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;color:var(--text-secondary)">${esc(r.device_id || '(none)')}</td>
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:12px;color:var(--text-secondary)">${esc(r.ip || '')}</td>
|
||||||
|
<td style="padding:8px;font-size:12px;color:var(--text-secondary)">${esc(uaShort(r.user_agent))}</td>
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;color:var(--text-muted)">${esc(r.error_fingerprint || '')}</td>
|
||||||
|
<td style="padding:8px;text-align:right">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-expand="${r.id}" style="font-size:11px;padding:2px 8px">Expand</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="display:none" data-expanded-for="${r.id}">
|
||||||
|
<td colspan="7" style="padding:12px 16px;background:var(--bg-input)">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">URL</div>
|
||||||
|
<div style="font-family:monospace;font-size:11px;color:var(--text-secondary);word-break:break-all;margin-bottom:10px">${esc(r.url || '(none)')}</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">Full User Agent</div>
|
||||||
|
<div style="font-family:monospace;font-size:11px;color:var(--text-secondary);word-break:break-all;margin-bottom:10px">${esc(r.user_agent || '(none)')}</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">error_data</div>
|
||||||
|
<pre style="margin:0;padding:8px;background:var(--bg-primary);border-radius:4px;font-family:monospace;font-size:11px;color:var(--text-secondary);overflow:auto;max-height:300px;white-space:pre-wrap;word-break:break-word">${esc(prettyJson(r.error_data))}</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">context</div>
|
||||||
|
<pre style="margin:0;padding:8px;background:var(--bg-primary);border-radius:4px;font-family:monospace;font-size:11px;color:var(--text-secondary);overflow:auto;max-height:420px;white-space:pre-wrap;word-break:break-word">${esc(prettyJson(r.context))}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
el.querySelectorAll('button[data-expand]').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const id = btn.getAttribute('data-expand');
|
||||||
|
const exp = el.querySelector(`tr[data-expanded-for="${id}"]`);
|
||||||
|
if (exp) {
|
||||||
|
const visible = exp.style.display !== 'none';
|
||||||
|
exp.style.display = visible ? 'none' : '';
|
||||||
|
btn.textContent = visible ? 'Expand' : 'Collapse';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pagination ----
|
||||||
|
pag.innerHTML = '';
|
||||||
|
if (totalPages > 1) {
|
||||||
|
const prev = document.createElement('button');
|
||||||
|
prev.className = 'btn btn-secondary btn-sm';
|
||||||
|
prev.textContent = '< Prev';
|
||||||
|
prev.disabled = page <= 1;
|
||||||
|
prev.onclick = () => { setHashParams({ page: page - 1 }); loadList(); };
|
||||||
|
pag.appendChild(prev);
|
||||||
|
|
||||||
|
const indicator = document.createElement('span');
|
||||||
|
indicator.style.cssText = 'padding:0 12px;font-size:13px;color:var(--text-muted)';
|
||||||
|
indicator.textContent = `Page ${page} of ${totalPages}`;
|
||||||
|
pag.appendChild(indicator);
|
||||||
|
|
||||||
|
const next = document.createElement('button');
|
||||||
|
next.className = 'btn btn-secondary btn-sm';
|
||||||
|
next.textContent = 'Next >';
|
||||||
|
next.disabled = page >= totalPages;
|
||||||
|
next.onclick = () => { setHashParams({ page: page + 1 }); loadList(); };
|
||||||
|
pag.appendChild(next);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
el.innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(err.message || err) + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,123 +1,344 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc, isPlatformAdmin } from '../utils.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
import { openAddUserModal } from '../components/workspace-members-add-user-modal.js';
|
||||||
|
import { openManageWorkspacesModal } from '../components/admin-user-workspaces-modal.js';
|
||||||
|
import { openCreateOrgModal } from '../components/admin-create-org-modal.js';
|
||||||
|
import { openTypeToConfirmModal } from '../components/type-to-confirm-modal.js';
|
||||||
|
// Reuse the members view's server-error -> friendly-string mapper (handles the
|
||||||
|
// 409 duplicate-email / weak-password / invalid-email cases) so we don't fork a
|
||||||
|
// second mapper.
|
||||||
|
import { mapMutationError } from './workspace-members.js';
|
||||||
|
|
||||||
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
|
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
|
||||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
|
||||||
|
|
||||||
|
// #14: the platform user-management dropdown manages users.role (the
|
||||||
|
// PLATFORM-level role) only - workspace/org roles are managed in the members
|
||||||
|
// views. Options are the current model; the legacy 'admin'/'superadmin' strings
|
||||||
|
// were normalized away. #13 adds 'platform_operator' (cross-org staff).
|
||||||
|
const PLATFORM_ROLE_OPTIONS = ['user', 'platform_operator', 'platform_admin'];
|
||||||
|
|
||||||
|
// Platform staff have cross-org access (no single workspace), so the Workspace
|
||||||
|
// column shows read-only "Platform (all)" for them. Note utils.isPlatformAdmin
|
||||||
|
// only covers admin/superadmin; operators are staff here too.
|
||||||
|
function isPlatformStaffRole(role) {
|
||||||
|
return role === 'platform_admin' || role === 'superadmin' || role === 'platform_operator';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short summary of a user's workspace membership for the Users-table cell.
|
||||||
|
// Platform staff have cross-org access (not per-workspace membership) -> "Platform
|
||||||
|
// (all)". Otherwise: Unassigned (0), the workspace name (1), or "N workspaces".
|
||||||
|
function workspaceSummary(u) {
|
||||||
|
if (isPlatformStaffRole(u.role)) return t('admin.workspace.platform_all');
|
||||||
|
const count = u.workspace_count || 0;
|
||||||
|
if (count === 0) return t('admin.workspace.unassigned');
|
||||||
|
if (count === 1) return esc(u.workspace_name || '');
|
||||||
|
return t('admin.workspace.multi', { n: count });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace cell: a summary + a "Manage" button that opens the full membership
|
||||||
|
// modal (add/remove workspaces, set per-workspace role). Manage is offered for
|
||||||
|
// everyone, including staff (you can grant them explicit memberships too).
|
||||||
|
function workspaceCell(u) {
|
||||||
|
return `<td style="padding:8px">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<span style="color:var(--text-muted);font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${workspaceSummary(u)}</span>
|
||||||
|
<button class="btn btn-secondary btn-sm" type="button" data-ws-manage="${esc(u.id)}">${t('admin.workspace.manage')}</button>
|
||||||
|
</div>
|
||||||
|
</td>`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function render(container) {
|
export async function render(container) {
|
||||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
if (user.role !== 'superadmin') {
|
if (!isPlatformAdmin(user)) {
|
||||||
container.innerHTML = '<div class="empty-state"><h3>Access Denied</h3><p>Platform admin access required.</p></div>';
|
container.innerHTML = `<div class="empty-state"><h3>${t('admin.access_denied')}</h3><p>${t('admin.access_denied_desc')}</p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>Platform Admin</h1><div class="subtitle">Superadmin controls - only you can see this</div></div>
|
<div><h1>${t('admin.title')}</h1><div class="subtitle">${t('admin.subtitle')}</div></div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="btn btn-secondary" id="adminCreateOrgBtn">${t('admin.create_org.button')}</button>
|
||||||
|
<button class="btn btn-primary" id="adminAddUserBtn">${t('admin.add_user')}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- All Users -->
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>All Users</h3>
|
<h3>${t('admin.all_users')}</h3>
|
||||||
<div id="allUsersTable"><p style="color:var(--text-muted)">Loading...</p></div>
|
<div id="allUsersTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plan Management -->
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Subscription Plans</h3>
|
<h3>${t('admin.orgs.title')}</h3>
|
||||||
<div id="plansTable"><p style="color:var(--text-muted)">Loading...</p></div>
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.orgs.desc')}</p>
|
||||||
|
<div id="orgsTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Info -->
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>System</h3>
|
<h3>${t('admin.branding.title')}</h3>
|
||||||
<div id="systemInfo"><p style="color:var(--text-muted)">Loading...</p></div>
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.branding.desc')}</p>
|
||||||
|
<div id="brandingForm"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>${t('admin.plans')}</h3>
|
||||||
|
<div id="plansTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>${t('admin.system')}</h3>
|
||||||
|
<div id="systemInfo"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Add User (#10): platform admin provisions a user into ANY workspace. The
|
||||||
|
// page is platform_admin-gated; the modal opens in picker mode (no fixed
|
||||||
|
// workspace) so the admin chooses the target org/workspace. The endpoint
|
||||||
|
// additionally enforces canAdminWorkspace (platform_admin passes everywhere).
|
||||||
|
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
|
||||||
|
openAddUserModal(null, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
showToast(t('members.success.user_created', { email: result.email }), 'success');
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
},
|
||||||
|
mapError: mapMutationError,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Organization (#35): platform admin provisions a new customer org +
|
||||||
|
// its first workspace (owned by the admin). The modal reloads on success so
|
||||||
|
// the new org shows up in the switcher.
|
||||||
|
document.getElementById('adminCreateOrgBtn')?.addEventListener('click', () => {
|
||||||
|
openCreateOrgModal({
|
||||||
|
onSuccess: (result) => showToast(t('admin.create_org.success', { name: result.name }), 'success'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadUsers();
|
||||||
|
loadOrgs();
|
||||||
|
loadBranding();
|
||||||
loadPlans();
|
loadPlans();
|
||||||
loadSystem();
|
loadSystem();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #36: list organizations with owner + resource counts; platform admin can
|
||||||
|
// cascade-delete an org or an individual workspace (type-the-name confirm).
|
||||||
|
async function loadOrgs() {
|
||||||
|
const el = document.getElementById('orgsTable');
|
||||||
|
if (!el) return;
|
||||||
|
let orgs;
|
||||||
|
try {
|
||||||
|
orgs = await api.adminListOrgs();
|
||||||
|
} catch (err) {
|
||||||
|
el.innerHTML = `<p style="color:var(--danger)">${esc(err.message || 'Failed to load organizations')}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!orgs.length) {
|
||||||
|
el.innerHTML = `<p style="color:var(--text-muted)">${t('admin.orgs.empty')}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = orgs.map(o => {
|
||||||
|
const wsRows = (o.workspaces || []).map(w => `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-top:1px solid var(--border)">
|
||||||
|
<div style="font-size:13px">${esc(w.name)}
|
||||||
|
<span style="color:var(--text-muted);font-size:11px">· ${w.device_count} ${t('admin.orgs.devices')} · ${w.member_count} ${t('admin.orgs.members')}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-danger btn-sm" data-del-ws="${esc(w.id)}" data-ws-name="${esc(w.name)}">${t('admin.orgs.delete_ws')}</button>
|
||||||
|
</div>`).join('');
|
||||||
|
return `
|
||||||
|
<div style="border:1px solid var(--border);border-radius:var(--radius);margin-bottom:10px">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg-secondary)">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600">${esc(o.name)}</div>
|
||||||
|
<div style="color:var(--text-muted);font-size:11px">
|
||||||
|
${t('admin.orgs.owner')}: ${esc(o.owner_email || '—')} ·
|
||||||
|
${o.workspace_count} ${t('admin.orgs.workspaces')} · ${o.device_count} ${t('admin.orgs.devices')} · ${o.member_count} ${t('admin.orgs.members')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-danger btn-sm" data-del-org="${esc(o.id)}" data-org-name="${esc(o.name)}">${t('admin.orgs.delete_org')}</button>
|
||||||
|
</div>
|
||||||
|
${wsRows}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
el.querySelectorAll('[data-del-org]').forEach(btn => btn.addEventListener('click', () => {
|
||||||
|
const id = btn.dataset.delOrg, name = btn.dataset.orgName;
|
||||||
|
openTypeToConfirmModal({
|
||||||
|
title: t('admin.orgs.delete_org_title'),
|
||||||
|
body: t('admin.orgs.delete_org_body', { name: esc(name) }),
|
||||||
|
expected: name,
|
||||||
|
confirmLabel: t('admin.orgs.delete_org'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
await api.adminDeleteOrg(id);
|
||||||
|
showToast(t('admin.orgs.org_deleted', { name }), 'success');
|
||||||
|
loadOrgs(); loadUsers();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
el.querySelectorAll('[data-del-ws]').forEach(btn => btn.addEventListener('click', () => {
|
||||||
|
const id = btn.dataset.delWs, name = btn.dataset.wsName;
|
||||||
|
openTypeToConfirmModal({
|
||||||
|
title: t('admin.orgs.delete_ws_title'),
|
||||||
|
body: t('admin.orgs.delete_ws_body', { name: esc(name) }),
|
||||||
|
expected: name,
|
||||||
|
confirmLabel: t('admin.orgs.delete_ws'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
await api.adminDeleteWorkspace(id);
|
||||||
|
showToast(t('admin.orgs.ws_deleted', { name }), 'success');
|
||||||
|
loadOrgs();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// #15: instance-level default branding form (platform default; every workspace
|
||||||
|
// without its own white-label inherits this, as does the login page).
|
||||||
|
async function loadBranding() {
|
||||||
|
const el = document.getElementById('brandingForm');
|
||||||
|
if (!el) return;
|
||||||
|
let b = {};
|
||||||
|
try { b = await api.adminGetBranding(); } catch (e) { el.innerHTML = `<p style="color:var(--danger)">${esc(e.message || 'Failed to load')}</p>`; return; }
|
||||||
|
const v = (x) => esc(x == null ? '' : x);
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
|
||||||
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.brand_name')}</label><input type="text" id="brBrandName" class="input" placeholder="ScreenTinker" value="${v(b.brand_name)}"></div>
|
||||||
|
<div class="form-group"><label>${t('admin.branding.primary_color')}</label><input type="text" id="brPrimary" class="input" placeholder="#3B82F6" value="${v(b.primary_color)}"></div>
|
||||||
|
<div class="form-group"><label>${t('admin.branding.bg_color')}</label><input type="text" id="brBg" class="input" placeholder="#111827" value="${v(b.bg_color)}"></div>
|
||||||
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.logo_url')}</label><input type="text" id="brLogo" class="input" placeholder="https://…/logo.png" value="${v(b.logo_url)}"></div>
|
||||||
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.favicon_url')}</label><input type="text" id="brFavicon" class="input" placeholder="https://…/favicon.ico" value="${v(b.favicon_url)}"></div>
|
||||||
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.custom_css')}</label><textarea id="brCss" class="input" rows="3" placeholder="/* optional */">${v(b.custom_css)}</textarea></div>
|
||||||
|
<label style="grid-column:1/-1;display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="brHide" ${b.hide_branding ? 'checked' : ''}> ${t('admin.branding.hide_branding')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="brSave" style="margin-top:12px">${t('admin.branding.save')}</button>
|
||||||
|
`;
|
||||||
|
document.getElementById('brSave').onclick = async () => {
|
||||||
|
try {
|
||||||
|
await api.adminSetBranding({
|
||||||
|
brand_name: document.getElementById('brBrandName').value.trim() || 'ScreenTinker',
|
||||||
|
primary_color: document.getElementById('brPrimary').value.trim() || null,
|
||||||
|
bg_color: document.getElementById('brBg').value.trim() || null,
|
||||||
|
logo_url: document.getElementById('brLogo').value.trim() || null,
|
||||||
|
favicon_url: document.getElementById('brFavicon').value.trim() || null,
|
||||||
|
custom_css: document.getElementById('brCss').value.trim() || null,
|
||||||
|
hide_branding: document.getElementById('brHide').checked,
|
||||||
|
});
|
||||||
|
showToast(t('admin.branding.saved'), 'success');
|
||||||
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
const el = document.getElementById('allUsersTable');
|
const el = document.getElementById('allUsersTable');
|
||||||
try {
|
try {
|
||||||
const [users, plans] = await Promise.all([API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json())]);
|
const [users, plans] = await Promise.all([
|
||||||
|
API('/auth/users'),
|
||||||
|
fetch('/api/subscription/plans').then(r => r.json()),
|
||||||
|
]);
|
||||||
|
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
<div class="table-wrap">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:720px">
|
||||||
<thead><tr style="border-bottom:1px solid var(--border)">
|
<thead><tr style="border-bottom:1px solid var(--border)">
|
||||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">User</th>
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.user')}</th>
|
||||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">Auth</th>
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.auth')}</th>
|
||||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">Last Login</th>
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.last_login')}</th>
|
||||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">Role</th>
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.role')}</th>
|
||||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">Plan</th>
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.plan')}</th>
|
||||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">Actions</th>
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.workspace')}</th>
|
||||||
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.actions')}</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${users.map(u => `
|
${users.map(u => `
|
||||||
<tr style="border-bottom:1px solid var(--border)">
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
<td style="padding:8px"><div style="font-weight:500">${u.name || u.email}</div><div style="font-size:11px;color:var(--text-muted)">${u.email}</div></td>
|
<td style="padding:8px"><div style="font-weight:500">${u.name || u.email}</div><div style="font-size:11px;color:var(--text-muted)">${u.email}</div></td>
|
||||||
<td style="padding:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span></td>
|
<td style="padding:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span></td>
|
||||||
<td style="padding:8px;font-size:11px;color:var(--text-muted)">${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : 'Never'}</td>
|
<td style="padding:8px;font-size:11px;color:var(--text-muted)">${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')}</td>
|
||||||
<td style="padding:8px">
|
<td style="padding:8px">
|
||||||
<select class="input" style="width:120px;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}">
|
<select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}">
|
||||||
<option value="user" ${u.role === 'user' ? 'selected' : ''}>User</option>
|
${PLATFORM_ROLE_OPTIONS.map(r => `<option value="${r}" ${u.role === r ? 'selected' : ''}>${t('admin.role.' + r)}</option>`).join('')}
|
||||||
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
|
|
||||||
<option value="superadmin" ${u.role === 'superadmin' ? 'selected' : ''}>Superadmin</option>
|
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:8px">
|
<td style="padding:8px">
|
||||||
<select class="input" style="width:130px;background:var(--bg-input);font-size:12px;padding:4px" data-plan-user="${u.id}">
|
<select class="input" style="max-width:130px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-plan-user="${u.id}">
|
||||||
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
|
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:8px">
|
${workspaceCell(u)}
|
||||||
${u.role !== 'superadmin' ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">Remove</button>` : '<span style="color:var(--text-muted);font-size:11px">Owner</span>'}
|
<td style="padding:8px;white-space:nowrap">
|
||||||
|
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
|
||||||
|
${!isPlatformAdmin(u) ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p style="color:var(--text-muted);font-size:11px;margin-top:8px">${users.length} total users</p>
|
</div>
|
||||||
|
<p style="color:var(--text-muted);font-size:11px;margin-top:8px">${t('admin.total_users', { n: users.length })}</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Role change
|
|
||||||
el.querySelectorAll('[data-role-user]').forEach(select => {
|
el.querySelectorAll('[data-role-user]').forEach(select => {
|
||||||
select.onchange = async () => {
|
select.onchange = async () => {
|
||||||
try {
|
try {
|
||||||
await API(`/auth/users/${select.dataset.roleUser}/role`, { method: 'PUT', body: JSON.stringify({ role: select.value }) });
|
await API(`/auth/users/${select.dataset.roleUser}/role`, { method: 'PUT', body: JSON.stringify({ role: select.value }) });
|
||||||
showToast('Role updated', 'success');
|
showToast(t('admin.toast.role_updated'), 'success');
|
||||||
} catch (err) { showToast(err.message, 'error'); loadUsers(); }
|
} catch (err) { showToast(err.message, 'error'); loadUsers(); }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Plan change
|
|
||||||
el.querySelectorAll('[data-plan-user]').forEach(select => {
|
el.querySelectorAll('[data-plan-user]').forEach(select => {
|
||||||
select.onchange = async () => {
|
select.onchange = async () => {
|
||||||
try {
|
try {
|
||||||
await API('/subscription/assign', { method: 'POST', body: JSON.stringify({ user_id: select.dataset.planUser, plan_id: select.value }) });
|
await API('/subscription/assign', { method: 'POST', body: JSON.stringify({ user_id: select.dataset.planUser, plan_id: select.value }) });
|
||||||
showToast('Plan updated', 'success');
|
showToast(t('admin.toast.plan_updated'), 'success');
|
||||||
} catch (err) { showToast(err.message, 'error'); loadUsers(); }
|
} catch (err) { showToast(err.message, 'error'); loadUsers(); }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete user
|
// Manage workspaces: open the per-user membership modal (add/remove
|
||||||
|
// workspaces, set per-workspace role). Refresh the table on close only if
|
||||||
|
// something changed (the modal calls onClose then).
|
||||||
|
el.querySelectorAll('[data-ws-manage]').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const u = users.find(x => x.id === btn.dataset.wsManage);
|
||||||
|
if (!u) return;
|
||||||
|
openManageWorkspacesModal(u, { onClose: () => loadUsers() });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset password handlers
|
||||||
|
el.querySelectorAll('[data-reset-pw-user]').forEach(btn => {
|
||||||
|
btn.onclick = async () => {
|
||||||
|
const email = btn.dataset.userEmail;
|
||||||
|
const pw = prompt(t('admin.prompt_reset_password', { email }));
|
||||||
|
if (pw === null) return;
|
||||||
|
if (pw.length < 8) { showToast(t('admin.toast.password_min_8'), 'error'); return; }
|
||||||
|
try {
|
||||||
|
await api.resetUserPassword(btn.dataset.resetPwUser, pw);
|
||||||
|
showToast(t('admin.toast.password_reset'), 'success');
|
||||||
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
el.querySelectorAll('[data-delete-user]').forEach(btn => {
|
el.querySelectorAll('[data-delete-user]').forEach(btn => {
|
||||||
let confirming = false;
|
let confirming = false;
|
||||||
btn.onclick = async () => {
|
btn.onclick = async () => {
|
||||||
if (confirming) {
|
if (confirming) {
|
||||||
try { await api.deleteUser(btn.dataset.deleteUser); showToast('User removed', 'success'); loadUsers(); }
|
try { await api.deleteUser(btn.dataset.deleteUser); showToast(t('admin.toast.user_removed'), 'success'); loadUsers(); }
|
||||||
catch (err) { showToast(err.message, 'error'); }
|
catch (err) { showToast(err.message, 'error'); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
confirming = true; btn.textContent = 'Confirm?'; btn.style.background = 'var(--danger)'; btn.style.color = 'white';
|
confirming = true; btn.textContent = t('admin.confirm'); btn.style.background = 'var(--danger)'; btn.style.color = 'white';
|
||||||
setTimeout(() => { confirming = false; btn.textContent = 'Remove'; btn.style.background = ''; btn.style.color = ''; }, 3000);
|
setTimeout(() => { confirming = false; btn.textContent = t('admin.remove'); btn.style.background = ''; btn.style.color = ''; }, 3000);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
|
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPlans() {
|
async function loadPlans() {
|
||||||
|
|
@ -125,28 +346,30 @@ async function loadPlans() {
|
||||||
try {
|
try {
|
||||||
const plans = await fetch('/api/subscription/plans').then(r => r.json());
|
const plans = await fetch('/api/subscription/plans').then(r => r.json());
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
<div class="table-wrap">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:500px">
|
||||||
<thead><tr style="border-bottom:1px solid var(--border)">
|
<thead><tr style="border-bottom:1px solid var(--border)">
|
||||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">Plan</th>
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.plan')}</th>
|
||||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">Devices</th>
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.devices')}</th>
|
||||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">Storage</th>
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.storage')}</th>
|
||||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">Monthly</th>
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.monthly')}</th>
|
||||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">Yearly</th>
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.yearly')}</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${plans.map(p => `
|
${plans.map(p => `
|
||||||
<tr style="border-bottom:1px solid var(--border)">
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
<td style="padding:8px;font-weight:500">${p.display_name}</td>
|
<td style="padding:8px;font-weight:500">${p.display_name}</td>
|
||||||
<td style="padding:8px;text-align:right">${p.max_devices === -1 ? 'Unlimited' : p.max_devices}</td>
|
<td style="padding:8px;text-align:right">${p.max_devices === -1 ? t('admin.unlimited') : p.max_devices}</td>
|
||||||
<td style="padding:8px;text-align:right">${p.max_storage_mb === -1 ? 'Unlimited' : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'}</td>
|
<td style="padding:8px;text-align:right">${p.max_storage_mb === -1 ? t('admin.unlimited') : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'}</td>
|
||||||
<td style="padding:8px;text-align:right">${p.price_monthly > 0 ? '$'+p.price_monthly : 'Free'}</td>
|
<td style="padding:8px;text-align:right">${p.price_monthly > 0 ? '$'+p.price_monthly : t('admin.free')}</td>
|
||||||
<td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}</td>
|
<td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
|
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSystem() {
|
async function loadSystem() {
|
||||||
|
|
@ -156,15 +379,15 @@ async function loadSystem() {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-card"><div class="info-card-label">Version</div><div class="info-card-value small">${version.version}</div></div>
|
<div class="info-card"><div class="info-card-label">${t('admin.version')}</div><div class="info-card-value small">${version.version}</div></div>
|
||||||
<div class="info-card"><div class="info-card-label">Frontend Hash</div><div class="info-card-value small">${version.hash}</div></div>
|
<div class="info-card"><div class="info-card-label">${t('admin.frontend_hash')}</div><div class="info-card-value small">${version.hash}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;margin-top:16px">
|
<div style="display:flex;gap:8px;margin-top:16px">
|
||||||
<a href="/api/status/backup?token=${token}" class="btn btn-secondary btn-sm" style="text-decoration:none">Download DB Backup</a>
|
<a href="/api/status/backup?token=${token}" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.download_db_backup')}</a>
|
||||||
<a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">Server Status</a>
|
<a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.server_status')}</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
|
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanup() {}
|
export function cleanup() {}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
export async function render(container) {
|
export async function render(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Subscription</h1>
|
<h1>${t('billing.title')}</h1>
|
||||||
<div class="subtitle">Manage your plan and billing</div>
|
<div class="subtitle">${t('billing.subtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="billingContent"><div class="empty-state"><h3>Loading...</h3></div></div>
|
<div id="billingContent"><div class="empty-state"><h3>${t('common.loading')}</h3></div></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -21,27 +23,26 @@ export async function render(container) {
|
||||||
const content = document.getElementById('billingContent');
|
const content = document.getElementById('billingContent');
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<!-- Current Plan -->
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Current Plan</h3>
|
<h3>${t('billing.current_plan')}</h3>
|
||||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
|
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
|
||||||
<div style="font-size:28px;font-weight:700;color:var(--accent)">${subData.plan.display_name}</div>
|
<div style="font-size:28px;font-weight:700;color:var(--accent)">${subData.plan.display_name}</div>
|
||||||
${subData.self_hosted ? '<span style="background:var(--success-dim);color:var(--success);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">Self-Hosted</span>' : ''}
|
${subData.self_hosted ? `<span style="background:var(--success-dim);color:var(--success);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">${t('billing.self_hosted')}</span>` : ''}
|
||||||
${subData.trial?.active ? `<span style="background:var(--warning-dim);color:var(--warning);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">Trial - ${subData.trial.days_left} days left</span>` : ''}
|
${subData.trial?.active ? `<span style="background:var(--warning-dim);color:var(--warning);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">${t('billing.trial_days_left', { n: subData.trial.days_left })}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${subData.trial?.active ? `
|
${subData.trial?.active ? `
|
||||||
<div style="background:var(--bg-secondary);border:1px solid var(--warning);border-radius:var(--radius);padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:12px">
|
<div style="background:var(--bg-secondary);border:1px solid var(--warning);border-radius:var(--radius);padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:12px">
|
||||||
<span style="font-size:20px">⏱</span>
|
<span style="font-size:20px">⏱</span>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:13px;font-weight:500">Your ${subData.trial.plan?.charAt(0).toUpperCase() + subData.trial.plan?.slice(1)} trial ends in ${subData.trial.days_left} days</div>
|
<div style="font-size:13px;font-weight:500">${t('billing.trial_ends', { plan: (subData.trial.plan?.charAt(0).toUpperCase() + subData.trial.plan?.slice(1)) || '', n: subData.trial.days_left })}</div>
|
||||||
<div style="font-size:12px;color:var(--text-muted)">After the trial, you'll be moved to the Free plan (1 device). Upgrade now to keep all your devices and features.</div>
|
<div style="font-size:12px;color:var(--text-muted)">${t('billing.trial_after')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="info-grid" style="margin-bottom:0">
|
<div class="info-grid" style="margin-bottom:0">
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Devices</div>
|
<div class="info-card-label">${t('billing.devices')}</div>
|
||||||
<div class="info-card-value">${subData.usage.devices} <span style="font-size:14px;color:var(--text-secondary)">/ ${subData.plan.max_devices === -1 ? 'Unlimited' : subData.plan.max_devices}</span></div>
|
<div class="info-card-value">${subData.usage.devices} <span style="font-size:14px;color:var(--text-secondary)">/ ${subData.plan.max_devices === -1 ? t('billing.unlimited') : subData.plan.max_devices}</span></div>
|
||||||
${subData.plan.max_devices > 0 ? `
|
${subData.plan.max_devices > 0 ? `
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-bar-fill ${subData.usage.devices / subData.plan.max_devices > 0.8 ? 'warning' : 'success'}"
|
<div class="progress-bar-fill ${subData.usage.devices / subData.plan.max_devices > 0.8 ? 'warning' : 'success'}"
|
||||||
|
|
@ -49,8 +50,8 @@ export async function render(container) {
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Storage</div>
|
<div class="info-card-label">${t('billing.storage')}</div>
|
||||||
<div class="info-card-value small">${subData.usage.storage_mb} MB <span style="color:var(--text-secondary)">/ ${subData.plan.max_storage_mb === -1 ? 'Unlimited' : subData.plan.max_storage_mb + ' MB'}</span></div>
|
<div class="info-card-value small">${subData.usage.storage_mb} MB <span style="color:var(--text-secondary)">/ ${subData.plan.max_storage_mb === -1 ? t('billing.unlimited') : subData.plan.max_storage_mb + ' MB'}</span></div>
|
||||||
${subData.plan.max_storage_mb > 0 ? `
|
${subData.plan.max_storage_mb > 0 ? `
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-bar-fill ${subData.usage.storage_mb / subData.plan.max_storage_mb > 0.8 ? 'warning' : 'success'}"
|
<div class="progress-bar-fill ${subData.usage.storage_mb / subData.plan.max_storage_mb > 0.8 ? 'warning' : 'success'}"
|
||||||
|
|
@ -58,51 +59,50 @@ export async function render(container) {
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Features</div>
|
<div class="info-card-label">${t('billing.features')}</div>
|
||||||
<div style="font-size:13px;margin-top:4px">
|
<div style="font-size:13px;margin-top:4px">
|
||||||
${subData.plan.remote_control ? '<div style="color:var(--success)">✓ Remote Control</div>' : '<div style="color:var(--text-muted)">✗ Remote Control</div>'}
|
${subData.plan.remote_control ? `<div style="color:var(--success)">✓ ${t('billing.feat.remote_control')}</div>` : `<div style="color:var(--text-muted)">✗ ${t('billing.feat.remote_control')}</div>`}
|
||||||
${subData.plan.remote_url ? '<div style="color:var(--success)">✓ Remote URLs</div>' : '<div style="color:var(--text-muted)">✗ Remote URLs</div>'}
|
${subData.plan.remote_url ? `<div style="color:var(--success)">✓ ${t('billing.feat.remote_urls')}</div>` : `<div style="color:var(--text-muted)">✗ ${t('billing.feat.remote_urls')}</div>`}
|
||||||
${subData.plan.priority_support ? '<div style="color:var(--success)">✓ Priority Support</div>' : '<div style="color:var(--text-muted)">✗ Priority Support</div>'}
|
${subData.plan.priority_support ? `<div style="color:var(--success)">✓ ${t('billing.feat.priority_support')}</div>` : `<div style="color:var(--text-muted)">✗ ${t('billing.feat.priority_support')}</div>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plans -->
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Available Plans</h3>
|
<h3>${t('billing.available_plans')}</h3>
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(240px, 1fr));gap:16px">
|
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(240px, 1fr));gap:16px">
|
||||||
${plans.map(p => `
|
${plans.map(p => `
|
||||||
<div style="background:var(--bg-secondary);border:${p.id === subData.plan.id ? '2px solid var(--accent)' : '1px solid var(--border)'};border-radius:var(--radius-lg);padding:20px;position:relative">
|
<div style="background:var(--bg-secondary);border:${p.id === subData.plan.id ? '2px solid var(--accent)' : '1px solid var(--border)'};border-radius:var(--radius-lg);padding:20px;position:relative">
|
||||||
${p.id === subData.plan.id ? '<div style="position:absolute;top:-10px;right:12px;background:var(--accent);color:white;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:500">Current</div>' : ''}
|
${p.id === subData.plan.id ? `<div style="position:absolute;top:-10px;right:12px;background:var(--accent);color:white;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:500">${t('billing.current')}</div>` : ''}
|
||||||
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${p.display_name}</div>
|
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${p.display_name}</div>
|
||||||
<div style="font-size:24px;font-weight:700;color:var(--accent);margin-bottom:12px">
|
<div style="font-size:24px;font-weight:700;color:var(--accent);margin-bottom:12px">
|
||||||
${p.price_monthly > 0 ? `$${p.price_monthly}<span style="font-size:13px;color:var(--text-secondary);font-weight:400">/mo</span>` : 'Free'}
|
${p.price_monthly > 0 ? `$${p.price_monthly}<span style="font-size:13px;color:var(--text-secondary);font-weight:400">${t('billing.per_month')}</span>` : t('billing.free')}
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:13px;color:var(--text-secondary);line-height:2">
|
<div style="font-size:13px;color:var(--text-secondary);line-height:2">
|
||||||
<div>${p.max_devices === -1 ? 'Unlimited' : p.max_devices} devices</div>
|
<div>${p.max_devices === -1 ? t('billing.unlimited') : p.max_devices} ${t('billing.devices_lc')}</div>
|
||||||
<div>${p.max_storage_mb === -1 ? 'Unlimited' : (p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024) + ' GB' : p.max_storage_mb + ' MB')} storage</div>
|
<div>${p.max_storage_mb === -1 ? t('billing.unlimited') : (p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024) + ' GB' : p.max_storage_mb + ' MB')} ${t('billing.storage_lc')}</div>
|
||||||
<div>${p.remote_control ? '✓' : '✗'} Remote Control</div>
|
<div>${p.remote_control ? '✓' : '✗'} ${t('billing.feat.remote_control')}</div>
|
||||||
<div>${p.remote_url ? '✓' : '✗'} Remote URLs</div>
|
<div>${p.remote_url ? '✓' : '✗'} ${t('billing.feat.remote_urls')}</div>
|
||||||
<div>${p.priority_support ? '✓' : '✗'} Priority Support</div>
|
<div>${p.priority_support ? '✓' : '✗'} ${t('billing.feat.priority_support')}</div>
|
||||||
</div>
|
</div>
|
||||||
${p.price_yearly > 0 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:8px">or $${p.price_yearly}/year (save ${Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100)}%)</div>` : ''}
|
${p.price_yearly > 0 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:8px">${t('billing.yearly_save', { price: p.price_yearly, pct: Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100) })}</div>` : ''}
|
||||||
${!subData.self_hosted && p.price_monthly > 0 && p.id !== subData.plan.id ? `
|
${!subData.self_hosted && p.price_monthly > 0 && p.id !== subData.plan.id ? `
|
||||||
<div style="margin-top:12px;display:flex;gap:6px">
|
<div style="margin-top:12px;display:flex;gap:6px">
|
||||||
<button class="btn btn-primary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','monthly')">Monthly</button>
|
<button class="btn btn-primary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','monthly')">${t('billing.monthly')}</button>
|
||||||
${p.price_yearly > 0 ? `<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','yearly')">Yearly</button>` : ''}
|
${p.price_yearly > 0 ? `<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','yearly')">${t('billing.yearly')}</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
${!subData.self_hosted && p.id === subData.plan.id && subData.subscription?.stripe_subscription_id ? `
|
${!subData.self_hosted && p.id === subData.plan.id && subData.subscription?.stripe_subscription_id ? `
|
||||||
<button class="btn btn-secondary btn-sm" style="width:100%;margin-top:12px" onclick="window._manageSubscription()">Manage Subscription</button>
|
<button class="btn btn-secondary btn-sm" style="width:100%;margin-top:12px" onclick="window._manageSubscription()">${t('billing.manage_subscription')}</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
${subData.self_hosted ? '<p style="color:var(--text-muted);font-size:12px;margin-top:12px">Self-hosted mode: plans can be assigned by admins without billing.</p>' : ''}
|
${subData.self_hosted ? `<p style="color:var(--text-muted);font-size:12px;margin-top:12px">${t('billing.self_hosted_note')}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
// Checkout handler
|
|
||||||
window._checkout = async (planId, interval) => {
|
window._checkout = async (planId, interval) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/stripe/checkout', {
|
const res = await fetch('/api/stripe/checkout', {
|
||||||
|
|
@ -114,11 +114,10 @@ export async function render(container) {
|
||||||
if (data.error) { showToast(data.error, 'error'); return; }
|
if (data.error) { showToast(data.error, 'error'); return; }
|
||||||
if (data.url) window.location.href = data.url;
|
if (data.url) window.location.href = data.url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('Failed to start checkout: ' + err.message, 'error');
|
showToast(t('billing.toast.checkout_failed', { error: err.message }), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Manage subscription handler (Stripe Customer Portal)
|
|
||||||
window._manageSubscription = async () => {
|
window._manageSubscription = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/stripe/portal', {
|
const res = await fetch('/api/stripe/portal', {
|
||||||
|
|
@ -129,18 +128,17 @@ export async function render(container) {
|
||||||
if (data.error) { showToast(data.error, 'error'); return; }
|
if (data.error) { showToast(data.error, 'error'); return; }
|
||||||
if (data.url) window.location.href = data.url;
|
if (data.url) window.location.href = data.url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('Failed to open billing portal: ' + err.message, 'error');
|
showToast(t('billing.toast.portal_failed', { error: err.message }), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for payment success/cancel in URL
|
|
||||||
if (window.location.hash.includes('payment=success')) {
|
if (window.location.hash.includes('payment=success')) {
|
||||||
showToast('Payment successful! Your plan has been upgraded.', 'success');
|
showToast(t('billing.toast.payment_success'), 'success');
|
||||||
window.location.hash = '#/billing';
|
window.location.hash = '#/billing';
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>Failed to load</h3><p>${err.message}</p></div>`;
|
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>${t('billing.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
function formatFileSize(bytes) {
|
||||||
if (!bytes) return '--';
|
if (!bytes) return '--';
|
||||||
|
|
@ -9,30 +11,60 @@ function formatFileSize(bytes) {
|
||||||
return `${bytes} B`;
|
return `${bytes} B`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazy-load authenticated thumbnails/previews. A plain <img> can't send the
|
||||||
|
// Bearer token, and the content thumbnail/file endpoints require auth (or a
|
||||||
|
// playlist/widget reference) - so a just-uploaded item's thumbnail 403'd. We fetch
|
||||||
|
// with the token and swap in an object URL. IntersectionObserver keeps it lazy so
|
||||||
|
// we stay under the /api/content rate limit; the object URL is revoked after load.
|
||||||
|
let _authImgObserver = null;
|
||||||
|
function loadAuthImage(img) {
|
||||||
|
const url = img.dataset.authSrc;
|
||||||
|
if (!url) return;
|
||||||
|
delete img.dataset.authSrc;
|
||||||
|
fetch(url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
|
||||||
|
.then(r => (r.ok ? r.blob() : Promise.reject(r.status)))
|
||||||
|
.then(blob => {
|
||||||
|
const obj = URL.createObjectURL(blob);
|
||||||
|
img.addEventListener('load', () => URL.revokeObjectURL(obj), { once: true });
|
||||||
|
img.src = obj;
|
||||||
|
})
|
||||||
|
.catch(() => { img.style.opacity = '0.25'; });
|
||||||
|
}
|
||||||
|
function hydrateAuthImages(root) {
|
||||||
|
const imgs = root.querySelectorAll('img[data-auth-src]');
|
||||||
|
if (typeof IntersectionObserver === 'undefined') { imgs.forEach(loadAuthImage); return; }
|
||||||
|
if (!_authImgObserver) {
|
||||||
|
_authImgObserver = new IntersectionObserver((entries, obs) => {
|
||||||
|
for (const e of entries) if (e.isIntersecting) { obs.unobserve(e.target); loadAuthImage(e.target); }
|
||||||
|
}, { rootMargin: '300px' });
|
||||||
|
}
|
||||||
|
imgs.forEach(img => _authImgObserver.observe(img));
|
||||||
|
}
|
||||||
|
|
||||||
export function render(container) {
|
export function render(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Content Library <span class="help-tip" data-tip="Upload videos and images here. Select multiple files for bulk upload. Use Remote URL to stream from external sources. Click a thumbnail to preview.">?</span></h1>
|
<h1>${t('content.title')} <span class="help-tip" data-tip="${t('content.help_tip')}">?</span></h1>
|
||||||
<div class="subtitle">Upload and manage your media files</div>
|
<div class="subtitle">${t('content.subtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:16px;margin-bottom:24px">
|
<div class="content-toolbar" style="display:flex;gap:16px;margin-bottom:24px">
|
||||||
<div class="upload-area" id="uploadArea" style="flex:1;margin-bottom:0">
|
<div class="upload-area" id="uploadArea" style="flex:1;margin-bottom:0">
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
<polyline points="17 8 12 3 7 8"/>
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p>Drop files here or click to upload</p>
|
<p>${t('content.drop')}</p>
|
||||||
<p class="upload-hint">Supports MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP</p>
|
<p class="upload-hint">${t('content.upload_hint')}</p>
|
||||||
<input type="file" id="fileInput" style="display:none" multiple accept="video/*,image/*">
|
<input type="file" id="fileInput" style="display:none" multiple accept="video/*,image/*">
|
||||||
<div class="upload-progress" id="uploadProgress" style="display:none">
|
<div class="upload-progress" id="uploadProgress" style="display:none">
|
||||||
<div class="upload-progress-bar">
|
<div class="upload-progress-bar">
|
||||||
<div class="upload-progress-fill" id="uploadProgressFill" style="width:0%"></div>
|
<div class="upload-progress-fill" id="uploadProgressFill" style="width:0%"></div>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:12px;color:var(--text-secondary);margin-top:6px" id="uploadProgressText">Uploading...</p>
|
<p style="font-size:12px;color:var(--text-secondary);margin-top:6px" id="uploadProgressText">${t('content.upload_progress')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="width:320px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;flex-direction:column;gap:12px">
|
<div style="width:320px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||||
|
|
@ -41,18 +73,18 @@ export function render(container) {
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
</svg>
|
</svg>
|
||||||
Remote URL
|
${t('content.remote_url')}
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:12px;color:var(--text-muted)">Stream directly from a URL. Saves local bandwidth.</p>
|
<p style="font-size:12px;color:var(--text-muted)">${t('content.remote_desc')}</p>
|
||||||
<input type="text" id="remoteUrlInput" class="input" placeholder="https://example.com/video.mp4">
|
<input type="text" id="remoteUrlInput" class="input" placeholder="${t('content.remote_url_placeholder')}">
|
||||||
<input type="text" id="remoteNameInput" class="input" placeholder="Display name (optional)">
|
<input type="text" id="remoteNameInput" class="input" placeholder="${t('content.remote_name_placeholder')}">
|
||||||
<select id="remoteMimeType" class="input" style="background:var(--bg-input)">
|
<select id="remoteMimeType" class="input" style="background:var(--bg-input)">
|
||||||
<option value="video/mp4">Video (MP4)</option>
|
<option value="video/mp4">${t('content.mime.video_mp4')}</option>
|
||||||
<option value="video/webm">Video (WebM)</option>
|
<option value="video/webm">${t('content.mime.video_webm')}</option>
|
||||||
<option value="image/jpeg">Image (JPEG)</option>
|
<option value="image/jpeg">${t('content.mime.image_jpeg')}</option>
|
||||||
<option value="image/png">Image (PNG)</option>
|
<option value="image/png">${t('content.mime.image_png')}</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-primary" id="addRemoteBtn">Add Remote URL</button>
|
<button class="btn btn-primary" id="addRemoteBtn">${t('content.remote_add_btn')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="width:320px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;flex-direction:column;gap:12px">
|
<div style="width:320px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||||
<div style="display:flex;align-items:center;gap:8px;color:var(--text-primary);font-weight:500">
|
<div style="display:flex;align-items:center;gap:8px;color:var(--text-primary);font-weight:500">
|
||||||
|
|
@ -60,25 +92,24 @@ export function render(container) {
|
||||||
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/>
|
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/>
|
||||||
<polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02"/>
|
<polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02"/>
|
||||||
</svg>
|
</svg>
|
||||||
YouTube
|
${t('content.youtube')}
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:12px;color:var(--text-muted)">Embed a YouTube video on your displays.</p>
|
<p style="font-size:12px;color:var(--text-muted)">${t('content.youtube_desc')}</p>
|
||||||
<input type="text" id="youtubeUrlInput" class="input" placeholder="https://youtube.com/watch?v=...">
|
<input type="text" id="youtubeUrlInput" class="input" placeholder="${t('content.youtube_url_placeholder')}">
|
||||||
<input type="text" id="youtubeNameInput" class="input" placeholder="Display name (optional)">
|
<input type="text" id="youtubeNameInput" class="input" placeholder="${t('content.youtube_name_placeholder')}">
|
||||||
<button class="btn btn-primary" id="addYoutubeBtn">Add YouTube Video</button>
|
<button class="btn btn-primary" id="addYoutubeBtn">${t('content.youtube_add_btn')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
|
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:center;flex-wrap:wrap">
|
||||||
<input type="text" id="contentSearch" class="input" placeholder="Search content..." style="width:250px">
|
<input type="text" id="contentSearch" class="input" placeholder="${t('content.search_placeholder')}" style="max-width:250px;width:100%">
|
||||||
<select id="folderFilter" class="input" style="width:180px;background:var(--bg-input)">
|
<button class="btn btn-secondary btn-sm" id="newFolderBtn">${t('content.new_folder_btn')}</button>
|
||||||
<option value="">All Folders</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-secondary btn-sm" id="newFolderBtn">+ New Folder</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="folderBreadcrumb" style="display:flex;gap:6px;align-items:center;margin-bottom:12px;font-size:13px;flex-wrap:wrap"></div>
|
||||||
|
<div id="folderGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:16px"></div>
|
||||||
<div class="content-grid" id="contentGrid">
|
<div class="content-grid" id="contentGrid">
|
||||||
<div class="empty-state" style="grid-column:1/-1"><h3>Loading...</h3></div>
|
<div class="empty-state" style="grid-column:1/-1"><h3>${t('common.loading')}</h3></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -114,12 +145,12 @@ export function render(container) {
|
||||||
const name = document.getElementById('remoteNameInput').value.trim();
|
const name = document.getElementById('remoteNameInput').value.trim();
|
||||||
const mimeType = document.getElementById('remoteMimeType').value;
|
const mimeType = document.getElementById('remoteMimeType').value;
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showToast('Enter a URL', 'error');
|
showToast(t('content.error_enter_url'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.addRemoteContent(url, name, mimeType);
|
await api.addRemoteContent(url, name, mimeType);
|
||||||
showToast('Remote content added', 'success');
|
showToast(t('content.toast.remote_added'), 'success');
|
||||||
document.getElementById('remoteUrlInput').value = '';
|
document.getElementById('remoteUrlInput').value = '';
|
||||||
document.getElementById('remoteNameInput').value = '';
|
document.getElementById('remoteNameInput').value = '';
|
||||||
loadContent();
|
loadContent();
|
||||||
|
|
@ -133,12 +164,12 @@ export function render(container) {
|
||||||
const url = document.getElementById('youtubeUrlInput').value.trim();
|
const url = document.getElementById('youtubeUrlInput').value.trim();
|
||||||
const name = document.getElementById('youtubeNameInput').value.trim();
|
const name = document.getElementById('youtubeNameInput').value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showToast('Enter a YouTube URL', 'error');
|
showToast(t('content.error_enter_youtube_url'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.addYoutubeContent(url, name);
|
await api.addYoutubeContent(url, name);
|
||||||
showToast('YouTube video added', 'success');
|
showToast(t('content.toast.youtube_added'), 'success');
|
||||||
document.getElementById('youtubeUrlInput').value = '';
|
document.getElementById('youtubeUrlInput').value = '';
|
||||||
document.getElementById('youtubeNameInput').value = '';
|
document.getElementById('youtubeNameInput').value = '';
|
||||||
loadContent();
|
loadContent();
|
||||||
|
|
@ -147,36 +178,41 @@ export function render(container) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Content search + folder filter
|
// Content search filters items currently shown in the grid.
|
||||||
function filterContent() {
|
function filterContent() {
|
||||||
const q = document.getElementById('contentSearch').value.toLowerCase();
|
const q = document.getElementById('contentSearch').value.toLowerCase();
|
||||||
const folder = document.getElementById('folderFilter').value;
|
|
||||||
document.querySelectorAll('.content-item').forEach(item => {
|
document.querySelectorAll('.content-item').forEach(item => {
|
||||||
const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || '';
|
const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || '';
|
||||||
const itemFolder = item.dataset.folder || '';
|
item.style.display = (!q || name.includes(q)) ? '' : 'none';
|
||||||
const matchSearch = !q || name.includes(q);
|
});
|
||||||
const matchFolder = !folder || itemFolder === folder;
|
document.querySelectorAll('.folder-card').forEach(card => {
|
||||||
item.style.display = (matchSearch && matchFolder) ? '' : 'none';
|
const name = card.dataset.name?.toLowerCase() || '';
|
||||||
|
card.style.display = (!q || name.includes(q)) ? '' : 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.getElementById('contentSearch').oninput = filterContent;
|
document.getElementById('contentSearch').oninput = filterContent;
|
||||||
document.getElementById('folderFilter').onchange = filterContent;
|
|
||||||
|
|
||||||
// New folder
|
// Create folder in the current folder.
|
||||||
document.getElementById('newFolderBtn').onclick = () => {
|
document.getElementById('newFolderBtn').onclick = async () => {
|
||||||
const name = prompt('Folder name:');
|
const name = prompt(t('content.prompt_folder_name'));
|
||||||
if (name) {
|
if (!name || !name.trim()) return;
|
||||||
// Just add to the dropdown - folders are created when content is moved into them
|
try {
|
||||||
const opt = document.createElement('option');
|
await api.createFolder(name.trim(), state.currentFolderId);
|
||||||
opt.value = name; opt.textContent = name;
|
showToast(t('content.toast.folder_created_named', { name }), 'success');
|
||||||
document.getElementById('folderFilter').appendChild(opt);
|
loadContent();
|
||||||
showToast(`Folder "${name}" created. Edit content to move it here.`, 'info');
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loadContent();
|
loadContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// View state — current folder navigation. Lives at module scope so the back button
|
||||||
|
// and other handlers can read it without threading it through every callback.
|
||||||
|
const state = {
|
||||||
|
currentFolderId: null, // null = root
|
||||||
|
folders: [], // all folders for this user (flat tree)
|
||||||
|
};
|
||||||
|
|
||||||
async function handleFiles(files) {
|
async function handleFiles(files) {
|
||||||
const progress = document.getElementById('uploadProgress');
|
const progress = document.getElementById('uploadProgress');
|
||||||
const progressFill = document.getElementById('uploadProgressFill');
|
const progressFill = document.getElementById('uploadProgressFill');
|
||||||
|
|
@ -185,16 +221,16 @@ async function handleFiles(files) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
progress.style.display = 'block';
|
progress.style.display = 'block';
|
||||||
progressFill.style.width = '0%';
|
progressFill.style.width = '0%';
|
||||||
progressText.textContent = `Uploading ${file.name}...`;
|
progressText.textContent = t('content.upload_progress_named', { name: file.name });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.uploadContent(file, (pct) => {
|
await api.uploadContent(file, (pct) => {
|
||||||
progressFill.style.width = pct + '%';
|
progressFill.style.width = pct + '%';
|
||||||
progressText.textContent = `Uploading ${file.name}... ${pct}%`;
|
progressText.textContent = t('content.upload_progress_named_pct', { name: file.name, pct });
|
||||||
});
|
});
|
||||||
showToast(`${file.name} uploaded successfully`, 'success');
|
showToast(t('content.toast.uploaded_named', { name: file.name }), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`Failed to upload ${file.name}: ${err.message}`, 'error');
|
showToast(t('content.toast.upload_failed_named', { name: file.name, error: err.message }), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,30 +240,149 @@ async function handleFiles(files) {
|
||||||
|
|
||||||
async function loadContent() {
|
async function loadContent() {
|
||||||
const grid = document.getElementById('contentGrid');
|
const grid = document.getElementById('contentGrid');
|
||||||
if (!grid) return;
|
const folderGrid = document.getElementById('folderGrid');
|
||||||
|
const breadcrumb = document.getElementById('folderBreadcrumb');
|
||||||
|
if (!grid || !folderGrid || !breadcrumb) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await api.getContent();
|
const [content, folders] = await Promise.all([
|
||||||
|
api.getContent(state.currentFolderId === null ? null : state.currentFolderId),
|
||||||
|
api.getFolders(),
|
||||||
|
]);
|
||||||
|
state.folders = folders;
|
||||||
|
|
||||||
|
// Breadcrumb path: walk parent_id chain from current folder up to root.
|
||||||
|
const folderById = new Map(folders.map(f => [f.id, f]));
|
||||||
|
const path = [];
|
||||||
|
let cursor = state.currentFolderId ? folderById.get(state.currentFolderId) : null;
|
||||||
|
while (cursor) {
|
||||||
|
path.unshift(cursor);
|
||||||
|
cursor = cursor.parent_id ? folderById.get(cursor.parent_id) : null;
|
||||||
|
}
|
||||||
|
breadcrumb.innerHTML = `
|
||||||
|
<a href="#" data-folder-nav="" style="color:var(--text-secondary);text-decoration:none">${t('content.breadcrumb_root')}</a>
|
||||||
|
${path.map(f => `
|
||||||
|
<span style="color:var(--text-muted)">/</span>
|
||||||
|
<a href="#" data-folder-nav="${f.id}" style="color:var(--text-primary);text-decoration:none">${esc(f.name)}</a>
|
||||||
|
`).join('')}
|
||||||
|
${state.currentFolderId ? `
|
||||||
|
<button class="btn btn-secondary btn-sm" id="renameFolderBtn" style="margin-left:auto">${t('content.rename_btn')}</button>
|
||||||
|
<button class="btn btn-danger btn-sm" id="deleteFolderBtn">${t('content.delete_folder_btn')}</button>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
breadcrumb.querySelectorAll('[data-folder-nav]').forEach(a => {
|
||||||
|
a.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = a.dataset.folderNav;
|
||||||
|
state.currentFolderId = id || null;
|
||||||
|
loadContent();
|
||||||
|
});
|
||||||
|
// Make breadcrumb segments drop targets too — otherwise the only way to move
|
||||||
|
// a file out of a folder is via the edit modal. Dropping on "All Content"
|
||||||
|
// moves to root; dropping on a parent name moves there.
|
||||||
|
a.addEventListener('dragover', (e) => {
|
||||||
|
if (!e.dataTransfer.types.includes('text/content-id')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
a.style.background = 'var(--primary)';
|
||||||
|
a.style.color = '#fff';
|
||||||
|
a.style.padding = '2px 8px';
|
||||||
|
a.style.borderRadius = '4px';
|
||||||
|
});
|
||||||
|
a.addEventListener('dragleave', () => {
|
||||||
|
a.style.background = '';
|
||||||
|
a.style.color = '';
|
||||||
|
a.style.padding = '';
|
||||||
|
a.style.borderRadius = '';
|
||||||
|
});
|
||||||
|
a.addEventListener('drop', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
a.style.background = ''; a.style.color = ''; a.style.padding = ''; a.style.borderRadius = '';
|
||||||
|
const contentId = e.dataTransfer.getData('text/content-id');
|
||||||
|
if (!contentId) return;
|
||||||
|
const targetFolderId = a.dataset.folderNav || null; // empty string = root
|
||||||
|
try {
|
||||||
|
await api.moveContent(contentId, targetFolderId);
|
||||||
|
showToast(targetFolderId ? t('content.toast.moved') : t('content.toast.moved_to_root'), 'success');
|
||||||
|
loadContent();
|
||||||
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const renameBtn = breadcrumb.querySelector('#renameFolderBtn');
|
||||||
|
if (renameBtn) renameBtn.onclick = async () => {
|
||||||
|
const current = folderById.get(state.currentFolderId);
|
||||||
|
const name = prompt(t('content.prompt_rename_folder'), current?.name || '');
|
||||||
|
if (!name || !name.trim() || name === current?.name) return;
|
||||||
|
try {
|
||||||
|
await api.renameFolder(state.currentFolderId, name.trim());
|
||||||
|
showToast(t('content.toast.folder_renamed'), 'success');
|
||||||
|
loadContent();
|
||||||
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
|
};
|
||||||
|
const deleteBtn = breadcrumb.querySelector('#deleteFolderBtn');
|
||||||
|
if (deleteBtn) deleteBtn.onclick = async () => {
|
||||||
|
if (!confirm(t('content.confirm_delete_folder'))) return;
|
||||||
|
try {
|
||||||
|
const parentId = folderById.get(state.currentFolderId)?.parent_id || null;
|
||||||
|
await api.deleteFolder(state.currentFolderId);
|
||||||
|
showToast(t('content.toast.folder_deleted'), 'success');
|
||||||
|
state.currentFolderId = parentId;
|
||||||
|
loadContent();
|
||||||
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render subfolders of the current folder.
|
||||||
|
const subfolders = folders.filter(f => (f.parent_id || null) === state.currentFolderId);
|
||||||
|
folderGrid.innerHTML = subfolders.map(f => `
|
||||||
|
<div class="folder-card" data-folder-id="${f.id}" data-name="${esc(f.name)}"
|
||||||
|
style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);padding:14px;cursor:pointer;display:flex;align-items:center;gap:10px"
|
||||||
|
data-drop-folder="${f.id}">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<div style="font-size:14px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(f.name)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
folderGrid.querySelectorAll('.folder-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
state.currentFolderId = card.dataset.folderId;
|
||||||
|
loadContent();
|
||||||
|
});
|
||||||
|
// Drop target for dragging content items into this folder.
|
||||||
|
card.addEventListener('dragover', (e) => { e.preventDefault(); card.style.outline = '2px solid var(--primary)'; });
|
||||||
|
card.addEventListener('dragleave', () => { card.style.outline = ''; });
|
||||||
|
card.addEventListener('drop', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
card.style.outline = '';
|
||||||
|
const contentId = e.dataTransfer.getData('text/content-id');
|
||||||
|
if (!contentId) return;
|
||||||
|
try {
|
||||||
|
await api.moveContent(contentId, card.dataset.folderId);
|
||||||
|
showToast(t('content.toast.moved'), 'success');
|
||||||
|
loadContent();
|
||||||
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (!content.length) {
|
if (!content.length) {
|
||||||
grid.innerHTML = `
|
grid.innerHTML = subfolders.length ? '' : `
|
||||||
<div class="empty-state" style="grid-column:1/-1">
|
<div class="empty-state" style="grid-column:1/-1">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
|
||||||
<polyline points="13 2 13 9 20 9"/>
|
<polyline points="13 2 13 9 20 9"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3>No content yet</h3>
|
<h3>${state.currentFolderId ? t('content.empty_folder_title') : t('content.no_content')}</h3>
|
||||||
<p>Upload videos and images to get started.</p>
|
<p>${state.currentFolderId ? t('content.empty_folder_desc') : t('content.no_content_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = content.map(c => `
|
grid.innerHTML = content.map(c => `
|
||||||
<div class="content-item" data-content-id="${c.id}" data-folder="${c.folder || ''}">
|
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${c.folder || ''}">
|
||||||
<div class="content-item-preview">
|
<div class="content-item-preview">
|
||||||
${c.mime_type === 'video/youtube'
|
${c.mime_type === 'video/youtube'
|
||||||
? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center">
|
? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center">
|
||||||
<img src="${c.thumbnail_path}" alt="${c.filename}" loading="lazy" style="width:100%;height:100%;object-fit:cover">
|
<img src="${c.thumbnail_path}" alt="${esc(c.filename)}" loading="lazy" style="width:100%;height:100%;object-fit:cover">
|
||||||
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
|
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="red" stroke="none">
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="red" stroke="none">
|
||||||
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/>
|
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/>
|
||||||
|
|
@ -241,56 +396,54 @@ async function loadContent() {
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span style="font-size:10px;color:var(--text-muted)">Remote</span>
|
<span style="font-size:10px;color:var(--text-muted)">${t('content.type_remote_short')}</span>
|
||||||
</div>`
|
</div>`
|
||||||
: c.thumbnail_path
|
: c.thumbnail_path
|
||||||
? `<img src="/api/content/${c.id}/thumbnail" alt="${c.filename}" loading="lazy">`
|
? `<img data-auth-src="/api/content/${c.id}/thumbnail" alt="${esc(c.filename)}" style="background:var(--bg-secondary)">`
|
||||||
: c.mime_type?.startsWith('video/')
|
: c.mime_type?.startsWith('video/')
|
||||||
? `<div class="video-icon">
|
? `<div class="video-icon">
|
||||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`
|
</div>`
|
||||||
: `<img src="/api/content/${c.id}/file" alt="${c.filename}" loading="lazy">`
|
: `<img data-auth-src="/api/content/${c.id}/file" alt="${esc(c.filename)}" style="background:var(--bg-secondary)">`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="content-item-body">
|
<div class="content-item-body">
|
||||||
<div class="content-item-name" title="${c.filename}">${c.filename}</div>
|
<div class="content-item-name" title="${esc(c.filename)}">${esc(c.filename)}</div>
|
||||||
<div class="content-item-size">
|
<div class="content-item-size">
|
||||||
${c.mime_type === 'video/youtube' ? 'YouTube' : c.remote_url ? 'Remote URL' : (c.mime_type?.startsWith('video/') ? 'Video' : 'Image')}
|
${c.mime_type === 'video/youtube' ? t('content.type_youtube') : c.remote_url ? t('content.type_remote') : (c.mime_type?.startsWith('video/') ? t('content.type_video') : t('content.type_image'))}
|
||||||
${c.duration_sec ? ` · ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''}
|
${c.duration_sec ? ` · ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''}
|
||||||
${c.file_size ? ' · ' + formatFileSize(c.file_size) : ''}
|
${c.file_size ? ' · ' + formatFileSize(c.file_size) : ''}
|
||||||
${c.width && c.height ? ` · ${c.width}x${c.height}` : ''}
|
${c.width && c.height ? ` · ${c.width}x${c.height}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-item-actions">
|
<div class="content-item-actions">
|
||||||
<button class="btn btn-secondary btn-sm" data-edit-content="${c.id}" title="Edit">
|
<button class="btn btn-secondary btn-sm" data-edit-content="${c.id}" title="${t('content.btn_edit')}">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Edit
|
${t('content.btn_edit')}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger btn-sm" data-delete-content="${c.id}" title="Delete">
|
<button class="btn btn-danger btn-sm" data-delete-content="${c.id}" title="${t('content.btn_delete')}">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<polyline points="3 6 5 6 21 6"/>
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
</svg>
|
</svg>
|
||||||
Delete
|
${t('content.btn_delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
hydrateAuthImages(grid);
|
||||||
|
|
||||||
// Populate folder dropdown
|
// Drag-to-move: each content item exposes its id; folder cards are the drop targets.
|
||||||
const folderSelect = document.getElementById('folderFilter');
|
grid.querySelectorAll('.content-item').forEach(item => {
|
||||||
const folders = [...new Set(content.filter(c => c.folder).map(c => c.folder))].sort();
|
item.addEventListener('dragstart', (e) => {
|
||||||
folders.forEach(f => {
|
e.dataTransfer.setData('text/content-id', item.dataset.contentId);
|
||||||
if (!folderSelect.querySelector(`option[value="${f}"]`)) {
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
const opt = document.createElement('option');
|
});
|
||||||
opt.value = f; opt.textContent = `${f} (${content.filter(c => c.folder === f).length})`;
|
|
||||||
folderSelect.appendChild(opt);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete handler via event delegation
|
// Delete handler via event delegation
|
||||||
|
|
@ -325,14 +478,14 @@ async function loadContent() {
|
||||||
if (btn.dataset.confirming === 'true') {
|
if (btn.dataset.confirming === 'true') {
|
||||||
try {
|
try {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Deleting...';
|
btn.textContent = t('content.btn_deleting');
|
||||||
await api.deleteContent(id);
|
await api.deleteContent(id);
|
||||||
showToast('Content deleted', 'success');
|
showToast(t('content.toast.deleted'), 'success');
|
||||||
loadContent();
|
loadContent();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Delete';
|
btn.textContent = t('content.btn_delete');
|
||||||
btn.dataset.confirming = 'false';
|
btn.dataset.confirming = 'false';
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -340,14 +493,14 @@ async function loadContent() {
|
||||||
|
|
||||||
// First click - show confirm state
|
// First click - show confirm state
|
||||||
btn.dataset.confirming = 'true';
|
btn.dataset.confirming = 'true';
|
||||||
btn.innerHTML = 'Confirm Delete?';
|
btn.innerHTML = t('content.btn_confirm_delete');
|
||||||
btn.style.background = 'var(--danger)';
|
btn.style.background = 'var(--danger)';
|
||||||
btn.style.color = 'white';
|
btn.style.color = 'white';
|
||||||
// Reset after 3 seconds if not clicked
|
// Reset after 3 seconds if not clicked
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (btn.dataset.confirming === 'true') {
|
if (btn.dataset.confirming === 'true') {
|
||||||
btn.dataset.confirming = 'false';
|
btn.dataset.confirming = 'false';
|
||||||
btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> Delete`;
|
btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> ${t('content.btn_delete')}`;
|
||||||
btn.style.background = '';
|
btn.style.background = '';
|
||||||
btn.style.color = '';
|
btn.style.color = '';
|
||||||
}
|
}
|
||||||
|
|
@ -355,7 +508,7 @@ async function loadContent() {
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Failed to load content</h3><p>${err.message}</p></div>`;
|
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('content.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,46 +520,53 @@ function showEditModal(contentItem, onSave) {
|
||||||
const isRemote = !!contentItem.remote_url;
|
const isRemote = !!contentItem.remote_url;
|
||||||
|
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="modal" style="width:500px">
|
<div class="modal" style="max-width:500px;width:95vw">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Edit Content</h3>
|
<h3>${t('content.edit_modal_title')}</h3>
|
||||||
<button class="btn-icon" id="closeEditModal">
|
<button class="btn-icon" id="closeEditModal">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Filename / Display Name</label>
|
<label>${t('content.label_filename')}</label>
|
||||||
<input type="text" id="editFilename" class="input" value="${contentItem.filename}">
|
<input type="text" id="editFilename" class="input" value="${esc(contentItem.filename)}">
|
||||||
</div>
|
</div>
|
||||||
${isRemote ? `
|
${isRemote ? `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Remote URL</label>
|
<label>${t('content.label_remote_url_field')}</label>
|
||||||
<input type="text" id="editRemoteUrl" class="input" value="${contentItem.remote_url}">
|
<input type="text" id="editRemoteUrl" class="input" value="${esc(contentItem.remote_url)}">
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>MIME Type</label>
|
<label>${t('content.label_mime_type')}</label>
|
||||||
<select id="editMimeType" class="input" style="background:var(--bg-input)">
|
<select id="editMimeType" class="input" style="background:var(--bg-input)">
|
||||||
<option value="video/mp4" ${contentItem.mime_type === 'video/mp4' ? 'selected' : ''}>Video (MP4)</option>
|
<option value="video/mp4" ${contentItem.mime_type === 'video/mp4' ? 'selected' : ''}>${t('content.mime.video_mp4')}</option>
|
||||||
<option value="video/webm" ${contentItem.mime_type === 'video/webm' ? 'selected' : ''}>Video (WebM)</option>
|
<option value="video/webm" ${contentItem.mime_type === 'video/webm' ? 'selected' : ''}>${t('content.mime.video_webm')}</option>
|
||||||
<option value="image/jpeg" ${contentItem.mime_type === 'image/jpeg' ? 'selected' : ''}>Image (JPEG)</option>
|
<option value="image/jpeg" ${contentItem.mime_type === 'image/jpeg' ? 'selected' : ''}>${t('content.mime.image_jpeg')}</option>
|
||||||
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>Image (PNG)</option>
|
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>${t('content.mime.image_png')}</option>
|
||||||
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>Image (GIF)</option>
|
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>${t('content.mime.image_gif')}</option>
|
||||||
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>Image (WebP)</option>
|
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>${t('content.mime.image_webp')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${t('content.label_folder')}</label>
|
||||||
|
<select id="editFolderId" class="input" style="background:var(--bg-input)">
|
||||||
|
<option value="">${t('content.folder_root_option')}</option>
|
||||||
|
${state.folders.map(f => `<option value="${f.id}" ${contentItem.folder_id === f.id ? 'selected' : ''}>${esc(folderPath(f, state.folders))}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
${!isRemote ? `
|
${!isRemote ? `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Replace File</label>
|
<label>${t('content.label_replace_file')}</label>
|
||||||
<input type="file" id="editFileReplace" accept="video/*,image/*" style="font-size:13px;color:var(--text-secondary)">
|
<input type="file" id="editFileReplace" accept="video/*,image/*" style="font-size:13px;color:var(--text-secondary)">
|
||||||
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">Leave empty to keep current file</p>
|
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('content.replace_file_hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" id="cancelEditBtn">Cancel</button>
|
<button class="btn btn-secondary" id="cancelEditBtn">${t('common.cancel')}</button>
|
||||||
<button class="btn btn-primary" id="saveEditBtn">Save Changes</button>
|
<button class="btn btn-primary" id="saveEditBtn">${t('content.save_changes')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -427,10 +587,12 @@ function showEditModal(contentItem, onSave) {
|
||||||
const headers = { Authorization: 'Bearer ' + token };
|
const headers = { Authorization: 'Bearer ' + token };
|
||||||
|
|
||||||
// Update metadata
|
// Update metadata
|
||||||
|
const folderId = overlay.querySelector('#editFolderId')?.value || '';
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
if (filename !== contentItem.filename) updateData.filename = filename;
|
if (filename !== contentItem.filename) updateData.filename = filename;
|
||||||
if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType;
|
if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType;
|
||||||
if (remoteUrl !== undefined && remoteUrl !== contentItem.remote_url) updateData.remote_url = remoteUrl;
|
if (remoteUrl !== undefined && remoteUrl !== contentItem.remote_url) updateData.remote_url = remoteUrl;
|
||||||
|
if ((contentItem.folder_id || '') !== folderId) updateData.folder_id = folderId || null;
|
||||||
|
|
||||||
if (Object.keys(updateData).length > 0) {
|
if (Object.keys(updateData).length > 0) {
|
||||||
await fetch('/api/content/' + contentItem.id, {
|
await fetch('/api/content/' + contentItem.id, {
|
||||||
|
|
@ -452,10 +614,10 @@ function showEditModal(contentItem, onSave) {
|
||||||
}
|
}
|
||||||
|
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
showToast('Content updated', 'success');
|
showToast(t('content.toast.updated'), 'success');
|
||||||
if (onSave) onSave();
|
if (onSave) onSave();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || 'Update failed', 'error');
|
showToast(err.message || t('content.error_update_failed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -475,13 +637,13 @@ function showPreview(content) {
|
||||||
${isYoutube
|
${isYoutube
|
||||||
? `<iframe src="${(() => { try { const u = new URL(src); if (!u.searchParams.has('mute')) u.searchParams.set('mute','1'); if (!u.searchParams.has('enablejsapi')) u.searchParams.set('enablejsapi','1'); if (!u.searchParams.has('origin')) u.searchParams.set('origin', window.location.origin); return u.toString(); } catch { return src; } })()}" style="width:80vw;height:45vw;max-height:80vh;display:block;border:none" allow="autoplay;encrypted-media" allowfullscreen></iframe>`
|
? `<iframe src="${(() => { try { const u = new URL(src); if (!u.searchParams.has('mute')) u.searchParams.set('mute','1'); if (!u.searchParams.has('enablejsapi')) u.searchParams.set('enablejsapi','1'); if (!u.searchParams.has('origin')) u.searchParams.set('origin', window.location.origin); return u.toString(); } catch { return src; } })()}" style="width:80vw;height:45vw;max-height:80vh;display:block;border:none" allow="autoplay;encrypted-media" allowfullscreen></iframe>`
|
||||||
: isVideo
|
: isVideo
|
||||||
? `<video src="${src}" controls autoplay style="max-width:80vw;max-height:80vh;display:block"></video>`
|
? `<video src="${esc(src)}" controls autoplay style="max-width:80vw;max-height:80vh;display:block"></video>`
|
||||||
: `<img src="${src}" style="max-width:80vw;max-height:80vh;display:block">`
|
: `<img src="${esc(src)}" style="max-width:80vw;max-height:80vh;display:block">`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:12px 16px;border-top:1px solid var(--border)">
|
<div style="padding:12px 16px;border-top:1px solid var(--border)">
|
||||||
<div style="font-weight:500">${content.filename}</div>
|
<div style="font-weight:500">${esc(content.filename)}</div>
|
||||||
<div style="font-size:12px;color:var(--text-muted)">${content.mime_type} ${content.remote_url ? '(Remote URL)' : ''}</div>
|
<div style="font-size:12px;color:var(--text-muted)">${esc(content.mime_type)} ${content.remote_url ? `(${t('content.type_remote')})` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -490,4 +652,17 @@ function showPreview(content) {
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a "Parent / Child / Leaf" path for a folder so the move-to dropdown is unambiguous
|
||||||
|
// when two folders share a name in different branches.
|
||||||
|
function folderPath(folder, all) {
|
||||||
|
const byId = new Map(all.map(f => [f.id, f]));
|
||||||
|
const parts = [folder.name];
|
||||||
|
let cursor = folder;
|
||||||
|
while (cursor.parent_id && byId.has(cursor.parent_id)) {
|
||||||
|
cursor = byId.get(cursor.parent_id);
|
||||||
|
parts.unshift(cursor.name);
|
||||||
|
}
|
||||||
|
return parts.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
export function cleanup() {}
|
export function cleanup() {}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,47 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { on, off, requestScreenshot } from '../socket.js';
|
import { on, off, requestScreenshot } from '../socket.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
function esc(str) {
|
import { t, tn } from '../i18n.js';
|
||||||
if (!str) return '';
|
|
||||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
||||||
}
|
|
||||||
|
|
||||||
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
||||||
|
// Command types only — labels resolved through t('dashboard.cmd.<type>')
|
||||||
const GROUP_COMMANDS = [
|
const GROUP_COMMANDS = [
|
||||||
{ type: 'screen_on', label: 'Screen On' },
|
{ type: 'screen_on' },
|
||||||
{ type: 'screen_off', label: 'Screen Off' },
|
{ type: 'screen_off' },
|
||||||
{ type: 'launch', label: 'Restart App' },
|
{ type: 'launch' },
|
||||||
{ type: 'update', label: 'Check Update' },
|
{ type: 'update' },
|
||||||
{ type: 'reboot', label: 'Reboot', destructive: true },
|
{ type: 'reboot', destructive: true },
|
||||||
{ type: 'shutdown', label: 'Shutdown', destructive: true },
|
{ type: 'shutdown', destructive: true },
|
||||||
];
|
];
|
||||||
|
const CMD_LABEL_KEY = {
|
||||||
|
screen_on: 'dashboard.cmd.screen_on',
|
||||||
|
screen_off: 'dashboard.cmd.screen_off',
|
||||||
|
launch: 'dashboard.cmd.restart_app',
|
||||||
|
update: 'dashboard.cmd.check_update',
|
||||||
|
reboot: 'dashboard.cmd.reboot',
|
||||||
|
shutdown: 'dashboard.cmd.shutdown',
|
||||||
|
};
|
||||||
|
|
||||||
let statusHandler = null;
|
let statusHandler = null;
|
||||||
let screenshotHandler = null;
|
let screenshotHandler = null;
|
||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
|
let playbackHandler = null;
|
||||||
|
let progressTickInterval = null;
|
||||||
|
let wallChangedHandler = null;
|
||||||
|
// device_id -> { content_name, duration_sec, started_at }
|
||||||
|
const playbackByDevice = new Map();
|
||||||
|
// Multi-select state for the "Create Video Wall" gesture. Holds device_ids
|
||||||
|
// the user has ticked via checkboxes on the dashboard cards.
|
||||||
|
const selectedDeviceIds = new Set();
|
||||||
|
|
||||||
function formatTimeAgo(timestamp) {
|
function formatTimeAgo(timestamp) {
|
||||||
if (!timestamp) return 'Never';
|
if (!timestamp) return t('common.never');
|
||||||
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||||
if (seconds < 60) return 'Just now';
|
if (seconds < 60) return t('common.just_now');
|
||||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
if (seconds < 3600) return t('common.minutes_ago', { n: Math.floor(seconds / 60) });
|
||||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
if (seconds < 86400) return t('common.hours_ago', { n: Math.floor(seconds / 3600) });
|
||||||
return `${Math.floor(seconds / 86400)}d ago`;
|
return t('common.days_ago', { n: Math.floor(seconds / 86400) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(mb) {
|
function formatBytes(mb) {
|
||||||
|
|
@ -36,14 +50,44 @@ function formatBytes(mb) {
|
||||||
return `${mb} MB`;
|
return `${mb} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderProgressFor(deviceId) {
|
||||||
|
const state = playbackByDevice.get(deviceId);
|
||||||
|
document.querySelectorAll(`#progress-${CSS.escape(deviceId)}`).forEach(el => {
|
||||||
|
if (!state) { el.style.display = 'none'; return; }
|
||||||
|
const elapsed = Math.max(0, (Date.now() - state.started_at) / 1000);
|
||||||
|
const name = state.content_name || '';
|
||||||
|
const fill = el.querySelector('.device-card-progress-fill');
|
||||||
|
const nameEl = el.querySelector('.dcp-name');
|
||||||
|
const timeEl = el.querySelector('.dcp-time');
|
||||||
|
if (state.duration_sec && state.duration_sec > 0) {
|
||||||
|
const remaining = Math.max(0, Math.ceil(state.duration_sec - elapsed));
|
||||||
|
const pct = Math.min(100, (elapsed / state.duration_sec) * 100);
|
||||||
|
fill.style.width = pct + '%';
|
||||||
|
if (nameEl) nameEl.textContent = name;
|
||||||
|
if (timeEl) timeEl.textContent = remaining + 's';
|
||||||
|
} else {
|
||||||
|
// Unknown duration (e.g. video plays to end) — show indeterminate state
|
||||||
|
fill.style.width = '100%';
|
||||||
|
fill.classList.add('indeterminate');
|
||||||
|
if (nameEl) nameEl.textContent = name;
|
||||||
|
if (timeEl) timeEl.textContent = '';
|
||||||
|
}
|
||||||
|
el.style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderDeviceCard(device) {
|
function renderDeviceCard(device) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const screenshotUrl = device.screenshot_path
|
const screenshotUrl = device.screenshot_path
|
||||||
? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}`
|
? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const checked = selectedDeviceIds.has(device.id);
|
||||||
return `
|
return `
|
||||||
<div class="device-card" data-device-id="${device.id}" onclick="window.location.hash='/device/${device.id}'">
|
<div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" onclick="window.location.hash='/device/${device.id}'">
|
||||||
|
<label class="device-card-select" title="Select for wall" onclick="event.stopPropagation()">
|
||||||
|
<input type="checkbox" class="device-select-cb" data-device-id="${device.id}"${checked ? ' checked' : ''}>
|
||||||
|
</label>
|
||||||
<div class="device-card-preview" id="preview-${device.id}">
|
<div class="device-card-preview" id="preview-${device.id}">
|
||||||
${screenshotUrl
|
${screenshotUrl
|
||||||
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
|
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
|
||||||
|
|
@ -53,17 +97,21 @@ function renderDeviceCard(device) {
|
||||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>No preview available</span>
|
<span>${t('dashboard.no_preview')}</span>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
<div class="device-card-status">
|
<div class="device-card-status">
|
||||||
<span class="status-dot ${device.status}"></span>
|
<span class="status-dot ${device.status}"></span>
|
||||||
<span>${device.status === 'provisioning' ? 'Awaiting Pairing' : device.status}</span>
|
<span>${device.status === 'provisioning' ? t('dashboard.awaiting_pairing') : device.status}</span>
|
||||||
</div>
|
</div>
|
||||||
${device.status === 'provisioning' && device.pairing_code ? `
|
${device.status === 'provisioning' && device.pairing_code ? `
|
||||||
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#f59e0b;padding:4px 12px;border-radius:6px;font-size:13px;font-weight:600;letter-spacing:2px;font-family:monospace">
|
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#f59e0b;padding:4px 12px;border-radius:6px;font-size:13px;font-weight:600;letter-spacing:2px;font-family:monospace">
|
||||||
${device.pairing_code}
|
${device.pairing_code}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
<div class="device-card-progress" id="progress-${device.id}" style="display:none">
|
||||||
|
<div class="device-card-progress-label"><span class="dcp-name"></span><span class="dcp-time"></span></div>
|
||||||
|
<div class="device-card-progress-track"><div class="device-card-progress-fill"></div></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-card-body">
|
<div class="device-card-body">
|
||||||
<div class="device-card-name">${esc(device.name)}</div>
|
<div class="device-card-name">${esc(device.name)}</div>
|
||||||
|
|
@ -109,28 +157,77 @@ function renderDeviceCard(device) {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGroupSection(group, devices) {
|
function renderWallCard(wall) {
|
||||||
|
// Compose a tiny grid preview using the wall's actual cols×rows. Each cell
|
||||||
|
// is filled (assigned) or hollow (empty slot).
|
||||||
|
const cells = [];
|
||||||
|
for (let r = 0; r < wall.grid_rows; r++) {
|
||||||
|
for (let c = 0; c < wall.grid_cols; c++) {
|
||||||
|
const dev = (wall.devices || []).find(d => d.grid_col === c && d.grid_row === r);
|
||||||
|
cells.push(`<div class="wall-card-cell${dev ? ' filled' : ''}" title="${dev ? esc(dev.device_name) : '[' + c + ',' + r + ']'}"></div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onlineCount = (wall.devices || []).filter(d => d.device_status === 'online').length;
|
||||||
|
return `
|
||||||
|
<div class="device-card wall-card" data-wall-id="${wall.id}" onclick="window.location.hash='#/wall/${wall.id}'">
|
||||||
|
<div class="device-card-preview wall-card-preview">
|
||||||
|
<div class="wall-card-grid" style="grid-template-columns:repeat(${wall.grid_cols},1fr);grid-template-rows:repeat(${wall.grid_rows},1fr)">${cells.join('')}</div>
|
||||||
|
<div class="device-card-status">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
|
||||||
|
<span>${wall.grid_cols}×${wall.grid_rows} wall</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-card-body">
|
||||||
|
<div class="device-card-name">${esc(wall.name)}</div>
|
||||||
|
<div class="device-card-meta">
|
||||||
|
<div class="meta-item">${(wall.devices || []).length} ${(wall.devices || []).length === 1 ? 'tile' : 'tiles'}</div>
|
||||||
|
<div class="meta-item" style="color:${onlineCount === (wall.devices || []).length ? 'var(--success)' : 'var(--text-muted)'}">${onlineCount} online</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupPlaylistLabel(devices, playlists) {
|
||||||
|
const playlistMap = new Map((playlists || []).map(p => [p.id, p]));
|
||||||
|
const assigned = devices.filter(d => d.playlist_id).map(d => d.playlist_id);
|
||||||
|
if (assigned.length === 0) return '';
|
||||||
|
const unique = [...new Set(assigned)];
|
||||||
|
if (unique.length === 1) {
|
||||||
|
const pl = playlistMap.get(unique[0]);
|
||||||
|
return pl ? esc(pl.name) : t('dashboard.unknown_playlist');
|
||||||
|
}
|
||||||
|
return t('dashboard.mixed_playlists');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupSection(group, devices, playlists) {
|
||||||
const onlineCount = devices.filter(d => d.status === 'online').length;
|
const onlineCount = devices.filter(d => d.status === 'online').length;
|
||||||
|
const playlistLabel = getGroupPlaylistLabel(devices, playlists);
|
||||||
return `
|
return `
|
||||||
<div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px">
|
<div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#3B82F6')}">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#3B82F6')}">
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
<strong style="font-size:15px">${esc(group.name)}</strong>
|
<strong style="font-size:15px">${esc(group.name)}</strong>
|
||||||
<span style="color:var(--text-muted);font-size:12px">${devices.length} device${devices.length !== 1 ? 's' : ''} · ${onlineCount} online</span>
|
<span style="color:var(--text-muted);font-size:12px">${tn('dashboard.devices_count', devices.length)} · ${t('dashboard.online_count', { n: onlineCount })}</span>
|
||||||
|
${playlistLabel ? `<span style="font-size:11px;color:var(--text-secondary);background:var(--bg-primary);padding:2px 8px;border-radius:10px">${t('dashboard.playlist_label', { name: playlistLabel })}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px;align-items:center">
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
${devices.length > 0 ? `
|
${devices.length > 0 ? `
|
||||||
|
<select class="input group-playlist-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" style="width:160px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
|
||||||
|
<option value="">${t('dashboard.set_playlist_placeholder')}</option>
|
||||||
|
${(playlists || []).map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' ' + t('dashboard.draft_suffix') : ''}</option>`).join('')}
|
||||||
|
</select>
|
||||||
<select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
|
<select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
|
||||||
<option value="">Send Command...</option>
|
<option value="">${t('dashboard.send_command_placeholder')}</option>
|
||||||
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${c.label}</option>`).join('')}
|
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${t(CMD_LABEL_KEY[c.type])}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
` : ''}
|
` : ''}
|
||||||
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="Add/remove devices">Manage</button>
|
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="${t('dashboard.manage_tooltip')}">${t('dashboard.manage')}</button>
|
||||||
<button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="Delete group">✕</button>
|
<button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="${t('dashboard.delete_group_tooltip')}">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-grid">
|
<div class="device-grid">
|
||||||
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : '<div style="color:var(--text-muted);font-size:13px;padding:8px 12px">No devices in this group. Click Manage to add some.</div>'}
|
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : `<div style="color:var(--text-muted);font-size:13px;padding:8px 12px">${t('dashboard.no_devices_in_group')}</div>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -140,26 +237,36 @@ export function render(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Displays <span class="help-tip" data-tip="Your paired display devices. Green = online, red = offline. Click a device to manage its playlist, view telemetry, or use remote control.">?</span></h1>
|
<h1>${t('dashboard.title')} <span class="help-tip" data-tip="${t('dashboard.help_tip')}">?</span></h1>
|
||||||
<div class="subtitle">Manage your remote displays</div>
|
<div class="subtitle">${t('dashboard.subtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button class="btn" id="createGroupBtn">+ Group</button>
|
<button class="btn" id="createGroupBtn">${t('dashboard.create_group')}</button>
|
||||||
<button class="btn btn-primary" id="addDeviceBtn">
|
<button class="btn btn-primary" id="addDeviceBtn">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
Add Display
|
${t('dashboard.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="dashStats" style="display:flex;gap:12px;margin-bottom:16px"></div>
|
<div id="selectionBar" style="display:none;align-items:center;gap:10px;padding:8px 12px;margin-bottom:12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px">
|
||||||
|
<span id="selectionCount" style="font-weight:500;font-size:13px"></span>
|
||||||
|
<button class="btn btn-primary btn-sm" id="createWallBtn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:4px">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/>
|
||||||
|
</svg>
|
||||||
|
Create Video Wall
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm" id="clearSelectionBtn">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div id="dashStats" class="dash-stats-row" style="display:flex;gap:12px;margin-bottom:16px"></div>
|
||||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
|
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
|
||||||
<input type="text" id="deviceSearch" class="input" placeholder="Search displays..." style="max-width:300px">
|
<input type="text" id="deviceSearch" class="input" placeholder="${t('dashboard.search')}" style="max-width:300px">
|
||||||
<select id="deviceFilter" class="input" style="width:140px;background:var(--bg-input)">
|
<select id="deviceFilter" class="input" style="width:140px;background:var(--bg-input)">
|
||||||
<option value="">All Status</option>
|
<option value="">${t('dashboard.all_status')}</option>
|
||||||
<option value="online">Online</option>
|
<option value="online">${t('dashboard.online')}</option>
|
||||||
<option value="offline">Offline</option>
|
<option value="offline">${t('dashboard.offline')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="groupedDevices"></div>
|
<div id="groupedDevices"></div>
|
||||||
|
|
@ -195,13 +302,13 @@ export function render(container) {
|
||||||
const code = document.getElementById('pairingCodeInput').value.trim();
|
const code = document.getElementById('pairingCodeInput').value.trim();
|
||||||
const name = document.getElementById('deviceNameInput').value.trim();
|
const name = document.getElementById('deviceNameInput').value.trim();
|
||||||
if (!code || code.length !== 6) {
|
if (!code || code.length !== 6) {
|
||||||
showToast('Enter a valid 6-digit pairing code', 'error');
|
showToast(t('dashboard.error_pairing_code'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.pairDevice(code, name || undefined);
|
await api.pairDevice(code, name || undefined);
|
||||||
document.getElementById('addDeviceModal').style.display = 'none';
|
document.getElementById('addDeviceModal').style.display = 'none';
|
||||||
showToast('Display paired successfully!', 'success');
|
showToast(t('dashboard.toast.display_paired'), 'success');
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
|
|
@ -210,15 +317,37 @@ export function render(container) {
|
||||||
|
|
||||||
// Create group
|
// Create group
|
||||||
container.querySelector('#createGroupBtn').addEventListener('click', async () => {
|
container.querySelector('#createGroupBtn').addEventListener('click', async () => {
|
||||||
const name = prompt('Group name:');
|
const name = prompt(t('dashboard.prompt_group_name'));
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
await api.createGroup(name);
|
await api.createGroup(name);
|
||||||
showToast('Group created', 'success');
|
showToast(t('dashboard.toast.group_created'), 'success');
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
} catch (e) { showToast(e.message, 'error'); }
|
} catch (e) { showToast(e.message, 'error'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Multi-select: a checkbox on each device card adds to selectedDeviceIds.
|
||||||
|
// The selection bar shows when 1+ are selected; "Create Video Wall" is the
|
||||||
|
// primary action — it creates the wall, removes devices from any group,
|
||||||
|
// assigns them, and navigates to the editor.
|
||||||
|
container.addEventListener('change', (ev) => {
|
||||||
|
const cb = ev.target.closest?.('.device-select-cb');
|
||||||
|
if (!cb) return;
|
||||||
|
const id = cb.dataset.deviceId;
|
||||||
|
if (cb.checked) selectedDeviceIds.add(id); else selectedDeviceIds.delete(id);
|
||||||
|
cb.closest('.device-card')?.classList.toggle('selected', cb.checked);
|
||||||
|
refreshSelectionBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('clearSelectionBtn').addEventListener('click', () => {
|
||||||
|
selectedDeviceIds.clear();
|
||||||
|
document.querySelectorAll('.device-select-cb').forEach(cb => { cb.checked = false; });
|
||||||
|
document.querySelectorAll('.device-card.selected').forEach(c => c.classList.remove('selected'));
|
||||||
|
refreshSelectionBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('createWallBtn').addEventListener('click', () => createWallFromSelection());
|
||||||
|
|
||||||
// Load everything
|
// Load everything
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
|
|
||||||
|
|
@ -232,7 +361,6 @@ export function render(container) {
|
||||||
};
|
};
|
||||||
|
|
||||||
screenshotHandler = (data) => {
|
screenshotHandler = (data) => {
|
||||||
// Update all instances of this device's preview (may appear in multiple groups)
|
|
||||||
document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => {
|
document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => {
|
||||||
const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token'));
|
const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token'));
|
||||||
const img = preview.querySelector('img');
|
const img = preview.querySelector('img');
|
||||||
|
|
@ -248,10 +376,28 @@ export function render(container) {
|
||||||
const deviceAddedHandler = () => loadDashboard();
|
const deviceAddedHandler = () => loadDashboard();
|
||||||
const deviceRemovedHandler = () => loadDashboard();
|
const deviceRemovedHandler = () => loadDashboard();
|
||||||
|
|
||||||
|
playbackHandler = (data) => {
|
||||||
|
if (!data?.device_id) return;
|
||||||
|
playbackByDevice.set(data.device_id, {
|
||||||
|
content_name: data.content_name || '',
|
||||||
|
duration_sec: data.duration_sec || null,
|
||||||
|
started_at: data.started_at || Date.now(),
|
||||||
|
});
|
||||||
|
renderProgressFor(data.device_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
wallChangedHandler = () => loadDashboard();
|
||||||
|
|
||||||
on('device-status', statusHandler);
|
on('device-status', statusHandler);
|
||||||
on('screenshot-ready', screenshotHandler);
|
on('screenshot-ready', screenshotHandler);
|
||||||
on('device-added', deviceAddedHandler);
|
on('device-added', deviceAddedHandler);
|
||||||
on('device-removed', deviceRemovedHandler);
|
on('device-removed', deviceRemovedHandler);
|
||||||
|
on('playback-progress', playbackHandler);
|
||||||
|
on('wall-changed', wallChangedHandler);
|
||||||
|
|
||||||
|
progressTickInterval = setInterval(() => {
|
||||||
|
for (const id of playbackByDevice.keys()) renderProgressFor(id);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
// Request fresh screenshots on load
|
// Request fresh screenshots on load
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -267,12 +413,77 @@ export function render(container) {
|
||||||
}, 30000);
|
}, 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshSelectionBar() {
|
||||||
|
const bar = document.getElementById('selectionBar');
|
||||||
|
const count = document.getElementById('selectionCount');
|
||||||
|
if (!bar || !count) return;
|
||||||
|
const n = selectedDeviceIds.size;
|
||||||
|
if (n === 0) { bar.style.display = 'none'; return; }
|
||||||
|
bar.style.display = 'flex';
|
||||||
|
// Need at least 2 to make a wall - surface the constraint inline so the
|
||||||
|
// greyed-out button isn't just silently unresponsive.
|
||||||
|
count.textContent = n < 2
|
||||||
|
? `${n} display selected - pick 1 more to create a wall`
|
||||||
|
: `${n} displays selected`;
|
||||||
|
const btn = document.getElementById('createWallBtn');
|
||||||
|
btn.disabled = n < 2;
|
||||||
|
btn.title = n < 2 ? 'Select at least 2 displays to create a video wall' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a sensible default grid for n devices: prefer near-square layouts,
|
||||||
|
// breaking ties toward more columns (more common physical wall layout).
|
||||||
|
function defaultGridForCount(n) {
|
||||||
|
if (n <= 1) return { cols: 1, rows: 1 };
|
||||||
|
if (n === 2) return { cols: 2, rows: 1 };
|
||||||
|
if (n === 3) return { cols: 3, rows: 1 };
|
||||||
|
if (n === 4) return { cols: 2, rows: 2 };
|
||||||
|
if (n === 6) return { cols: 3, rows: 2 };
|
||||||
|
if (n === 8) return { cols: 4, rows: 2 };
|
||||||
|
if (n === 9) return { cols: 3, rows: 3 };
|
||||||
|
// Generic fallback — square-ish, columns >= rows
|
||||||
|
const cols = Math.ceil(Math.sqrt(n));
|
||||||
|
const rows = Math.ceil(n / cols);
|
||||||
|
return { cols, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWallFromSelection() {
|
||||||
|
const ids = [...selectedDeviceIds];
|
||||||
|
if (ids.length < 2) { showToast('Select at least 2 displays', 'error'); return; }
|
||||||
|
const name = prompt('Name this video wall:', `Wall ${new Date().toLocaleString()}`);
|
||||||
|
if (!name) return;
|
||||||
|
const { cols, rows } = defaultGridForCount(ids.length);
|
||||||
|
try {
|
||||||
|
const wall = await api.createWall({ name, grid_cols: cols, grid_rows: rows });
|
||||||
|
// Pack selected devices into row-major order. The user can reposition in
|
||||||
|
// the editor; this just gives every selection a sensible starting tile.
|
||||||
|
const placement = ids.slice(0, cols * rows).map((id, i) => ({
|
||||||
|
device_id: id,
|
||||||
|
grid_col: i % cols,
|
||||||
|
grid_row: Math.floor(i / cols),
|
||||||
|
}));
|
||||||
|
await api.setWallDevices(wall.id, placement);
|
||||||
|
selectedDeviceIds.clear();
|
||||||
|
showToast('Video wall created', 'success');
|
||||||
|
window.location.hash = `#/wall/${wall.id}`;
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
const main = document.getElementById('groupedDevices');
|
const main = document.getElementById('groupedDevices');
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [devices, groups] = await Promise.all([api.getDevices(), api.getGroups()]);
|
const [rawDevices, groups, playlists, walls] = await Promise.all([
|
||||||
|
api.getDevices(), api.getGroups(), api.getPlaylists(), api.getWalls(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Deduplicate devices by id — a stale reconnect race can briefly cause the same
|
||||||
|
// device to appear twice in the list. Last-write-wins keeps the freshest state.
|
||||||
|
const seen = new Map();
|
||||||
|
for (const d of rawDevices) seen.set(d.id, d);
|
||||||
|
const devices = Array.from(seen.values());
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
const online = devices.filter(d => d.status === 'online').length;
|
const online = devices.filter(d => d.status === 'online').length;
|
||||||
|
|
@ -282,20 +493,20 @@ async function loadDashboard() {
|
||||||
if (statsEl) {
|
if (statsEl) {
|
||||||
statsEl.innerHTML = `
|
statsEl.innerHTML = `
|
||||||
<div class="info-card" style="flex:1;min-width:120px">
|
<div class="info-card" style="flex:1;min-width:120px">
|
||||||
<div class="info-card-label">Total Displays</div>
|
<div class="info-card-label">${t('dashboard.total_displays')}</div>
|
||||||
<div class="info-card-value">${devices.length}</div>
|
<div class="info-card-value">${devices.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card" style="flex:1;min-width:120px">
|
<div class="info-card" style="flex:1;min-width:120px">
|
||||||
<div class="info-card-label">Online</div>
|
<div class="info-card-label">${t('dashboard.online')}</div>
|
||||||
<div class="info-card-value" style="color:var(--success)">${online}</div>
|
<div class="info-card-value" style="color:var(--success)">${online}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card" style="flex:1;min-width:120px">
|
<div class="info-card" style="flex:1;min-width:120px">
|
||||||
<div class="info-card-label">Offline</div>
|
<div class="info-card-label">${t('dashboard.offline')}</div>
|
||||||
<div class="info-card-value" style="color:${offline > 0 ? 'var(--danger)' : 'var(--text-muted)'}">${offline}</div>
|
<div class="info-card-value" style="color:${offline > 0 ? 'var(--danger)' : 'var(--text-muted)'}">${offline}</div>
|
||||||
</div>
|
</div>
|
||||||
${provisioning > 0 ? `
|
${provisioning > 0 ? `
|
||||||
<div class="info-card" style="flex:1;min-width:120px">
|
<div class="info-card" style="flex:1;min-width:120px">
|
||||||
<div class="info-card-label">Awaiting Pairing</div>
|
<div class="info-card-label">${t('dashboard.awaiting_pairing')}</div>
|
||||||
<div class="info-card-value" style="color:var(--warning,#f59e0b)">${provisioning}</div>
|
<div class="info-card-value" style="color:var(--warning,#f59e0b)">${provisioning}</div>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
@ -309,42 +520,72 @@ async function loadDashboard() {
|
||||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3>No displays yet</h3>
|
<h3>${t('dashboard.no_displays')}</h3>
|
||||||
<p>Install the ScreenTinker app on your Apolosign TV and pair it using the button above.</p>
|
<p>${t('dashboard.no_displays_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Devices that belong to a wall are owned by that wall — they don't appear
|
||||||
|
// as their own cards anywhere on the dashboard. The wall's card stands in.
|
||||||
|
const walledDeviceIds = new Set();
|
||||||
|
for (const w of (walls || [])) for (const d of (w.devices || [])) walledDeviceIds.add(d.device_id);
|
||||||
|
const dashboardDevices = devices.filter(d => !walledDeviceIds.has(d.id));
|
||||||
|
|
||||||
// Fetch group memberships
|
// Fetch group memberships
|
||||||
const groupsWithDevices = await Promise.all(groups.map(async g => {
|
const groupsWithDevices = await Promise.all(groups.map(async g => {
|
||||||
const members = await api.getGroupDevices(g.id);
|
const members = await api.getGroupDevices(g.id);
|
||||||
const memberIds = new Set(members.map(m => m.id));
|
const memberIds = new Set(members.map(m => m.id));
|
||||||
// Use full device data from the main devices list (has telemetry/screenshots)
|
// Use full device data from the main devices list (has telemetry/screenshots)
|
||||||
const fullDevices = devices.filter(d => memberIds.has(d.id));
|
// and exclude any wall members.
|
||||||
|
const fullDevices = dashboardDevices.filter(d => memberIds.has(d.id));
|
||||||
return { ...g, devices: fullDevices, memberIds };
|
return { ...g, devices: fullDevices, memberIds };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Find ungrouped devices
|
// Render each device exactly once: the first group it belongs to wins.
|
||||||
const allGroupedIds = new Set();
|
// memberIds is preserved for the Manage modal so multi-group membership info stays accurate.
|
||||||
groupsWithDevices.forEach(g => g.memberIds.forEach(id => allGroupedIds.add(id)));
|
const renderedIds = new Set();
|
||||||
const ungrouped = devices.filter(d => !allGroupedIds.has(d.id));
|
for (const g of groupsWithDevices) {
|
||||||
|
g.devices = g.devices.filter(d => {
|
||||||
|
if (renderedIds.has(d.id)) return false;
|
||||||
|
renderedIds.add(d.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ungrouped = dashboardDevices.filter(d => !renderedIds.has(d.id));
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
// Render each group with its devices
|
// Walls render before groups: they're a higher-level construct (multiple
|
||||||
for (const g of groupsWithDevices) {
|
// physical screens acting as one logical display).
|
||||||
html += renderGroupSection(g, g.devices);
|
if ((walls || []).length > 0) {
|
||||||
|
html += `
|
||||||
|
<div class="wall-section" style="margin-bottom:24px">
|
||||||
|
<div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid #8b5cf6">
|
||||||
|
<strong style="font-size:15px">Video Walls</strong>
|
||||||
|
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${walls.length} wall${walls.length === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-grid">${walls.map(renderWallCard).join('')}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render ungrouped devices
|
// Render each group with its devices
|
||||||
|
for (const g of groupsWithDevices) {
|
||||||
|
html += renderGroupSection(g, g.devices, playlists);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render ungrouped devices. The wrapper is tagged data-ungrouped="1" so
|
||||||
|
// attachGroupHandlers can wire it as a drop target — dropping a device here
|
||||||
|
// removes it from every group it currently belongs to.
|
||||||
if (ungrouped.length > 0) {
|
if (ungrouped.length > 0) {
|
||||||
html += `
|
html += `
|
||||||
<div style="margin-bottom:24px">
|
<div class="ungrouped-section" data-ungrouped="1" style="margin-bottom:24px">
|
||||||
${groups.length > 0 ? `
|
${groups.length > 0 ? `
|
||||||
<div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid var(--text-muted)">
|
<div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid var(--text-muted)">
|
||||||
<strong style="font-size:15px;color:var(--text-muted)">Ungrouped</strong>
|
<strong style="font-size:15px;color:var(--text-muted)">${t('dashboard.ungrouped')}</strong>
|
||||||
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${ungrouped.length} device${ungrouped.length !== 1 ? 's' : ''}</span>
|
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${tn('dashboard.devices_count', ungrouped.length)}</span>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
<div class="device-grid">
|
<div class="device-grid">
|
||||||
${ungrouped.map(renderDeviceCard).join('')}
|
${ungrouped.map(renderDeviceCard).join('')}
|
||||||
|
|
@ -354,14 +595,133 @@ async function loadDashboard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
main.innerHTML = html;
|
main.innerHTML = html;
|
||||||
attachGroupHandlers(groupsWithDevices, devices);
|
attachGroupHandlers(groupsWithDevices, dashboardDevices);
|
||||||
|
|
||||||
|
// Drop any selections for devices that have since been absorbed into a
|
||||||
|
// wall, and update the toolbar.
|
||||||
|
for (const id of [...selectedDeviceIds]) {
|
||||||
|
if (walledDeviceIds.has(id)) selectedDeviceIds.delete(id);
|
||||||
|
}
|
||||||
|
refreshSelectionBar();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
main.innerHTML = `<div class="empty-state"><h3>Failed to load displays</h3><p>${err.message}</p></div>`;
|
main.innerHTML = `<div class="empty-state"><h3>${t('dashboard.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachGroupHandlers(groupsWithDevices, allDevices) {
|
function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||||||
|
// Drag-and-drop: device cards are draggable; group sections + the Ungrouped
|
||||||
|
// wrapper are drop targets. Drop on a group adds membership (mirrors the
|
||||||
|
// Manage modal). Drop on Ungrouped removes the device from every group it's
|
||||||
|
// currently a member of.
|
||||||
|
const groupsByDeviceId = new Map();
|
||||||
|
for (const g of groupsWithDevices) {
|
||||||
|
g.memberIds.forEach(id => {
|
||||||
|
if (!groupsByDeviceId.has(id)) groupsByDeviceId.set(id, []);
|
||||||
|
groupsByDeviceId.get(id).push({ id: g.id, name: g.name });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.device-card').forEach(card => {
|
||||||
|
card.addEventListener('dragstart', (e) => {
|
||||||
|
e.dataTransfer.setData('text/device-id', card.dataset.deviceId);
|
||||||
|
e.dataTransfer.setData('text/device-name', card.dataset.deviceName || '');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function highlightOn(el) { el.style.outline = '2px solid var(--primary)'; el.style.outlineOffset = '2px'; }
|
||||||
|
function highlightOff(el) { el.style.outline = ''; el.style.outlineOffset = ''; }
|
||||||
|
|
||||||
|
document.querySelectorAll('.group-section').forEach(section => {
|
||||||
|
section.addEventListener('dragover', (e) => {
|
||||||
|
if (!e.dataTransfer.types.includes('text/device-id')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
highlightOn(section);
|
||||||
|
});
|
||||||
|
section.addEventListener('dragleave', (e) => {
|
||||||
|
// Avoid flicker when moving across child elements
|
||||||
|
if (e.target === section) highlightOff(section);
|
||||||
|
});
|
||||||
|
section.addEventListener('drop', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightOff(section);
|
||||||
|
const deviceId = e.dataTransfer.getData('text/device-id');
|
||||||
|
const deviceName = e.dataTransfer.getData('text/device-name') || 'this device';
|
||||||
|
if (!deviceId) return;
|
||||||
|
const groupId = section.dataset.groupId;
|
||||||
|
const targetGroup = groupsWithDevices.find(g => g.id === groupId);
|
||||||
|
if (!targetGroup) return;
|
||||||
|
// Already in this group — no-op.
|
||||||
|
if (targetGroup.memberIds.has(deviceId)) {
|
||||||
|
showToast(t('dashboard.toast.already_in_group', { name: deviceName, group: targetGroup.name }), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If the device is in another group, mirror the Manage modal's confirm.
|
||||||
|
const others = (groupsByDeviceId.get(deviceId) || []).map(g => g.name);
|
||||||
|
if (others.length > 0) {
|
||||||
|
if (!confirm(t('dashboard.confirm_add_to_group', { name: deviceName, groups: others.join(', '), target: targetGroup.name }))) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.addDeviceToGroup(groupId, deviceId);
|
||||||
|
showToast(t('dashboard.toast.moved_device', { name: deviceName, group: targetGroup.name }), 'success');
|
||||||
|
loadDashboard();
|
||||||
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ungrouped wrapper: remove device from every group it's in.
|
||||||
|
document.querySelectorAll('[data-ungrouped="1"]').forEach(section => {
|
||||||
|
section.addEventListener('dragover', (e) => {
|
||||||
|
if (!e.dataTransfer.types.includes('text/device-id')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
highlightOn(section);
|
||||||
|
});
|
||||||
|
section.addEventListener('dragleave', (e) => {
|
||||||
|
if (e.target === section) highlightOff(section);
|
||||||
|
});
|
||||||
|
section.addEventListener('drop', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightOff(section);
|
||||||
|
const deviceId = e.dataTransfer.getData('text/device-id');
|
||||||
|
const deviceName = e.dataTransfer.getData('text/device-name') || 'this device';
|
||||||
|
if (!deviceId) return;
|
||||||
|
const memberships = groupsByDeviceId.get(deviceId) || [];
|
||||||
|
if (memberships.length === 0) return; // already ungrouped
|
||||||
|
try {
|
||||||
|
await Promise.all(memberships.map(m => api.removeDeviceFromGroup(m.id, deviceId)));
|
||||||
|
showToast(tn('dashboard.toast.removed_device', memberships.length, { name: deviceName }), 'success');
|
||||||
|
loadDashboard();
|
||||||
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Playlist assignment handlers
|
||||||
|
document.querySelectorAll('.group-playlist-select').forEach(select => {
|
||||||
|
select.addEventListener('change', async (e) => {
|
||||||
|
const playlistId = e.target.value;
|
||||||
|
if (!playlistId) return;
|
||||||
|
const groupId = e.target.dataset.groupId;
|
||||||
|
const groupName = e.target.dataset.groupName;
|
||||||
|
const playlistName = e.target.options[e.target.selectedIndex].textContent;
|
||||||
|
|
||||||
|
if (!confirm(t('dashboard.confirm_assign_playlist', { playlist: playlistName, group: groupName }))) {
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.groupAssignPlaylist(groupId, playlistId);
|
||||||
|
showToast(tn('dashboard.toast.playlist_assigned', result.devices_updated), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Command select handlers
|
// Command select handlers
|
||||||
document.querySelectorAll('.group-cmd-select').forEach(select => {
|
document.querySelectorAll('.group-cmd-select').forEach(select => {
|
||||||
select.addEventListener('change', async (e) => {
|
select.addEventListener('change', async (e) => {
|
||||||
|
|
@ -370,9 +730,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||||||
const groupId = e.target.dataset.groupId;
|
const groupId = e.target.dataset.groupId;
|
||||||
const groupName = e.target.dataset.groupName;
|
const groupName = e.target.dataset.groupName;
|
||||||
const count = e.target.dataset.deviceCount;
|
const count = e.target.dataset.deviceCount;
|
||||||
|
const cmdLabel = t(CMD_LABEL_KEY[type] || type);
|
||||||
|
|
||||||
if (DESTRUCTIVE_COMMANDS.includes(type)) {
|
if (DESTRUCTIVE_COMMANDS.includes(type)) {
|
||||||
if (!confirm(`${type.toUpperCase()} all ${count} device${count !== '1' ? 's' : ''} in "${groupName}"?\n\nThis cannot be undone.`)) {
|
if (!confirm(t('dashboard.confirm_destructive_command', { cmd: cmdLabel.toUpperCase(), n: count, group: groupName }))) {
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -380,7 +741,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.sendGroupCommand(groupId, type);
|
const result = await api.sendGroupCommand(groupId, type);
|
||||||
showToast(`${type} sent to ${result.sent}/${result.total} devices${result.offline > 0 ? ` (${result.offline} offline)` : ''}`, result.offline > 0 ? 'warning' : 'success');
|
const msg = result.offline > 0
|
||||||
|
? t('dashboard.toast.command_sent_with_offline', { cmd: cmdLabel, sent: result.sent, total: result.total, offline: result.offline })
|
||||||
|
: t('dashboard.toast.command_sent', { cmd: cmdLabel, sent: result.sent, total: result.total });
|
||||||
|
showToast(msg, result.offline > 0 ? 'warning' : 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
}
|
}
|
||||||
|
|
@ -393,10 +757,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const id = btn.dataset.groupDelete;
|
const id = btn.dataset.groupDelete;
|
||||||
if (!confirm('Delete this group? Devices will not be affected.')) return;
|
if (!confirm(t('dashboard.confirm_delete_group'))) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteGroup(id);
|
await api.deleteGroup(id);
|
||||||
showToast('Group deleted', 'success');
|
showToast(t('dashboard.toast.group_deleted'), 'success');
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
} catch (e) { showToast(e.message, 'error'); }
|
} catch (e) { showToast(e.message, 'error'); }
|
||||||
});
|
});
|
||||||
|
|
@ -418,7 +782,7 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div style="background:var(--bg-card);border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:70vh;overflow-y:auto">
|
<div style="background:var(--bg-card);border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:70vh;overflow-y:auto">
|
||||||
<h3 style="margin:0 0 4px">${esc(group.name)}</h3>
|
<h3 style="margin:0 0 4px">${esc(group.name)}</h3>
|
||||||
<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">Check devices to add them to this group</p>
|
<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">${t('dashboard.manage_group_subtitle')}</p>
|
||||||
<div style="display:flex;flex-direction:column;gap:6px">
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
${allDevices.filter(d => d.status !== 'provisioning').map(d => {
|
${allDevices.filter(d => d.status !== 'provisioning').map(d => {
|
||||||
const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name);
|
const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name);
|
||||||
|
|
@ -433,7 +797,7 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
|
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
|
||||||
<button class="btn" id="manageGroupClose">Done</button>
|
<button class="btn" id="manageGroupClose">${t('common.done')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -446,9 +810,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||||||
cb.addEventListener('change', async () => {
|
cb.addEventListener('change', async () => {
|
||||||
const deviceId = cb.dataset.deviceId;
|
const deviceId = cb.dataset.deviceId;
|
||||||
const existingGroups = cb.dataset.inGroups;
|
const existingGroups = cb.dataset.inGroups;
|
||||||
|
const cbName = cb.closest('label')?.querySelector('span:not(.status-dot)')?.textContent || '';
|
||||||
try {
|
try {
|
||||||
if (cb.checked && existingGroups) {
|
if (cb.checked && existingGroups) {
|
||||||
if (!confirm(`This device is already in: ${existingGroups}\n\nAdd it to "${group.name}" too?`)) {
|
if (!confirm(t('dashboard.confirm_add_to_group', { name: cbName, groups: existingGroups, target: group.name }))) {
|
||||||
cb.checked = false;
|
cb.checked = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -471,10 +836,17 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||||||
export function cleanup() {
|
export function cleanup() {
|
||||||
if (statusHandler) off('device-status', statusHandler);
|
if (statusHandler) off('device-status', statusHandler);
|
||||||
if (screenshotHandler) off('screenshot-ready', screenshotHandler);
|
if (screenshotHandler) off('screenshot-ready', screenshotHandler);
|
||||||
|
if (playbackHandler) off('playback-progress', playbackHandler);
|
||||||
|
if (wallChangedHandler) off('wall-changed', wallChangedHandler);
|
||||||
off('device-added', () => {});
|
off('device-added', () => {});
|
||||||
off('device-removed', () => {});
|
off('device-removed', () => {});
|
||||||
if (refreshInterval) clearInterval(refreshInterval);
|
if (refreshInterval) clearInterval(refreshInterval);
|
||||||
|
if (progressTickInterval) clearInterval(progressTickInterval);
|
||||||
statusHandler = null;
|
statusHandler = null;
|
||||||
screenshotHandler = null;
|
screenshotHandler = null;
|
||||||
|
playbackHandler = null;
|
||||||
|
wallChangedHandler = null;
|
||||||
refreshInterval = null;
|
refreshInterval = null;
|
||||||
|
progressTickInterval = null;
|
||||||
|
playbackByDevice.clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
|
// Background swatches: ids resolve to translated names; values are the actual
|
||||||
|
// CSS to apply.
|
||||||
const BACKGROUNDS = [
|
const BACKGROUNDS = [
|
||||||
{ name: 'Black', value: '#000000' },
|
{ id: 'black', value: '#000000' },
|
||||||
{ name: 'Dark Blue', value: '#0f172a' },
|
{ id: 'dark_blue', value: '#0f172a' },
|
||||||
{ name: 'Dark Gradient', value: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)' },
|
{ id: 'dark_gradient', value: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)' },
|
||||||
{ name: 'Blue Gradient', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
{ id: 'blue_gradient', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||||||
{ name: 'Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
{ id: 'sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||||||
{ name: 'Ocean', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
{ id: 'ocean', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
||||||
{ name: 'Forest', value: 'linear-gradient(135deg, #134e5e 0%, #71b280 100%)' },
|
{ id: 'forest', value: 'linear-gradient(135deg, #134e5e 0%, #71b280 100%)' },
|
||||||
{ name: 'Dark Red', value: 'linear-gradient(135deg, #200122 0%, #6f0000 100%)' },
|
{ id: 'dark_red', value: 'linear-gradient(135deg, #200122 0%, #6f0000 100%)' },
|
||||||
{ name: 'White', value: '#FFFFFF' },
|
{ id: 'white', value: '#FFFFFF' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FONTS = ['Arial', 'Helvetica', 'Georgia', 'Impact', 'Verdana', 'Trebuchet MS', 'Courier New', 'Times New Roman'];
|
const FONTS = ['Arial', 'Helvetica', 'Georgia', 'Impact', 'Verdana', 'Trebuchet MS', 'Courier New', 'Times New Roman'];
|
||||||
|
|
@ -30,64 +34,77 @@ export function render(container) {
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>Content Designer <span class="help-tip" data-tip="Create custom signage with live elements: clocks, weather, RSS tickers, countdowns, QR codes. Publish as a widget or export as PNG.">?</span></h1><div class="subtitle">Create dynamic signage content</div></div>
|
<div><h1>${t('designer.title')} <span class="help-tip" data-tip="${t('designer.help_tip')}">?</span></h1><div class="subtitle">${t('designer.subtitle')}</div></div>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button class="btn btn-secondary" id="loadDesignBtn">Load Design</button>
|
<button class="btn btn-secondary" id="loadDesignBtn">${t('designer.load_design')}</button>
|
||||||
<button class="btn btn-secondary" id="exportPngBtn">Export PNG</button>
|
<button class="btn btn-secondary" id="exportPngBtn">${t('designer.export_png')}</button>
|
||||||
<button class="btn btn-primary" id="publishBtn">Publish to Library</button>
|
<button class="btn btn-primary" id="publishBtn">${t('designer.publish')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:20px">
|
<div style="display:flex;gap:20px">
|
||||||
<!-- Preview -->
|
<!-- Preview -->
|
||||||
<div style="flex:1">
|
<div style="flex:1">
|
||||||
<div id="previewWrap" style="position:relative;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;background:#000;aspect-ratio:16/9">
|
<div id="previewWrap" style="position:relative;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;background:#000;aspect-ratio:16/9">
|
||||||
<div id="designPreview" style="position:relative;width:100%;height:100%;overflow:hidden"></div>
|
<div id="designPreview" style="position:relative;width:100%;height:100%;overflow:hidden;container-type:inline-size"></div>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:11px;color:var(--text-muted);margin-top:8px">Click elements to select. Drag to reposition. Live preview updates in real-time.</p>
|
<p style="font-size:11px;color:var(--text-muted);margin-top:8px">${t('designer.preview_hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div style="width:300px;display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 120px);overflow-y:auto">
|
<div style="width:300px;display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 120px);overflow-y:auto">
|
||||||
|
<!-- AI Generate (#41) -->
|
||||||
|
<div style="background:var(--bg-card);border:1px solid var(--accent);border-radius:var(--radius);padding:12px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||||
|
<h4 style="font-size:13px">${t('designer.ai.title')}</h4>
|
||||||
|
<button class="btn-icon" id="aiSettingsBtn" title="${t('designer.ai.settings')}" style="padding:2px">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="aiPrompt" rows="2" class="input" placeholder="${t('designer.ai.placeholder')}" style="width:100%;resize:vertical;font-size:12px"></textarea>
|
||||||
|
<button class="btn btn-primary btn-sm" id="aiGenerateBtn" style="width:100%;justify-content:center;margin-top:6px">${t('designer.ai.generate')}</button>
|
||||||
|
<div id="aiStatus" style="font-size:11px;color:var(--text-muted);margin-top:6px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add Elements -->
|
<!-- Add Elements -->
|
||||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
||||||
<h4 style="font-size:13px;margin-bottom:10px">Add Element</h4>
|
<h4 style="font-size:13px;margin-bottom:10px">${t('designer.add_element')}</h4>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
|
||||||
<button class="btn btn-secondary btn-sm" id="addText" style="justify-content:center">💬 Text</button>
|
<button class="btn btn-secondary btn-sm" id="addText" style="justify-content:center">💬 ${t('designer.el.text')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addHeading" style="justify-content:center">📜 Heading</button>
|
<button class="btn btn-secondary btn-sm" id="addHeading" style="justify-content:center">📜 ${t('designer.el.heading')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addImage" style="justify-content:center">📷 Image</button>
|
<button class="btn btn-secondary btn-sm" id="addImage" style="justify-content:center">📷 ${t('designer.el.image')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addVideo" style="justify-content:center">🎬 Video</button>
|
<button class="btn btn-secondary btn-sm" id="addVideo" style="justify-content:center">🎬 ${t('designer.el.video')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addClock" style="justify-content:center">🕓 Clock</button>
|
<button class="btn btn-secondary btn-sm" id="addClock" style="justify-content:center">🕓 ${t('designer.el.clock')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addDate" style="justify-content:center">📅 Date</button>
|
<button class="btn btn-secondary btn-sm" id="addDate" style="justify-content:center">📅 ${t('designer.el.date')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addWeather" style="justify-content:center">⛅ Weather</button>
|
<button class="btn btn-secondary btn-sm" id="addWeather" style="justify-content:center">⛅ ${t('designer.el.weather')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addTicker" style="justify-content:center">📰 Ticker</button>
|
<button class="btn btn-secondary btn-sm" id="addTicker" style="justify-content:center">📰 ${t('designer.el.ticker')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addShape" style="justify-content:center">■ Shape</button>
|
<button class="btn btn-secondary btn-sm" id="addShape" style="justify-content:center">■ ${t('designer.el.shape')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addQR" style="justify-content:center">▩ QR Code</button>
|
<button class="btn btn-secondary btn-sm" id="addQR" style="justify-content:center">▩ ${t('designer.el.qr')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addCountdown" style="justify-content:center">⏱ Countdown</button>
|
<button class="btn btn-secondary btn-sm" id="addCountdown" style="justify-content:center">⏱ ${t('designer.el.countdown')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="addWebpage" style="justify-content:center">🌐 Webpage</button>
|
<button class="btn btn-secondary btn-sm" id="addWebpage" style="justify-content:center">🌐 ${t('designer.el.webpage')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Background -->
|
<!-- Background -->
|
||||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
||||||
<h4 style="font-size:13px;margin-bottom:8px">Background</h4>
|
<h4 style="font-size:13px;margin-bottom:8px">${t('designer.background')}</h4>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
|
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
|
||||||
${BACKGROUNDS.map(b => `<div style="width:30px;height:30px;border-radius:4px;cursor:pointer;border:2px solid var(--border);background:${b.value}" data-bg="${b.value}" title="${b.name}"></div>`).join('')}
|
${BACKGROUNDS.map(b => `<div style="width:30px;height:30px;border-radius:4px;cursor:pointer;border:2px solid var(--border);background:${b.value}" data-bg="${b.value}" title="${t('designer.bg.' + b.id)}"></div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px">
|
<div style="display:flex;gap:6px">
|
||||||
<input type="color" id="bgColor" value="#000000" style="flex:1;height:32px;border:none;cursor:pointer;border-radius:4px">
|
<input type="color" id="bgColor" value="#000000" style="flex:1;height:32px;border:none;cursor:pointer;border-radius:4px">
|
||||||
<button class="btn btn-secondary btn-sm" id="bgImageBtn">Image</button>
|
<button class="btn btn-secondary btn-sm" id="bgImageBtn">${t('designer.bg_image')}</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="bgImageInput" style="display:none" accept="image/*">
|
<input type="file" id="bgImageInput" style="display:none" accept="image/*">
|
||||||
</div>
|
</div>
|
||||||
<!-- Properties -->
|
<!-- Properties -->
|
||||||
<div id="propPanel" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px;display:none">
|
<div id="propPanel" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px;display:none">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||||
<h4 style="font-size:13px">Properties</h4>
|
<h4 style="font-size:13px">${t('designer.properties')}</h4>
|
||||||
<button class="btn btn-danger btn-sm" id="deleteEl">Delete</button>
|
<button class="btn btn-danger btn-sm" id="deleteEl">${t('common.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="propFields"></div>
|
<div id="propFields"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Layers -->
|
<!-- Layers -->
|
||||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
||||||
<h4 style="font-size:13px;margin-bottom:8px">Layers</h4>
|
<h4 style="font-size:13px;margin-bottom:8px">${t('designer.layers')}</h4>
|
||||||
<div id="layerList" style="font-size:12px"></div>
|
<div id="layerList" style="font-size:12px"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -107,9 +124,40 @@ export function render(container) {
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AI generate (#41): prompt -> validated design spec -> load onto the canvas.
|
||||||
|
document.getElementById('aiSettingsBtn').onclick = openAiSettings;
|
||||||
|
const aiGenBtn = document.getElementById('aiGenerateBtn');
|
||||||
|
aiGenBtn.onclick = async () => {
|
||||||
|
const prompt = document.getElementById('aiPrompt').value.trim();
|
||||||
|
const status = document.getElementById('aiStatus');
|
||||||
|
if (!prompt) { status.textContent = t('designer.ai.need_prompt'); return; }
|
||||||
|
aiGenBtn.disabled = true; aiGenBtn.textContent = t('designer.ai.generating');
|
||||||
|
status.textContent = t('designer.ai.contacting');
|
||||||
|
try {
|
||||||
|
const design = await api.aiGenerateDesign(prompt);
|
||||||
|
elements = []; selectedIdx = -1;
|
||||||
|
if (design.backgroundImage) {
|
||||||
|
bgImageDataUrl = design.backgroundImage; // AI-generated backdrop
|
||||||
|
if (design.background) bgValue = design.background; // kept as fallback
|
||||||
|
} else if (design.background) {
|
||||||
|
bgValue = design.background; bgImageDataUrl = null;
|
||||||
|
const bc = document.getElementById('bgColor'); if (bc) bc.value = design.background;
|
||||||
|
}
|
||||||
|
(design.elements || []).forEach(el => elements.push(el));
|
||||||
|
redraw();
|
||||||
|
status.textContent = design.image_warning
|
||||||
|
? t('designer.ai.done_imgwarn', { n: (design.elements || []).length })
|
||||||
|
: t('designer.ai.done', { n: (design.elements || []).length });
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = (err && err.message) || t('designer.ai.failed');
|
||||||
|
} finally {
|
||||||
|
aiGenBtn.disabled = false; aiGenBtn.textContent = t('designer.ai.generate');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Add element handlers
|
// Add element handlers
|
||||||
document.getElementById('addText').onclick = () => addElement({ type: 'text', x: 10, y: 60, text: 'Your text here', fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', bold: false, shadow: false });
|
document.getElementById('addText').onclick = () => addElement({ type: 'text', x: 10, y: 60, text: t('designer.default.text'), fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', bold: false, shadow: false });
|
||||||
document.getElementById('addHeading').onclick = () => addElement({ type: 'text', x: 5, y: 5, text: 'HEADING', fontSize: 64, fontFamily: 'Impact', color: '#FFFFFF', bold: true, shadow: true });
|
document.getElementById('addHeading').onclick = () => addElement({ type: 'text', x: 5, y: 5, text: t('designer.default.heading'), fontSize: 64, fontFamily: 'Impact', color: '#FFFFFF', bold: true, shadow: true });
|
||||||
document.getElementById('addImage').onclick = () => {
|
document.getElementById('addImage').onclick = () => {
|
||||||
const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*';
|
const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*';
|
||||||
input.onchange = () => {
|
input.onchange = () => {
|
||||||
|
|
@ -120,30 +168,30 @@ export function render(container) {
|
||||||
input.click();
|
input.click();
|
||||||
};
|
};
|
||||||
document.getElementById('addVideo').onclick = () => {
|
document.getElementById('addVideo').onclick = () => {
|
||||||
const url = prompt('Video URL (MP4):');
|
const url = prompt(t('designer.prompt.video_url'));
|
||||||
if (url) addElement({ type: 'video', x: 5, y: 5, width: 50, height: 50, src: url, muted: true, loop: true });
|
if (url) addElement({ type: 'video', x: 5, y: 5, width: 50, height: 50, src: url, muted: true, loop: true });
|
||||||
};
|
};
|
||||||
document.getElementById('addClock').onclick = () => addElement({ type: 'clock', x: 60, y: 5, fontSize: 48, fontFamily: 'Arial', color: '#FFFFFF', format: '12h', showSeconds: true, shadow: true });
|
document.getElementById('addClock').onclick = () => addElement({ type: 'clock', x: 60, y: 5, fontSize: 48, fontFamily: 'Arial', color: '#FFFFFF', format: '12h', showSeconds: true, shadow: true });
|
||||||
document.getElementById('addDate').onclick = () => addElement({ type: 'date', x: 60, y: 20, fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', shadow: false });
|
document.getElementById('addDate').onclick = () => addElement({ type: 'date', x: 60, y: 20, fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', shadow: false });
|
||||||
document.getElementById('addWeather').onclick = () => {
|
document.getElementById('addWeather').onclick = () => {
|
||||||
const location = prompt('City, State:', 'Milwaukee, WI');
|
const location = prompt(t('designer.prompt.weather_location'), 'Milwaukee, WI');
|
||||||
if (location) addElement({ type: 'weather', x: 5, y: 70, fontSize: 36, color: '#FFFFFF', location, units: 'imperial' });
|
if (location) addElement({ type: 'weather', x: 5, y: 70, fontSize: 36, color: '#FFFFFF', location, units: 'imperial' });
|
||||||
};
|
};
|
||||||
document.getElementById('addTicker').onclick = () => {
|
document.getElementById('addTicker').onclick = () => {
|
||||||
const url = prompt('RSS Feed URL:', 'https://feeds.bbci.co.uk/news/rss.xml');
|
const url = prompt(t('designer.prompt.rss_url'), 'https://feeds.bbci.co.uk/news/rss.xml');
|
||||||
if (url) addElement({ type: 'ticker', x: 0, y: 90, width: 100, height: 10, feedUrl: url, speed: 30, fontSize: 20, color: '#FFFFFF', bgColor: 'rgba(0,0,0,0.7)' });
|
if (url) addElement({ type: 'ticker', x: 0, y: 90, width: 100, height: 10, feedUrl: url, speed: 30, fontSize: 20, color: '#FFFFFF', bgColor: 'rgba(0,0,0,0.7)' });
|
||||||
};
|
};
|
||||||
document.getElementById('addShape').onclick = () => addElement({ type: 'shape', x: 20, y: 20, width: 30, height: 20, color: '#3b82f6', opacity: 0.7, radius: 8, shape: 'rect' });
|
document.getElementById('addShape').onclick = () => addElement({ type: 'shape', x: 20, y: 20, width: 30, height: 20, color: '#3b82f6', opacity: 0.7, radius: 8, shape: 'rect' });
|
||||||
document.getElementById('addQR').onclick = () => {
|
document.getElementById('addQR').onclick = () => {
|
||||||
const data = prompt('QR Code URL:', 'https://example.com');
|
const data = prompt(t('designer.prompt.qr_url'), 'https://example.com');
|
||||||
if (data) addElement({ type: 'qr', x: 80, y: 70, size: 15, data, fgColor: '#FFFFFF', bgColor: '#000000' });
|
if (data) addElement({ type: 'qr', x: 80, y: 70, size: 15, data, fgColor: '#FFFFFF', bgColor: '#000000' });
|
||||||
};
|
};
|
||||||
document.getElementById('addCountdown').onclick = () => {
|
document.getElementById('addCountdown').onclick = () => {
|
||||||
const target = prompt('Target date (YYYY-MM-DD):', '2026-04-01');
|
const target = prompt(t('designer.prompt.countdown_date'), '2026-04-01');
|
||||||
if (target) addElement({ type: 'countdown', x: 20, y: 40, fontSize: 48, color: '#FFFFFF', targetDate: target, label: 'Coming Soon' });
|
if (target) addElement({ type: 'countdown', x: 20, y: 40, fontSize: 48, color: '#FFFFFF', targetDate: target, label: t('designer.default.coming_soon') });
|
||||||
};
|
};
|
||||||
document.getElementById('addWebpage').onclick = () => {
|
document.getElementById('addWebpage').onclick = () => {
|
||||||
const url = prompt('Webpage URL:');
|
const url = prompt(t('designer.prompt.webpage_url'));
|
||||||
if (url) addElement({ type: 'webpage', x: 5, y: 5, width: 40, height: 40, url });
|
if (url) addElement({ type: 'webpage', x: 5, y: 5, width: 40, height: 40, url });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -159,10 +207,10 @@ export function render(container) {
|
||||||
const res = await fetch('/api/widgets', {
|
const res = await fetch('/api/widgets', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||||
body: JSON.stringify({ widget_type: 'text', name: `Design ${new Date().toLocaleDateString()}`, config: { html: generateInnerHTML(), css: '', background: bgValue } })
|
body: JSON.stringify({ widget_type: 'text', name: t('designer.widget_name', { date: new Date().toLocaleDateString() }), config: { html: generateInnerHTML(), css: '', background: bgValue } })
|
||||||
});
|
});
|
||||||
if (res.ok) showToast('Published as widget! Assign it to a layout zone.', 'success');
|
if (res.ok) showToast(t('designer.toast.published'), 'success');
|
||||||
else showToast('Publish failed', 'error');
|
else showToast(t('designer.toast.publish_failed'), 'error');
|
||||||
} catch (err) { showToast(err.message, 'error'); }
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -207,7 +255,7 @@ export function render(container) {
|
||||||
}
|
}
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.download = 'signage-design.png'; link.href = canvas.toDataURL('image/png'); link.click();
|
link.download = 'signage-design.png'; link.href = canvas.toDataURL('image/png'); link.click();
|
||||||
} catch (err) { showToast('Export failed: ' + err.message, 'error'); }
|
} catch (err) { showToast(t('designer.toast.export_failed', { error: err.message }), 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load saved design
|
// Load saved design
|
||||||
|
|
@ -222,8 +270,8 @@ export function render(container) {
|
||||||
bgValue = data.bgValue || '#000';
|
bgValue = data.bgValue || '#000';
|
||||||
bgImageDataUrl = data.bgImageDataUrl || null;
|
bgImageDataUrl = data.bgImageDataUrl || null;
|
||||||
redraw();
|
redraw();
|
||||||
showToast('Design loaded', 'success');
|
showToast(t('designer.toast.loaded'), 'success');
|
||||||
} catch { showToast('Invalid design file', 'error'); }
|
} catch { showToast(t('designer.toast.invalid_file'), 'error'); }
|
||||||
};
|
};
|
||||||
reader.readAsText(input.files[0]);
|
reader.readAsText(input.files[0]);
|
||||||
};
|
};
|
||||||
|
|
@ -270,6 +318,110 @@ function addElement(el) {
|
||||||
redraw();
|
redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #41: per-workspace AI endpoint config (BYO OpenAI-compatible endpoint + key).
|
||||||
|
async function openAiSettings() {
|
||||||
|
let cur = {};
|
||||||
|
try { cur = await api.aiGetSettings(); } catch { /* show empty form */ }
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal" style="max-width:520px;width:95vw">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>${t('designer.ai.settings_title')}</h3>
|
||||||
|
<button class="btn-icon" data-ai-close aria-label="Close"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('designer.ai.settings_desc')}</p>
|
||||||
|
<div class="form-group"><label>${t('designer.ai.base_url')}</label>
|
||||||
|
<input id="aiBaseUrl" class="input" value="${esc(cur.base_url || '')}" placeholder="https://api.openai.com/v1 · http://localhost:11434/v1" style="width:100%"></div>
|
||||||
|
<div class="form-group"><label>${t('designer.ai.model')}</label>
|
||||||
|
<div style="display:flex;gap:6px">
|
||||||
|
<input id="aiModel" class="input" list="aiModelList" value="${esc(cur.model || '')}" placeholder="gpt-4o-mini · llama3.1:8b" style="flex:1" autocomplete="off">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="aiLoadModels" type="button" style="white-space:nowrap">${t('designer.ai.load_models')}</button>
|
||||||
|
</div>
|
||||||
|
<datalist id="aiModelList"></datalist>
|
||||||
|
<div id="aiModelMsg" style="font-size:11px;color:var(--text-muted);margin-top:4px"></div></div>
|
||||||
|
<div class="form-group"><label>${t('designer.ai.api_key')}</label>
|
||||||
|
<input id="aiKey" class="input" type="password" autocomplete="off" placeholder="${cur.has_key ? t('designer.ai.key_set') : t('designer.ai.key_placeholder')}" style="width:100%">
|
||||||
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('designer.ai.key_hint')}</div></div>
|
||||||
|
|
||||||
|
<hr style="border:none;border-top:1px solid var(--border);margin:14px 0 10px">
|
||||||
|
<h4 style="font-size:13px;margin-bottom:4px">${t('designer.ai.images_title')}</h4>
|
||||||
|
<p style="font-size:11px;color:var(--text-muted);margin-bottom:8px">${t('designer.ai.images_desc')}</p>
|
||||||
|
<div class="form-group"><label>${t('designer.ai.image_provider')}</label>
|
||||||
|
<select id="aiImageProvider" class="input" style="width:100%">
|
||||||
|
<option value="" ${!cur.image_provider ? 'selected' : ''}>${t('designer.ai.image_off')}</option>
|
||||||
|
<option value="sdcpp" ${cur.image_provider === 'sdcpp' ? 'selected' : ''}>Stable Diffusion — local (sd.cpp)</option>
|
||||||
|
<option value="openai" ${cur.image_provider === 'openai' ? 'selected' : ''}>OpenAI / OpenAI-compatible</option>
|
||||||
|
<option value="comfyui" ${cur.image_provider === 'comfyui' ? 'selected' : ''}>ComfyUI</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="form-group"><label>${t('designer.ai.image_base_url')}</label>
|
||||||
|
<input id="aiImageBaseUrl" class="input" value="${esc(cur.image_base_url || '')}" placeholder="http://localhost:8080/v1 · http://localhost:8188" style="width:100%"></div>
|
||||||
|
<div class="form-group"><label>${t('designer.ai.image_model')}</label>
|
||||||
|
<input id="aiImageModel" class="input" value="${esc(cur.image_model || '')}" placeholder="${t('designer.ai.image_model_ph')}" style="width:100%"></div>
|
||||||
|
<div class="form-group"><label>${t('designer.ai.image_api_key')}</label>
|
||||||
|
<input id="aiImageKey" class="input" type="password" autocomplete="off" placeholder="${cur.has_image_key ? t('designer.ai.key_set') : t('designer.ai.image_key_ph')}" style="width:100%">
|
||||||
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('designer.ai.image_key_hint')}</div></div>
|
||||||
|
<div id="aiSettingsErr" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" data-ai-close>${t('common.cancel')}</button>
|
||||||
|
<button class="btn btn-primary" id="aiSaveSettings">${t('common.save')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
const close = () => overlay.remove();
|
||||||
|
overlay.querySelectorAll('[data-ai-close]').forEach(b => b.onclick = close);
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) close(); };
|
||||||
|
|
||||||
|
// Load the model list from the entered endpoint into the dropdown.
|
||||||
|
overlay.querySelector('#aiLoadModels').onclick = async () => {
|
||||||
|
const msg = overlay.querySelector('#aiModelMsg');
|
||||||
|
const base_url = overlay.querySelector('#aiBaseUrl').value.trim();
|
||||||
|
if (!base_url) { msg.style.color = 'var(--danger)'; msg.textContent = t('designer.ai.need_base_url'); return; }
|
||||||
|
const btn = overlay.querySelector('#aiLoadModels');
|
||||||
|
btn.disabled = true;
|
||||||
|
msg.style.color = 'var(--text-muted)'; msg.textContent = t('designer.ai.loading_models');
|
||||||
|
try {
|
||||||
|
const r = await api.aiListModels(base_url, overlay.querySelector('#aiKey').value || undefined);
|
||||||
|
const models = r.models || [];
|
||||||
|
overlay.querySelector('#aiModelList').innerHTML = models.map(m => `<option value="${esc(m)}"></option>`).join('');
|
||||||
|
const modelInput = overlay.querySelector('#aiModel');
|
||||||
|
if (models.length && !modelInput.value) modelInput.value = models[0];
|
||||||
|
msg.textContent = t('designer.ai.models_loaded', { n: models.length });
|
||||||
|
} catch (e2) {
|
||||||
|
msg.style.color = 'var(--danger)'; msg.textContent = (e2 && e2.message) || t('designer.ai.models_failed');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.querySelector('#aiSaveSettings').onclick = async () => {
|
||||||
|
const errEl = overlay.querySelector('#aiSettingsErr');
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
const data = {
|
||||||
|
base_url: overlay.querySelector('#aiBaseUrl').value.trim(),
|
||||||
|
model: overlay.querySelector('#aiModel').value.trim(),
|
||||||
|
image_provider: overlay.querySelector('#aiImageProvider').value,
|
||||||
|
image_base_url: overlay.querySelector('#aiImageBaseUrl').value.trim(),
|
||||||
|
image_model: overlay.querySelector('#aiImageModel').value.trim(),
|
||||||
|
};
|
||||||
|
const key = overlay.querySelector('#aiKey').value;
|
||||||
|
if (key) data.api_key = key;
|
||||||
|
const imgKey = overlay.querySelector('#aiImageKey').value;
|
||||||
|
if (imgKey) data.image_api_key = imgKey;
|
||||||
|
try {
|
||||||
|
await api.aiSaveSettings(data);
|
||||||
|
showToast(t('designer.ai.saved'), 'success');
|
||||||
|
close();
|
||||||
|
} catch (e2) {
|
||||||
|
errEl.textContent = (e2 && e2.message) || t('designer.ai.save_failed');
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getBounds(el) {
|
function getBounds(el) {
|
||||||
const w = el.width || el.size || (el.fontSize ? el.fontSize * 0.6 * (el.text?.length || 8) / 100 * 100 : 20);
|
const w = el.width || el.size || (el.fontSize ? el.fontSize * 0.6 * (el.text?.length || 8) / 100 * 100 : 20);
|
||||||
const h = el.height || el.size || (el.fontSize ? el.fontSize * 1.2 / 100 * 100 : 10);
|
const h = el.height || el.size || (el.fontSize ? el.fontSize * 1.2 / 100 * 100 : 10);
|
||||||
|
|
@ -297,13 +449,13 @@ function redraw() {
|
||||||
|
|
||||||
switch (el.type) {
|
switch (el.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap;${border}${cursor}" data-idx="${i}">${el.text}</div>`;
|
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap;${border}${cursor}" data-idx="${i}">${el.text}</div>`;
|
||||||
break;
|
break;
|
||||||
case 'clock':
|
case 'clock':
|
||||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};font-weight:bold;${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="clock_${i}"></div>`;
|
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};font-weight:bold;${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="clock_${i}"></div>`;
|
||||||
break;
|
break;
|
||||||
case 'date':
|
case 'date':
|
||||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="date_${i}"></div>`;
|
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="date_${i}"></div>`;
|
||||||
break;
|
break;
|
||||||
case 'image':
|
case 'image':
|
||||||
html += `<img src="${el.src}" style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;object-fit:contain;${border}${cursor}" data-idx="${i}" draggable="false">`;
|
html += `<img src="${el.src}" style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;object-fit:contain;${border}${cursor}" data-idx="${i}" draggable="false">`;
|
||||||
|
|
@ -315,23 +467,23 @@ function redraw() {
|
||||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.color};opacity:${el.opacity};border-radius:${el.radius || 0}px;${el.shape === 'circle' ? 'border-radius:50%;' : ''}${border}${cursor}" data-idx="${i}"></div>`;
|
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.color};opacity:${el.opacity};border-radius:${el.radius || 0}px;${el.shape === 'circle' ? 'border-radius:50%;' : ''}${border}${cursor}" data-idx="${i}"></div>`;
|
||||||
break;
|
break;
|
||||||
case 'weather':
|
case 'weather':
|
||||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;color:${el.color};${border}${cursor}" data-idx="${i}" id="weather_${i}">⛅ Loading...</div>`;
|
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;color:${el.color};${border}${cursor}" data-idx="${i}" id="weather_${i}">⛅ ${t('common.loading')}</div>`;
|
||||||
break;
|
break;
|
||||||
case 'ticker':
|
case 'ticker':
|
||||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.bgColor};overflow:hidden;display:flex;align-items:center;${border}" data-idx="${i}">
|
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.bgColor};overflow:hidden;display:flex;align-items:center;${border}" data-idx="${i}">
|
||||||
<div style="white-space:nowrap;animation:ticker ${el.speed || 30}s linear infinite;font-size:${el.fontSize / 10}vw;color:${el.color}" id="ticker_${i}">Loading news...</div>
|
<div style="white-space:nowrap;animation:ticker ${el.speed || 30}s linear infinite;font-size:${el.fontSize / 10}cqw;color:${el.color}" id="ticker_${i}">${t('designer.loading_news')}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case 'qr':
|
case 'qr':
|
||||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.size}%;aspect-ratio:1;background:${el.bgColor};display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:8px;${border}${cursor}" data-idx="${i}">
|
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.size}%;aspect-ratio:1;background:${el.bgColor};display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:8px;${border}${cursor}" data-idx="${i}">
|
||||||
<div style="font-size:1.5vw;color:${el.fgColor};font-weight:bold">QR CODE</div>
|
<div style="font-size:1.5vw;color:${el.fgColor};font-weight:bold">${t('designer.qr_label')}</div>
|
||||||
<div style="font-size:0.8vw;color:${el.fgColor};opacity:0.7;margin-top:4px">${el.data?.slice(0, 25)}</div>
|
<div style="font-size:0.8vw;color:${el.fgColor};opacity:0.7;margin-top:4px">${el.data?.slice(0, 25)}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case 'countdown':
|
case 'countdown':
|
||||||
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color};${border}${cursor}" data-idx="${i}">
|
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color};${border}${cursor}" data-idx="${i}">
|
||||||
<div style="font-size:${el.fontSize / 15}vw;opacity:0.8">${el.label || ''}</div>
|
<div style="font-size:${el.fontSize / 15}cqw;opacity:0.8">${el.label || ''}</div>
|
||||||
<div style="font-size:${el.fontSize / 10}vw;font-weight:bold" id="countdown_${i}"></div>
|
<div style="font-size:${el.fontSize / 10}cqw;font-weight:bold" id="countdown_${i}"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case 'webpage':
|
case 'webpage':
|
||||||
|
|
@ -378,7 +530,7 @@ function updateDynamic() {
|
||||||
if (cdEl && el.targetDate) {
|
if (cdEl && el.targetDate) {
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const diff = new Date(el.targetDate) - new Date();
|
const diff = new Date(el.targetDate) - new Date();
|
||||||
if (diff <= 0) { cdEl.textContent = 'NOW!'; return; }
|
if (diff <= 0) { cdEl.textContent = t('designer.countdown_now'); return; }
|
||||||
const days = Math.floor(diff / 86400000);
|
const days = Math.floor(diff / 86400000);
|
||||||
const hours = Math.floor((diff % 86400000) / 3600000);
|
const hours = Math.floor((diff % 86400000) / 3600000);
|
||||||
const mins = Math.floor((diff % 3600000) / 60000);
|
const mins = Math.floor((diff % 3600000) / 60000);
|
||||||
|
|
@ -397,15 +549,15 @@ function updateDynamic() {
|
||||||
const temp = el.units === 'metric' ? cur.temp_C + '°C' : cur.temp_F + '°F';
|
const temp = el.units === 'metric' ? cur.temp_C + '°C' : cur.temp_F + '°F';
|
||||||
wEl.textContent = `${temp} ${cur.weatherDesc?.[0]?.value || ''}`;
|
wEl.textContent = `${temp} ${cur.weatherDesc?.[0]?.value || ''}`;
|
||||||
}
|
}
|
||||||
}).catch(() => { wEl.textContent = '⛅ ' + el.location; });
|
}).catch(() => { wEl.textContent = '⛅ ' + el.location; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (el.type === 'ticker') {
|
if (el.type === 'ticker') {
|
||||||
const tEl = document.getElementById(`ticker_${i}`);
|
const tEl = document.getElementById(`ticker_${i}`);
|
||||||
if (tEl && el.feedUrl) {
|
if (tEl && el.feedUrl) {
|
||||||
fetch(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(el.feedUrl)}`).then(r => r.json()).then(d => {
|
fetch(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(el.feedUrl)}`).then(r => r.json()).then(d => {
|
||||||
tEl.textContent = (d.items || []).map(item => item.title).join(' • ') || 'No items';
|
tEl.textContent = (d.items || []).map(item => item.title).join(' • ') || t('designer.no_items');
|
||||||
}).catch(() => { tEl.textContent = 'Feed unavailable'; });
|
}).catch(() => { tEl.textContent = t('designer.feed_unavailable'); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -426,42 +578,42 @@ function updateProps() {
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
if (el.type === 'text') {
|
if (el.type === 'text') {
|
||||||
html += `<div class="form-group"><label>Text</label><input type="text" class="input" value="${el.text}" data-prop="text"></div>
|
html += `<div class="form-group"><label>${t('designer.prop.text')}</label><input type="text" class="input" value="${el.text}" data-prop="text"></div>
|
||||||
<div class="form-group"><label>Size</label><input type="range" min="8" max="120" value="${el.fontSize}" data-prop="fontSize" style="width:100%"><span style="font-size:11px;color:var(--text-muted)">${el.fontSize}px</span></div>
|
<div class="form-group"><label>${t('designer.prop.size')}</label><input type="range" min="8" max="120" value="${el.fontSize}" data-prop="fontSize" style="width:100%"><span style="font-size:11px;color:var(--text-muted)">${el.fontSize}px</span></div>
|
||||||
<div class="form-group"><label>Font</label><select class="input" style="background:var(--bg-input)" data-prop="fontFamily">${FONTS.map(f => `<option ${f === el.fontFamily ? 'selected' : ''}>${f}</option>`).join('')}</select></div>
|
<div class="form-group"><label>${t('designer.prop.font')}</label><select class="input" style="background:var(--bg-input)" data-prop="fontFamily">${FONTS.map(f => `<option ${f === el.fontFamily ? 'selected' : ''}>${f}</option>`).join('')}</select></div>
|
||||||
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
<div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
||||||
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.bold ? 'checked' : ''} data-prop="bold"> Bold</label>
|
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.bold ? 'checked' : ''} data-prop="bold"> ${t('designer.prop.bold')}</label>
|
||||||
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.shadow ? 'checked' : ''} data-prop="shadow"> Shadow</label>`;
|
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.shadow ? 'checked' : ''} data-prop="shadow"> ${t('designer.prop.shadow')}</label>`;
|
||||||
} else if (el.type === 'clock') {
|
} else if (el.type === 'clock') {
|
||||||
html += `<div class="form-group"><label>Size</label><input type="range" min="16" max="120" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
|
html += `<div class="form-group"><label>${t('designer.prop.size')}</label><input type="range" min="16" max="120" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
|
||||||
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>
|
<div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>
|
||||||
<div class="form-group"><label>Format</label><select class="input" style="background:var(--bg-input)" data-prop="format"><option ${el.format === '12h' ? 'selected' : ''} value="12h">12h</option><option ${el.format === '24h' ? 'selected' : ''} value="24h">24h</option></select></div>
|
<div class="form-group"><label>${t('designer.prop.format')}</label><select class="input" style="background:var(--bg-input)" data-prop="format"><option ${el.format === '12h' ? 'selected' : ''} value="12h">12h</option><option ${el.format === '24h' ? 'selected' : ''} value="24h">24h</option></select></div>
|
||||||
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.showSeconds ? 'checked' : ''} data-prop="showSeconds"> Show seconds</label>`;
|
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.showSeconds ? 'checked' : ''} data-prop="showSeconds"> ${t('designer.prop.show_seconds')}</label>`;
|
||||||
} else if (el.type === 'image' || el.type === 'video' || el.type === 'webpage') {
|
} else if (el.type === 'image' || el.type === 'video' || el.type === 'webpage') {
|
||||||
html += `<div style="display:flex;gap:6px"><div class="form-group" style="flex:1;margin:0"><label>W%</label><input type="number" class="input" value="${Math.round(el.width)}" data-prop="width"></div>
|
html += `<div style="display:flex;gap:6px"><div class="form-group" style="flex:1;margin:0"><label>W%</label><input type="number" class="input" value="${Math.round(el.width)}" data-prop="width"></div>
|
||||||
<div class="form-group" style="flex:1;margin:0"><label>H%</label><input type="number" class="input" value="${Math.round(el.height)}" data-prop="height"></div></div>`;
|
<div class="form-group" style="flex:1;margin:0"><label>H%</label><input type="number" class="input" value="${Math.round(el.height)}" data-prop="height"></div></div>`;
|
||||||
if (el.type === 'video') html += `<label style="font-size:12px;display:flex;gap:6px;margin:8px 0"><input type="checkbox" ${el.muted ? 'checked' : ''} data-prop="muted"> Muted</label>
|
if (el.type === 'video') html += `<label style="font-size:12px;display:flex;gap:6px;margin:8px 0"><input type="checkbox" ${el.muted ? 'checked' : ''} data-prop="muted"> ${t('designer.prop.muted')}</label>
|
||||||
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.loop ? 'checked' : ''} data-prop="loop"> Loop</label>`;
|
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.loop ? 'checked' : ''} data-prop="loop"> ${t('designer.prop.loop')}</label>`;
|
||||||
} else if (el.type === 'shape') {
|
} else if (el.type === 'shape') {
|
||||||
html += `<div style="display:flex;gap:6px"><div class="form-group" style="flex:1;margin:0"><label>W%</label><input type="number" class="input" value="${Math.round(el.width)}" data-prop="width"></div>
|
html += `<div style="display:flex;gap:6px"><div class="form-group" style="flex:1;margin:0"><label>W%</label><input type="number" class="input" value="${Math.round(el.width)}" data-prop="width"></div>
|
||||||
<div class="form-group" style="flex:1;margin:0"><label>H%</label><input type="number" class="input" value="${Math.round(el.height)}" data-prop="height"></div></div>
|
<div class="form-group" style="flex:1;margin:0"><label>H%</label><input type="number" class="input" value="${Math.round(el.height)}" data-prop="height"></div></div>
|
||||||
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>
|
<div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>
|
||||||
<div class="form-group"><label>Opacity</label><input type="range" min="0" max="1" step="0.1" value="${el.opacity}" data-prop="opacity" style="width:100%"></div>
|
<div class="form-group"><label>${t('designer.prop.opacity')}</label><input type="range" min="0" max="1" step="0.1" value="${el.opacity}" data-prop="opacity" style="width:100%"></div>
|
||||||
<div class="form-group"><label>Shape</label><select class="input" style="background:var(--bg-input)" data-prop="shape"><option ${el.shape === 'rect' ? 'selected' : ''}>rect</option><option ${el.shape === 'circle' ? 'selected' : ''}>circle</option></select></div>`;
|
<div class="form-group"><label>${t('designer.prop.shape')}</label><select class="input" style="background:var(--bg-input)" data-prop="shape"><option ${el.shape === 'rect' ? 'selected' : ''}>rect</option><option ${el.shape === 'circle' ? 'selected' : ''}>circle</option></select></div>`;
|
||||||
} else if (el.type === 'weather') {
|
} else if (el.type === 'weather') {
|
||||||
html += `<div class="form-group"><label>Location</label><input type="text" class="input" value="${el.location}" data-prop="location"></div>
|
html += `<div class="form-group"><label>${t('designer.prop.location')}</label><input type="text" class="input" value="${el.location}" data-prop="location"></div>
|
||||||
<div class="form-group"><label>Size</label><input type="range" min="16" max="80" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
|
<div class="form-group"><label>${t('designer.prop.size')}</label><input type="range" min="16" max="80" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
|
||||||
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`;
|
<div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`;
|
||||||
} else if (el.type === 'ticker') {
|
} else if (el.type === 'ticker') {
|
||||||
html += `<div class="form-group"><label>Feed URL</label><input type="text" class="input" value="${el.feedUrl}" data-prop="feedUrl"></div>
|
html += `<div class="form-group"><label>${t('designer.prop.feed_url')}</label><input type="text" class="input" value="${el.feedUrl}" data-prop="feedUrl"></div>
|
||||||
<div class="form-group"><label>Speed (seconds)</label><input type="number" class="input" value="${el.speed}" data-prop="speed"></div>
|
<div class="form-group"><label>${t('designer.prop.speed')}</label><input type="number" class="input" value="${el.speed}" data-prop="speed"></div>
|
||||||
<div class="form-group"><label>Text Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>
|
<div class="form-group"><label>${t('designer.prop.text_color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>
|
||||||
<div class="form-group"><label>BG Color</label><input type="text" class="input" value="${el.bgColor}" data-prop="bgColor"></div>`;
|
<div class="form-group"><label>${t('designer.prop.bg_color')}</label><input type="text" class="input" value="${el.bgColor}" data-prop="bgColor"></div>`;
|
||||||
} else if (el.type === 'countdown') {
|
} else if (el.type === 'countdown') {
|
||||||
html += `<div class="form-group"><label>Target Date</label><input type="date" class="input" value="${el.targetDate}" data-prop="targetDate"></div>
|
html += `<div class="form-group"><label>${t('designer.prop.target_date')}</label><input type="date" class="input" value="${el.targetDate}" data-prop="targetDate"></div>
|
||||||
<div class="form-group"><label>Label</label><input type="text" class="input" value="${el.label}" data-prop="label"></div>
|
<div class="form-group"><label>${t('designer.prop.label')}</label><input type="text" class="input" value="${el.label}" data-prop="label"></div>
|
||||||
<div class="form-group"><label>Size</label><input type="range" min="16" max="100" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
|
<div class="form-group"><label>${t('designer.prop.size')}</label><input type="range" min="16" max="100" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
|
||||||
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`;
|
<div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save design button
|
// Save design button
|
||||||
|
|
@ -470,7 +622,7 @@ function updateProps() {
|
||||||
a.download = 'design.json';
|
a.download = 'design.json';
|
||||||
a.href = 'data:application/json,' + encodeURIComponent(JSON.stringify({elements: ${JSON.stringify(elements)}, bgValue: '${bgValue}'}));
|
a.href = 'data:application/json,' + encodeURIComponent(JSON.stringify({elements: ${JSON.stringify(elements)}, bgValue: '${bgValue}'}));
|
||||||
a.click();
|
a.click();
|
||||||
})()">Save Design File</button>`;
|
})()">${t('designer.save_design_file')}</button>`;
|
||||||
|
|
||||||
fields.innerHTML = html;
|
fields.innerHTML = html;
|
||||||
|
|
||||||
|
|
@ -498,7 +650,7 @@ function updateLayers() {
|
||||||
<span>${typeIcons[el.type] || '?'}</span>
|
<span>${typeIcons[el.type] || '?'}</span>
|
||||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${el.text || el.type}</span>
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${el.text || el.type}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('') || '<p style="color:var(--text-muted)">No elements yet</p>';
|
`).join('') || `<p style="color:var(--text-muted)">${t('designer.no_elements')}</p>`;
|
||||||
|
|
||||||
list.querySelectorAll('[data-layer]').forEach(el => {
|
list.querySelectorAll('[data-layer]').forEach(el => {
|
||||||
el.onclick = () => { selectedIdx = parseInt(el.dataset.layer); redraw(); };
|
el.onclick = () => { selectedIdx = parseInt(el.dataset.layer); redraw(); };
|
||||||
|
|
@ -507,6 +659,9 @@ function updateLayers() {
|
||||||
|
|
||||||
function generateInnerHTML() {
|
function generateInnerHTML() {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
// A background image (e.g. AI-generated) is the body background in the editor;
|
||||||
|
// bake it into the published HTML as a full-cover bottom layer so it survives.
|
||||||
|
if (bgImageDataUrl) html += `<img src="${bgImageDataUrl}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover" alt="">`;
|
||||||
elements.forEach((el, i) => {
|
elements.forEach((el, i) => {
|
||||||
// Use vw units for font sizes (same as designer preview) so output scales to any viewport
|
// Use vw units for font sizes (same as designer preview) so output scales to any viewport
|
||||||
const fs = el.fontSize / 10;
|
const fs = el.fontSize / 10;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
81
frontend/js/views/force-password-change.js
Normal file
81
frontend/js/views/force-password-change.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// #10: forced first-login password change. When an admin provisions a user
|
||||||
|
// with must_change_password=1, route() in app.js redirects them here and blocks
|
||||||
|
// every other view until they set a new password. Reuses the same PUT /api/auth/me
|
||||||
|
// path as the Settings change-password form; on success the server clears
|
||||||
|
// must_change_password, we refresh the cached user, and return to the app.
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
import { showToast } from '../components/toast.js';
|
||||||
|
|
||||||
|
export async function render(container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
|
||||||
|
<div style="width:400px;max-width:100%">
|
||||||
|
<div style="text-align:center;margin-bottom:24px">
|
||||||
|
<h1 style="font-size:22px;font-weight:700;color:var(--accent)">${t('forcepw.title')}</h1>
|
||||||
|
<p style="color:var(--text-secondary);font-size:13px;margin-top:6px">${t('forcepw.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${t('forcepw.current')}</label>
|
||||||
|
<input type="password" id="fpwCurrent" class="input" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${t('forcepw.new')}</label>
|
||||||
|
<input type="password" id="fpwNew" class="input" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${t('forcepw.confirm')}</label>
|
||||||
|
<input type="password" id="fpwConfirm" class="input" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('forcepw.hint')}</p>
|
||||||
|
<button class="btn btn-primary" id="fpwSubmit" style="width:100%;justify-content:center;padding:10px">${t('forcepw.submit')}</button>
|
||||||
|
<p id="fpwError" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const current = container.querySelector('#fpwCurrent');
|
||||||
|
const next = container.querySelector('#fpwNew');
|
||||||
|
const confirm = container.querySelector('#fpwConfirm');
|
||||||
|
const submit = container.querySelector('#fpwSubmit');
|
||||||
|
const errorEl = container.querySelector('#fpwError');
|
||||||
|
current.focus();
|
||||||
|
|
||||||
|
const showError = (msg) => { errorEl.textContent = msg; errorEl.style.display = 'block'; };
|
||||||
|
|
||||||
|
async function doChange() {
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
const cur = current.value;
|
||||||
|
const nw = next.value;
|
||||||
|
const cf = confirm.value;
|
||||||
|
if (!cur || !nw) { showError(t('forcepw.error_required')); return; }
|
||||||
|
if (nw.length < 8) { showError(t('forcepw.error_min8')); return; }
|
||||||
|
if (nw !== cf) { showError(t('forcepw.error_mismatch')); return; }
|
||||||
|
|
||||||
|
submit.disabled = true;
|
||||||
|
submit.textContent = t('forcepw.submitting');
|
||||||
|
try {
|
||||||
|
await api.updateMe({ password: nw, current_password: cur });
|
||||||
|
// Refresh the cached user so the (now-cleared) must_change_password flag
|
||||||
|
// is reflected, then return to the app.
|
||||||
|
try {
|
||||||
|
const fresh = await api.getMe();
|
||||||
|
localStorage.setItem('user', JSON.stringify(fresh));
|
||||||
|
} catch { /* fall through; reload re-fetches */ }
|
||||||
|
showToast(t('forcepw.success'), 'success');
|
||||||
|
window.location.hash = '#/';
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
submit.disabled = false;
|
||||||
|
submit.textContent = t('forcepw.submit');
|
||||||
|
showError(err?.message || t('forcepw.error_generic'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submit.addEventListener('click', doChange);
|
||||||
|
[current, next, confirm].forEach(el => el.addEventListener('keydown', (e) => { if (e.key === 'Enter') doChange(); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanup() {}
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
|
// Help guides + FAQ are documentation. Page chrome is translated; the body
|
||||||
|
// content is intentionally left in English because partial machine
|
||||||
|
// translation of multi-paragraph docs reads worse than a single source of
|
||||||
|
// truth. A native-language docs site is the right long-term answer.
|
||||||
export function render(container) {
|
export function render(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>Help Center</h1><div class="subtitle">Quick guides and FAQ</div></div>
|
<div><h1>${t('help.title')}</h1><div class="subtitle">${t('help.subtitle')}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:32px">
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:32px">
|
||||||
|
|
@ -9,6 +15,7 @@ export function render(container) {
|
||||||
{ icon: '📺', title: 'Setting Up a Display', steps: ['Download the APK or open the Web Player', 'Enter your server URL', 'Note the 6-digit pairing code', 'Click "Add Display" in the dashboard and enter the code', 'Assign content to the display\'s playlist'] },
|
{ icon: '📺', title: 'Setting Up a Display', steps: ['Download the APK or open the Web Player', 'Enter your server URL', 'Note the 6-digit pairing code', 'Click "Add Display" in the dashboard and enter the code', 'Assign content to the display\'s playlist'] },
|
||||||
{ icon: '📤', title: 'Uploading Content', steps: ['Go to Content Library', 'Drag and drop files or click the upload area', 'Supports MP4, WebM, JPEG, PNG, GIF, WebP', 'Videos auto-detect duration and generate thumbnails', 'Use Remote URL to stream from external sources'] },
|
{ icon: '📤', title: 'Uploading Content', steps: ['Go to Content Library', 'Drag and drop files or click the upload area', 'Supports MP4, WebM, JPEG, PNG, GIF, WebP', 'Videos auto-detect duration and generate thumbnails', 'Use Remote URL to stream from external sources'] },
|
||||||
{ icon: '⚙', title: 'Using Widgets', steps: ['Go to Widgets and click "New Widget"', 'Choose a type: Clock, Weather, RSS, Text, Webpage, or Social', 'Configure the widget settings', 'Assign the widget to a device via the Playlist tab', 'Widgets render as live HTML content'] },
|
{ icon: '⚙', title: 'Using Widgets', steps: ['Go to Widgets and click "New Widget"', 'Choose a type: Clock, Weather, RSS, Text, Webpage, or Social', 'Configure the widget settings', 'Assign the widget to a device via the Playlist tab', 'Widgets render as live HTML content'] },
|
||||||
|
{ icon: '✨', title: 'AI Content Design', steps: ['Open Designer and click the gear on the "AI generate" panel', 'Add an OpenAI-compatible text endpoint + model (OpenAI cloud, or a local Ollama)', 'Optional: pick an image provider for AI backgrounds (OpenAI, or local Stable Diffusion / ComfyUI)', 'Type a prompt, click "Generate design", then tweak and Publish', 'Run it fully local + free — see docs/local-ai-setup.md'] },
|
||||||
{ icon: '📋', title: 'Multi-Zone Layouts', steps: ['Go to Layouts and create a new layout or use a template', 'Drag zones to position them on the canvas', 'Resize using the corner handle', 'Assign the layout to a device in the Playlist tab', 'Each zone can show different content'] },
|
{ icon: '📋', title: 'Multi-Zone Layouts', steps: ['Go to Layouts and create a new layout or use a template', 'Drag zones to position them on the canvas', 'Resize using the corner handle', 'Assign the layout to a device in the Playlist tab', 'Each zone can show different content'] },
|
||||||
{ icon: '📅', title: 'Content Scheduling', steps: ['Go to Schedule and select a device', 'Click "Add Schedule" to create a time slot', 'Set start/end times and recurrence rules', 'Higher priority schedules override lower ones', 'Content auto-switches based on the schedule'] },
|
{ icon: '📅', title: 'Content Scheduling', steps: ['Go to Schedule and select a device', 'Click "Add Schedule" to create a time slot', 'Set start/end times and recurrence rules', 'Higher priority schedules override lower ones', 'Content auto-switches based on the schedule'] },
|
||||||
{ icon: '🖥', title: 'Remote Control', steps: ['Go to a device\'s detail page', 'Click the "Remote Control" tab', 'Click "Start Remote" to begin streaming', 'Use the d-pad, volume, and power buttons', 'Click anywhere on the screen to simulate a tap'] },
|
{ icon: '🖥', title: 'Remote Control', steps: ['Go to a device\'s detail page', 'Click the "Remote Control" tab', 'Click "Start Remote" to begin streaming', 'Use the d-pad, volume, and power buttons', 'Click anywhere on the screen to simulate a tap'] },
|
||||||
|
|
@ -25,7 +32,7 @@ export function render(container) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Frequently Asked Questions</h3>
|
<h3>${t('help.faq')}</h3>
|
||||||
${[
|
${[
|
||||||
{ q: 'What devices are supported?', a: 'Android TV/tablets (APK), Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, Fire TV, and any device with a web browser.' },
|
{ q: 'What devices are supported?', a: 'Android TV/tablets (APK), Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, Fire TV, and any device with a web browser.' },
|
||||||
{ q: 'How does the free trial work?', a: 'New accounts get a 14-day free trial of the Pro plan (15 devices, all features). After 14 days, you\'re moved to the Free plan (1 device) unless you upgrade.' },
|
{ q: 'How does the free trial work?', a: 'New accounts get a 14-day free trial of the Pro plan (15 devices, all features). After 14 days, you\'re moved to the Free plan (1 device) unless you upgrade.' },
|
||||||
|
|
@ -46,10 +53,10 @@ export function render(container) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Keyboard Shortcuts</h3>
|
<h3>${t('help.shortcuts')}</h3>
|
||||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:13px">
|
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:13px">
|
||||||
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">Esc</kbd> <span style="color:var(--text-secondary)">Reset web player (on player page)</span>
|
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">Esc</kbd> <span style="color:var(--text-secondary)">${t('help.shortcut_esc')}</span>
|
||||||
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">F</kbd> <span style="color:var(--text-secondary)">Toggle fullscreen (web player)</span>
|
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">F</kbd> <span style="color:var(--text-secondary)">${t('help.shortcut_f')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
|
||||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||||
|
|
||||||
|
|
@ -14,17 +16,17 @@ export async function render(container) {
|
||||||
async function renderList(container) {
|
async function renderList(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>Kiosk Pages <span class="help-tip" data-tip="Create interactive touchscreen interfaces. Add buttons with icons and actions. Includes idle screen that shows after inactivity. Assign to devices as a widget.">?</span></h1><div class="subtitle">Create interactive touchscreen interfaces</div></div>
|
<div><h1>${t('kiosk.title')} <span class="help-tip" data-tip="${t('kiosk.help_tip')}">?</span></h1><div class="subtitle">${t('kiosk.subtitle')}</div></div>
|
||||||
<button class="btn btn-primary" id="newKioskBtn">
|
<button class="btn btn-primary" id="newKioskBtn">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
New Kiosk Page
|
${t('kiosk.new_page')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-grid" id="kioskGrid"></div>
|
<div class="content-grid" id="kioskGrid"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('newKioskBtn').onclick = async () => {
|
document.getElementById('newKioskBtn').onclick = async () => {
|
||||||
const name = prompt('Kiosk page name:');
|
const name = prompt(t('kiosk.prompt_name'));
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const page = await API('/kiosk', { method: 'POST', body: JSON.stringify({ name }) });
|
const page = await API('/kiosk', { method: 'POST', body: JSON.stringify({ name }) });
|
||||||
window.location.hash = `#/kiosk/${page.id}`;
|
window.location.hash = `#/kiosk/${page.id}`;
|
||||||
|
|
@ -34,7 +36,7 @@ async function renderList(container) {
|
||||||
const pages = await API('/kiosk');
|
const pages = await API('/kiosk');
|
||||||
const grid = document.getElementById('kioskGrid');
|
const grid = document.getElementById('kioskGrid');
|
||||||
if (!pages.length) {
|
if (!pages.length) {
|
||||||
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><h3>No kiosk pages yet</h3><p>Create an interactive touchscreen interface for your displays.</p></div>';
|
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('kiosk.empty_title')}</h3><p>${t('kiosk.empty_desc')}</p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
grid.innerHTML = pages.map(p => `
|
grid.innerHTML = pages.map(p => `
|
||||||
|
|
@ -43,28 +45,27 @@ async function renderList(container) {
|
||||||
<span style="font-size:48px">🖱</span>
|
<span style="font-size:48px">🖱</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-item-body">
|
<div class="content-item-body">
|
||||||
<div class="content-item-name">${p.name}</div>
|
<div class="content-item-name">${esc(p.name)}</div>
|
||||||
<div class="content-item-size">Kiosk Page</div>
|
<div class="content-item-size">${t('kiosk.label')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-item-actions">
|
<div class="content-item-actions">
|
||||||
<a href="/api/kiosk/${p.id}/render" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none" onclick="event.stopPropagation()">Preview</a>
|
<a href="/api/kiosk/${p.id}/render" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none" onclick="event.stopPropagation()">${t('kiosk.preview')}</a>
|
||||||
<button class="btn btn-danger btn-sm" data-delete-kiosk="${p.id}" data-kiosk-name="${p.name}" onclick="event.stopPropagation()">Delete</button>
|
<button class="btn btn-danger btn-sm" data-delete-kiosk="${esc(p.id)}" data-kiosk-name="${esc(p.name)}" onclick="event.stopPropagation()">${t('common.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
// Delete handler
|
|
||||||
grid.querySelectorAll('[data-delete-kiosk]').forEach(btn => {
|
grid.querySelectorAll('[data-delete-kiosk]').forEach(btn => {
|
||||||
btn.onclick = async (e) => {
|
btn.onclick = async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const name = btn.dataset.kioskName;
|
const name = btn.dataset.kioskName;
|
||||||
if (!confirm(`Delete kiosk page "${name}"? This cannot be undone.`)) return;
|
if (!confirm(t('kiosk.confirm_delete', { name }))) return;
|
||||||
try {
|
try {
|
||||||
await API(`/kiosk/${btn.dataset.deleteKiosk}`, { method: 'DELETE' });
|
await API(`/kiosk/${btn.dataset.deleteKiosk}`, { method: 'DELETE' });
|
||||||
showToast('Kiosk page deleted');
|
showToast(t('kiosk.toast.deleted'));
|
||||||
renderList(container);
|
renderList(container);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || 'Failed to delete', 'error');
|
showToast(err.message || t('kiosk.toast.delete_failed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -73,7 +74,7 @@ async function renderList(container) {
|
||||||
|
|
||||||
async function renderEditor(container, pageId) {
|
async function renderEditor(container, pageId) {
|
||||||
let page;
|
let page;
|
||||||
try { page = await API(`/kiosk/${pageId}`); } catch { container.innerHTML = '<div class="empty-state"><h3>Page not found</h3></div>'; return; }
|
try { page = await API(`/kiosk/${pageId}`); } catch { container.innerHTML = `<div class="empty-state"><h3>${t('kiosk.not_found')}</h3></div>`; return; }
|
||||||
|
|
||||||
let config = JSON.parse(page.config || '{}');
|
let config = JSON.parse(page.config || '{}');
|
||||||
if (!config.buttons) config.buttons = [];
|
if (!config.buttons) config.buttons = [];
|
||||||
|
|
@ -82,49 +83,47 @@ async function renderEditor(container, pageId) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<a href="#/kiosk" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px">
|
<a href="#/kiosk" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||||
Back to Kiosk Pages
|
${t('kiosk.back')}
|
||||||
</a>
|
</a>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>${page.name}</h1>
|
<h1>${esc(page.name)}</h1>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<a href="/api/kiosk/${pageId}/render" target="_blank" class="btn btn-secondary" style="text-decoration:none">Preview</a>
|
<a href="/api/kiosk/${pageId}/render" target="_blank" class="btn btn-secondary" style="text-decoration:none">${t('kiosk.preview')}</a>
|
||||||
<button class="btn btn-primary" id="saveKioskBtn">Save</button>
|
<button class="btn btn-primary" id="saveKioskBtn">${t('common.save')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:20px">
|
<div style="display:flex;gap:20px">
|
||||||
<!-- Preview -->
|
|
||||||
<div style="flex:1">
|
<div style="flex:1">
|
||||||
<iframe id="kioskPreview" src="/api/kiosk/${pageId}/render" style="width:100%;aspect-ratio:16/9;border:1px solid var(--border);border-radius:var(--radius-lg)"></iframe>
|
<iframe id="kioskPreview" src="/api/kiosk/${pageId}/render" style="width:100%;aspect-ratio:16/9;border:1px solid var(--border);border-radius:var(--radius-lg)"></iframe>
|
||||||
</div>
|
</div>
|
||||||
<!-- Editor -->
|
|
||||||
<div style="width:320px;max-height:calc(100vh - 140px);overflow-y:auto;display:flex;flex-direction:column;gap:12px">
|
<div style="width:320px;max-height:calc(100vh - 140px);overflow-y:auto;display:flex;flex-direction:column;gap:12px">
|
||||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
||||||
<h4 style="font-size:13px;margin-bottom:10px">Page Settings</h4>
|
<h4 style="font-size:13px;margin-bottom:10px">${t('kiosk.page_settings')}</h4>
|
||||||
<div class="form-group"><label>Title</label><input type="text" id="kTitle" class="input" value="${config.title || ''}"></div>
|
<div class="form-group"><label>${t('kiosk.title_label')}</label><input type="text" id="kTitle" class="input" value="${esc(config.title || '')}"></div>
|
||||||
<div class="form-group"><label>Subtitle</label><input type="text" id="kSubtitle" class="input" value="${config.subtitle || ''}"></div>
|
<div class="form-group"><label>${t('kiosk.subtitle_label')}</label><input type="text" id="kSubtitle" class="input" value="${esc(config.subtitle || '')}"></div>
|
||||||
<div class="form-group"><label>Logo URL</label><input type="text" id="kLogo" class="input" value="${config.logoUrl || ''}" placeholder="https://..."></div>
|
<div class="form-group"><label>${t('kiosk.logo_url')}</label><input type="text" id="kLogo" class="input" value="${esc(config.logoUrl || '')}" placeholder="https://..."></div>
|
||||||
<div class="form-group"><label>Footer Text</label><input type="text" id="kFooter" class="input" value="${config.footer || ''}"></div>
|
<div class="form-group"><label>${t('kiosk.footer_text')}</label><input type="text" id="kFooter" class="input" value="${esc(config.footer || '')}"></div>
|
||||||
<div class="form-group"><label>Idle Screen Title</label><input type="text" id="kIdleTitle" class="input" value="${config.idleTitle || 'Touch to Begin'}"></div>
|
<div class="form-group"><label>${t('kiosk.idle_title')}</label><input type="text" id="kIdleTitle" class="input" value="${esc(config.idleTitle || t('kiosk.idle_default'))}"></div>
|
||||||
<div class="form-group"><label>Idle Timeout (seconds)</label><input type="number" id="kIdleTimeout" class="input" value="${config.idleTimeout || 60}"></div>
|
<div class="form-group"><label>${t('kiosk.idle_timeout')}</label><input type="number" id="kIdleTimeout" class="input" value="${config.idleTimeout || 60}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
||||||
<h4 style="font-size:13px;margin-bottom:10px">Style</h4>
|
<h4 style="font-size:13px;margin-bottom:10px">${t('kiosk.style')}</h4>
|
||||||
<div class="form-group"><label>Background</label><input type="text" id="kBg" class="input" value="${config.style?.background || '#111827'}"></div>
|
<div class="form-group"><label>${t('kiosk.background')}</label><input type="text" id="kBg" class="input" value="${esc(config.style?.background || '#111827')}"></div>
|
||||||
<div class="form-group"><label>Text Color</label><input type="color" id="kTextColor" value="${config.style?.textColor || '#f1f5f9'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
<div class="form-group"><label>${t('kiosk.text_color')}</label><input type="color" id="kTextColor" value="${config.style?.textColor || '#f1f5f9'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
||||||
<div class="form-group"><label>Columns</label><select id="kColumns" class="input" style="background:var(--bg-input)">
|
<div class="form-group"><label>${t('kiosk.columns')}</label><select id="kColumns" class="input" style="background:var(--bg-input)">
|
||||||
<option ${(config.style?.columns || 3) === 2 ? 'selected' : ''} value="2">2</option>
|
<option ${(config.style?.columns || 3) === 2 ? 'selected' : ''} value="2">2</option>
|
||||||
<option ${(config.style?.columns || 3) === 3 ? 'selected' : ''} value="3">3</option>
|
<option ${(config.style?.columns || 3) === 3 ? 'selected' : ''} value="3">3</option>
|
||||||
<option ${(config.style?.columns || 3) === 4 ? 'selected' : ''} value="4">4</option>
|
<option ${(config.style?.columns || 3) === 4 ? 'selected' : ''} value="4">4</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div class="form-group"><label>Button Color</label><input type="color" id="kBtnBg" value="${config.style?.buttonBg || '#1e293b'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
<div class="form-group"><label>${t('kiosk.button_color')}</label><input type="color" id="kBtnBg" value="${config.style?.buttonBg || '#1e293b'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
||||||
<div class="form-group"><label>Button Hover Color</label><input type="color" id="kBtnHover" value="${config.style?.buttonHover || '#3b82f6'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
<div class="form-group"><label>${t('kiosk.button_hover')}</label><input type="color" id="kBtnHover" value="${config.style?.buttonHover || '#3b82f6'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||||
<h4 style="font-size:13px">Buttons</h4>
|
<h4 style="font-size:13px">${t('kiosk.buttons')}</h4>
|
||||||
<button class="btn btn-secondary btn-sm" id="addBtnBtn">+ Add</button>
|
<button class="btn btn-secondary btn-sm" id="addBtnBtn">${t('kiosk.add_btn')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="buttonList"></div>
|
<div id="buttonList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,23 +136,22 @@ async function renderEditor(container, pageId) {
|
||||||
list.innerHTML = config.buttons.map((btn, i) => `
|
list.innerHTML = config.buttons.map((btn, i) => `
|
||||||
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:8px;margin-bottom:6px">
|
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:8px;margin-bottom:6px">
|
||||||
<div style="display:flex;gap:6px;margin-bottom:6px">
|
<div style="display:flex;gap:6px;margin-bottom:6px">
|
||||||
<input type="text" class="input" value="${btn.icon || ''}" placeholder="Emoji" style="width:50px;text-align:center" data-btn="${i}" data-field="icon">
|
<input type="text" class="input" value="${esc(btn.icon || '')}" placeholder="${t('kiosk.icon_placeholder')}" style="width:50px;text-align:center" data-btn="${i}" data-field="icon">
|
||||||
<input type="text" class="input" value="${btn.label || ''}" placeholder="Label" style="flex:1" data-btn="${i}" data-field="label">
|
<input type="text" class="input" value="${esc(btn.label || '')}" placeholder="${t('kiosk.label_placeholder')}" style="flex:1" data-btn="${i}" data-field="label">
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="input" value="${btn.sublabel || ''}" placeholder="Sublabel" style="font-size:12px;margin-bottom:4px" data-btn="${i}" data-field="sublabel">
|
<input type="text" class="input" value="${esc(btn.sublabel || '')}" placeholder="${t('kiosk.sublabel_placeholder')}" style="font-size:12px;margin-bottom:4px" data-btn="${i}" data-field="sublabel">
|
||||||
<div style="display:flex;gap:6px;align-items:center">
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
<select class="input" style="background:var(--bg-input);font-size:11px;flex:1" data-btn="${i}" data-field="action">
|
<select class="input" style="background:var(--bg-input);font-size:11px;flex:1" data-btn="${i}" data-field="action">
|
||||||
<option value="" ${!btn.action ? 'selected' : ''}>No action</option>
|
<option value="" ${!btn.action ? 'selected' : ''}>${t('kiosk.action_none')}</option>
|
||||||
<option value="url" ${btn.action === 'url' ? 'selected' : ''}>Open URL</option>
|
<option value="url" ${btn.action === 'url' ? 'selected' : ''}>${t('kiosk.action_url')}</option>
|
||||||
<option value="page" ${btn.action === 'page' ? 'selected' : ''}>Go to page</option>
|
<option value="page" ${btn.action === 'page' ? 'selected' : ''}>${t('kiosk.action_page')}</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn-icon" style="color:var(--danger)" data-remove-btn="${i}" title="Remove">✕</button>
|
<button class="btn-icon" style="color:var(--danger)" data-remove-btn="${i}" title="${t('common.delete')}">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="input" value="${btn.url || btn.page || ''}" placeholder="URL or page" style="font-size:11px;margin-top:4px" data-btn="${i}" data-field="url">
|
<input type="text" class="input" value="${esc(btn.url || btn.page || '')}" placeholder="${t('kiosk.url_placeholder')}" style="font-size:11px;margin-top:4px" data-btn="${i}" data-field="url">
|
||||||
</div>
|
</div>
|
||||||
`).join('') || '<p style="color:var(--text-muted);font-size:12px">No buttons yet</p>';
|
`).join('') || `<p style="color:var(--text-muted);font-size:12px">${t('kiosk.no_buttons')}</p>`;
|
||||||
|
|
||||||
// Bind inputs
|
|
||||||
list.querySelectorAll('[data-btn]').forEach(input => {
|
list.querySelectorAll('[data-btn]').forEach(input => {
|
||||||
input.oninput = () => {
|
input.oninput = () => {
|
||||||
const idx = parseInt(input.dataset.btn);
|
const idx = parseInt(input.dataset.btn);
|
||||||
|
|
@ -168,7 +166,7 @@ async function renderEditor(container, pageId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('addBtnBtn').onclick = () => {
|
document.getElementById('addBtnBtn').onclick = () => {
|
||||||
config.buttons.push({ label: 'New Button', sublabel: '', icon: '⭐', action: '', url: '' });
|
config.buttons.push({ label: t('kiosk.new_button'), sublabel: '', icon: '⭐', action: '', url: '' });
|
||||||
renderButtons();
|
renderButtons();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -190,7 +188,7 @@ async function renderEditor(container, pageId) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await API(`/kiosk/${pageId}`, { method: 'PUT', body: JSON.stringify({ config }) });
|
await API(`/kiosk/${pageId}`, { method: 'PUT', body: JSON.stringify({ config }) });
|
||||||
showToast('Kiosk page saved', 'success');
|
showToast(t('kiosk.toast.saved'), 'success');
|
||||||
document.getElementById('kioskPreview').src = `/api/kiosk/${pageId}/render?t=${Date.now()}`;
|
document.getElementById('kioskPreview').src = `/api/kiosk/${pageId}/render?t=${Date.now()}`;
|
||||||
} catch (err) { showToast(err.message, 'error'); }
|
} catch (err) { showToast(err.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { t, tn } from '../i18n.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
|
||||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||||
|
|
||||||
|
|
@ -15,22 +17,22 @@ export async function render(container) {
|
||||||
async function renderList(container) {
|
async function renderList(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>Layouts <span class="help-tip" data-tip="Create multi-zone screen layouts. Use templates or build custom ones. Drag zones to position, resize with corner handle. Assign layouts to devices in the Playlist tab.">?</span></h1><div class="subtitle">Screen layouts and templates</div></div>
|
<div><h1>${t('layout.title')} <span class="help-tip" data-tip="${t('layout.help_tip')}">?</span></h1><div class="subtitle">${t('layout.subtitle')}</div></div>
|
||||||
<button class="btn btn-primary" id="newLayoutBtn">
|
<button class="btn btn-primary" id="newLayoutBtn">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
New Layout
|
${t('layout.new_layout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 style="margin-bottom:12px;font-size:14px;color:var(--text-secondary)">Templates</h3>
|
<h3 style="margin-bottom:12px;font-size:14px;color:var(--text-secondary)">${t('layout.templates')}</h3>
|
||||||
<div class="content-grid" id="templateGrid"></div>
|
<div class="content-grid" id="templateGrid"></div>
|
||||||
<h3 style="margin:24px 0 12px;font-size:14px;color:var(--text-secondary)">My Layouts</h3>
|
<h3 style="margin:24px 0 12px;font-size:14px;color:var(--text-secondary)">${t('layout.my_layouts')}</h3>
|
||||||
<div class="content-grid" id="layoutGrid"></div>
|
<div class="content-grid" id="layoutGrid"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('newLayoutBtn').onclick = async () => {
|
document.getElementById('newLayoutBtn').onclick = async () => {
|
||||||
const name = prompt('Layout name:');
|
const name = prompt(t('layout.prompt_name'));
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const layout = await API('/layouts', { method: 'POST', body: JSON.stringify({ name, zones: [{ name: 'Main', x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 100 }] }) });
|
const layout = await API('/layouts', { method: 'POST', body: JSON.stringify({ name, zones: [{ name: t('layout.default_zone_name'), x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 100 }] }) });
|
||||||
window.location.hash = `#/layout/${layout.id}`;
|
window.location.hash = `#/layout/${layout.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -41,9 +43,8 @@ async function renderList(container) {
|
||||||
|
|
||||||
document.getElementById('templateGrid').innerHTML = templates.map(l => renderLayoutCard(l, true)).join('');
|
document.getElementById('templateGrid').innerHTML = templates.map(l => renderLayoutCard(l, true)).join('');
|
||||||
document.getElementById('layoutGrid').innerHTML = custom.length ? custom.map(l => renderLayoutCard(l, false)).join('') :
|
document.getElementById('layoutGrid').innerHTML = custom.length ? custom.map(l => renderLayoutCard(l, false)).join('') :
|
||||||
'<div class="empty-state" style="grid-column:1/-1"><p>No custom layouts yet</p></div>';
|
`<div class="empty-state" style="grid-column:1/-1"><p>${t('layout.empty_custom')}</p></div>`;
|
||||||
|
|
||||||
// Use template click
|
|
||||||
container.querySelectorAll('[data-use-template]').forEach(btn => {
|
container.querySelectorAll('[data-use-template]').forEach(btn => {
|
||||||
btn.onclick = async () => {
|
btn.onclick = async () => {
|
||||||
const layout = await API(`/layouts/${btn.dataset.useTemplate}/duplicate`, { method: 'POST', body: '{}' });
|
const layout = await API(`/layouts/${btn.dataset.useTemplate}/duplicate`, { method: 'POST', body: '{}' });
|
||||||
|
|
@ -51,23 +52,21 @@ async function renderList(container) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit layout click
|
|
||||||
container.querySelectorAll('[data-edit-layout]').forEach(btn => {
|
container.querySelectorAll('[data-edit-layout]').forEach(btn => {
|
||||||
btn.onclick = () => { window.location.hash = `#/layout/${btn.dataset.editLayout}`; };
|
btn.onclick = () => { window.location.hash = `#/layout/${btn.dataset.editLayout}`; };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete layout click
|
|
||||||
container.querySelectorAll('[data-delete-layout]').forEach(btn => {
|
container.querySelectorAll('[data-delete-layout]').forEach(btn => {
|
||||||
btn.onclick = async (e) => {
|
btn.onclick = async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const name = btn.dataset.layoutName;
|
const name = btn.dataset.layoutName;
|
||||||
if (!confirm(`Delete layout "${name}"? This cannot be undone.`)) return;
|
if (!confirm(t('layout.confirm_delete', { name }))) return;
|
||||||
try {
|
try {
|
||||||
await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' });
|
await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' });
|
||||||
showToast('Layout deleted');
|
showToast(t('layout.toast.deleted'));
|
||||||
renderList(container);
|
renderList(container);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || 'Failed to delete layout', 'error');
|
showToast(err.message || t('layout.toast.delete_failed'), 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -77,6 +76,8 @@ async function renderList(container) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLayoutCard(layout, isTemplate) {
|
function renderLayoutCard(layout, isTemplate) {
|
||||||
|
const zoneCount = layout.zones?.length || 0;
|
||||||
|
const zonesText = tn('layout.zone_count', zoneCount);
|
||||||
return `
|
return `
|
||||||
<div class="content-item" style="cursor:pointer">
|
<div class="content-item" style="cursor:pointer">
|
||||||
<div class="content-item-preview" style="position:relative;background:var(--bg-primary)">
|
<div class="content-item-preview" style="position:relative;background:var(--bg-primary)">
|
||||||
|
|
@ -84,20 +85,20 @@ function renderLayoutCard(layout, isTemplate) {
|
||||||
${(layout.zones || []).map(z => `
|
${(layout.zones || []).map(z => `
|
||||||
<div style="position:absolute;left:${z.x_percent}%;top:${z.y_percent}%;width:${z.width_percent}%;height:${z.height_percent}%;
|
<div style="position:absolute;left:${z.x_percent}%;top:${z.y_percent}%;width:${z.width_percent}%;height:${z.height_percent}%;
|
||||||
background:rgba(59,130,246,0.15);border:1px solid rgba(59,130,246,0.4);display:flex;align-items:center;justify-content:center;
|
background:rgba(59,130,246,0.15);border:1px solid rgba(59,130,246,0.4);display:flex;align-items:center;justify-content:center;
|
||||||
font-size:9px;color:var(--text-muted);overflow:hidden">${z.name}</div>
|
font-size:9px;color:var(--text-muted);overflow:hidden">${esc(z.name)}</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-item-body">
|
<div class="content-item-body">
|
||||||
<div class="content-item-name">${layout.name}</div>
|
<div class="content-item-name">${esc(layout.name)}</div>
|
||||||
<div class="content-item-size">${layout.zones?.length || 0} zone(s) ${isTemplate ? '• Template' : ''}</div>
|
<div class="content-item-size">${zonesText}${isTemplate ? ' • ' + t('layout.template_label') : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-item-actions">
|
<div class="content-item-actions">
|
||||||
${isTemplate
|
${isTemplate
|
||||||
? `<button class="btn btn-primary btn-sm" data-use-template="${layout.id}">Use Template</button>`
|
? `<button class="btn btn-primary btn-sm" data-use-template="${layout.id}">${t('layout.use_template')}</button>`
|
||||||
: `<button class="btn btn-secondary btn-sm" data-edit-layout="${layout.id}">Edit</button>`
|
: `<button class="btn btn-secondary btn-sm" data-edit-layout="${layout.id}">${t('common.edit')}</button>`
|
||||||
}
|
}
|
||||||
<button class="btn btn-danger btn-sm" data-delete-layout="${layout.id}" data-layout-name="${layout.name}" style="margin-left:4px">Delete</button>
|
<button class="btn btn-danger btn-sm" data-delete-layout="${layout.id}" data-layout-name="${esc(layout.name)}" style="margin-left:4px">${t('common.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -107,44 +108,51 @@ async function renderEditor(container, layoutId) {
|
||||||
let layout;
|
let layout;
|
||||||
try {
|
try {
|
||||||
layout = await API(`/layouts/${layoutId}`);
|
layout = await API(`/layouts/${layoutId}`);
|
||||||
} catch { container.innerHTML = '<div class="empty-state"><h3>Layout not found</h3></div>'; return; }
|
} catch { container.innerHTML = `<div class="empty-state"><h3>${t('layout.not_found')}</h3></div>`; return; }
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<a href="#/layouts" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px">
|
<a href="#/layouts" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||||
Back to Layouts
|
${t('layout.back')}
|
||||||
</a>
|
</a>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 id="layoutName">${layout.name}</h1>
|
<h1 id="layoutName">${esc(layout.name)}</h1>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button class="btn btn-secondary btn-sm" id="addZoneBtn">Add Zone</button>
|
<button class="btn btn-secondary btn-sm" id="addZoneBtn">${t('layout.add_zone')}</button>
|
||||||
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">Save</button>
|
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">${t('common.save')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:20px">
|
<div style="display:flex;gap:20px">
|
||||||
<div style="flex:1">
|
<div style="flex:1">
|
||||||
<div id="canvasWrap" style="position:relative;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden">
|
<div id="canvasWrap" style="position:relative;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden">
|
||||||
<div id="canvas" style="position:relative;width:100%;padding-top:56.25%">
|
<div id="canvas" style="position:relative;width:100%;padding-top:56.25%">
|
||||||
<!-- Zones rendered here -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="width:280px">
|
<div style="width:280px">
|
||||||
<h3 style="font-size:14px;margin-bottom:12px">Zones</h3>
|
<h3 style="font-size:14px;margin-bottom:12px">${t('layout.zones')}</h3>
|
||||||
<div id="zoneList"></div>
|
<div id="zoneList"></div>
|
||||||
<div id="zoneProperties" style="margin-top:16px;display:none">
|
<div id="zoneProperties" style="margin-top:16px;display:none">
|
||||||
<h3 style="font-size:14px;margin-bottom:12px">Properties</h3>
|
<h3 style="font-size:14px;margin-bottom:12px">${t('layout.properties')}</h3>
|
||||||
<div class="form-group"><label>Name</label><input type="text" id="propName" class="input"></div>
|
<div class="form-group"><label>${t('layout.prop.name')}</label><input type="text" id="propName" class="input"></div>
|
||||||
<div class="form-group"><label>X (%)</label><input type="number" id="propX" class="input" min="0" max="100" step="0.1"></div>
|
<div class="form-group"><label>${t('layout.prop.x')}</label><input type="number" id="propX" class="input" min="0" max="100" step="0.1"></div>
|
||||||
<div class="form-group"><label>Y (%)</label><input type="number" id="propY" class="input" min="0" max="100" step="0.1"></div>
|
<div class="form-group"><label>${t('layout.prop.y')}</label><input type="number" id="propY" class="input" min="0" max="100" step="0.1"></div>
|
||||||
<div class="form-group"><label>Width (%)</label><input type="number" id="propW" class="input" min="1" max="100" step="0.1"></div>
|
<div class="form-group"><label>${t('layout.prop.width')}</label><input type="number" id="propW" class="input" min="1" max="100" step="0.1"></div>
|
||||||
<div class="form-group"><label>Height (%)</label><input type="number" id="propH" class="input" min="1" max="100" step="0.1"></div>
|
<div class="form-group"><label>${t('layout.prop.height')}</label><input type="number" id="propH" class="input" min="1" max="100" step="0.1"></div>
|
||||||
<div class="form-group"><label>Type</label>
|
<div class="form-group"><label>${t('layout.prop.type')}</label>
|
||||||
<select id="propType" class="input" style="background:var(--bg-input)">
|
<select id="propType" class="input" style="background:var(--bg-input)">
|
||||||
<option value="content">Content</option><option value="widget">Widget</option>
|
<option value="content">${t('layout.type_content')}</option><option value="widget">${t('layout.type_widget')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-danger btn-sm" id="deleteZoneBtn" style="width:100%;justify-content:center;margin-top:8px">Delete Zone</button>
|
<div class="form-group"><label>${t('layout.prop.fit')}</label>
|
||||||
|
<select id="propFit" class="input" style="background:var(--bg-input)">
|
||||||
|
<option value="contain">${t('layout.fit_contain')}</option>
|
||||||
|
<option value="cover">${t('layout.fit_cover')}</option>
|
||||||
|
<option value="fill">${t('layout.fit_fill')}</option>
|
||||||
|
</select>
|
||||||
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('layout.fit_hint')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-danger btn-sm" id="deleteZoneBtn" style="width:100%;justify-content:center;margin-top:8px">${t('layout.delete_zone')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -156,7 +164,6 @@ async function renderEditor(container, layoutId) {
|
||||||
|
|
||||||
function renderZones() {
|
function renderZones() {
|
||||||
const canvas = document.getElementById('canvas');
|
const canvas = document.getElementById('canvas');
|
||||||
// Clear only zone divs
|
|
||||||
canvas.querySelectorAll('.zone-el').forEach(z => z.remove());
|
canvas.querySelectorAll('.zone-el').forEach(z => z.remove());
|
||||||
|
|
||||||
zones.forEach((z, i) => {
|
zones.forEach((z, i) => {
|
||||||
|
|
@ -170,7 +177,6 @@ async function renderEditor(container, layoutId) {
|
||||||
user-select:none;z-index:${z.z_index || 0}`;
|
user-select:none;z-index:${z.z_index || 0}`;
|
||||||
el.textContent = z.name;
|
el.textContent = z.name;
|
||||||
|
|
||||||
// Drag to move
|
|
||||||
el.onmousedown = (e) => {
|
el.onmousedown = (e) => {
|
||||||
if (e.target !== el) return;
|
if (e.target !== el) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -200,7 +206,6 @@ async function renderEditor(container, layoutId) {
|
||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resize handle
|
|
||||||
const handle = document.createElement('div');
|
const handle = document.createElement('div');
|
||||||
handle.style.cssText = 'position:absolute;right:0;bottom:0;width:12px;height:12px;cursor:se-resize;background:var(--accent);border-radius:2px 0 0 0;opacity:0.7';
|
handle.style.cssText = 'position:absolute;right:0;bottom:0;width:12px;height:12px;cursor:se-resize;background:var(--accent);border-radius:2px 0 0 0;opacity:0.7';
|
||||||
handle.onmousedown = (e) => {
|
handle.onmousedown = (e) => {
|
||||||
|
|
@ -228,13 +233,12 @@ async function renderEditor(container, layoutId) {
|
||||||
canvas.appendChild(el);
|
canvas.appendChild(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Zone list sidebar
|
|
||||||
document.getElementById('zoneList').innerHTML = zones.map((z, i) => `
|
document.getElementById('zoneList').innerHTML = zones.map((z, i) => `
|
||||||
<div style="padding:8px 10px;background:${selectedZone === i ? 'var(--bg-card-hover)' : 'var(--bg-secondary)'};
|
<div style="padding:8px 10px;background:${selectedZone === i ? 'var(--bg-card-hover)' : 'var(--bg-secondary)'};
|
||||||
border:1px solid ${selectedZone === i ? 'var(--accent)' : 'var(--border)'};border-radius:var(--radius);
|
border:1px solid ${selectedZone === i ? 'var(--accent)' : 'var(--border)'};border-radius:var(--radius);
|
||||||
margin-bottom:4px;cursor:pointer;font-size:13px" data-zone-idx="${i}">
|
margin-bottom:4px;cursor:pointer;font-size:13px" data-zone-idx="${i}">
|
||||||
<div style="font-weight:500">${z.name}</div>
|
<div style="font-weight:500">${esc(z.name)}</div>
|
||||||
<div style="font-size:11px;color:var(--text-muted)">${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}% • ${z.zone_type}</div>
|
<div style="font-size:11px;color:var(--text-muted)">${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}% • ${esc(z.zone_type)}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
|
|
@ -254,10 +258,10 @@ async function renderEditor(container, layoutId) {
|
||||||
document.getElementById('propW').value = z.width_percent;
|
document.getElementById('propW').value = z.width_percent;
|
||||||
document.getElementById('propH').value = z.height_percent;
|
document.getElementById('propH').value = z.height_percent;
|
||||||
document.getElementById('propType').value = z.zone_type;
|
document.getElementById('propType').value = z.zone_type;
|
||||||
|
document.getElementById('propFit').value = z.fit_mode || 'cover';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Property input handlers
|
['propName', 'propX', 'propY', 'propW', 'propH', 'propType', 'propFit'].forEach(id => {
|
||||||
['propName', 'propX', 'propY', 'propW', 'propH', 'propType'].forEach(id => {
|
|
||||||
document.getElementById(id).oninput = () => {
|
document.getElementById(id).oninput = () => {
|
||||||
if (selectedZone === null) return;
|
if (selectedZone === null) return;
|
||||||
const z = zones[selectedZone];
|
const z = zones[selectedZone];
|
||||||
|
|
@ -267,12 +271,13 @@ async function renderEditor(container, layoutId) {
|
||||||
z.width_percent = parseFloat(document.getElementById('propW').value) || 10;
|
z.width_percent = parseFloat(document.getElementById('propW').value) || 10;
|
||||||
z.height_percent = parseFloat(document.getElementById('propH').value) || 10;
|
z.height_percent = parseFloat(document.getElementById('propH').value) || 10;
|
||||||
z.zone_type = document.getElementById('propType').value;
|
z.zone_type = document.getElementById('propType').value;
|
||||||
|
z.fit_mode = document.getElementById('propFit').value;
|
||||||
renderZones();
|
renderZones();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('addZoneBtn').onclick = () => {
|
document.getElementById('addZoneBtn').onclick = () => {
|
||||||
zones.push({ id: null, name: `Zone ${zones.length + 1}`, x_percent: 10, y_percent: 10, width_percent: 30, height_percent: 30, z_index: 0, zone_type: 'content', fit_mode: 'cover', background_color: '#000000', sort_order: zones.length });
|
zones.push({ id: null, name: t('layout.zone_n', { n: zones.length + 1 }), x_percent: 10, y_percent: 10, width_percent: 30, height_percent: 30, z_index: 0, zone_type: 'content', fit_mode: 'contain', background_color: '#000000', sort_order: zones.length });
|
||||||
selectedZone = zones.length - 1;
|
selectedZone = zones.length - 1;
|
||||||
renderZones();
|
renderZones();
|
||||||
updateProperties();
|
updateProperties();
|
||||||
|
|
@ -288,16 +293,21 @@ async function renderEditor(container, layoutId) {
|
||||||
|
|
||||||
document.getElementById('saveLayoutBtn').onclick = async () => {
|
document.getElementById('saveLayoutBtn').onclick = async () => {
|
||||||
try {
|
try {
|
||||||
// Delete existing zones and recreate
|
// Single atomic update: send the full zone set and the server replaces them
|
||||||
for (const z of layout.zones || []) {
|
// exactly. The old per-zone delete-then-add loop could accumulate zones
|
||||||
await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' });
|
// (and regenerated every zone id each save). Keep each zone's id so
|
||||||
}
|
// device->zone assignments survive.
|
||||||
for (const z of zones) {
|
const updated = await API(`/layouts/${layoutId}`, {
|
||||||
await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) });
|
method: 'PUT',
|
||||||
}
|
body: JSON.stringify({ zones }),
|
||||||
showToast('Layout saved', 'success');
|
});
|
||||||
layout = await API(`/layouts/${layoutId}`);
|
if (updated && updated.error) { showToast(updated.error, 'error'); return; }
|
||||||
zones = layout.zones;
|
layout = updated;
|
||||||
|
zones = layout.zones || [];
|
||||||
|
selectedZone = null;
|
||||||
|
showToast(t('layout.toast.saved'), 'success');
|
||||||
|
renderZones();
|
||||||
|
updateProperties();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
let authConfig = null;
|
let authConfig = null;
|
||||||
|
|
||||||
|
|
@ -9,49 +10,88 @@ async function loadAuthConfig() {
|
||||||
return authConfig;
|
return authConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function render(container) {
|
// #15: resolve instance/default branding for the (pre-login) login page.
|
||||||
const config = await loadAuthConfig();
|
// Public endpoint: custom-domain match -> platform default -> ScreenTinker.
|
||||||
const isSetup = config.needsSetup;
|
async function loadLoginBranding() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/branding?domain=' + encodeURIComponent(location.hostname));
|
||||||
|
if (!res.ok) return {};
|
||||||
|
return await res.json();
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = `
|
function brandEsc(s) {
|
||||||
<div style="display:flex;align-items:center;justify-content:center;height:100vh;margin-left:calc(-1 * var(--sidebar-width))">
|
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
<div style="width:400px;max-width:90vw">
|
}
|
||||||
<div style="text-align:center;margin-bottom:32px">
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" style="margin:0 auto 12px">
|
// Apply document-level branding (colors, favicon, title, custom CSS) for login.
|
||||||
|
function applyLoginBrandingDoc(b) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (b.primary_color) root.style.setProperty('--accent', b.primary_color);
|
||||||
|
if (b.bg_color) root.style.setProperty('--bg-primary', b.bg_color);
|
||||||
|
if (b.brand_name) document.title = b.brand_name;
|
||||||
|
if (b.favicon_url) {
|
||||||
|
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(l => l.setAttribute('href', b.favicon_url));
|
||||||
|
}
|
||||||
|
if (b.custom_css) {
|
||||||
|
let style = document.getElementById('wl-custom-css');
|
||||||
|
if (!style) { style = document.createElement('style'); style.id = 'wl-custom-css'; document.head.appendChild(style); }
|
||||||
|
style.textContent = b.custom_css;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function render(container) {
|
||||||
|
const [config, branding] = await Promise.all([loadAuthConfig(), loadLoginBranding()]);
|
||||||
|
const isSetup = config.needsSetup;
|
||||||
|
// registration_enabled may be absent on older servers — treat as enabled for back-compat
|
||||||
|
const canRegister = config.registration_enabled !== false;
|
||||||
|
|
||||||
|
applyLoginBrandingDoc(branding);
|
||||||
|
const brandName = branding.brand_name || 'ScreenTinker';
|
||||||
|
// Branded logo if set, else the default ScreenTinker glyph.
|
||||||
|
const logoHtml = branding.logo_url
|
||||||
|
? `<img src="${brandEsc(branding.logo_url)}" alt="${brandEsc(brandName)}" style="max-height:48px;max-width:200px;margin:0 auto 12px;display:block">`
|
||||||
|
: `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" style="margin:0 auto 12px">
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||||
</svg>
|
</svg>`;
|
||||||
<h1 style="font-size:24px;font-weight:700;color:var(--accent)">ScreenTinker</h1>
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
|
||||||
|
<div style="width:400px;max-width:100%">
|
||||||
|
<div style="text-align:center;margin-bottom:32px">
|
||||||
|
${logoHtml}
|
||||||
|
<h1 style="font-size:24px;font-weight:700;color:var(--accent)">${brandEsc(brandName)}</h1>
|
||||||
<p style="color:var(--text-secondary);font-size:13px;margin-top:4px">
|
<p style="color:var(--text-secondary);font-size:13px;margin-top:4px">
|
||||||
${isSetup ? 'Create your admin account to get started' : 'Sign in to manage your displays'}
|
${isSetup ? t('auth.subtitle_setup') : t('auth.subtitle_signin')}
|
||||||
</p>
|
</p>
|
||||||
${isSetup ? '' : '<p style="color:var(--warning);font-size:12px;margin-top:8px">New accounts get a 14-day free Pro trial</p>'}
|
${!isSetup && canRegister ? `<p style="color:var(--warning);font-size:12px;margin-top:8px">${t('auth.trial_notice')}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px">
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px">
|
||||||
<!-- Local Auth Form -->
|
<!-- Local Auth Form -->
|
||||||
<div id="localAuthForm">
|
<div id="localAuthForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Email</label>
|
<label>${t('auth.email')}</label>
|
||||||
<input type="email" id="loginEmail" class="input" placeholder="you@example.com" autocomplete="email">
|
<input type="email" id="loginEmail" class="input" placeholder="${t('auth.placeholder_email')}" autocomplete="email">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Password</label>
|
<label>${t('auth.password')}</label>
|
||||||
<input type="password" id="loginPassword" class="input" placeholder="••••••••" autocomplete="current-password">
|
<input type="password" id="loginPassword" class="input" placeholder="${t('auth.placeholder_password')}" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
${isSetup ? `
|
${isSetup ? `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Name</label>
|
<label>${t('auth.name')}</label>
|
||||||
<input type="text" id="loginName" class="input" placeholder="Your name">
|
<input type="text" id="loginName" class="input" placeholder="${t('auth.placeholder_name')}">
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<button class="btn btn-primary" id="loginBtn" style="width:100%;justify-content:center;padding:10px">
|
<button class="btn btn-primary" id="loginBtn" style="width:100%;justify-content:center;padding:10px">
|
||||||
${isSetup ? 'Create Admin Account' : 'Sign In'}
|
${isSetup ? t('auth.create_admin_account') : t('auth.sign_in')}
|
||||||
</button>
|
</button>
|
||||||
${!isSetup ? `
|
${!isSetup && canRegister ? `
|
||||||
<button class="btn btn-secondary" id="showRegisterBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
|
<button class="btn btn-secondary" id="showRegisterBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
|
||||||
Create Account
|
${t('auth.create_account')}
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -59,29 +99,29 @@ export async function render(container) {
|
||||||
<!-- Register form (hidden by default) -->
|
<!-- Register form (hidden by default) -->
|
||||||
<div id="registerForm" style="display:none">
|
<div id="registerForm" style="display:none">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Name</label>
|
<label>${t('auth.name')}</label>
|
||||||
<input type="text" id="regName" class="input" placeholder="Your name">
|
<input type="text" id="regName" class="input" placeholder="${t('auth.placeholder_name')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Email</label>
|
<label>${t('auth.email')}</label>
|
||||||
<input type="email" id="regEmail" class="input" placeholder="you@example.com">
|
<input type="email" id="regEmail" class="input" placeholder="${t('auth.placeholder_email')}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Password</label>
|
<label>${t('auth.password')}</label>
|
||||||
<input type="password" id="regPassword" class="input" placeholder="At least 6 characters">
|
<input type="password" id="regPassword" class="input" placeholder="${t('auth.placeholder_register_password')}">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="registerBtn" style="width:100%;justify-content:center;padding:10px">
|
<button class="btn btn-primary" id="registerBtn" style="width:100%;justify-content:center;padding:10px">
|
||||||
Create Account
|
${t('auth.create_account')}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" id="showLoginBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
|
<button class="btn btn-secondary" id="showLoginBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
|
||||||
Back to Sign In
|
${t('auth.back_to_signin')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${config.googleEnabled || config.microsoftEnabled ? `
|
${config.googleEnabled || config.microsoftEnabled ? `
|
||||||
<div style="display:flex;align-items:center;gap:12px;margin:20px 0">
|
<div style="display:flex;align-items:center;gap:12px;margin:20px 0">
|
||||||
<hr style="flex:1;border-color:var(--border)">
|
<hr style="flex:1;border-color:var(--border)">
|
||||||
<span style="color:var(--text-muted);font-size:12px">OR</span>
|
<span style="color:var(--text-muted);font-size:12px">${t('auth.divider_or')}</span>
|
||||||
<hr style="flex:1;border-color:var(--border)">
|
<hr style="flex:1;border-color:var(--border)">
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
@ -95,7 +135,7 @@ export async function render(container) {
|
||||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
</svg>
|
</svg>
|
||||||
Sign in with Google
|
${t('auth.signin_google')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
@ -108,25 +148,25 @@ export async function render(container) {
|
||||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||||
</svg>
|
</svg>
|
||||||
Sign in with Microsoft
|
${t('auth.signin_microsoft')}
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Support Access (collapsible) -->
|
<!-- Support Access (collapsible) -->
|
||||||
<details style="margin-top:16px">
|
<details style="margin-top:16px">
|
||||||
<summary style="font-size:11px;color:var(--text-muted);cursor:pointer;text-align:center">Support Access</summary>
|
<summary style="font-size:11px;color:var(--text-muted);cursor:pointer;text-align:center">${t('auth.support_access')}</summary>
|
||||||
<div style="margin-top:8px">
|
<div style="margin-top:8px">
|
||||||
<input type="text" id="supportToken" class="input" placeholder="Paste support token" style="font-family:monospace;font-size:11px">
|
<input type="text" id="supportToken" class="input" placeholder="${t('auth.support_token_placeholder')}" style="font-family:monospace">
|
||||||
<button class="btn btn-secondary" id="supportLoginBtn" style="width:100%;justify-content:center;padding:8px;margin-top:6px;font-size:12px">Authenticate with Support Token</button>
|
<button class="btn btn-secondary" id="supportLoginBtn" style="width:100%;justify-content:center;padding:8px;margin-top:6px;font-size:12px">${t('auth.support_authenticate')}</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<p id="loginError" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></p>
|
<p id="loginError" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></p>
|
||||||
<p style="text-align:center;margin-top:16px;font-size:11px;color:var(--text-muted)">
|
<p style="text-align:center;margin-top:16px;font-size:11px;color:var(--text-muted)">
|
||||||
<a href="/legal/terms.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">Terms of Service</a>
|
<a href="/legal/terms.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">${t('auth.terms')}</a>
|
||||||
·
|
·
|
||||||
<a href="/legal/privacy.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">Privacy Policy</a>
|
<a href="/legal/privacy.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">${t('auth.privacy')}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -145,7 +185,7 @@ function setupHandlers(config, isSetup) {
|
||||||
// Support token login
|
// Support token login
|
||||||
document.getElementById('supportLoginBtn')?.addEventListener('click', async () => {
|
document.getElementById('supportLoginBtn')?.addEventListener('click', async () => {
|
||||||
const token = document.getElementById('supportToken')?.value.trim();
|
const token = document.getElementById('supportToken')?.value.trim();
|
||||||
if (!token) { showError('Paste a support token'); return; }
|
if (!token) { showError(t('auth.error_paste_support_token')); return; }
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/support', {
|
const res = await fetch('/api/auth/support', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -155,7 +195,7 @@ function setupHandlers(config, isSetup) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) { showError(data.error); return; }
|
if (!res.ok) { showError(data.error); return; }
|
||||||
onAuthSuccess(data);
|
onAuthSuccess(data);
|
||||||
} catch (err) { showError('Support login failed'); }
|
} catch (err) { showError(t('auth.error_support_failed')); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Local login/register
|
// Local login/register
|
||||||
|
|
@ -182,7 +222,7 @@ function setupHandlers(config, isSetup) {
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
const email = document.getElementById('loginEmail').value.trim();
|
const email = document.getElementById('loginEmail').value.trim();
|
||||||
const password = document.getElementById('loginPassword').value;
|
const password = document.getElementById('loginPassword').value;
|
||||||
if (!email || !password) { showError('Email and password required'); return; }
|
if (!email || !password) { showError(t('auth.error_email_password_required')); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch('/api/auth/login', {
|
||||||
|
|
@ -194,7 +234,7 @@ function setupHandlers(config, isSetup) {
|
||||||
if (!res.ok) { showError(data.error); return; }
|
if (!res.ok) { showError(data.error); return; }
|
||||||
onAuthSuccess(data);
|
onAuthSuccess(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Login failed');
|
showError(t('auth.error_login_failed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,8 +242,8 @@ function setupHandlers(config, isSetup) {
|
||||||
const email = document.getElementById(isFirstUser ? 'loginEmail' : 'regEmail').value.trim();
|
const email = document.getElementById(isFirstUser ? 'loginEmail' : 'regEmail').value.trim();
|
||||||
const password = document.getElementById(isFirstUser ? 'loginPassword' : 'regPassword').value;
|
const password = document.getElementById(isFirstUser ? 'loginPassword' : 'regPassword').value;
|
||||||
const name = document.getElementById(isFirstUser ? 'loginName' : 'regName')?.value.trim() || '';
|
const name = document.getElementById(isFirstUser ? 'loginName' : 'regName')?.value.trim() || '';
|
||||||
if (!email || !password) { showError('Email and password required'); return; }
|
if (!email || !password) { showError(t('auth.error_email_password_required')); return; }
|
||||||
if (password.length < 6) { showError('Password must be at least 6 characters'); return; }
|
if (password.length < 6) { showError(t('auth.error_password_min_6')); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/register', {
|
const res = await fetch('/api/auth/register', {
|
||||||
|
|
@ -215,7 +255,7 @@ function setupHandlers(config, isSetup) {
|
||||||
if (!res.ok) { showError(data.error); return; }
|
if (!res.ok) { showError(data.error); return; }
|
||||||
onAuthSuccess(data);
|
onAuthSuccess(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Registration failed');
|
showError(t('auth.error_registration_failed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,7 +286,7 @@ function setupHandlers(config, isSetup) {
|
||||||
});
|
});
|
||||||
client.requestAccessToken();
|
client.requestAccessToken();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Google sign-in failed');
|
showError(t('auth.error_google_failed'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -276,7 +316,7 @@ function setupHandlers(config, isSetup) {
|
||||||
else showError(data.error);
|
else showError(data.error);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Microsoft sign-in failed');
|
showError(t('auth.error_microsoft_failed'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
frontend/js/views/no-workspace.js
Normal file
31
frontend/js/views/no-workspace.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// #12: empty state for a signed-in user who belongs to zero workspaces. Happens
|
||||||
|
// on deployments with AUTO_CREATE_ORG_ON_SIGNUP=false, where a self-service
|
||||||
|
// signup is created org-less and an admin/operator assigns them to a workspace
|
||||||
|
// afterward. Without this, such a user would be bounced into onboarding (whose
|
||||||
|
// device-pairing step needs a workspace) - a broken flow. Here they get a clear
|
||||||
|
// "ask your admin" message instead.
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
|
export function render(container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
|
||||||
|
<div style="width:440px;max-width:100%;text-align:center">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="1.6" style="margin:0 auto 16px">
|
||||||
|
<rect x="3" y="4" width="18" height="14" rx="2"/>
|
||||||
|
<path d="M3 9h18"/>
|
||||||
|
</svg>
|
||||||
|
<h1 style="font-size:20px;font-weight:700;margin-bottom:8px">${t('noworkspace.title')}</h1>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;line-height:1.6;margin-bottom:24px">${t('noworkspace.body')}</p>
|
||||||
|
<button class="btn btn-secondary" id="noWsSignOut" style="padding:8px 16px">${t('noworkspace.sign_out')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.querySelector('#noWsSignOut').addEventListener('click', () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.hash = '#/login';
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanup() {}
|
||||||
|
|
@ -1,96 +1,101 @@
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
const STEPS = [
|
// Steps are computed lazily so translated strings refresh on language change.
|
||||||
|
function getSteps() {
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
title: 'Welcome to ScreenTinker!',
|
title: t('onboarding.step.welcome.title'),
|
||||||
icon: '👋',
|
icon: '👋',
|
||||||
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:16px">Let's get you set up in under 5 minutes.</p>
|
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.welcome.intro')}</p>
|
||||||
<p style="color:var(--text-muted);font-size:14px">This wizard will guide you through:</p>
|
<p style="color:var(--text-muted);font-size:14px">${t('onboarding.step.welcome.guide_through')}</p>
|
||||||
<ul style="color:var(--text-muted);font-size:14px;padding-left:20px;margin-top:8px;line-height:2">
|
<ul style="color:var(--text-muted);font-size:14px;padding-left:20px;margin-top:8px;line-height:2">
|
||||||
<li>Downloading the player app</li>
|
<li>${t('onboarding.step.welcome.bullet_download')}</li>
|
||||||
<li>Pairing your first display</li>
|
<li>${t('onboarding.step.welcome.bullet_pair')}</li>
|
||||||
<li>Uploading and assigning content</li>
|
<li>${t('onboarding.step.welcome.bullet_upload')}</li>
|
||||||
</ul>`,
|
</ul>`,
|
||||||
action: null
|
action: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Step 1: Get the Player App',
|
title: t('onboarding.step.player.title'),
|
||||||
icon: '📥',
|
icon: '📥',
|
||||||
content: `<p style="color:var(--text-secondary);margin-bottom:16px">Install the player on your display device.</p>
|
content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.player.intro')}</p>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
<a href="/download/apk" style="background:var(--bg-input);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center;text-decoration:none;color:var(--text-primary)">
|
<a href="/download/apk" style="background:var(--bg-input);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center;text-decoration:none;color:var(--text-primary)">
|
||||||
<div style="font-size:32px;margin-bottom:8px">🤖</div>
|
<div style="font-size:32px;margin-bottom:8px">🤖</div>
|
||||||
<div style="font-weight:600;font-size:14px">Android APK</div>
|
<div style="font-weight:600;font-size:14px">${t('onboarding.step.player.android_label')}</div>
|
||||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">TV boxes, tablets, Fire TV</div>
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('onboarding.step.player.android_desc')}</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="/player" target="_blank" style="background:var(--bg-input);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center;text-decoration:none;color:var(--text-primary)">
|
<a href="/player" target="_blank" style="background:var(--bg-input);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center;text-decoration:none;color:var(--text-primary)">
|
||||||
<div style="font-size:32px;margin-bottom:8px">🌐</div>
|
<div style="font-size:32px;margin-bottom:8px">🌐</div>
|
||||||
<div style="font-weight:600;font-size:14px">Web Player</div>
|
<div style="font-weight:600;font-size:14px">${t('onboarding.step.player.web_label')}</div>
|
||||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Any browser, Pi, ChromeOS</div>
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('onboarding.step.player.web_desc')}</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:var(--text-muted);font-size:12px;margin-top:12px">Open the app on your display and enter this server URL:</p>
|
<p style="color:var(--text-muted);font-size:12px;margin-top:12px">${t('onboarding.step.player.url_hint')}</p>
|
||||||
<code style="display:block;background:var(--bg-input);padding:10px;border-radius:6px;margin-top:6px;font-size:14px;user-select:all">${window.location.origin}</code>`,
|
<code style="display:block;background:var(--bg-input);padding:10px;border-radius:6px;margin-top:6px;font-size:14px;user-select:all">${window.location.origin}</code>`,
|
||||||
action: null
|
action: null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Step 2: Pair Your Display',
|
title: t('onboarding.step.pair.title'),
|
||||||
icon: '🔗',
|
icon: '🔗',
|
||||||
content: `<p style="color:var(--text-secondary);margin-bottom:16px">Enter the 6-digit code shown on your display.</p>
|
content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.pair.intro')}</p>
|
||||||
<div style="text-align:center;margin:20px 0">
|
<div style="text-align:center;margin:20px 0">
|
||||||
<input type="text" id="onboardPairingCode" maxlength="6" pattern="[0-9]{6}" placeholder="000000"
|
<input type="text" id="onboardPairingCode" maxlength="6" pattern="[0-9]{6}" placeholder="000000"
|
||||||
style="width:240px;padding:16px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;
|
style="max-width:240px;width:100%;padding:16px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;
|
||||||
color:var(--text-primary);font-size:32px;font-weight:700;text-align:center;letter-spacing:8px;font-family:monospace">
|
color:var(--text-primary);font-size:32px;font-weight:700;text-align:center;letter-spacing:8px;font-family:monospace">
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:center">
|
<div style="text-align:center">
|
||||||
<input type="text" id="onboardDeviceName" placeholder="Display name (e.g., Lobby TV)"
|
<input type="text" id="onboardDeviceName" placeholder="${t('onboarding.step.pair.name_placeholder')}"
|
||||||
style="width:240px;padding:10px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;text-align:center">
|
style="max-width:240px;width:100%;padding:10px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;text-align:center">
|
||||||
</div>
|
</div>
|
||||||
<p id="onboardPairStatus" style="color:var(--text-muted);font-size:13px;text-align:center;margin-top:12px"></p>`,
|
<p id="onboardPairStatus" style="color:var(--text-muted);font-size:13px;text-align:center;margin-top:12px"></p>`,
|
||||||
action: 'pair'
|
action: 'pair'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Step 3: Upload Content',
|
title: t('onboarding.step.upload.title'),
|
||||||
icon: '📤',
|
icon: '📤',
|
||||||
content: `<p style="color:var(--text-secondary);margin-bottom:16px">Upload a video or image to display.</p>
|
content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.upload.intro')}</p>
|
||||||
<div style="border:2px dashed var(--border);border-radius:12px;padding:32px;text-align:center;cursor:pointer" id="onboardUploadArea">
|
<div style="border:2px dashed var(--border);border-radius:12px;padding:32px;text-align:center;cursor:pointer" id="onboardUploadArea">
|
||||||
<div style="font-size:32px;margin-bottom:8px">📁</div>
|
<div style="font-size:32px;margin-bottom:8px">📁</div>
|
||||||
<p style="color:var(--text-secondary)">Click to select a file</p>
|
<p style="color:var(--text-secondary)">${t('onboarding.step.upload.click_to_select')}</p>
|
||||||
<p style="color:var(--text-muted);font-size:12px;margin-top:4px">MP4, WebM, JPEG, PNG, GIF</p>
|
<p style="color:var(--text-muted);font-size:12px;margin-top:4px">${t('onboarding.step.upload.formats')}</p>
|
||||||
<input type="file" id="onboardFileInput" style="display:none" accept="video/*,image/*">
|
<input type="file" id="onboardFileInput" style="display:none" accept="video/*,image/*">
|
||||||
</div>
|
</div>
|
||||||
<div id="onboardUploadProgress" style="display:none;margin-top:12px">
|
<div id="onboardUploadProgress" style="display:none;margin-top:12px">
|
||||||
<div style="height:4px;background:var(--bg-primary);border-radius:2px;overflow:hidden">
|
<div style="height:4px;background:var(--bg-primary);border-radius:2px;overflow:hidden">
|
||||||
<div id="onboardProgressBar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
|
<div id="onboardProgressBar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
|
||||||
</div>
|
</div>
|
||||||
<p id="onboardUploadText" style="font-size:12px;color:var(--text-muted);margin-top:6px">Uploading...</p>
|
<p id="onboardUploadText" style="font-size:12px;color:var(--text-muted);margin-top:6px">${t('onboarding.step.upload.uploading')}</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
action: 'upload'
|
action: 'upload'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "You're All Set!",
|
title: t('onboarding.step.done.title'),
|
||||||
icon: '🎉',
|
icon: '🎉',
|
||||||
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:20px">Your display is paired and content is playing!</p>
|
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:20px">${t('onboarding.step.done.intro')}</p>
|
||||||
<div style="background:var(--bg-input);border-radius:8px;padding:16px;margin-bottom:16px">
|
<div style="background:var(--bg-input);border-radius:8px;padding:16px;margin-bottom:16px">
|
||||||
<p style="font-size:14px;color:var(--text-primary);font-weight:600;margin-bottom:8px">What's next?</p>
|
<p style="font-size:14px;color:var(--text-primary);font-weight:600;margin-bottom:8px">${t('onboarding.step.done.whats_next')}</p>
|
||||||
<ul style="color:var(--text-muted);font-size:13px;padding-left:20px;line-height:2">
|
<ul style="color:var(--text-muted);font-size:13px;padding-left:20px;line-height:2">
|
||||||
<li>Add more content in the <strong>Content Library</strong></li>
|
<li>${t('onboarding.step.done.next_content')}</li>
|
||||||
<li>Create multi-zone layouts in <strong>Layouts</strong></li>
|
<li>${t('onboarding.step.done.next_layouts')}</li>
|
||||||
<li>Set up a schedule in the <strong>Schedule</strong> calendar</li>
|
<li>${t('onboarding.step.done.next_schedule')}</li>
|
||||||
<li>Add live widgets (clock, weather, ticker) in <strong>Widgets</strong></li>
|
<li>${t('onboarding.step.done.next_widgets')}</li>
|
||||||
<li>Create interactive screens in <strong>Kiosk</strong></li>
|
<li>${t('onboarding.step.done.next_kiosk')}</li>
|
||||||
<li>Design custom content in the <strong>Designer</strong></li>
|
<li>${t('onboarding.step.done.next_designer')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>`,
|
</div>`,
|
||||||
action: null
|
action: null
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function render(container) {
|
export function render(container) {
|
||||||
let currentStep = 0;
|
let currentStep = 0;
|
||||||
let pairedDeviceId = null;
|
let pairedDeviceId = null;
|
||||||
|
|
||||||
function renderStep() {
|
function renderStep() {
|
||||||
|
const STEPS = getSteps();
|
||||||
const step = STEPS[currentStep];
|
const step = STEPS[currentStep];
|
||||||
const isFirst = currentStep === 0;
|
const isFirst = currentStep === 0;
|
||||||
const isLast = currentStep === STEPS.length - 1;
|
const isLast = currentStep === STEPS.length - 1;
|
||||||
|
|
@ -113,17 +118,16 @@ export function render(container) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;justify-content:space-between">
|
<div style="display:flex;justify-content:space-between">
|
||||||
${isFirst ? '<div></div>' : `<button class="btn btn-secondary" id="prevBtn">Back</button>`}
|
${isFirst ? '<div></div>' : `<button class="btn btn-secondary" id="prevBtn">${t('onboarding.back')}</button>`}
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
${!isLast ? `<button class="btn btn-secondary" id="skipBtn" style="color:var(--text-muted)">Skip Wizard</button>` : ''}
|
${!isLast ? `<button class="btn btn-secondary" id="skipBtn" style="color:var(--text-muted)">${t('onboarding.skip')}</button>` : ''}
|
||||||
<button class="btn btn-primary" id="nextBtn">${isLast ? 'Go to Dashboard' : step.action ? (step.action === 'pair' ? 'Pair Display' : 'Next') : 'Next'}</button>
|
<button class="btn btn-primary" id="nextBtn">${isLast ? t('onboarding.go_to_dashboard') : step.action === 'pair' ? t('onboarding.pair_display') : t('onboarding.next')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Bind buttons
|
|
||||||
document.getElementById('prevBtn')?.addEventListener('click', () => { currentStep--; renderStep(); });
|
document.getElementById('prevBtn')?.addEventListener('click', () => { currentStep--; renderStep(); });
|
||||||
document.getElementById('skipBtn')?.addEventListener('click', () => {
|
document.getElementById('skipBtn')?.addEventListener('click', () => {
|
||||||
localStorage.setItem('rd_onboarded', 'true');
|
localStorage.setItem('rd_onboarded', 'true');
|
||||||
|
|
@ -132,7 +136,6 @@ export function render(container) {
|
||||||
});
|
});
|
||||||
document.getElementById('nextBtn')?.addEventListener('click', handleNext);
|
document.getElementById('nextBtn')?.addEventListener('click', handleNext);
|
||||||
|
|
||||||
// Step-specific setup
|
|
||||||
if (step.action === 'upload') {
|
if (step.action === 'upload') {
|
||||||
const area = document.getElementById('onboardUploadArea');
|
const area = document.getElementById('onboardUploadArea');
|
||||||
const input = document.getElementById('onboardFileInput');
|
const input = document.getElementById('onboardFileInput');
|
||||||
|
|
@ -142,6 +145,7 @@ export function render(container) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNext() {
|
async function handleNext() {
|
||||||
|
const STEPS = getSteps();
|
||||||
const step = STEPS[currentStep];
|
const step = STEPS[currentStep];
|
||||||
|
|
||||||
if (step.action === 'pair') {
|
if (step.action === 'pair') {
|
||||||
|
|
@ -150,12 +154,12 @@ export function render(container) {
|
||||||
const status = document.getElementById('onboardPairStatus');
|
const status = document.getElementById('onboardPairStatus');
|
||||||
|
|
||||||
if (!code || code.length !== 6) {
|
if (!code || code.length !== 6) {
|
||||||
if (status) status.textContent = 'Enter a valid 6-digit code';
|
if (status) status.textContent = t('onboarding.toast.invalid_code');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (status) status.textContent = 'Pairing...';
|
if (status) status.textContent = t('onboarding.toast.pairing');
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const res = await fetch('/api/provision/pair', {
|
const res = await fetch('/api/provision/pair', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -163,13 +167,13 @@ export function render(container) {
|
||||||
body: JSON.stringify({ pairing_code: code, name: name || undefined })
|
body: JSON.stringify({ pairing_code: code, name: name || undefined })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) { if (status) status.textContent = data.error || 'Pairing failed'; return; }
|
if (!res.ok) { if (status) status.textContent = data.error || t('onboarding.toast.pair_failed'); return; }
|
||||||
pairedDeviceId = data.id;
|
pairedDeviceId = data.id;
|
||||||
showToast('Display paired!', 'success');
|
showToast(t('onboarding.toast.paired'), 'success');
|
||||||
currentStep++;
|
currentStep++;
|
||||||
renderStep();
|
renderStep();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (status) status.textContent = 'Pairing failed: ' + err.message;
|
if (status) status.textContent = t('onboarding.toast.pair_failed_with_error', { error: err.message });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -208,9 +212,8 @@ export function render(container) {
|
||||||
xhr.onload = async () => {
|
xhr.onload = async () => {
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
const content = JSON.parse(xhr.responseText);
|
const content = JSON.parse(xhr.responseText);
|
||||||
if (text) text.textContent = 'Uploaded! Assigning to display...';
|
if (text) text.textContent = t('onboarding.toast.uploaded_assigning');
|
||||||
|
|
||||||
// Auto-assign to paired device
|
|
||||||
if (pairedDeviceId) {
|
if (pairedDeviceId) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/assignments/device/${pairedDeviceId}`, {
|
await fetch(`/api/assignments/device/${pairedDeviceId}`, {
|
||||||
|
|
@ -221,17 +224,17 @@ export function render(container) {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('Content uploaded and assigned!', 'success');
|
showToast(t('onboarding.toast.content_assigned'), 'success');
|
||||||
currentStep++;
|
currentStep++;
|
||||||
renderStep();
|
renderStep();
|
||||||
} else {
|
} else {
|
||||||
if (text) text.textContent = 'Upload failed';
|
if (text) text.textContent = t('onboarding.toast.upload_failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
xhr.onerror = () => { if (text) text.textContent = 'Upload failed'; };
|
xhr.onerror = () => { if (text) text.textContent = t('onboarding.toast.upload_failed'); };
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (text) text.textContent = 'Error: ' + err.message;
|
if (text) text.textContent = t('onboarding.toast.error_with_error', { error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
// Escape user-controlled strings for safe HTML interpolation
|
import { t, tn } from '../i18n.js';
|
||||||
function esc(str) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = str || '';
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(ts) {
|
function formatDate(ts) {
|
||||||
if (!ts) return '--';
|
if (!ts) return '--';
|
||||||
|
|
@ -19,6 +14,39 @@ function getTypeIcon(item) {
|
||||||
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
|
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #74/#75 per-item schedule editor helpers. Client validation MIRRORS the server
|
||||||
|
// (server/routes/playlists.js validateBlocks): same time/date regexes, non-empty days.
|
||||||
|
const SCHED_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
const SCHED_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function daysSummary(days) {
|
||||||
|
const labels = t('itemsched.dow_short').split(',');
|
||||||
|
const s = [...days].sort((a, b) => a - b);
|
||||||
|
if (s.length === 7) return t('itemsched.every_day');
|
||||||
|
if (s.length === 5 && [1, 2, 3, 4, 5].every(d => s.includes(d))) return t('itemsched.mon_fri');
|
||||||
|
if (s.length === 2 && s.includes(0) && s.includes(6)) return t('itemsched.sat_sun');
|
||||||
|
return s.map(d => labels[d]).join(' ');
|
||||||
|
}
|
||||||
|
function blockSummary(b) {
|
||||||
|
let s = `${daysSummary(b.days)} ${b.start}-${b.end}`;
|
||||||
|
if (b.start_date || b.end_date) s += ` · ${b.start_date || '…'}→${b.end_date || '…'}`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function scheduleSummary(schedules) {
|
||||||
|
if (!schedules || !schedules.length) return '';
|
||||||
|
return schedules.length === 1 ? blockSummary(schedules[0]) : `${blockSummary(schedules[0])} +${schedules.length - 1}`;
|
||||||
|
}
|
||||||
|
function validateScheduleBlocks(blocks) {
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (!b.days || !b.days.length) return t('itemsched.err.days');
|
||||||
|
if (!SCHED_TIME_RE.test(b.start)) return t('itemsched.err.start');
|
||||||
|
if (!(SCHED_TIME_RE.test(b.end) || b.end === '24:00')) return t('itemsched.err.end');
|
||||||
|
if (b.start_date && !SCHED_DATE_RE.test(b.start_date)) return t('itemsched.err.start_date');
|
||||||
|
if (b.end_date && !SCHED_DATE_RE.test(b.end_date)) return t('itemsched.err.end_date');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let currentPlaylistId = null;
|
let currentPlaylistId = null;
|
||||||
|
|
||||||
export function render(container) {
|
export function render(container) {
|
||||||
|
|
@ -37,23 +65,33 @@ export function cleanup() {
|
||||||
currentPlaylistId = null;
|
currentPlaylistId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== LIST VIEW ====================
|
let showAutoGenerated = true;
|
||||||
|
|
||||||
async function renderList(container) {
|
async function renderList(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Playlists</h1>
|
<h1>${t('playlist.title')}</h1>
|
||||||
<div class="subtitle">Create and manage content playlists</div>
|
<div class="subtitle">${t('playlist.subtitle')}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-secondary);cursor:pointer">
|
||||||
|
<input type="checkbox" id="showAutoToggle" ${showAutoGenerated ? 'checked' : ''}>
|
||||||
|
${t('playlist.show_auto_generated')}
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" id="createPlaylistBtn">${t('playlist.new_playlist_btn')}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="createPlaylistBtn">+ New Playlist</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="playlistGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px">
|
<div id="playlistGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px">
|
||||||
<div style="color:var(--text-muted);padding:40px;text-align:center">Loading...</div>
|
<div style="color:var(--text-muted);padding:40px;text-align:center">${t('common.loading')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('createPlaylistBtn').addEventListener('click', showCreateModal);
|
document.getElementById('createPlaylistBtn').addEventListener('click', showCreateModal);
|
||||||
|
document.getElementById('showAutoToggle').addEventListener('change', (e) => {
|
||||||
|
showAutoGenerated = e.target.checked;
|
||||||
|
loadPlaylists();
|
||||||
|
});
|
||||||
loadPlaylists();
|
loadPlaylists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,25 +108,42 @@ async function loadPlaylists() {
|
||||||
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
||||||
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 style="margin-bottom:8px;color:var(--text-primary)">No playlists yet</h3>
|
<h3 style="margin-bottom:8px;color:var(--text-primary)">${t('playlist.empty_title')}</h3>
|
||||||
<p>Create your first playlist to organize content for your displays.</p>
|
<p>${t('playlist.empty_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = playlists.map(p => `
|
const filtered = showAutoGenerated ? playlists : playlists.filter(p => !p.is_auto_generated);
|
||||||
|
if (!filtered.length) {
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)">
|
||||||
|
${playlists.length ? t('playlist.all_auto_generated') : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = filtered.map(p => `
|
||||||
<a href="#/playlists/${esc(p.id)}" class="playlist-card" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;text-decoration:none;color:inherit;display:block;transition:border-color 0.15s;cursor:pointer">
|
<a href="#/playlists/${esc(p.id)}" class="playlist-card" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;text-decoration:none;color:inherit;display:block;transition:border-color 0.15s;cursor:pointer">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:12px">
|
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:12px">
|
||||||
<div style="font-size:16px;font-weight:600;color:var(--text-primary)" data-name="${esc(p.id)}">${esc(p.name)}</div>
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
<div style="font-size:12px;color:var(--text-muted);white-space:nowrap;margin-left:12px">${p.item_count} item${p.item_count !== 1 ? 's' : ''}</div>
|
<div style="font-size:16px;font-weight:600;color:var(--text-primary)">${esc(p.name)}</div>
|
||||||
|
${p.is_auto_generated ? `<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:var(--bg-input);color:var(--text-muted)">${t('playlist.tag_auto')}</span>` : ''}
|
||||||
|
${p.status === 'draft' ? `<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:#78350f;color:#fbbf24">${t('playlist.tag_draft')}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted);white-space:nowrap;margin-left:12px">${tn('playlist.item_count', p.item_count)}</div>
|
||||||
</div>
|
</div>
|
||||||
${p.description ? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;line-height:1.4">${esc(p.description)}</div>` : ''}
|
${p.description ? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;line-height:1.4">${esc(p.description)}</div>` : ''}
|
||||||
<div style="font-size:12px;color:var(--text-muted)">Created ${formatDate(p.created_at)}</div>
|
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted)">
|
||||||
|
<span>${t('playlist.created_at', { date: formatDate(p.created_at) })}</span>
|
||||||
|
${p.display_count ? `<span>${tn('playlist.display_count', p.display_count)}</span>` : ''}
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
`).join('');
|
`).join('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
grid.innerHTML = `<div style="grid-column:1/-1;color:var(--text-muted);padding:40px;text-align:center">Failed to load playlists: ${esc(err.message)}</div>`;
|
grid.innerHTML = `<div style="grid-column:1/-1;color:var(--text-muted);padding:40px;text-align:center">${t('playlist.load_failed', { error: esc(err.message) })}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,12 +152,12 @@ function showCreateModal() {
|
||||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
|
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:400px;max-width:90vw">
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:400px;max-width:90vw">
|
||||||
<h3 style="margin-bottom:16px;color:var(--text-primary)">New Playlist</h3>
|
<h3 style="margin-bottom:16px;color:var(--text-primary)">${t('playlist.new_playlist')}</h3>
|
||||||
<input type="text" id="newPlaylistName" class="input" placeholder="Playlist name" style="width:100%;margin-bottom:12px" autofocus>
|
<input type="text" id="newPlaylistName" class="input" placeholder="${t('playlist.name_placeholder')}" style="width:100%;margin-bottom:12px" autofocus>
|
||||||
<textarea id="newPlaylistDesc" class="input" placeholder="Description (optional)" style="width:100%;height:60px;resize:vertical;margin-bottom:16px"></textarea>
|
<textarea id="newPlaylistDesc" class="input" placeholder="${t('playlist.desc_placeholder')}" style="width:100%;height:60px;resize:vertical;margin-bottom:16px"></textarea>
|
||||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
<button class="btn btn-secondary" id="cancelCreateBtn">Cancel</button>
|
<button class="btn btn-secondary" id="cancelCreateBtn">${t('common.cancel')}</button>
|
||||||
<button class="btn btn-primary" id="confirmCreateBtn">Create</button>
|
<button class="btn btn-primary" id="confirmCreateBtn">${t('playlist.create_btn')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -121,7 +176,7 @@ function showCreateModal() {
|
||||||
try {
|
try {
|
||||||
const pl = await api.createPlaylist(name, desc);
|
const pl = await api.createPlaylist(name, desc);
|
||||||
modal.remove();
|
modal.remove();
|
||||||
showToast('Playlist created');
|
showToast(t('playlist.toast.created'));
|
||||||
window.location.hash = `#/playlists/${pl.id}`;
|
window.location.hash = `#/playlists/${pl.id}`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
|
|
@ -132,11 +187,9 @@ function showCreateModal() {
|
||||||
nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doCreate(); });
|
nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doCreate(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== DETAIL VIEW ====================
|
|
||||||
|
|
||||||
async function renderDetail(container, playlistId) {
|
async function renderDetail(container, playlistId) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="color:var(--text-muted);padding:40px;text-align:center">Loading...</div>
|
<div style="color:var(--text-muted);padding:40px;text-align:center">${t('common.loading')}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -145,26 +198,46 @@ async function renderDetail(container, playlistId) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="padding:40px;text-align:center;color:var(--text-muted)">
|
<div style="padding:40px;text-align:center;color:var(--text-muted)">
|
||||||
<p>Failed to load playlist: ${esc(err.message)}</p>
|
<p>${t('playlist.load_failed', { error: esc(err.message) })}</p>
|
||||||
<a href="#/playlists" class="btn btn-secondary" style="margin-top:16px">Back to Playlists</a>
|
<a href="#/playlists" class="btn btn-secondary" style="margin-top:16px">${t('playlist.back_to_playlists')}</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetailContent(container, playlist) {
|
function renderDetailContent(container, playlist) {
|
||||||
|
const isDraft = playlist.status === 'draft';
|
||||||
|
const hasPublished = !!playlist.published_snapshot;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
|
${isDraft ? `
|
||||||
|
<div id="draftBanner" style="background:#78350f;border:1px solid #92400e;border-radius:var(--radius-lg);padding:14px 20px;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;color:#fbbf24">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;font-size:14px">${t('playlist.draft.banner_title')}</div>
|
||||||
|
<div style="font-size:12px;color:#fcd34d;opacity:0.85">${hasPublished ? t('playlist.draft.devices_showing_published') : t('playlist.draft.never_published')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||||
|
${hasPublished ? `<button class="btn btn-secondary btn-sm" id="discardDraftBtn" style="color:#fbbf24;border-color:#92400e">${t('playlist.draft.discard_changes')}</button>` : ''}
|
||||||
|
<button class="btn btn-sm" id="publishBtn" style="background:#f59e0b;color:#000;font-weight:600;border:none">${t('playlist.draft.publish')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div style="display:flex;align-items:center;gap:12px">
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
<a href="#/playlists" style="color:var(--text-muted);text-decoration:none;font-size:20px" title="Back">←</a>
|
<a href="#/playlists" style="color:var(--text-muted);text-decoration:none;font-size:20px" title="${t('playlist.back')}">←</a>
|
||||||
<div>
|
<div>
|
||||||
<h1 id="playlistTitle" style="cursor:pointer" title="Click to rename">${esc(playlist.name)}</h1>
|
<h1 id="playlistTitle" style="cursor:pointer" title="${t('playlist.click_to_rename')}">${esc(playlist.name)}</h1>
|
||||||
<div class="subtitle" id="playlistDesc" style="cursor:pointer" title="Click to edit description">${playlist.description ? esc(playlist.description) : '<span style="opacity:0.5">Add a description...</span>'}</div>
|
<div class="subtitle" id="playlistDesc" style="cursor:pointer" title="${t('playlist.click_to_edit_desc')}">${playlist.description ? esc(playlist.description) : `<span style="opacity:0.5">${t('playlist.add_desc_placeholder')}</span>`}</div>
|
||||||
|
${playlist.display_count ? `<div style="font-size:12px;color:var(--text-muted);margin-top:4px">${tn('playlist.assigned_to', playlist.display_count)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button class="btn btn-primary" id="addItemBtn">+ Add Content</button>
|
<button class="btn btn-primary" id="addItemBtn">${t('playlist.add_content')}</button>
|
||||||
<button class="btn btn-secondary" id="deletePlaylistBtn" style="color:var(--danger)">Delete Playlist</button>
|
<button class="btn btn-secondary" id="deletePlaylistBtn" style="color:var(--danger)">${t('playlist.delete_playlist')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -174,19 +247,46 @@ function renderDetailContent(container, playlist) {
|
||||||
|
|
||||||
renderItems(playlist.items || []);
|
renderItems(playlist.items || []);
|
||||||
|
|
||||||
// Inline rename
|
const publishBtn = document.getElementById('publishBtn');
|
||||||
|
if (publishBtn) {
|
||||||
|
publishBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
publishBtn.disabled = true;
|
||||||
|
publishBtn.textContent = t('playlist.draft.publishing');
|
||||||
|
const updated = await api.publishPlaylist(playlist.id);
|
||||||
|
showToast(t('playlist.toast.published'));
|
||||||
|
renderDetailContent(container, updated);
|
||||||
|
} catch (err) {
|
||||||
|
publishBtn.disabled = false;
|
||||||
|
publishBtn.textContent = t('playlist.draft.publish');
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const discardBtn = document.getElementById('discardDraftBtn');
|
||||||
|
if (discardBtn) {
|
||||||
|
discardBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(t('playlist.confirm_discard_draft'))) return;
|
||||||
|
try {
|
||||||
|
const updated = await api.discardPlaylistDraft(playlist.id);
|
||||||
|
showToast(t('playlist.toast.draft_discarded'));
|
||||||
|
renderDetailContent(container, updated);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('playlistTitle').addEventListener('click', () => inlineEdit(playlist, 'name'));
|
document.getElementById('playlistTitle').addEventListener('click', () => inlineEdit(playlist, 'name'));
|
||||||
document.getElementById('playlistDesc').addEventListener('click', () => inlineEdit(playlist, 'description'));
|
document.getElementById('playlistDesc').addEventListener('click', () => inlineEdit(playlist, 'description'));
|
||||||
|
|
||||||
// Add content
|
|
||||||
document.getElementById('addItemBtn').addEventListener('click', () => showAddItemModal(playlist.id));
|
document.getElementById('addItemBtn').addEventListener('click', () => showAddItemModal(playlist.id));
|
||||||
|
|
||||||
// Delete playlist
|
|
||||||
document.getElementById('deletePlaylistBtn').addEventListener('click', async () => {
|
document.getElementById('deletePlaylistBtn').addEventListener('click', async () => {
|
||||||
if (!confirm(`Delete "${playlist.name}"? This cannot be undone.`)) return;
|
if (!confirm(t('playlist.confirm_delete', { name: playlist.name }))) return;
|
||||||
try {
|
try {
|
||||||
await api.deletePlaylist(playlist.id);
|
await api.deletePlaylist(playlist.id);
|
||||||
showToast('Playlist deleted');
|
showToast(t('playlist.toast.deleted'));
|
||||||
window.location.hash = '#/playlists';
|
window.location.hash = '#/playlists';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
|
|
@ -194,6 +294,16 @@ function renderDetailContent(container, playlist) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAfterMutation() {
|
||||||
|
if (!currentPlaylistId) return;
|
||||||
|
const mainContainer = document.getElementById('draftBanner')?.parentElement || document.querySelector('.page-header')?.parentElement;
|
||||||
|
if (!mainContainer) return;
|
||||||
|
try {
|
||||||
|
const playlist = await api.getPlaylist(currentPlaylistId);
|
||||||
|
renderDetailContent(mainContainer, playlist);
|
||||||
|
} catch (e) { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
function renderItems(items) {
|
function renderItems(items) {
|
||||||
const itemsEl = document.getElementById('playlistItems');
|
const itemsEl = document.getElementById('playlistItems');
|
||||||
if (!itemsEl) return;
|
if (!itemsEl) return;
|
||||||
|
|
@ -201,38 +311,51 @@ function renderItems(items) {
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
itemsEl.innerHTML = `
|
itemsEl.innerHTML = `
|
||||||
<div style="text-align:center;padding:40px;color:var(--text-muted);border:2px dashed var(--border);border-radius:var(--radius-lg)">
|
<div style="text-align:center;padding:40px;color:var(--text-muted);border:2px dashed var(--border);border-radius:var(--radius-lg)">
|
||||||
<p style="margin-bottom:8px">This playlist is empty</p>
|
<p style="margin-bottom:8px">${t('playlist.items_empty')}</p>
|
||||||
<p style="font-size:13px">Click "Add Content" to add items.</p>
|
<p style="font-size:13px">${t('playlist.items_empty_hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsEl.innerHTML = items.map((item, i) => `
|
itemsEl.innerHTML = items.map((item, i) => `
|
||||||
<div class="playlist-item" data-item-id="${item.id}" draggable="true" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;display:flex;align-items:center;gap:12px;cursor:grab;transition:border-color 0.15s">
|
<div class="playlist-item" data-item-id="${item.id}" data-index="${i}" draggable="true" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;display:flex;align-items:center;gap:12px;cursor:grab;transition:border-color 0.15s">
|
||||||
<div style="color:var(--text-muted);font-size:12px;min-width:24px;text-align:center;user-select:none">${i + 1}</div>
|
<div style="color:var(--text-muted);font-size:12px;min-width:24px;text-align:center;user-select:none">${i + 1}</div>
|
||||||
<div style="width:48px;height:36px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center">
|
<div style="width:48px;height:36px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center">
|
||||||
${item.thumbnail_path
|
${item.thumbnail_path
|
||||||
? `<img src="/uploads/thumbnails/${esc(item.thumbnail_path.split('/').pop())}" style="width:100%;height:100%;object-fit:cover">`
|
? `<img src="/api/content/${esc(item.content_id)}/thumbnail" style="width:100%;height:100%;object-fit:cover">`
|
||||||
: `<div style="color:var(--text-muted);opacity:0.5">${getTypeIcon(item)}</div>`
|
: `<div style="color:var(--text-muted);opacity:0.5">${getTypeIcon(item)}</div>`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-size:14px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(item.filename || item.widget_name || 'Unknown')}</div>
|
<div style="font-size:14px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(item.filename || item.widget_name || t('common.unknown'))}</div>
|
||||||
<div style="font-size:12px;color:var(--text-muted)">${item.widget_id ? 'Widget' : (item.mime_type || 'Unknown type')}</div>
|
<div style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:8px;min-width:0">
|
||||||
|
<span style="white-space:nowrap">${item.widget_id ? t('playlist.item_widget') : esc(item.mime_type || t('playlist.unknown_type'))}</span>
|
||||||
|
${item.schedules && item.schedules.length ? `<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:#0c2a3f;color:#7dd3fc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${esc(scheduleSummary(item.schedules))}">🕐 ${esc(scheduleSummary(item.schedules))}</span>` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
||||||
<label style="font-size:12px;color:var(--text-muted)">Duration</label>
|
<label style="font-size:12px;color:var(--text-muted)">${t('playlist.duration')}</label>
|
||||||
<input type="number" class="input item-duration" data-item-id="${item.id}" value="${item.duration_sec}" min="1" style="width:60px;padding:4px 8px;font-size:13px;text-align:center">
|
<input type="number" class="input item-duration" data-item-id="${item.id}" value="${item.duration_sec}" min="1" style="width:60px;padding:4px 8px;font-size:13px;text-align:center">
|
||||||
<span style="font-size:12px;color:var(--text-muted)">sec</span>
|
<span style="font-size:12px;color:var(--text-muted)">${t('playlist.sec')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon item-remove" data-item-id="${item.id}" title="Remove" style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
|
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||||
|
<button class="btn-icon item-schedule" data-item-id="${item.id}" title="${t('itemsched.title')}" aria-label="${t('itemsched.title')}" style="color:${item.schedules && item.schedules.length ? '#38bdf8' : 'var(--text-muted)'};background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon item-move" data-item-id="${item.id}" data-dir="up" title="${t('playlist.move_up')}" aria-label="${t('playlist.move_up')}" ${i === 0 ? 'disabled' : ''} style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px;${i === 0 ? 'opacity:0.3;cursor:not-allowed' : ''}">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon item-move" data-item-id="${item.id}" data-dir="down" title="${t('playlist.move_down')}" aria-label="${t('playlist.move_down')}" ${i === items.length - 1 ? 'disabled' : ''} style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px;${i === items.length - 1 ? 'opacity:0.3;cursor:not-allowed' : ''}">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon item-remove" data-item-id="${item.id}" title="${t('common.delete')}" aria-label="${t('playlist.remove_item')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
// Duration change handlers
|
|
||||||
itemsEl.querySelectorAll('.item-duration').forEach(input => {
|
itemsEl.querySelectorAll('.item-duration').forEach(input => {
|
||||||
input.addEventListener('change', async (e) => {
|
input.addEventListener('change', async (e) => {
|
||||||
const itemId = e.target.dataset.itemId;
|
const itemId = e.target.dataset.itemId;
|
||||||
|
|
@ -240,13 +363,13 @@ function renderItems(items) {
|
||||||
if (!val || val < 1) { e.target.value = 10; return; }
|
if (!val || val < 1) { e.target.value = 10; return; }
|
||||||
try {
|
try {
|
||||||
await api.updatePlaylistItem(currentPlaylistId, itemId, { duration_sec: val });
|
await api.updatePlaylistItem(currentPlaylistId, itemId, { duration_sec: val });
|
||||||
|
refreshAfterMutation();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove handlers
|
|
||||||
itemsEl.querySelectorAll('.item-remove').forEach(btn => {
|
itemsEl.querySelectorAll('.item-remove').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
const itemId = e.currentTarget.dataset.itemId;
|
const itemId = e.currentTarget.dataset.itemId;
|
||||||
|
|
@ -254,14 +377,43 @@ function renderItems(items) {
|
||||||
await api.deletePlaylistItem(currentPlaylistId, itemId);
|
await api.deletePlaylistItem(currentPlaylistId, itemId);
|
||||||
const playlist = await api.getPlaylist(currentPlaylistId);
|
const playlist = await api.getPlaylist(currentPlaylistId);
|
||||||
renderItems(playlist.items || []);
|
renderItems(playlist.items || []);
|
||||||
showToast('Item removed');
|
refreshAfterMutation();
|
||||||
|
showToast(t('playlist.toast.item_removed'));
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
itemsEl.querySelectorAll('.item-schedule').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const itemId = e.currentTarget.dataset.itemId;
|
||||||
|
const item = items.find(it => String(it.id) === String(itemId));
|
||||||
|
if (item) showScheduleModal(item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
itemsEl.querySelectorAll('.item-move').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
if (btn.disabled) return;
|
||||||
|
const itemId = parseInt(e.currentTarget.dataset.itemId, 10);
|
||||||
|
const dir = e.currentTarget.dataset.dir;
|
||||||
|
const order = Array.from(itemsEl.querySelectorAll('.playlist-item'))
|
||||||
|
.map(el => parseInt(el.dataset.itemId, 10));
|
||||||
|
const idx = order.indexOf(itemId);
|
||||||
|
const swap = dir === 'up' ? idx - 1 : idx + 1;
|
||||||
|
if (swap < 0 || swap >= order.length) return;
|
||||||
|
[order[idx], order[swap]] = [order[swap], order[idx]];
|
||||||
|
try {
|
||||||
|
const updated = await api.reorderPlaylistItems(currentPlaylistId, order);
|
||||||
|
renderItems(updated);
|
||||||
|
refreshAfterMutation();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag-to-reorder
|
|
||||||
setupDragReorder(itemsEl);
|
setupDragReorder(itemsEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,27 +448,23 @@ function setupDragReorder(container) {
|
||||||
const target = e.target.closest('.playlist-item');
|
const target = e.target.closest('.playlist-item');
|
||||||
if (!target || !dragEl || target === dragEl) return;
|
if (!target || !dragEl || target === dragEl) return;
|
||||||
|
|
||||||
// Reorder DOM
|
|
||||||
container.insertBefore(dragEl, target);
|
container.insertBefore(dragEl, target);
|
||||||
|
|
||||||
// Collect new order
|
|
||||||
const order = Array.from(container.querySelectorAll('.playlist-item'))
|
const order = Array.from(container.querySelectorAll('.playlist-item'))
|
||||||
.map(el => parseInt(el.dataset.itemId, 10));
|
.map(el => parseInt(el.dataset.itemId, 10));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await api.reorderPlaylistItems(currentPlaylistId, order);
|
const items = await api.reorderPlaylistItems(currentPlaylistId, order);
|
||||||
renderItems(items);
|
renderItems(items);
|
||||||
|
refreshAfterMutation();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
// Reload to fix state
|
|
||||||
const playlist = await api.getPlaylist(currentPlaylistId);
|
const playlist = await api.getPlaylist(currentPlaylistId);
|
||||||
renderItems(playlist.items || []);
|
renderItems(playlist.items || []);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== INLINE EDIT ====================
|
|
||||||
|
|
||||||
function inlineEdit(playlist, field) {
|
function inlineEdit(playlist, field) {
|
||||||
const el = field === 'name' ? document.getElementById('playlistTitle') : document.getElementById('playlistDesc');
|
const el = field === 'name' ? document.getElementById('playlistTitle') : document.getElementById('playlistDesc');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
@ -346,7 +494,7 @@ function inlineEdit(playlist, field) {
|
||||||
const newEl = document.createElement('h1');
|
const newEl = document.createElement('h1');
|
||||||
newEl.id = 'playlistTitle';
|
newEl.id = 'playlistTitle';
|
||||||
newEl.style.cursor = 'pointer';
|
newEl.style.cursor = 'pointer';
|
||||||
newEl.title = 'Click to rename';
|
newEl.title = t('playlist.click_to_rename');
|
||||||
newEl.textContent = playlist.name;
|
newEl.textContent = playlist.name;
|
||||||
input.replaceWith(newEl);
|
input.replaceWith(newEl);
|
||||||
newEl.addEventListener('click', () => inlineEdit(playlist, 'name'));
|
newEl.addEventListener('click', () => inlineEdit(playlist, 'name'));
|
||||||
|
|
@ -374,11 +522,11 @@ function inlineEdit(playlist, field) {
|
||||||
newEl.className = 'subtitle';
|
newEl.className = 'subtitle';
|
||||||
newEl.id = 'playlistDesc';
|
newEl.id = 'playlistDesc';
|
||||||
newEl.style.cursor = 'pointer';
|
newEl.style.cursor = 'pointer';
|
||||||
newEl.title = 'Click to edit description';
|
newEl.title = t('playlist.click_to_edit_desc');
|
||||||
if (playlist.description) {
|
if (playlist.description) {
|
||||||
newEl.textContent = playlist.description;
|
newEl.textContent = playlist.description;
|
||||||
} else {
|
} else {
|
||||||
newEl.innerHTML = '<span style="opacity:0.5">Add a description...</span>';
|
newEl.innerHTML = `<span style="opacity:0.5">${t('playlist.add_desc_placeholder')}</span>`;
|
||||||
}
|
}
|
||||||
input.replaceWith(newEl);
|
input.replaceWith(newEl);
|
||||||
newEl.addEventListener('click', () => inlineEdit(playlist, 'description'));
|
newEl.addEventListener('click', () => inlineEdit(playlist, 'description'));
|
||||||
|
|
@ -389,22 +537,20 @@ function inlineEdit(playlist, field) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ADD ITEM MODAL ====================
|
|
||||||
|
|
||||||
async function showAddItemModal(playlistId) {
|
async function showAddItemModal(playlistId) {
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
|
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:560px;max-width:90vw;max-height:80vh;display:flex;flex-direction:column">
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;max-width:560px;width:95vw;max-height:80vh;display:flex;flex-direction:column">
|
||||||
<h3 style="margin-bottom:16px;color:var(--text-primary)">Add Content to Playlist</h3>
|
<h3 style="margin-bottom:16px;color:var(--text-primary)">${t('playlist.add_modal_title')}</h3>
|
||||||
<div style="display:flex;gap:8px;margin-bottom:12px">
|
<div style="display:flex;gap:8px;margin-bottom:12px">
|
||||||
<button class="btn btn-primary btn-sm tab-btn active" data-tab="content">Content</button>
|
<button class="btn btn-primary btn-sm tab-btn active" data-tab="content">${t('playlist.tab_content')}</button>
|
||||||
<button class="btn btn-secondary btn-sm tab-btn" data-tab="widgets">Widgets</button>
|
<button class="btn btn-secondary btn-sm tab-btn" data-tab="widgets">${t('playlist.tab_widgets')}</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="addItemSearch" class="input" placeholder="Search..." style="width:100%;margin-bottom:12px">
|
<input type="text" id="addItemSearch" class="input" placeholder="${t('playlist.search_placeholder')}" style="width:100%;margin-bottom:12px">
|
||||||
<div id="addItemList" style="flex:1;overflow-y:auto;min-height:200px;max-height:400px"></div>
|
<div id="addItemList" style="flex:1;overflow-y:auto;min-height:200px;max-height:400px"></div>
|
||||||
<div style="display:flex;justify-content:flex-end;margin-top:16px">
|
<div style="display:flex;justify-content:flex-end;margin-top:16px">
|
||||||
<button class="btn btn-secondary" id="closeAddModal">Close</button>
|
<button class="btn btn-secondary" id="closeAddModal">${t('playlist.close')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -414,14 +560,13 @@ async function showAddItemModal(playlistId) {
|
||||||
let allContent = [];
|
let allContent = [];
|
||||||
let allWidgets = [];
|
let allWidgets = [];
|
||||||
|
|
||||||
// Load data
|
|
||||||
try {
|
try {
|
||||||
[allContent, allWidgets] = await Promise.all([
|
[allContent, allWidgets] = await Promise.all([
|
||||||
api.getContent(),
|
api.getContent(),
|
||||||
api.getWidgets ? api.getWidgets() : Promise.resolve([])
|
api.getWidgets ? api.getWidgets() : Promise.resolve([])
|
||||||
]);
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('addItemList').innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">Failed to load: ${esc(err.message)}</div>`;
|
document.getElementById('addItemList').innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">${t('playlist.load_failed', { error: esc(err.message) })}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTab() {
|
function renderTab() {
|
||||||
|
|
@ -434,15 +579,15 @@ async function showAddItemModal(playlistId) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
list.innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">No ${activeTab} found</div>`;
|
list.innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">${activeTab === 'content' ? t('playlist.no_content_found') : t('playlist.no_widgets_found')}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = filtered.map(item => {
|
list.innerHTML = filtered.map(item => {
|
||||||
const isWidget = activeTab === 'widgets';
|
const isWidget = activeTab === 'widgets';
|
||||||
const name = item.filename || item.name || 'Unknown';
|
const name = item.filename || item.name || t('common.unknown');
|
||||||
const sub = isWidget ? (item.widget_type || 'Widget') : (item.mime_type || '');
|
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : (item.mime_type || '');
|
||||||
const thumb = item.thumbnail_path ? `/uploads/thumbnails/${esc(item.thumbnail_path.split('/').pop())}` : null;
|
const thumb = item.thumbnail_path ? `/api/content/${esc(item.id)}/thumbnail` : null;
|
||||||
return `
|
return `
|
||||||
<div class="add-item-row" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:var(--radius);cursor:pointer;transition:background 0.1s">
|
<div class="add-item-row" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:var(--radius);cursor:pointer;transition:background 0.1s">
|
||||||
<div style="width:40px;height:30px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center">
|
<div style="width:40px;height:30px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center">
|
||||||
|
|
@ -452,12 +597,11 @@ async function showAddItemModal(playlistId) {
|
||||||
<div style="font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(name)}</div>
|
<div style="font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(name)}</div>
|
||||||
<div style="font-size:11px;color:var(--text-muted)">${esc(sub)}</div>
|
<div style="font-size:11px;color:var(--text-muted)">${esc(sub)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm add-item-btn" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}">Add</button>
|
<button class="btn btn-primary btn-sm add-item-btn" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}">${t('playlist.add_btn')}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Add button handlers
|
|
||||||
list.querySelectorAll('.add-item-btn').forEach(btn => {
|
list.querySelectorAll('.add-item-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -466,24 +610,21 @@ async function showAddItemModal(playlistId) {
|
||||||
const data = type === 'widget' ? { widget_id: id } : { content_id: id };
|
const data = type === 'widget' ? { widget_id: id } : { content_id: id };
|
||||||
try {
|
try {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Adding...';
|
btn.textContent = t('playlist.adding');
|
||||||
await api.addPlaylistItem(playlistId, data);
|
await api.addPlaylistItem(playlistId, data);
|
||||||
btn.textContent = 'Added';
|
btn.textContent = t('playlist.added');
|
||||||
btn.classList.remove('btn-primary');
|
btn.classList.remove('btn-primary');
|
||||||
btn.classList.add('btn-secondary');
|
btn.classList.add('btn-secondary');
|
||||||
// Refresh the detail view items
|
refreshAfterMutation();
|
||||||
const playlist = await api.getPlaylist(playlistId);
|
|
||||||
renderItems(playlist.items || []);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Add';
|
btn.textContent = t('playlist.add_btn');
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab switching
|
|
||||||
modal.querySelectorAll('.tab-btn').forEach(btn => {
|
modal.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
activeTab = btn.dataset.tab;
|
activeTab = btn.dataset.tab;
|
||||||
|
|
@ -496,12 +637,117 @@ async function showAddItemModal(playlistId) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search
|
|
||||||
document.getElementById('addItemSearch').addEventListener('input', renderTab);
|
document.getElementById('addItemSearch').addEventListener('input', renderTab);
|
||||||
|
|
||||||
// Close
|
|
||||||
document.getElementById('closeAddModal').addEventListener('click', () => modal.remove());
|
document.getElementById('closeAddModal').addEventListener('click', () => modal.remove());
|
||||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||||
|
|
||||||
renderTab();
|
renderTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #74/#75: per-item schedule editor. Multiple blocks (days + time window + optional
|
||||||
|
// date range) OR together; an item with no blocks always plays. Client validation
|
||||||
|
// mirrors the server; saving marks the playlist DRAFT (must re-publish to reach devices).
|
||||||
|
function showScheduleModal(item) {
|
||||||
|
let blocks = (item.schedules || []).map(b => ({
|
||||||
|
days: Array.isArray(b.days) ? [...b.days] : [],
|
||||||
|
start: b.start || '00:00',
|
||||||
|
end: b.end || '24:00',
|
||||||
|
start_date: b.start_date || '',
|
||||||
|
end_date: b.end_date || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
function blockRow(b, idx) {
|
||||||
|
const eod = b.end === '24:00';
|
||||||
|
const dayLabels = t('itemsched.dow_short').split(',');
|
||||||
|
return `
|
||||||
|
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:10px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||||
|
<strong style="font-size:13px">${t('itemsched.block', { n: idx + 1 })}</strong>
|
||||||
|
<button class="sched-remove" data-idx="${idx}" title="${t('itemsched.remove_block')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;font-size:14px">✕</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px">
|
||||||
|
${dayLabels.map((lbl, d) => `<button class="sched-day" data-idx="${idx}" data-day="${d}" style="padding:4px 9px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid var(--border);background:${b.days.includes(d) ? 'var(--accent)' : 'var(--bg-input)'};color:${b.days.includes(d) ? '#000' : 'var(--text-muted)'}">${lbl}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center">
|
||||||
|
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.from')} <input type="time" class="input sched-start" data-idx="${idx}" value="${esc(b.start)}" style="width:118px"></label>
|
||||||
|
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.to')} <input type="time" class="input sched-end" data-idx="${idx}" value="${esc(eod ? '00:00' : b.end)}" ${eod ? 'disabled' : ''} style="width:118px"></label>
|
||||||
|
<label style="font-size:12px;color:var(--text-muted)"><input type="checkbox" class="sched-eod" data-idx="${idx}" ${eod ? 'checked' : ''}> ${t('itemsched.end_of_day')}</label>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-top:10px">
|
||||||
|
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.starts')} <input type="date" class="input sched-sd" data-idx="${idx}" value="${esc(b.start_date)}" style="width:150px"></label>
|
||||||
|
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.ends')} <input type="date" class="input sched-ed" data-idx="${idx}" value="${esc(b.end_date)}" style="width:150px"></label>
|
||||||
|
<span style="font-size:11px;color:var(--text-muted)">${t('itemsched.dates_hint')}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:580px;max-width:94vw;max-height:88vh;overflow:auto">
|
||||||
|
<h3 style="margin:0 0 4px">${t('itemsched.title')}</h3>
|
||||||
|
<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">${esc(item.filename || item.widget_name || 'item')}</p>
|
||||||
|
<p style="font-size:12px;color:#7dd3fc;background:#0c2a3f;border-radius:6px;padding:8px 10px;margin:0 0 16px">${t('itemsched.hint')}</p>
|
||||||
|
<div>${blocks.length ? blocks.map(blockRow).join('') : `<p style="font-size:13px;color:var(--text-muted);margin:0 0 10px">${t('itemsched.none')}</p>`}</div>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="schedAddBlock" style="margin-bottom:4px">${t('itemsched.add_block')}</button>
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:20px">
|
||||||
|
<button class="btn btn-secondary" id="schedCancel">${t('itemsched.cancel')}</button>
|
||||||
|
<button class="btn" id="schedSave" style="background:#f59e0b;color:#000;font-weight:600;border:none">${t('itemsched.save')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
wire();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire() {
|
||||||
|
modal.querySelectorAll('.sched-day').forEach(btn => btn.addEventListener('click', () => {
|
||||||
|
const i = +btn.dataset.idx, d = +btn.dataset.day;
|
||||||
|
const set = new Set(blocks[i].days);
|
||||||
|
if (set.has(d)) set.delete(d); else set.add(d);
|
||||||
|
blocks[i].days = [...set];
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
modal.querySelectorAll('.sched-start').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].start = el.value; }));
|
||||||
|
modal.querySelectorAll('.sched-end').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].end = el.value; }));
|
||||||
|
modal.querySelectorAll('.sched-eod').forEach(el => el.addEventListener('change', () => {
|
||||||
|
blocks[+el.dataset.idx].end = el.checked ? '24:00' : '17:00';
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
modal.querySelectorAll('.sched-sd').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].start_date = el.value; }));
|
||||||
|
modal.querySelectorAll('.sched-ed').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].end_date = el.value; }));
|
||||||
|
modal.querySelectorAll('.sched-remove').forEach(btn => btn.addEventListener('click', () => { blocks.splice(+btn.dataset.idx, 1); render(); }));
|
||||||
|
document.getElementById('schedAddBlock').addEventListener('click', () => {
|
||||||
|
blocks.push({ days: [0, 1, 2, 3, 4, 5, 6], start: '09:00', end: '17:00', start_date: '', end_date: '' });
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.getElementById('schedCancel').addEventListener('click', () => modal.remove());
|
||||||
|
document.getElementById('schedSave').addEventListener('click', doSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSave() {
|
||||||
|
const payload = blocks.map(b => ({
|
||||||
|
days: b.days, start: b.start, end: b.end,
|
||||||
|
start_date: b.start_date || null, end_date: b.end_date || null
|
||||||
|
}));
|
||||||
|
const err = validateScheduleBlocks(payload);
|
||||||
|
if (err) { showToast(err, 'error'); return; }
|
||||||
|
try {
|
||||||
|
const saved = await api.setItemSchedules(currentPlaylistId, item.id, payload);
|
||||||
|
item.schedules = saved;
|
||||||
|
modal.remove();
|
||||||
|
// Saving makes the playlist a DRAFT — surface the re-publish step explicitly.
|
||||||
|
showToast(payload.length ? t('itemsched.toast.saved') : t('itemsched.toast.cleared'));
|
||||||
|
const playlist = await api.getPlaylist(currentPlaylistId);
|
||||||
|
renderItems(playlist.items || []);
|
||||||
|
refreshAfterMutation();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||||
|
|
||||||
|
|
@ -11,36 +13,36 @@ export async function render(container) {
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>Reports <span class="help-tip" data-tip="Proof-of-play analytics. See what played, when, and on which device. Filter by date range and device. Export to CSV for ad verification.">?</span></h1><div class="subtitle">Proof-of-play analytics and device uptime</div></div>
|
<div><h1>${t('report.title')} <span class="help-tip" data-tip="${t('report.help_tip')}">?</span></h1><div class="subtitle">${t('report.subtitle')}</div></div>
|
||||||
<a class="btn btn-secondary" id="exportBtn">
|
<a class="btn btn-secondary" id="exportBtn">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
Export CSV
|
${t('report.export_csv')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end">
|
<div style="display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end">
|
||||||
<div class="form-group" style="margin:0"><label>Device</label>
|
<div class="form-group" style="margin:0"><label>${t('report.device')}</label>
|
||||||
<select id="reportDevice" class="input" style="width:200px;background:var(--bg-input)">
|
<select id="reportDevice" class="input" style="width:200px;background:var(--bg-input)">
|
||||||
<option value="">All Devices</option>
|
<option value="">${t('report.all_devices')}</option>
|
||||||
${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
|
${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin:0"><label>Start Date</label>
|
<div class="form-group" style="margin:0"><label>${t('report.start_date')}</label>
|
||||||
<input type="date" id="reportStart" class="input" value="${thirtyDaysAgo.toISOString().split('T')[0]}">
|
<input type="date" id="reportStart" class="input" value="${thirtyDaysAgo.toISOString().split('T')[0]}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin:0"><label>End Date</label>
|
<div class="form-group" style="margin:0"><label>${t('report.end_date')}</label>
|
||||||
<input type="date" id="reportEnd" class="input" value="${today.toISOString().split('T')[0]}">
|
<input type="date" id="reportEnd" class="input" value="${today.toISOString().split('T')[0]}">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" id="loadReportBtn">Load Report</button>
|
<button class="btn btn-primary btn-sm" id="loadReportBtn">${t('report.load_report')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="reportContent"><div class="empty-state"><h3>Select a date range and click Load Report</h3></div></div>
|
<div id="reportContent"><div class="empty-state"><h3>${t('report.select_range')}</h3></div></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.getElementById('loadReportBtn').onclick = loadReport;
|
document.getElementById('loadReportBtn').onclick = loadReport;
|
||||||
loadReport(); // Auto-load on page render
|
loadReport();
|
||||||
document.getElementById('exportBtn').onclick = () => {
|
document.getElementById('exportBtn').onclick = () => {
|
||||||
const deviceId = document.getElementById('reportDevice').value;
|
const deviceId = document.getElementById('reportDevice').value;
|
||||||
const start = document.getElementById('reportStart').value;
|
const start = document.getElementById('reportStart').value;
|
||||||
|
|
@ -55,81 +57,79 @@ export async function render(container) {
|
||||||
const end = document.getElementById('reportEnd').value;
|
const end = document.getElementById('reportEnd').value;
|
||||||
const content = document.getElementById('reportContent');
|
const content = document.getElementById('reportContent');
|
||||||
|
|
||||||
content.innerHTML = '<div class="empty-state"><h3>Loading...</h3></div>';
|
content.innerHTML = `<div class="empty-state"><h3>${t('common.loading')}</h3></div>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const summary = await API(`/reports/summary?device_id=${deviceId}&start=${start}&end=${end}`);
|
const summary = await API(`/reports/summary?device_id=${deviceId}&start=${start}&end=${end}`);
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<!-- Summary Cards -->
|
|
||||||
<div class="info-grid" style="margin-bottom:24px">
|
<div class="info-grid" style="margin-bottom:24px">
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Total Plays</div>
|
<div class="info-card-label">${t('report.total_plays')}</div>
|
||||||
<div class="info-card-value">${summary.overall.total_plays.toLocaleString()}</div>
|
<div class="info-card-value">${summary.overall.total_plays.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Total Hours</div>
|
<div class="info-card-label">${t('report.total_hours')}</div>
|
||||||
<div class="info-card-value">${summary.overall.total_hours}</div>
|
<div class="info-card-value">${summary.overall.total_hours}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Unique Content</div>
|
<div class="info-card-label">${t('report.unique_content')}</div>
|
||||||
<div class="info-card-value">${summary.overall.unique_content}</div>
|
<div class="info-card-value">${summary.overall.unique_content}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Active Devices</div>
|
<div class="info-card-label">${t('report.active_devices')}</div>
|
||||||
<div class="info-card-value">${summary.overall.unique_devices}</div>
|
<div class="info-card-value">${summary.overall.unique_devices}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Avg Duration</div>
|
<div class="info-card-label">${t('report.avg_duration')}</div>
|
||||||
<div class="info-card-value small">${formatDuration(summary.overall.avg_duration_sec)}</div>
|
<div class="info-card-value small">${formatDuration(summary.overall.avg_duration_sec)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px">
|
||||||
<!-- Plays per Day Chart -->
|
|
||||||
<div class="settings-section" style="margin:0">
|
<div class="settings-section" style="margin:0">
|
||||||
<h3 style="font-size:14px;margin-bottom:12px">Plays per Day</h3>
|
<h3 style="font-size:14px;margin-bottom:12px">${t('report.plays_per_day')}</h3>
|
||||||
<div id="dailyChart" style="height:200px;display:flex;align-items:flex-end;gap:2px"></div>
|
<div id="dailyChart" style="height:200px;display:flex;align-items:flex-end;gap:2px"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plays by Hour Chart -->
|
|
||||||
<div class="settings-section" style="margin:0">
|
<div class="settings-section" style="margin:0">
|
||||||
<h3 style="font-size:14px;margin-bottom:12px">Plays by Hour</h3>
|
<h3 style="font-size:14px;margin-bottom:12px">${t('report.plays_by_hour')}</h3>
|
||||||
<div id="hourlyChart" style="height:200px;display:flex;align-items:flex-end;gap:1px"></div>
|
<div id="hourlyChart" style="height:200px;display:flex;align-items:flex-end;gap:1px"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top Content -->
|
|
||||||
<div class="settings-section" style="margin-bottom:20px">
|
<div class="settings-section" style="margin-bottom:20px">
|
||||||
<h3 style="font-size:14px;margin-bottom:12px">Top Content</h3>
|
<h3 style="font-size:14px;margin-bottom:12px">${t('report.top_content')}</h3>
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
<div class="table-wrap">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:460px">
|
||||||
<thead><tr style="border-bottom:1px solid var(--border)">
|
<thead><tr style="border-bottom:1px solid var(--border)">
|
||||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">Content</th>
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('report.col.content')}</th>
|
||||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">Plays</th>
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.plays')}</th>
|
||||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">Total Hours</th>
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.total_hours')}</th>
|
||||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">Completion</th>
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.completion')}</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${summary.by_content.map(c => `
|
${summary.by_content.map(c => `
|
||||||
<tr style="border-bottom:1px solid var(--border)">
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
<td style="padding:8px">${c.content_name || 'Unknown'}</td>
|
<td style="padding:8px">${c.content_name || t('common.unknown')}</td>
|
||||||
<td style="padding:8px;text-align:right">${c.plays}</td>
|
<td style="padding:8px;text-align:right">${c.plays}</td>
|
||||||
<td style="padding:8px;text-align:right">${(c.total_seconds / 3600).toFixed(1)}</td>
|
<td style="padding:8px;text-align:right">${(c.total_seconds / 3600).toFixed(1)}</td>
|
||||||
<td style="padding:8px;text-align:right">${c.plays > 0 ? Math.round((c.completed_plays / c.plays) * 100) : 0}%</td>
|
<td style="padding:8px;text-align:right">${c.plays > 0 ? Math.round((c.completed_plays / c.plays) * 100) : 0}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('') || '<tr><td colspan="4" style="padding:16px;text-align:center;color:var(--text-muted)">No data</td></tr>'}
|
`).join('') || `<tr><td colspan="4" style="padding:16px;text-align:center;color:var(--text-muted)">${t('report.no_data')}</td></tr>`}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- By Device -->
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3 style="font-size:14px;margin-bottom:12px">By Device</h3>
|
<h3 style="font-size:14px;margin-bottom:12px">${t('report.by_device')}</h3>
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
<div class="table-wrap">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:400px">
|
||||||
<thead><tr style="border-bottom:1px solid var(--border)">
|
<thead><tr style="border-bottom:1px solid var(--border)">
|
||||||
<th style="padding:8px;text-align:left;color:var(--text-muted)">Device</th>
|
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('report.col.device')}</th>
|
||||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">Plays</th>
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.plays')}</th>
|
||||||
<th style="padding:8px;text-align:right;color:var(--text-muted)">Total Hours</th>
|
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.total_hours')}</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${summary.by_device.map(d => `
|
${summary.by_device.map(d => `
|
||||||
|
|
@ -138,19 +138,18 @@ export async function render(container) {
|
||||||
<td style="padding:8px;text-align:right">${d.plays}</td>
|
<td style="padding:8px;text-align:right">${d.plays}</td>
|
||||||
<td style="padding:8px;text-align:right">${(d.total_seconds / 3600).toFixed(1)}</td>
|
<td style="padding:8px;text-align:right">${(d.total_seconds / 3600).toFixed(1)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('') || '<tr><td colspan="3" style="padding:16px;text-align:center;color:var(--text-muted)">No data</td></tr>'}
|
`).join('') || `<tr><td colspan="3" style="padding:16px;text-align:center;color:var(--text-muted)">${t('report.no_data')}</td></tr>`}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Render daily chart
|
|
||||||
renderBarChart('dailyChart', summary.by_day.map(d => ({
|
renderBarChart('dailyChart', summary.by_day.map(d => ({
|
||||||
label: new Date(d.day).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
label: new Date(d.day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||||
value: d.plays
|
value: d.plays
|
||||||
})));
|
})));
|
||||||
|
|
||||||
// Render hourly chart
|
|
||||||
const hourData = Array.from({ length: 24 }, (_, i) => {
|
const hourData = Array.from({ length: 24 }, (_, i) => {
|
||||||
const found = summary.by_hour.find(h => h.hour === i);
|
const found = summary.by_hour.find(h => h.hour === i);
|
||||||
return { label: i === 0 ? '12a' : i < 12 ? i + 'a' : i === 12 ? '12p' : (i - 12) + 'p', value: found?.plays || 0 };
|
return { label: i === 0 ? '12a' : i < 12 ? i + 'a' : i === 12 ? '12p' : (i - 12) + 'p', value: found?.plays || 0 };
|
||||||
|
|
@ -158,7 +157,7 @@ export async function render(container) {
|
||||||
renderBarChart('hourlyChart', hourData);
|
renderBarChart('hourlyChart', hourData);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
content.innerHTML = `<div class="empty-state"><h3>Error</h3><p>${err.message}</p></div>`;
|
content.innerHTML = `<div class="empty-state"><h3>${t('report.error')}</h3><p>${esc(err.message)}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,118 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||||
|
|
||||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
||||||
|
function esc(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
|
||||||
|
|
||||||
export async function render(container) {
|
export async function render(container) {
|
||||||
const devices = await api.getDevices();
|
const [devices, content, groups, playlists, layoutsRaw] = await Promise.all([
|
||||||
const content = await api.getContent();
|
api.getDevices(),
|
||||||
const selectedDevice = devices[0]?.id || '';
|
api.getContent(),
|
||||||
|
api.getGroups(),
|
||||||
|
api.getPlaylists(),
|
||||||
|
API('/layouts'),
|
||||||
|
]);
|
||||||
|
const layouts = (Array.isArray(layoutsRaw) ? layoutsRaw : []).filter(l => !l.is_template);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const weekStart = new Date(today);
|
const weekStart = new Date(today);
|
||||||
weekStart.setDate(today.getDate() - today.getDay());
|
weekStart.setDate(today.getDate() - today.getDay());
|
||||||
weekStart.setHours(0, 0, 0, 0);
|
weekStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
t('schedule.day.sun'), t('schedule.day.mon'), t('schedule.day.tue'),
|
||||||
|
t('schedule.day.wed'), t('schedule.day.thu'), t('schedule.day.fri'),
|
||||||
|
t('schedule.day.sat'),
|
||||||
|
];
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>Schedule <span class="help-tip" data-tip="Visual weekly calendar for content scheduling. Click Add Schedule to create time slots. Set recurrence for repeating content. Higher priority overrides lower.">?</span></h1><div class="subtitle">Content scheduling calendar</div></div>
|
<div><h1>${t('schedule.title')} <span class="help-tip" data-tip="${t('schedule.help_tip')}">?</span></h1><div class="subtitle">${t('schedule.subtitle')}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
|
<div class="schedule-controls" style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
|
||||||
<select id="schedDevice" class="input" style="width:200px;background:var(--bg-input)">
|
<select id="schedDevice" class="input" style="width:200px;max-width:100%;background:var(--bg-input)">
|
||||||
${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
|
${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-secondary btn-sm" id="prevWeek">< Prev</button>
|
<button class="btn btn-secondary btn-sm" id="prevWeek">${t('schedule.prev_week')}</button>
|
||||||
<span id="weekLabel" style="color:var(--text-secondary);font-size:13px"></span>
|
<span id="weekLabel" style="color:var(--text-secondary);font-size:13px"></span>
|
||||||
<button class="btn btn-secondary btn-sm" id="nextWeek">Next ></button>
|
<button class="btn btn-secondary btn-sm" id="nextWeek">${t('schedule.next_week')}</button>
|
||||||
<button class="btn btn-primary btn-sm" id="addScheduleBtn">Add Schedule</button>
|
<button class="btn btn-primary btn-sm" id="addScheduleBtn">${t('schedule.add_schedule')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-x:auto">
|
<div style="overflow-x:auto">
|
||||||
<div id="calendar" style="display:grid;grid-template-columns:60px repeat(7,1fr);min-width:800px;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden"></div>
|
<div id="calendar" style="display:grid;grid-template-columns:60px repeat(7,1fr);min-width:800px;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add/Edit Schedule Modal -->
|
|
||||||
<div class="modal-overlay" id="scheduleModal" style="display:none">
|
<div class="modal-overlay" id="scheduleModal" style="display:none">
|
||||||
<div class="modal" style="width:480px">
|
<div class="modal" style="width:480px">
|
||||||
<div class="modal-header"><h3 id="schedModalTitle">Add Schedule</h3>
|
<div class="modal-header"><h3 id="schedModalTitle">${t('schedule.add_schedule')}</h3>
|
||||||
<button class="btn-icon" onclick="document.getElementById('scheduleModal').style.display='none'">
|
<button class="btn-icon" onclick="document.getElementById('scheduleModal').style.display='none'">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group"><label>Content</label>
|
<div class="form-group"><label>${t('schedule.apply_to')}</label>
|
||||||
|
<div style="display:flex;gap:16px;margin-bottom:8px">
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
|
||||||
|
<input type="radio" name="schedTarget" value="device" checked id="schedTargetDevice"> ${t('schedule.target_device')}
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
|
||||||
|
<input type="radio" name="schedTarget" value="group" id="schedTargetGroup"> ${t('schedule.target_group')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<select id="schedDeviceSelect" class="input" style="background:var(--bg-input)">
|
||||||
|
${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<select id="schedGroupSelect" class="input" style="background:var(--bg-input);display:none">
|
||||||
|
${groups.map(g => `<option value="${esc(g.id)}">${esc(g.name)} (${t('schedule.group_devices_count', { n: g.device_count })})</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
${groups.length === 0 ? `<div id="schedNoGroups" style="display:none;color:var(--text-muted);font-size:12px;margin-top:4px">${t('schedule.no_groups_msg')}</div>` : ''}
|
||||||
|
<div id="schedZoneNote" style="display:none;color:var(--text-muted);font-size:11px;margin-top:4px">${t('schedule.zone_note')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>${t('schedule.playlist_override')}</label>
|
||||||
|
<select id="schedPlaylist" class="input" style="background:var(--bg-input)">
|
||||||
|
<option value="">${t('schedule.no_playlist_override')}</option>
|
||||||
|
${playlists.map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' ' + t('schedule.draft_suffix') : ''}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>${t('schedule.layout_override')}</label>
|
||||||
|
<select id="schedLayout" class="input" style="background:var(--bg-input)">
|
||||||
|
<option value="">${t('schedule.no_layout_override')}</option>
|
||||||
|
${layouts.map(l => `<option value="${esc(l.id)}">${esc(l.name)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>${t('schedule.content_label')} <span style="color:var(--text-muted);font-weight:normal;font-size:11px">${t('schedule.content_hint')}</span></label>
|
||||||
<select id="schedContent" class="input" style="background:var(--bg-input)">
|
<select id="schedContent" class="input" style="background:var(--bg-input)">
|
||||||
${content.map(c => `<option value="${c.id}">${c.filename}</option>`).join('')}
|
<option value="">${t('schedule.content_none')}</option>
|
||||||
|
${content.map(c => `<option value="${esc(c.id)}">${esc(c.filename)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label>Title (optional)</label><input type="text" id="schedTitle" class="input" placeholder="e.g., Morning Playlist"></div>
|
<div class="form-group"><label>${t('schedule.title_label')}</label><input type="text" id="schedTitle" class="input" placeholder="${t('schedule.title_placeholder')}"></div>
|
||||||
<div style="display:flex;gap:12px">
|
<div style="display:flex;gap:12px">
|
||||||
<div class="form-group" style="flex:1"><label>Start Time</label><input type="time" id="schedStart" class="input" value="09:00"></div>
|
<div class="form-group" style="flex:1"><label>${t('schedule.start_time')}</label><input type="time" id="schedStart" class="input" value="09:00"></div>
|
||||||
<div class="form-group" style="flex:1"><label>End Time</label><input type="time" id="schedEnd" class="input" value="17:00"></div>
|
<div class="form-group" style="flex:1"><label>${t('schedule.end_time')}</label><input type="time" id="schedEnd" class="input" value="17:00"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label>Repeat</label>
|
<div class="form-group"><label>${t('schedule.repeat')}</label>
|
||||||
<select id="schedRepeat" class="input" style="background:var(--bg-input)">
|
<select id="schedRepeat" class="input" style="background:var(--bg-input)">
|
||||||
<option value="">No repeat</option>
|
<option value="">${t('schedule.repeat_none')}</option>
|
||||||
<option value="FREQ=DAILY">Daily</option>
|
<option value="FREQ=DAILY">${t('schedule.repeat_daily')}</option>
|
||||||
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">Weekdays</option>
|
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">${t('schedule.repeat_weekdays')}</option>
|
||||||
<option value="FREQ=WEEKLY;BYDAY=SA,SU">Weekends</option>
|
<option value="FREQ=WEEKLY;BYDAY=SA,SU">${t('schedule.repeat_weekends')}</option>
|
||||||
<option value="FREQ=WEEKLY">Weekly</option>
|
<option value="FREQ=WEEKLY">${t('schedule.repeat_weekly')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label>Priority</label><input type="number" id="schedPriority" class="input" value="0" min="0" max="100"></div>
|
<div class="form-group"><label>${t('schedule.priority')}</label><input type="number" id="schedPriority" class="input" value="0" min="0" max="100"></div>
|
||||||
<div class="form-group"><label>Color</label><input type="color" id="schedColor" value="#3B82F6" style="width:60px;height:32px;border:none;cursor:pointer"></div>
|
<div class="form-group"><label>${t('schedule.color')}</label><input type="color" id="schedColor" value="#3B82F6" style="width:60px;height:32px;border:none;cursor:pointer"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="display:flex;justify-content:space-between;gap:8px">
|
||||||
|
<button class="btn btn-danger" id="deleteScheduleBtn" style="display:none">${t('common.delete')}</button>
|
||||||
|
<div style="display:flex;gap:8px;margin-left:auto">
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('scheduleModal').style.display='none'">${t('common.cancel')}</button>
|
||||||
|
<button class="btn btn-primary" id="saveScheduleBtn">${t('common.save')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" onclick="document.getElementById('scheduleModal').style.display='none'">Cancel</button>
|
|
||||||
<button class="btn btn-primary" id="saveScheduleBtn">Save</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -75,11 +121,29 @@ export async function render(container) {
|
||||||
let currentWeekStart = new Date(weekStart);
|
let currentWeekStart = new Date(weekStart);
|
||||||
let editingId = null;
|
let editingId = null;
|
||||||
|
|
||||||
|
const deviceRadio = document.getElementById('schedTargetDevice');
|
||||||
|
const groupRadio = document.getElementById('schedTargetGroup');
|
||||||
|
const deviceSelect = document.getElementById('schedDeviceSelect');
|
||||||
|
const groupSelect = document.getElementById('schedGroupSelect');
|
||||||
|
const noGroupsMsg = document.getElementById('schedNoGroups');
|
||||||
|
const zoneNote = document.getElementById('schedZoneNote');
|
||||||
|
|
||||||
|
function updateTargetVisibility() {
|
||||||
|
const isGroup = groupRadio.checked;
|
||||||
|
deviceSelect.style.display = isGroup ? 'none' : '';
|
||||||
|
groupSelect.style.display = isGroup ? '' : 'none';
|
||||||
|
if (noGroupsMsg) noGroupsMsg.style.display = (isGroup && groups.length === 0) ? '' : 'none';
|
||||||
|
zoneNote.style.display = isGroup ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceRadio.addEventListener('change', updateTargetVisibility);
|
||||||
|
groupRadio.addEventListener('change', updateTargetVisibility);
|
||||||
|
|
||||||
function updateWeekLabel() {
|
function updateWeekLabel() {
|
||||||
const end = new Date(currentWeekStart);
|
const end = new Date(currentWeekStart);
|
||||||
end.setDate(end.getDate() + 6);
|
end.setDate(end.getDate() + 6);
|
||||||
document.getElementById('weekLabel').textContent =
|
document.getElementById('weekLabel').textContent =
|
||||||
`${currentWeekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
|
`${currentWeekStart.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCalendar() {
|
async function loadCalendar() {
|
||||||
|
|
@ -92,7 +156,6 @@ export async function render(container) {
|
||||||
const cal = document.getElementById('calendar');
|
const cal = document.getElementById('calendar');
|
||||||
let html = '<div style="background:var(--bg-secondary);border-bottom:1px solid var(--border)"></div>';
|
let html = '<div style="background:var(--bg-secondary);border-bottom:1px solid var(--border)"></div>';
|
||||||
|
|
||||||
// Day headers
|
|
||||||
for (let d = 0; d < 7; d++) {
|
for (let d = 0; d < 7; d++) {
|
||||||
const date = new Date(currentWeekStart);
|
const date = new Date(currentWeekStart);
|
||||||
date.setDate(date.getDate() + d);
|
date.setDate(date.getDate() + d);
|
||||||
|
|
@ -103,9 +166,8 @@ export async function render(container) {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hour rows
|
|
||||||
for (const h of HOURS) {
|
for (const h of HOURS) {
|
||||||
html += `<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);border-bottom:1px solid var(--border);text-align:right">${h === 0 ? '12am' : h < 12 ? h + 'am' : h === 12 ? '12pm' : (h - 12) + 'pm'}</div>`;
|
html += `<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);border-bottom:1px solid var(--border);text-align:right">${h === 0 ? t('schedule.hour_12am') : h < 12 ? h + t('schedule.hour_am') : h === 12 ? t('schedule.hour_12pm') : (h - 12) + t('schedule.hour_pm')}</div>`;
|
||||||
for (let d = 0; d < 7; d++) {
|
for (let d = 0; d < 7; d++) {
|
||||||
html += `<div style="position:relative;min-height:28px;border-bottom:1px solid var(--border);border-left:1px solid var(--border);background:var(--bg-primary)" data-hour="${h}" data-day="${d}"></div>`;
|
html += `<div style="position:relative;min-height:28px;border-bottom:1px solid var(--border);border-left:1px solid var(--border);background:var(--bg-primary)" data-hour="${h}" data-day="${d}"></div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +175,6 @@ export async function render(container) {
|
||||||
|
|
||||||
cal.innerHTML = html;
|
cal.innerHTML = html;
|
||||||
|
|
||||||
// Render events
|
|
||||||
events.forEach(ev => {
|
events.forEach(ev => {
|
||||||
const start = new Date(ev.instance_start || ev.start_time);
|
const start = new Date(ev.instance_start || ev.start_time);
|
||||||
const end = new Date(ev.instance_end || ev.end_time);
|
const end = new Date(ev.instance_end || ev.end_time);
|
||||||
|
|
@ -125,12 +186,17 @@ export async function render(container) {
|
||||||
const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`);
|
const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`);
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
|
|
||||||
|
const isGroupSchedule = !!ev.group_id;
|
||||||
const block = document.createElement('div');
|
const block = document.createElement('div');
|
||||||
const topOffset = (startHour - Math.floor(startHour)) * 28;
|
const topOffset = (startHour - Math.floor(startHour)) * 28;
|
||||||
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px;
|
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px;
|
||||||
background:${ev.color || '#3B82F6'};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.85`;
|
background:${ev.color || '#3B82F6'};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.85;
|
||||||
block.textContent = ev.title || ev.content_name || ev.widget_name || 'Scheduled';
|
${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.6);' : ''}`;
|
||||||
block.title = `${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}`;
|
|
||||||
|
const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || t('schedule.scheduled_label');
|
||||||
|
const prefix = isGroupSchedule ? `[${esc(ev.group_name || t('schedule.target_group'))}] ` : '';
|
||||||
|
block.textContent = prefix + label;
|
||||||
|
block.title = `${isGroupSchedule ? t('schedule.tooltip_group_prefix') + (ev.group_name || '') + '\n' : ''}${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}\n${t('schedule.tooltip_priority', { n: ev.priority })}`;
|
||||||
block.onclick = () => editSchedule(ev);
|
block.onclick = () => editSchedule(ev);
|
||||||
cell.appendChild(block);
|
cell.appendChild(block);
|
||||||
});
|
});
|
||||||
|
|
@ -138,7 +204,9 @@ export async function render(container) {
|
||||||
|
|
||||||
function editSchedule(ev) {
|
function editSchedule(ev) {
|
||||||
editingId = ev.id;
|
editingId = ev.id;
|
||||||
document.getElementById('schedModalTitle').textContent = 'Edit Schedule';
|
document.getElementById('schedModalTitle').textContent = t('schedule.edit_schedule');
|
||||||
|
document.getElementById('schedPlaylist').value = ev.playlist_id || '';
|
||||||
|
document.getElementById('schedLayout').value = ev.layout_id || '';
|
||||||
document.getElementById('schedContent').value = ev.content_id || '';
|
document.getElementById('schedContent').value = ev.content_id || '';
|
||||||
document.getElementById('schedTitle').value = ev.title || '';
|
document.getElementById('schedTitle').value = ev.title || '';
|
||||||
const start = new Date(ev.start_time);
|
const start = new Date(ev.start_time);
|
||||||
|
|
@ -148,26 +216,66 @@ export async function render(container) {
|
||||||
document.getElementById('schedRepeat').value = ev.recurrence || '';
|
document.getElementById('schedRepeat').value = ev.recurrence || '';
|
||||||
document.getElementById('schedPriority').value = ev.priority || 0;
|
document.getElementById('schedPriority').value = ev.priority || 0;
|
||||||
document.getElementById('schedColor').value = ev.color || '#3B82F6';
|
document.getElementById('schedColor').value = ev.color || '#3B82F6';
|
||||||
|
|
||||||
|
if (ev.group_id) {
|
||||||
|
groupRadio.checked = true;
|
||||||
|
groupSelect.value = ev.group_id;
|
||||||
|
} else {
|
||||||
|
deviceRadio.checked = true;
|
||||||
|
deviceSelect.value = ev.device_id || document.getElementById('schedDevice').value;
|
||||||
|
}
|
||||||
|
updateTargetVisibility();
|
||||||
|
|
||||||
|
document.getElementById('deleteScheduleBtn').style.display = '';
|
||||||
document.getElementById('scheduleModal').style.display = 'flex';
|
document.getElementById('scheduleModal').style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('addScheduleBtn').onclick = () => {
|
document.getElementById('addScheduleBtn').onclick = () => {
|
||||||
editingId = null;
|
editingId = null;
|
||||||
document.getElementById('schedModalTitle').textContent = 'Add Schedule';
|
document.getElementById('schedModalTitle').textContent = t('schedule.add_schedule');
|
||||||
document.getElementById('schedTitle').value = '';
|
document.getElementById('schedTitle').value = '';
|
||||||
|
document.getElementById('schedPlaylist').value = '';
|
||||||
|
document.getElementById('schedLayout').value = '';
|
||||||
|
document.getElementById('schedContent').value = '';
|
||||||
|
deviceRadio.checked = true;
|
||||||
|
deviceSelect.value = document.getElementById('schedDevice').value;
|
||||||
|
updateTargetVisibility();
|
||||||
|
document.getElementById('deleteScheduleBtn').style.display = 'none';
|
||||||
document.getElementById('scheduleModal').style.display = 'flex';
|
document.getElementById('scheduleModal').style.display = 'flex';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
document.getElementById('deleteScheduleBtn').onclick = async () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
if (!confirm(t('schedule.confirm_delete') || 'Delete this schedule?')) return;
|
||||||
|
try {
|
||||||
|
await API(`/schedules/${editingId}`, { method: 'DELETE' });
|
||||||
|
document.getElementById('scheduleModal').style.display = 'none';
|
||||||
|
showToast(t('schedule.toast.deleted') || 'Schedule deleted', 'success');
|
||||||
|
loadCalendar();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
document.getElementById('saveScheduleBtn').onclick = async () => {
|
document.getElementById('saveScheduleBtn').onclick = async () => {
|
||||||
const deviceId = document.getElementById('schedDevice').value;
|
const isGroup = groupRadio.checked;
|
||||||
const contentId = document.getElementById('schedContent').value;
|
const contentId = document.getElementById('schedContent').value;
|
||||||
const startTime = document.getElementById('schedStart').value;
|
const startTime = document.getElementById('schedStart').value;
|
||||||
const endTime = document.getElementById('schedEnd').value;
|
const endTime = document.getElementById('schedEnd').value;
|
||||||
|
|
||||||
|
if (isGroup && groups.length === 0) {
|
||||||
|
showToast(t('schedule.toast.no_groups'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistId = document.getElementById('schedPlaylist').value;
|
||||||
|
const layoutId = document.getElementById('schedLayout').value;
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const data = {
|
const data = {
|
||||||
device_id: deviceId,
|
content_id: contentId || null,
|
||||||
content_id: contentId,
|
playlist_id: playlistId || null,
|
||||||
|
layout_id: layoutId || null,
|
||||||
title: document.getElementById('schedTitle').value,
|
title: document.getElementById('schedTitle').value,
|
||||||
start_time: `${today}T${startTime}:00`,
|
start_time: `${today}T${startTime}:00`,
|
||||||
end_time: `${today}T${endTime}:00`,
|
end_time: `${today}T${endTime}:00`,
|
||||||
|
|
@ -176,6 +284,12 @@ export async function render(container) {
|
||||||
color: document.getElementById('schedColor').value,
|
color: document.getElementById('schedColor').value,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
data.group_id = groupSelect.value;
|
||||||
|
} else {
|
||||||
|
data.device_id = deviceSelect.value;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) });
|
await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||||
|
|
@ -183,7 +297,7 @@ export async function render(container) {
|
||||||
await API('/schedules', { method: 'POST', body: JSON.stringify(data) });
|
await API('/schedules', { method: 'POST', body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
document.getElementById('scheduleModal').style.display = 'none';
|
document.getElementById('scheduleModal').style.display = 'none';
|
||||||
showToast('Schedule saved', 'success');
|
showToast(t('schedule.toast.saved'), 'success');
|
||||||
loadCalendar();
|
loadCalendar();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue