Compare commits

...

128 commits

Author SHA1 Message Date
ScreenTinker 12fe0e43eb fix(zones): frontend assignment-flow picker + missed devices.js zone_id projection
Follow-up to 73f41c3 (server-side zone_id wiring). With this commit
the zone feature is verified working end-to-end: dashboard zone
picker renders correctly, zone_id saves and persists, the per-row
zone dropdown reflects the saved zone after reload, and a live
player run with computed-style inspection confirmed zone divs and
video elements size correctly within their geometry.

Frontend (device-detail.js, en.js):
- Add-content modal: zone picker slot now renders in all four states
  (has_zones / no_layout / fetch_failed / empty_layout) instead of
  silently vanishing when zones.length === 0. Informational rows
  match form-group styling and tell the user which control to use
  next. Closes the gate-4 symptom where 38-of-42 devices (no layout
  assigned) silently dropped zone_id on every assignment.
- Both /api/layouts/:id fetches (add modal, edit-path) now have
  !res.ok throw guards and surface failures via console.warn instead
  of swallowing them. The add modal additionally exposes the failure
  state to the user via the fetch_failed info row.
- Edit-path zone dropdown: replaced brittle DOM-scraping (reading
  the i18n label text and matching z.id.slice(0,8) against rendered
  meta HTML) with a data-current-zone-id attribute stashed at row
  render from a.zone_id. Removes the i18n-format coupling and gives
  exact UUID match.
- 3 new i18n keys in en.js (other locales fall back).

Server (devices.js):
- The GET /api/devices/:id assignments query had its own ad-hoc
  SELECT projection that was missed during the 73f41c3 site survey.
  Without pi.zone_id in this projection, loadDevice() got assignments
  without zone_id and the edit-path dropdown displayed "No zone"
  after every save+reload even though the DB had the correct value.
  One-line fix: add pi.zone_id, mirroring the ITEM_SELECT change in
  routes/assignments.js. Listed as the 8th site that 73f41c3's
  original survey missed; this commit closes it.

Verification:
- JS parse + en.js ESM load + server module load all clean.
- Live SQL probe: GET /api/devices/:id projection now returns zone_id
  for the test rows (id=31 zone_id=z-sh-1, id=54 zone_id=z-sh-2).
- Browser test by hand: zone picker renders per state, zone_id
  persists, reload shows saved zone, computed styles on rendered
  .zone divs match expected geometry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:26:58 -05:00
ScreenTinker 73f41c3288 fix(zone-id): restore zone-aware playlist_items wiring (issue #3 follow-up)
Phase 2 (assignments -> playlist_items) dropped zone_id during the
conversion: migrateAssignmentsToPlaylists INSERTed only (playlist_id,
content_id, widget_id, sort_order, duration_sec), and the new
playlist_items DDL omitted the zone_id column entirely. Every write
path on top of playlist_items inherited that omission - the
multi-zone layout assignment feature stopped working.

Frontend always sent zone_id correctly (device-detail.js:1015,1072
POST and PUT both include it; api.addAssignment and api.updateAssignment
forward the body verbatim). Server silently dropped it. The
assignments.js PUT route was the most direct evidence: it destructured
zone_id from req.body but never added it to the updates array.

Schema:
- schema.sql: add zone_id TEXT REFERENCES layout_zones(id) ON DELETE
  SET NULL to fresh-install DDL.
- database.js migrations[]: add idempotent ALTER TABLE for existing
  installs (the surrounding try/catch loop handles duplicate-column).

Backfill (new gated migration phase2_zone_id_backfill):
- Pre-migration snapshot copied to db/remote_display.pre-zone-id-
  backfill-<ts>.db (one-off for this migration; the general
  every-migration-snapshot framework is a separate concern, not built
  here).
- Best-effort UPDATE playlist_items.zone_id from surviving
  assignments rows via device.playlist_id + content_id/widget_id
  match, LIMIT 1 for the multi-match edge case.
- Regenerates published_snapshot for every published playlist so the
  JSON the player consumes carries zone_id going forward. Even with
  zero rows backfilled (the common case post-Phase-2 cleanup) this
  closes the snapshot-staleness gap.
- Stamps schema_migrations regardless so it won't re-run on next boot.
- On the live local DB: 0 playlist_items backfilled, 18
  published_snapshots regenerated. On the April 13 prod fixture
  (sandboxed copy): 0 backfilled, 7 regenerated. Expected and matches
  our pre-flight finding that assignments was effectively scrubbed of
  zone_id everywhere.

Route wiring (7 sites + 1 shared constant):
- assignments.js ITEM_SELECT: project pi.zone_id (read path so the
  frontend display at device-detail.js:500 surfaces the value).
- assignments.js POST INSERT: include zone_id column + value.
- assignments.js PUT: actually use the already-destructured zone_id
  in the updates allow-list. Treats undefined as "no change" so a PUT
  that omits zone_id leaves the existing value intact; any explicit
  value (including null) is written.
- assignments.js copy-to INSERT: preserve a.zone_id during
  device-to-device playlist copy.
- playlists.js buildSnapshotItems: project pi.zone_id so the snapshot
  JSON carries it. This is what the player's renderZones loop reads
  (player/index.html:1338 matches a.zone_id === zone.id).
- playlists.js discard-revert INSERT: restore zone_id from snapshot
  item on revert.

Out of scope (verified safe by SQL semantics + UI inspection):
- playlists.js POST item-add and PUT item-update in the playlist-detail
  surface: the UI there doesn't expose zone editing, and their SQL
  leaves zone_id NULL on insert / untouched on update. No regression.
- Other playlists.js SELECT projections (lines 141, 190, 240, 265, 334,
  379, 419) all use SELECT pi.* and auto-pick zone_id once the column
  exists.
- Kiosk-page assign at device-detail.js:1027 doesn't send zone_id;
  separate pre-existing gap, not part of this regression.

Tests (all local, no push, no prod deploy):
- Migration boot on live local DB: clean, idempotent (second boot
  skips the gated function).
- Migration boot on April 13 prod fixture (sandboxed copy at
  /tmp/zone-fix-fixtures/test-run.db): cleanly runs the full migration
  stack (multi-tenancy + 5 other phases the fixture predated) then
  the new zone_id backfill. Live local DB untouched.
- 8 SQL-level route behavior tests pass: INSERT stores zone_id, PUT
  changes/clears zone_id, ITEM_SELECT and buildSnapshotItems
  projections include zone_id, copy-to preserves, discard-revert
  restores from snapshot JSON, undefined zone_id in PUT leaves
  existing value intact.

Not verified: end-to-end multi-zone playback on a real device. The
SQL + snapshot JSON layer is correct (player consumes
playlist.find(a => a.zone_id === zone.id) and now gets the right
zone_id back from the snapshot); confirming render-to-correct-zone
on actual hardware is the next step before prod deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:20:44 -05:00
ScreenTinker cdd29d5e3b Merge pull request #6 from ChrisChrome: web player auto-connect
Adds a 5-second countdown on the player's Connect button when the
device is unpaired. Auto-clicks at 0 unless the user interacts with
the serverUrl field. Useful for headless kiosks where clicking is
painful. Includes a follow-up race-condition fix so the timer can't
double-fire if Connect is clicked manually during the countdown.
2026-05-14 16:12:58 -05:00
Christopher Cookman f6ef75549b Fix possible race condition in player auto-connect 2026-05-14 14:54:17 -06:00
Christopher Cookman 98e742c612
Merge branch 'screentinker:main' into main 2026-05-14 13:46:40 -06:00
Christopher Cookman d5e4e4d927 Feat: Web player auto connect
Add a simple 5 second countdown to the web player to get a code without interacting (for systems where interaction is a hassle, or impossible)
2026-05-14 13:46:19 -06:00
ScreenTinker 8439f2bf18 fix(landing): replace broken Custom pricing card with enterprise contact form
The "Custom" tier on the public pricing page was misrendering as a
better-than-Free tier: headline "Custom", price "Free", "Unlimited
devices/storage", "Get Started" button. Root cause is in DB data,
not markup - the 'enterprise' plan row has price_monthly=0 and
max_devices/storage=-1, and the dynamic render in landing.html maps
those to "Free" + "Unlimited" with the wrong CTA.

Fix: filter the 'enterprise' plan out of the public landing render
(client-side, in landing.html only) and replace it with a hardcoded
Enterprise / Custom marketing card whose Contact Us button opens a
new lead-capture modal.

The DB row itself stays - it is actively used elsewhere:
- auth.js: first user in SELF_HOSTED=true mode is assigned to it
- settings.js: white-label feature is gated on enterprise plan
- 1 user (the dev account) is currently assigned to it
- /api/subscription/plans is also consumed by billing.js, settings.js,
  admin.js (logged-in surfaces); they keep getting the full plan list.
The filter is scoped to landing.html's render only.

The in-app billing page renders the same plan with the same cosmetic
bug; that's a logged-in admin surface, out of scope for this commit.

Other 4 cards (Free, Starter, Pro, Business) unchanged.

Frontend (landing.html):
- Filter 'enterprise' from public render
- Hardcoded Enterprise / Custom card. Uses .price class with "Let's
  talk" + empty .yearly spacer to match Free card's vertical baseline
  so the feature list aligns with the paid cards' baselines.
- Modal markup, CSS (mirrored from frontend/css/main.css conventions
  since landing.html doesn't import main.css), and inline JS for
  open/close/submit/escape/background-click.
- Honeypot field: hidden 'fax_number' input (off-screen + aria-hidden
  + tabindex=-1). Picked over the obvious 'website' name to catch
  mid-tier bots that explicitly skip the well-known honeypot names.

Backend (new server/routes/contact.js):
- POST /api/contact/enterprise, public (unauthenticated)
- Rate limited 5/min/IP+path via the existing rateLimit middleware
- Honeypot check: populated fax_number returns 200 silently, no email
- Server-side validation: required fields, email format, screens
  1-100000, multi_tenant in {single,multi}, hosting in {hosted,self,
  unsure}. Length caps prevent textarea-bomb abuse.
- Sends via existing services/email.js (Microsoft Graph) to
  dan@bytetinker.net from the support@screentinker.com Graph sender.
- Log lines: "[contact] enterprise inquiry from EMAIL (COMPANY)
  delivered" or "[contact] honeypot triggered from IP; dropping".

Wired in server.js alongside other public routes (before requireAuth).

Build-time tests passed locally:
- Module loads, server boots clean
- Validation: missing fields, bad email, bad multi_tenant, bad
  hosting, screens out of range - all return 400 with the right
  error message
- Honeypot: populated fax_number returns 200 success, no email sent,
  log line confirms drop
- Rate limit: kicks in at 6th request within a minute as expected
- Real end-to-end send: one test submission delivered to
  dan@bytetinker.net via Graph (subject "[ScreenTinker] Enterprise
  inquiry: ScreenTinker Build Verification", body formatted with all
  fields). GRAPH_DEV_RESTRICT_TO was temporarily widened to include
  the recipient for the test and restored to dw5304@gmail.com
  immediately after.
- Card render order verified against live API: Free (outline,
  Get Started) | Starter | Pro (featured, Most Popular badge) |
  Business | Enterprise / Custom (Contact Us -> modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:52:24 -05:00
ScreenTinker f5ca26ae2d fix(socket): offline debounce + truthful single-device command feedback
Two dashboard-accuracy improvements for issue #3.

Disconnect debounce (5s):
- Brief transient flaps (Engine.IO ping miss, eviction-then-reconnect,
  Wi-Fi blip) no longer immediately flip the device to offline in the
  dashboard. Disconnect handler now defers the offline transition;
  register handlers cancel the pending timer if reconnect lands in
  window.
- Existing stale-disconnect guard kept as fast-path for the eviction
  case (no timer scheduled at all when the active heartbeat conn is
  already a different socket).
- Re-check at timer fire compares socketIds: aborts only if a
  GENUINELY DIFFERENT socket reclaimed the device. Just the closing
  socket's own (not-yet-cleaned-up) entry is treated as stale and
  proceeds with offline transition.
- Server-restart mid-grace is handled by the heartbeat checker safety
  net (existing component): any 'online' row with last_heartbeat
  older than heartbeatTimeout gets marked offline on next sweep.

Truthful single-device command feedback:
- dashboard:device-command handler now checks deviceNs.adapter.rooms
  for an active socket before emitting (matches the group-command
  route's pattern).
- If room is empty, falls through to commandQueue.queueCommand (lazy
  require - if commit C is reverted, MODULE_NOT_FOUND is cached and
  every subsequent call gets consistent queued=false behavior).
- Returns three-state ack to caller: { delivered, queued, reason }.
- Server log line was misleading - now logs 'Command delivered to
  device X' vs 'Command for offline device X (queued=true/false)'.

Frontend:
- sendCommand() takes optional callback. Without one, fires-and-forgets
  (no behavior change for non-wired callers). With one, uses Socket.IO
  .timeout(5000).emit so the callback always fires (ack or no_ack).
- Six device-detail command buttons wired to three-state toasts:
  reboot, shutdown, screen_off, screen_on, launch, update.
  - delivered: green/success toast (existing localized message)
  - queued: amber/warning toast (new generic message)
  - no_ack: red/error toast
  - fallback: red/error toast
- Two callers intentionally left fire-and-forget:
  - window._sendCmd (generic remote-overlay keypress/touch helper)
  - enable_system_capture (has its own visual state machine; out of
    scope for this commit)

Three new i18n keys (en.js only; other locales follow later):
- device.toast.command_queued
- device.toast.command_undeliverable
- device.toast.command_no_ack

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:11:40 -05:00
ScreenTinker 742d8c4b09 feat(socket): delivery queue for offline-device emits
Short-lived per-device queue covers the TV-flap window (issue #3):
when a device is mid-reconnect, prior code emitted to an empty room
and the event vanished. Now playlist-updates and commands targeting
an offline device are queued and flushed in order on the next
device:register for that device_id.

server/lib/command-queue.js (new):
- pendingPlaylistUpdate: per-device marker (rebuild via builder on
  flush -> always fresh DB state, no stale snapshots)
- pendingCommands: per-device Map<type, payload> with last-of-type
  dedup (most recent screen_off wins)
- TTL via COMMAND_QUEUE_TTL_MS env (default 30000)
- Active sweep every 30s prunes expired entries

Memory bounds: ~6 entries per device worst case (1 playlist marker
+ 5 command types), unref'd sweep timer.

Wired emit sites (8 total; the four direct socket.emit calls in
deviceSocket register handlers are intentionally NOT queued because
the socket is alive by definition at those points):
- server/routes/video-walls.js   (pushWallPayloadToDevice)
- server/routes/device-groups.js (pushPlaylistToDevice)
- server/routes/content.js       (content-delete fan-out)
- server/routes/playlists.js     (pushToDevices + assign)
- server/services/scheduler.js   (scheduled rotations)
- server/ws/deviceSocket.js x2   (wall leader reclaim/reassign)

server/ws/deviceSocket.js register paths now call flushQueue after
heartbeat.registerConnection + socket.join. Existing
socket.emit('device:playlist-update', ...) lines kept - they send
the initial state on register; the flush replays any queued events.
Player's handlePlaylistUpdate fingerprint check dedupes the
overlap.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:06:43 -05:00
ScreenTinker 3da49ec79c chore(config): env-configurable heartbeat timing
Make HEARTBEAT_INTERVAL and HEARTBEAT_TIMEOUT env-tunable so
self-hosters with slow/jittery networks don't have to edit
config.js (issue #3 reporter did exactly this to confirm the
diagnosis). Defaults unchanged at 10000ms / 45000ms so existing
deployments keep current behavior.

Same parseInt(env) || default pattern as PORT/HTTPS_PORT/PING_*.
README env table extended.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:03:02 -05:00
ScreenTinker 1aee4f2d5b fix(socket): raise Engine.IO ping/pong + prefer WebSocket transport
Connection-stability layer for issue #3. LG webOS WebKit (and other
TV-grade clients) miss Engine.IO pongs under decode load with the
Socket.IO defaults of 25s ping / 20s timeout, causing spurious
transport drops and a connect/reconnect/evict/disconnect loop on
the device. Default polling-first transport adds another fragility
layer via the polling->WebSocket upgrade dance.

- pingInterval / pingTimeout default to 30000 / 30000 (worst-case
  dead-socket detection 60s, up from ~45s). Both env-configurable
  via PING_INTERVAL / PING_TIMEOUT.
- Player Socket.IO client: transports: ['websocket', 'polling'].
  Tries WebSocket first; falls back to polling on the same connect
  attempt if WebSocket fails. Polling fallback preserved for
  firewall-restricted networks.

App-level heartbeat checker is unchanged and remains the safety net
for clients that miss the transport-level ping/pong window.

Tradeoffs documented in inline comments. README env table extended
with PING_INTERVAL and PING_TIMEOUT rows.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:02:34 -05:00
ScreenTinker c4ac81c7a6 chore(discord): update Discord invite link
Old invite replaced with current permanent invite across README,
landing page, and anywhere else it appeared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:26:20 -05:00
ScreenTinker 1e23335356 fix(player): graceful handling when displayed content is removed
Deleting a content asset that was actively displayed on screens
caused affected players to go black and never recover; deleting an
actively-playing video also failed to stop playback (audio kept
going). Root cause: handlePlaylistUpdate never tore down the current
media element and could drive currentIndex to NaN when a late
onended fired during the playlist swap.

- Add teardownCurrentMedia() - pause, clear src, .load() to actually
  release the decoder and kill audio; null event handlers to prevent
  late onended races
- handlePlaylistUpdate: preserve continuity - if the playing item
  survives the update keep it playing, otherwise walk forward from
  the old position to the next surviving item; empty playlist tears
  down to waiting state
- Guard playCurrentItem against empty playlist / non-finite index
- Remove dead device:content-delete socket handler (never emitted)

Resolves #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:17:40 -05:00
ScreenTinker 3dfec5d2f9 feat(config): DISABLE_HOMEPAGE env var to redirect / to the app
Self-hosters running internal-only deployments don't need the
marketing homepage. With DISABLE_HOMEPAGE=true, requests to /
302-redirect to /app instead of serving the landing page.
Unset/false preserves current behavior.

Requested via discord feedback.
2026-05-14 12:03:29 -05:00
ScreenTinker 4b2a5c51ea docs(readme): comprehensive review - multi-tenancy, current features, tech stack, deployment, contribution
The repo has been shipping multiple features ahead of the README (12+
commits today alone). This is a catch-up pass to bring the docs current.

Key additions / updates:
- Multi-tenancy architecture (orgs > workspaces > members + roles)
- Auto-migration on boot
- Teams currently consolidated into workspace_members
- Tech stack reference (Node 20.6+, msal-node, etc.)
- Deployment env vars (full reference table)
- Local dev setup with .env approach
- Contribution/Discord/issue reporting

No code changes - docs only.
2026-05-12 18:57:41 -05:00
ScreenTinker f4d2a0330b chore(email): log successful sends for observability
Previously sendEmail() only logged on error/suppression paths; success
was silent. After prod deploy of c71c401 it was unclear whether the
first alert tick had actually delivered email or not - the answer was
yes but had to be derived from 'no error log + recipient query showed
matching device'. Add a log line on success so future observability
doesn't require detective work.
2026-05-12 18:34:19 -05:00
ScreenTinker dddc48440b docs(readme): document Microsoft Graph email setup + dev restrict + spam protections
Replaces the stub EMAIL_WEBHOOK_URL row with the real 5-variable
GRAPH_* config table, Azure AD app registration steps (single-tenant
+ Mail.Send application permission + admin consent), the local-dev
stdout-fallback behavior when unconfigured, the optional
GRAPH_DEV_RESTRICT_TO allow-list for safe development against fresh
prod DB clones, and a brief enumeration of the alert spam protections
(2h dedup, 24h long-offline cutoff, sequential send pattern, per-user
email_alerts opt-out).

Pairs with c71c401 which shipped the implementation.
2026-05-12 18:34:08 -05:00
ScreenTinker c71c4016ca feat(email): Microsoft Graph send + alert spam protection + preferences UI
Replaces the unused EMAIL_WEBHOOK_URL stub with a real Microsoft Graph
Mail.Send pipeline via @azure/msal-node client-credentials flow. Prior
state on prod: every alert email was logged to journalctl and never
sent (21 fallback log lines per hour for the chronic-offline devices).

Four coordinated changes shipped as one commit since they're all part
of making email delivery actually work responsibly:

1. services/email.js (NEW): Graph send via plain HTTPS (no SDK), in-memory
   MSAL token cache (refresh 60s pre-expiry), graceful stdout fallback
   when GRAPH_* env vars absent. Drop-in replacement for the old webhook.

2. services/alerts.js refactored: sequential await around sendEmail (was
   parallel fire-and-forget; first run hit Graph's MailboxConcurrency 429
   ApplicationThrottled on a 30-device backlog). Sequential at ~250ms per
   send takes 5-8s for the full backlog, well within the 60s tick. Also:
   24h long-offline cutoff to stop nagging about chronic-offline devices
   (the 20,000+ minute ones); 2-hour dedup window (was 1h) via a generic
   shouldSendAlert(type, id, windowMs) helper that future alert types
   (payment_failed, plan_limit_hit, etc.) can reuse.

3. Preferences UI: single checkbox in settings.js Account section bound
   to users.email_alerts. Saved via the existing Save Profile button. PUT
   /api/auth/me extended to accept email_alerts. requireAuth middleware
   SELECT now includes email_alerts so it propagates via req.user.

4. Dev safety net: GRAPH_DEV_RESTRICT_TO env var as an allow-list. When
   set, only listed recipients reach Graph; everyone else is suppressed
   with a log line. Prevents local dev (which often runs against fresh
   prod DB copies) from accidentally emailing real prod users. UNSET on
   prod systemd unit so production fans out normally.

Also: package.json scripts use --env-file-if-exists=.env so local dev
picks up .env automatically (Node 20.6+ built-in, no dotenv dep). Prod
runs via systemd ExecStart and is unaffected. server/.gitignore added
to keep .env out of git.

Smoke verified end-to-end:
- Sequential send pattern verified (a prior parallel-send tick had hit
  Graph's MailboxConcurrency 429 on 30 simultaneous sends; sequential
  at ~250ms each completes the same backlog without throttling)
- 24h cutoff silenced 20/21 prod devices on the next tick
- Dev restrict suppressed the 1 within-24h send
- User-preference toggle flipped via UI -> DB -> alert path silently
  continued before reaching even the suppression log
2026-05-12 18:16:40 -05:00
ScreenTinker f115cb454f style(switcher): widen dropdown, tighten rows, prevent org-line wrap
Visual polish to match the new device-count info in ce332ea. The
sidebar-constrained 188px dropdown was too narrow once a second
info chunk ('. N devices') joined the org name on the muted subtitle
line - long names like 'BRASA SALA\'s organization . 2 devices'
wrapped, doubling row height for half the dropdown.

Width: was left:0 + right:0 (= sidebar content width 188px). Now
left:0 + min-width:280px + max-width:360px. Detaches from the
sidebar (which is z-indexed) and extends into the main content area;
the max bound prevents indefinite sprawl on pathological org names.

Row height: padding 10px 12px -> 8px 12px; ws-org margin-top 2px -> 1px.
~58px per row -> ~46px. Less density-heavy at the platform_admin scale
(37 rows visible).

Menu padding: 4px 0 added on the panel so the first/last rows don't
sit flush against the panel border (fixes the 'first row clipped'
visual the tighter rows would otherwise still show).

Max-height: 320px -> 360px. Modest bump now that rows are shorter -
shows ~7 rows at once vs ~5 before.

.ws-org gains white-space:nowrap + overflow:hidden + text-overflow:ellipsis
so the org+count line truncates instead of wrapping. The 360px max-width
sets the truncation threshold.
2026-05-12 14:04:56 -05:00
ScreenTinker ce332ead67 feat(switcher): per-workspace device count in dropdown rows
/me's accessible_workspaces query gains a device_count field via a
correlated subquery on workspaces.id - WHERE workspace_id = w.id
strictly excludes the unclaimed pair-pool (workspace_id IS NULL fails
equality). Added to both query branches (platform_admin LEFT JOIN and
regular INNER JOIN); microseconds per row at current scale (~37 rows
worst case), not optimizing.

Frontend appends the count to the muted org-name line with a middle-dot
separator: 'Acme Studios . 2 devices'. Singular/plural respected via the
existing tn() helper convention; 'No devices' for empty workspaces. New
formatResourceCount(n, keyBase, zeroKey) helper is generic so the same
shape can wire users/playlists/schedules counts later without refactor.

New i18n keys: switcher.devices_count_one, switcher.devices_count_other,
switcher.no_devices. Added to en.js only; other locales fall back to en
via the existing lookup chain (verified in i18n.js:19).

API smoke verified: switcher-test sees Studio A=2, Field Crew=2;
dw5304 (platform_admin) sees all 37 workspaces with their device counts
varying 0-4; single-workspace zero-device user (geoff.case) sees 0.
2026-05-12 14:04:21 -05:00
ScreenTinker 42966da973 feat(teams): temporarily disable Teams API while feature is redesigned
Teams in its pre-Workspaces form is being paused while the feature is
redesigned as a user-grouping primitive within the new Workspaces
architecture. The original Teams data model had no workspace-awareness
and was effectively non-functional after Phase 2.2 (every route migrated
away from team_id), but the UI remained reachable and allowed users to
accumulate orphan data while believing they were configuring access
control.

Hide the Teams sidebar nav entry to prevent new entries to the UI.
/api/teams now returns 503 Service Unavailable with a 'feature
redesign in progress' message. Existing teams/team_members/team_invites
table data is preserved indefinitely for forward migration to the
future teams design.

Bonus: requireAuth middleware fires before the catch-all so unauthenticated
callers see the standard 401 instead of the 503 redesign message - avoids
exposing the 'feature being redesigned' signal to unauthenticated probes
or fingerprint scanners.
2026-05-12 13:30:55 -05:00
ScreenTinker 766f02ae5d docs(upload): correct misleading defParamCharset comment
The previous comment claimed defParamCharset:'utf8' fixed multipart
filename header decoding. It doesn't - that option only fires for the
RFC 5987 encoded filename*=utf-8''... form, which clients rarely send.
The actual UTF-8 recovery happens in the storage.filename callback
(added in d679ca8) via Buffer.from(name,'latin1').toString('utf8').
The option is kept set for the rare RFC 5987 case but the comment no
longer overclaims what it does.
2026-05-12 11:57:54 -05:00
ScreenTinker d679ca8d14 fix(upload): re-decode multipart filename header from latin1 to utf8 in multer storage callback
busboy reads the Content-Disposition filename="..." header value as
latin1 by default - even with defParamCharset:'utf8' set, that option
only applies to RFC 5987 encoded filename*=... params, which most
clients (browsers, curl, programmatic HTTP) don't send. Modern clients
send raw UTF-8 bytes for non-ASCII filenames; busboy interprets those
bytes one-byte-per-char as latin1, producing a JS string like 'A-tilde
+ quarter-mark' for 'u-umlaut'. JS then re-encodes that string as UTF-8
on the way to SQLite, yielding 4 bytes (c3 83 c2 bc) for what should be
2 bytes (c3 bc). Classic double-encoding mojibake - shows up in the UI
as 'BegrA-tilde...' instead of 'Begru-umlaut...'.

Fix: in the multer filename callback, re-decode file.originalname from
latin1 to utf8 to recover the original byte sequence. Mutating
originalname here propagates to every route handler reading
req.file.originalname (POST /, PUT /:id/replace, and any future upload
route using the same middleware).

This is the actual visible-mojibake bug semetra22 reported. The prior
commit b677752 (NFC normalize in safeFilename) handles a separate but
related case (macOS NFD clients sending decomposed forms); both fixes
compose correctly - latin1->utf8 first restores the byte sequence,
then NFC normalize collapses NFD into composed form.

Smoke verified by sending raw UTF-8 multipart from a Node https client
(no shell escaping). NFC input 'Begru-umlaut-essungsscreens.jpg' with
bytes c3bc c39f arrives clean (was c383c2bc c383c29f before). NFD input
'u + combining diaeresis' arrives as composed NFC c3bc after both fixes.
2026-05-12 11:55:55 -05:00
ScreenTinker b67775283b fix(server): NFC normalize user-facing filenames in safeFilename
Single line change to safeFilename() in routes/content.js: add
.normalize('NFC') before sanitizeString. Covers all 4 user-facing
filename storage sites (POST /, POST /remote, POST /embed, PUT /:id
rename) since they all flow through safeFilename.

Fixes macOS NFD vs Linux NFC mismatch on filename storage that mangled
umlauts (ae/oe/ue/ss) in displayed filenames. macOS clients send
NFD-decomposed names (e.g. 'u' + combining diaeresis U+0308 instead of
the precomposed U+00FC); Linux + most renderers expect NFC. Without
this, names like 'Begruessungsscreens.jpg' arrive with the combining
char floating and display as mojibake.

Reported by semetra22 in Discord with extraordinarily good debugging
narrowing (rename works, upload doesn't = bug is in upload path).
Single-point fix at the convergence of all user-facing filename flows.

Existing NFD-mangled rows in DB not backfilled; users can re-upload or
rename to repair. Optional one-time UPDATE backfill captured as follow-up
in handoff doc.

Smoke verified by invoking safeFilename directly on NFD + NFC inputs of
'Begruessungsscreens.jpg' - both produce identical NFC-normalized bytes
(42656772c3bcc39f756e677373637265656e732e6a7067).
2026-05-12 11:51:34 -05:00
ScreenTinker 1e142d9644 fix(frontend): playlists.js thumbnail path uses API endpoint instead of legacy direct path
2 line substitutions in frontend/js/views/playlists.js: switches
/uploads/thumbnails/{filename} -> /api/content/{id}/thumbnail at both
the playlist editor render (line 293) and the Add-to-playlist content
picker (line 543). Brings playlist view inline with widgets.js,
content-library.js, and device-detail.js which already use the API
path.

Side benefit: thumbnails now go through the workspace-aware permission
check in content.js's /api/content/:id/thumbnail handler (checkContentRead)
instead of unauthenticated static file serve at /uploads/thumbnails/.

Reported by semetra22 in Discord ('All images retrieved via the API
display correctly, but in the playlists, the images are fetched
directly from /uploads/thumbnails/filename and do not display properly').
2026-05-12 11:44:30 -05:00
ScreenTinker fc29843035 feat(socket): Phase 2.3 workspace-scoped dashboard socket rooms + per-command permission gates. Dashboard namespace was previously a flat broadcast - every connected dashboard received every device's status/screenshot/playback events platform-wide (foreign device names + IPs included). Inbound socket commands gated by a legacy admin/superadmin role check that was dead code post-Phase-1 rename.
Fix: at connect, enumerate the user's accessible workspace_ids (direct workspace_members + org_owner/admin paths + platform_admin 'all') via new accessibleWorkspaceIds() helper in lib/tenancy.js; socket.join one room per workspace. All 12 dashboardNs.emit sites across deviceSocket / heartbeat / server.js / devices route / video-walls route now route via dashboardNs.to(workspaceRoom(...)).emit() with the workspace looked up from the relevant device or wall. New lib/socket-rooms.js holds the helpers and breaks a circular dependency (dashboardSocket already requires heartbeat, so heartbeat can't require dashboardSocket).

Inbound 6 commands rewired to canActOnDevice(socket, deviceId, tier): request-screenshot is read tier (workspace_viewer+); remote-touch/key/start/stop and device-command are write tier (workspace_editor+). Platform_admin and org_owner/admin always pass via actingAs. Legacy admin/superadmin branch dropped.

Lifecycle note: workspace-switch already calls window.location.reload (Phase 3 switcher), which forces a fresh socket with updated memberships - no per-emit re-evaluation needed.

Smoke tested with 3 simultaneous socket.io-client connections (switcher-test, swninja, dw5304 platform_admin) + direct canActOnDevice invocation for 6 user/device/tier combinations. All 9 outbound isolation cells and all 6 permission gates pass. Fixture mutation: switcher-test's Field Crew membership flipped from workspace_editor to workspace_viewer to exercise the read/write tier split in one login.
2026-05-12 11:34:24 -05:00
ScreenTinker 56da64d0cd feat(workspaces): rename via switcher dropdown - new PATCH /api/workspaces/:id route, per-row pencil affordance in switcher (visible only when caller can_admin), small rename modal with name + slug fields, validation (name <=80 chars, slug ^[a-z0-9]+(?:-[a-z0-9]+)*$ <=60 chars, blank slug -> NULL), 409 on per-org slug collision. Permission gating via new canAdminWorkspace(db, user, ws) helper in lib/permissions.js - reused-ready for future Phase 3 admin actions. /me query now joins organization_members to compute can_admin per accessible_workspaces entry. Drive-by fixes surfaced: (1) activityLogger method filter was missing PATCH, added; (2) routes that operate on a target workspace by URL param need to stamp req.workspaceId from the param so activityLogger captures the right tenant attribution - documented in the route. Smoke fixture: switcher-test@local.test is workspace_admin of Studio A and workspace_editor of Field Crew (no org_owner) so the can_admin true/false split is exercised in one login. 2026-05-12 11:06:55 -05:00
ScreenTinker 0c91390e56 fix(frontend): workspace switcher (Phase 3 MVP) + SW network-first migration + platform_admin accessible_workspaces expansion + static render CSS cleanup. The switcher adds a sidebar dropdown for users who are members of multiple workspaces, renders as static text with a 'Workspace' label for single-workspace users, and muted 'No workspace' for zero. Uses existing /api/auth/me's accessible_workspaces and POST /api/auth/switch-workspace endpoints. Platform admin / superadmin users now see all workspaces in accessible_workspaces (closing the known regression from 88d91b1) via a LEFT JOIN that preserves workspace_role semantics (null = acting-as, role string = direct member). No cap on the list - deliberate for now, revisit at 50+ workspaces. SW fix bumps rd-admin-v1 -> rd-admin-v2 and switches fetch strategy from cache-first to network-first so the server's existing Cache-Control: no-cache + ETag headers actually get respected; preserves offline fallback. Static render CSS drops the bordered-box chrome that was making single-workspace users think the static text was clickable. Includes test fixture user switcher-test@local.test (credentials in fixture SQL header). Surfaced by semetra22 / Discord report about 'screens jumbled up' post-migration; root cause was the missing workspace switcher UI making devices in non-active workspaces appear missing. 2026-05-12 10:55:09 -05:00
ScreenTinker bc445a0a7c fix(boot): auto-apply Phase 1 multi-tenancy migration on startup if not yet applied; refactor scripts/migrate-multitenancy.js to expose runMigration() with CLI wrapper preserved; pre-migration snapshot to db/remote_display.pre-migration-<timestamp>.db; belt-and-suspenders guards on migrateFolderWorkspaceIds + backfillActivityLogWorkspace so the inline backfills skip cleanly if workspaces table absent. Fixes startup crash on pre-multi-tenancy installs (semetra22 / Discord report) where 'npm start' after pulling latest hit migrateFolderWorkspaceIds and crashed with 'no such table: workspaces'. Self-hosters now get an automatic upgrade path without needing to run 'node scripts/migrate-multitenancy.js' manually. 2026-05-12 08:22:47 -05:00
ScreenTinker 92e26aafcb fix(server): mount activityLogger middleware before workspace routes so POST/PUT/DELETE actually get logged - pre-existing bug, the middleware was a no-op for every API route because route mounts came first in server.js (L305 routes vs L368 middleware). Zero double-log risk: the one inline logActivity caller at routes/auth.js:452 is on /api/auth which mounts before the new middleware position. activity_log row growth will pick up significantly going forward (pruneActivityLog 90-day retention already handles the bound). Surfaced by Phase 2.2 migration discipline. 2026-05-11 23:17:28 -05:00
ScreenTinker 88d91b10af activity_log: stop the bleeding - writer-leak fix on 3 sites (activityLogger middleware, alert service, login route) + one-time backfill of 548 NULL-workspace rows via device.workspace_id or workspace_members lookup; activity.js route migration deferred to its own slice tomorrow.
KNOWN REGRESSION (Phase 3 fix): platform_admin / superadmin no longer has cross-workspace 'see everything' view. Every route migrated tonight (2.2a-2.2m) deliberately removed the role-based bypass per design doc - cross-workspace visibility will come via dedicated admin endpoints in Phase 3, not magic role bypasses. Until Phase 3 ships, platform admins must switch-workspace to see other workspaces' data.
2026-05-11 23:14:06 -05:00
ScreenTinker f88805f36d fix(schedule): add delete button to schedule edit modal so schedules can be removed from the UI (DELETE /api/schedules/:id already existed) 2026-05-11 23:05:03 -05:00
ScreenTinker 0b9aa56e75 Phase 2.2m: schedules.js scoped to workspace_id; schedule.workspace_id inherited from target (device/group); fixes 6 pre-existing cross-tenant leaks (POST content/widget/layout/playlist accepted with no check, PUT verifyOwnership rewrite across all 6 polymorphic targets) 2026-05-11 23:03:54 -05:00
ScreenTinker a77ab365dd fix(dashboard): selection bar surfaces 'pick 1 more' hint when 1 display selected so the disabled Create Video Wall button isn't silently unresponsive 2026-05-11 22:56:15 -05:00
ScreenTinker b6c90d3421 Phase 2.2l: video-walls.js scoped to workspace_id; fixes 4 pre-existing cross-tenant leaks (POST playlist_id, PUT playlist/content/leader_device, PUT /devices, PUT /content); drops dead admin/team_members code paths left over from Phase 2.1 role rename; team_id column noted for future cleanup 2026-05-11 22:56:08 -05:00
ScreenTinker 52e68ac490 fix(playlists): POST /publish returns items with pi.id so post-publish delete works without refresh; future refactor candidate to share SELECT shape with GET /:id 2026-05-11 22:28:59 -05:00
ScreenTinker 833e84578e Phase 2.2k: playlists.js scoped to workspace_id; fixes 3 pre-existing cross-tenant leaks (content add, widget add with NO existing check, device assign); content.js snapshot-scrub bundle; status.js export endpoint deferred to dedicated slice 2026-05-11 22:22:18 -05:00
ScreenTinker 90fe6e0f9a Phase 2.2j: assignments.js scoped to workspace_id via playlist.workspace_id; fixes 3 pre-existing cross-tenant leaks (content add, widget add with NO existing check, copy-to cross-workspace); ensureDevicePlaylist loop-closer; status.js playlist INSERT bundle 2026-05-11 22:12:13 -05:00
ScreenTinker e17538b186 Phase 2.2i: device-groups.js scoped to workspace_id; fixes 3 pre-existing cross-tenant leaks (group device add, bulk content assign, bulk playlist assign); pre-emptive workspace_id stamp on ensureDevicePlaylist helper 2026-05-11 21:58:13 -05:00
ScreenTinker c7f9d014ca Phase 2.2h: layouts.js scoped to workspace_id; templates via is_template path; fixes pre-existing PUT /device/:deviceId cross-tenant layout-assignment leak 2026-05-11 21:45:28 -05:00
ScreenTinker f17d757ba0 Phase 2.2g: reports.js scoped to workspace_id; fixes pre-existing /export and /uptime cross-tenant leaks 2026-05-11 21:36:54 -05:00
ScreenTinker 0d642e4d80 Phase 2.2f: white-label.js scoped to workspace_id; requireWorkspaceAdmin gate; status.js bundle 2026-05-11 21:30:22 -05:00
ScreenTinker 806c931e43 Phase 2.2e: kiosk.js scoped to workspace_id; import kiosk INSERT bundled 2026-05-11 21:20:18 -05:00
ScreenTinker efce13e05d Phase 2.2d: widgets.js scoped to workspace_id; import + widget-reference defense bundled 2026-05-11 21:13:51 -05:00
ScreenTinker a4610e8d0d Phase 2.2c: content_folders gets workspace_id (schema + backfill); folders.js scoped; content.js folder-move strict same-workspace 2026-05-11 21:04:03 -05:00
ScreenTinker a5dbc5d665 Phase 2.2b: content.js + status.js import scoped to workspace_id; uploads stamp workspace_id 2026-05-11 20:50:25 -05:00
ScreenTinker afd2a10df2 Phase 2.2a: devices.js scoped to workspace_id; pair flow stamps workspace_id on claim 2026-05-11 20:33:58 -05:00
ScreenTinker ac3eb74122 i18n: register Italian locale in language registry (followup to PR #2) 2026-05-11 20:05:09 -05:00
ScreenTinker 2954fd1a84 Phase 2.1: tenancy middleware, permission helpers, JWT workspace context, frontend + backend role-rename compat 2026-05-11 20:02:00 -05:00
ScreenTinker d8492f3720 Phase 1: multi-tenancy design doc + migration scripts 2026-05-11 19:37:15 -05:00
screentinker fc84ab8d8b
Merge pull request #2 from albanobattistella/patch-1
Add Italian Translation
2026-05-11 19:24:07 -05:00
albanobattistella 9f1ca2e177
Add Italian Translation 2026-05-09 15:58:48 +02:00
ScreenTinker 45a6800621 fix: log real client IPs through Cloudflare instead of CF edge
Express's req.ip was resolving to a Cloudflare edge address (e.g.
172.70.x.x) for any request fronted by Cloudflare, because trust proxy
was set to '1' — that trusts the immediate hop, which IS Cloudflare.
All activity_log rows from API paths captured the proxy, not the
client. The WebSocket path was unaffected and recorded the real IP.

Two layers of defense:

1. trust proxy now lists Cloudflare's published v4 + v6 ranges plus
   loopback / linklocal / uniquelocal (config/cloudflareIps.js). With
   this list req.ip resolves to the original client when fronted by
   CF, and X-Forwarded-For from any non-trusted source is ignored —
   so the value can't be spoofed.

2. New getClientIp(req) helper in services/activity.js prefers the
   CF-Connecting-IP header but only honors it when the immediate TCP
   peer is itself a trusted address. Same gate as trust proxy, so a
   visitor who hits the origin directly with a forged header is
   logged at their real address.

Routed all five activity-log call sites (auth login success/failure,
admin password reset, generic activityLogger middleware, and the
in-memory rate-limiter key) through the helper.

Logging-only change. No schema changes. Existing rows are not
modified — fix applies to new entries going forward.

Verified locally:
- Bare loopback hit logs 127.0.0.1 (not a proxy address).
- Helper unit cases including an untrusted peer (203.0.113.7) sending
  a forged CF-Connecting-IP correctly fall back to the real peer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:26:37 -05:00
ScreenTinker 2068bc8833 Video walls: free-form canvas editor, leader-driven sync, group dissolve, progress bars
Wall editor: replaces the small grid with a Figma-style pan/zoom canvas. Each
display is a rectangle that can be dragged/resized to match its physical
arrangement; a separate semi-transparent player rect overlays the screens and
defines what content plays where. Drag empty space to pan, wheel to zoom,
"Center" button auto-fits content. Per-rect numeric x/y/w/h panel; arrow keys
nudge by 1px (10px with shift). Negative coordinates supported for screens
offset above/left of the origin. Coords rounded to integers on save.

Wall rendering: each device receives screen_rect + player_rect, maps the
player into its viewport with vw/vh and object-fit:fill so vertical position
of every source pixel is identical across devices that share viewport height.
Leader emits wall:sync at 4Hz with sent_at timestamp; followers apply
latency-adjusted target and use playbackRate ±3% for sub-300ms drift,
hard-seek for >300ms. Followers stay muted; leader unmutes via gesture with
AudioContext priming and pause+play retry to bypass Firefox autoplay.
"Tap to enable audio" overlay as a final fallback.

Reconnect handling: server re-evaluates leader on device:register so the
top-left tile reclaims leadership when it returns. Followers emit
wall:sync-request on entering wall mode (incl. reconnect) so they snap to
position immediately instead of drifting until the next periodic tick.

Group dissolve: removing a device from its last group clears its playlist
to mirror wall-leave semantics. Leaving a group with playlists on remaining
groups inherits the next group's playlist.

Dashboard: walls render as their own card section (hidden the device cards
they contain). Multi-select checkboxes on cards + "Create Video Wall" toolbar
action that creates the wall, removes devices from groups, and opens the
editor. dashboard:wall-changed broadcast triggers live re-render. Per-card
playback progress bar driven by play_start events forwarded from devices.

Security: PUT /walls/:id/devices verifies caller owns each device (or has
team-owner access via the widgets pattern), preventing cross-tenant device
takeover. wall:sync and wall:sync-request validate that the sending device
is a member of the named wall; relay re-stamps device_id with currentDeviceId
so clients can't spoof or shadow-exclude peers.

Schema: video_walls += player_x/y/width/height, playlist_id;
video_wall_devices += canvas_x/y/width/height. All idempotent migrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:11:16 -05:00
ScreenTinker 388e9e6ab8 Admin password reset + widget visibility fix
Password reset for other users:
- New PUT /api/auth/users/:id/password endpoint
- Superadmin can reset any local user; admin can reset role=user
  members of teams they own only (cannot reset other admins or
  superadmins, cannot self-reset — that goes through PUT /me with
  current_password)
- OAuth users are excluded (no password to reset)
- Rate-limited 20 req/min/IP to cap blast radius if an admin session
  is compromised
- Explicit audit log entry "password_reset_for_user / target: <email>"
  on every reset; activity logger's summarizeAction never reads the
  password field, so the password value is not stored anywhere

Frontend: Reset Password button in the Admin user table and Settings
> User Management table. Shown only for local-auth users that aren't
the current user; prompts for an 8+ char password.

Widgets visibility fix:
- routes/widgets.js had `const isAdmin = req.user.role === 'superadmin'`
  which mislabeled superadmin as admin and silently restricted real
  admins (role=admin) to seeing only their own widgets. Now matches
  /auth/users behavior: superadmin sees all, admin sees own + public
  + widgets owned by members of teams they own, user sees own + public.

7 new i18n keys (admin.reset_password, admin.prompt_reset_password,
admin.toast.password_min_8, admin.toast.password_reset, and the
matching settings.user.* / settings.toast.* trio). 1024 keys total,
parity 100% across en/es/fr/de/pt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:45:25 -05:00
ScreenTinker dec56506f9 i18n: add Android localized string resources
Adds values-{es,fr,de,pt,hi}/strings.xml mirroring values/strings.xml.
Two strings: app_name (kept as RemoteDisplay across all locales) and
the accessibility service description (translated).

Hindi is a copy of English by design — same approach as the web's
empty hi.js. Native review can replace the en text in place once
done; Android picks the right file based on device language.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:20:14 -05:00
ScreenTinker aebaacf2c1 i18n batch 7: index.html modal + player overlay
- Add-Display modal in index.html: marked translatable elements with
  data-i18n / data-i18n-placeholder / data-i18n-html attributes
- app.js: translateStaticDom() walks data-i18n* on init and on every
  language-changed event so static HTML stays in sync
- server/player/index.html: standalone player gets its own inline
  PLAYER_I18N table (en/es/fr/de/pt) with a tiny _t() helper. Reads
  rd_lang from localStorage (set by dashboard) so the player picks up
  the same language. Translates info overlay, setup screen, and
  status messages.
- 1018 keys total in dashboard locales, parity 100%.

This completes the wiring; Android resources are next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:19:06 -05:00
ScreenTinker 6d6f901ef4 i18n batch 6: wire teams + activity + help (~62 keys)
- teams.js: list, detail with members + shared devices, invite/role
  controls, all toasts
- activity.js: page chrome, action verb/noun mapping translated through
  t() so the audit log reads naturally in each language
- help.js: page chrome translated; guides and FAQ body content kept
  in English with a comment explaining why (machine-translated docs
  read worse than English source)
- 1008 keys total, parity 100% across en/es/fr/de/pt

All 16 dashboard views now use t(). index.html modal, player overlay,
and Android resources still pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:16:21 -05:00
ScreenTinker 7a17bb5079 i18n batch 5: wire layout-editor + video-wall + billing (~85 keys)
- layout-editor.js: list with templates + custom, zone editor with
  drag/resize and properties panel
- video-wall.js: list with grid preview, editor with grid config,
  bezel inputs, drag-and-drop device placement
- billing.js: current plan card, plans grid with checkout buttons,
  Stripe portal integration
- 943 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:13:38 -05:00
ScreenTinker f4a81d7be2 i18n batch 4: wire schedule + reports + kiosk (~95 keys)
- schedule.js: weekly calendar, add/edit modal with target/recurrence,
  hour labels, day-of-week headers
- reports.js: filters, summary cards, top-content + by-device tables,
  daily/hourly charts
- kiosk.js: list + editor, page settings, style controls, button list
  with action types
- 838 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:09:32 -05:00
ScreenTinker 457a2e4dd4 i18n batch 3b: wire onboarding.js + admin.js (~84 keys)
- Onboarding: 5-step wizard (welcome, get player, pair, upload, done)
  with translated step titles, content, prompts, error messages
- Admin: superadmin user table, plans, system info, role/plan
  selectors, delete confirms
- 750 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:04:23 -05:00
ScreenTinker 04891bccee i18n batch 3a: wire playlists.js (~65 keys)
- List view: tags, item/display pluralization, empty state, load errors
- Detail view: draft banner, inline rename/description, items list
- Drag-reorder + up/down buttons, duration editor
- Add-item modal with content/widgets tabs and search
- 671 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:00:52 -05:00
ScreenTinker 103803fb92 i18n batch 2b: wire designer.js (~80 keys)
- All 12 element types (text, heading, image, video, clock, date,
  weather, ticker, shape, qr, countdown, webpage)
- Background swatches, properties panel, layers list
- Translated prompts for video/weather/RSS/QR/countdown/webpage URLs
- Toasts for publish, export, load, invalid file
- 612 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:57:12 -05:00
ScreenTinker 0743901e48 i18n batch 2a: wire widgets.js (~107 keys)
- All widget types (clock/weather/rss/text/webpage/social/directory-board)
  with localized names + descriptions
- Full Directory Board editor (categories, entries, logo, backgrounds)
- Content picker overlay
- Confirms, toasts, empty states
- 532 keys total, 100% parity across en/es/fr/de/pt

Designer.js follows in batch 2b.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:52:31 -05:00
ScreenTinker eccf4b7af1 i18n batch 1/6: wire device-detail + settings (~242 keys)
- device-detail.js: tabs, draft banner, layout selector, info cards,
  uptime timeline, controls, remote tab, playlist items, copy/assign
  modals, all toasts and confirms
- settings.js: account, change password, license, user management,
  white-label, server info, setup guide, your data export/import,
  language selector, about
- es/fr/de/pt all at 425/425 key parity; hi skeleton untouched
- Native review still recommended before publicizing as fully supported

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:47:17 -05:00
ScreenTinker 8e7a093150 i18n: extract all strings, add 6 language translations, restructure i18n module
Session 1 of 2 of the i18n rollout.

- Split i18n module into per-language files under frontend/js/i18n/ so a
  translator can edit one language without touching the others.
- Add Portuguese (pt) and seed Hindi (hi). Hindi is intentionally a skeleton
  -- 0 keys, full English fallback -- because we have an active Indian user
  and would rather ship "no Hindi" than ship machine-quality Hindi that
  could read as unprofessional or get formality/gender register wrong.
- 183 keys, 100% parity across en/es/fr/de/pt; native review still
  recommended before publicizing as "fully supported".
- Add t(key, vars) variable substitution and tn(keyBase, n, vars) plural
  helper for _one/_other key pairs.
- setLanguage() now triggers a CustomEvent + HashChangeEvent so the
  existing hash router naturally re-renders the current view, plus a
  subscriber pattern for nav labels rendered once outside the router.
- Wire t() into 3 high-traffic views end-to-end: dashboard, login,
  content-library. Sidebar nav labels in app.js update on language change.
- The remaining 16 views still ship with hardcoded English; they will be
  wired in session 2. The t() lookup is robust against unwired views, so
  the dashboard works in 5 languages while clicking into e.g. Schedule
  still shows English. No regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:25:22 -05:00
ScreenTinker a2c8ab4336 Match YouTube oEmbed embed format (revert nocookie, add referrerpolicy) to fix Error 153 2026-04-29 11:32:57 -05:00
ScreenTinker a273e5b2b6 Switch YouTube embed to youtube-nocookie.com to avoid Error 153 from tracking blockers 2026-04-29 11:28:49 -05:00
ScreenTinker 8bfb4584a1 Ignore local video/ directory 2026-04-29 11:26:24 -05:00
ScreenTinker a27738120a Add YouTube video embed to landing page 2026-04-29 11:25:29 -05:00
ScreenTinker 19b62fdc1b Fix landing-page comparison: ScreenTinker 15-device price is \$1,188 not \$989
The Pro plan is \$99/mo flat, so 15 devices for a year = \$1,188. The
landing page's compare table mistakenly showed \$989, which would imply
\$82.42/mo and contradicts every other place the price is quoted (the
comparison pages, the demo video, the pricing cards).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:23:58 -05:00
ScreenTinker 25ab1c485b SEO: add meta tags, sitemap, robots.txt, comparison pages, guides, internal linking
Landing page (frontend/landing.html):
- Title now includes "Self-Hosted" for that keyword
- Description appended "MIT licensed."
- Keywords aligned to spec (digital signage raspberry pi, digital
  signage android tv, video wall software, kiosk software, etc.)
- SoftwareApplication JSON-LD: added applicationSubCategory
  "DigitalSignage", license URL, refreshed description
- Image alt text + og:image:alt + twitter:image:alt now include
  "open-source digital signage"
- New Resources section above the CTA with 6 cards linking to all
  new guides and comparison pages
- Footer rewritten as a 5-column grid (Brand / Guides / Compare /
  Project / Legal) with the new internal links

New SEO pages, all dark-themed, mobile-responsive, ASCII-only:
- frontend/css/seo-page.css (shared nav/footer/article/table styles)
- frontend/compare/yodeck-alternative.html
- frontend/compare/screencloud-alternative.html
- frontend/compare/optisigns-alternative.html
- frontend/guides/raspberry-pi-digital-signage.html
- frontend/guides/digital-signage-android-tv.html
- frontend/guides/self-hosted-digital-signage.html

Each new page has unique title/description/canonical, OG and Twitter
card tags, BreadcrumbList JSON-LD, single h1, proper h2/h3 nesting,
visible breadcrumb, comparison table or step-by-step ordered list,
"Related guides" cross-link block, and a CTA.

Sitemap (frontend/sitemap.xml): added all 6 new URLs with appropriate
priority (0.8 for compare pages, 0.9 for guides). Existing landing
(1.0) and legal pages preserved.

Robots (frontend/robots.txt): allow /compare/ and /guides/, disallow
/player (was previously allowed by mistake).

Server (server/server.js): added explicit GET /sitemap.xml and
GET /robots.txt routes ahead of the static middleware so the
Content-Type is guaranteed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:54:32 -05:00
ScreenTinker b2aa7fab54 Player: keep video playing if unmute is blocked
video.play().catch(() => {}) silently swallowed the rejection from the
browser's autoplay policy, so when a user click triggered the unmute
path the video paused (browser side-effect of unmuting a muted-autoplay
video) and never resumed.

Surface the play() rejection in the log, and fall back to muted playback
if the unmuted play() is blocked. Same for the YT side: explicitly set
volume on unmute. Bumped SW cache to v9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:18:32 -05:00
ScreenTinker a3551a2654 Player: only request fullscreen on real user clicks
The remote-control feature dispatches synthetic click events on the
player when the dashboard forwards touches. The global click handler
called requestFullscreen() on every click, but the browser only honors
that API for trusted user gestures — synthetic events rejected with
"Permissions check failed" / "API can only be initiated by a user
gesture", spamming the console for the duration of any remote session.

Gate the fullscreen request on event.isTrusted. Local user clicks still
trigger fullscreen; remote-control taps no longer try (and fail).
Bumped SW cache to v8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:13:58 -05:00
ScreenTinker 63dcc2b656 Drag-and-drop devices into groups on the dashboard
Device cards are now draggable. Group sections accept drops to add
membership (mirroring the Manage modal — same confirmation if the
device is already in another group). The Ungrouped section also
accepts drops to remove the device from every group it's in.

The existing Manage modal still works for bulk add/remove and for
finding devices not currently visible. Click-to-open on a card still
works; drag is only triggered on actual mouse movement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:54:33 -05:00
ScreenTinker 9b26b4930b Make breadcrumb a drop target for moving content out of folders
Once inside a folder, the only drop targets shown were that folder's
own subfolders — no way to drag a file back up to root or to a parent
without opening the edit modal. Breadcrumb segments now accept content
drops: drop on 'All Content' to move to root, or onto a parent folder
name to move there. The edit modal still works for cross-branch moves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:51:02 -05:00
ScreenTinker 66a137cffe Android: bump to 1.7.8 + fix safeOn return type
Released APK 1.7.8 includes the OOM/crash-loop fix, WebSocket crash
hardening, and the http(s)-only ImageLoader scheme guard. Bumped
versionCode 10 -> 11 and versionName 1.7.7 -> 1.7.8 so existing
1.7.7 installs auto-update on the next UpdateChecker poll.

Also fixed the safeOn extension function: Socket.on() returns Emitter,
not Socket, so the original `return on(...)` failed compile with a
type mismatch. Switched to `on(...); return this` for proper chaining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:45:18 -05:00
ScreenTinker a4c85eaabc Remove playerContainer position:relative override that nuked YT iframe
createYoutubeEmbed set container.style.position = 'relative' to anchor
the click-to-unmute overlay. That overrode #playerContainer's
position:fixed/inset:0 — the container fell into normal flow with
zero height (the YT iframe inside has no intrinsic size), so the new
absolute-positioned iframe rendered as 100% of 0 = black screen.

The container is already position:fixed, so absolute children anchor
to it correctly without the override. Removed the line. Bumped SW
cache to v7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:36:39 -05:00
ScreenTinker fb0a7f48dd Force YouTube iframe fullscreen with absolute positioning
The previous CSS fix used 100% width/height but YT.Player can bake in
300x150 fallback pixel dimensions if the placeholder isn't laid out at
construction time. Inline pixel dimensions beat percentage CSS at
equal specificity, so the iframe stayed small.

Use absolute positioning with !important to force fullscreen over
whatever YT set inline. Bumped sw cache to v6 to invalidate the
previously-cached player HTML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:34:40 -05:00
ScreenTinker ed46011ae4 Pin YouTube iframe to fill the player container
The .zone iframe sizing rule only applies to multi-zone layouts. In
fullscreen single-zone mode the YT IFrame API replaces our placeholder
div with an iframe directly inside #playerContainer, where no CSS rule
sized it — leaving it at the iframe default size (~300x150) and
producing a tiny square in the corner. Added explicit rules so any
iframe child of #playerContainer fills the viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:59:23 -05:00
ScreenTinker fb58256b1c Fix YouTube autoplay block from stale localStorage gesture flag
userHasInteracted was initialized from localStorage('rd_audio_unlocked')
on every page load. Browser autoplay policy is per-document, so a flag
from a prior session does not actually grant autoplay rights — but the
player code used it to decide whether to start the YouTube embed muted
(autoplay-able) or unmuted (blocked). Result: kiosks with the flag set
loaded a YT embed with mute=0 that the browser refused to start.

- userHasInteracted now always starts as false. The cold-load tap
  overlay flips it to true on real gesture; the 5s auto-dismiss leaves
  it false and playback stays muted (still allowed).
- unlockAudio() now also calls activeYtPlayer.unMute() so the muted
  embed unmutes immediately when the user finally taps the overlay.
- Removed the now-unused localStorage writes of rd_audio_unlocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:56:49 -05:00
ScreenTinker f951d51214 Always show tap overlay on player cold load
Browser autoplay policy is per-document — a previous session's
localStorage flag does not grant the new page autoplay rights. The
'audio previously unlocked, skipping tap overlay' branch was racing
with YouTube's autoplay block, leaving the player stuck on a paused
embed.

Removed the skip-overlay optimization. The existing 5s auto-dismiss
+ muted-connect fallback still handles unattended kiosks, and a real
user only needs to tap once per cold load to get audio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:54:19 -05:00
ScreenTinker 06ba054898 Fix web player TDZ crash on cached-playlist startup
The cached-playlist restore at the top of the script synchronously calls
playCurrentItem -> renderContent -> createYoutubeEmbed, which references
ytGeneration / activeYtPlayer / ytApiReady / ytApiCallbacks. Those were
declared with `let` further down in the script, so the references hit
the temporal dead zone and threw on every cold start with a YouTube
item in the cached playlist:

  Uncaught ReferenceError: can't access lexical declaration
  'ytGeneration' before initialization

Hoisted the four declarations to the top of the script alongside the
other player state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:51:35 -05:00
ScreenTinker f8cc62308f Fix screenshot fallback query and API 404 hang
Two pre-existing bugs surfaced during deploy:

- /api/devices/:id/screenshot fell back to a query referencing
  screenshots.created_at, but the schema column is captured_at. Threw
  SqliteError 500 whenever the in-memory cache was cold (e.g. just
  after a server restart).

- The SPA catch-all at /* served index.html for non-/api paths but did
  nothing for unmatched /api/ paths — the response hung until the
  upstream timeout (524 from Cloudflare at 15s). Now returns 404 JSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:49:10 -05:00
ScreenTinker 8ec33721f7 Security: sanitize notes, add CSP headers, tighten CORS
LOW 1 (notes XSS): device.notes textarea content now goes through
esc(). Notes weren't in the sanitizeBody allow-list at write time, so
HTML in the field would render unescaped on the device-detail page.

LOW 2 (CSP): enabled Helmet contentSecurityPolicy with default-src
'self', script-src 'self', style-src 'self' 'unsafe-inline', plus the
data:/blob:/https: image and media sources the player needs. Strict
script-src blocks <script> injection; script-src-attr 'unsafe-inline'
keeps existing inline onclick handlers working until they can be
refactored to addEventListener (TODO comment in code).

  CSP applies to /app and most other paths. Skipped on the public
  widget and kiosk render endpoints, the landing page, and /player —
  those legitimately need inline scripts/styles. upgrade-insecure-
  requests is explicitly disabled so HTTP-only self-hosted LAN
  deployments aren't broken.

  Refactored two inline onclick handlers in index.html to data-close-
  modal attributes wired by a delegated listener in app.js. Was the
  only blocker for /app under strict script-src.

LOW 3 (CORS): Express CORS now only allows screentinker.com (and
subdomains) + localhost in production. SELF_HOSTED=true bypasses the
allowlist (operator owns their deployment). Development mode stays
open. Same policy applied to the Socket.IO CORS config which was
previously origin: '*'. Native clients (Android, server-to-server,
kiosk iframes) send no Origin and pass through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:31 -05:00
ScreenTinker c105a5941e Security: fix IDORs, XSS, rate limits, SSRF validation
HIGH 1 (teams IDOR): POST/DELETE /api/teams/:id/devices now require the
caller to own the device before assigning or detaching it. Without this
check, any team member could pull any device into their team via UUID
guess and gain remote-control access.

HIGH 2 (schedules IDOR): PUT /api/schedules/:id now re-verifies
ownership of every changed target field — device_id, group_id,
content_id, widget_id, layout_id, playlist_id. Previously only the
schedule owner was checked, letting users fire arbitrary content on
victim devices via update.

HIGH 3 (filename XSS): file.originalname captured by multer bypassed
sanitizeBody. New safeFilename() wraps every INSERT path (multipart
upload, remote URL, YouTube). Frontend sinks now go through esc() in
content-library.js, device-detail.js, video-wall.js. Web player gets
an inline escHtml helper for its info overlay where filenames, device
name, and serverUrl land in innerHTML.

HIGH 4 (kiosk public XSS): config.idleTimeout is now coerced via the
existing safeNumber() helper at both interpolation sites. A crafted
value with a newline can no longer escape the JS line comment to
inject arbitrary code into the public render endpoint.

HIGH 5 (folder DoS): POST /api/folders enforces a per-user cap of 100
folders (429 on overflow). Superadmin exempt.

MED 1 (SSRF): ImageLoader.decodeUrl rejects any URL scheme other than
http(s) so a malicious remote_url can't read local files via file://.
On the server, validateRemoteUrl() is extracted and now also runs on
PUT /api/content/:id remote_url updates — previously the SSRF check
only fired on POST.

MED 2 (fingerprint takeover): the WS device:register fingerprint
reclaim path now rejects takeover while the target device is online or
within 24h of its last heartbeat. A leaked fingerprint can no longer
hijack an active display.

MED 3 (npm audit): bumped uuid 9.x -> 14.0.0 (v3/v5/v6 buffer bounds
CVE; we only use v4 so not exploitable, but clears the audit). path-
to-regexp resolved to 0.1.13 via npm audit fix. 0 vulns remaining.

MED 4 (folder admin consistency): ownedFolder() and the content.js
folder_id move check now both treat only superadmin as privileged,
matching GET /api/folders. Previously a plain "admin" could rename
or delete folders they couldn't see, and could move content into
folders they couldn't list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:18 -05:00
ScreenTinker 76a0076b65 Fix UTF-8 encoding for special characters in filenames
multer/busboy decode multipart filename headers as latin1 by default,
which mangled umlauts and other non-ASCII characters end-to-end
(Größe.jpg arrived as Größe.jpg and was stored that way). Setting
defParamCharset: 'utf8' on the multer options makes the entire
upload pipeline consistent UTF-8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:41 -05:00
ScreenTinker fcecf805ed Add media folder organization to content library
New content_folders table with hierarchical parent_id and per-user
scoping. content.folder_id added (ON DELETE SET NULL so deleting a
folder drops items back to root). New /api/folders route exposes
list/create/rename/move/delete with cycle detection on move.

Content library UI: breadcrumb navigation, subfolder grid, "+ New
Folder" creates inside the current folder, drag-and-drop content
items onto folder cards to move them, and the edit modal has a
folder dropdown showing each folder's full path.

Per-user scoping is enforced server-side: every folder query
filters by user_id, and folder ownership is checked on both folder
mutations and content.folder_id updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:36 -05:00
ScreenTinker 8866e305f0 Fix Android app crash on WebSocket connection loss
Every Socket.IO listener now goes through a safeOn helper that wraps
the body in try/catch(Throwable). Unsafe args[0] as JSONObject and
data.getString() patterns replaced with firstOrNull as? JSONObject
and optString — a malformed payload from the server, or a transient
state error during disconnect, no longer surfaces as an unhandled
exception on the IO thread.

Reconnection now uses explicit exponential backoff with jitter
(1s → 60s, randomizationFactor 0.5) so a fleet doesn't reconnect in
lockstep after a server blip. EVENT_DISCONNECT stops the heartbeat
while disconnected; the player keeps showing cached content. register,
sendHeartbeat, requestPlaylistRefresh, sendScreenshot, sendContentAck,
sendPlaybackState, and disconnect are all wrapped — telemetry / WiFi
service calls can throw on some devices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:26 -05:00
ScreenTinker cd6e39a4a7 Fix Android app OOM crash on 4K images and crash loop recovery
A 4K image assigned to a 1080p display decoded as a ~33 MB ARGB_8888
bitmap and OOM'd. Worse, the cached playlist on disk meant relaunch
hit the same image and crashed again — only a reinstall recovered.

New ImageLoader utility reads bounds via inJustDecodeBounds, computes
inSampleSize against the device screen (or zone size for multi-zone
layouts), and returns null on OOM/Throwable so callers skip the item
instead of crashing. MediaPlayerManager exposes an onImageError
callback wired to playlistController.next() so a bad item advances
the playlist. The cached-playlist restore in onCreate now catches
Throwable (was Exception) and clears the cache on any failure,
breaking the crash loop. android:largeHeap="true" added as belt and
braces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:10 -05:00
ScreenTinker ee6888e737 Fix display duplication on WebSocket reconnect
Server-side: when a device reconnects on a fresh socket while the old
TCP zombie is still around, the old socket's eventual disconnect handler
flipped the device offline and removed the new heartbeat entry. Now we
proactively evict any prior socket on register and ignore disconnects
from sockets that are no longer the registered one for that device_id.

Frontend: dedupe devices by id from the API response and only render
each device in the first group it belongs to (multi-group membership
is still tracked for the Manage modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:00 -05:00
ScreenTinker 05f70b7910 Update ToS: add CSAM policy, fix MIT license conflict, add governing law
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:26:32 -05:00
ScreenTinker c2b1bb20ae Fix stale setup.sh references in Pi installer
Curl-pipe URLs, --help output, clone-and-run path, and the root-check
error message all referenced pi-setup.sh / setup.sh / screentinker/pi,
none of which exist. Point them all at the actual filename and path:
scripts/raspberry-pi-setup.sh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:28:26 -05:00
ScreenTinker 261f74e1e4 Rewrite Pi setup script as all-in-one installer
Turns the Raspberry Pi script from a basic Chromium kiosk launcher
into a full installer with two modes:

- All-in-One: installs Node.js, clones the repo, runs the server
  on port 3001, and launches the kiosk pointing at localhost. One
  Pi does everything.
- Player-Only: connects to an existing server; same kiosk behavior
  as before but with better Chromium flags and crash-flag cleanup.

Other changes:
- Detects Pi OS Lite vs Desktop and adjusts strategy (startx + vt1
  for Lite, plain kiosk launcher for Desktop)
- Auto-login on tty1 for Lite installs
- GPU memory, overscan, console-blanking, and watchdog tweaks
- screentinker-{status,update,logs} management commands
- MOTD with command hints
- Cleans up the legacy remotedisplay.service / kiosk script on
  upgrade so old installs migrate cleanly
- set -euo pipefail, root check, architecture check, tee'd log at
  /var/log/screentinker-setup.log

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:26:49 -05:00
ScreenTinker 846d61a1b0 Add Discord link and refresh feature copy
- README + landing page footer now link to the community Discord
- Landing page feature grid gains Playlists, Directory Board,
  Offline Resilience, and Mobile Dashboard cards; Scheduling and
  Self-Hosted copy updated to mention group-level schedules and
  the DISABLE_REGISTRATION env var
- Structured data featureList expanded to match; Organization
  sameAs now includes Discord
- README feature list clarifies scheduling precedence, mobile
  responsiveness scope, and the auth/IDOR/XSS audit work

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 17:47:00 -05:00
ScreenTinker 2959eaa149 Refresh cached user so admin plan/role changes propagate
The JWT only carries { id, email, role } and the server reads plan_id
fresh from the DB per request, but the frontend cached the user object
in localStorage at login and never refreshed it. After an admin changed
a user's plan, the dashboard kept rendering the old plan until the
user logged out and back in.

Added api.getMe() and a refreshCurrentUser() helper that runs at
startup and on every hashchange. Settings page now fetches the user
fresh via api.getMe() on render, with localStorage as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:38:46 -05:00
ScreenTinker 281a735e84 Fix white-label settings not applying on page load
Root cause: the Settings page loaded /api/white-label into the form
inputs but never applied the saved values (primary_color, bg_color,
brand_name, favicon, custom_css) to the actual document. Nothing in
app.js bootstrap touched branding. So the save hit the DB correctly,
reload kept the DB value correctly, but the page always rendered the
hardcoded defaults from css/variables.css and the static "ScreenTinker"
label in index.html — which looked like the save had reverted.

Fix: new frontend/js/branding.js module that fetches /api/white-label
once at startup (app.js) and applies values to:
  - --accent and --bg-primary CSS vars
  - document.title and the .sidebar-header .logo span text
  - all <link rel="icon">/apple-touch-icon hrefs
  - a <style id="wl-custom-css"> tag for custom_css
  - the theme-color meta tag

Settings save now calls resetBranding() after POST so changes apply
immediately without a reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:36:20 -05:00
ScreenTinker 4392bb460a Add DISABLE_REGISTRATION env var to block public sign-ups
When DISABLE_REGISTRATION=true (or 1), POST /api/auth/register returns
403 with a clear error. OAuth endpoints (/google, /microsoft) also
refuse to auto-create new accounts — existing OAuth users can still
sign in. First-user setup (empty users table) is always allowed so a
fresh install can still be initialized.

GET /api/auth/config now returns registration_enabled so the login
view can hide the "Create Account" button and the trial banner when
registration is off. Absence of the flag is treated as enabled for
back-compat with older servers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:35:32 -05:00
ScreenTinker ea86d70475 README: update feature list to reflect current capabilities
- Playlists: draft/publish workflow with revert
- Device groups: group playlist assignment and group scheduling
- Scheduling: priority-based conflict resolution, group-level schedules,
  device-level overrides
- Widgets: replace "Content designer" line; list all widget types
  including Directory Board
- Offline resilience: Android ContentCache + web player service worker
- Mobile-responsive dashboard
- Account management: password change, profile, email reset
- Security: JWT, rate limiting, ownership checks, XSS/IDOR audits

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:39:07 -05:00
ScreenTinker 6a0e5a28a9 Fix content file access gate for widget references
Extend the public /api/content/:id/file gate to unlock content referenced
by widgets (previously only playlists unlocked it), so device browsers
and kiosk iframes can fetch logos and background images that widgets
embed.

Security: scope the widget lookup to the content owner's widgets only
(w.user_id = content.user_id). Otherwise a user could unlock another
user's content file by creating their own widget whose config references
the victim's content UUID. The pre-existing playlist gate has the same
shape and is left for a separate fix.

Also adds a 30/min rate limit on POST /api/widgets/preview, which
inlines user content as base64 and is memory-intensive.

Perf note: the widgets.config LIKE scan is O(n). Fine at current scale;
revisit with a content_widget_refs join table if the widget table grows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:28:55 -05:00
ScreenTinker 4e4664b603 Add directory board editor UI with content picker, category/entry management
Inline editor with:
- Collapsible categories, reorder up/down, delete
- Entries with identifier, name, subtitle, available toggle
- Add/remove with auto-focus on new row
- Empty state prompts first category
- Theme, scroll speed, column count selectors
- Reusable content picker (single/multi-select) against user's image library
- Logo picker + background image picker (multi) via that picker
- Preview button posts unsaved config to /widgets/preview and shows the
  returned HTML in a modal iframe (srcdoc + injected <base> so relative
  content URLs resolve against our origin)
- Delete confirms with widget name

Also escapes w.name / typeMeta.name / w.id in the widget grid to prevent
stored XSS against admins viewing other users' widgets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:28:47 -05:00
ScreenTinker 08a83c9ba9 Add directory board widget renderer with scrolling, anti-burn-in, dark/light themes
Lobby-style tenant/room directory with vertical marquee, seamless loop via
content cloning, pixel shift + bg pulse for anti-burn-in, rotating background
images with crossfade. Supports logo, title, footer, subtitles per entry,
and Available (green) state. All user strings rendered via textContent in
browser — no server-side HTML escaping of entries needed.

Also refactors render dispatch into renderWidgetHtml() and adds a POST
/preview endpoint that inlines user-owned image content as base64 data
URIs so the editor can preview unsaved widgets. Preview is gated by:
- image/* MIME only
- 10 MB size cap
- user_id ownership check
- path traversal guard via basename + resolve

Unknown widget_type on /preview returns 400.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:28:37 -05:00
ScreenTinker a981171c94 SEO: open-source positioning, GitHub links, OG image, semantic <main>
- Retarget primary keywords ("open-source", "CMS") in title, description,
  OG/Twitter tags and hero h1
- Swap OG/Twitter image from icon-512 to dashboard-preview.png with
  width/height/alt metadata
- Add GitHub link in nav (icon), hero (secondary btn), footer, and a
  new Open Source callout section
- Wrap content in <main> landmark; add width/height on screenshot for
  CLS; add third-party license page to sitemap; Organization schema
  sameAs now points to the GitHub repo

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:56:22 -05:00
ScreenTinker ea80d3aca5 Landing: replace iframe mock with dashboard screenshot
Swaps the live-app iframe for a static PNG of the Displays view.
Faster load, no auth flash, looks sharp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:47:13 -05:00
ScreenTinker 3476f2b7e7 Landing: group Sign In next to Start Free Trial on the right
Removes the far-right floating position; Sign In sits in the nav
cluster alongside the CTA instead of pinned to the viewport edge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:37:41 -05:00
ScreenTinker e0bfa76545 Landing: float Sign In to far top-right, separate from Start Free Trial
Sign In now lives outside the nav-links cluster with margin-left:auto,
pinning it to the top-right corner with visible separation from the
primary CTA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:30:33 -05:00
ScreenTinker 87a935cb74 Landing: fix mobile nav overflow so Sign In stays visible
Pixel 8 Pro portrait (~412px) was clipping Sign In because logo + both
buttons overflowed. Hide logo text below 420px, shorten 'Start Free Trial'
to 'Try Free' on mobile, nowrap nav-links with tighter padding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:27:19 -05:00
ScreenTinker 25f3870472 Landing: keep Sign In button visible on mobile nav
Previously hidden behind the primary CTA; now shows alongside it with
tighter padding on small screens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:24:57 -05:00
ScreenTinker 52297ec618 Settings: add account profile + password change UI
Adds a per-user Account section in Settings with name edit and password
change. Password change requires current password; local auth only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:13:20 -05:00
ScreenTinker 772ead28a2 Fix reset-admin.js: honor recovery token in requireAuth
scripts/reset-admin.js signed a JWT with a synthetic id ("recovery-XXX")
and instructed the operator to paste it into localStorage. But the
requireAuth middleware always SELECTs the user row by id, so every
authed API call under the recovery token returned 401 "User not found"
and the recovery flow was effectively dead.

Fix:
- reset-admin.js now sets a `recovery: true` claim on the JWT.
- requireAuth / optionalAuth short-circuit the DB lookup when
  decoded.recovery === true and synthesize a req.user record in
  memory (role: admin, plan_id: enterprise). The synthetic user is
  never persisted, so FK-constrained writes that expect a real
  user (creating devices, etc.) will still fail — which is fine,
  recovery is only meant to let the operator reset a password or
  create a fresh admin via the Settings UI.

Security: a recovery token still requires the jwtSecret to sign,
so only someone with filesystem access to the server can mint one.
Token TTL remains 1h.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:08:49 -05:00
ScreenTinker 8da0e60c20 Mobile: public-facing pages (landing + login)
Login view:
- Remove `margin-left: calc(-1 * var(--sidebar-width))` from the
  centering wrapper. It was a hack to compensate for the sidebar
  offset, but app.js already zeros the app margin on the login
  route. On mobile this was pushing the login card ~240px off
  the left edge of the viewport.
- Use min-height + padding so the card breathes on short screens.
- Drop inline font-size:11px on the support-token input so the
  global .input 16px mobile rule applies (iOS focus-zoom prevention).

app.js:
- Hide the mobile hamburger button on the login route; it has no
  function there since the sidebar is already hidden.

Landing page:
- Scope the old blanket `.nav-links { display: none }` to hide only
  the section anchors + secondary Sign In button, so the primary
  "Start Free Trial" CTA stays visible on mobile.
- Wrap the 5-column Compare table in a horizontal-scroll container
  and set min-width:560px so it scrolls instead of overflowing
  the page.
- Add min-height:44px to .btn on mobile, tighten section padding
  to 16px (from 24px) so content doesn't feel cramped against
  the viewport edge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 18:52:53 -05:00
ScreenTinker 481ae0209a Mobile: fix modal and form control overflow
Inline width:NNNpx beats the .modal { width: 95vw } mobile rule due to
specificity. Convert to max-width:NNNpx;width:95vw on the three affected
modals so they cap at their desktop size but still shrink on mobile:
  - playlists.js add-item modal (560px)
  - device-detail.js assign-playlist modal (650px)
  - content-library.js edit-content modal (500px)

Same fix pattern for fixed-width form controls flagged in QA — selects
and inputs change to max-width:NNNpx;width:100% so they keep their
desktop size but shrink to container on mobile:
  - admin.js role/plan selects (120/130px)
  - teams.js member role + add-device selects (100/200px)
  - content-library.js search input + folder filter (250/180px)
  - onboarding.js pairing code + display name inputs (240px)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 18:48:51 -05:00
ScreenTinker 0bd34544e5 QA fixes: toast aria-live + scope playlist flex-wrap to mobile
- Toast now announces via role="status"/aria-live="polite" by default,
  and role="alert"/aria-live="assertive" for errors. Screen readers
  previously got nothing when notifications appeared.
- Move playlist-item flex-wrap:wrap from inline style into the
  @media (max-width: 768px) block so desktop rows don't wrap controls
  when the viewport is intermediate-narrow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 16:00:41 -05:00
ScreenTinker 8cd5dd518a Playlist: add up/down reorder buttons
Adds accessible up/down arrow buttons alongside the existing drag-to-
reorder handle on each playlist item. Touch users (and keyboard users)
now have a reliable way to re-order without relying on HTML5 drag-drop,
which is effectively unusable on mobile. First/last items have the
respective arrow disabled.

Uses the same /reorder API the drag handler uses, so behavior stays
consistent. flex-wrap on the item container prevents control overflow
on narrow screens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:57:40 -05:00
ScreenTinker 06d3e93e21 Mobile: horizontal-scroll tables + tab fade (Commit 4/4)
- Wrap wide tables (admin, settings, reports) in .table-wrap with
  min-width on the table so they scroll horizontally on narrow screens
  instead of collapsing rows.
- Add global .table-wrap { overflow-x: auto } utility.
- Mobile: add mask-image fade on .tabs right edge to hint scrollability
  when tabs overflow; flex-shrink:0 on .tab keeps labels intact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:56:01 -05:00
ScreenTinker b45d81cfaa Mobile: modals, forms, tap targets, toast (Commit 3/4)
- Buttons: min-height 44px (36px for .btn-sm, 40px for .btn-icon) on mobile
- Inputs/selects/textarea: font-size 16px (prevents iOS focus zoom), min-height 44px
- Pairing input: scaled letter-spacing down so 6 digits fit at 375px width
- Modals at 95vw: tighter header/body/footer padding so content breathes
- Toast container: bar-style full-width (left/right:12px) instead of
  fixed-right 280px that clipped below 400px viewports

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:51:12 -05:00
ScreenTinker 7c8504d593 Mobile: grid + layout reflow (Commit 2/4)
- Dashboard stats row (.dash-stats-row): flex column on mobile
- Content-library toolbar: stack upload area + remote URL + YouTube boxes vertically
- Info grid: 1 col on mobile (was 2 col); device detail metadata reads cleaner
- Content grid: drop to 1 col below 480px (iPhone SE)
- Schedule controls: wrap, device select fills row
- Schedule calendar: already wrapped in overflow-x:auto, kept horizontal-scroll
  approach (future: dedicated mobile day-view)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:50:56 -05:00
ScreenTinker 09dbb4b199 Mobile: sidebar polish (Commit 1/4)
- Move hamburger click + backdrop click out of inline onclick into app.js
- Add aria-label/aria-expanded/aria-controls to hamburger button
- Close drawer on Escape keypress
- Bump hamburger button to 44px, nav-link min-height to 44px (tap targets)
- Bump .content top padding to 68px on mobile to match 44px hamburger

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:49:49 -05:00
ScreenTinker 2d3bb55db4 Fix startup crash on existing DB: defer group_id index to migration
The CREATE INDEX on schedules(group_id) in schema.sql ran before the
phase4 migration added the group_id column, crashing on existing databases.
Move the index creation to the migration which already handles it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 07:59:49 -05:00
ScreenTinker 52dd44a3e8 Add group-level scheduling, group playlist assignment, and persist audio unlock
Phase 4 group scheduling: schema migration adds group_id to schedules with
CHECK constraint, scheduler evaluates group+device schedules with priority,
group deletion converts schedules to per-device copies. Dashboard gets
playlist assignment dropdown and current playlist label on group headers.
Player persists audio unlock state in localStorage so version reloads
don't lose audio on unattended displays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:22:42 -05:00
ScreenTinker 2104c9cc9f Auto-reload web player when server code changes
Player polls /api/version every 30s and reloads if the hash changes.
Server hash now includes player/index.html and sw.js so player code
updates are detected without requiring a hard refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:44:47 -05:00
ScreenTinker ad3095cdf5 Fix player video cycling bug and connecting overlay during cached playback
Clear pending advance timers when switching content items to prevent stale
image/widget duration timers from interrupting video playback. Also skip
showing "Connecting..." overlay when cached playlist is already playing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:34:48 -05:00
ScreenTinker d73abc809d Simplify service worker: stop intercepting content requests
The SW was causing "unexpected error" on video/image fetches due to
range request handling, opaque response caching, and stale SW races.

Fix: SW now ONLY caches player page + socket.io JS for offline boot.
Content files are left to browser native HTTP cache (server already
sets Cache-Control: public, max-age=2592000, immutable).

Also: auto-reload player when new SW activates so deploys take effect
immediately without manual hard refresh.

Bumped cache to v5 — activate purges all old caches (including the
broken rd-content-v1 content cache).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:24:15 -05:00
ScreenTinker b4ac2fb821 Fix broken service worker + device auth rejection on playlist refresh
Bug 1 (SW): Rewrote service worker fetch handler:
- Skip range requests (video seeking) to avoid caching partial responses
- Skip non-GET requests entirely
- Use ignoreSearch on cache match to avoid query-param misses
- Don't cache opaque cross-origin responses
- Outer catch on Cache API failures
- Don't intercept catch-all requests (let browser handle natively)
- Bump cache version to v4 to purge broken cached responses

Bug 2 (auth): Playlist refresh register was missing device_token,
causing auth rejection every 5 minutes. Fixed by including token
in the refresh-register emit. Added diagnostic logging on both
client and server for token validation failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:18:08 -05:00
ScreenTinker dc7450b6a7 Offline resilience: persist playlist cache for cold-start recovery
Web player:
- Cache playlist JSON to localStorage on every update
- Restore and start playing immediately on boot before connecting
- Clear cache on unpair/reset

Android app:
- Cache playlist JSON to EncryptedSharedPreferences on every update
- Restore cached playlist on cold-start, play from disk-cached content
- Update cache on content deletion, clear on unpair

Server (device socket):
- Fingerprint reconnect: issue fresh token instead of rejecting
- Send device:paired on fingerprint recovery for claimed devices
- Add status logging and dashboard notification on fingerprint reconnect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:49:45 -05:00
ScreenTinker 470197d203 Fix 8 security findings from Phase 3 audit + device-detail banner refresh
Security fixes:
- Critical: Add ownership checks to assignments PUT/:id and DELETE/:id (IDOR)
- Critical: Add ownership checks to assignments copy-to endpoint for both devices
- High: Validate device ownership when adding to device groups
- High: UUID-validate content ID before LIKE query + scope to owner's playlists
- Low: Handle FK violations gracefully in playlist discard (deleted content/widgets)
- Low: Escape mime_type with esc() in playlist item display (XSS)

Bug fix:
- Device-detail mutation handlers now reload full page to show draft banner

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:36:16 -05:00
ScreenTinker f30d8b82cd Unify publish behavior: all edits go to draft, require explicit publish
Remove autoPublish from assignments.js and device-groups.js. All item
mutations (add, update, delete, reorder, copy) now call markDraft
regardless of which UI the edit comes from. Users must explicitly
click Publish to push changes to devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:22:44 -05:00
ScreenTinker 436a3be7f6 Phase 3: playlist publish/draft state with auto-publish from device detail
Schema: add status and published_snapshot columns to playlists table.
Migration snapshots all existing playlists as published (idempotent via schema_migrations).

Devices always receive the published_snapshot, not live playlist_items.
Edits from device-detail/groups auto-publish immediately (display updates instantly).
Edits from playlist detail page go to draft (requires explicit publish).
POST /playlists/:id/publish snapshots and pushes to all devices.
POST /playlists/:id/discard reverts playlist_items from published snapshot.
Content deletion scrubs references from all published snapshots.

Frontend: draft badge in playlist list, prominent yellow banner with publish/discard
buttons on playlist detail and device detail pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 20:52:29 -05:00
112 changed files with 19353 additions and 2790 deletions

3
.gitignore vendored
View file

@ -40,3 +40,6 @@ Thumbs.db
# Environment # Environment
.env .env
.env.* .env.*
# Local-only marketing assets
video/

176
README.md
View file

@ -1,31 +1,79 @@
# 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
- **Playlists** — first-class playlist objects: create, reorder, set per-item duration, share one playlist across multiple displays - **Playlists** — first-class playlist objects: create, reorder, set per-item duration, share one playlist across multiple displays; draft/publish workflow with revert-to-published
- **Device groups** — organize displays into groups, bulk-assign content or playlists, send bulk commands (reboot, screen on/off, launch, update, shutdown) - **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
- **Multi-zone layouts** — split screens into zones with drag-and-drop editor; 7 built-in templates (fullscreen, split, L-bar, PiP, grid) - **Multi-zone layouts** — split screens into zones with drag-and-drop editor; 7 built-in templates (fullscreen, split, L-bar, PiP, grid)
- **Video walls** — combine multiple displays into one screen with bezel compensation, device rotation, and leader-based sync - **Video walls** — combine multiple displays into one screen with bezel compensation, device rotation, and leader-based sync
- **Remote control** — live view, touch injection, key input, power on/off - **Remote control** — live view, touch injection, key input, power on/off
- **Scheduling** — visual weekly calendar with recurrence rules (daily/weekly/monthly), priority levels, timezone support, and playlist overrides - **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
- **Content designer** — clocks, weather, RSS tickers, countdowns, QR codes - **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** — per-content and per-device analytics, hourly/daily breakdowns, CSV export for ad verification - **Proof-of-play** — per-content and per-device analytics, hourly/daily breakdowns, CSV export for ad verification
- **Device telemetry** — battery, storage, RAM, CPU, WiFi signal strength, and uptime reported by Android players - **Device telemetry** — battery, storage, RAM, CPU, WiFi signal strength, and uptime reported by Android players
- **Alerts** — email notifications when devices go offline - **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
- **Teams** — multi-user with owner, editor, and viewer roles; team-based device access - **Mobile-responsive** — full management dashboard and landing page work on phones and tablets
- **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
- **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 - **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 - **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 - **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 - **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 - **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
Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, and any device with a web browser. Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, and any device with a web browser.
@ -34,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
@ -43,22 +92,34 @@ 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 (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. 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` | | `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` |
| `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) | _(none)_ | | `APP_URL` | Your public URL (used for Stripe callbacks) | _(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
@ -115,15 +176,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
@ -158,6 +246,12 @@ Environment=SELF_HOSTED=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
@ -232,6 +326,8 @@ 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`. Back it up regularly:
@ -292,6 +388,41 @@ keytool -genkey -v -keystore android/release-key.jks -keyalg RSA -keysize 2048 -
- **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
### 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
``` ```
@ -315,11 +446,24 @@ 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:
- 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 planned. Direct contact: [dan@bytetinker.net](mailto:dan@bytetinker.net) or via Discord.
## License ## License

View file

@ -11,8 +11,8 @@ android {
applicationId = "com.remotedisplay.player" applicationId = "com.remotedisplay.player"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 10 versionCode = 11
versionName = "1.7.7" versionName = "1.7.8"
} }
signingConfigs { signingConfigs {

View file

@ -20,6 +20,7 @@
android:allowBackup="true" android:allowBackup="true"
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">

View file

@ -144,10 +144,36 @@ 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")
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 {
@ -184,6 +210,9 @@ class MainActivity : AppCompatActivity() {
val assignments = data.getJSONArray("assignments") val assignments = data.getJSONArray("assignments")
// 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")
@ -272,6 +301,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 +403,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)

View file

@ -62,4 +62,13 @@ class ServerConfig(context: Context) {
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()
}
} }

View file

@ -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,7 +19,8 @@ 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
@ -89,20 +91,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 +126,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()
} }
} }

View file

@ -180,22 +180,30 @@ class ZoneManager(
layoutParams = params layoutParams = params
} }
// Load image // Target the zone size (already known) so we don't decode larger than the
// visible region. Falls back to screen size if zone hasn't been measured.
val targetW = if (w > 0) w else com.remotedisplay.player.util.ImageLoader.screenWidth(context)
val targetH = if (h > 0) h else com.remotedisplay.player.util.ImageLoader.screenHeight(context)
val file = contentId?.let { contentCache.getCachedFile(it) } val file = contentId?.let { contentCache.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) {
try { imageView.setImageBitmap(bitmap) }
catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") }
} else {
Log.w(TAG, "Zone ${zone.name}: skipping unloadable image $contentId")
}
} else if (!remoteUrl.isNullOrEmpty()) { } else if (!remoteUrl.isNullOrEmpty()) {
// Load from URL in background
Thread { Thread {
try { val bitmap = com.remotedisplay.player.util.ImageLoader.decodeUrl(remoteUrl, 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) }
input.close() catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") }
imageView.post { if (bitmap != null) imageView.setImageBitmap(bitmap) } }
} catch (e: Exception) { } else {
Log.e(TAG, "Image load failed: ${e.message}") Log.w(TAG, "Zone ${zone.name}: skipping unloadable remote image")
} }
}.start() }.start()
} }

View file

@ -65,6 +65,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,189 +94,206 @@ 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) // Persist device_token (issued on first register, or refreshed on reconnect)
if (data.has("device_token")) { if (data.has("device_token")) {
config.deviceToken = data.getString("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 credentials") Log.w("WebSocketService", "Device not found on server - clearing credentials")
config.clearDeviceCredentials() config.clearDeviceCredentials()
handler.post { onUnpaired?.invoke() } handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
} }
on("device:auth-error") { args -> safeOn("device:auth-error") { args ->
val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed" val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed"
Log.w("WebSocketService", "Device auth rejected: $msg — clearing credentials for re-pair") Log.w("WebSocketService", "Device auth rejected: $msg — clearing credentials for re-pair")
config.clearDeviceCredentials() config.clearDeviceCredentials()
handler.post { onUnpaired?.invoke() } handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
} }
on("device:paired") { args -> safeOn("device:paired") { args ->
val data = args[0] as JSONObject val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val id = data.getString("device_id") 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) } 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)
// Send device_token for authentication (may be empty for legacy devices)
val token = config.deviceToken val token = config.deviceToken
if (token.isNotEmpty()) { if (token.isNotEmpty()) {
put("device_token", token) 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 {
@ -291,16 +323,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)
val token = config.deviceToken val token = config.deviceToken
if (token.isNotEmpty()) { if (token.isNotEmpty()) put("device_token", token)
put("device_token", token) try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") }
}
put("device_info", deviceInfo.getDeviceInfo())
} }
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() {
@ -310,11 +343,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)
@ -381,11 +418,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) {
@ -440,28 +479,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
} }

View file

@ -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
}
}

View file

@ -0,0 +1,5 @@
<?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>
</resources>

View file

@ -0,0 +1,5 @@
<?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>
</resources>

View file

@ -0,0 +1,5 @@
<?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>
</resources>

View 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>

View file

@ -0,0 +1,5 @@
<?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>
</resources>

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View file

@ -0,0 +1,154 @@
<!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, supports more platforms natively, 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="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="partial">Limited support</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="yes">Yes</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">Screenshot only</td></tr>
<tr><td>White-label / reseller</td><td class="yes">Yes</td><td class="yes">Yes</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>
<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>Built-in player on more platforms.</strong> Native Android APK, web player works on any browser, Pi setup script, Windows-friendly, macOS-friendly.</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">&copy; 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>

View 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 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>
<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">&copy; 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>

View file

@ -0,0 +1,151 @@
<!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 supports more platforms than Yodeck. 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, supports more platforms. 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, supports more platforms. 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 supports more platforms out of the box. 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="partial">Limited</td></tr>
<tr><td>Web browser player</td><td class="yes">Yes</td><td class="no">No</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="partial">Add-on</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>
<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">&copy; 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>

View file

@ -32,6 +32,81 @@ 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;
}
.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; }
.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); }
.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); }
.nav-links { .nav-links {
flex: 1; flex: 1;
padding: 12px 8px; padding: 12px 8px;
@ -237,6 +312,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 +1269,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 +1282,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 +1312,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
View 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; }
}

View 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 &gt; My Fire TV &gt; 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 &gt; 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">&copy; 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>

View 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">&copy; 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>

View 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">&copy; 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>

View file

@ -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">
@ -31,6 +31,7 @@
</svg> </svg>
<span>ScreenTinker</span> <span>ScreenTinker</span>
</div> </div>
<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 +107,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 +160,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">
&#129302; Android APK &#129302; <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">
&#127760; Web Player &#127760; <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">
&#129359; Raspberry Pi &#129359; <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">
&#128187; Windows &#128187; <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>

View file

@ -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 }) }),
@ -115,13 +147,25 @@ export const api = {
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 }) }),
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }), 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 // Device Groups - Playlist
groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }), groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }),
// 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) }),
// 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 })

View file

@ -18,11 +18,61 @@ import * as teams from './views/teams.js';
import * as admin from './views/admin.js'; import * as admin from './views/admin.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 { applyBranding } from './branding.js';
import { t } from './i18n.js';
import { isPlatformAdmin } from './utils.js';
import { renderWorkspaceSwitcher } from './components/workspace-switcher.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;
// 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,6 +83,25 @@ function getCurrentUser() {
} catch { return null; } } catch { return null; }
} }
// 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 }));
} catch {}
}
function route() { function route() {
// Cleanup previous view // Cleanup previous view
if (currentView && currentView.cleanup) currentView.cleanup(); if (currentView && currentView.cleanup) currentView.cleanup();
@ -64,6 +133,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 +143,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();
@ -159,9 +232,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 +252,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 +270,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 +363,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';
});

59
frontend/js/branding.js Normal file
View file

@ -0,0 +1,59 @@
// 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;
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;
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.querySelector('.sidebar-header .logo span');
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();
}

View file

@ -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"/>' :

View 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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

View file

@ -0,0 +1,135 @@
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);
}
// 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) {
container.classList.remove('open');
container.innerHTML = `<span class="workspace-switcher-static">${esc(list[0].name)}</span>`;
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];
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">
${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)
: '';
return `
<div class="workspace-switcher-item ${w.id === currentId ? 'current' : ''}" data-workspace-id="${esc(w.id)}" 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>
${w.can_admin ? `
<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>
` : ''}
</div>
`;
}).join('')}
</div>
`;
const button = container.querySelector('.workspace-switcher-button');
button.addEventListener('click', (e) => {
e.stopPropagation();
const opening = !container.classList.contains('open');
container.classList.toggle('open');
button.setAttribute('aria-expanded', String(opening));
});
// Pencil click opens the rename modal. Must stopPropagation so the click
// doesn't bubble up to the switcher-item's switch handler.
container.querySelectorAll('.workspace-switcher-pencil').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const wsId = btn.dataset.renameId;
const ws = sorted.find(w => w.id === wsId);
if (!ws) return;
container.classList.remove('open');
const { openWorkspaceRenameModal } = await import('./workspace-rename-modal.js');
openWorkspaceRenameModal(ws);
});
});
container.querySelectorAll('.workspace-switcher-item').forEach(item => {
item.addEventListener('click', async (e) => {
// Ignore clicks that originated on the pencil (it has its own handler).
if (e.target.closest('.workspace-switcher-pencil')) return;
const wsId = item.dataset.workspaceId;
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');
}
});
});
// 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 => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

View file

@ -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);
}

1072
frontend/js/i18n/de.js Normal file

File diff suppressed because it is too large Load diff

1121
frontend/js/i18n/en.js Normal file

File diff suppressed because it is too large Load diff

1071
frontend/js/i18n/es.js Normal file

File diff suppressed because it is too large Load diff

1072
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
View 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

File diff suppressed because it is too large Load diff

1072
frontend/js/i18n/pt.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -48,6 +48,16 @@ export function connectSocket() {
emit('playback-state', data); emit('playback-state', 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 +119,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; }

View file

@ -3,3 +3,11 @@ export function esc(str) {
if (str == null) return ''; if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
} }
// 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'));
}

View file

@ -1,16 +1,17 @@
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.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>
`; `;
@ -25,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 `
@ -40,7 +41,7 @@ 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>${esc(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)"> ${esc(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">${esc(item.details)}</div>` : ''} ${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(item.details)}</div>` : ''}
@ -81,22 +82,29 @@ function getActionIcon(action) {
return '&#128196;'; return '&#128196;';
} }
// 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() {}

View file

@ -1,38 +1,36 @@
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 { esc, isPlatformAdmin } from '../utils.js';
import { t } from '../i18n.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());
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> </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.plans')}</h3>
<div id="plansTable"><p style="color:var(--text-muted)">Loading...</p></div> <div id="plansTable"><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.system')}</h3>
<div id="systemInfo"><p style="color:var(--text-muted)">Loading...</p></div> <div id="systemInfo"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div> </div>
`; `;
@ -46,76 +44,91 @@ 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.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> <option value="user" ${u.role === 'user' ? 'selected' : ''}>${t('admin.role.user')}</option>
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option> <option value="admin" ${u.role === 'admin' ? 'selected' : ''}>${t('admin.role.admin')}</option>
<option value="superadmin" ${u.role === 'superadmin' ? 'selected' : ''}>Superadmin</option> <option value="superadmin" ${u.role === 'superadmin' ? 'selected' : ''}>${t('admin.role.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"> <td style="padding:8px;white-space:nowrap">
${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>'} ${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>` : ''}
${u.role !== 'superadmin' ? `<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 // 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)">${esc(err.message)}</p>`; } } catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
@ -126,26 +139,28 @@ 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)">${esc(err.message)}</p>`; } } catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
} }
@ -157,12 +172,12 @@ 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)">${esc(err.message)}</p>`; } } catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }

View file

@ -1,16 +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 { 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 {
@ -22,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">&#9201;</span> <span style="font-size:20px">&#9201;</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'}"
@ -50,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'}"
@ -59,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)">&#10003; Remote Control</div>' : '<div style="color:var(--text-muted)">&#10007; Remote Control</div>'} ${subData.plan.remote_control ? `<div style="color:var(--success)">&#10003; ${t('billing.feat.remote_control')}</div>` : `<div style="color:var(--text-muted)">&#10007; ${t('billing.feat.remote_control')}</div>`}
${subData.plan.remote_url ? '<div style="color:var(--success)">&#10003; Remote URLs</div>' : '<div style="color:var(--text-muted)">&#10007; Remote URLs</div>'} ${subData.plan.remote_url ? `<div style="color:var(--success)">&#10003; ${t('billing.feat.remote_urls')}</div>` : `<div style="color:var(--text-muted)">&#10007; ${t('billing.feat.remote_urls')}</div>`}
${subData.plan.priority_support ? '<div style="color:var(--success)">&#10003; Priority Support</div>' : '<div style="color:var(--text-muted)">&#10007; Priority Support</div>'} ${subData.plan.priority_support ? `<div style="color:var(--success)">&#10003; ${t('billing.feat.priority_support')}</div>` : `<div style="color:var(--text-muted)">&#10007; ${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 ? '&#10003;' : '&#10007;'} Remote Control</div> <div>${p.remote_control ? '&#10003;' : '&#10007;'} ${t('billing.feat.remote_control')}</div>
<div>${p.remote_url ? '&#10003;' : '&#10007;'} Remote URLs</div> <div>${p.remote_url ? '&#10003;' : '&#10007;'} ${t('billing.feat.remote_urls')}</div>
<div>${p.priority_support ? '&#10003;' : '&#10007;'} Priority Support</div> <div>${p.priority_support ? '&#10003;' : '&#10007;'} ${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', {
@ -115,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', {
@ -130,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>${esc(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>`;
} }
} }

View file

@ -1,6 +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 { esc } from '../utils.js';
import { t } from '../i18n.js';
function formatFileSize(bytes) { function formatFileSize(bytes) {
if (!bytes) return '--'; if (!bytes) return '--';
@ -14,26 +15,26 @@ 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">
@ -42,18 +43,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">
@ -61,25 +62,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>
`; `;
@ -115,12 +115,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();
@ -134,12 +134,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();
@ -148,36 +148,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');
@ -186,16 +191,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');
} }
} }
@ -205,30 +210,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"/>
@ -242,56 +366,53 @@ 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 src="/api/content/${c.id}/thumbnail" alt="${esc(c.filename)}" loading="lazy">`
: 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 src="/api/content/${c.id}/file" alt="${esc(c.filename)}" loading="lazy">`
} }
</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 ? ` &middot; ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''} ${c.duration_sec ? ` &middot; ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''}
${c.file_size ? ' &middot; ' + formatFileSize(c.file_size) : ''} ${c.file_size ? ' &middot; ' + formatFileSize(c.file_size) : ''}
${c.width && c.height ? ` &middot; ${c.width}x${c.height}` : ''} ${c.width && c.height ? ` &middot; ${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('');
// 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
@ -326,14 +447,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;
@ -341,14 +462,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 = '';
} }
@ -356,7 +477,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>${esc(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>`;
} }
} }
@ -368,46 +489,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>
`; `;
@ -428,10 +556,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, {
@ -453,10 +583,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');
} }
}; };
} }
@ -476,13 +606,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>
`; `;
@ -491,4 +621,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() {}

View file

@ -2,28 +2,46 @@ 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'; import { esc } from '../utils.js';
import { t, tn } from '../i18n.js';
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) {
@ -32,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">`
@ -49,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>
@ -105,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' : ''} &middot; ${onlineCount} online</span> <span style="color:var(--text-muted);font-size:12px">${tn('dashboard.devices_count', devices.length)} &middot; ${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">&#x2715;</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')}">&#x2715;</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>
`; `;
@ -136,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>
@ -191,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');
@ -206,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();
@ -228,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');
@ -244,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(() => {
@ -263,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;
@ -278,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>` : ''}
`; `;
@ -305,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('')}
@ -350,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>${esc(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) => {
@ -366,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;
} }
@ -376,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');
} }
@ -389,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'); }
}); });
@ -414,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);
@ -429,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>
`; `;
@ -442,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;
} }
@ -467,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();
} }

View file

@ -1,16 +1,19 @@
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';
// 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,11 +33,11 @@ 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">
@ -43,51 +46,51 @@ export function render(container) {
<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"></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">
<!-- 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">&#128172; Text</button> <button class="btn btn-secondary btn-sm" id="addText" style="justify-content:center">&#128172; ${t('designer.el.text')}</button>
<button class="btn btn-secondary btn-sm" id="addHeading" style="justify-content:center">&#128220; Heading</button> <button class="btn btn-secondary btn-sm" id="addHeading" style="justify-content:center">&#128220; ${t('designer.el.heading')}</button>
<button class="btn btn-secondary btn-sm" id="addImage" style="justify-content:center">&#128247; Image</button> <button class="btn btn-secondary btn-sm" id="addImage" style="justify-content:center">&#128247; ${t('designer.el.image')}</button>
<button class="btn btn-secondary btn-sm" id="addVideo" style="justify-content:center">&#127916; Video</button> <button class="btn btn-secondary btn-sm" id="addVideo" style="justify-content:center">&#127916; ${t('designer.el.video')}</button>
<button class="btn btn-secondary btn-sm" id="addClock" style="justify-content:center">&#128339; Clock</button> <button class="btn btn-secondary btn-sm" id="addClock" style="justify-content:center">&#128339; ${t('designer.el.clock')}</button>
<button class="btn btn-secondary btn-sm" id="addDate" style="justify-content:center">&#128197; Date</button> <button class="btn btn-secondary btn-sm" id="addDate" style="justify-content:center">&#128197; ${t('designer.el.date')}</button>
<button class="btn btn-secondary btn-sm" id="addWeather" style="justify-content:center">&#9925; Weather</button> <button class="btn btn-secondary btn-sm" id="addWeather" style="justify-content:center">&#9925; ${t('designer.el.weather')}</button>
<button class="btn btn-secondary btn-sm" id="addTicker" style="justify-content:center">&#128240; Ticker</button> <button class="btn btn-secondary btn-sm" id="addTicker" style="justify-content:center">&#128240; ${t('designer.el.ticker')}</button>
<button class="btn btn-secondary btn-sm" id="addShape" style="justify-content:center">&#9632; Shape</button> <button class="btn btn-secondary btn-sm" id="addShape" style="justify-content:center">&#9632; ${t('designer.el.shape')}</button>
<button class="btn btn-secondary btn-sm" id="addQR" style="justify-content:center">&#9641; QR Code</button> <button class="btn btn-secondary btn-sm" id="addQR" style="justify-content:center">&#9641; ${t('designer.el.qr')}</button>
<button class="btn btn-secondary btn-sm" id="addCountdown" style="justify-content:center">&#9201; Countdown</button> <button class="btn btn-secondary btn-sm" id="addCountdown" style="justify-content:center">&#9201; ${t('designer.el.countdown')}</button>
<button class="btn btn-secondary btn-sm" id="addWebpage" style="justify-content:center">&#127760; Webpage</button> <button class="btn btn-secondary btn-sm" id="addWebpage" style="justify-content:center">&#127760; ${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>
@ -108,8 +111,8 @@ export function render(container) {
}; };
// 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 +123,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 +162,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 +210,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 +225,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]);
}; };
@ -315,16 +318,16 @@ 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}">&#9925; Loading...</div>`; 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}">&#9925; ${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}vw;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;
@ -378,7 +381,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 +400,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 = '&#9925; ' + 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 +429,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 +473,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 +501,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(); };

View file

@ -2,6 +2,7 @@ import { api } from '../api.js';
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js'; import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
import { t, tn } from '../i18n.js';
let currentDevice = null; let currentDevice = null;
let statusHandler = null; let statusHandler = null;
@ -33,10 +34,10 @@ export function render(container, deviceId) {
<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="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/> <line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>
</svg> </svg>
Back to Displays ${t('device.back')}
</a> </a>
<div id="deviceContent"> <div id="deviceContent">
<div class="empty-state"><h3>Loading...</h3></div> <div class="empty-state"><h3>${t('common.loading')}</h3></div>
</div> </div>
</div> </div>
`; `;
@ -94,7 +95,7 @@ export function render(container, deviceId) {
if (data.device_id !== deviceId) return; if (data.device_id !== deviceId) return;
const el = document.getElementById('nowPlayingInfo'); const el = document.getElementById('nowPlayingInfo');
if (el && data.current_content_id) { if (el && data.current_content_id) {
el.textContent = `Playing: ${data.current_content_id}`; el.textContent = t('device.now_playing_id', { id: data.current_content_id });
} }
}; };
@ -115,26 +116,26 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="device-header-left"> <div class="device-header-left">
<h1 id="deviceName">${device.name}</h1> <h1 id="deviceName">${device.name}</h1>
<span class="device-status-badge ${device.status}">${device.status}</span> <span class="device-status-badge ${device.status}">${device.status}</span>
${device.owner_name || device.owner_email ? `<span style="font-size:12px;color:var(--text-muted)">Owner: ${device.owner_name || device.owner_email}</span>` : ''} ${device.owner_name || device.owner_email ? `<span style="font-size:12px;color:var(--text-muted)">${t('device.owner_label', { owner: device.owner_name || device.owner_email })}</span>` : ''}
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="renameBtn">Rename</button> <button class="btn btn-secondary btn-sm" id="renameBtn">${t('device.rename')}</button>
<button class="btn btn-secondary btn-sm" id="screenshotBtn"> <button class="btn btn-secondary btn-sm" id="screenshotBtn">
<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">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/> <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"/> <polyline points="21 15 16 10 5 21"/>
</svg> </svg>
Screenshot ${t('device.screenshot_btn')}
</button> </button>
<button class="btn btn-danger btn-sm" id="deleteDeviceBtn">Remove</button> <button class="btn btn-danger btn-sm" id="deleteDeviceBtn">${t('device.remove')}</button>
</div> </div>
</div> </div>
<div class="tabs"> <div class="tabs">
<div class="tab active" data-tab="nowplaying">Now Playing <span class="help-tip" data-tip="Live screenshot of what's currently displaying on this device.">?</span></div> <div class="tab active" data-tab="nowplaying">${t('device.tab.now_playing')} <span class="help-tip" data-tip="${t('device.tab.now_playing_tip')}">?</span></div>
<div class="tab" data-tab="playlist">Playlist <span class="help-tip" data-tip="Content assigned to this device. Drag items to reorder. Add media, widgets, or kiosk pages.">?</span></div> <div class="tab" data-tab="playlist">${t('device.tab.playlist')} <span class="help-tip" data-tip="${t('device.tab.playlist_tip')}">?</span></div>
<div class="tab" data-tab="info">Device Info <span class="help-tip" data-tip="Hardware telemetry, orientation settings, notes, and device controls.">?</span></div> <div class="tab" data-tab="info">${t('device.tab.info')} <span class="help-tip" data-tip="${t('device.tab.info_tip')}">?</span></div>
<div class="tab" data-tab="remote">Remote Control <span class="help-tip" data-tip="View the device screen in real-time and send key presses. Works on Android APK and web player.">?</span></div> <div class="tab" data-tab="remote">${t('device.tab.remote')} <span class="help-tip" data-tip="${t('device.tab.remote_tip')}">?</span></div>
</div> </div>
<!-- Now Playing Tab --> <!-- Now Playing Tab -->
@ -148,45 +149,60 @@ async function loadDevice(deviceId, activeTab = null) {
<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 screenshot available. Click "Screenshot" to capture one.</span> <span>${t('device.no_screenshot')}</span>
</div>` </div>`
} }
</div> </div>
<p id="nowPlayingInfo" style="color:var(--text-secondary);font-size:13px;"> <p id="nowPlayingInfo" style="color:var(--text-secondary);font-size:13px;">
${device.assignments?.length ? `${device.assignments.length} item(s) in playlist` : 'No content assigned'} ${device.assignments?.length ? tn('device.playlist_count', device.assignments.length) : t('device.no_content_assigned')}
</p> </p>
</div> </div>
<!-- Playlist Tab --> <!-- Playlist Tab -->
<div class="tab-content" id="tab-playlist"> <div class="tab-content" id="tab-playlist">
${device.playlist_status === 'draft' ? `
<div id="deviceDraftBanner" style="background:#78350f;border:1px solid #92400e;border-radius:var(--radius);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('device.draft.banner_title')}</div>
<div style="font-size:12px;color:#fcd34d;opacity:0.85">${device.playlist_has_published ? t('device.draft.devices_showing_published') : t('device.draft.never_published')}</div>
</div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0">
${device.playlist_has_published ? `<button class="btn btn-secondary btn-sm" id="deviceDiscardDraftBtn" style="color:#fbbf24;border-color:#92400e">${t('device.draft.discard')}</button>` : ''}
<button class="btn btn-sm" id="devicePublishBtn" style="background:#f59e0b;color:#000;font-weight:600;border:none">${t('device.draft.publish')}</button>
</div>
</div>
` : ''}
<!-- Layout selector --> <!-- Layout selector -->
<div style="background:var(--bg-secondary);border:1px solid var(--border);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(--border);border-radius:var(--radius);padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:12px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>
</svg> </svg>
<div style="flex:1"> <div style="flex:1">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">Screen Layout</div> <div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">${t('device.layout.label')}</div>
<select id="deviceLayoutSelect" class="input" style="background:var(--bg-input);padding:4px 8px;font-size:13px"> <select id="deviceLayoutSelect" class="input" style="background:var(--bg-input);padding:4px 8px;font-size:13px">
<option value="">Fullscreen (default)</option> <option value="">${t('device.layout.fullscreen_default')}</option>
</select> </select>
</div> </div>
<button class="btn btn-secondary btn-sm" id="applyLayoutBtn">Apply</button> <button class="btn btn-secondary btn-sm" id="applyLayoutBtn">${t('device.layout.apply')}</button>
</div> </div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:12px"> <div style="display:flex;align-items:center;gap:12px">
<h3 style="font-size:16px">Playlist</h3> <h3 style="font-size:16px">${t('device.playlist.label')}</h3>
<select class="input" id="playlistPicker" style="font-size:12px;padding:4px 8px;width:200px"> <select class="input" id="playlistPicker" style="font-size:12px;padding:4px 8px;width:200px">
<option value="">No playlist</option> <option value="">${t('device.playlist.no_playlist')}</option>
</select> </select>
</div> </div>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm" id="copyPlaylistBtn">Copy To...</button> <button class="btn btn-secondary btn-sm" id="copyPlaylistBtn">${t('device.playlist.copy_to_btn')}</button>
<button class="btn btn-primary btn-sm" id="addContentBtn"> <button class="btn btn-primary btn-sm" id="addContentBtn">
<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">
<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 Content ${t('device.playlist.add_content_btn')}
</button> </button>
</div> </div>
</div> </div>
@ -199,16 +215,16 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="tab-content" id="tab-info"> <div class="tab-content" id="tab-info">
<div class="info-grid"> <div class="info-grid">
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Status</div> <div class="info-card-label">${t('device.info.status')}</div>
<div class="info-card-value" style="color:var(--${device.status === 'online' ? 'success' : 'danger'})">${device.status}</div> <div class="info-card-value" style="color:var(--${device.status === 'online' ? 'success' : 'danger'})">${device.status}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">IP Address</div> <div class="info-card-label">${t('device.info.ip_address')}</div>
<div class="info-card-value small">${device.ip_address || '--'}</div> <div class="info-card-value small">${device.ip_address || '--'}</div>
</div> </div>
${device.android_version && !device.android_version.startsWith('Web/') ? ` ${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Battery</div> <div class="info-card-label">${t('device.info.battery')}</div>
<div class="info-card-value" id="telBattery">${latestTelemetry.battery_level != null ? latestTelemetry.battery_level + '%' : '--'}</div> <div class="info-card-value" id="telBattery">${latestTelemetry.battery_level != null ? latestTelemetry.battery_level + '%' : '--'}</div>
${latestTelemetry.battery_level != null ? ` ${latestTelemetry.battery_level != null ? `
<div class="progress-bar"> <div class="progress-bar">
@ -217,8 +233,8 @@ async function loadDevice(deviceId, activeTab = null) {
</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('device.info.storage')}</div>
<div class="info-card-value small" id="telStorage">${latestTelemetry.storage_free_mb ? formatBytes(latestTelemetry.storage_free_mb) + ' free' : '--'}</div> <div class="info-card-value small" id="telStorage">${latestTelemetry.storage_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.storage_free_mb) }) : '--'}</div>
${latestTelemetry.storage_total_mb ? ` ${latestTelemetry.storage_total_mb ? `
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-bar-fill ${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb) < 0.8 ? 'success' : 'warning'}" <div class="progress-bar-fill ${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb) < 0.8 ? 'success' : 'warning'}"
@ -227,42 +243,42 @@ async function loadDevice(deviceId, activeTab = null) {
</div> </div>
` : ` ` : `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Player Type</div> <div class="info-card-label">${t('device.info.player_type')}</div>
<div class="info-card-value small">Web Player</div> <div class="info-card-value small">${t('device.info.web_player')}</div>
</div> </div>
`} `}
${device.android_version && !device.android_version.startsWith('Web/') ? ` ${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">WiFi</div> <div class="info-card-label">${t('device.info.wifi')}</div>
<div class="info-card-value small" id="telWifi">${latestTelemetry.wifi_ssid || '--'}</div> <div class="info-card-value small" id="telWifi">${latestTelemetry.wifi_ssid || '--'}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:2px" id="telRssi">${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}</div> <div style="font-size:11px;color:var(--text-muted);margin-top:2px" id="telRssi">${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}</div>
</div> </div>
` : ''} ` : ''}
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Uptime</div> <div class="info-card-label">${t('device.info.uptime')}</div>
<div class="info-card-value small" id="telUptime">${formatUptime(latestTelemetry.uptime_seconds)}</div> <div class="info-card-value small" id="telUptime">${formatUptime(latestTelemetry.uptime_seconds)}</div>
</div> </div>
${device.android_version && !device.android_version.startsWith('Web/') ? ` ${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Android Version</div> <div class="info-card-label">${t('device.info.android_version')}</div>
<div class="info-card-value small">${device.android_version}</div> <div class="info-card-value small">${device.android_version}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">App Version</div> <div class="info-card-label">${t('device.info.app_version')}</div>
<div class="info-card-value small">${device.app_version || '--'}</div> <div class="info-card-value small">${device.app_version || '--'}</div>
</div> </div>
` : ''} ` : ''}
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Screen Resolution</div> <div class="info-card-label">${t('device.info.screen_resolution')}</div>
<div class="info-card-value small">${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}</div> <div class="info-card-value small">${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}</div>
</div> </div>
${device.android_version && !device.android_version.startsWith('Web/') ? ` ${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card"> <div class="info-card">
<div class="info-card-label">RAM</div> <div class="info-card-label">${t('device.info.ram')}</div>
<div class="info-card-value small" id="telRam">${latestTelemetry.ram_free_mb ? formatBytes(latestTelemetry.ram_free_mb) + ' free' : '--'}</div> <div class="info-card-value small" id="telRam">${latestTelemetry.ram_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.ram_free_mb) }) : '--'}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">CPU Usage</div> <div class="info-card-label">${t('device.info.cpu_usage')}</div>
<div class="info-card-value small" id="telCpu">${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}</div> <div class="info-card-value small" id="telCpu">${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}</div>
</div> </div>
` : ''} ` : ''}
@ -270,16 +286,16 @@ async function loadDevice(deviceId, activeTab = null) {
<!-- Uptime Timeline (24h) --> <!-- Uptime Timeline (24h) -->
<div style="margin-top:20px"> <div style="margin-top:20px">
<h4 style="font-size:13px;margin-bottom:8px">Uptime Timeline (Last 24 Hours)</h4> <h4 style="font-size:13px;margin-bottom:8px">${t('device.timeline.title')}</h4>
<div id="uptimeTimeline" style="display:flex;height:32px;border-radius:4px;overflow:hidden;border:1px solid var(--border);background:var(--bg-primary)"></div> <div id="uptimeTimeline" style="display:flex;height:32px;border-radius:4px;overflow:hidden;border:1px solid var(--border);background:var(--bg-primary)"></div>
<div style="display:flex;justify-content:space-between;margin-top:4px"> <div style="display:flex;justify-content:space-between;margin-top:4px">
<span style="font-size:10px;color:var(--text-muted)">24h ago</span> <span style="font-size:10px;color:var(--text-muted)">${t('device.timeline.h24_ago')}</span>
<span style="font-size:10px;color:var(--text-muted)">Now</span> <span style="font-size:10px;color:var(--text-muted)">${t('device.timeline.now')}</span>
</div> </div>
<div style="display:flex;gap:12px;margin-top:8px;font-size:11px;color:var(--text-muted)"> <div style="display:flex;gap:12px;margin-top:8px;font-size:11px;color:var(--text-muted)">
<span><span style="display:inline-block;width:10px;height:10px;background:var(--success);border-radius:2px;vertical-align:-1px"></span> Online</span> <span><span style="display:inline-block;width:10px;height:10px;background:var(--success);border-radius:2px;vertical-align:-1px"></span> ${t('device.timeline.online')}</span>
<span><span style="display:inline-block;width:10px;height:10px;background:var(--danger);border-radius:2px;vertical-align:-1px"></span> Offline</span> <span><span style="display:inline-block;width:10px;height:10px;background:var(--danger);border-radius:2px;vertical-align:-1px"></span> ${t('device.timeline.offline')}</span>
<span><span style="display:inline-block;width:10px;height:10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:2px;vertical-align:-1px"></span> No data</span> <span><span style="display:inline-block;width:10px;height:10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:2px;vertical-align:-1px"></span> ${t('device.timeline.no_data')}</span>
<span id="uptimePercent" style="margin-left:auto;font-weight:600"></span> <span id="uptimePercent" style="margin-left:auto;font-weight:600"></span>
</div> </div>
</div> </div>
@ -287,63 +303,63 @@ async function loadDevice(deviceId, activeTab = null) {
<div style="margin-top:20px"> <div style="margin-top:20px">
<div style="display:flex;gap:12px;margin-bottom:12px"> <div style="display:flex;gap:12px;margin-bottom:12px">
<div class="form-group" style="flex:1;margin:0"> <div class="form-group" style="flex:1;margin:0">
<label>Orientation / Rotation</label> <label>${t('device.form.orientation_label')}</label>
<select id="deviceOrientation" class="input" style="background:var(--bg-input)"> <select id="deviceOrientation" class="input" style="background:var(--bg-input)">
<option value="landscape" ${'landscape' === (device.orientation || 'landscape') ? 'selected' : ''}>Landscape (0°)</option> <option value="landscape" ${'landscape' === (device.orientation || 'landscape') ? 'selected' : ''}>${t('device.form.orientation.landscape')}</option>
<option value="portrait" ${'portrait' === device.orientation ? 'selected' : ''}>Portrait (90° CW)</option> <option value="portrait" ${'portrait' === device.orientation ? 'selected' : ''}>${t('device.form.orientation.portrait')}</option>
<option value="landscape-flipped" ${'landscape-flipped' === device.orientation ? 'selected' : ''}>Landscape Flipped (180°)</option> <option value="landscape-flipped" ${'landscape-flipped' === device.orientation ? 'selected' : ''}>${t('device.form.orientation.landscape_flipped')}</option>
<option value="portrait-flipped" ${'portrait-flipped' === device.orientation ? 'selected' : ''}>Portrait Flipped (270° CW)</option> <option value="portrait-flipped" ${'portrait-flipped' === device.orientation ? 'selected' : ''}>${t('device.form.orientation.portrait_flipped')}</option>
</select> </select>
</div> </div>
<div class="form-group" style="flex:1;margin:0"> <div class="form-group" style="flex:1;margin:0">
<label>Default Content</label> <label>${t('device.form.default_content_label')}</label>
<select id="deviceDefaultContent" class="input" style="background:var(--bg-input)"> <select id="deviceDefaultContent" class="input" style="background:var(--bg-input)">
<option value="">None (show "Waiting...")</option> <option value="">${t('device.form.default_content_none')}</option>
</select> </select>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Notes</label> <label>${t('device.form.notes_label')}</label>
<textarea id="deviceNotes" class="input" rows="3" placeholder="Location, setup details, etc." style="resize:vertical">${device.notes || ''}</textarea> <textarea id="deviceNotes" class="input" rows="3" placeholder="${t('device.form.notes_placeholder')}" style="resize:vertical">${esc(device.notes || '')}</textarea>
</div> </div>
<button class="btn btn-secondary btn-sm" id="saveNotesBtn">Save Settings</button> <button class="btn btn-secondary btn-sm" id="saveNotesBtn">${t('device.form.save_settings')}</button>
</div> </div>
<div style="margin-top:20px;display:flex;gap:8px;flex-wrap:wrap"> <div style="margin-top:20px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="rebootBtn"> <button class="btn btn-secondary btn-sm" id="rebootBtn">
<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">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/> <polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg> </svg>
Reboot Device ${t('device.ctl.reboot_device')}
</button> </button>
<button class="btn btn-secondary btn-sm" id="screenOffBtn"> <button class="btn btn-secondary btn-sm" id="screenOffBtn">
<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">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/> <rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg> </svg>
Screen Off ${t('device.ctl.screen_off')}
</button> </button>
<button class="btn btn-secondary btn-sm" id="screenOnBtn"> <button class="btn btn-secondary btn-sm" id="screenOnBtn">
<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">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg> </svg>
Screen On ${t('device.ctl.screen_on')}
</button> </button>
<button class="btn btn-secondary btn-sm" id="launchAppBtn"> <button class="btn btn-secondary btn-sm" id="launchAppBtn">
<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">
<polygon points="5 3 19 12 5 21 5 3"/> <polygon points="5 3 19 12 5 21 5 3"/>
</svg> </svg>
Launch Player ${t('device.ctl.launch_player')}
</button> </button>
<button class="btn btn-secondary btn-sm" id="forceUpdateBtn"> <button class="btn btn-secondary btn-sm" id="forceUpdateBtn">
<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>
Force Update ${t('device.ctl.force_update')}
</button> </button>
<button class="btn btn-danger btn-sm" id="shutdownBtn"> <button class="btn btn-danger btn-sm" id="shutdownBtn">
<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="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/> <path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg> </svg>
Shutdown ${t('device.ctl.shutdown')}
</button> </button>
</div> </div>
</div> </div>
@ -360,24 +376,24 @@ async function loadDevice(deviceId, activeTab = null) {
<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>
<p style="color:var(--text-secondary)">Click "Start Remote" to begin</p> <p style="color:var(--text-secondary)">${t('device.remote.start_prompt')}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="remote-controls"> <div class="remote-controls">
<button class="btn btn-primary" id="startRemoteBtn">Start Remote</button> <button class="btn btn-primary" id="startRemoteBtn">${t('device.remote.start')}</button>
<button class="btn btn-secondary" id="stopRemoteBtn" style="display:none">Stop Remote</button> <button class="btn btn-secondary" id="stopRemoteBtn" style="display:none">${t('device.remote.stop')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<!-- Always available --> <!-- Always available -->
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_UP')">Vol +</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_UP')">${t('device.remote.vol_up')}</button>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_DOWN')">Vol -</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_VOLUME_DOWN')">${t('device.remote.vol_down')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<!-- System View controls (disabled until enabled) --> <!-- System View controls (disabled until enabled) -->
<div id="systemViewControls" style="opacity:0.4;pointer-events:none"> <div id="systemViewControls" style="opacity:0.4;pointer-events:none">
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_HOME')">Home</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_HOME')">${t('device.remote.home')}</button>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_BACK')">Back</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_BACK')">${t('device.remote.back')}</button>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_APP_SWITCH')">Recents</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_APP_SWITCH')">${t('device.remote.recents')}</button>
<button class="btn btn-danger btn-sm" onclick="window._sendKey('KEYCODE_POWER')">Power</button> <button class="btn btn-danger btn-sm" onclick="window._sendKey('KEYCODE_POWER')">${t('device.remote.power')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_UP')">&#9650;</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_UP')">&#9650;</button>
<div style="display:flex;gap:4px"> <div style="display:flex;gap:4px">
@ -385,19 +401,19 @@ async function loadDevice(deviceId, activeTab = null) {
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendKey('KEYCODE_DPAD_RIGHT')">&#9654;</button> <button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendKey('KEYCODE_DPAD_RIGHT')">&#9654;</button>
</div> </div>
<button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_DOWN')">&#9660;</button> <button class="btn btn-secondary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_DOWN')">&#9660;</button>
<button class="btn btn-primary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_CENTER')">OK</button> <button class="btn btn-primary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_CENTER')">${t('device.remote.ok')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<button class="btn btn-secondary btn-sm" onclick="window._sendCmd('settings')">Settings</button> <button class="btn btn-secondary btn-sm" onclick="window._sendCmd('settings')">${t('device.remote.settings')}</button>
<hr style="border-color:var(--border);margin:8px 0"> <hr style="border-color:var(--border);margin:8px 0">
<div style="display:flex;gap:4px"> <div style="display:flex;gap:4px">
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_off')">Scrn Off</button> <button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_off')">${t('device.remote.scrn_off')}</button>
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_on')">Scrn On</button> <button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_on')">${t('device.remote.scrn_on')}</button>
</div> </div>
</div> </div>
<button class="btn btn-primary btn-sm" id="enableSystemCaptureBtn" onclick="window._enableSystemView()" title="Prompts the device user to allow full screen capture - enables remote view of home screen, settings, and other apps" style="margin-top:8px"> <button class="btn btn-primary btn-sm" id="enableSystemCaptureBtn" onclick="window._enableSystemView()" title="${t('device.remote.system_view_tooltip')}" style="margin-top:8px">
Enable System View ${t('device.remote.enable_system_view')}
</button> </button>
<span id="systemViewHint" style="font-size:10px;color:var(--text-muted);line-height:1.2;display:block;margin-top:4px">Requires one-time approval on device</span> <span id="systemViewHint" style="font-size:10px;color:var(--text-muted);line-height:1.2;display:block;margin-top:4px">${t('device.remote.system_view_hint')}</span>
</div> </div>
</div> </div>
</div> </div>
@ -416,13 +432,13 @@ async function loadDevice(deviceId, activeTab = null) {
// Unlock the system controls after a short delay (user needs to tap "Start now" on device) // Unlock the system controls after a short delay (user needs to tap "Start now" on device)
const btn = document.getElementById('enableSystemCaptureBtn'); const btn = document.getElementById('enableSystemCaptureBtn');
const hint = document.getElementById('systemViewHint'); const hint = document.getElementById('systemViewHint');
if (btn) { btn.textContent = 'Waiting for device approval...'; btn.disabled = true; } if (btn) { btn.textContent = t('device.remote.waiting_for_approval'); btn.disabled = true; }
// Check periodically if the device granted it (we'll know because screenshots keep coming even after Home) // Check periodically if the device granted it (we'll know because screenshots keep coming even after Home)
setTimeout(() => { setTimeout(() => {
const controls = document.getElementById('systemViewControls'); const controls = document.getElementById('systemViewControls');
if (controls) { controls.style.opacity = '1'; controls.style.pointerEvents = 'auto'; } if (controls) { controls.style.opacity = '1'; controls.style.pointerEvents = 'auto'; }
if (btn) { btn.textContent = 'System View Enabled'; btn.style.background = 'var(--success)'; } if (btn) { btn.textContent = t('device.remote.system_view_enabled'); btn.style.background = 'var(--success)'; }
if (hint) hint.textContent = 'Navigation and system controls unlocked'; if (hint) hint.textContent = t('device.remote.unlocked_hint');
}, 5000); }, 5000);
}; };
@ -450,13 +466,13 @@ async function loadDevice(deviceId, activeTab = null) {
} }
} catch (err) { } catch (err) {
contentEl.innerHTML = `<div class="empty-state"><h3>Failed to load device</h3><p>${esc(err.message)}</p></div>`; contentEl.innerHTML = `<div class="empty-state"><h3>${t('device.failed_load')}</h3><p>${esc(err.message)}</p></div>`;
} }
} }
function renderPlaylist(assignments) { function renderPlaylist(assignments) {
if (!assignments.length) { if (!assignments.length) {
return `<div class="empty-state"><h3>No content assigned</h3><p>Add content from your library to this display's playlist.</p></div>`; return `<div class="empty-state"><h3>${t('device.playlist.empty_title')}</h3><p>${t('device.playlist.empty_desc')}</p></div>`;
} }
return assignments.map((a, i) => ` return assignments.map((a, i) => `
<div class="playlist-item" data-assignment-id="${a.id}" draggable="true" data-sort="${i}"> <div class="playlist-item" data-assignment-id="${a.id}" draggable="true" data-sort="${i}">
@ -478,26 +494,26 @@ function renderPlaylist(assignments) {
</div>` </div>`
} }
<div class="playlist-item-info"> <div class="playlist-item-info">
<div class="playlist-item-name">${a.filename || a.widget_name || 'Unknown'}</div> <div class="playlist-item-name">${esc(a.filename || a.widget_name || t('common.unknown'))}</div>
<div class="playlist-item-meta"> <div class="playlist-item-meta">
${a.widget_id && !a.content_id ? `Widget (${a.widget_type || 'custom'})` : a.mime_type === 'video/youtube' ? 'YouTube' : a.mime_type?.startsWith('video/') ? 'Video' : 'Image'} ${a.widget_id && !a.content_id ? t('device.pl_item.widget_with_type', { type: a.widget_type || 'custom' }) : a.mime_type === 'video/youtube' ? t('device.pl_item.youtube') : a.mime_type?.startsWith('video/') ? t('device.pl_item.video') : t('device.pl_item.image')}
${a.zone_id ? ` &middot; <span style="color:var(--accent)">Zone: ${a.zone_id.slice(0,8)}</span>` : ''} ${a.zone_id ? ` &middot; <span style="color:var(--accent)">${t('device.pl_item.zone_label', { id: a.zone_id.slice(0,8) })}</span>` : ''}
${a.content_duration ? ` &middot; ${Math.floor(a.content_duration / 60)}:${String(Math.floor(a.content_duration % 60)).padStart(2, '0')}` : ''} ${a.content_duration ? ` &middot; ${Math.floor(a.content_duration / 60)}:${String(Math.floor(a.content_duration % 60)).padStart(2, '0')}` : ''}
${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` &middot; ${a.duration_sec}s` : ''} ${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` &middot; ${a.duration_sec}s` : ''}
${a.schedule_start ? ` &middot; ${a.schedule_start}-${a.schedule_end}` : ''} ${a.schedule_start ? ` &middot; ${a.schedule_start}-${a.schedule_end}` : ''}
</div> </div>
</div> </div>
<div class="playlist-item-actions" style="display:flex;align-items:center;gap:4px"> <div class="playlist-item-actions" style="display:flex;align-items:center;gap:4px">
<select class="input zone-select" data-assignment-id="${a.id}" style="width:100px;font-size:11px;padding:2px 4px;background:var(--bg-input);display:none"> <select class="input zone-select" data-assignment-id="${a.id}" data-current-zone-id="${a.zone_id || ''}" style="width:100px;font-size:11px;padding:2px 4px;background:var(--bg-input);display:none">
<option value="">No zone</option> <option value="">${t('device.pl_item.no_zone')}</option>
</select> </select>
<button class="btn-icon mute-toggle" data-mute-assignment="${a.id}" data-muted="${a.muted ? '1' : '0'}" title="${a.muted ? 'Unmute' : 'Mute'}" style="color:${a.muted ? 'var(--danger)' : 'var(--text-muted)'}"> <button class="btn-icon mute-toggle" data-mute-assignment="${a.id}" data-muted="${a.muted ? '1' : '0'}" title="${a.muted ? t('device.pl_item.unmute') : t('device.pl_item.mute')}" style="color:${a.muted ? 'var(--danger)' : 'var(--text-muted)'}">
${a.muted ${a.muted
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>' ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>'
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>' : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>'
} }
</button> </button>
<button class="btn-icon" title="Remove" data-remove-assignment="${a.id}"> <button class="btn-icon" title="${t('device.pl_item.remove')}" data-remove-assignment="${a.id}">
<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">
<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"/> <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> </svg>
@ -522,18 +538,18 @@ async function setupActions(device) {
// Screenshot button // Screenshot button
document.getElementById('screenshotBtn')?.addEventListener('click', () => { document.getElementById('screenshotBtn')?.addEventListener('click', () => {
requestScreenshot(device.id); requestScreenshot(device.id);
showToast('Screenshot requested', 'info'); showToast(t('device.toast.screenshot_requested'), 'info');
}); });
// Rename // Rename
document.getElementById('renameBtn')?.addEventListener('click', async () => { document.getElementById('renameBtn')?.addEventListener('click', async () => {
const name = prompt('Enter new name:', device.name); const name = prompt(t('device.prompt_new_name'), device.name);
if (name && name !== device.name) { if (name && name !== device.name) {
try { try {
await api.updateDevice(device.id, { name }); await api.updateDevice(device.id, { name });
document.getElementById('deviceName').textContent = name; document.getElementById('deviceName').textContent = name;
currentDevice.name = name; currentDevice.name = name;
showToast('Display renamed', 'success'); showToast(t('device.toast.renamed'), 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -562,12 +578,43 @@ async function setupActions(device) {
orientation: document.getElementById('deviceOrientation').value, orientation: document.getElementById('deviceOrientation').value,
default_content_id: document.getElementById('deviceDefaultContent').value || null, default_content_id: document.getElementById('deviceDefaultContent').value || null,
}); });
showToast('Settings saved', 'success'); showToast(t('device.toast.settings_saved'), 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
}); });
// Publish / Discard from device detail
const devicePublishBtn = document.getElementById('devicePublishBtn');
if (devicePublishBtn && device.playlist_id) {
devicePublishBtn.addEventListener('click', async () => {
try {
devicePublishBtn.disabled = true;
devicePublishBtn.textContent = t('device.draft.publishing');
await api.publishPlaylist(device.playlist_id);
showToast(t('device.toast.published'));
loadDevice(device.id, 'playlist');
} catch (err) {
devicePublishBtn.disabled = false;
devicePublishBtn.textContent = t('device.draft.publish');
showToast(err.message, 'error');
}
});
}
const deviceDiscardBtn = document.getElementById('deviceDiscardDraftBtn');
if (deviceDiscardBtn && device.playlist_id) {
deviceDiscardBtn.addEventListener('click', async () => {
if (!confirm(t('device.confirm_discard_draft'))) return;
try {
await api.discardPlaylistDraft(device.playlist_id);
showToast(t('device.toast.draft_discarded'));
loadDevice(device.id, 'playlist');
} catch (err) {
showToast(err.message, 'error');
}
});
}
// Populate playlist picker // Populate playlist picker
const playlistPicker = document.getElementById('playlistPicker'); const playlistPicker = document.getElementById('playlistPicker');
if (playlistPicker) { if (playlistPicker) {
@ -575,7 +622,9 @@ async function setupActions(device) {
playlists.forEach(p => { playlists.forEach(p => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = p.id; opt.value = p.id;
opt.textContent = `${p.name}${p.is_auto_generated ? ' (auto)' : ''}${p.item_count} items`; opt.textContent = p.is_auto_generated
? t('device.playlist_picker.with_auto', { name: p.name, n: p.item_count })
: t('device.playlist_picker.with_count', { name: p.name, n: p.item_count });
if (p.id === device.playlist_id) opt.selected = true; if (p.id === device.playlist_id) opt.selected = true;
playlistPicker.appendChild(opt); playlistPicker.appendChild(opt);
}); });
@ -592,7 +641,7 @@ async function setupActions(device) {
const assignments = await api.getAssignments(device.id); const assignments = await api.getAssignments(device.id);
document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments); document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments);
attachRemoveHandlers(device); attachRemoveHandlers(device);
showToast('Playlist changed'); showToast(t('device.toast.playlist_changed'));
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -604,12 +653,12 @@ async function setupActions(device) {
try { try {
const devices = await api.getDevices(); const devices = await api.getDevices();
const others = devices.filter(d => d.id !== device.id); const others = devices.filter(d => d.id !== device.id);
if (!others.length) { showToast('No other devices to copy to', 'info'); return; } if (!others.length) { showToast(t('device.copy.no_other_devices'), 'info'); return; }
const targetId = prompt('Copy playlist to which device?\n\n' + others.map((d, i) => `${i + 1}. ${d.name}`).join('\n') + '\n\nEnter number:'); const targetId = prompt(t('device.copy.prompt', { list: others.map((d, i) => `${i + 1}. ${d.name}`).join('\n') }));
if (!targetId) return; if (!targetId) return;
const target = others[parseInt(targetId) - 1]; const target = others[parseInt(targetId) - 1];
if (!target) { showToast('Invalid selection', 'error'); return; } if (!target) { showToast(t('device.copy.invalid_selection'), 'error'); return; }
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch(`/api/assignments/device/${device.id}/copy-to/${target.id}`, { const res = await fetch(`/api/assignments/device/${device.id}/copy-to/${target.id}`, {
@ -618,7 +667,7 @@ async function setupActions(device) {
body: JSON.stringify({ replace: false }) body: JSON.stringify({ replace: false })
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) showToast(`Copied ${data.copied} items to ${target.name}`, 'success'); if (res.ok) showToast(t('device.copy.toast', { n: data.copied, device: target.name }), 'success');
else showToast(data.error, 'error'); else showToast(data.error, 'error');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}); });
@ -630,50 +679,62 @@ async function setupActions(device) {
deleteBtn?.addEventListener('click', async () => { deleteBtn?.addEventListener('click', async () => {
if (deleteConfirming) { if (deleteConfirming) {
try { try {
deleteBtn.textContent = 'Removing...'; deleteBtn.textContent = t('device.toast.removing');
deleteBtn.disabled = true; deleteBtn.disabled = true;
await api.deleteDevice(device.id); await api.deleteDevice(device.id);
showToast('Display removed', 'success'); showToast(t('device.toast.removed'), 'success');
window.location.hash = '/'; window.location.hash = '/';
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
deleteBtn.textContent = 'Remove'; deleteBtn.textContent = t('device.remove');
deleteBtn.disabled = false; deleteBtn.disabled = false;
deleteConfirming = false; deleteConfirming = false;
} }
return; return;
} }
deleteConfirming = true; deleteConfirming = true;
deleteBtn.textContent = 'Click again to confirm'; deleteBtn.textContent = t('device.click_to_confirm');
deleteBtn.style.background = 'var(--danger)'; deleteBtn.style.background = 'var(--danger)';
deleteBtn.style.color = 'white'; deleteBtn.style.color = 'white';
clearTimeout(deleteTimeout); clearTimeout(deleteTimeout);
deleteTimeout = setTimeout(() => { deleteTimeout = setTimeout(() => {
deleteConfirming = false; deleteConfirming = false;
deleteBtn.textContent = 'Remove'; deleteBtn.textContent = t('device.remove');
deleteBtn.style.background = ''; deleteBtn.style.background = '';
deleteBtn.style.color = ''; deleteBtn.style.color = '';
}, 3000); }, 3000);
}); });
// Send a command and surface the three-state ack as a toast.
// - delivered: device received it (green/success)
// - queued: device is offline, will deliver on reconnect (amber/warning)
// - no_ack / fallback: server didn't respond or queue unavailable (red/error)
function sendWithFeedback(type, cmdLabel, successKey) {
sendCommand(device.id, type, {}, (ack) => {
if (ack?.delivered) showToast(t(successKey), 'success');
else if (ack?.queued) showToast(t('device.toast.command_queued', { cmd: cmdLabel }), 'warning');
else if (ack?.reason === 'no_ack') showToast(t('device.toast.command_no_ack', { cmd: cmdLabel }), 'error');
else showToast(t('device.toast.command_undeliverable', { cmd: cmdLabel }), 'error');
});
}
// Reboot (double-click to confirm) // Reboot (double-click to confirm)
const rebootBtn = document.getElementById('rebootBtn'); const rebootBtn = document.getElementById('rebootBtn');
let rebootConfirming = false; let rebootConfirming = false;
let rebootTimeout = null; let rebootTimeout = null;
rebootBtn?.addEventListener('click', () => { rebootBtn?.addEventListener('click', () => {
if (rebootConfirming) { if (rebootConfirming) {
sendCommand(device.id, 'reboot', {}); sendWithFeedback('reboot', 'Reboot', 'device.toast.reboot_sent');
showToast('Reboot command sent', 'info');
rebootConfirming = false; rebootConfirming = false;
rebootBtn.textContent = 'Reboot Device'; rebootBtn.textContent = t('device.ctl.reboot_device');
return; return;
} }
rebootConfirming = true; rebootConfirming = true;
rebootBtn.textContent = 'Click again to confirm'; rebootBtn.textContent = t('device.click_to_confirm');
clearTimeout(rebootTimeout); clearTimeout(rebootTimeout);
rebootTimeout = setTimeout(() => { rebootTimeout = setTimeout(() => {
rebootConfirming = false; rebootConfirming = false;
rebootBtn.textContent = 'Reboot Device'; rebootBtn.textContent = t('device.ctl.reboot_device');
}, 3000); }, 3000);
}); });
@ -683,20 +744,19 @@ async function setupActions(device) {
let shutdownTimeout = null; let shutdownTimeout = null;
shutdownBtn?.addEventListener('click', () => { shutdownBtn?.addEventListener('click', () => {
if (shutdownConfirming) { if (shutdownConfirming) {
sendCommand(device.id, 'shutdown', {}); sendWithFeedback('shutdown', 'Shutdown', 'device.toast.shutdown_sent');
showToast('Shutdown command sent', 'info');
shutdownConfirming = false; shutdownConfirming = false;
shutdownBtn.textContent = 'Shutdown'; shutdownBtn.textContent = t('device.ctl.shutdown');
return; return;
} }
shutdownConfirming = true; shutdownConfirming = true;
shutdownBtn.textContent = 'Click again to confirm'; shutdownBtn.textContent = t('device.click_to_confirm');
shutdownBtn.style.background = 'var(--danger)'; shutdownBtn.style.background = 'var(--danger)';
shutdownBtn.style.color = 'white'; shutdownBtn.style.color = 'white';
clearTimeout(shutdownTimeout); clearTimeout(shutdownTimeout);
shutdownTimeout = setTimeout(() => { shutdownTimeout = setTimeout(() => {
shutdownConfirming = false; shutdownConfirming = false;
shutdownBtn.textContent = 'Shutdown'; shutdownBtn.textContent = t('device.ctl.shutdown');
shutdownBtn.style.background = ''; shutdownBtn.style.background = '';
shutdownBtn.style.color = ''; shutdownBtn.style.color = '';
}, 3000); }, 3000);
@ -704,26 +764,22 @@ async function setupActions(device) {
// Screen Off // Screen Off
document.getElementById('screenOffBtn')?.addEventListener('click', () => { document.getElementById('screenOffBtn')?.addEventListener('click', () => {
sendCommand(device.id, 'screen_off', {}); sendWithFeedback('screen_off', 'Screen off', 'device.toast.screen_off_sent');
showToast('Screen off command sent', 'info');
}); });
// Screen On // Screen On
document.getElementById('screenOnBtn')?.addEventListener('click', () => { document.getElementById('screenOnBtn')?.addEventListener('click', () => {
sendCommand(device.id, 'screen_on', {}); sendWithFeedback('screen_on', 'Screen on', 'device.toast.screen_on_sent');
showToast('Screen on command sent', 'info');
}); });
// Launch Player // Launch Player
document.getElementById('launchAppBtn')?.addEventListener('click', () => { document.getElementById('launchAppBtn')?.addEventListener('click', () => {
sendCommand(device.id, 'launch', {}); sendWithFeedback('launch', 'Launch', 'device.toast.launch_sent');
showToast('Launch command sent', 'info');
}); });
// Force Update // Force Update
document.getElementById('forceUpdateBtn')?.addEventListener('click', () => { document.getElementById('forceUpdateBtn')?.addEventListener('click', () => {
sendCommand(device.id, 'update', {}); sendWithFeedback('update', 'Update', 'device.toast.update_triggered');
showToast('Update check triggered', 'info');
}); });
} }
@ -741,7 +797,7 @@ function setupRemote(device) {
startBtn.style.display = 'none'; startBtn.style.display = 'none';
stopBtn.style.display = ''; stopBtn.style.display = '';
overlay.style.display = 'none'; overlay.style.display = 'none';
showToast('Remote session started', 'info'); showToast(t('device.toast.remote_started'), 'info');
}); });
stopBtn?.addEventListener('click', () => { stopBtn?.addEventListener('click', () => {
@ -782,7 +838,7 @@ async function setupPlaylistActions(device) {
layouts.filter(l => !l.is_template).forEach(l => { layouts.filter(l => !l.is_template).forEach(l => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = l.id; opt.value = l.id;
opt.textContent = `${l.name} (${l.zones?.length || 0} zones)`; opt.textContent = t('device.layout.zones_count', { name: l.name, n: l.zones?.length || 0 });
if (device.layout_id === l.id) opt.selected = true; if (device.layout_id === l.id) opt.selected = true;
select.appendChild(opt); select.appendChild(opt);
}); });
@ -790,7 +846,7 @@ async function setupPlaylistActions(device) {
layouts.filter(l => l.is_template).forEach(l => { layouts.filter(l => l.is_template).forEach(l => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = l.id; opt.value = l.id;
opt.textContent = `[Template] ${l.name} (${l.zones?.length || 0} zones)`; opt.textContent = t('device.layout.template_zones_count', { name: l.name, n: l.zones?.length || 0 });
if (device.layout_id === l.id) opt.selected = true; if (device.layout_id === l.id) opt.selected = true;
select.appendChild(opt); select.appendChild(opt);
}); });
@ -808,7 +864,7 @@ async function setupPlaylistActions(device) {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ layout_id: layoutId || null }) body: JSON.stringify({ layout_id: layoutId || null })
}); });
showToast(layoutId ? 'Layout applied' : 'Switched to fullscreen', 'success'); showToast(layoutId ? t('device.toast.layout_applied') : t('device.toast.switched_to_fullscreen'), 'success');
// Reload the device page to show updated zone selectors, stay on playlist tab // Reload the device page to show updated zone selectors, stay on playlist tab
loadDevice(device.id, 'playlist'); loadDevice(device.id, 'playlist');
} catch (err) { } catch (err) {
@ -828,26 +884,38 @@ async function setupPlaylistActions(device) {
fetch('/api/kiosk', { headers }).then(r => r.json()), fetch('/api/kiosk', { headers }).then(r => r.json()),
]); ]);
// Get layout zones if device has a layout assigned // Get layout zones if device has a layout assigned. We track
// zonesFetchFailed separately so the modal can distinguish "fetch
// broke" from "fetch succeeded, layout genuinely has no zones" -
// both end with zones=[] but the user message differs.
// The !res.ok throw is required because fetch only rejects on network
// errors; an HTTP 403/404 would otherwise json-parse into {error: ...}
// and zones would silently be [].
let zones = []; let zones = [];
let zonesFetchFailed = false;
if (device.layout_id) { if (device.layout_id) {
try { try {
const layout = await fetch(`/api/layouts/${device.layout_id}`, { headers }).then(r => r.json()); const res = await fetch(`/api/layouts/${device.layout_id}`, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const layout = await res.json();
zones = layout.zones || []; zones = layout.zones || [];
} catch {} } catch (e) {
console.warn('Failed to load layout for zone picker:', e.message);
zonesFetchFailed = true;
}
} }
if (!content.length && !widgets.length && !kioskPages.length) { if (!content.length && !widgets.length && !kioskPages.length) {
showToast('No content, widgets, or kiosk pages yet. Create something first!', 'error'); showToast(t('device.assign.empty_all'), 'error');
return; return;
} }
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'modal-overlay'; modal.className = 'modal-overlay';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal" style="width:650px"> <div class="modal" style="max-width:650px;width:95vw">
<div class="modal-header"> <div class="modal-header">
<h3>Add to Playlist</h3> <h3>${t('device.assign.modal_title')}</h3>
<button class="btn-icon" id="closeAssignModal"> <button class="btn-icon" id="closeAssignModal">
<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"/>
@ -855,24 +923,30 @@ async function setupPlaylistActions(device) {
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
${zones.length > 0 ? `
<div class="form-group"> <div class="form-group">
<label>Zone</label> <label>${t('device.assign.zone_label')}</label>
${zones.length > 0 ? `
<select id="assignZone" class="input" style="background:var(--bg-input)"> <select id="assignZone" class="input" style="background:var(--bg-input)">
<option value="">Default (fullscreen)</option> <option value="">${t('device.assign.zone_default')}</option>
${zones.map(z => `<option value="${z.id}">${z.name} (${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}%)</option>`).join('')} ${zones.map(z => `<option value="${z.id}">${z.name} (${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}%)</option>`).join('')}
</select> </select>
` : !device.layout_id ? `
<div style="font-size:12px;color:var(--text-muted);padding:6px 0;line-height:1.5">${t('device.assign.zone_no_layout')}</div>
` : zonesFetchFailed ? `
<div style="font-size:12px;color:var(--danger);padding:6px 0;line-height:1.5">${t('device.assign.zone_load_failed')}</div>
` : `
<div style="font-size:12px;color:var(--text-muted);padding:6px 0;line-height:1.5">${t('device.assign.zone_empty_layout')}</div>
`}
</div> </div>
` : ''}
<div class="form-group"> <div class="form-group">
<label>Display Duration (seconds, for images/widgets)</label> <label>${t('device.assign.duration_label')}</label>
<input type="number" id="assignDuration" class="input" value="10" min="1" max="3600"> <input type="number" id="assignDuration" class="input" value="10" min="1" max="3600">
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:12px"> <div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:12px">
<div class="assign-tab active" data-tab="media" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid var(--accent);color:var(--accent)">Media (${content.length})</div> <div class="assign-tab active" data-tab="media" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid var(--accent);color:var(--accent)">${t('device.assign.tab.media', { n: content.length })}</div>
<div class="assign-tab" data-tab="widgets" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;color:var(--text-secondary)">Widgets (${widgets.length})</div> <div class="assign-tab" data-tab="widgets" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;color:var(--text-secondary)">${t('device.assign.tab.widgets', { n: widgets.length })}</div>
<div class="assign-tab" data-tab="kiosk" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;color:var(--text-secondary)">Kiosk (${kioskPages.length})</div> <div class="assign-tab" data-tab="kiosk" style="padding:8px 16px;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;color:var(--text-secondary)">${t('device.assign.tab.kiosk', { n: kioskPages.length })}</div>
</div> </div>
<!-- Media grid --> <!-- Media grid -->
<div class="assign-content-grid" id="assignMedia"> <div class="assign-content-grid" id="assignMedia">
@ -888,9 +962,9 @@ async function setupPlaylistActions(device) {
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>` </div>`
} }
<div class="assign-content-item-name">${c.filename}</div> <div class="assign-content-item-name">${esc(c.filename)}</div>
</div> </div>
`).join('') || '<p style="color:var(--text-muted);padding:16px;text-align:center">No media uploaded yet</p>'} `).join('') || `<p style="color:var(--text-muted);padding:16px;text-align:center">${t('device.assign.no_media')}</p>`}
</div> </div>
<!-- Widgets grid --> <!-- Widgets grid -->
<div class="assign-content-grid" id="assignWidgets" style="display:none"> <div class="assign-content-grid" id="assignWidgets" style="display:none">
@ -903,7 +977,7 @@ async function setupPlaylistActions(device) {
</div> </div>
<div class="assign-content-item-name">${w.name}</div> <div class="assign-content-item-name">${w.name}</div>
</div>`; </div>`;
}).join('') || '<p style="color:var(--text-muted);padding:16px;text-align:center">No widgets created yet. <a href="#/widgets" style="color:var(--accent)">Create one</a></p>'} }).join('') || `<p style="color:var(--text-muted);padding:16px;text-align:center">${t('device.assign.no_widgets')} <a href="#/widgets" style="color:var(--accent)">${t('device.assign.create_one')}</a></p>`}
</div> </div>
<!-- Kiosk grid --> <!-- Kiosk grid -->
<div class="assign-content-grid" id="assignKiosk" style="display:none"> <div class="assign-content-grid" id="assignKiosk" style="display:none">
@ -912,12 +986,12 @@ async function setupPlaylistActions(device) {
<div style="aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:32px">&#128433;</div> <div style="aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;background:var(--bg-primary);font-size:32px">&#128433;</div>
<div class="assign-content-item-name">${k.name}</div> <div class="assign-content-item-name">${k.name}</div>
</div> </div>
`).join('') || '<p style="color:var(--text-muted);padding:16px;text-align:center">No kiosk pages yet. <a href="#/kiosk" style="color:var(--accent)">Create one</a></p>'} `).join('') || `<p style="color:var(--text-muted);padding:16px;text-align:center">${t('device.assign.no_kiosk')} <a href="#/kiosk" style="color:var(--accent)">${t('device.assign.create_one')}</a></p>`}
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" id="cancelAssign">Cancel</button> <button class="btn btn-secondary" id="cancelAssign">${t('common.cancel')}</button>
<button class="btn btn-primary" id="confirmAssign">Add Selected</button> <button class="btn btn-primary" id="confirmAssign">${t('device.assign.add_selected')}</button>
</div> </div>
</div> </div>
`; `;
@ -949,7 +1023,7 @@ async function setupPlaylistActions(device) {
modal.querySelector('#cancelAssign').onclick = () => modal.remove(); modal.querySelector('#cancelAssign').onclick = () => modal.remove();
modal.querySelector('#confirmAssign').onclick = async () => { modal.querySelector('#confirmAssign').onclick = async () => {
if (!selectedId) { if (!selectedId) {
showToast('Select something first', 'error'); showToast(t('device.assign.select_first'), 'error');
return; return;
} }
const duration = parseInt(modal.querySelector('#assignDuration').value) || 10; const duration = parseInt(modal.querySelector('#assignDuration').value) || 10;
@ -965,16 +1039,14 @@ async function setupPlaylistActions(device) {
const wRes = await fetch('/api/widgets', { const wRes = await fetch('/api/widgets', {
method: 'POST', method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' }, headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ widget_type: 'webpage', name: `Kiosk: ${kioskPages.find(k => k.id === selectedId)?.name || 'Page'}`, config: { url: `${serverUrl}/api/kiosk/${selectedId}/render` } }) body: JSON.stringify({ widget_type: 'webpage', name: t('device.assign.kiosk_widget_name', { name: kioskPages.find(k => k.id === selectedId)?.name || 'Page' }), config: { url: `${serverUrl}/api/kiosk/${selectedId}/render` } })
}); });
const widget = await wRes.json(); const widget = await wRes.json();
await api.addAssignment(device.id, { widget_id: widget.id, duration_sec: 0 }); await api.addAssignment(device.id, { widget_id: widget.id, duration_sec: 0 });
} }
modal.remove(); modal.remove();
showToast('Added to playlist', 'success'); showToast(t('device.toast.added_to_playlist'), 'success');
const assignments = await api.getAssignments(device.id); loadDevice(device.id, 'playlist');
document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments);
attachRemoveHandlers(device);
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -988,39 +1060,46 @@ async function setupPlaylistActions(device) {
} }
function attachRemoveHandlers(device) { function attachRemoveHandlers(device) {
// Populate zone selectors if device has a layout // Populate zone selectors if device has a layout. The current zone_id for
// each assignment is read from data-current-zone-id on the .zone-select
// element (stashed at render time from a.zone_id); no DOM-scraping.
// Fetch errors are logged - the dropdowns simply stay hidden (display:none
// is the default from the render), same end-state as before but no longer
// silent.
if (device.layout_id) { if (device.layout_id) {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` }}) fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` }})
.then(r => r.json()) .then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(layout => { .then(layout => {
const zones = layout.zones || []; const zones = layout.zones || [];
document.querySelectorAll('.zone-select').forEach(select => { document.querySelectorAll('.zone-select').forEach(select => {
select.style.display = ''; select.style.display = '';
const assignmentId = select.dataset.assignmentId; const assignmentId = select.dataset.assignmentId;
// Find current zone_id from the playlist item's data const currentZoneId = select.dataset.currentZoneId || '';
const zoneText = select.closest('.playlist-item')?.querySelector('[style*="color:var(--accent)"]')?.textContent || '';
zones.forEach(z => { zones.forEach(z => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = z.id; opt.value = z.id;
opt.textContent = z.name; opt.textContent = z.name;
select.appendChild(opt); select.appendChild(opt);
}); });
// Set current value by matching zone_id from the meta text if (currentZoneId) select.value = currentZoneId;
const currentAssignment = document.querySelector(`.playlist-item[data-assignment-id="${assignmentId}"]`);
if (currentAssignment) {
const meta = currentAssignment.querySelector('.playlist-item-meta')?.innerHTML || '';
const zoneMatch = zones.find(z => meta.includes(z.id.slice(0, 8)));
if (zoneMatch) select.value = zoneMatch.id;
}
select.onchange = async () => { select.onchange = async () => {
try { try {
await api.updateAssignment(assignmentId, { zone_id: select.value || null }); await api.updateAssignment(assignmentId, { zone_id: select.value || null });
showToast(`Zone updated`, 'success'); showToast(t('device.toast.zone_updated'), 'success');
loadDevice(device.id, 'playlist');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };
}); });
}).catch(() => {}); })
.catch(e => {
// No toast - fires once per device-detail load, would be annoying for
// a layout misconfig that's already surfaced via the modal info row.
console.warn('Failed to load layout for edit-zone dropdowns:', e.message);
});
} }
// Mute toggle buttons // Mute toggle buttons
@ -1031,10 +1110,8 @@ function attachRemoveHandlers(device) {
const currentlyMuted = btn.dataset.muted === '1'; const currentlyMuted = btn.dataset.muted === '1';
try { try {
await api.updateAssignment(id, { muted: !currentlyMuted }); await api.updateAssignment(id, { muted: !currentlyMuted });
showToast(currentlyMuted ? 'Unmuted' : 'Muted', 'success'); showToast(currentlyMuted ? t('device.toast.unmuted') : t('device.toast.muted'), 'success');
const assignments = await api.getAssignments(device.id); loadDevice(device.id, 'playlist');
document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments);
attachRemoveHandlers(device);
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}); });
}); });
@ -1046,10 +1123,8 @@ function attachRemoveHandlers(device) {
const id = btn.dataset.removeAssignment; const id = btn.dataset.removeAssignment;
try { try {
await api.deleteAssignment(id); await api.deleteAssignment(id);
showToast('Content removed from playlist', 'success'); showToast(t('device.toast.removed_from_playlist'), 'success');
const assignments = await api.getAssignments(device.id); loadDevice(device.id, 'playlist');
document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments);
attachRemoveHandlers(device);
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -1099,13 +1174,11 @@ function attachRemoveHandlers(device) {
try { try {
await api.reorderAssignments(device.id, newOrder); await api.reorderAssignments(device.id, newOrder);
showToast('Playlist reordered', 'success'); showToast(t('device.toast.playlist_reordered'), 'success');
loadDevice(device.id, 'playlist');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
// Reload to revert loadDevice(device.id, 'playlist');
const assignments = await api.getAssignments(device.id);
container.innerHTML = renderPlaylist(assignments);
attachRemoveHandlers(device);
} }
}); });
}); });
@ -1154,7 +1227,11 @@ function renderUptimeTimeline(uptimeData, statusLog = []) {
const knownSlots = slotStatus.filter(s => s !== 'unknown').length; const knownSlots = slotStatus.filter(s => s !== 'unknown').length;
const onlineSlots = slotStatus.filter(s => s === 'online').length; const onlineSlots = slotStatus.filter(s => s === 'online').length;
const uptimePct = knownSlots > 0 ? Math.round((onlineSlots / knownSlots) * 100) : 0; const uptimePct = knownSlots > 0 ? Math.round((onlineSlots / knownSlots) * 100) : 0;
if (percentEl) percentEl.textContent = `${uptimePct}% uptime (${knownSlots > 0 ? knownSlots * 15 + 'min tracked' : 'no data'})`; if (percentEl) {
percentEl.textContent = knownSlots > 0
? t('device.timeline.uptime_pct_tracked', { pct: uptimePct, n: knownSlots * 15 })
: t('device.timeline.uptime_pct_no_data', { pct: uptimePct });
}
// Color map // Color map
const colors = { const colors = {
@ -1168,7 +1245,7 @@ function renderUptimeTimeline(uptimeData, statusLog = []) {
timeline.innerHTML = slotStatus.map((status, i) => { timeline.innerHTML = slotStatus.map((status, i) => {
const time = new Date((dayAgo + i * slotDuration) * 1000); const time = new Date((dayAgo + i * slotDuration) * 1000);
const label = time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const label = time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const statusLabel = status === 'unknown' ? 'No data' : status.charAt(0).toUpperCase() + status.slice(1); const statusLabel = status === 'unknown' ? t('device.timeline.no_data') : status === 'online' ? t('device.timeline.online') : t('device.timeline.offline');
return `<div style="flex:1;background:${colors[status]};opacity:${opacities[status]}" title="${label} - ${statusLabel}"></div>`; return `<div style="flex:1;background:${colors[status]};opacity:${opacities[status]}" title="${label} - ${statusLabel}"></div>`;
}).join(''); }).join('');
} }
@ -1179,11 +1256,11 @@ function updateTelemetryDisplay(telemetry) {
if (el) el.textContent = val; if (el) el.textContent = val;
}; };
if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%'); if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%');
if (telemetry.storage_free_mb) update('telStorage', formatBytes(telemetry.storage_free_mb) + ' free'); if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) }));
if (telemetry.wifi_ssid) update('telWifi', telemetry.wifi_ssid); if (telemetry.wifi_ssid) update('telWifi', telemetry.wifi_ssid);
if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm'); if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm');
if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds)); if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds));
if (telemetry.ram_free_mb) update('telRam', formatBytes(telemetry.ram_free_mb) + ' free'); if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) }));
if (telemetry.cpu_usage != null) update('telCpu', telemetry.cpu_usage.toFixed(1) + '%'); if (telemetry.cpu_usage != null) update('telCpu', telemetry.cpu_usage.toFixed(1) + '%');
} }

View file

@ -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">
@ -25,7 +31,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 +52,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>
`; `;

View file

@ -1,4 +1,5 @@
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());
@ -14,17 +15,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 +35,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 => `
@ -44,27 +45,26 @@ async function renderList(container) {
</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">${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="${p.id}" data-kiosk-name="${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 +73,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 +82,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>${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="${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="${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="${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="${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="${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="${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 +135,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="${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="${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="${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">&#10005;</button> <button class="btn-icon" style="color:var(--danger)" data-remove-btn="${i}" title="${t('common.delete')}">&#10005;</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="${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 +165,7 @@ async function renderEditor(container, pageId) {
} }
document.getElementById('addBtnBtn').onclick = () => { document.getElementById('addBtnBtn').onclick = () => {
config.buttons.push({ label: 'New Button', sublabel: '', icon: '&#11088;', action: '', url: '' }); config.buttons.push({ label: t('kiosk.new_button'), sublabel: '', icon: '&#11088;', action: '', url: '' });
renderButtons(); renderButtons();
}; };
@ -190,7 +187,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'); }
}; };

View file

@ -1,5 +1,6 @@
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';
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 +16,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 +42,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 +51,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 +75,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)">
@ -90,14 +90,14 @@ function renderLayoutCard(layout, isTemplate) {
</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">${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="${layout.name}" style="margin-left:4px">${t('common.delete')}</button>
</div> </div>
</div> </div>
`; `;
@ -107,44 +107,43 @@ 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">${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> <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 +155,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 +168,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 +197,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,7 +224,6 @@ 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);
@ -256,7 +251,6 @@ async function renderEditor(container, layoutId) {
document.getElementById('propType').value = z.zone_type; document.getElementById('propType').value = z.zone_type;
} }
// Property input handlers
['propName', 'propX', 'propY', 'propW', 'propH', 'propType'].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;
@ -272,7 +266,7 @@ async function renderEditor(container, layoutId) {
}); });
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: 'cover', background_color: '#000000', sort_order: zones.length });
selectedZone = zones.length - 1; selectedZone = zones.length - 1;
renderZones(); renderZones();
updateProperties(); updateProperties();
@ -288,14 +282,13 @@ async function renderEditor(container, layoutId) {
document.getElementById('saveLayoutBtn').onclick = async () => { document.getElementById('saveLayoutBtn').onclick = async () => {
try { try {
// Delete existing zones and recreate
for (const z of layout.zones || []) { for (const z of layout.zones || []) {
await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' }); await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' });
} }
for (const z of zones) { for (const z of zones) {
await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) }); await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) });
} }
showToast('Layout saved', 'success'); showToast(t('layout.toast.saved'), 'success');
layout = await API(`/layouts/${layoutId}`); layout = await API(`/layouts/${layoutId}`);
zones = layout.zones; zones = layout.zones;
} catch (err) { } catch (err) {

View file

@ -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;
@ -12,10 +13,12 @@ async function loadAuthConfig() {
export async function render(container) { export async function render(container) {
const config = await loadAuthConfig(); const config = await loadAuthConfig();
const isSetup = config.needsSetup; const isSetup = config.needsSetup;
// registration_enabled may be absent on older servers — treat as enabled for back-compat
const canRegister = config.registration_enabled !== false;
container.innerHTML = ` container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;height:100vh;margin-left:calc(-1 * var(--sidebar-width))"> <div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
<div style="width:400px;max-width:90vw"> <div style="width:400px;max-width:100%">
<div style="text-align:center;margin-bottom:32px"> <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"> <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"/>
@ -24,34 +27,34 @@ export async function render(container) {
</svg> </svg>
<h1 style="font-size:24px;font-weight:700;color:var(--accent)">ScreenTinker</h1> <h1 style="font-size:24px;font-weight:700;color:var(--accent)">ScreenTinker</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 +62,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 +98,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 +111,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>
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
<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 +148,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 +158,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 +185,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 +197,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 +205,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 +218,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 +249,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 +279,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'));
} }
}); });
} }

View file

@ -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: '&#128075;', icon: '&#128075;',
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: '&#128229;', icon: '&#128229;',
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">&#129302;</div> <div style="font-size:32px;margin-bottom:8px">&#129302;</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">&#127760;</div> <div style="font-size:32px;margin-bottom:8px">&#127760;</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: '&#128279;', icon: '&#128279;',
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: '&#128228;', icon: '&#128228;',
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">&#128193;</div> <div style="font-size:32px;margin-bottom:8px">&#128193;</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: '&#127881;', icon: '&#127881;',
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 });
} }
} }

View file

@ -1,6 +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 { esc } from '../utils.js';
import { t, tn } from '../i18n.js';
function formatDate(ts) { function formatDate(ts) {
if (!ts) return '--'; if (!ts) return '--';
@ -31,27 +32,25 @@ export function cleanup() {
currentPlaylistId = null; currentPlaylistId = null;
} }
// ==================== LIST VIEW ====================
let showAutoGenerated = true; 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>
<div style="display:flex;gap:8px;align-items:center"> <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"> <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' : ''}> <input type="checkbox" id="showAutoToggle" ${showAutoGenerated ? 'checked' : ''}>
Show auto-generated ${t('playlist.show_auto_generated')}
</label> </label>
<button class="btn btn-primary" id="createPlaylistBtn">+ New Playlist</button> <button class="btn btn-primary" id="createPlaylistBtn">${t('playlist.new_playlist_btn')}</button>
</div> </div>
</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>
`; `;
@ -76,8 +75,8 @@ 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;
@ -87,7 +86,7 @@ async function loadPlaylists() {
if (!filtered.length) { if (!filtered.length) {
grid.innerHTML = ` grid.innerHTML = `
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)"> <div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)">
${playlists.length ? 'All playlists are auto-generated. Toggle "Show auto-generated" to see them.' : ''} ${playlists.length ? t('playlist.all_auto_generated') : ''}
</div> </div>
`; `;
return; return;
@ -98,19 +97,20 @@ async function loadPlaylists() {
<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="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
<div style="font-size:16px;font-weight:600;color:var(--text-primary)">${esc(p.name)}</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)">auto</span>' : ''} ${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>
<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: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="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted)"> <div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted)">
<span>Created ${formatDate(p.created_at)}</span> <span>${t('playlist.created_at', { date: formatDate(p.created_at) })}</span>
${p.display_count ? `<span>${p.display_count} display${p.display_count !== 1 ? 's' : ''}</span>` : ''} ${p.display_count ? `<span>${tn('playlist.display_count', p.display_count)}</span>` : ''}
</div> </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>`;
} }
} }
@ -119,12 +119,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>
`; `;
@ -143,7 +143,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');
@ -154,11 +154,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 {
@ -167,27 +165,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">&larr;</a> <a href="#/playlists" style="color:var(--text-muted);text-decoration:none;font-size:20px" title="${t('playlist.back')}">&larr;</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">Assigned to ${playlist.display_count} display${playlist.display_count !== 1 ? 's' : ''}</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>
@ -197,19 +214,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');
@ -217,6 +261,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;
@ -224,38 +278,45 @@ 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)">${item.widget_id ? t('playlist.item_widget') : esc(item.mime_type || t('playlist.unknown_type'))}</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-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;
@ -263,13 +324,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;
@ -277,14 +338,35 @@ 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-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);
} }
@ -319,27 +401,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;
@ -369,7 +447,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'));
@ -397,11 +475,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'));
@ -412,22 +490,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>
`; `;
@ -437,14 +513,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() {
@ -457,15 +532,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">
@ -475,12 +550,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();
@ -489,24 +563,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;
@ -519,10 +590,8 @@ 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(); });

View file

@ -1,6 +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 { 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());
@ -12,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;
@ -56,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 => `
@ -139,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 };
@ -159,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>${esc(err.message)}</p></div>`; content.innerHTML = `<div class="empty-state"><h3>${t('report.error')}</h3><p>${esc(err.message)}</p></div>`;
} }
} }
} }

View file

@ -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">&lt; 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 &gt;</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');

View file

@ -1,105 +1,137 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { getLanguage, setLanguage, getAvailableLanguages } from '../i18n.js'; import { getLanguage, setLanguage, getAvailableLanguages, t, tn } from '../i18n.js';
import { esc } from '../utils.js'; import { esc, isPlatformAdmin } from '../utils.js';
import { resetBranding } from '../branding.js';
export async function render(container) { export async function render(container) {
const serverUrl = `${window.location.protocol}//${window.location.host}`; const serverUrl = `${window.location.protocol}//${window.location.host}`;
const user = JSON.parse(localStorage.getItem('user') || '{}'); // Fetch fresh user from the server — plan_id and role may have been changed
const isSuperAdmin = user.role === 'superadmin'; // by an admin since login. Fall back to localStorage if the request fails.
let user;
try { user = await api.getMe(); localStorage.setItem('user', JSON.stringify(user)); }
catch { user = JSON.parse(localStorage.getItem('user') || '{}'); }
const isSuperAdmin = isPlatformAdmin(user);
const isAdmin = user.role === 'admin' || isSuperAdmin; const isAdmin = user.role === 'admin' || isSuperAdmin;
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>Settings</h1> <h1>${t('settings.title')}</h1>
<div class="subtitle">Server configuration and setup information</div> <div class="subtitle">${t('settings.subtitle')}</div>
</div> </div>
</div> </div>
<div class="settings-section">
<h3>${t('settings.account')}</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
<div class="form-group"><label>${t('auth.email')}</label><input type="email" class="input" value="${esc(user.email || '')}" disabled></div>
<div class="form-group"><label>${t('auth.name')}</label><input type="text" id="acctName" class="input" value="${esc(user.name || '')}"></div>
</div>
<div class="form-group" style="margin-top:12px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="acctEmailAlerts" ${user.email_alerts ? 'checked' : ''}>
<span>${t('settings.email_alerts')}</span>
</label>
</div>
<button class="btn btn-secondary btn-sm" id="saveAcctBtn">${t('settings.save_profile')}</button>
${user.auth_provider === 'local' ? `
<div style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px">
<h4 style="font-size:14px;margin-bottom:8px">${t('settings.change_password')}</h4>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('settings.password_min_8')}</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
<div class="form-group"><label>${t('settings.current_password')}</label><input type="password" id="acctCurrentPw" class="input" autocomplete="current-password"></div>
<div class="form-group"><label>${t('settings.new_password')}</label><input type="password" id="acctNewPw" class="input" autocomplete="new-password"></div>
<div class="form-group"><label>${t('settings.confirm_new_password')}</label><input type="password" id="acctConfirmPw" class="input" autocomplete="new-password"></div>
</div>
<button class="btn btn-primary btn-sm" id="changePwBtn">${t('settings.change_password')}</button>
</div>
` : `
<p style="color:var(--text-muted);font-size:12px;margin-top:16px">${t('settings.sso_note', { provider: esc(user.auth_provider || 'SSO') })}</p>
`}
</div>
${isAdmin ? ` ${isAdmin ? `
<div class="settings-section"> <div class="settings-section">
<h3>License</h3> <h3>${t('settings.license')}</h3>
<div id="licenseSection"><p style="color:var(--text-muted);font-size:13px">MIT License - all features included.</p></div> <div id="licenseSection"><p style="color:var(--text-muted);font-size:13px">${t('settings.license_mit')}</p></div>
</div> </div>
${isSuperAdmin ? '<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">Platform admin tools are in the <a href="#/admin" style="color:var(--accent)">Admin</a> page.</p>' : ''} ${isSuperAdmin ? `<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('settings.platform_admin_link')} <a href="#/admin" style="color:var(--accent)">${t('nav.admin')}</a> ${t('settings.platform_admin_page_suffix')}</p>` : ''}
<div class="settings-section"> <div class="settings-section">
<h3>User Management</h3> <h3>${t('settings.user_management')}</h3>
<div id="userManagement"><p style="color:var(--text-muted)">Loading users...</p></div> <div id="userManagement"><p style="color:var(--text-muted)">${t('settings.loading_users')}</p></div>
</div> </div>
<div class="settings-section" id="whiteLabelSection"> <div class="settings-section" id="whiteLabelSection">
<h3>White Label / Branding</h3> <h3>${t('settings.white_label')}</h3>
<div id="whiteLabelForm"> <div id="whiteLabelForm">
<p style="color:var(--text-muted);font-size:12px;margin-bottom:16px">Customize the look of your dashboard and player for your clients.</p> <p style="color:var(--text-muted);font-size:12px;margin-bottom:16px">${t('settings.white_label_desc')}</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group"><label>Brand Name</label><input type="text" id="wlBrandName" class="input" placeholder="ScreenTinker"></div> <div class="form-group"><label>${t('settings.brand_name')}</label><input type="text" id="wlBrandName" class="input" placeholder="ScreenTinker"></div>
<div class="form-group"><label>Logo URL</label><input type="text" id="wlLogoUrl" class="input" placeholder="https://..."></div> <div class="form-group"><label>${t('settings.logo_url')}</label><input type="text" id="wlLogoUrl" class="input" placeholder="https://..."></div>
<div class="form-group"><label>Primary Color</label><input type="color" id="wlPrimaryColor" value="#3B82F6" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div> <div class="form-group"><label>${t('settings.primary_color')}</label><input type="color" id="wlPrimaryColor" value="#3B82F6" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
<div class="form-group"><label>Background Color</label><input type="color" id="wlBgColor" value="#111827" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div> <div class="form-group"><label>${t('settings.bg_color')}</label><input type="color" id="wlBgColor" value="#111827" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
<div class="form-group"><label>Custom Domain</label><input type="text" id="wlDomain" class="input" placeholder="signage.yourcompany.com"></div> <div class="form-group"><label>${t('settings.custom_domain')}</label><input type="text" id="wlDomain" class="input" placeholder="signage.yourcompany.com"></div>
<div class="form-group"><label>Favicon URL</label><input type="text" id="wlFavicon" class="input" placeholder="https://..."></div> <div class="form-group"><label>${t('settings.favicon_url')}</label><input type="text" id="wlFavicon" class="input" placeholder="https://..."></div>
</div> </div>
<div class="form-group"><label>Custom CSS (optional)</label><textarea id="wlCustomCss" class="input" rows="3" style="font-family:monospace;font-size:12px" placeholder=":root { --accent: #ff6600; }"></textarea></div> <div class="form-group"><label>${t('settings.custom_css')}</label><textarea id="wlCustomCss" class="input" rows="3" style="font-family:monospace;font-size:12px" placeholder=":root { --accent: #ff6600; }"></textarea></div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px"><input type="checkbox" id="wlHideBranding"> Hide "ScreenTinker" branding</label></div> <div class="form-group"><label style="display:flex;align-items:center;gap:8px"><input type="checkbox" id="wlHideBranding"> ${t('settings.hide_branding')}</label></div>
<button class="btn btn-primary btn-sm" id="saveWhiteLabelBtn">Save Branding</button> <button class="btn btn-primary btn-sm" id="saveWhiteLabelBtn">${t('settings.save_branding')}</button>
<button class="btn btn-secondary btn-sm" id="previewWhiteLabelBtn" style="margin-left:8px">Preview</button> <button class="btn btn-secondary btn-sm" id="previewWhiteLabelBtn" style="margin-left:8px">${t('settings.preview')}</button>
</div> </div>
</div> </div>
` : ''} ` : ''}
<div class="settings-section"> <div class="settings-section">
<h3>Server Information</h3> <h3>${t('settings.server_info')}</h3>
<div class="info-grid"> <div class="info-grid">
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Server URL</div> <div class="info-card-label">${t('settings.server_url')}</div>
<div class="info-card-value small">${serverUrl}</div> <div class="info-card-value small">${serverUrl}</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">Use this URL when setting up the Android app</p> <p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('settings.server_url_hint')}</p>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">API Endpoint</div> <div class="info-card-label">${t('settings.api_endpoint')}</div>
<div class="info-card-value small">${serverUrl}/api</div> <div class="info-card-value small">${serverUrl}/api</div>
</div> </div>
</div> </div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Setup Guide</h3> <h3>${t('settings.setup_guide')}</h3>
<div style="color:var(--text-secondary);font-size:13px;line-height:1.8"> <div style="color:var(--text-secondary);font-size:13px;line-height:1.8">
<ol style="padding-left:20px;list-style:decimal"> <ol style="padding-left:20px;list-style:decimal">
<li>Install the <strong>ScreenTinker</strong> APK on your Apolosign portable TV via sideloading</li> <li>${t('settings.setup_step_1')}</li>
<li>Open the app and enter this server URL: <code style="background:var(--bg-input);padding:2px 6px;border-radius:4px">${serverUrl}</code></li> <li>${t('settings.setup_step_2_prefix')} <code style="background:var(--bg-input);padding:2px 6px;border-radius:4px">${serverUrl}</code></li>
<li>The app will display a <strong>6-digit pairing code</strong></li> <li>${t('settings.setup_step_3')}</li>
<li>Click <strong>"Add Display"</strong> on the dashboard and enter the pairing code</li> <li>${t('settings.setup_step_4')}</li>
<li>Upload content in the <strong>Content Library</strong></li> <li>${t('settings.setup_step_5')}</li>
<li>Assign content to the display's <strong>Playlist</strong></li> <li>${t('settings.setup_step_6')}</li>
</ol> </ol>
</div> </div>
</div> </div>
${isAdmin ? `
` : ''}
<div class="settings-section"> <div class="settings-section">
<h3>Your Data</h3> <h3>${t('settings.your_data')}</h3>
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">Export or import your devices, content, layouts, schedules, and all settings. Use this to migrate between cloud and self-hosted instances.</p> <p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">${t('settings.your_data_desc')}</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="exportDataBtn"> <button class="btn btn-secondary btn-sm" id="exportDataBtn">
<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 My Data ${t('settings.export_my_data')}
</button> </button>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);cursor:pointer"> <label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);cursor:pointer">
<input type="checkbox" id="exportIncludeFiles"> Include media files (ZIP) <input type="checkbox" id="exportIncludeFiles"> ${t('settings.include_media_zip')}
</label> </label>
<button class="btn btn-secondary btn-sm" id="importDataBtn"> <button class="btn btn-secondary btn-sm" id="importDataBtn">
<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="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/> <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"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg> </svg>
Import Data ${t('settings.import_data')}
</button> </button>
<input type="file" id="importFileInput" accept=".json,.zip" style="display:none"> <input type="file" id="importFileInput" accept=".json,.zip" style="display:none">
</div> </div>
@ -107,23 +139,23 @@ export async function render(container) {
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Language</h3> <h3>${t('settings.language')}</h3>
<select id="langSelect" class="input" style="width:200px;background:var(--bg-input)"> <select id="langSelect" class="input" style="width:200px;background:var(--bg-input)">
${getAvailableLanguages().map(l => `<option value="${l.code}" ${l.code === getLanguage() ? 'selected' : ''}>${l.name}</option>`).join('')} ${getAvailableLanguages().map(l => `<option value="${l.code}" ${l.code === getLanguage() ? 'selected' : ''}>${l.name}</option>`).join('')}
</select> </select>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>About</h3> <h3>${t('settings.about')}</h3>
<div style="color:var(--text-secondary);font-size:13px"> <div style="color:var(--text-secondary);font-size:13px">
<p><strong>ScreenTinker</strong> v1.4.1</p> <p><strong>ScreenTinker</strong> v1.4.1</p>
<p style="margin-top:4px">Digital signage management system.</p> <p style="margin-top:4px">${t('settings.about_tagline')}</p>
<p style="margin-top:12px"> <p style="margin-top:12px">
<a href="/legal/terms.html" target="_blank" style="color:var(--accent);font-size:12px">Terms of Service</a> <a href="/legal/terms.html" target="_blank" style="color:var(--accent);font-size:12px">${t('auth.terms')}</a>
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
<a href="/legal/privacy.html" target="_blank" style="color:var(--accent);font-size:12px">Privacy Policy</a> <a href="/legal/privacy.html" target="_blank" style="color:var(--accent);font-size:12px">${t('auth.privacy')}</a>
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
<a href="/legal/third-party.html" target="_blank" style="color:var(--accent);font-size:12px">Third-Party Licenses</a> <a href="/legal/third-party.html" target="_blank" style="color:var(--accent);font-size:12px">${t('settings.third_party_licenses')}</a>
</p> </p>
</div> </div>
</div> </div>
@ -148,7 +180,7 @@ export async function render(container) {
if (res.ok) { if (res.ok) {
document.getElementById('supportTokenOutput').value = data.token; document.getElementById('supportTokenOutput').value = data.token;
document.getElementById('supportTokenResult').style.display = 'block'; document.getElementById('supportTokenResult').style.display = 'block';
showToast(`Support token generated (valid ${hours}h)`, 'success'); showToast(t('settings.toast.support_token_generated', { hours }), 'success');
} else showToast(data.error, 'error'); } else showToast(data.error, 'error');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}); });
@ -175,35 +207,35 @@ export async function render(container) {
statusEl.style.background = 'var(--bg-secondary)'; statusEl.style.background = 'var(--bg-secondary)';
statusEl.style.border = '1px solid var(--border)'; statusEl.style.border = '1px solid var(--border)';
statusEl.style.color = 'var(--text-secondary)'; statusEl.style.color = 'var(--text-secondary)';
statusEl.textContent = 'Reading file...'; statusEl.textContent = t('settings.import.reading_file');
try { try {
let data; let data;
if (isZip) { if (isZip) {
// For ZIP, show basic info and skip preview parsing // For ZIP, show basic info and skip preview parsing
data = { format: 'screentinker-export-v1', _isZip: true }; data = { format: 'screentinker-export-v1', _isZip: true };
statusEl.innerHTML = `ZIP export detected: <strong>${esc(file.name)}</strong> (${(file.size / 1048576).toFixed(1)} MB)<br>Contains data + media files.<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`; statusEl.innerHTML = `${t('settings.import.zip_detected', { name: esc(file.name), size: (file.size / 1048576).toFixed(1) })}<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">${t('settings.import.confirm')}</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">${t('common.cancel')}</button>`;
} else { } else {
const text = await file.text(); const text = await file.text();
data = JSON.parse(text); data = JSON.parse(text);
if (!data.format || !data.format.startsWith('screentinker-export')) { if (!data.format || !data.format.startsWith('screentinker-export')) {
statusEl.style.color = 'var(--danger)'; statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Invalid file. Must be a ScreenTinker export JSON or ZIP.'; statusEl.textContent = t('settings.import.invalid_file');
return; return;
} }
const summary = [ const summary = [
data.devices?.length ? `${data.devices.length} devices` : null, data.devices?.length ? t('settings.import.summary_devices', { n: data.devices.length }) : null,
data.content?.length ? `${data.content.length} content items` : null, data.content?.length ? t('settings.import.summary_content', { n: data.content.length }) : null,
data.widgets?.length ? `${data.widgets.length} widgets` : null, data.widgets?.length ? t('settings.import.summary_widgets', { n: data.widgets.length }) : null,
data.layouts?.length ? `${data.layouts.length} layouts` : null, data.layouts?.length ? t('settings.import.summary_layouts', { n: data.layouts.length }) : null,
data.schedules?.length ? `${data.schedules.length} schedules` : null, data.schedules?.length ? t('settings.import.summary_schedules', { n: data.schedules.length }) : null,
data.video_walls?.length ? `${data.video_walls.length} video walls` : null, data.video_walls?.length ? t('settings.import.summary_walls', { n: data.video_walls.length }) : null,
data.kiosk_pages?.length ? `${data.kiosk_pages.length} kiosk pages` : null, data.kiosk_pages?.length ? t('settings.import.summary_kiosk', { n: data.kiosk_pages.length }) : null,
].filter(Boolean).join(', '); ].filter(Boolean).join(', ');
statusEl.innerHTML = `Found: ${esc(summary) || 'empty export'}.<br>From: ${esc(data.user?.email) || 'unknown'} (exported ${esc(data.exported_at?.split('T')[0]) || 'unknown'})<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`; statusEl.innerHTML = `${t('settings.import.found_summary', { summary: esc(summary) || t('settings.import.empty_export'), email: esc(data.user?.email) || t('common.unknown'), date: esc(data.exported_at?.split('T')[0]) || t('common.unknown') })}<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">${t('settings.import.confirm')}</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">${t('common.cancel')}</button>`;
} }
document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; }; document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; };
document.getElementById('confirmImportBtn').onclick = async () => { document.getElementById('confirmImportBtn').onclick = async () => {
statusEl.innerHTML = isZip ? 'Uploading and importing... This may take a moment for large files.' : 'Importing...'; statusEl.innerHTML = isZip ? t('settings.import.uploading_zip') : t('settings.import.importing');
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
let res; let res;
@ -226,34 +258,75 @@ export async function render(container) {
if (res.ok) { if (res.ok) {
const imported = Object.entries(result.stats).filter(([k,v]) => v > 0 && k !== 'files_restored').map(([k,v]) => `${v} ${k}`).join(', '); const imported = Object.entries(result.stats).filter(([k,v]) => v > 0 && k !== 'files_restored').map(([k,v]) => `${v} ${k}`).join(', ');
statusEl.style.color = 'var(--success)'; statusEl.style.color = 'var(--success)';
let html = `Import complete: ${imported}.`; let html = t('settings.import.complete', { imported });
if (result.device_pairings?.length) { if (result.device_pairings?.length) {
html += `<br><br><strong>Device Pairing Codes:</strong><br><table style="margin-top:8px;font-size:12px;border-collapse:collapse">` + html += `<br><br><strong>${t('settings.import.pairing_codes_title')}</strong><br><table style="margin-top:8px;font-size:12px;border-collapse:collapse">` +
result.device_pairings.map(d => `<tr><td style="padding:4px 12px 4px 0">${d.name}</td><td style="font-family:monospace;font-weight:700;font-size:14px;letter-spacing:2px">${d.pairing_code}</td></tr>`).join('') + result.device_pairings.map(d => `<tr><td style="padding:4px 12px 4px 0">${d.name}</td><td style="font-family:monospace;font-weight:700;font-size:14px;letter-spacing:2px">${d.pairing_code}</td></tr>`).join('') +
`</table><br>Enter these codes on each device to re-link them. All assignments and schedules will be preserved.`; `</table><br>${t('settings.import.pairing_codes_hint')}`;
} }
html += `<br><br>${(result.notes || []).map(n => '&bull; ' + n).join('<br>')}`; html += `<br><br>${(result.notes || []).map(n => '&bull; ' + n).join('<br>')}`;
statusEl.innerHTML = html; statusEl.innerHTML = html;
showToast('Data imported successfully', 'success'); showToast(t('settings.toast.import_success'), 'success');
} else { } else {
statusEl.style.color = 'var(--danger)'; statusEl.style.color = 'var(--danger)';
statusEl.textContent = result.error || 'Import failed'; statusEl.textContent = result.error || t('settings.import.failed');
} }
} catch (err) { } catch (err) {
statusEl.style.color = 'var(--danger)'; statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Import failed: ' + err.message; statusEl.textContent = t('settings.import.failed_with_error', { error: err.message });
} }
e.target.value = ''; e.target.value = '';
}; };
} catch (err) { } catch (err) {
statusEl.style.color = 'var(--danger)'; statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Failed to read file: ' + err.message; statusEl.textContent = t('settings.import.read_failed', { error: err.message });
} }
}); });
document.getElementById('langSelect')?.addEventListener('change', (e) => { document.getElementById('langSelect')?.addEventListener('change', (e) => {
// setLanguage dispatches hashchange so the router re-renders the current
// view (including this settings page) with new strings — no refresh needed.
setLanguage(e.target.value); setLanguage(e.target.value);
showToast('Language changed. Refresh for full effect.', 'info'); });
document.getElementById('saveAcctBtn')?.addEventListener('click', async () => {
const name = document.getElementById('acctName').value.trim();
if (!name) return showToast(t('settings.toast.name_required'), 'error');
const email_alerts = !!document.getElementById('acctEmailAlerts')?.checked;
const btn = document.getElementById('saveAcctBtn');
btn.disabled = true;
try {
const updated = await api.updateMe({ name, email_alerts });
const stored = JSON.parse(localStorage.getItem('user') || '{}');
localStorage.setItem('user', JSON.stringify({ ...stored, ...updated }));
showToast(t('settings.toast.profile_saved'), 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
}
});
document.getElementById('changePwBtn')?.addEventListener('click', async () => {
const current = document.getElementById('acctCurrentPw').value;
const next = document.getElementById('acctNewPw').value;
const confirm = document.getElementById('acctConfirmPw').value;
if (!current) return showToast(t('settings.toast.current_password_required'), 'error');
if (next.length < 8) return showToast(t('settings.toast.new_password_min_8'), 'error');
if (next !== confirm) return showToast(t('settings.toast.passwords_dont_match'), 'error');
const btn = document.getElementById('changePwBtn');
btn.disabled = true;
try {
await api.updateMe({ current_password: current, password: next });
document.getElementById('acctCurrentPw').value = '';
document.getElementById('acctNewPw').value = '';
document.getElementById('acctConfirmPw').value = '';
showToast(t('settings.toast.password_changed'), 'success');
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
}
}); });
} }
@ -261,15 +334,16 @@ async function loadWhiteLabel() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const headers = { Authorization: `Bearer ${token}` }; const headers = { Authorization: `Bearer ${token}` };
// Only show white-label for enterprise/superadmin // Only show white-label for enterprise plans or platform admins.
// Use the fresh user cached by render() above, which called api.getMe().
const user = JSON.parse(localStorage.getItem('user') || '{}'); const user = JSON.parse(localStorage.getItem('user') || '{}');
const section = document.getElementById('whiteLabelSection'); const section = document.getElementById('whiteLabelSection');
if (section && user.plan_id !== 'enterprise' && user.role !== 'superadmin') { if (section && user.plan_id !== 'enterprise' && !isPlatformAdmin(user)) {
section.innerHTML = ` section.innerHTML = `
<h3>White Label / Branding</h3> <h3>${t('settings.white_label')}</h3>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center"> <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center">
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:8px">Custom branding is available on the Enterprise plan</p> <p style="color:var(--text-secondary);font-size:14px;margin-bottom:8px">${t('settings.white_label_enterprise_only')}</p>
<a href="#/billing" class="btn btn-secondary btn-sm" style="text-decoration:none">View Plans</a> <a href="#/billing" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('settings.view_plans')}</a>
</div> </div>
`; `;
return; return;
@ -305,7 +379,8 @@ async function loadWhiteLabel() {
hide_branding: document.getElementById('wlHideBranding').checked ? 1 : 0, hide_branding: document.getElementById('wlHideBranding').checked ? 1 : 0,
}) })
}); });
showToast('Branding saved', 'success'); await resetBranding();
showToast(t('settings.toast.branding_saved'), 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -316,7 +391,7 @@ async function loadWhiteLabel() {
const bg = document.getElementById('wlBgColor').value; const bg = document.getElementById('wlBgColor').value;
document.documentElement.style.setProperty('--accent', primary); document.documentElement.style.setProperty('--accent', primary);
document.documentElement.style.setProperty('--bg-primary', bg); document.documentElement.style.setProperty('--bg-primary', bg);
showToast('Preview applied (refresh to reset)', 'info'); showToast(t('settings.toast.preview_applied'), 'info');
}); });
} }
@ -333,14 +408,15 @@ async function loadUsers() {
const currentUser = JSON.parse(localStorage.getItem('user') || '{}'); 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:520px">
<thead> <thead>
<tr style="border-bottom:1px solid var(--border);text-align:left"> <tr style="border-bottom:1px solid var(--border);text-align:left">
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">User</th> <th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_user')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Auth</th> <th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_auth')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Role</th> <th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_role')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Plan</th> <th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_plan')}</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Actions</th> <th style="padding:8px 12px;color:var(--text-muted);font-weight:500">${t('settings.user.col_actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -361,14 +437,16 @@ async function loadUsers() {
${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:10px 12px"> <td style="padding:10px 12px;white-space:nowrap">
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${u.id}">Remove</button>` : '<span style="color:var(--text-muted);font-size:11px">You</span>'} ${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm reset-user-pw-btn" data-user-id="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('settings.user.reset_password')}</button>` : ''}
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${u.id}">${t('settings.user.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('settings.user.you')}</span>`}
</td> </td>
</tr> </tr>
`).join('')} `).join('')}
</tbody> </tbody>
</table> </table>
<p style="color:var(--text-muted);font-size:11px;margin-top:12px">${users.length} user(s) registered</p> </div>
<p style="color:var(--text-muted);font-size:11px;margin-top:12px">${tn('settings.user.count', users.length)}</p>
`; `;
// Plan change handlers // Plan change handlers
@ -378,7 +456,7 @@ async function loadUsers() {
const planId = select.value; const planId = select.value;
try { try {
await api.assignPlan(userId, planId); await api.assignPlan(userId, planId);
showToast('Plan updated', 'success'); showToast(t('settings.toast.plan_updated'), 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
loadUsers(); // Revert loadUsers(); // Revert
@ -386,6 +464,22 @@ async function loadUsers() {
}); });
}); });
// Reset password handlers
el.querySelectorAll('.reset-user-pw-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const email = btn.dataset.userEmail;
const pw = prompt(t('settings.user.prompt_reset_password', { email }));
if (pw === null) return;
if (pw.length < 8) { showToast(t('settings.toast.new_password_min_8'), 'error'); return; }
try {
await api.resetUserPassword(btn.dataset.userId, pw);
showToast(t('settings.toast.password_reset_for_user'), 'success');
} catch (err) {
showToast(err.message, 'error');
}
});
});
// Delete user handlers // Delete user handlers
el.querySelectorAll('.delete-user-btn').forEach(btn => { el.querySelectorAll('.delete-user-btn').forEach(btn => {
let confirming = false; let confirming = false;
@ -393,7 +487,7 @@ async function loadUsers() {
if (confirming) { if (confirming) {
try { try {
await api.deleteUser(btn.dataset.userId); await api.deleteUser(btn.dataset.userId);
showToast('User removed', 'success'); showToast(t('settings.toast.user_removed'), 'success');
loadUsers(); loadUsers();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -401,12 +495,12 @@ async function loadUsers() {
return; return;
} }
confirming = true; confirming = true;
btn.textContent = 'Confirm?'; btn.textContent = t('settings.user.confirm');
btn.style.background = 'var(--danger)'; btn.style.background = 'var(--danger)';
btn.style.color = 'white'; btn.style.color = 'white';
setTimeout(() => { setTimeout(() => {
confirming = false; confirming = false;
btn.textContent = 'Remove'; btn.textContent = t('settings.user.remove');
btn.style.background = ''; btn.style.background = '';
btn.style.color = ''; btn.style.color = '';
}, 3000); }, 3000);

View file

@ -1,5 +1,6 @@
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';
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,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>Teams <span class="help-tip" data-tip="Create teams to share devices with other users. Owners manage the team, editors can change content/playlists, viewers can only monitor.">?</span></h1><div class="subtitle">Manage teams and shared access</div></div> <div><h1>${t('team.title')} <span class="help-tip" data-tip="${t('team.help_tip')}">?</span></h1><div class="subtitle">${t('team.subtitle')}</div></div>
<button class="btn btn-primary" id="newTeamBtn"> <button class="btn btn-primary" id="newTeamBtn">
<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 Team ${t('team.new_team')}
</button> </button>
</div> </div>
<div id="teamsList"></div> <div id="teamsList"></div>
`; `;
document.getElementById('newTeamBtn').onclick = async () => { document.getElementById('newTeamBtn').onclick = async () => {
const name = prompt('Team name:'); const name = prompt(t('team.prompt_name'));
if (!name) return; if (!name) return;
const team = await API('/teams', { method: 'POST', body: JSON.stringify({ name }) }); const team = await API('/teams', { method: 'POST', body: JSON.stringify({ name }) });
window.location.hash = `#/team/${team.id}`; window.location.hash = `#/team/${team.id}`;
@ -36,19 +37,19 @@ async function renderList(container) {
const list = document.getElementById('teamsList'); const list = document.getElementById('teamsList');
if (!teams.length) { if (!teams.length) {
list.innerHTML = '<div class="empty-state"><h3>No teams yet</h3><p>Create a team to share devices with other users.</p></div>'; list.innerHTML = `<div class="empty-state"><h3>${t('team.empty_title')}</h3><p>${t('team.empty_desc')}</p></div>`;
return; return;
} }
list.innerHTML = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px"> list.innerHTML = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px">
${teams.map(t => ` ${teams.map(team => `
<div class="content-item" style="cursor:pointer" onclick="window.location.hash='#/team/${t.id}'"> <div class="content-item" style="cursor:pointer" onclick="window.location.hash='#/team/${team.id}'">
<div style="padding:20px"> <div style="padding:20px">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<div style="width:40px;height:40px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:white">${t.name[0].toUpperCase()}</div> <div style="width:40px;height:40px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:white">${team.name[0].toUpperCase()}</div>
<div> <div>
<div style="font-weight:600;font-size:16px">${t.name}</div> <div style="font-weight:600;font-size:16px">${team.name}</div>
<div style="font-size:12px;color:var(--text-muted)">Your role: ${t.my_role} &middot; ${t.member_count} member(s)</div> <div style="font-size:12px;color:var(--text-muted)">${t('team.your_role', { role: team.my_role })} &middot; ${tn('team.member_count', team.member_count)}</div>
</div> </div>
</div> </div>
</div> </div>
@ -66,28 +67,27 @@ async function renderTeamDetail(container, teamId) {
API(`/teams/${teamId}/devices`), API(`/teams/${teamId}/devices`),
api.getDevices() api.getDevices()
]); ]);
} catch { container.innerHTML = '<div class="empty-state"><h3>Team not found</h3></div>'; return; } } catch { container.innerHTML = `<div class="empty-state"><h3>${t('team.not_found')}</h3></div>`; return; }
const unassignedDevices = allDevices.filter(d => !d.team_id || d.team_id !== teamId); const unassignedDevices = allDevices.filter(d => !d.team_id || d.team_id !== teamId);
container.innerHTML = ` container.innerHTML = `
<a href="#/teams" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px"> <a href="#/teams" 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 Teams ${t('team.back')}
</a> </a>
<div class="page-header"> <div class="page-header">
<h1>${team.name}</h1> <h1>${team.name}</h1>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-danger btn-sm" id="deleteTeamBtn">Delete Team</button> <button class="btn btn-danger btn-sm" id="deleteTeamBtn">${t('team.delete_team')}</button>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
<!-- Members -->
<div class="settings-section" style="margin:0"> <div class="settings-section" style="margin:0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="font-size:15px">Members (${team.members?.length || 0})</h3> <h3 style="font-size:15px">${t('team.members_count', { n: team.members?.length || 0 })}</h3>
<button class="btn btn-secondary btn-sm" id="inviteMemberBtn">+ Invite</button> <button class="btn btn-secondary btn-sm" id="inviteMemberBtn">${t('team.invite')}</button>
</div> </div>
<div id="membersList"> <div id="membersList">
${(team.members || []).map(m => ` ${(team.members || []).map(m => `
@ -97,23 +97,22 @@ async function renderTeamDetail(container, teamId) {
<div style="font-size:13px;font-weight:500">${m.user_name || m.email}</div> <div style="font-size:13px;font-weight:500">${m.user_name || m.email}</div>
<div style="font-size:11px;color:var(--text-muted)">${m.email}</div> <div style="font-size:11px;color:var(--text-muted)">${m.email}</div>
</div> </div>
<select class="input" style="width:100px;background:var(--bg-input);font-size:12px;padding:4px 8px" data-member-id="${m.user_id}" ${m.role === 'owner' ? 'disabled' : ''}> <select class="input" style="max-width:100px;width:100%;background:var(--bg-input);font-size:12px;padding:4px 8px" data-member-id="${m.user_id}" ${m.role === 'owner' ? 'disabled' : ''}>
<option value="viewer" ${m.role === 'viewer' ? 'selected' : ''}>Viewer</option> <option value="viewer" ${m.role === 'viewer' ? 'selected' : ''}>${t('team.role_viewer')}</option>
<option value="editor" ${m.role === 'editor' ? 'selected' : ''}>Editor</option> <option value="editor" ${m.role === 'editor' ? 'selected' : ''}>${t('team.role_editor')}</option>
<option value="owner" ${m.role === 'owner' ? 'selected' : ''}>Owner</option> <option value="owner" ${m.role === 'owner' ? 'selected' : ''}>${t('team.role_owner')}</option>
</select> </select>
${m.role !== 'owner' ? `<button class="btn-icon" data-remove-member="${m.user_id}" style="color:var(--danger)" title="Remove">&#10005;</button>` : ''} ${m.role !== 'owner' ? `<button class="btn-icon" data-remove-member="${m.user_id}" style="color:var(--danger)" title="${t('team.remove')}">&#10005;</button>` : ''}
</div> </div>
`).join('') || '<p style="color:var(--text-muted);font-size:13px">No members yet</p>'} `).join('') || `<p style="color:var(--text-muted);font-size:13px">${t('team.no_members')}</p>`}
</div> </div>
</div> </div>
<!-- Devices -->
<div class="settings-section" style="margin:0"> <div class="settings-section" style="margin:0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="font-size:15px">Shared Devices (${devices.length})</h3> <h3 style="font-size:15px">${t('team.shared_devices', { n: devices.length })}</h3>
<select id="addDeviceToTeam" class="input" style="width:200px;background:var(--bg-input);font-size:12px"> <select id="addDeviceToTeam" class="input" style="max-width:200px;width:100%;background:var(--bg-input);font-size:12px">
<option value="">+ Add device...</option> <option value="">${t('team.add_device')}</option>
${unassignedDevices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')} ${unassignedDevices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
</select> </select>
</div> </div>
@ -125,75 +124,69 @@ async function renderTeamDetail(container, teamId) {
<div style="font-size:13px;font-weight:500">${d.name}</div> <div style="font-size:13px;font-weight:500">${d.name}</div>
<div style="font-size:11px;color:var(--text-muted)">${d.status}</div> <div style="font-size:11px;color:var(--text-muted)">${d.status}</div>
</div> </div>
<button class="btn-icon" data-remove-device="${d.id}" style="color:var(--danger)" title="Remove from team">&#10005;</button> <button class="btn-icon" data-remove-device="${d.id}" style="color:var(--danger)" title="${t('team.remove_from_team')}">&#10005;</button>
</div> </div>
`).join('') || '<p style="color:var(--text-muted);font-size:13px">No devices shared with this team</p>'} `).join('') || `<p style="color:var(--text-muted);font-size:13px">${t('team.no_devices')}</p>`}
</div> </div>
</div> </div>
</div> </div>
`; `;
// Invite member
document.getElementById('inviteMemberBtn').onclick = async () => { document.getElementById('inviteMemberBtn').onclick = async () => {
const email = prompt('Email address to invite:'); const email = prompt(t('team.prompt_email'));
if (!email) return; if (!email) return;
const role = prompt('Role (viewer, editor, or owner):', 'editor'); const role = prompt(t('team.prompt_role'), 'editor');
if (!['viewer', 'editor', 'owner'].includes(role)) { showToast('Invalid role', 'error'); return; } if (!['viewer', 'editor', 'owner'].includes(role)) { showToast(t('team.toast.invalid_role'), 'error'); return; }
try { try {
await API(`/teams/${teamId}/invite`, { method: 'POST', body: JSON.stringify({ email, role }) }); await API(`/teams/${teamId}/invite`, { method: 'POST', body: JSON.stringify({ email, role }) });
showToast('Invitation sent', 'success'); showToast(t('team.toast.invitation_sent'), 'success');
renderTeamDetail(container, teamId); renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };
// Change member role
container.querySelectorAll('[data-member-id]').forEach(select => { container.querySelectorAll('[data-member-id]').forEach(select => {
select.onchange = async () => { select.onchange = async () => {
try { try {
await API(`/teams/${teamId}/members/${select.dataset.memberId}`, { method: 'PUT', body: JSON.stringify({ role: select.value }) }); await API(`/teams/${teamId}/members/${select.dataset.memberId}`, { method: 'PUT', body: JSON.stringify({ role: select.value }) });
showToast('Role updated', 'success'); showToast(t('team.toast.role_updated'), 'success');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };
}); });
// Remove member
container.querySelectorAll('[data-remove-member]').forEach(btn => { container.querySelectorAll('[data-remove-member]').forEach(btn => {
btn.onclick = async () => { btn.onclick = async () => {
try { try {
await API(`/teams/${teamId}/members/${btn.dataset.removeMember}`, { method: 'DELETE' }); await API(`/teams/${teamId}/members/${btn.dataset.removeMember}`, { method: 'DELETE' });
showToast('Member removed', 'success'); showToast(t('team.toast.member_removed'), 'success');
renderTeamDetail(container, teamId); renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };
}); });
// Add device to team
document.getElementById('addDeviceToTeam').onchange = async (e) => { document.getElementById('addDeviceToTeam').onchange = async (e) => {
const deviceId = e.target.value; const deviceId = e.target.value;
if (!deviceId) return; if (!deviceId) return;
try { try {
await API(`/teams/${teamId}/devices`, { method: 'POST', body: JSON.stringify({ device_id: deviceId }) }); await API(`/teams/${teamId}/devices`, { method: 'POST', body: JSON.stringify({ device_id: deviceId }) });
showToast('Device added to team', 'success'); showToast(t('team.toast.device_added'), 'success');
renderTeamDetail(container, teamId); renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };
// Remove device from team
container.querySelectorAll('[data-remove-device]').forEach(btn => { container.querySelectorAll('[data-remove-device]').forEach(btn => {
btn.onclick = async () => { btn.onclick = async () => {
try { try {
await API(`/teams/${teamId}/devices/${btn.dataset.removeDevice}`, { method: 'DELETE' }); await API(`/teams/${teamId}/devices/${btn.dataset.removeDevice}`, { method: 'DELETE' });
showToast('Device removed from team', 'success'); showToast(t('team.toast.device_removed'), 'success');
renderTeamDetail(container, teamId); renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };
}); });
// Delete team
document.getElementById('deleteTeamBtn').onclick = async () => { document.getElementById('deleteTeamBtn').onclick = async () => {
try { try {
await API(`/teams/${teamId}`, { method: 'DELETE' }); await API(`/teams/${teamId}`, { method: 'DELETE' });
showToast('Team deleted', 'success'); showToast(t('team.toast.deleted'), 'success');
window.location.hash = '#/teams'; window.location.hash = '#/teams';
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };

View file

@ -1,7 +1,21 @@
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: { '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());
// Default dimensions for the canvas coordinate space (pixels). Screens added
// fresh start at 320x180 (16:9). The editor canvas itself renders at this
// natural scale so canvas-pixels == display-pixels.
const DEFAULT_SCREEN_W = 320;
const DEFAULT_SCREEN_H = 180;
const CANVAS_MIN_W = 1200;
const CANVAS_MIN_H = 700;
const CANVAS_PADDING = 200; // extra room beyond bounding box, in canvas units
export async function render(container) { export async function render(container) {
const hash = window.location.hash; const hash = window.location.hash;
@ -15,17 +29,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>Video Walls <span class="help-tip" data-tip="Combine multiple displays into one large screen. Set grid size, drag devices into positions, adjust bezel compensation. Assign content to play across all devices.">?</span></h1><div class="subtitle">Combine multiple displays into one large screen</div></div> <div><h1>${t('wall.title')} <span class="help-tip" data-tip="${t('wall.help_tip')}">?</span></h1><div class="subtitle">${t('wall.subtitle')}</div></div>
<button class="btn btn-primary" id="newWallBtn"> <button class="btn btn-primary" id="newWallBtn">
<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 Video Wall ${t('wall.new_wall')}
</button> </button>
</div> </div>
<div class="content-grid" id="wallGrid"></div> <div class="content-grid" id="wallGrid"></div>
`; `;
document.getElementById('newWallBtn').onclick = async () => { document.getElementById('newWallBtn').onclick = async () => {
const name = prompt('Video wall name:'); const name = prompt(t('wall.prompt_name'));
if (!name) return; if (!name) return;
const wall = await API('/walls', { method: 'POST', body: JSON.stringify({ name }) }); const wall = await API('/walls', { method: 'POST', body: JSON.stringify({ name }) });
window.location.hash = `#/wall/${wall.id}`; window.location.hash = `#/wall/${wall.id}`;
@ -36,7 +50,7 @@ async function renderList(container) {
const grid = document.getElementById('wallGrid'); const grid = document.getElementById('wallGrid');
if (!walls.length) { if (!walls.length) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><h3>No video walls yet</h3><p>Create a video wall to combine multiple displays.</p></div>'; grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('wall.empty_title')}</h3><p>${t('wall.empty_desc')}</p></div>`;
return; return;
} }
@ -54,160 +68,730 @@ async function renderList(container) {
</div> </div>
<div class="content-item-body"> <div class="content-item-body">
<div class="content-item-name">${w.name}</div> <div class="content-item-name">${w.name}</div>
<div class="content-item-size">${w.grid_cols}x${w.grid_rows} grid ${w.devices?.length || 0} devices</div> <div class="content-item-size">${t('wall.grid_summary', { cols: w.grid_cols, rows: w.grid_rows, n: w.devices?.length || 0 })}</div>
</div> </div>
</div> </div>
`).join(''); `).join('');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
} }
// ============================================================
// Free-form canvas wall editor
// ============================================================
async function renderWallEditor(container, wallId) { async function renderWallEditor(container, wallId) {
let wall, devices; let wall, devices, playlists;
try { try {
[wall, devices] = await Promise.all([API(`/walls/${wallId}`), api.getDevices()]); [wall, devices, playlists] = await Promise.all([
} catch { container.innerHTML = '<div class="empty-state"><h3>Wall not found</h3></div>'; return; } API(`/walls/${wallId}`),
api.getDevices(),
api.getPlaylists(),
]);
} catch { container.innerHTML = `<div class="empty-state"><h3>${t('wall.not_found')}</h3></div>`; return; }
const content = await api.getContent(); // Local state — server-roundtripped on Save. Backfill from grid math when
const unassigned = devices.filter(d => !wall.devices?.find(wd => wd.device_id === d.id)); // canvas_* columns aren't populated (fresh walls or pre-canvas walls).
const baseW = DEFAULT_SCREEN_W;
const baseH = DEFAULT_SCREEN_H;
const bezelH = wall.bezel_h_mm || 0;
const bezelV = wall.bezel_v_mm || 0;
let screens = (wall.devices || []).map(d => ({
device_id: d.device_id,
device_name: d.device_name,
device_status: d.device_status,
grid_col: d.grid_col,
grid_row: d.grid_row,
rotation: d.rotation || 0,
x: d.canvas_x ?? (d.grid_col * (baseW + bezelH)),
y: d.canvas_y ?? (d.grid_row * (baseH + bezelV)),
w: d.canvas_width ?? baseW,
h: d.canvas_height ?? baseH,
}));
// Default player covers the bounding box of all screens; if there are no
// screens yet, player stays at 0,0 with default screen size.
let player;
if (wall.player_x !== null && wall.player_x !== undefined) {
player = { x: wall.player_x, y: wall.player_y, w: wall.player_width, h: wall.player_height };
} else if (screens.length > 0) {
const b = boundsOf(screens);
player = { x: b.x, y: b.y, w: b.w, h: b.h };
} else {
player = { x: 0, y: 0, w: baseW, h: baseH };
}
let dirty = false;
function markDirty() {
dirty = true;
const btn = document.getElementById('saveLayoutBtn');
if (btn) { btn.disabled = false; btn.classList.add('btn-primary'); }
}
// Selection state for the fine-position panel + arrow-key nudge. One rect
// at a time: either a screen (by device_id) or the player.
// null when nothing is selected.
let selected = null;
function getSelectedRect() {
if (!selected) return null;
if (selected.type === 'player') return player;
return screens.find(s => s.device_id === selected.device_id) || null;
}
function selectScreen(deviceId) {
selected = { type: 'screen', device_id: deviceId };
applySelectionClasses();
renderSelectionPanel();
}
function selectPlayer() {
selected = { type: 'player' };
applySelectionClasses();
renderSelectionPanel();
}
function applySelectionClasses() {
canvas.querySelectorAll('.selected').forEach(e => e.classList.remove('selected'));
if (!selected) return;
if (selected.type === 'player') canvas.querySelector('.wall-player')?.classList.add('selected');
else {
const el = canvas.querySelector(`.wall-screen[data-device-id="${CSS.escape(selected.device_id)}"]`);
if (el) el.classList.add('selected');
}
}
function getUnassigned() {
const inThisWall = new Set(screens.map(s => s.device_id));
return devices.filter(d => !d.wall_id && !inThisWall.has(d.id));
}
container.innerHTML = ` container.innerHTML = `
<a href="#/walls" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px"> <a href="#/walls" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:12px;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 Video Walls ${t('wall.back')}
</a> </a>
<div class="page-header"> <div class="page-header" style="margin-bottom:12px">
<h1>${wall.name}</h1> <h1 style="display:flex;align-items:center;gap:10px">
<span id="wallTitleText">${esc(wall.name)}</span>
<button class="btn btn-sm" id="renameWallBtn" title="Rename wall" style="padding:2px 8px;font-size:12px"></button>
</h1>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-danger btn-sm" id="deleteWallBtn">Delete Wall</button> <button class="btn btn-sm" id="centerViewBtn" title="Re-center and fit content to the viewport">Center</button>
<button class="btn btn-sm" id="autoArrangeBtn" title="Lay out screens in a grid using the columns/rows/bezel below">Auto-arrange</button>
<button class="btn btn-sm" id="fitPlayerBtn" title="Snap the player rect to the bounding box of all screens">Fit player to screens</button>
<button class="btn btn-sm" id="saveLayoutBtn" disabled>Save layout</button>
<button class="btn btn-danger btn-sm" id="deleteWallBtn">${t('wall.delete_wall')}</button>
</div> </div>
</div> </div>
<div style="display:flex;gap:24px"> <div style="display:flex;gap:16px;align-items:flex-start">
<div style="flex:1"> <div style="flex:1;min-width:0">
<h3 style="font-size:14px;margin-bottom:12px">Grid Configuration</h3> <div id="canvasViewport" class="wall-viewport" style="border:1px solid var(--border);border-radius:var(--radius-lg);height:75vh;min-height:560px">
<div style="display:flex;gap:12px;margin-bottom:16px"> <div id="wallCanvas" class="wall-canvas"></div>
<div class="form-group" style="margin:0"><label>Columns</label><input type="number" id="gridCols" class="input" value="${wall.grid_cols}" min="1" max="10" style="width:80px"></div> <div class="wall-zoom-readout" id="zoomReadout">100%</div>
<div class="form-group" style="margin:0"><label>Rows</label><input type="number" id="gridRows" class="input" value="${wall.grid_rows}" min="1" max="10" style="width:80px"></div>
<div class="form-group" style="margin:0"><label>H Bezel (mm)</label><input type="number" id="bezelH" class="input" value="${wall.bezel_h_mm}" min="0" step="0.5" style="width:80px"></div>
<div class="form-group" style="margin:0"><label>V Bezel (mm)</label><input type="number" id="bezelV" class="input" value="${wall.bezel_v_mm}" min="0" step="0.5" style="width:80px"></div>
<button class="btn btn-primary btn-sm" id="updateGridBtn" style="align-self:flex-end">Update</button>
</div> </div>
<div style="display:flex;gap:12px;margin-top:12px;align-items:center;flex-wrap:wrap">
<div id="wallGrid" style="display:inline-grid;gap:4px;background:var(--bg-primary);padding:16px;border:1px solid var(--border);border-radius:var(--radius-lg)"></div> <div class="form-group" style="margin:0"><label style="font-size:11px;color:var(--text-muted)">${t('wall.columns')}</label><input type="number" id="gridCols" class="input" value="${wall.grid_cols}" min="1" max="20" style="width:70px"></div>
<div class="form-group" style="margin:0"><label style="font-size:11px;color:var(--text-muted)">${t('wall.rows')}</label><input type="number" id="gridRows" class="input" value="${wall.grid_rows}" min="1" max="20" style="width:70px"></div>
<h3 style="font-size:14px;margin:24px 0 12px">Content</h3> <div class="form-group" style="margin:0"><label style="font-size:11px;color:var(--text-muted)">${t('wall.h_bezel')}</label><input type="number" id="bezelH" class="input" value="${Math.round(wall.bezel_h_mm)}" min="0" step="1" style="width:80px"></div>
<select id="wallContent" class="input" style="width:300px;background:var(--bg-input)"> <div class="form-group" style="margin:0"><label style="font-size:11px;color:var(--text-muted)">${t('wall.v_bezel')}</label><input type="number" id="bezelV" class="input" value="${Math.round(wall.bezel_v_mm)}" min="0" step="1" style="width:80px"></div>
<option value="">No content</option> <span style="font-size:11px;color:var(--text-muted);max-width:340px">Cols/rows/bezel are used by Auto-arrange. Drag freely on the canvas to override.</span>
${content.filter(c => c.mime_type?.startsWith('video/')).map(c => `<option value="${c.id}" ${c.id === wall.content_id ? 'selected' : ''}>${c.filename}</option>`).join('')} </div>
<div style="margin-top:16px">
<h3 style="font-size:14px;margin:0 0 8px">${t('wall.playlist') || 'Playlist'}</h3>
<select id="wallPlaylist" class="input" style="width:300px;background:var(--bg-input)">
<option value="">${t('wall.no_playlist') || 'No playlist'}</option>
${(playlists || []).map(p => `<option value="${esc(p.id)}" ${p.id === wall.playlist_id ? 'selected' : ''}>${esc(p.name)}${p.status === 'draft' ? ' (draft)' : ''}</option>`).join('')}
</select> </select>
<button class="btn btn-primary btn-sm" id="setContentBtn" style="margin-left:8px">Set Content</button> <button class="btn btn-primary btn-sm" id="setPlaylistBtn" style="margin-left:8px">${t('wall.set_playlist') || 'Set Playlist'}</button>
</div>
</div> </div>
<div style="width:250px"> <div style="width:260px;flex-shrink:0">
<h3 style="font-size:14px;margin-bottom:12px">Available Displays</h3> <div id="selectionPanel" class="wall-selection-panel" style="margin-bottom:14px"></div>
<div id="availableDevices"> <h3 style="font-size:14px;margin-bottom:6px">${t('wall.available_displays')}</h3>
${unassigned.map(d => ` <p style="color:var(--text-muted);font-size:11px;margin:0 0 8px">Drag onto the canvas to add. Use the on a tile to remove.</p>
<div class="playlist-item" style="cursor:grab;margin-bottom:4px" draggable="true" data-device-id="${d.id}" data-device-name="${d.name}"> <div id="availableDevices" style="min-height:60px;padding:6px;border:1px dashed var(--border);border-radius:8px"></div>
<div class="info-card" style="margin-top:14px;padding:10px;font-size:12px;line-height:1.55">
<strong style="font-size:12px">How it works</strong>
<ul style="margin:6px 0 0 14px;padding:0;color:var(--text-secondary)">
<li>Each rectangle is a physical screen.</li>
<li>The blue dashed rectangle is the player window content plays inside this rect.</li>
<li>Each screen shows only the part of the player that overlaps it.</li>
<li>Drag corners to resize, drag the body to move.</li>
</ul>
</div>
</div>
</div>
`;
const canvas = document.getElementById('wallCanvas');
function renderAll() {
canvas.innerHTML = '';
canvas.appendChild(renderPlayerEl());
for (const s of screens) canvas.appendChild(renderScreenEl(s));
updateOverlapsAll();
renderSidebar();
applySelectionClasses();
renderSelectionPanel();
applyTransform();
}
// Render the fine-position panel: numeric x/y/w/h inputs for the selected
// rect plus the arrow-key hint. Two-way bound — typing into inputs moves
// the rect; dragging the rect updates the inputs in place (without
// rebuilding the DOM, so focus survives a drag).
function renderSelectionPanel() {
const panel = document.getElementById('selectionPanel');
if (!panel) return;
const rect = getSelectedRect();
if (!rect) {
panel.innerHTML = `
<div class="info-card" style="padding:10px;font-size:12px">
<strong style="font-size:12px">Fine position</strong>
<p style="margin:4px 0 0;color:var(--text-muted);font-size:11px">Click a tile or the player to dial in exact pixel positions.</p>
</div>`;
return;
}
const isPlayer = selected.type === 'player';
const label = isPlayer ? 'Player rect' : (rect.device_name || 'Screen');
panel.innerHTML = `
<div class="info-card" style="padding:10px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<strong style="font-size:12px">${esc(label)}</strong>
<button class="btn btn-sm" id="deselectBtn" style="padding:2px 8px;font-size:11px">Deselect</button>
</div>
<div class="wall-pos-grid">
<label>X</label><input type="number" data-field="x" value="${Math.round(rect.x)}" step="1">
<label>Y</label><input type="number" data-field="y" value="${Math.round(rect.y)}" step="1">
<label>W</label><input type="number" data-field="w" value="${Math.round(rect.w)}" step="1" min="40">
<label>H</label><input type="number" data-field="h" value="${Math.round(rect.h)}" step="1" min="24">
</div>
<p style="margin:8px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
Arrow keys nudge by 1px. Hold <kbd>Shift</kbd> for 10px.
Click outside any rect to deselect.
</p>
</div>
`;
panel.querySelector('#deselectBtn').addEventListener('click', () => {
selected = null;
applySelectionClasses();
renderSelectionPanel();
});
panel.querySelectorAll('input[data-field]').forEach(input => {
input.addEventListener('input', () => {
const v = parseFloat(input.value);
if (!isFinite(v)) return;
const f = input.dataset.field;
const r = getSelectedRect();
if (!r) return;
if (f === 'w') r.w = Math.max(40, v);
else if (f === 'h') r.h = Math.max(24, v);
else r[f] = v; // x/y can be negative
const el = selectedDomEl();
if (el) setRectStyle(el, r);
updateOverlapsAll();
markDirty();
// Don't rebuild this panel — keeps the input focused.
});
});
}
function selectedDomEl() {
if (!selected) return null;
if (selected.type === 'player') return canvas.querySelector('.wall-player');
return canvas.querySelector(`.wall-screen[data-device-id="${CSS.escape(selected.device_id)}"]`);
}
// Sync the panel inputs to the rect's current values without rebuilding
// the DOM (so focus survives a drag-resize). Called from drag onChange.
function updateSelectionInputsFromRect() {
if (!selected) return;
const rect = getSelectedRect();
if (!rect) return;
const panel = document.getElementById('selectionPanel');
if (!panel) return;
for (const f of ['x','y','w','h']) {
const input = panel.querySelector(`input[data-field="${f}"]`);
if (input && document.activeElement !== input) input.value = Math.round(rect[f]);
}
}
// Pan/zoom state. pan is in viewport screen pixels; zoom is unitless.
// The canvas div is a 0×0 anchor; its CSS transform supplies the mapping
// from data coords to viewport pixels. All rect children inherit it.
let pan = { x: 0, y: 0 };
let zoom = 1;
function applyTransform() {
canvas.style.transform = `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`;
const r = document.getElementById('zoomReadout');
if (r) r.textContent = Math.round(zoom * 100) + '%';
}
// Re-center bounds in the viewport with a small zoom-out so there's slack
// around the content for dragging into. Capped at 1× so we never zoom *in*
// beyond natural scale on a small layout.
function centerView() {
const viewport = document.getElementById('canvasViewport');
if (!viewport) return;
const all = screens.length > 0 ? [...screens, player] : [player];
const b = boundsOf(all);
const vw = viewport.clientWidth, vh = viewport.clientHeight;
if (!b.w || !b.h) {
pan = { x: vw / 2, y: vh / 2 };
zoom = 1;
} else {
const fitX = (vw * 0.75) / b.w;
const fitY = (vh * 0.75) / b.h;
zoom = Math.max(0.1, Math.min(1, fitX, fitY));
pan.x = vw / 2 - (b.x + b.w / 2) * zoom;
pan.y = vh / 2 - (b.y + b.h / 2) * zoom;
}
applyTransform();
}
function renderScreenEl(s) {
const el = document.createElement('div');
el.className = 'wall-screen';
el.dataset.deviceId = s.device_id;
setRectStyle(el, s);
el.innerHTML = `
<div class="wall-screen-overlap"></div>
<div class="wall-screen-label">
<div class="wall-screen-name" title="${esc(s.device_name)}">${esc(s.device_name)}</div>
<div class="wall-screen-meta">
<span class="status-dot ${s.device_status}" style="display:inline-block"></span>
<span style="font-size:10px;color:var(--text-muted)">${Math.round(s.w)}×${Math.round(s.h)}</span>
</div>
</div>
<button class="wall-screen-remove" title="Remove from wall">×</button>
${resizeHandlesHtml()}
`;
el.querySelector('.wall-screen-remove').addEventListener('click', (ev) => {
ev.stopPropagation();
screens = screens.filter(x => x.device_id !== s.device_id);
if (selected?.type === 'screen' && selected.device_id === s.device_id) selected = null;
markDirty();
renderAll();
});
el.addEventListener('pointerdown', (ev) => {
if (ev.target.closest('.wall-screen-remove')) return;
selectScreen(s.device_id);
});
attachDragResize(el, s, () => {
setRectStyle(el, s);
const meta = el.querySelector('.wall-screen-meta span:last-child');
if (meta) meta.textContent = `${Math.round(s.w)}×${Math.round(s.h)}`;
updateOverlapsAll();
updateSelectionInputsFromRect();
markDirty();
});
return el;
}
function renderPlayerEl() {
const el = document.createElement('div');
el.className = 'wall-player';
setRectStyle(el, player);
el.innerHTML = `
<div class="wall-player-label">
<span style="font-weight:600">PLAYER</span>
<span style="font-size:10px;color:rgba(255,255,255,0.7);margin-left:8px">${Math.round(player.w)}×${Math.round(player.h)}</span>
</div>
${resizeHandlesHtml()}
`;
el.addEventListener('pointerdown', () => selectPlayer());
attachDragResize(el, player, () => {
setRectStyle(el, player);
const meta = el.querySelector('.wall-player-label span:last-child');
if (meta) meta.textContent = `${Math.round(player.w)}×${Math.round(player.h)}`;
updateOverlapsAll();
updateSelectionInputsFromRect();
markDirty();
});
return el;
}
function updateOverlapsAll() {
canvas.querySelectorAll('.wall-screen').forEach(el => {
const id = el.dataset.deviceId;
const s = screens.find(x => x.device_id === id);
if (!s) return;
const ov = el.querySelector('.wall-screen-overlap');
const inter = intersect(s, player);
if (!inter) { ov.style.display = 'none'; return; }
ov.style.display = 'block';
ov.style.left = (inter.x - s.x) + 'px';
ov.style.top = (inter.y - s.y) + 'px';
ov.style.width = inter.w + 'px';
ov.style.height = inter.h + 'px';
});
}
function renderSidebar() {
const sidebar = document.getElementById('availableDevices');
const unassigned = getUnassigned();
sidebar.innerHTML = unassigned.length
? unassigned.map(d => `
<div class="playlist-item" style="cursor:grab;margin-bottom:4px" draggable="true"
data-device-id="${esc(d.id)}" data-device-name="${esc(d.name)}" data-device-status="${esc(d.status)}">
<div class="playlist-item-info"> <div class="playlist-item-info">
<div class="playlist-item-name">${d.name}</div> <div class="playlist-item-name">${esc(d.name)}</div>
<div class="playlist-item-meta"><span class="status-dot ${d.status}" style="display:inline-block"></span> ${d.status}</div> <div class="playlist-item-meta"><span class="status-dot ${d.status}" style="display:inline-block"></span> ${d.status}</div>
</div> </div>
</div> </div>
`).join('') || '<p style="color:var(--text-muted);font-size:12px">All devices assigned</p>'} `).join('')
</div> : `<p style="color:var(--text-muted);font-size:12px;text-align:center;padding:12px">${t('wall.all_assigned')}</p>`;
</div>
</div>
`;
function renderGrid() { sidebar.querySelectorAll('[draggable]').forEach(el => {
const cols = parseInt(document.getElementById('gridCols').value) || 2; el.addEventListener('dragstart', (e) => {
const rows = parseInt(document.getElementById('gridRows').value) || 2; e.dataTransfer.effectAllowed = 'copy';
const grid = document.getElementById('wallGrid'); e.dataTransfer.setData('text/plain', JSON.stringify({
grid.style.gridTemplateColumns = `repeat(${cols}, 120px)`; type: 'sidebar-device',
device_id: el.dataset.deviceId,
device_name: el.dataset.deviceName,
device_status: el.dataset.deviceStatus,
}));
});
});
let html = '';
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const dev = wall.devices?.find(d => d.grid_col === c && d.grid_row === r);
html += `
<div style="width:120px;aspect-ratio:16/9;background:${dev ? 'rgba(59,130,246,0.2)' : 'var(--bg-card)'};
border:2px ${dev ? 'solid var(--accent)' : '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)"
data-grid-col="${c}" data-grid-row="${r}">
${dev ? `<div style="font-weight:500">${dev.device_name}</div><div style="font-size:9px;color:var(--text-muted)">[${c},${r}]</div>` :
`<div style="color:var(--text-muted)">Drop here</div><div style="font-size:9px">[${c},${r}]</div>`}
</div>
`;
} }
}
grid.innerHTML = html;
// Drop targets // Click on canvas background (not on a rect) clears selection
grid.querySelectorAll('[data-grid-col]').forEach(cell => { canvas.addEventListener('pointerdown', (ev) => {
cell.ondragover = (e) => { e.preventDefault(); cell.style.borderColor = 'var(--success)'; }; if (ev.target === canvas) {
cell.ondragleave = () => { cell.style.borderColor = ''; }; selected = null;
cell.ondrop = async (e) => { applySelectionClasses();
renderSelectionPanel();
}
});
// Arrow keys nudge the selected rect by 1px (or 10px with shift). Only
// when focus isn't in a text input — typing into the panel's number fields
// should still let the browser handle native arrow-key behavior.
function onArrowNudge(e) {
if (!selected) return;
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
let dx = 0, dy = 0;
if (e.key === 'ArrowLeft') dx = -1;
else if (e.key === 'ArrowRight') dx = 1;
else if (e.key === 'ArrowUp') dy = -1;
else if (e.key === 'ArrowDown') dy = 1;
else return;
e.preventDefault(); e.preventDefault();
cell.style.borderColor = ''; const step = e.shiftKey ? 10 : 1;
const deviceId = e.dataTransfer.getData('device-id'); const rect = getSelectedRect();
const deviceName = e.dataTransfer.getData('device-name'); if (!rect) return;
const col = parseInt(cell.dataset.gridCol); rect.x = rect.x + dx * step;
const row = parseInt(cell.dataset.gridRow); rect.y = rect.y + dy * step;
const el = selectedDomEl();
// Add to wall devices if (el) setRectStyle(el, rect);
const existing = wall.devices?.filter(d => !(d.grid_col === col && d.grid_row === row)) || []; updateOverlapsAll();
existing.push({ device_id: deviceId, device_name: deviceName, grid_col: col, grid_row: row }); updateSelectionInputsFromRect();
markDirty();
try {
const updated = await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: existing }) });
wall.devices = updated.devices;
renderGrid();
showToast(`${deviceName} placed at [${col},${row}]`, 'success');
} catch (err) { showToast(err.message, 'error'); }
};
});
} }
document.addEventListener('keydown', onArrowNudge);
cleanupHooks.push(() => document.removeEventListener('keydown', onArrowNudge));
// Drag sources // Canvas accepts sidebar drops to spawn a new screen rect
container.querySelectorAll('[draggable]').forEach(el => { const viewport = document.getElementById('canvasViewport');
el.ondragstart = (e) => {
e.dataTransfer.setData('device-id', el.dataset.deviceId); // Pan: pointer-drag on empty viewport space (i.e., not on a rect or its
e.dataTransfer.setData('device-name', el.dataset.deviceName); // children). The wall-canvas div itself counts as empty.
}; let panState = null;
viewport.addEventListener('pointerdown', (ev) => {
// Skip if the pointer landed on a rect — that starts drag/resize instead.
if (ev.target.closest('.wall-screen, .wall-player')) return;
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
// Empty-space click also clears selection
if (selected) {
selected = null;
applySelectionClasses();
renderSelectionPanel();
}
panState = { px: ev.clientX, py: ev.clientY, ox: pan.x, oy: pan.y, pid: ev.pointerId };
viewport.classList.add('panning');
viewport.setPointerCapture(ev.pointerId);
});
viewport.addEventListener('pointermove', (ev) => {
if (!panState || ev.pointerId !== panState.pid) return;
pan.x = panState.ox + (ev.clientX - panState.px);
pan.y = panState.oy + (ev.clientY - panState.py);
applyTransform();
});
function endPan(ev) {
if (!panState || ev.pointerId !== panState.pid) return;
try { viewport.releasePointerCapture(panState.pid); } catch {}
panState = null;
viewport.classList.remove('panning');
}
viewport.addEventListener('pointerup', endPan);
viewport.addEventListener('pointercancel', endPan);
// Wheel zoom — pivot at cursor so the world point under the cursor stays
// pinned. Clamped to a sane range.
viewport.addEventListener('wheel', (ev) => {
ev.preventDefault();
const vpRect = viewport.getBoundingClientRect();
const cx = ev.clientX - vpRect.left;
const cy = ev.clientY - vpRect.top;
const worldX = (cx - pan.x) / zoom;
const worldY = (cy - pan.y) / zoom;
const factor = ev.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = Math.max(0.1, Math.min(5, zoom * factor));
pan.x = cx - worldX * newZoom;
pan.y = cy - worldY * newZoom;
zoom = newZoom;
applyTransform();
}, { passive: false });
viewport.addEventListener('dragover', (e) => { e.preventDefault(); });
viewport.addEventListener('drop', (e) => {
e.preventDefault();
let data;
try { data = JSON.parse(e.dataTransfer.getData('text/plain') || '{}'); } catch { return; }
if (data.type !== 'sidebar-device' || !data.device_id) return;
const vpRect = viewport.getBoundingClientRect();
// Drop pixel → canvas-data coord: undo viewport offset, pan, and zoom.
const x = (e.clientX - vpRect.left - pan.x) / zoom - DEFAULT_SCREEN_W / 2;
const y = (e.clientY - vpRect.top - pan.y) / zoom - DEFAULT_SCREEN_H / 2;
screens.push({
device_id: data.device_id,
device_name: data.device_name || 'Display',
device_status: data.device_status || 'offline',
grid_col: 0, grid_row: 0, rotation: 0,
x, y, w: DEFAULT_SCREEN_W, h: DEFAULT_SCREEN_H,
});
markDirty();
renderAll();
}); });
document.getElementById('updateGridBtn').onclick = async () => { // ---------- Toolbar ----------
document.getElementById('centerViewBtn').addEventListener('click', () => centerView());
document.getElementById('autoArrangeBtn').addEventListener('click', () => {
const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 1);
const rows = Math.max(1, parseInt(document.getElementById('gridRows').value) || 1);
const bH = Math.max(0, parseInt(document.getElementById('bezelH').value) || 0);
const bV = Math.max(0, parseInt(document.getElementById('bezelV').value) || 0);
const w = DEFAULT_SCREEN_W;
const h = DEFAULT_SCREEN_H;
let i = 0;
for (const s of screens) {
if (i >= cols * rows) break;
const c = i % cols;
const r = Math.floor(i / cols);
s.x = c * (w + bH);
s.y = r * (h + bV);
s.w = w;
s.h = h;
s.grid_col = c;
s.grid_row = r;
i++;
}
// Fit player to whole grid bounding box
const b = boundsOf(screens);
player.x = b.x; player.y = b.y; player.w = b.w; player.h = b.h;
markDirty();
renderAll();
});
document.getElementById('fitPlayerBtn').addEventListener('click', () => {
if (screens.length === 0) return;
const b = boundsOf(screens);
player.x = b.x; player.y = b.y; player.w = b.w; player.h = b.h;
markDirty();
renderAll();
});
document.getElementById('saveLayoutBtn').addEventListener('click', async () => {
try { try {
// Persist player rect + grid/bezel inputs to the wall, devices to its
// member list. Two PUTs because the existing routes are split that way.
const cols = Math.max(1, parseInt(document.getElementById('gridCols').value) || 1);
const rows = Math.max(1, parseInt(document.getElementById('gridRows').value) || 1);
const bH = Math.max(0, parseInt(document.getElementById('bezelH').value) || 0);
const bV = Math.max(0, parseInt(document.getElementById('bezelV').value) || 0);
// Quantize all coords to integers before persisting. Drag/resize
// produce floats (screen-pixel deltas divided by zoom), and even tiny
// FP drift between two screens with the same nominal Y/H produces
// visibly different `top`/`height` percentages downstream — a known
// source of vertical-misalignment bugs across the wall.
await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({
grid_cols: parseInt(document.getElementById('gridCols').value), grid_cols: cols, grid_rows: rows, bezel_h_mm: bH, bezel_v_mm: bV,
grid_rows: parseInt(document.getElementById('gridRows').value), player_x: Math.round(player.x), player_y: Math.round(player.y),
bezel_h_mm: parseFloat(document.getElementById('bezelH').value), player_width: Math.round(player.w), player_height: Math.round(player.h),
bezel_v_mm: parseFloat(document.getElementById('bezelV').value),
})}); })});
wall.grid_cols = parseInt(document.getElementById('gridCols').value); // grid_col/grid_row are kept only to satisfy the legacy
wall.grid_rows = parseInt(document.getElementById('gridRows').value); // UNIQUE(wall_id, grid_col, grid_row) constraint — render math now uses
renderGrid(); // canvas_* fields. Synthetic (i, 0) guarantees uniqueness.
showToast('Grid updated', 'success'); const payload = screens.map((s, i) => ({
device_id: s.device_id,
grid_col: i,
grid_row: 0,
rotation: s.rotation || 0,
canvas_x: Math.round(s.x), canvas_y: Math.round(s.y),
canvas_width: Math.round(s.w), canvas_height: Math.round(s.h),
}));
await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: payload }) });
// Re-fetch master device list so wall_id changes propagate to the sidebar
devices = await api.getDevices();
dirty = false;
const btn = document.getElementById('saveLayoutBtn');
btn.disabled = true;
btn.classList.remove('btn-primary');
showToast('Layout saved', 'success');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; });
document.getElementById('setContentBtn').onclick = async () => { document.getElementById('renameWallBtn').addEventListener('click', async () => {
const contentId = document.getElementById('wallContent').value; const newName = prompt('Wall name:', wall.name);
if (!newName || newName === wall.name) return;
try { try {
await API(`/walls/${wallId}/content`, { method: 'PUT', body: JSON.stringify({ content_id: contentId || null }) }); await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ name: newName }) });
showToast('Content updated', 'success'); wall.name = newName;
document.getElementById('wallTitleText').textContent = newName;
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; });
document.getElementById('deleteWallBtn').onclick = async () => { document.getElementById('setPlaylistBtn').addEventListener('click', async () => {
const playlistId = document.getElementById('wallPlaylist').value || null;
try {
await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ playlist_id: playlistId }) });
wall.playlist_id = playlistId;
showToast(t('wall.toast.playlist_updated') || 'Playlist updated', 'success');
} catch (err) { showToast(err.message, 'error'); }
});
document.getElementById('deleteWallBtn').addEventListener('click', async () => {
if (!confirm(`Delete wall "${wall.name}"? This returns all displays to ungrouped.`)) return;
try { try {
await API(`/walls/${wallId}`, { method: 'DELETE' }); await API(`/walls/${wallId}`, { method: 'DELETE' });
showToast('Wall deleted', 'success'); showToast(t('wall.toast.deleted'), 'success');
window.location.hash = '#/walls'; window.location.hash = '#/walls';
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; });
renderGrid(); // Warn before navigating away with unsaved layout changes
function beforeUnloadWarn(e) { if (dirty) { e.preventDefault(); e.returnValue = ''; } }
window.addEventListener('beforeunload', beforeUnloadWarn);
cleanupHooks.push(() => window.removeEventListener('beforeunload', beforeUnloadWarn));
renderAll();
// Center on initial mount once the viewport has measurable dimensions.
// requestAnimationFrame defers until layout settles; fits content + padding.
requestAnimationFrame(() => centerView());
// ---------- Internal helpers ----------
function setRectStyle(el, r) {
el.style.left = r.x + 'px';
el.style.top = r.y + 'px';
el.style.width = r.w + 'px';
el.style.height = r.h + 'px';
} }
export function cleanup() {} function attachDragResize(el, rect, onChange) {
// Drag the body to move; drag a corner/edge handle to resize.
el.addEventListener('pointerdown', (ev) => {
// Ignore if clicking the remove button or other inner controls
if (ev.target.closest('.wall-screen-remove')) return;
const handle = ev.target.closest('.wall-handle');
const dir = handle?.dataset.dir;
const mode = dir ? `resize:${dir}` : 'move';
ev.preventDefault();
ev.stopPropagation();
el.setPointerCapture(ev.pointerId);
const startX = ev.clientX;
const startY = ev.clientY;
const start = { x: rect.x, y: rect.y, w: rect.w, h: rect.h };
function move(e) {
// Convert screen-pixel deltas to data-pixel deltas via current zoom
// so the rect stays under the cursor regardless of zoom level.
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
if (mode === 'move') {
// Allow negative coords — physical screen layouts can offset above
// or to the left of the canvas's notional origin.
rect.x = start.x + dx;
rect.y = start.y + dy;
} else {
applyResize(mode.slice(7), dx, dy, start, rect);
}
onChange();
}
function up(e) {
el.releasePointerCapture(ev.pointerId);
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
el.removeEventListener('pointercancel', up);
onChange();
}
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', up);
});
}
}
function applyResize(dir, dx, dy, start, rect) {
const minW = 40, minH = 24;
let { x, y, w, h } = start;
if (dir.includes('e')) w = Math.max(minW, start.w + dx);
if (dir.includes('s')) h = Math.max(minH, start.h + dy);
if (dir.includes('w')) {
const newW = Math.max(minW, start.w - dx);
x = start.x + (start.w - newW);
w = newW;
}
if (dir.includes('n')) {
const newH = Math.max(minH, start.h - dy);
y = start.y + (start.h - newH);
h = newH;
}
// x/y unconstrained — negative coords are allowed
rect.x = x;
rect.y = y;
rect.w = w;
rect.h = h;
}
function resizeHandlesHtml() {
return ['nw','n','ne','e','se','s','sw','w']
.map(d => `<div class="wall-handle wall-handle-${d}" data-dir="${d}"></div>`)
.join('');
}
function boundsOf(rects) {
let x = Infinity, y = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const r of rects) {
if (r.x < x) x = r.x;
if (r.y < y) y = r.y;
if (r.x + r.w > x2) x2 = r.x + r.w;
if (r.y + r.h > y2) y2 = r.y + r.h;
}
if (!isFinite(x)) return { x: 0, y: 0, w: 0, h: 0 };
return { x, y, w: x2 - x, h: y2 - y };
}
function intersect(a, b) {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
const x2 = Math.min(a.x + a.w, b.x + b.w);
const y2 = Math.min(a.y + a.h, b.y + b.h);
if (x2 <= x || y2 <= y) return null;
return { x, y, w: x2 - x, h: y2 - y };
}
// Cleanup hooks set during render so we can detach them on view unload.
const cleanupHooks = [];
export function cleanup() {
while (cleanupHooks.length) {
try { cleanupHooks.pop()(); } catch {}
}
}

View file

@ -1,32 +1,152 @@
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 WIDGET_TYPES = [ // Widget type ids only — name + desc are looked up via t() so they switch
{ id: 'clock', name: 'Clock', icon: '&#128339;', desc: 'Digital clock with date' }, // language with the rest of the UI.
{ id: 'weather', name: 'Weather', icon: '&#9925;', desc: 'Current weather conditions' }, const WIDGET_TYPES = ['clock', 'weather', 'rss', 'text', 'webpage', 'social', 'directory-board'];
{ id: 'rss', name: 'News Ticker', icon: '&#128240;', desc: 'Scrolling RSS feed' }, const WIDGET_ICONS = {
{ id: 'text', name: 'Text/HTML', icon: '&#128221;', desc: 'Custom text or HTML content' }, clock: '&#128339;',
{ id: 'webpage', name: 'Webpage', icon: '&#127760;', desc: 'Embed a webpage' }, weather: '&#9925;',
{ id: 'social', name: 'Social Feed', icon: '&#128172;', desc: 'Social media feed' }, rss: '&#128240;',
]; text: '&#128221;',
webpage: '&#127760;',
social: '&#128172;',
'directory-board': '&#127970;',
};
const widgetTypeName = (id) => t(`widget.type.${id.replace(/-/g, '_')}.name`);
const widgetTypeDesc = (id) => t(`widget.type.${id.replace(/-/g, '_')}.desc`);
function escAttr(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function openContentPicker({ multiple = false, title } = {}) {
return new Promise(async (resolve) => {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
overlay.innerHTML = `
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column">
<h3 style="margin:0 0 12px;color:var(--text-primary)">${title || t('widget.picker.default_title')}</h3>
<input type="text" id="cpSearch" class="input" placeholder="${t('widget.picker.search')}" style="margin-bottom:12px">
<div id="cpList" style="flex:1;overflow-y:auto;min-height:200px"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px;gap:8px;flex-wrap:wrap">
<div style="font-size:12px;color:var(--text-muted)" id="cpSelCount"></div>
<div style="display:flex;gap:8px;margin-left:auto">
<button class="btn btn-secondary" id="cpCancel">${t('common.cancel')}</button>
${multiple ? `<button class="btn btn-primary" id="cpDone">${t('common.done')}</button>` : ''}
</div>
</div>
</div>`;
document.body.appendChild(overlay);
let items = [];
try { items = await API('/content'); } catch {}
items = (items || []).filter(i => (i.mime_type || '').startsWith('image/'));
const selected = new Set();
const resolveUrl = (item) => item.remote_url || `/api/content/${item.id}/file`;
const updateCount = () => {
const el = overlay.querySelector('#cpSelCount');
if (el && multiple) el.textContent = t('widget.picker.selected_count', { n: selected.size });
};
function renderList() {
const q = (overlay.querySelector('#cpSearch').value || '').toLowerCase();
const filtered = items.filter(i => (i.filename || '').toLowerCase().includes(q));
const list = overlay.querySelector('#cpList');
if (!filtered.length) {
list.innerHTML = `<div style="color:var(--text-muted);padding:32px;text-align:center;font-size:13px">${items.length ? t('widget.picker.no_matches') : t('widget.picker.no_images')}</div>`;
return;
}
list.innerHTML = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px">${
filtered.map(c => {
const isSel = selected.has(c.id);
const thumb = c.remote_url || `/api/content/${c.id}/thumbnail`;
return `
<div data-pick-id="${escAttr(c.id)}" style="position:relative;cursor:pointer;border-radius:6px;overflow:hidden;border:2px solid ${isSel ? 'var(--primary, #4a7cff)' : 'transparent'};aspect-ratio:4/3;background:var(--bg-input)">
<img src="${escAttr(thumb)}" style="width:100%;height:100%;object-fit:cover" loading="lazy" onerror="this.style.opacity='0.2'">
<div style="position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,0.75);color:#fff;padding:4px 6px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escAttr(c.filename)}</div>
${isSel ? '<div style="position:absolute;top:6px;right:6px;width:22px;height:22px;background:var(--primary, #4a7cff);color:#fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;line-height:1">&#10003;</div>' : ''}
</div>`;
}).join('')
}</div>`;
list.querySelectorAll('[data-pick-id]').forEach(el => el.onclick = () => {
const id = el.dataset.pickId;
if (multiple) {
if (selected.has(id)) selected.delete(id); else selected.add(id);
updateCount();
renderList();
} else {
const item = items.find(x => String(x.id) === id);
if (item) { cleanup(); resolve(resolveUrl(item)); }
}
});
}
function cleanup() { overlay.remove(); }
overlay.querySelector('#cpSearch').oninput = renderList;
overlay.querySelector('#cpCancel').onclick = () => { cleanup(); resolve(multiple ? [] : null); };
if (multiple) {
overlay.querySelector('#cpDone').onclick = () => {
const urls = Array.from(selected).map(id => {
const item = items.find(x => String(x.id) === id);
return item ? resolveUrl(item) : null;
}).filter(Boolean);
cleanup();
resolve(urls);
};
}
overlay.onclick = (e) => { if (e.target === overlay) { cleanup(); resolve(multiple ? [] : null); } };
updateCount();
renderList();
});
}
function showPreviewModal(html) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
overlay.innerHTML = `
<div style="width:100%;max-width:1400px;height:90vh;background:var(--bg-card);border-radius:8px;display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border)">
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--border)">
<strong style="color:var(--text-primary)">${t('widget.preview_title')}</strong>
<button class="btn btn-secondary btn-sm" id="pvClose">${t('widget.close')}</button>
</div>
<iframe id="pvIframe" style="flex:1;width:100%;border:0;background:#000"></iframe>
</div>`;
document.body.appendChild(overlay);
// srcdoc resolves relative URLs against about:srcdoc, so inject <base> pointing to our origin
const baseTag = `<base href="${window.location.origin}/">`;
const withBase = /<head[^>]*>/i.test(html)
? html.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`)
: html.replace(/<html([^>]*)>/i, `<html$1><head>${baseTag}</head>`);
overlay.querySelector('#pvIframe').srcdoc = withBase;
const close = () => overlay.remove();
overlay.querySelector('#pvClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
document.addEventListener('keydown', function esc(ev) {
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
}
export async function render(container) { export async function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div><h1>Widgets <span class="help-tip" data-tip="Dynamic content elements: live clocks, weather, RSS tickers, text, webpages, and social feeds. Create a widget then assign it to a device playlist.">?</span></h1><div class="subtitle">Add dynamic content to your layouts</div></div> <div><h1>${t('widget.title')} <span class="help-tip" data-tip="${t('widget.help_tip')}">?</span></h1><div class="subtitle">${t('widget.subtitle')}</div></div>
<button class="btn btn-primary" id="newWidgetBtn"> <button class="btn btn-primary" id="newWidgetBtn">
<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 Widget ${t('widget.new_widget')}
</button> </button>
</div> </div>
<div id="widgetTypeGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:24px;display:none"> <div id="widgetTypeGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:24px;display:none">
${WIDGET_TYPES.map(t => ` ${WIDGET_TYPES.map(id => `
<div class="content-item" style="cursor:pointer" data-create-type="${t.id}"> <div class="content-item" style="cursor:pointer" data-create-type="${id}">
<div style="padding:20px;text-align:center"> <div style="padding:20px;text-align:center">
<div style="font-size:36px;margin-bottom:8px">${t.icon}</div> <div style="font-size:36px;margin-bottom:8px">${WIDGET_ICONS[id]}</div>
<div style="font-weight:600;font-size:14px">${t.name}</div> <div style="font-weight:600;font-size:14px">${widgetTypeName(id)}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t.desc}</div> <div style="font-size:11px;color:var(--text-muted);margin-top:4px">${widgetTypeDesc(id)}</div>
</div> </div>
</div> </div>
`).join('')} `).join('')}
@ -36,16 +156,16 @@ export async function render(container) {
<!-- Widget Config Modal --> <!-- Widget Config Modal -->
<div class="modal-overlay" id="widgetModal" style="display:none"> <div class="modal-overlay" id="widgetModal" style="display:none">
<div class="modal" style="width:560px"> <div class="modal" style="width:560px">
<div class="modal-header"><h3 id="widgetModalTitle">Configure Widget</h3> <div class="modal-header"><h3 id="widgetModalTitle">${t('widget.configure')}</h3>
<button class="btn-icon" onclick="document.getElementById('widgetModal').style.display='none'"> <button class="btn-icon" onclick="document.getElementById('widgetModal').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" id="widgetConfigForm"></div> <div class="modal-body" id="widgetConfigForm"></div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" onclick="document.getElementById('widgetModal').style.display='none'">Cancel</button> <button class="btn btn-secondary" onclick="document.getElementById('widgetModal').style.display='none'">${t('common.cancel')}</button>
<button class="btn btn-secondary" id="previewWidgetBtn">Preview</button> <button class="btn btn-secondary" id="previewWidgetBtn">${t('widget.preview')}</button>
<button class="btn btn-primary" id="saveWidgetBtn">Save</button> <button class="btn btn-primary" id="saveWidgetBtn">${t('common.save')}</button>
</div> </div>
</div> </div>
</div> </div>
@ -53,6 +173,7 @@ export async function render(container) {
let editingWidget = null; let editingWidget = null;
let creatingType = null; let creatingType = null;
let dirState = { categories: [], logo_url: '', background_images: [] };
document.getElementById('newWidgetBtn').onclick = () => { document.getElementById('newWidgetBtn').onclick = () => {
const grid = document.getElementById('widgetTypeGrid'); const grid = document.getElementById('widgetTypeGrid');
@ -69,57 +190,305 @@ export async function render(container) {
}); });
function showConfigForm(type, config) { function showConfigForm(type, config) {
const typeName = WIDGET_TYPES.find(t => t.id === type)?.name || type; const typeName = widgetTypeName(type);
document.getElementById('widgetModalTitle').textContent = editingWidget ? `Edit ${typeName}` : `New ${typeName}`; document.getElementById('widgetModalTitle').textContent = editingWidget
? t('widget.edit_x', { type: typeName })
: t('widget.new_x', { type: typeName });
let html = '<div class="form-group"><label>Widget Name</label><input type="text" id="wName" class="input" value="' + (config._name || typeName) + '"></div>'; let html = `<div class="form-group"><label>${t('widget.field.name')}</label><input type="text" id="wName" class="input" value="${escAttr(config._name || typeName)}"></div>`;
switch (type) { switch (type) {
case 'clock': case 'clock':
html += ` html += `
<div class="form-group"><label>Format</label><select id="wFormat" class="input" style="background:var(--bg-input)"><option value="12h" ${config.format === '12h' ? 'selected' : ''}>12 Hour</option><option value="24h" ${config.format === '24h' ? 'selected' : ''}>24 Hour</option></select></div> <div class="form-group"><label>${t('widget.field.format')}</label><select id="wFormat" class="input" style="background:var(--bg-input)"><option value="12h" ${config.format === '12h' ? 'selected' : ''}>${t('widget.field.format_12h')}</option><option value="24h" ${config.format === '24h' ? 'selected' : ''}>${t('widget.field.format_24h')}</option></select></div>
<div class="form-group"><label>Timezone</label><input type="text" id="wTimezone" class="input" value="${config.timezone || 'America/Chicago'}" placeholder="America/New_York"></div> <div class="form-group"><label>${t('widget.field.timezone')}</label><input type="text" id="wTimezone" class="input" value="${config.timezone || 'America/Chicago'}" placeholder="America/New_York"></div>
<div class="form-group"><label>Font Size (px)</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 64}"></div> <div class="form-group"><label>${t('widget.field.font_size_px')}</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 64}"></div>
<div class="form-group"><label>Color</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div> <div class="form-group"><label>${t('widget.field.color')}</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div>
<div class="form-group"><label>Background</label><input type="color" id="wBg" value="${config.background || '#000000'}" style="width:60px;height:32px;border:none"></div>`; <div class="form-group"><label>${t('widget.field.background')}</label><input type="color" id="wBg" value="${config.background || '#000000'}" style="width:60px;height:32px;border:none"></div>`;
break; break;
case 'weather': case 'weather':
html += ` html += `
<div class="form-group"><label>Location</label><input type="text" id="wLocation" class="input" value="${config.location || ''}" placeholder="City, State"></div> <div class="form-group"><label>${t('widget.field.location')}</label><input type="text" id="wLocation" class="input" value="${config.location || ''}" placeholder="${t('widget.field.location_placeholder')}"></div>
<div class="form-group"><label>Units</label><select id="wUnits" class="input" style="background:var(--bg-input)"><option value="imperial" ${config.units !== 'metric' ? 'selected' : ''}>Imperial (°F)</option><option value="metric" ${config.units === 'metric' ? 'selected' : ''}>Metric (°C)</option></select></div> <div class="form-group"><label>${t('widget.field.units')}</label><select id="wUnits" class="input" style="background:var(--bg-input)"><option value="imperial" ${config.units !== 'metric' ? 'selected' : ''}>${t('widget.field.units_imperial')}</option><option value="metric" ${config.units === 'metric' ? 'selected' : ''}>${t('widget.field.units_metric')}</option></select></div>
<div class="form-group"><label>Font Size</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 48}"></div> <div class="form-group"><label>${t('widget.field.font_size')}</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 48}"></div>
<div class="form-group"><label>Color</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div>`; <div class="form-group"><label>${t('widget.field.color')}</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div>`;
break; break;
case 'rss': case 'rss':
html += ` html += `
<div class="form-group"><label>Feed URL</label><input type="text" id="wFeedUrl" class="input" value="${config.feed_url || ''}" placeholder="https://example.com/feed.xml"></div> <div class="form-group"><label>${t('widget.field.feed_url')}</label><input type="text" id="wFeedUrl" class="input" value="${config.feed_url || ''}" placeholder="https://example.com/feed.xml"></div>
<div class="form-group"><label>Scroll Speed (seconds)</label><input type="number" id="wScrollSpeed" class="input" value="${config.scroll_speed || 30}"></div> <div class="form-group"><label>${t('widget.field.scroll_speed_seconds')}</label><input type="number" id="wScrollSpeed" class="input" value="${config.scroll_speed || 30}"></div>
<div class="form-group"><label>Max Items</label><input type="number" id="wMaxItems" class="input" value="${config.max_items || 10}"></div> <div class="form-group"><label>${t('widget.field.max_items')}</label><input type="number" id="wMaxItems" class="input" value="${config.max_items || 10}"></div>
<div class="form-group"><label>Font Size</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 24}"></div> <div class="form-group"><label>${t('widget.field.font_size')}</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 24}"></div>
<div class="form-group"><label>Color</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div> <div class="form-group"><label>${t('widget.field.color')}</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div>
<div class="form-group"><label>Background</label><input type="color" id="wBg" value="${config.background || '#000000'}" style="width:60px;height:32px;border:none"></div>`; <div class="form-group"><label>${t('widget.field.background')}</label><input type="color" id="wBg" value="${config.background || '#000000'}" style="width:60px;height:32px;border:none"></div>`;
break; break;
case 'text': case 'text':
html += ` html += `
<div class="form-group"><label>HTML Content</label><textarea id="wHtml" class="input" rows="6" style="font-family:monospace;font-size:12px">${config.html || '<h1 style="color:white;text-align:center;margin-top:40px">Hello World</h1>'}</textarea></div> <div class="form-group"><label>${t('widget.field.html_content')}</label><textarea id="wHtml" class="input" rows="6" style="font-family:monospace;font-size:12px">${config.html || '<h1 style="color:white;text-align:center;margin-top:40px">Hello World</h1>'}</textarea></div>
<div class="form-group"><label>CSS (optional)</label><textarea id="wCss" class="input" rows="3" style="font-family:monospace;font-size:12px">${config.css || ''}</textarea></div> <div class="form-group"><label>${t('widget.field.css_optional')}</label><textarea id="wCss" class="input" rows="3" style="font-family:monospace;font-size:12px">${config.css || ''}</textarea></div>
<div class="form-group"><label>Background</label><input type="color" id="wBg" value="${config.background || '#000000'}" style="width:60px;height:32px;border:none"></div>`; <div class="form-group"><label>${t('widget.field.background')}</label><input type="color" id="wBg" value="${config.background || '#000000'}" style="width:60px;height:32px;border:none"></div>`;
break; break;
case 'webpage': case 'webpage':
html += ` html += `
<div class="form-group"><label>URL</label><input type="text" id="wUrl" class="input" value="${config.url || ''}" placeholder="https://example.com"></div> <div class="form-group"><label>${t('widget.field.url')}</label><input type="text" id="wUrl" class="input" value="${config.url || ''}" placeholder="https://example.com"></div>
<div class="form-group"><label>Zoom (%)</label><input type="number" id="wZoom" class="input" value="${config.zoom || 100}"></div> <div class="form-group"><label>${t('widget.field.zoom_pct')}</label><input type="number" id="wZoom" class="input" value="${config.zoom || 100}"></div>
<div class="form-group"><label>Refresh Interval (seconds, 0 = never)</label><input type="number" id="wRefresh" class="input" value="${config.refresh_interval || 0}"></div>`; <div class="form-group"><label>${t('widget.field.refresh_interval')}</label><input type="number" id="wRefresh" class="input" value="${config.refresh_interval || 0}"></div>`;
break; break;
case 'social': case 'social':
html += ` html += `
<div class="form-group"><label>Platform</label><select id="wPlatform" class="input" style="background:var(--bg-input)"><option value="twitter">Twitter/X</option><option value="instagram">Instagram</option></select></div> <div class="form-group"><label>${t('widget.field.platform')}</label><select id="wPlatform" class="input" style="background:var(--bg-input)"><option value="twitter">${t('widget.field.platform_twitter')}</option><option value="instagram">${t('widget.field.platform_instagram')}</option></select></div>
<div class="form-group"><label>Query</label><input type="text" id="wQuery" class="input" value="${config.query || ''}" placeholder="@handle or #hashtag"></div>`; <div class="form-group"><label>${t('widget.field.query')}</label><input type="text" id="wQuery" class="input" value="${config.query || ''}" placeholder="${t('widget.field.query_placeholder')}"></div>`;
break;
case 'directory-board':
html += `
<div class="form-group"><label>${t('widget.dir.title_label')}</label><input type="text" id="wTitle" class="input" value="${escAttr(config.title)}" placeholder="${t('widget.dir.title_placeholder')}"></div>
<div class="form-group"><label>${t('widget.dir.logo_label')}</label><div id="wLogoBox"></div></div>
<div class="form-group"><label>${t('widget.dir.footer_text_label')}</label><input type="text" id="wFooter" class="input" value="${escAttr(config.footer_text)}" placeholder="${t('widget.dir.footer_placeholder')}"></div>
<div class="form-group">
<label>${t('widget.dir.bg_images_label')}</label>
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">${t('widget.dir.bg_images_hint')}</div>
<div id="wBgList"></div>
<button type="button" class="btn btn-secondary btn-sm" id="wBgAdd" style="margin-top:8px">${t('widget.dir.add_bg_image')}</button>
</div>
<div class="form-group" style="display:flex;gap:12px;flex-wrap:wrap">
<div style="flex:1;min-width:140px"><label>${t('widget.dir.theme')}</label><select id="wTheme" class="input" style="background:var(--bg-input)">
<option value="dark" ${!config.theme || config.theme === 'dark' ? 'selected' : ''}>${t('widget.dir.theme_dark')}</option>
<option value="light" ${config.theme === 'light' ? 'selected' : ''}>${t('widget.dir.theme_light')}</option>
</select></div>
<div style="flex:1;min-width:140px"><label>${t('widget.dir.scroll_speed')}</label><select id="wSpeed" class="input" style="background:var(--bg-input)">
<option value="slow" ${config.scroll_speed === 'slow' ? 'selected' : ''}>${t('widget.dir.speed_slow')}</option>
<option value="medium" ${!config.scroll_speed || config.scroll_speed === 'medium' ? 'selected' : ''}>${t('widget.dir.speed_medium')}</option>
<option value="fast" ${config.scroll_speed === 'fast' ? 'selected' : ''}>${t('widget.dir.speed_fast')}</option>
</select></div>
<div style="flex:1;min-width:140px"><label>${t('widget.dir.columns')}</label><select id="wCols" class="input" style="background:var(--bg-input)">
<option value="auto" ${!config.columns || config.columns === 'auto' ? 'selected' : ''}>${t('widget.dir.columns_auto')}</option>
<option value="1" ${config.columns === '1' ? 'selected' : ''}>1</option>
<option value="2" ${config.columns === '2' ? 'selected' : ''}>2</option>
<option value="3" ${config.columns === '3' ? 'selected' : ''}>3</option>
<option value="4" ${config.columns === '4' ? 'selected' : ''}>4</option>
</select></div>
</div>
<div class="form-group">
<label>${t('widget.dir.categories')}</label>
<div id="dbCategories"></div>
<button type="button" class="btn btn-secondary btn-sm" id="dbAddCategory" style="margin-top:10px">${t('widget.dir.add_category')}</button>
</div>`;
break; break;
} }
document.getElementById('widgetConfigForm').innerHTML = html; document.getElementById('widgetConfigForm').innerHTML = html;
const modalEl = document.querySelector('#widgetModal .modal');
if (modalEl) modalEl.style.width = type === 'directory-board' ? '720px' : '560px';
document.getElementById('widgetModal').style.display = 'flex'; document.getElementById('widgetModal').style.display = 'flex';
if (type === 'directory-board') {
dirState.logo_url = config.logo_url || '';
dirState.background_images = Array.isArray(config.background_images) ? config.background_images.slice() : [];
dirState.categories = (config.categories || []).map(cat => ({
name: cat.name || '',
_expanded: false,
entries: (cat.entries || []).map(e => ({
identifier: e.identifier || '',
name: e.name || '',
subtitle: e.subtitle || '',
available: !!e.available,
})),
}));
renderLogoPicker();
renderBgList();
renderDirCategories();
document.getElementById('dbAddCategory').onclick = () => {
dirState.categories.push({ name: '', _expanded: true, entries: [] });
renderDirCategories({ focusCatName: dirState.categories.length - 1 });
};
document.getElementById('wBgAdd').onclick = pickBgImages;
}
}
function renderDirCategories(opts = {}) {
const cont = document.getElementById('dbCategories');
if (!cont) return;
if (!dirState.categories.length) {
cont.innerHTML = `<div style="padding:20px;text-align:center;color:var(--text-muted);border:1px dashed var(--border);border-radius:6px;font-size:13px">${t('widget.dir.empty_categories')}</div>`;
return;
}
cont.innerHTML = dirState.categories.map((cat, i) => {
const entryRows = (cat.entries || []).map((e, j) => `
<div class="db-entry" style="display:flex;gap:6px;align-items:flex-start;margin-bottom:8px;flex-wrap:wrap">
<input type="text" class="input" data-entry-id="${i}-${j}" value="${escAttr(e.identifier)}" placeholder="${t('widget.dir.entry_id_placeholder')}" style="width:90px">
<div style="display:flex;flex-direction:column;gap:4px;flex:1;min-width:140px">
<input type="text" class="input" data-entry-name="${i}-${j}" value="${escAttr(e.name)}" placeholder="${t('widget.dir.entry_name_placeholder')}">
<input type="text" class="input" data-entry-subtitle="${i}-${j}" value="${escAttr(e.subtitle)}" placeholder="${t('widget.dir.entry_subtitle_placeholder')}" style="font-size:12px">
</div>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;color:var(--text-muted);padding-top:8px">
<input type="checkbox" data-entry-avail="${i}-${j}" ${e.available ? 'checked' : ''}> ${t('widget.dir.available')}
</label>
<button type="button" class="btn-icon" data-entry-up="${i}-${j}" ${j === 0 ? 'disabled' : ''} title="${t('widget.dir.move_up')}" style="padding:4px 6px">&#8593;</button>
<button type="button" class="btn-icon" data-entry-down="${i}-${j}" ${j === cat.entries.length - 1 ? 'disabled' : ''} title="${t('widget.dir.move_down')}" style="padding:4px 6px">&#8595;</button>
<button type="button" class="btn-icon" data-entry-delete="${i}-${j}" title="${t('widget.dir.delete_entry')}" style="padding:4px 6px;color:#ff6b6b">&#215;</button>
</div>
`).join('');
const entryCount = cat.entries.length;
const entriesLabel = entryCount === 1 ? t('widget.dir.entry') : t('widget.dir.entries');
return `
<div class="db-category" style="border:1px solid var(--border);border-radius:6px;margin-bottom:8px;padding:8px;background:var(--bg-input)">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<button type="button" class="btn-icon" data-cat-toggle="${i}" title="${cat._expanded ? t('widget.dir.collapse') : t('widget.dir.expand')}" style="padding:4px 8px">${cat._expanded ? '&#9660;' : '&#9654;'}</button>
<input type="text" class="input" data-cat-name="${i}" value="${escAttr(cat.name)}" placeholder="${t('widget.dir.category_name_placeholder')}" style="flex:1;min-width:140px;font-weight:600">
<span style="font-size:11px;color:var(--text-muted);white-space:nowrap">${entryCount} ${entriesLabel}</span>
<button type="button" class="btn-icon" data-cat-up="${i}" ${i === 0 ? 'disabled' : ''} title="${t('widget.dir.move_up')}" style="padding:4px 6px">&#8593;</button>
<button type="button" class="btn-icon" data-cat-down="${i}" ${i === dirState.categories.length - 1 ? 'disabled' : ''} title="${t('widget.dir.move_down')}" style="padding:4px 6px">&#8595;</button>
<button type="button" class="btn-icon" data-cat-delete="${i}" title="${t('widget.dir.delete_category')}" style="padding:4px 6px;color:#ff6b6b">&#215;</button>
</div>
${cat._expanded ? `
<div style="padding:10px 0 4px 4px;margin-top:8px;border-top:1px solid var(--border)">
${entryRows || `<div style="font-size:12px;color:var(--text-muted);padding:4px 0 8px">${t('widget.dir.no_entries')}</div>`}
<button type="button" class="btn btn-secondary btn-sm" data-add-entry="${i}" style="margin-top:4px">${t('widget.dir.add_entry')}</button>
</div>
` : ''}
</div>
`;
}).join('');
wireDirHandlers(opts);
}
function wireDirHandlers(opts = {}) {
const cont = document.getElementById('dbCategories');
if (!cont) return;
cont.querySelectorAll('[data-cat-toggle]').forEach(b => b.onclick = () => {
const i = +b.dataset.catToggle;
dirState.categories[i]._expanded = !dirState.categories[i]._expanded;
renderDirCategories();
});
cont.querySelectorAll('[data-cat-name]').forEach(inp => inp.oninput = () => {
dirState.categories[+inp.dataset.catName].name = inp.value;
});
cont.querySelectorAll('[data-cat-up]').forEach(b => b.onclick = () => {
const i = +b.dataset.catUp;
if (i === 0) return;
[dirState.categories[i - 1], dirState.categories[i]] = [dirState.categories[i], dirState.categories[i - 1]];
renderDirCategories();
});
cont.querySelectorAll('[data-cat-down]').forEach(b => b.onclick = () => {
const i = +b.dataset.catDown;
if (i >= dirState.categories.length - 1) return;
[dirState.categories[i + 1], dirState.categories[i]] = [dirState.categories[i], dirState.categories[i + 1]];
renderDirCategories();
});
cont.querySelectorAll('[data-cat-delete]').forEach(b => b.onclick = () => {
const i = +b.dataset.catDelete;
const label = dirState.categories[i].name || t('widget.dir.unnamed');
if (!confirm(t('widget.dir.confirm_delete_category', { name: label }))) return;
dirState.categories.splice(i, 1);
renderDirCategories();
});
cont.querySelectorAll('[data-entry-id]').forEach(inp => inp.oninput = () => {
const [i, j] = inp.dataset.entryId.split('-').map(Number);
dirState.categories[i].entries[j].identifier = inp.value;
});
cont.querySelectorAll('[data-entry-name]').forEach(inp => inp.oninput = () => {
const [i, j] = inp.dataset.entryName.split('-').map(Number);
dirState.categories[i].entries[j].name = inp.value;
});
cont.querySelectorAll('[data-entry-subtitle]').forEach(inp => inp.oninput = () => {
const [i, j] = inp.dataset.entrySubtitle.split('-').map(Number);
dirState.categories[i].entries[j].subtitle = inp.value;
});
cont.querySelectorAll('[data-entry-avail]').forEach(inp => inp.onchange = () => {
const [i, j] = inp.dataset.entryAvail.split('-').map(Number);
dirState.categories[i].entries[j].available = inp.checked;
});
cont.querySelectorAll('[data-entry-up]').forEach(b => b.onclick = () => {
const [i, j] = b.dataset.entryUp.split('-').map(Number);
if (j === 0) return;
const es = dirState.categories[i].entries;
[es[j - 1], es[j]] = [es[j], es[j - 1]];
renderDirCategories();
});
cont.querySelectorAll('[data-entry-down]').forEach(b => b.onclick = () => {
const [i, j] = b.dataset.entryDown.split('-').map(Number);
const es = dirState.categories[i].entries;
if (j >= es.length - 1) return;
[es[j + 1], es[j]] = [es[j], es[j + 1]];
renderDirCategories();
});
cont.querySelectorAll('[data-entry-delete]').forEach(b => b.onclick = () => {
const [i, j] = b.dataset.entryDelete.split('-').map(Number);
dirState.categories[i].entries.splice(j, 1);
renderDirCategories();
});
cont.querySelectorAll('[data-add-entry]').forEach(b => b.onclick = () => {
const i = +b.dataset.addEntry;
dirState.categories[i].entries.push({ identifier: '', name: '', subtitle: '', available: false });
renderDirCategories({ focusEntryId: `${i}-${dirState.categories[i].entries.length - 1}` });
});
if (opts.focusCatName != null) {
const inp = cont.querySelector(`[data-cat-name="${opts.focusCatName}"]`);
if (inp) { inp.focus(); inp.select(); }
}
if (opts.focusEntryId) {
const inp = cont.querySelector(`[data-entry-id="${opts.focusEntryId}"]`);
if (inp) inp.focus();
}
}
function renderLogoPicker() {
const box = document.getElementById('wLogoBox');
if (!box) return;
if (dirState.logo_url) {
box.innerHTML = `
<div style="display:flex;align-items:center;gap:10px;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input)">
<img src="${escAttr(dirState.logo_url)}" style="max-height:50px;max-width:120px;object-fit:contain;background:#0003;border-radius:3px" onerror="this.style.opacity='0.3'">
<div style="flex:1;min-width:0;font-size:11px;color:var(--text-muted);word-break:break-all;overflow:hidden;text-overflow:ellipsis">${escAttr(dirState.logo_url)}</div>
<button type="button" class="btn btn-secondary btn-sm" id="wLogoChange">${t('widget.dir.change')}</button>
<button type="button" class="btn-icon" id="wLogoClear" title="${t('widget.dir.remove_logo')}" style="color:#ff6b6b;padding:4px 8px">&#215;</button>
</div>`;
document.getElementById('wLogoChange').onclick = pickLogo;
document.getElementById('wLogoClear').onclick = () => { dirState.logo_url = ''; renderLogoPicker(); };
} else {
box.innerHTML = `<button type="button" class="btn btn-secondary btn-sm" id="wLogoChoose">${t('widget.dir.choose_logo')}</button>`;
document.getElementById('wLogoChoose').onclick = pickLogo;
}
}
async function pickLogo() {
const url = await openContentPicker({ multiple: false, title: t('widget.picker.select_logo') });
if (url) { dirState.logo_url = url; renderLogoPicker(); }
}
function renderBgList() {
const list = document.getElementById('wBgList');
if (!list) return;
if (!dirState.background_images.length) {
list.innerHTML = `<div style="font-size:12px;color:var(--text-muted);font-style:italic;padding:4px 0">${t('widget.dir.no_bg_images')}</div>`;
return;
}
list.innerHTML = `<div style="display:flex;gap:8px;flex-wrap:wrap">${
dirState.background_images.map((u, i) => `
<div style="position:relative;width:90px;height:68px;border-radius:4px;overflow:hidden;background:var(--bg-input);border:1px solid var(--border)">
<img src="${escAttr(u)}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">
<button type="button" data-bg-remove="${i}" title="${t('widget.dir.remove_bg')}" style="position:absolute;top:3px;right:3px;width:22px;height:22px;border-radius:50%;border:0;background:rgba(0,0,0,0.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;padding:0">&#215;</button>
</div>
`).join('')
}</div>`;
list.querySelectorAll('[data-bg-remove]').forEach(b => b.onclick = () => {
dirState.background_images.splice(+b.dataset.bgRemove, 1);
renderBgList();
});
}
async function pickBgImages() {
const urls = await openContentPicker({ multiple: true, title: t('widget.picker.select_bg_images') });
if (urls && urls.length) {
dirState.background_images.push(...urls);
renderBgList();
}
} }
function getConfigFromForm(type) { function getConfigFromForm(type) {
@ -132,6 +501,24 @@ export async function render(container) {
case 'text': Object.assign(config, { html: val('wHtml'), css: val('wCss'), background: val('wBg') }); break; case 'text': Object.assign(config, { html: val('wHtml'), css: val('wCss'), background: val('wBg') }); break;
case 'webpage': Object.assign(config, { url: val('wUrl'), zoom: parseInt(val('wZoom')) || 100, refresh_interval: parseInt(val('wRefresh')) || 0 }); break; case 'webpage': Object.assign(config, { url: val('wUrl'), zoom: parseInt(val('wZoom')) || 100, refresh_interval: parseInt(val('wRefresh')) || 0 }); break;
case 'social': Object.assign(config, { platform: val('wPlatform'), query: val('wQuery') }); break; case 'social': Object.assign(config, { platform: val('wPlatform'), query: val('wQuery') }); break;
case 'directory-board': Object.assign(config, {
title: val('wTitle') || ' ',
logo_url: dirState.logo_url || '',
footer_text: val('wFooter') || '',
background_images: dirState.background_images.slice(),
theme: val('wTheme') || 'dark',
scroll_speed: val('wSpeed') || 'medium',
columns: val('wCols') || 'auto',
categories: dirState.categories.map(cat => ({
name: cat.name || '',
entries: (cat.entries || []).map(e => ({
identifier: e.identifier || '',
name: e.name || '',
subtitle: e.subtitle || '',
available: !!e.available,
})),
})),
}); break;
} }
return config; return config;
} }
@ -147,40 +534,49 @@ export async function render(container) {
await API('/widgets', { method: 'POST', body: JSON.stringify({ widget_type: type, name, config }) }); await API('/widgets', { method: 'POST', body: JSON.stringify({ widget_type: type, name, config }) });
} }
document.getElementById('widgetModal').style.display = 'none'; document.getElementById('widgetModal').style.display = 'none';
showToast('Widget saved', 'success'); showToast(t('widget.toast.saved'), 'success');
loadWidgets(); loadWidgets();
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };
document.getElementById('previewWidgetBtn').onclick = () => { document.getElementById('previewWidgetBtn').onclick = async () => {
if (editingWidget) { const type = editingWidget?.widget_type || creatingType;
window.open(`/api/widgets/${editingWidget.id}/render`, '_blank', 'width=600,height=400'); if (!type) return;
} else { const config = getConfigFromForm(type);
showToast('Save the widget first to preview', 'info'); try {
} const res = await fetch('/api/widgets/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ widget_type: type, config }),
});
if (!res.ok) throw new Error(t('widget.toast.preview_failed'));
const html = await res.text();
showPreviewModal(html);
} catch (err) { showToast(err.message, 'error'); }
}; };
async function loadWidgets() { async function loadWidgets() {
const widgets = await API('/widgets'); const widgets = await API('/widgets');
const grid = document.getElementById('widgetGrid'); const grid = document.getElementById('widgetGrid');
if (!widgets.length) { if (!widgets.length) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><h3>No widgets yet</h3><p>Create a widget to add dynamic content to your layouts.</p></div>'; grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('widget.empty_title')}</h3><p>${t('widget.empty_desc')}</p></div>`;
return; return;
} }
grid.innerHTML = widgets.map(w => { grid.innerHTML = widgets.map(w => {
const typeMeta = WIDGET_TYPES.find(t => t.id === w.widget_type) || {}; const icon = WIDGET_ICONS[w.widget_type] || '?';
const typeLabel = WIDGET_TYPES.includes(w.widget_type) ? widgetTypeName(w.widget_type) : w.widget_type;
return ` return `
<div class="content-item"> <div class="content-item">
<div class="content-item-preview" style="display:flex;align-items:center;justify-content:center;flex-direction:column;gap:4px"> <div class="content-item-preview" style="display:flex;align-items:center;justify-content:center;flex-direction:column;gap:4px">
<span style="font-size:36px">${typeMeta.icon || '?'}</span> <span style="font-size:36px">${icon}</span>
</div> </div>
<div class="content-item-body"> <div class="content-item-body">
<div class="content-item-name">${w.name}</div> <div class="content-item-name">${escAttr(w.name)}</div>
<div class="content-item-size">${typeMeta.name || w.widget_type}</div> <div class="content-item-size">${escAttr(typeLabel)}</div>
</div> </div>
<div class="content-item-actions"> <div class="content-item-actions">
<button class="btn btn-secondary btn-sm" data-edit-widget="${w.id}">Edit</button> <button class="btn btn-secondary btn-sm" data-edit-widget="${escAttr(w.id)}">${t('common.edit')}</button>
<button class="btn btn-danger btn-sm" data-delete-widget="${w.id}">Delete</button> <button class="btn btn-danger btn-sm" data-delete-widget="${escAttr(w.id)}">${t('common.delete')}</button>
</div> </div>
</div> </div>
`; `;
@ -201,9 +597,12 @@ export async function render(container) {
} }
const deleteBtn = e.target.closest('[data-delete-widget]'); const deleteBtn = e.target.closest('[data-delete-widget]');
if (deleteBtn) { if (deleteBtn) {
const w = widgets.find(x => x.id === deleteBtn.dataset.deleteWidget);
const label = w ? w.name : t('widget.this_widget');
if (!confirm(t('widget.confirm_delete', { name: label }))) return;
try { try {
await API(`/widgets/${deleteBtn.dataset.deleteWidget}`, { method: 'DELETE' }); await API(`/widgets/${deleteBtn.dataset.deleteWidget}`, { method: 'DELETE' });
showToast('Widget deleted', 'success'); showToast(t('widget.toast.deleted'), 'success');
loadWidgets(); loadWidgets();
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
} }

View file

@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary SEO --> <!-- Primary SEO -->
<title>ScreenTinker - Digital Signage Software | Manage Any Screen Remotely</title> <title>ScreenTinker - Self-Hosted Open-Source Digital Signage CMS | Free Display Management</title>
<meta name="description" content="Free digital signage software for any screen. Remote control, video walls, multi-zone layouts, scheduling, kiosk mode, and analytics. Works on Android, Raspberry Pi, Windows, ChromeOS, and smart TVs. Start free, no credit card required."> <meta name="description" content="Open-source digital signage CMS. Free plan, self-host or cloud. Manage TVs, video walls, kiosks, and schedules across Android, Raspberry Pi, Windows, and any browser. MIT licensed.">
<meta name="keywords" content="digital signage, digital signage software, remote display, signage management, video wall, kiosk mode, screen management, content management, Android signage, Raspberry Pi signage, free digital signage"> <meta name="keywords" content="digital signage, open source digital signage, self hosted signage, digital signage cms, free digital signage software, digital signage raspberry pi, digital signage android tv, video wall software, kiosk software, screen management">
<meta name="author" content="ScreenTinker"> <meta name="author" content="ScreenTinker">
<meta name="robots" content="index, follow"> <meta name="robots" content="index, follow">
<link rel="canonical" href="https://screentinker.com/"> <link rel="canonical" href="https://screentinker.com/">
@ -15,16 +15,20 @@
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://screentinker.com/"> <meta property="og:url" content="https://screentinker.com/">
<meta property="og:title" content="ScreenTinker - Digital Signage Made Simple"> <meta property="og:title" content="ScreenTinker - Open-Source Digital Signage CMS">
<meta property="og:description" content="Manage content on TVs, displays, and kiosks from anywhere. Remote control, video walls, scheduling, and analytics. 9 platforms supported. Start free."> <meta property="og:description" content="Open-source digital signage CMS. Free plan, self-host or cloud. Manage TVs, video walls, kiosks, and schedules across 9 platforms.">
<meta property="og:image" content="https://screentinker.com/assets/icon-512.png"> <meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta property="og:image:width" content="2500">
<meta property="og:image:height" content="1314">
<meta property="og:image:alt" content="ScreenTinker open-source digital signage dashboard showing four online displays with playlist assignments">
<meta property="og:site_name" content="ScreenTinker"> <meta property="og:site_name" content="ScreenTinker">
<!-- Twitter --> <!-- Twitter -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ScreenTinker - Digital Signage Made Simple"> <meta name="twitter:title" content="ScreenTinker - Open-Source Digital Signage CMS">
<meta name="twitter:description" content="Free digital signage software for any screen. Remote control, video walls, layouts, scheduling, kiosk mode. Works on 9 platforms."> <meta name="twitter:description" content="Open-source digital signage CMS. Free plan, self-host or cloud. Video walls, kiosks, scheduling, and live remote control across 9 platforms.">
<meta name="twitter:image" content="https://screentinker.com/assets/icon-512.png"> <meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta name="twitter:image:alt" content="ScreenTinker open-source digital signage dashboard showing four online displays with playlist assignments">
<!-- Theme --> <!-- Theme -->
<meta name="theme-color" content="#111827"> <meta name="theme-color" content="#111827">
@ -39,7 +43,7 @@
/* Nav */ /* Nav */
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 { 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-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); } .nav-logo { display:flex; align-items:center; gap:10px; font-weight:700; font-size:18px; color:var(--accent); flex-shrink:0; }
.nav-links a { color:var(--muted); margin-left:24px; font-size:14px; transition:color 0.2s; } .nav-links a { color:var(--muted); margin-left:24px; font-size:14px; transition:color 0.2s; }
.nav-links a:hover { color:var(--text); } .nav-links a:hover { color:var(--text); }
.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 { 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; }
@ -81,6 +85,24 @@
.platform-item .icon { font-size:40px; margin-bottom:8px; } .platform-item .icon { font-size:40px; margin-bottom:8px; }
.platform-item .name { font-size:13px; color:var(--muted); } .platform-item .name { font-size:13px; color:var(--muted); }
/* Modal (mirrors frontend/css/main.css conventions so screenshots look
consistent across landing and dashboard; copied inline since
landing.html doesn't import main.css). */
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); display:flex; align-items:center; justify-content:center; z-index:1000; padding:16px; }
.modal { background:var(--card); border:1px solid var(--border); border-radius:12px; width:100%; max-width:560px; max-height:90vh; overflow-y:auto; }
.modal-header { padding:20px 24px; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; }
.modal-header h3 { font-size:18px; margin:0; }
.modal-close { background:none; border:none; color:var(--muted); font-size:24px; cursor:pointer; padding:0; line-height:1; }
.modal-body { padding:20px 24px; }
.modal-description { color:var(--muted); font-size:14px; margin-bottom:16px; }
.modal-footer { padding:16px 24px; border-top:1px solid var(--border); display:flex; gap:12px; justify-content:flex-end; }
.modal-body label { display:block; margin-bottom:12px; font-size:13px; color:var(--text); }
.modal-body input, .modal-body select, .modal-body textarea { width:100%; margin-top:4px; padding:8px 10px; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; font-size:14px; font-family:inherit; box-sizing:border-box; }
.modal-body input:focus, .modal-body select:focus, .modal-body textarea:focus { outline:none; border-color:var(--accent); }
.modal-body textarea { resize:vertical; }
.contact-status-success { color:#10b981; font-size:13px; }
.contact-status-error { color:#f87171; font-size:13px; }
/* Pricing */ /* Pricing */
.pricing { max-width:1200px; margin:0 auto; padding:80px 24px; } .pricing { max-width:1200px; margin:0 auto; padding:80px 24px; }
.pricing h2 { text-align:center; font-size:36px; margin-bottom:12px; } .pricing h2 { text-align:center; font-size:36px; margin-bottom:12px; }
@ -117,13 +139,35 @@
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 { 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 { color:var(--dim); margin-left:16px; font-size:13px; }
/* Horizontal-scroll wrapper for wide tables on mobile */
.table-scroll { width:100%; overflow-x:auto; -webkit-overflow-scrolling:touch; }
.nav-links { display:flex; align-items:center; flex-wrap:nowrap; }
.btn-short { display:none; }
@media (max-width:768px) { @media (max-width:768px) {
.nav-links { display:none; } /* Hide section anchor links on mobile; keep Try Free + Sign In */
.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 !important; flex-shrink:0; min-height:0; }
.btn-full { display:none; }
.btn-short { display:inline; }
}
@media (max-width:420px) {
.nav-logo-text { display:none; }
}
.feature-grid { grid-template-columns:1fr; } .feature-grid { grid-template-columns:1fr; }
.pricing-grid { grid-template-columns:1fr; } .pricing-grid { grid-template-columns:1fr; }
.compare-table { font-size:12px; } .compare-table { font-size:12px; min-width:560px; }
.compare-table th, .compare-table td { padding:8px; } .compare-table th, .compare-table td { padding:8px; }
footer { flex-direction:column; text-align:center; } footer { flex-direction:column; text-align:center; }
/* 44px tap targets for all buttons on mobile */
.btn { min-height:44px; }
.hero { padding:110px 16px 60px; }
.features, .platforms, .pricing, .compare { padding:60px 16px; }
.cta { padding:60px 16px; }
.screenshot { padding:0 16px; margin-bottom:60px; }
} }
</style> </style>
</head> </head>
@ -132,37 +176,60 @@
<div class="nav-inner"> <div class="nav-inner">
<div class="nav-logo"> <div class="nav-logo">
<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> <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>
ScreenTinker <span class="nav-logo-text">ScreenTinker</span>
</div> </div>
<div class="nav-links"> <div class="nav-links">
<a href="#features">Features</a> <a href="#features">Features</a>
<a href="#platforms">Platforms</a> <a href="#platforms">Platforms</a>
<a href="#pricing">Pricing</a> <a href="#pricing">Pricing</a>
<a href="#compare">Compare</a> <a href="#compare">Compare</a>
<a href="/app" class="btn btn-outline" style="margin-left:16px">Sign In</a> <a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" aria-label="ScreenTinker on GitHub" class="nav-github" style="margin-left:16px;display:inline-flex;align-items:center;color:var(--muted)">
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Start Free Trial</a> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.4 3.6 1 .1-.8.4-1.4.8-1.7-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2a11 11 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3"/></svg>
</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"><span class="btn-full">Start Free Trial</span><span class="btn-short">Try Free</span></a>
</div> </div>
</div> </div>
</nav> </nav>
<main>
<!-- Hero --> <!-- Hero -->
<section class="hero"> <section class="hero">
<div class="hero-badge">&#9889; 14-day free Pro trial &middot; No credit card required</div> <div class="hero-badge">&#9889; Open source &middot; Free plan &middot; 14-day Pro trial, no credit card</div>
<h1>Digital Signage<br>for <span>Any Screen</span></h1> <h1>Open-Source Digital Signage<br>for <span>Any Screen</span></h1>
<p>Manage content on TVs, displays, and kiosks from anywhere. Remote control, video walls, scheduling, and analytics. Works on any device.</p> <p>Manage content on TVs, displays, and kiosks from anywhere. Remote control, video walls, scheduling, and analytics. Self-host or use our managed cloud.</p>
<div class="hero-btns"> <div class="hero-btns">
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free Trial</a> <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">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="margin-right:2px"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.4 3.6 1 .1-.8.4-1.4.8-1.7-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2a11 11 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3"/></svg>
View on GitHub
</a>
<a href="#compare" class="btn btn-outline" style="padding:14px 28px;font-size:16px">See How We Compare</a> <a href="#compare" class="btn btn-outline" style="padding:14px 28px;font-size:16px">See How We Compare</a>
</div> </div>
</section> </section>
<!-- Screenshot placeholder --> <!-- Intro video -->
<div style="max-width:800px;margin:32px auto;border-radius:12px;overflow:hidden;border:1px solid var(--border)">
<div style="position:relative;padding-bottom:56.25%;height:0">
<iframe src="https://www.youtube.com/embed/Sq3RbnuDgKw?rel=0" title="ScreenTinker - Open Source Digital Signage for Any Screen" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen style="position:absolute;top:0;left:0;width:100%;height:100%"></iframe>
</div>
</div>
<!-- Dashboard preview -->
<div class="screenshot"> <div class="screenshot">
<div class="mock"> <img src="/assets/dashboard-preview.png" alt="ScreenTinker open-source digital signage dashboard showing 4 online displays with playlist assignments" loading="lazy" width="2500" height="1314">
<iframe src="#/" style="width:100%;height:100%;border:none;border-radius:12px;pointer-events:none" loading="lazy"></iframe>
</div>
</div> </div>
<!-- Open Source callout -->
<section class="opensource" id="opensource" style="max-width:900px;margin:0 auto;padding:40px 24px;text-align:center">
<h2 style="font-size:28px;margin-bottom:12px">Fully Open Source</h2>
<p style="color:var(--muted);font-size:16px;margin-bottom:20px">MIT licensed. Self-host on your own infrastructure or use our managed cloud. Your data, your rules.</p>
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="display:inline-flex;align-items:center;gap:8px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.4 3.6 1 .1-.8.4-1.4.8-1.7-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2a11 11 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3"/></svg>
github.com/screentinker/screentinker
</a>
</section>
<!-- Features --> <!-- Features -->
<section class="features" id="features"> <section class="features" id="features">
<h2>Everything You Need</h2> <h2>Everything You Need</h2>
@ -171,12 +238,16 @@
<div class="feature-card"><div class="feature-icon">&#128250;</div><h3>Multi-Zone Layouts</h3><p>Split screens into zones with a drag-and-drop editor. 7 built-in templates or create your own.</p></div> <div class="feature-card"><div class="feature-icon">&#128250;</div><h3>Multi-Zone Layouts</h3><p>Split screens into zones with a drag-and-drop editor. 7 built-in templates or create your own.</p></div>
<div class="feature-card"><div class="feature-icon">&#127916;</div><h3>Video Wall</h3><p>Combine multiple displays into one giant screen. Automatic bezel compensation. Any grid size.</p></div> <div class="feature-card"><div class="feature-icon">&#127916;</div><h3>Video Wall</h3><p>Combine multiple displays into one giant screen. Automatic bezel compensation. Any grid size.</p></div>
<div class="feature-card"><div class="feature-icon">&#128421;</div><h3>Remote Control</h3><p>See what's on screen in real-time. Send key presses, navigate menus, power on/off remotely.</p></div> <div class="feature-card"><div class="feature-icon">&#128421;</div><h3>Remote Control</h3><p>See what's on screen in real-time. Send key presses, navigate menus, power on/off remotely.</p></div>
<div class="feature-card"><div class="feature-icon">&#128197;</div><h3>Scheduling</h3><p>Visual weekly calendar. Set content to play at specific times with recurrence rules.</p></div> <div class="feature-card"><div class="feature-icon">&#128197;</div><h3>Scheduling</h3><p>Visual weekly calendar with recurrence rules. Schedule per device or for whole groups — device rules override the group.</p></div>
<div class="feature-card"><div class="feature-icon">&#127926;</div><h3>Playlists</h3><p>Share one playlist across many displays. Draft changes, preview, then publish — or revert to the last published version.</p></div>
<div class="feature-card"><div class="feature-icon">&#127970;</div><h3>Directory Board</h3><p>Scrolling lobby, building, and staff directories. Categories, dark &amp; light themes, anti-burn-in. Not found in any other open-source CMS.</p></div>
<div class="feature-card"><div class="feature-icon">&#128295;</div><h3>Content Designer</h3><p>Built-in editor with live clocks, weather, RSS tickers, countdowns, QR codes, and more.</p></div> <div class="feature-card"><div class="feature-icon">&#128295;</div><h3>Content Designer</h3><p>Built-in editor with live clocks, weather, RSS tickers, countdowns, QR codes, and more.</p></div>
<div class="feature-card"><div class="feature-icon">&#128433;</div><h3>Kiosk Mode</h3><p>Create interactive touchscreen interfaces. Wayfinding, directories, check-in screens.</p></div> <div class="feature-card"><div class="feature-icon">&#128433;</div><h3>Kiosk Mode</h3><p>Create interactive touchscreen interfaces. Wayfinding, directories, check-in screens.</p></div>
<div class="feature-card"><div class="feature-icon">&#128202;</div><h3>Proof-of-Play</h3><p>Track what played, when, and on which device. Export CSV reports for ad verification.</p></div> <div class="feature-card"><div class="feature-icon">&#128202;</div><h3>Proof-of-Play</h3><p>Track what played, when, and on which device. Export CSV reports for ad verification.</p></div>
<div class="feature-card"><div class="feature-icon">&#128276;</div><h3>Alerts & Monitoring</h3><p>Email alerts when devices go offline. Full telemetry: battery, storage, WiFi, uptime.</p></div> <div class="feature-card"><div class="feature-icon">&#128276;</div><h3>Alerts & Monitoring</h3><p>Email alerts when devices go offline. Full telemetry: battery, storage, WiFi, uptime.</p></div>
<div class="feature-card"><div class="feature-icon">&#128274;</div><h3>Self-Hosted Option</h3><p>Deploy on your own infrastructure. Your data never leaves your network. Full control.</p></div> <div class="feature-card"><div class="feature-icon">&#128268;</div><h3>Offline Resilience</h3><p>Displays keep playing cached content when the internet or your server drops. They catch up automatically when you're back.</p></div>
<div class="feature-card"><div class="feature-icon">&#128241;</div><h3>Mobile Dashboard</h3><p>Manage everything from your phone. The full dashboard works on any mobile browser — no separate app needed.</p></div>
<div class="feature-card"><div class="feature-icon">&#128274;</div><h3>Self-Hosted Option</h3><p>Deploy on your own infrastructure. Your data never leaves your network. Lock down signups with a single env var.</p></div>
<div class="feature-card"><div class="feature-icon">&#127912;</div><h3>White Label</h3><p>Custom branding, colors, logo, and domain. Resell under your own brand.</p></div> <div class="feature-card"><div class="feature-icon">&#127912;</div><h3>White Label</h3><p>Custom branding, colors, logo, and domain. Resell under your own brand.</p></div>
<div class="feature-card"><div class="feature-icon">&#128101;</div><h3>Teams</h3><p>Multi-user accounts with owner, editor, and viewer roles. Invite by email.</p></div> <div class="feature-card"><div class="feature-icon">&#128101;</div><h3>Teams</h3><p>Multi-user accounts with owner, editor, and viewer roles. Invite by email.</p></div>
<div class="feature-card"><div class="feature-icon">&#128260;</div><h3>Auto-Update</h3><p>Devices automatically update when you push a new version. Zero manual intervention.</p></div> <div class="feature-card"><div class="feature-icon">&#128260;</div><h3>Auto-Update</h3><p>Devices automatically update when you push a new version. Zero manual intervention.</p></div>
@ -209,10 +280,11 @@
<!-- Compare --> <!-- Compare -->
<section class="compare" id="compare"> <section class="compare" id="compare">
<h2>How We Compare</h2> <h2>How We Compare</h2>
<div class="table-scroll">
<table class="compare-table"> <table class="compare-table">
<thead><tr><th></th><th style="color:var(--accent);font-weight:700">ScreenTinker</th><th>Yodeck</th><th>ScreenCloud</th><th>OptiSigns</th></tr></thead> <thead><tr><th></th><th style="color:var(--accent);font-weight:700">ScreenTinker</th><th>Yodeck</th><th>ScreenCloud</th><th>OptiSigns</th></tr></thead>
<tbody> <tbody>
<tr><td>Price (15 devices/yr)</td><td style="color:var(--accent);font-weight:600">$989</td><td>$1,440+</td><td>$3,600+</td><td>$1,800+</td></tr> <tr><td>Price (15 devices/yr)</td><td style="color:var(--accent);font-weight:600">$1,188</td><td>$1,440+</td><td>$3,600+</td><td>$1,800+</td></tr>
<tr><td>Free tier</td><td class="yes">&#10003; 1 device</td><td class="yes">&#10003;</td><td class="no">&#10007;</td><td class="yes">&#10003;</td></tr> <tr><td>Free tier</td><td class="yes">&#10003; 1 device</td><td class="yes">&#10003;</td><td class="no">&#10007;</td><td class="yes">&#10003;</td></tr>
<tr><td>Platforms</td><td class="yes">9 platforms</td><td>2</td><td>2</td><td>3</td></tr> <tr><td>Platforms</td><td class="yes">9 platforms</td><td>2</td><td>2</td><td>3</td></tr>
<tr><td>Video Wall</td><td class="yes">&#10003; Included</td><td class="no">&#10007;</td><td class="no">&#10007;</td><td class="paid">Paid add-on</td></tr> <tr><td>Video Wall</td><td class="yes">&#10003; Included</td><td class="no">&#10007;</td><td class="no">&#10007;</td><td class="paid">Paid add-on</td></tr>
@ -226,6 +298,45 @@
<tr><td>Hardware Lock-in</td><td class="yes">None</td><td>RPi focused</td><td>Chromecast</td><td>Various</td></tr> <tr><td>Hardware Lock-in</td><td class="yes">None</td><td>RPi focused</td><td>Chromecast</td><td>Various</td></tr>
</tbody> </tbody>
</table> </table>
</div>
</section>
<!-- Resources / internal linking for SEO -->
<section class="resources" id="resources" style="max-width:1000px;margin:0 auto;padding:60px 24px;">
<h2 style="text-align:center;font-size:32px;margin-bottom:8px">Resources</h2>
<p style="text-align:center;color:var(--muted);margin-bottom:32px">Setup guides and honest comparisons.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px">
<a href="/guides/raspberry-pi-digital-signage.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Guide</div>
<div style="font-weight:600;margin-bottom:4px">How to set up digital signage on Raspberry Pi</div>
<div style="font-size:13px;color:var(--muted)">Hardware, OS, install script, pairing.</div>
</a>
<a href="/guides/digital-signage-android-tv.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Guide</div>
<div style="font-weight:600;margin-bottom:4px">Free digital signage for Android TV &amp; Fire TV</div>
<div style="font-size:13px;color:var(--muted)">APK sideload, kiosk mode, hardware tips.</div>
</a>
<a href="/guides/self-hosted-digital-signage.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Guide</div>
<div style="font-weight:600;margin-bottom:4px">Self-hosted digital signage guide</div>
<div style="font-size:13px;color:var(--muted)">Sizing, deploy, TLS, backups.</div>
</a>
<a href="/compare/yodeck-alternative.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Compare</div>
<div style="font-weight:600;margin-bottom:4px">ScreenTinker vs Yodeck</div>
<div style="font-size:13px;color:var(--muted)">Features, pricing, platform support.</div>
</a>
<a href="/compare/screencloud-alternative.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Compare</div>
<div style="font-weight:600;margin-bottom:4px">ScreenTinker vs ScreenCloud</div>
<div style="font-size:13px;color:var(--muted)">Pricing breakdown at scale.</div>
</a>
<a href="/compare/optisigns-alternative.html" style="display:block;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:20px;color:var(--text);transition:border-color 0.2s">
<div style="font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Compare</div>
<div style="font-weight:600;margin-bottom:4px">ScreenTinker vs OptiSigns</div>
<div style="font-size:13px;color:var(--muted)">Side by side feature and price comparison.</div>
</a>
</div>
</section> </section>
<!-- CTA --> <!-- CTA -->
@ -234,18 +345,104 @@
<p>14-day free Pro trial. No credit card required. Set up in under 5 minutes.</p> <p>14-day free Pro trial. No credit card required. Set up in under 5 minutes.</p>
<a href="/app#/login" class="btn btn-primary" style="padding:14px 32px;font-size:16px">Start Free Trial</a> <a href="/app#/login" class="btn btn-primary" style="padding:14px 32px;font-size:16px">Start Free Trial</a>
</section> </section>
</main>
<!-- Footer --> <!-- Footer -->
<footer> <div style="border-top:1px solid var(--border);background:rgba(15,23,42,0.4)">
<div style="color:var(--dim);font-size:13px">&copy; 2026 ScreenTinker. All rights reserved.</div> <div style="max-width:1200px;margin:0 auto;padding:48px 24px 24px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:32px">
<div class="links"> <div>
<a href="/legal/terms.html">Terms</a> <div style="font-weight:700;color:var(--accent);margin-bottom:12px">ScreenTinker</div>
<a href="/legal/privacy.html">Privacy</a> <div style="font-size:13px;color:var(--muted);line-height:1.6">Open-source digital signage. Free plan, self-host or cloud. MIT licensed.</div>
<a href="/legal/third-party.html">Licenses</a> </div>
<a href="/api/status" target="_blank">Status</a> <div>
<a href="/app#/login">Sign In</a> <div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px">Guides</div>
<div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
<a href="/guides/raspberry-pi-digital-signage.html" style="color:var(--muted)">Raspberry Pi setup</a>
<a href="/guides/digital-signage-android-tv.html" style="color:var(--muted)">Android TV &amp; Fire TV</a>
<a href="/guides/self-hosted-digital-signage.html" style="color:var(--muted)">Self-hosting guide</a>
</div>
</div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px">Compare</div>
<div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
<a href="/compare/yodeck-alternative.html" style="color:var(--muted)">vs Yodeck</a>
<a href="/compare/screencloud-alternative.html" style="color:var(--muted)">vs ScreenCloud</a>
<a href="/compare/optisigns-alternative.html" style="color:var(--muted)">vs OptiSigns</a>
</div>
</div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px">Project</div>
<div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" style="color:var(--muted)">GitHub</a>
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener" style="color:var(--muted)">Discord</a>
<a href="https://www.youtube.com/@ScreenTinker" target="_blank" rel="noopener" style="color:var(--muted)">YouTube</a>
<a href="/api/status" target="_blank" style="color:var(--muted)">Status</a>
<a href="/app#/login" style="color:var(--muted)">Sign in</a>
</div>
</div>
<div>
<div style="font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;text-transform:uppercase;letter-spacing:0.5px">Legal</div>
<div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
<a href="/legal/terms.html" style="color:var(--muted)">Terms</a>
<a href="/legal/privacy.html" style="color:var(--muted)">Privacy</a>
<a href="/legal/third-party.html" style="color:var(--muted)">Licenses</a>
</div>
</div>
</div>
<div style="max-width:1200px;margin:0 auto;padding:16px 24px 32px;border-top:1px solid var(--border);color:var(--dim);font-size:13px;text-align:center">
&copy; 2026 ScreenTinker. All rights reserved.
</div>
</div>
<!-- Enterprise contact form modal. Opened by the Enterprise / Custom card's
Contact Us button. Submits to POST /api/contact/enterprise which sends
the inquiry via Microsoft Graph to dan@bytetinker.net. -->
<div class="modal-overlay" id="contactModal" style="display:none">
<div class="modal" role="dialog" aria-labelledby="contactModalTitle" aria-modal="true">
<div class="modal-header">
<h3 id="contactModalTitle">Enterprise Inquiry</h3>
<button type="button" class="modal-close" onclick="closeContactModal()" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<p class="modal-description">Tell us about your deployment and we'll get back to you within one business day.</p>
<form id="contactForm" novalidate>
<label>Name *<input name="name" type="text" required autocomplete="name" maxlength="200"></label>
<label>Email *<input name="email" type="email" required autocomplete="email" maxlength="200"></label>
<label>Company / organization *<input name="company" type="text" required autocomplete="organization" maxlength="200"></label>
<label>Estimated number of screens *<input name="screens" type="number" min="1" max="100000" required></label>
<label>Multi-tenant? *
<select name="multi_tenant" required>
<option value="">Select one</option>
<option value="single">Single organization</option>
<option value="multi">Multiple organizations / managing screens for multiple clients</option>
</select>
</label>
<label>Hosting preference *
<select name="hosting" required>
<option value="">Select one</option>
<option value="hosted">Hosted for me</option>
<option value="self">Self-host</option>
<option value="unsure">Not sure yet</option>
</select>
</label>
<label>Message<textarea name="message" rows="4" maxlength="5000"></textarea></label>
<!-- Honeypot: hidden from real users (off-screen + aria-hidden +
tabindex=-1), bots fill anything they see. If this comes back
populated, the server returns success but drops the submission.
Field name 'fax_number' is plausible enough to fool mid-tier
bots that explicitly skip the obvious 'website' name. -->
<div style="position:absolute;left:-9999px" aria-hidden="true">
<label>Fax number<input name="fax_number" type="text" tabindex="-1" autocomplete="off"></label>
</div>
<div id="contactFormStatus" style="min-height:20px;margin-top:8px"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" onclick="closeContactModal()">Cancel</button>
<button type="button" class="btn btn-primary" id="contactSubmitBtn" onclick="submitContactForm()">Send</button>
</div>
</div>
</div> </div>
</footer>
<!-- Structured Data for Google --> <!-- Structured Data for Google -->
<script type="application/ld+json"> <script type="application/ld+json">
@ -254,9 +451,11 @@
"@type": "SoftwareApplication", "@type": "SoftwareApplication",
"name": "ScreenTinker", "name": "ScreenTinker",
"applicationCategory": "BusinessApplication", "applicationCategory": "BusinessApplication",
"applicationSubCategory": "DigitalSignage",
"operatingSystem": "Android, Web, Windows, Linux, ChromeOS", "operatingSystem": "Android, Web, Windows, Linux, ChromeOS",
"description": "Digital signage management software with remote control, video walls, multi-zone layouts, scheduling, kiosk mode, and analytics. Works on 9 platforms.", "description": "Self-hosted, open-source digital signage CMS. MIT licensed. Manage TVs, video walls, kiosks, and content schedules across Android, Raspberry Pi, Windows, ChromeOS, and any browser.",
"url": "https://screentinker.com", "url": "https://screentinker.com",
"license": "https://opensource.org/license/mit",
"offers": [ "offers": [
{ {
"@type": "Offer", "@type": "Offer",
@ -291,11 +490,15 @@
"Multi-zone screen layouts", "Multi-zone screen layouts",
"Video wall support", "Video wall support",
"Remote control with live view", "Remote control with live view",
"Content scheduling with calendar", "Content scheduling with calendar (device and group level)",
"Playlists with draft/publish workflow",
"Directory Board widget for lobby and staff directories",
"Built-in content designer", "Built-in content designer",
"Interactive kiosk/touchscreen mode", "Interactive kiosk/touchscreen mode",
"Proof-of-play analytics", "Proof-of-play analytics",
"Device monitoring and alerts", "Device monitoring and alerts",
"Offline resilience with cached playback",
"Mobile-responsive dashboard",
"White-label/reseller support", "White-label/reseller support",
"Self-hosted option", "Self-hosted option",
"9 platform support", "9 platform support",
@ -311,7 +514,10 @@
"name": "ScreenTinker", "name": "ScreenTinker",
"url": "https://screentinker.com", "url": "https://screentinker.com",
"logo": "https://screentinker.com/assets/icon-512.png", "logo": "https://screentinker.com/assets/icon-512.png",
"sameAs": [] "sameAs": [
"https://github.com/screentinker/screentinker",
"https://discord.gg/utTdsrqq4Z"
]
} }
</script> </script>
@ -357,10 +563,18 @@
</script> </script>
<script> <script>
// Load pricing from API // Load pricing from API. The 'enterprise' plan is filtered out of the
// public render because its DB row has price=0 / max=-1 (renders as
// "Free" + "Unlimited") which would undercut the actual Free tier and
// confuse visitors. The row itself stays in the DB - it's actively used
// for self-hosted first-user assignment and white-label gating - we just
// replace it on the public marketing page with a hardcoded Contact Us
// card. Other consumers of /api/subscription/plans (billing.js,
// settings.js, admin.js) get the full list as before.
fetch('/api/subscription/plans').then(r => r.json()).then(plans => { fetch('/api/subscription/plans').then(r => r.json()).then(plans => {
const grid = document.getElementById('pricingGrid'); const grid = document.getElementById('pricingGrid');
grid.innerHTML = plans.filter(p => p.active).map((p, i) => ` const publicPlans = plans.filter(p => p.active && p.name !== 'enterprise');
const planCardsHtml = publicPlans.map((p, i) => `
<div class="price-card ${i === 2 ? 'featured' : ''}"> <div class="price-card ${i === 2 ? 'featured' : ''}">
<h3>${p.display_name}</h3> <h3>${p.display_name}</h3>
<div class="price">${p.price_monthly > 0 ? '$' + p.price_monthly : 'Free'}<span>${p.price_monthly > 0 ? '/mo' : ''}</span></div> <div class="price">${p.price_monthly > 0 ? '$' + p.price_monthly : 'Free'}<span>${p.price_monthly > 0 ? '/mo' : ''}</span></div>
@ -375,6 +589,25 @@
<a href="/app#/login" class="btn ${i === 0 ? 'btn-outline' : 'btn-primary'}" style="width:100%;justify-content:center">${p.price_monthly > 0 ? 'Start Trial' : 'Get Started'}</a> <a href="/app#/login" class="btn ${i === 0 ? 'btn-outline' : 'btn-primary'}" style="width:100%;justify-content:center">${p.price_monthly > 0 ? 'Start Trial' : 'Get Started'}</a>
</div> </div>
`).join(''); `).join('');
// Hardcoded Enterprise / Custom card. Uses .price class for vertical
// alignment with the other cards (same baseline for the feature list);
// empty .yearly spacer matches the Free card's structure.
const enterpriseCardHtml = `
<div class="price-card">
<h3>Enterprise / Custom</h3>
<div class="price">Let's talk</div>
<div class="yearly">&nbsp;</div>
<ul>
<li>Everything in Business</li>
<li>Multi-tenant / multiple organizations</li>
<li>Volume device pricing</li>
<li>Custom hosting options</li>
<li>Priority support</li>
</ul>
<button type="button" class="btn btn-primary" style="width:100%;justify-content:center" onclick="openContactModal()">Contact Us</button>
</div>
`;
grid.innerHTML = planCardsHtml + enterpriseCardHtml;
}); });
// Smooth scroll for anchor links // Smooth scroll for anchor links
@ -385,6 +618,73 @@
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); } if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); }
}); });
}); });
// Contact modal: open/close, submit, validation. Functions are attached
// to window so the inline onclick handlers on the Contact Us button (in
// the dynamically-rendered pricing card) can find them.
window.openContactModal = function() {
document.getElementById('contactModal').style.display = 'flex';
setTimeout(() => document.querySelector('#contactForm input[name="name"]')?.focus(), 50);
};
window.closeContactModal = function() {
document.getElementById('contactModal').style.display = 'none';
const status = document.getElementById('contactFormStatus');
status.className = '';
status.textContent = '';
};
window.submitContactForm = async function() {
const form = document.getElementById('contactForm');
const status = document.getElementById('contactFormStatus');
const btn = document.getElementById('contactSubmitBtn');
status.className = '';
status.textContent = '';
const data = Object.fromEntries(new FormData(form).entries());
const required = ['name', 'email', 'company', 'screens', 'multi_tenant', 'hosting'];
for (const f of required) {
if (!data[f] || String(data[f]).trim() === '') {
status.className = 'contact-status-error';
status.textContent = 'Please fill all required fields.';
return;
}
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
status.className = 'contact-status-error';
status.textContent = 'Please enter a valid email address.';
return;
}
btn.disabled = true;
try {
const res = await fetch('/api/contact/enterprise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.ok) {
status.className = 'contact-status-success';
status.textContent = "Thanks! We'll be in touch within one business day.";
form.reset();
setTimeout(() => window.closeContactModal(), 3000);
} else {
const err = await res.json().catch(() => ({}));
status.className = 'contact-status-error';
status.textContent = err.error || "Sorry, something went wrong. Try emailing dan@bytetinker.net directly.";
}
} catch (e) {
status.className = 'contact-status-error';
status.textContent = "Network error. Try emailing dan@bytetinker.net directly.";
} finally {
btn.disabled = false;
}
};
// Close on background click + Escape
document.getElementById('contactModal').addEventListener('click', e => {
if (e.target.id === 'contactModal') window.closeContactModal();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('contactModal').style.display === 'flex') {
window.closeContactModal();
}
});
</script> </script>
</body> </body>
</html> </html>

View file

@ -22,7 +22,7 @@
<div class="container"> <div class="container">
<a href="/" class="back">&larr; Back to ScreenTinker</a> <a href="/" class="back">&larr; Back to ScreenTinker</a>
<h1>Terms of Service</h1> <h1>Terms of Service</h1>
<p class="updated">Last updated: March 24, 2026</p> <p class="updated">Last updated: April 24, 2026</p>
<h2>1. Acceptance of Terms</h2> <h2>1. Acceptance of Terms</h2>
<p>By accessing or using ScreenTinker ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p> <p>By accessing or using ScreenTinker ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p>
@ -55,6 +55,10 @@
<li>Overload the Service infrastructure through automated or abusive means</li> <li>Overload the Service infrastructure through automated or abusive means</li>
</ul> </ul>
<h2>5.1 Prohibited Content - Zero Tolerance</h2>
<p>ScreenTinker maintains zero tolerance for child sexual abuse material (CSAM). Any account found to upload, display, transmit, or facilitate the distribution of such material will be immediately terminated and reported to the National Center for Missing and Exploited Children (NCMEC) and appropriate law enforcement agencies, as required under 18 U.S.C. 2258A.</p>
<p>All account data, upload records, IP logs, and device telemetry associated with such violations will be preserved and provided to law enforcement pursuant to lawful requests. This policy applies to all content uploaded, displayed, or transmitted through the Service, including content on connected devices, in the web player, and through the API.</p>
<h2>6. Content</h2> <h2>6. Content</h2>
<ul> <ul>
<li>You retain ownership of content you upload to the Service.</li> <li>You retain ownership of content you upload to the Service.</li>
@ -93,19 +97,17 @@
<p>ScreenTinker incorporates open-source software components licensed under MIT and Apache 2.0 licenses. A complete list of third-party software and their licenses is available at our <a href="/legal/third-party.html">Third-Party Software Notices</a> page.</p> <p>ScreenTinker incorporates open-source software components licensed under MIT and Apache 2.0 licenses. A complete list of third-party software and their licenses is available at our <a href="/legal/third-party.html">Third-Party Software Notices</a> page.</p>
<h2>12. Restrictions</h2> <h2>12. Restrictions</h2>
<p>You agree not to, and will not permit others to:</p> <p>The core ScreenTinker software is open-source and available at <a href="https://github.com/screentinker/screentinker">github.com/screentinker/screentinker</a> under the MIT License. The restrictions in this section apply only to the hosted Service at screentinker.com and to proprietary components that are not published under the MIT License, including the license key generation and validation system, billing infrastructure, and internal deployment tooling.</p>
<p>In connection with your use of the hosted Service and those proprietary components, you agree not to, and will not permit others to:</p>
<ul> <ul>
<li>Reverse engineer, decompile, disassemble, or otherwise attempt to derive the source code of the Software</li> <li>Circumvent, bypass, disable, tamper with, or forge any license keys, authentication, or usage-tracking mechanisms</li>
<li>Modify, adapt, translate, or create derivative works based on the Software</li> <li>Share, transfer, or assign your account credentials, license keys, or API tokens to unauthorized parties</li>
<li>Remove, alter, or obscure any proprietary notices, labels, or marks on the Software</li> <li>Overload, attack, or otherwise abuse the hosted platform or its infrastructure</li>
<li>Copy, distribute, or sublicense the Software except as expressly permitted by your subscription plan</li> <li>Scrape, crawl, or programmatically extract data from the hosted Service beyond the provided API and documented rate limits</li>
<li>Use the Software to build a competing product or service</li> <li>Operate the hosted Service beyond the scope of your current subscription plan</li>
<li>Circumvent or disable any licensing, authentication, or usage-tracking mechanisms in the Software</li> <li>Reverse engineer, decompile, or attempt to derive the source code of proprietary components not published under the MIT License</li>
<li>Share, transfer, or assign your license key or account credentials to unauthorized parties</li>
<li>Operate the Software beyond the scope of your current subscription plan or license agreement</li>
<li>Scrape, crawl, or programmatically extract data from the Software beyond the provided API</li>
</ul> </ul>
<p>Violation of these restrictions may result in immediate termination of your account and may subject you to legal action.</p> <p>Violation of these restrictions may result in immediate termination of your account and may subject you to legal action. Nothing in this section limits, modifies, or overrides any rights granted to you by the MIT License covering the open-source portion of ScreenTinker, which governs that code exclusively.</p>
<h2>13. License Keys and Self-Hosted Deployments</h2> <h2>13. License Keys and Self-Hosted Deployments</h2>
<ul> <ul>
@ -131,7 +133,12 @@
<h2>16. Changes to Terms</h2> <h2>16. Changes to Terms</h2>
<p>We may update these terms from time to time. We will notify registered users of material changes via email. Continued use of the Service after changes constitutes acceptance.</p> <p>We may update these terms from time to time. We will notify registered users of material changes via email. Continued use of the Service after changes constitutes acceptance.</p>
<h2>17. Contact</h2> <h2>17. Governing Law and Jurisdiction</h2>
<p>These Terms of Service are governed by and construed in accordance with the laws of the State of Wisconsin, United States, without regard to its conflict of law provisions. Any dispute arising out of or relating to these terms or the Service shall be resolved exclusively in the state or federal courts located in Kenosha County, Wisconsin, and you consent to the personal jurisdiction of those courts.</p>
<p>Users accessing the Service from outside the United States acknowledge that the Service is operated from and governed by the laws of the United States and the State of Wisconsin.</p>
<p>If any provision of these terms is held invalid or unenforceable by a court of competent jurisdiction, that provision shall be enforced to the maximum extent permissible and the remaining provisions shall remain in full force and effect.</p>
<h2>18. Contact</h2>
<p>For questions about these terms, contact us at support@screentinker.com</p> <p>For questions about these terms, contact us at support@screentinker.com</p>
</div> </div>
</body> </body>

View file

@ -1,10 +1,12 @@
User-agent: * User-agent: *
Allow: / Allow: /
Allow: /legal/ Allow: /legal/
Allow: /player/ Allow: /compare/
Allow: /guides/
Disallow: /api/ Disallow: /api/
Disallow: /app Disallow: /app
Disallow: /player
Disallow: /uploads/ Disallow: /uploads/
Disallow: /scripts/ Disallow: /scripts/

View file

@ -5,6 +5,36 @@
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>
<url>
<loc>https://screentinker.com/compare/yodeck-alternative.html</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://screentinker.com/compare/screencloud-alternative.html</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://screentinker.com/compare/optisigns-alternative.html</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://screentinker.com/guides/raspberry-pi-digital-signage.html</loc>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://screentinker.com/guides/digital-signage-android-tv.html</loc>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://screentinker.com/guides/self-hosted-digital-signage.html</loc>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url> <url>
<loc>https://screentinker.com/legal/terms.html</loc> <loc>https://screentinker.com/legal/terms.html</loc>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
@ -15,4 +45,9 @@
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.3</priority> <priority>0.3</priority>
</url> </url>
<url>
<loc>https://screentinker.com/legal/third-party.html</loc>
<changefreq>monthly</changefreq>
<priority>0.2</priority>
</url>
</urlset> </urlset>

View file

@ -1,4 +1,8 @@
const CACHE = 'rd-admin-v1'; // Service worker for the admin SPA. Bumped to v2 to invalidate the cache-first
// caches that were shipping stale JS to existing clients (the server already
// sends Cache-Control: no-cache + ETag, but the previous SW intercepted before
// any of that mattered). Strategy is now network-first with offline fallback.
const CACHE = 'rd-admin-v2';
self.addEventListener('install', e => { self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll([ e.waitUntil(caches.open(CACHE).then(c => c.addAll([
@ -15,7 +19,18 @@ self.addEventListener('activate', e => {
}); });
self.addEventListener('fetch', e => { self.addEventListener('fetch', e => {
// Network first for API, cache first for static // Don't intercept API or socket.io traffic - those need to hit the network unmediated.
if (e.request.url.includes('/api/') || e.request.url.includes('/socket.io/')) return; if (e.request.url.includes('/api/') || e.request.url.includes('/socket.io/')) return;
e.respondWith(caches.match(e.request).then(r => r || fetch(e.request))); // Network-first: respect the server's Cache-Control: no-cache + ETag (304s
// stay fast); fall back to cache only when offline. Re-populate the cache
// on every successful fetch so the offline fallback stays current.
e.respondWith(
fetch(e.request)
.then(resp => {
const copy = resp.clone();
caches.open(CACHE).then(c => c.put(e.request, copy)).catch(() => {});
return resp;
})
.catch(() => caches.match(e.request))
);
}); });

View file

@ -0,0 +1,318 @@
#!/usr/bin/env node
// Phase 1 multitenancy migration runner.
//
// Adds: organizations, organization_members, workspaces, workspace_members,
// workspace_invites tables.
// Adds: workspace_id columns to every resource table; organization_id,
// acting_user_id, was_acting_as to activity_log; reseller billing
// metadata columns to workspaces (added at table create time).
// Backfills: one organization per existing user, one default workspace per
// org (or one workspace per existing team), all resource rows
// get the user's default workspace_id, activity_log gets both
// workspace_id and organization_id. Roles migrate: superadmin
// -> platform_admin, legacy 'admin' -> 'user'.
// Idempotent: tracked by schema_migrations row 'phase5_multitenancy_backfill'.
// Re-running is a no-op.
//
// Two invocation modes:
// 1. CLI: node scripts/migrate-multitenancy.js [--dry-run]
// 2. In-process: require('./scripts/migrate-multitenancy').runMigration({ db })
//
// In-process mode is used by server/db/database.js on startup so self-hosters
// who pull latest and restart don't have to remember to run the script
// manually.
'use strict';
const path = require('path');
const SERVER_DIR = path.resolve(__dirname, '..', 'server');
// Resolve modules relative to server/ where the deps live, not relative to
// this script's dir. Works both for CLI invocation and when require'd from
// database.js - Node resolves modules relative to the required file's own
// __dirname, not the caller's.
const resolveFromServer = (name) => require.resolve(name, { paths: [SERVER_DIR] });
const Database = require(resolveFromServer('better-sqlite3'));
const { v4: uuidv4 } = require(resolveFromServer('uuid'));
const config = require(path.join(SERVER_DIR, 'config'));
const MIGRATION_ID = 'phase5_multitenancy_backfill';
function alreadyApplied(db) {
try {
return !!db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(MIGRATION_ID);
} catch { return false; }
}
function runMigration({ db: existingDb = null, dryRun = false, logger = console } = {}) {
const db = existingDb || (() => {
const d = new Database(config.dbPath);
d.pragma('journal_mode = WAL');
d.pragma('foreign_keys = ON');
return d;
})();
const ownDb = !existingDb;
try {
if (alreadyApplied(db)) {
logger.log('[migrate] already applied - nothing to do');
return { skipped: true };
}
logger.log(`[migrate] mode=${dryRun ? 'DRY RUN' : 'COMMIT'}`);
logger.log(`[migrate] db=${config.dbPath}`);
// 1. New tables (idempotent).
db.exec(`
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE,
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,
grace_period_ends INTEGER,
locked_at INTEGER,
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',
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,
created_by TEXT REFERENCES users(id),
billing_type TEXT DEFAULT 'client_billable',
billing_notes TEXT,
billing_contact_email TEXT,
billing_contract_ref TEXT,
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',
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'))
);
`);
// 2. Additive columns (idempotent: ignore 'duplicate column' errors).
const alters = [
'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',
];
for (const sql of alters) {
try { db.exec(sql); } catch (e) { /* column exists */ }
}
// 3. Indexes.
db.exec(`
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);
`);
// 4. Backfill (single transaction).
const users = db.prepare('SELECT * FROM users').all();
const teams = db.prepare('SELECT * FROM teams').all();
const teamMembers = db.prepare('SELECT * FROM team_members').all();
const userDefaultWs = new Map(); // user_id -> workspace_id
const userToOrg = new Map(); // user_id -> organization_id
const RESOURCE_TABLES_WITH_TEAM_ID = ['devices', 'content', 'layouts', 'widgets', 'video_walls'];
const RESOURCE_TABLES_NO_TEAM_ID = ['playlists', 'schedules', 'device_groups', 'white_labels', 'kiosk_pages', 'alert_configs'];
function table_has_col(t, c) {
return db.prepare(`PRAGMA table_info(${t})`).all().some(x => x.name === c);
}
const stats = { orgs: 0, workspaces: 0, org_members: 0, ws_members: 0, role_changes: { sa: 0, adm: 0 }, backfill: {} };
const backfill = db.transaction(() => {
for (const u of users) {
const orgId = uuidv4();
const orgName = (u.name && u.name.trim()) ? `${u.name}'s organization` : `${u.email}'s organization`;
db.prepare(`INSERT INTO organizations (
id, name, owner_user_id, plan_id,
stripe_customer_id, stripe_subscription_id,
subscription_status, subscription_ends
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
orgId, orgName, u.id, u.plan_id || 'free',
u.stripe_customer_id || null, u.stripe_subscription_id || null,
u.subscription_status || 'active', u.subscription_ends || null
);
stats.orgs++;
db.prepare(`INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_owner')`).run(orgId, u.id);
stats.org_members++;
userToOrg.set(u.id, orgId);
const ownedTeams = teams.filter(t => t.owner_id === u.id);
let defaultWsId;
if (ownedTeams.length === 0) {
defaultWsId = uuidv4();
db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, 'Default', ?)`).run(defaultWsId, orgId, u.id);
stats.workspaces++;
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(defaultWsId, u.id);
stats.ws_members++;
} else {
// Re-use each team's id as the workspace id so existing FKs and bookmarks survive.
for (const t of ownedTeams) {
db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, ?, ?)`).run(t.id, orgId, t.name, t.owner_id);
stats.workspaces++;
const tms = teamMembers.filter(m => m.team_id === t.id);
let ownerSeen = false;
for (const m of tms) {
if (m.user_id === t.owner_id) ownerSeen = true;
const wsRole =
m.role === 'owner' ? 'workspace_admin' :
m.role === 'editor' ? 'workspace_editor' :
'workspace_viewer';
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role, invited_by, joined_at) VALUES (?, ?, ?, ?, ?)`)
.run(t.id, m.user_id, wsRole, m.invited_by || null, m.joined_at || Math.floor(Date.now() / 1000));
stats.ws_members++;
}
if (!ownerSeen) {
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(t.id, t.owner_id);
stats.ws_members++;
}
}
defaultWsId = ownedTeams
.slice()
.sort((a, b) => (a.created_at || 0) - (b.created_at || 0))[0].id;
}
userDefaultWs.set(u.id, defaultWsId);
}
for (const t of RESOURCE_TABLES_WITH_TEAM_ID) {
if (!table_has_col(t, 'workspace_id')) { stats.backfill[t] = 'skipped (no workspace_id col)'; continue; }
const rows = db.prepare(`SELECT id, user_id, team_id FROM ${t} WHERE workspace_id IS NULL`).all();
const upd = db.prepare(`UPDATE ${t} SET workspace_id = ? WHERE id = ?`);
let filled = 0;
for (const r of rows) {
const wsId = r.team_id || userDefaultWs.get(r.user_id);
if (wsId) { upd.run(wsId, r.id); filled++; }
}
stats.backfill[t] = `${filled}/${rows.length}`;
}
for (const t of RESOURCE_TABLES_NO_TEAM_ID) {
if (!table_has_col(t, 'workspace_id')) { stats.backfill[t] = 'skipped (no workspace_id col)'; continue; }
const rows = db.prepare(`SELECT id, user_id FROM ${t} WHERE workspace_id IS NULL`).all();
const upd = db.prepare(`UPDATE ${t} SET workspace_id = ? WHERE id = ?`);
let filled = 0;
for (const r of rows) {
const wsId = userDefaultWs.get(r.user_id);
if (wsId) { upd.run(wsId, r.id); filled++; }
}
stats.backfill[t] = `${filled}/${rows.length}`;
}
const aRows = db.prepare(`SELECT id, user_id FROM activity_log WHERE workspace_id IS NULL OR organization_id IS NULL`).all();
const aUpd = db.prepare(`UPDATE activity_log SET workspace_id = ?, organization_id = ? WHERE id = ?`);
let aFilled = 0;
for (const r of aRows) {
const wsId = r.user_id ? (userDefaultWs.get(r.user_id) || null) : null;
const orgId = r.user_id ? (userToOrg.get(r.user_id) || null) : null;
aUpd.run(wsId, orgId, r.id);
if (wsId || orgId) aFilled++;
}
stats.backfill.activity_log = `${aFilled}/${aRows.length} (NULLs are anonymous platform events)`;
// Role migration.
stats.role_changes.sa = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'superadmin'`).get().n;
stats.role_changes.adm = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`).get().n;
db.prepare(`UPDATE users SET role = 'platform_admin' WHERE role = 'superadmin'`).run();
db.prepare(`UPDATE users SET role = 'user' WHERE role = 'admin'`).run();
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(MIGRATION_ID);
if (dryRun) {
// Force rollback by throwing - better-sqlite3 db.transaction() reverts everything.
throw new Error('__DRY_RUN_ROLLBACK__');
}
});
try {
backfill();
logger.log('[migrate] committed');
return { ok: true, stats };
} catch (e) {
if (e.message === '__DRY_RUN_ROLLBACK__') {
logger.log('[migrate] rolled back (dry run)');
return { ok: true, dryRun: true, stats };
}
throw e;
}
} finally {
if (ownDb) db.close();
}
}
// CLI wrapper - only fires when invoked as 'node scripts/migrate-multitenancy.js'.
// When required from server/db/database.js the CLI block is skipped.
if (require.main === module) {
process.chdir(SERVER_DIR);
const dryRun = process.argv.includes('--dry-run');
try {
const result = runMigration({ dryRun });
if (result.stats) {
console.log('---summary---');
console.log(JSON.stringify(result.stats, null, 2));
}
process.exit(0);
} catch (e) {
console.error('[migrate] FAILED:', e.message);
process.exit(1);
}
}
module.exports = { runMigration };

View file

@ -0,0 +1,100 @@
#!/usr/bin/env node
// Phase 1 multitenancy parity check.
//
// Compares per-user resource counts between the pre-migration snapshot and
// the current DB. Every row must end up in exactly one workspace owned by
// the original user. Drift = bug.
//
// Usage:
// node scripts/parity-multitenancy.js
//
// Exits non-zero on any drift.
'use strict';
const path = require('path');
const SERVER_DIR = path.resolve(__dirname, '..', 'server');
process.chdir(SERVER_DIR);
const Database = require(require.resolve('better-sqlite3', { paths: [SERVER_DIR] }));
const config = require(path.join(SERVER_DIR, 'config'));
const PRE = path.resolve(SERVER_DIR, 'db', 'remote_display.pre-multitenancy.db');
const POST = config.dbPath;
console.log(`[parity] pre = ${PRE}`);
console.log(`[parity] post = ${POST}`);
const pre = new Database(PRE, { readonly: true });
const post = new Database(POST, { readonly: true });
const TABLES = ['devices','content','playlists','layouts','widgets','schedules','video_walls','device_groups','white_labels','kiosk_pages','alert_configs'];
const users = pre.prepare('SELECT id, email FROM users').all();
let pass = 0, fail = 0;
console.log('\n--- per-user, per-table row counts ---');
for (const u of users) {
for (const t of TABLES) {
const preN = pre.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE user_id = ?`).get(u.id).n;
// Post: rows belonging to any workspace owned by an org owned by this user.
let postN;
try {
postN = post.prepare(`
SELECT COUNT(*) AS n FROM ${t} r
WHERE r.workspace_id IN (
SELECT w.id FROM workspaces w
JOIN organizations o ON w.organization_id = o.id
WHERE o.owner_user_id = ?
)
`).get(u.id).n;
} catch (e) {
postN = '<no workspace_id col>';
}
const ok = preN === postN;
if (preN === 0 && postN === 0) continue;
console.log(` ${u.email.padEnd(30)} ${t.padEnd(16)} pre=${String(preN).padStart(4)} post=${String(postN).padStart(4)} ${ok ? 'PASS' : 'FAIL'}`);
if (ok) pass++; else fail++;
}
}
console.log('\n--- platform-wide totals ---');
const totalsPre = {};
const totalsPost = {};
for (const t of TABLES) {
totalsPre[t] = pre .prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n;
totalsPost[t] = post.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n;
const ok = totalsPre[t] === totalsPost[t];
console.log(` ${t.padEnd(16)} pre=${String(totalsPre[t]).padStart(4)} post=${String(totalsPost[t]).padStart(4)} ${ok ? 'PASS' : 'FAIL'}`);
if (ok) pass++; else fail++;
}
console.log('\n--- new tables populated ---');
const newTables = ['organizations', 'organization_members', 'workspaces', 'workspace_members'];
for (const t of newTables) {
const n = post.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n;
console.log(` ${t.padEnd(24)} rows=${n}`);
}
console.log('\n--- orphan check: rows with NON-NULL user_id but NULL workspace_id ---');
console.log('(rows with NULL user_id are unclaimed devices or platform templates - expected NULL workspace_id)');
for (const t of TABLES) {
try {
const realOrphans = post.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE user_id IS NOT NULL AND workspace_id IS NULL`).get().n;
const expectedNulls = post.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE user_id IS NULL`).get().n;
const tag = realOrphans > 0 ? 'FAIL' : 'PASS';
console.log(` ${t.padEnd(16)} bug_orphans=${realOrphans} expected_nulls(user_id IS NULL)=${expectedNulls} ${tag}`);
if (realOrphans > 0) fail++; else pass++;
} catch { /* no column */ }
}
console.log('\n--- role migration ---');
const sa = post.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'superadmin'`).get().n;
const pa = post.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'platform_admin'`).get().n;
const adm = post.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`).get().n;
console.log(` superadmin (should be 0) : ${sa}`);
console.log(` platform_admin (should be > 0 if any pre had superadmin): ${pa}`);
console.log(` admin (should be 0) : ${adm}`);
if (sa === 0 && adm === 0) pass++; else fail++;
console.log(`\n--- summary: ${pass} pass, ${fail} fail ---`);
pre.close(); post.close();
process.exit(fail === 0 ? 0 : 1);

View file

@ -1,106 +1,644 @@
#!/bin/bash #!/bin/bash
# ScreenTinker - Raspberry Pi Setup Script # ScreenTinker - Raspberry Pi Setup Script
# Run: curl -sSL https://screentinker.com/scripts/pi-setup.sh | bash
# #
# This sets up a Raspberry Pi as a digital signage player: # All-in-One: runs the ScreenTinker server AND kiosk player on one Pi
# 1. Installs Chromium if needed # Player-Only: connects to an existing ScreenTinker server
# 2. Creates a systemd service for kiosk mode #
# 3. Auto-starts on boot # Usage:
# All-in-One: curl -sSL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash
# Player-Only: curl -sSL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash -s -- --player-only https://screentinker.com
#
# Or clone and run:
# git clone https://github.com/screentinker/screentinker.git
# cd screentinker/scripts && sudo ./raspberry-pi-setup.sh
#
# Works on Raspberry Pi OS Lite or Desktop (Bookworm / Bullseye)
# Tested on Pi 3B+, Pi 4, Pi 5
SERVER_URL="${1:-https://screentinker.com}" set -euo pipefail
PLAYER_URL="$SERVER_URL/player"
echo "==================================" # -- Configuration --
echo " ScreenTinker Pi Player Setup" SCREENTINKER_DIR="/opt/screentinker"
echo "==================================" SCREENTINKER_PORT=3001
echo "Server: $SERVER_URL" NODE_MAJOR=20
LOG_FILE="/var/log/screentinker-setup.log"
# -- Colors --
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log() { echo -e "${GREEN}[ScreenTinker]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
err() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
# -- Parse arguments --
PLAYER_ONLY=false
SERVER_URL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--player-only) PLAYER_ONLY=true; shift ;;
--help|-h)
echo "Usage: sudo ./raspberry-pi-setup.sh [OPTIONS] [SERVER_URL]"
echo "" echo ""
echo "Options:"
echo " --player-only URL Player-only mode (no local server)"
echo " --help Show this help"
echo ""
echo "Examples:"
echo " sudo ./raspberry-pi-setup.sh # All-in-One (interactive)"
echo " sudo ./raspberry-pi-setup.sh --player-only https://screentinker.com"
exit 0
;;
http*) SERVER_URL="$1"; shift ;;
*) shift ;;
esac
done
# Install chromium if not present # -- Root check --
if ! command -v chromium-browser &> /dev/null && ! command -v chromium &> /dev/null; then if [ "$(id -u)" -ne 0 ]; then
echo "Installing Chromium..." err "This script must be run as root. Try: sudo bash raspberry-pi-setup.sh"
sudo apt-get update && sudo apt-get install -y chromium-browser unclutter
fi fi
CHROMIUM=$(command -v chromium-browser || command -v chromium) # -- Architecture check --
ARCH=$(uname -m)
# Disable screen blanking if [[ "$ARCH" != "aarch64" && "$ARCH" != "armv7l" ]]; then
if [ -f /etc/lightdm/lightdm.conf ]; then warn "Detected architecture: $ARCH (expected aarch64 or armv7l for Raspberry Pi)"
sudo sed -i 's/#xserver-command=X/xserver-command=X -s 0 -dpms/' /etc/lightdm/lightdm.conf read -p "Continue anyway? (y/N) " -n 1 -r; echo
[[ ! $REPLY =~ ^[Yy]$ ]] && exit 1
fi fi
# Create autostart directory # -- Interactive mode selection (if no flags passed) --
mkdir -p ~/.config/autostart if [ "$PLAYER_ONLY" = false ] && [ -z "$SERVER_URL" ]; then
echo ""
echo -e "${BLUE}======================================${NC}"
echo -e "${BLUE} ScreenTinker Raspberry Pi Setup${NC}"
echo -e "${BLUE}======================================${NC}"
echo ""
echo " 1) All-in-One (recommended)"
echo " Runs the server AND player on this Pi."
echo " Manage everything from your phone."
echo ""
echo " 2) Player Only"
echo " Connects to an existing ScreenTinker server."
echo " This Pi just displays content."
echo ""
read -p "Choose [1/2]: " MODE_CHOICE
case "$MODE_CHOICE" in
2)
PLAYER_ONLY=true
read -p "Server URL (e.g., https://screentinker.com): " SERVER_URL
;;
*) ;;
esac
fi
# Create kiosk script # Strip trailing slash from server URL
cat > ~/remotedisplay-kiosk.sh << EOF SERVER_URL="${SERVER_URL%/}"
#!/bin/bash
# Wait for network
sleep 5
# Disable screen saver and power management # Set kiosk URL
xset s off if [ "$PLAYER_ONLY" = true ]; then
xset -dpms [ -z "$SERVER_URL" ] && err "Player-only mode requires a server URL"
xset s noblank KIOSK_URL="${SERVER_URL}/player"
log "Player-only mode: $SERVER_URL"
else
KIOSK_URL="http://localhost:${SCREENTINKER_PORT}/player"
log "All-in-One mode: server + player"
fi
# Hide cursor echo ""
unclutter -idle 0.1 -root & log "Setup log: $LOG_FILE"
exec > >(tee -a "$LOG_FILE") 2>&1
# Launch Chromium in kiosk mode # -- Detect Pi OS variant --
$CHROMIUM \\ HAS_DESKTOP=false
--noerrandprompts \\ if dpkg -l xserver-xorg 2>/dev/null | grep -q "^ii"; then
--disable-infobars \\ HAS_DESKTOP=true
--disable-session-crashed-bubble \\ log "Detected: Pi OS with Desktop"
--kiosk \\ else
--incognito \\ log "Detected: Pi OS Lite (headless)"
--autoplay-policy=no-user-gesture-required \\ fi
--disable-features=TranslateUI \\
--check-for-update-interval=31536000 \\
--disable-component-update \\
"$PLAYER_URL"
EOF
chmod +x ~/remotedisplay-kiosk.sh
# Create systemd service # ============================================================
sudo tee /etc/systemd/system/remotedisplay.service > /dev/null << EOF # 1. System packages
# ============================================================
log "Updating system packages..."
apt-get update -qq
apt-get upgrade -y -qq
log "Installing base dependencies..."
apt-get install -y -qq \
git curl wget unzip htop \
avahi-daemon \
fonts-liberation fonts-noto-color-emoji \
>> "$LOG_FILE" 2>&1
# ============================================================
# 2. Node.js (all-in-one only)
# ============================================================
if [ "$PLAYER_ONLY" = false ]; then
NEED_NODE=true
if command -v node &>/dev/null; then
CUR=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$CUR" -ge "$NODE_MAJOR" ]; then
log "Node.js $(node -v) already installed"
NEED_NODE=false
fi
fi
if [ "$NEED_NODE" = true ]; then
log "Installing Node.js ${NODE_MAJOR}.x..."
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - >> "$LOG_FILE" 2>&1
apt-get install -y -qq nodejs >> "$LOG_FILE" 2>&1
log "Node.js $(node -v) installed"
fi
fi
# ============================================================
# 3. Clone / update ScreenTinker (all-in-one only)
# ============================================================
if [ "$PLAYER_ONLY" = false ]; then
if [ -d "$SCREENTINKER_DIR/.git" ]; then
log "Repo exists at $SCREENTINKER_DIR, pulling latest..."
cd "$SCREENTINKER_DIR" && git pull origin main >> "$LOG_FILE" 2>&1
else
log "Cloning ScreenTinker..."
git clone https://github.com/screentinker/screentinker.git "$SCREENTINKER_DIR" >> "$LOG_FILE" 2>&1
fi
log "Installing Node.js dependencies..."
cd "$SCREENTINKER_DIR/server"
npm install --production >> "$LOG_FILE" 2>&1
# Data directories
mkdir -p "$SCREENTINKER_DIR/server/db"
mkdir -p "$SCREENTINKER_DIR/server/uploads"
fi
# Determine the runtime user
PI_USER="${SUDO_USER:-pi}"
PI_HOME=$(eval echo "~$PI_USER")
# Set ownership (all-in-one only)
if [ "$PLAYER_ONLY" = false ]; then
chown -R "$PI_USER":"$PI_USER" "$SCREENTINKER_DIR"
fi
# ============================================================
# 4. Server systemd service (all-in-one only)
# ============================================================
if [ "$PLAYER_ONLY" = false ]; then
log "Creating screentinker-server service..."
cat > /etc/systemd/system/screentinker-server.service << EOF
[Unit] [Unit]
Description=ScreenTinker Kiosk Player Description=ScreenTinker Digital Signage Server
After=graphical.target After=network-online.target
Wants=graphical.target Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=$USER User=${PI_USER}
WorkingDirectory=${SCREENTINKER_DIR}/server
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=5
StartLimitBurst=5
StartLimitIntervalSec=60
Environment=NODE_ENV=production
Environment=PORT=${SCREENTINKER_PORT}
Environment=SELF_HOSTED=true
Environment=HOST=0.0.0.0
StandardOutput=journal
StandardError=journal
SyslogIdentifier=screentinker-server
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable screentinker-server.service
log "Server service enabled"
fi
# ============================================================
# 5. Kiosk display packages
# ============================================================
log "Installing kiosk packages..."
if [ "$HAS_DESKTOP" = false ]; then
# Lite: install X11 + Chromium from scratch
apt-get install -y -qq \
xserver-xorg x11-xserver-utils xinit \
chromium-browser \
unclutter xdotool \
>> "$LOG_FILE" 2>&1
else
# Desktop: X already running, just ensure Chromium + helpers
apt-get install -y -qq unclutter xdotool >> "$LOG_FILE" 2>&1
if ! command -v chromium-browser &>/dev/null && ! command -v chromium &>/dev/null; then
apt-get install -y -qq chromium-browser >> "$LOG_FILE" 2>&1
fi
fi
# Find Chromium binary
CHROMIUM_BIN=$(command -v chromium-browser 2>/dev/null || command -v chromium 2>/dev/null || echo "/usr/bin/chromium-browser")
# ============================================================
# 6. Kiosk launcher script
# ============================================================
log "Creating kiosk launcher..."
cat > "$PI_HOME/screentinker-kiosk.sh" << KIOSKEOF
#!/bin/bash
# ScreenTinker Kiosk - launches Chromium in fullscreen player mode
KIOSK_URL="${KIOSK_URL}"
# Wait for display
sleep 2
# Disable screen blanking and power management
xset s off
xset s noblank
xset -dpms
xset s 0 0
# Hide cursor after 3 seconds of inactivity
unclutter -idle 3 -root &
# Clean Chromium crash flags (prevents restore session dialogs)
CDIR="\$HOME/.config/chromium/Default"
mkdir -p "\$CDIR"
if [ -f "\$CDIR/Preferences" ]; then
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' "\$CDIR/Preferences" 2>/dev/null || true
sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' "\$CDIR/Preferences" 2>/dev/null || true
fi
# Wait for local server if running all-in-one
if echo "\$KIOSK_URL" | grep -q "localhost"; then
echo "Waiting for ScreenTinker server..."
for i in \$(seq 1 30); do
if curl -sf "http://localhost:${SCREENTINKER_PORT}/api/health" >/dev/null 2>&1; then
echo "Server ready"
break
fi
sleep 2
done
fi
exec ${CHROMIUM_BIN} \\
--kiosk \\
--noerrdialogs \\
--disable-infobars \\
--disable-session-crashed-bubble \\
--disable-features=TranslateUI \\
--disable-component-update \\
--check-for-update-interval=31536000 \\
--autoplay-policy=no-user-gesture-required \\
--no-first-run \\
--start-fullscreen \\
--disable-pinch \\
--overscroll-history-navigation=0 \\
--disable-translate \\
--disable-sync \\
--disable-background-networking \\
--disable-default-apps \\
--disable-extensions \\
--disable-hang-monitor \\
--disable-popup-blocking \\
--disable-prompt-on-repost \\
--metrics-recording-only \\
--safebrowsing-disable-auto-update \\
--ignore-certificate-errors \\
"\$KIOSK_URL"
KIOSKEOF
chmod +x "$PI_HOME/screentinker-kiosk.sh"
chown "$PI_USER":"$PI_USER" "$PI_HOME/screentinker-kiosk.sh"
# ============================================================
# 7. Xinitrc (Pi OS Lite - starts kiosk from console)
# ============================================================
if [ "$HAS_DESKTOP" = false ]; then
cat > "$PI_HOME/.xinitrc" << 'EOF'
#!/bin/bash
exec ~/screentinker-kiosk.sh
EOF
chmod +x "$PI_HOME/.xinitrc"
chown "$PI_USER":"$PI_USER" "$PI_HOME/.xinitrc"
fi
# ============================================================
# 8. Kiosk systemd service
# ============================================================
log "Creating kiosk service..."
if [ "$HAS_DESKTOP" = false ]; then
# Lite: start X ourselves
if [ "$PLAYER_ONLY" = false ]; then
KIOSK_AFTER="After=screentinker-server.service"
KIOSK_REQ="Requires=screentinker-server.service"
else
KIOSK_AFTER="After=network-online.target"
KIOSK_REQ="Wants=network-online.target"
fi
cat > /etc/systemd/system/screentinker-kiosk.service << EOF
[Unit]
Description=ScreenTinker Kiosk Display
${KIOSK_AFTER}
${KIOSK_REQ}
[Service]
Type=simple
User=${PI_USER}
Environment=DISPLAY=:0 Environment=DISPLAY=:0
ExecStart=/bin/bash $HOME/remotedisplay-kiosk.sh Environment=XAUTHORITY=${PI_HOME}/.Xauthority
ExecStartPre=/bin/sleep 3
ExecStart=/usr/bin/startx ${PI_HOME}/.xinitrc -- :0 -nolisten tcp vt1
Restart=always Restart=always
RestartSec=10 RestartSec=10
TTYPath=/dev/tty1
StandardInput=tty
StandardOutput=journal
StandardError=journal
SyslogIdentifier=screentinker-kiosk
[Install]
WantedBy=multi-user.target
EOF
else
# Desktop: X already running, just launch Chromium
if [ "$PLAYER_ONLY" = false ]; then
KIOSK_AFTER="After=screentinker-server.service graphical.target"
KIOSK_REQ="Requires=screentinker-server.service"
else
KIOSK_AFTER="After=graphical.target"
KIOSK_REQ="Wants=graphical.target"
fi
cat > /etc/systemd/system/screentinker-kiosk.service << EOF
[Unit]
Description=ScreenTinker Kiosk Display
${KIOSK_AFTER}
${KIOSK_REQ}
[Service]
Type=simple
User=${PI_USER}
Environment=DISPLAY=:0
ExecStartPre=/bin/sleep 5
ExecStart=/bin/bash ${PI_HOME}/screentinker-kiosk.sh
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=screentinker-kiosk
[Install] [Install]
WantedBy=graphical.target WantedBy=graphical.target
EOF EOF
fi
sudo systemctl daemon-reload systemctl daemon-reload
sudo systemctl enable remotedisplay.service systemctl enable screentinker-kiosk.service
log "Kiosk service enabled"
# Create desktop autostart entry (fallback) # Desktop: autostart entry as fallback
cat > ~/.config/autostart/remotedisplay.desktop << EOF if [ "$HAS_DESKTOP" = true ]; then
AUTOSTART_DIR="$PI_HOME/.config/autostart"
mkdir -p "$AUTOSTART_DIR"
cat > "$AUTOSTART_DIR/screentinker.desktop" << EOF
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=ScreenTinker Name=ScreenTinker Player
Exec=$HOME/remotedisplay-kiosk.sh Exec=${PI_HOME}/screentinker-kiosk.sh
X-GNOME-Autostart-enabled=true X-GNOME-Autostart-enabled=true
EOF EOF
chown -R "$PI_USER":"$PI_USER" "$AUTOSTART_DIR"
fi
# ============================================================
# 9. Auto-login on tty1 (Lite only)
# ============================================================
if [ "$HAS_DESKTOP" = false ]; then
log "Configuring auto-login on tty1..."
mkdir -p /etc/systemd/system/getty@tty1.service.d
cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf << EOF
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin ${PI_USER} --noclear %I \$TERM
EOF
fi
# ============================================================
# 10. Pi display and boot optimizations
# ============================================================
log "Applying display optimizations..."
# Find config.txt (Pi 5 uses /boot/firmware/, older uses /boot/)
CONFIG_FILE=""
for p in /boot/firmware/config.txt /boot/config.txt; do
[ -f "$p" ] && CONFIG_FILE="$p" && break
done
if [ -n "$CONFIG_FILE" ]; then
# GPU memory for video playback
if ! grep -q "^gpu_mem=" "$CONFIG_FILE"; then
echo -e "\n# ScreenTinker: GPU memory for smooth video" >> "$CONFIG_FILE"
echo "gpu_mem=128" >> "$CONFIG_FILE"
log "GPU memory: 128MB"
fi
# Disable overscan (removes black borders on TVs)
if ! grep -q "^disable_overscan=1" "$CONFIG_FILE"; then
echo "disable_overscan=1" >> "$CONFIG_FILE"
log "Overscan disabled"
fi
fi
# Disable console blanking
for p in /boot/firmware/cmdline.txt /boot/cmdline.txt; do
if [ -f "$p" ]; then
if ! grep -q "consoleblank=0" "$p"; then
sed -i 's/$/ consoleblank=0/' "$p"
log "Console blanking disabled"
fi
break
fi
done
# Lightdm screen blanking (Desktop only)
if [ "$HAS_DESKTOP" = true ] && [ -f /etc/lightdm/lightdm.conf ]; then
sed -i 's/#xserver-command=X/xserver-command=X -s 0 -dpms/' /etc/lightdm/lightdm.conf
fi
# Hardware watchdog for auto-recovery from system hangs
if grep -q "#RuntimeWatchdogSec=0" /etc/systemd/system.conf 2>/dev/null; then
sed -i 's/#RuntimeWatchdogSec=0/RuntimeWatchdogSec=10/' /etc/systemd/system.conf
log "Hardware watchdog enabled (10s)"
fi
# ============================================================
# 11. Management scripts (all-in-one only)
# ============================================================
if [ "$PLAYER_ONLY" = false ]; then
log "Creating management scripts..."
cat > /usr/local/bin/screentinker-update << 'UPDATEEOF'
#!/bin/bash
echo "Stopping services..."
sudo systemctl stop screentinker-kiosk.service 2>/dev/null || true
sudo systemctl stop screentinker-server.service 2>/dev/null || true
echo "Pulling latest..."
cd /opt/screentinker && git pull origin main
echo "Installing dependencies..."
cd server && npm install --production
echo "Starting services..."
sudo systemctl start screentinker-server.service
sleep 3
sudo systemctl start screentinker-kiosk.service
echo "" echo ""
echo "==================================" echo "Done! Server: $(systemctl is-active screentinker-server.service)"
echo " Setup Complete!" echo " Kiosk: $(systemctl is-active screentinker-kiosk.service)"
echo "==================================" UPDATEEOF
chmod +x /usr/local/bin/screentinker-update
cat > /usr/local/bin/screentinker-status << 'STATUSEOF'
#!/bin/bash
echo "" echo ""
echo "The player will auto-start on next boot." echo "=== ScreenTinker Status ==="
echo "To start now: ~/remotedisplay-kiosk.sh" echo ""
echo "To stop: sudo systemctl stop remotedisplay" IP=$(hostname -I | awk '{print $1}')
echo "Player URL: $PLAYER_URL"
if systemctl is-active screentinker-server.service &>/dev/null; then
echo "Server: RUNNING (PID $(systemctl show screentinker-server.service -p MainPID --value))"
else
echo "Server: STOPPED"
fi
if systemctl is-active screentinker-kiosk.service &>/dev/null; then
echo "Kiosk: RUNNING"
else
echo "Kiosk: STOPPED"
fi
echo ""
echo "Uptime: $(uptime -p)"
echo "CPU Temp: $(vcgencmd measure_temp 2>/dev/null | cut -d= -f2 || echo 'n/a')"
echo "Disk: $(df -h /opt/screentinker 2>/dev/null | tail -1 | awk '{print $3 "/" $2 " (" $5 " used)"}')"
echo "Memory: $(free -h | awk '/Mem:/ {print $3 " / " $2}')"
echo ""
echo "Dashboard: http://${IP}:3001"
echo "Player: http://${IP}:3001/player"
echo "mDNS: http://$(hostname).local:3001"
echo ""
STATUSEOF
chmod +x /usr/local/bin/screentinker-status
cat > /usr/local/bin/screentinker-logs << 'LOGSEOF'
#!/bin/bash
case "${1:-server}" in
server) journalctl -u screentinker-server.service -f --no-hostname ;;
kiosk) journalctl -u screentinker-kiosk.service -f --no-hostname ;;
all) journalctl -u screentinker-server.service -u screentinker-kiosk.service -f --no-hostname ;;
*) echo "Usage: screentinker-logs [server|kiosk|all]" ;;
esac
LOGSEOF
chmod +x /usr/local/bin/screentinker-logs
fi
# ============================================================
# 12. MOTD
# ============================================================
cat > /etc/motd << 'MOTDEOF'
____ _____ _
/ ___| ___ _ __ ___ ___ |_ _|_ _ __ | | _____ _ __
\___ \ / __| '__/ _ \/ _ \ | || | '_ \| |/ / _ \ '__|
___) | (__| | | __/ __/ | || | | | | < __/ |
|____/ \___|_| \___|\___| |_||_|_| |_|_|\_\___|_|
Open-Source Digital Signage for Any Screen
Commands:
screentinker-status Show system info and URLs
screentinker-update Pull latest and restart
screentinker-logs Follow logs (server|kiosk|all)
MOTDEOF
# ============================================================
# 13. Clean up legacy remotedisplay naming
# ============================================================
if [ -f /etc/systemd/system/remotedisplay.service ]; then
log "Cleaning up legacy remotedisplay service..."
systemctl stop remotedisplay.service 2>/dev/null || true
systemctl disable remotedisplay.service 2>/dev/null || true
rm -f /etc/systemd/system/remotedisplay.service
rm -f "$PI_HOME/remotedisplay-kiosk.sh"
rm -f "$PI_HOME/.config/autostart/remotedisplay.desktop"
systemctl daemon-reload
fi
# ============================================================
# Done
# ============================================================
echo ""
echo -e "${GREEN}======================================${NC}"
echo -e "${GREEN} ScreenTinker Setup Complete!${NC}"
echo -e "${GREEN}======================================${NC}"
echo ""
IP=$(hostname -I | awk '{print $1}')
if [ "$PLAYER_ONLY" = false ]; then
echo "Mode: All-in-One (server + player)"
echo ""
echo "After reboot this Pi will:"
echo " - Start the ScreenTinker server on port $SCREENTINKER_PORT"
echo " - Display the player fullscreen on the connected screen"
echo ""
echo "First steps:"
echo " 1. Reboot: sudo reboot"
echo " 2. From your phone, go to http://${IP}:${SCREENTINKER_PORT}"
echo " (or http://$(hostname).local:${SCREENTINKER_PORT})"
echo " 3. Register - first user gets full admin access"
echo " 4. Add a display and enter the pairing code from the TV"
echo " 5. Upload content and push it to the screen"
echo ""
echo "Management:"
echo " screentinker-status Check everything is running"
echo " screentinker-update Update to latest version"
echo " screentinker-logs Watch server logs"
else
echo "Mode: Player Only"
echo "Server: $SERVER_URL"
echo ""
echo "After reboot this Pi will:"
echo " - Open the player in fullscreen kiosk mode"
echo " - Auto-reconnect if the server goes down"
echo ""
echo "To pair:"
echo " 1. Reboot: sudo reboot"
echo " 2. The pairing screen will appear on the TV"
echo " 3. Enter the code in your ScreenTinker dashboard"
fi
echo ""
echo "Services:"
if [ "$PLAYER_ONLY" = false ]; then
echo " sudo systemctl [start|stop|restart] screentinker-server"
fi
echo " sudo systemctl [start|stop|restart] screentinker-kiosk"
echo ""
echo -e "${YELLOW}Reboot to start: sudo reboot${NC}"
echo "" echo ""
echo "Press Escape in the player to reset/reconfigure."
echo "Press F for fullscreen toggle."

View file

@ -13,7 +13,7 @@ const crypto = require('crypto');
const nonce = crypto.randomBytes(8).toString('hex'); const nonce = crypto.randomBytes(8).toString('hex');
const token = jwt.sign( const token = jwt.sign(
{ id: 'recovery-' + nonce, email: 'admin@localhost', role: 'admin' }, { id: 'recovery-' + nonce, email: 'admin@localhost', role: 'admin', recovery: true },
config.jwtSecret, config.jwtSecret,
{ expiresIn: '1h' } { expiresIn: '1h' }
); );

1
server/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

View file

@ -8,8 +8,22 @@ module.exports = {
contentDir: path.join(__dirname, 'uploads', 'content'), contentDir: path.join(__dirname, 'uploads', 'content'),
screenshotsDir: path.join(__dirname, 'uploads', 'screenshots'), screenshotsDir: path.join(__dirname, 'uploads', 'screenshots'),
frontendDir: path.join(__dirname, '..', 'frontend'), frontendDir: path.join(__dirname, '..', 'frontend'),
heartbeatInterval: 10000, // Check every 10s // App-level heartbeat. Checker runs every heartbeatInterval and marks
heartbeatTimeout: 45000, // Offline after 45s (3 missed 15s beats) // devices offline if last_heartbeat is older than heartbeatTimeout.
// Env override for self-hosters on slow/jittery networks (issue #3:
// reporter found raising HEARTBEAT_TIMEOUT to 60s reduced false offlines).
heartbeatInterval: parseInt(process.env.HEARTBEAT_INTERVAL) || 10000,
heartbeatTimeout: parseInt(process.env.HEARTBEAT_TIMEOUT) || 45000,
// How long the server holds commands/playlist-updates for a device that's
// offline at emit time (ms). On reconnect within this window, queued events
// are flushed in order. Past TTL they're dropped. See lib/command-queue.js.
commandQueueTtlMs: parseInt(process.env.COMMAND_QUEUE_TTL_MS) || 30000,
// Engine.IO transport-level ping/pong. Raised from Socket.IO defaults
// (25000/20000) because TV WebKits (LG webOS, older Tizen) miss pongs
// under decode load - tighter values cause spurious transport drops.
// Worst-case dead-socket detection: pingInterval + pingTimeout = 60s.
pingInterval: parseInt(process.env.PING_INTERVAL) || 30000,
pingTimeout: parseInt(process.env.PING_TIMEOUT) || 30000,
maxFileSize: 500 * 1024 * 1024, // 500MB maxFileSize: 500 * 1024 * 1024, // 500MB
thumbnailWidth: 320, thumbnailWidth: 320,
screenshotQuality: 70, screenshotQuality: 70,
@ -35,8 +49,24 @@ module.exports = {
// Stripe (optional - for paid subscriptions) // Stripe (optional - for paid subscriptions)
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '', stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
// Email alerts webhook URL (POST endpoint for sending emails) // Microsoft Graph email sender (services/email.js). Required for actual
emailWebhookUrl: process.env.EMAIL_WEBHOOK_URL || '', // delivery; absent values short-circuit to a stdout fallback for local dev.
graphTenantId: process.env.GRAPH_TENANT_ID || '',
graphClientId: process.env.GRAPH_CLIENT_ID || '',
graphClientSecret: process.env.GRAPH_CLIENT_SECRET || '',
graphSenderEmail: process.env.GRAPH_SENDER_EMAIL || '',
graphSenderName: process.env.GRAPH_SENDER_NAME || 'ScreenTinker',
// Dev safety net: comma-separated allow-list of recipient emails. When set,
// sends to any address NOT in the list are suppressed (logged but not posted
// to Graph). Intended for local dev that pulls fresh prod DB copies - keeps
// us from accidentally emailing real prod users. UNSET on prod systemd unit.
graphDevRestrictTo: process.env.GRAPH_DEV_RESTRICT_TO || '',
// Self-hosted mode: if true, first user gets enterprise plan and no billing // Self-hosted mode: if true, first user gets enterprise plan and no billing
selfHosted: process.env.SELF_HOSTED === 'true', selfHosted: process.env.SELF_HOSTED === 'true',
// Disable public registration (OAuth auto-signup is also blocked when set).
// First-user setup is still allowed so a fresh install can be initialized.
disableRegistration: ['true', '1'].includes(String(process.env.DISABLE_REGISTRATION || '').toLowerCase()),
// Redirect / -> /app instead of serving the marketing landing page.
// For self-hosted internal deployments that don't want the public homepage.
disableHomepage: ['true', '1'].includes(String(process.env.DISABLE_HOMEPAGE || '').toLowerCase()),
}; };

View file

@ -0,0 +1,39 @@
// Cloudflare published edge IP ranges.
// Source: https://www.cloudflare.com/ips-v4 and https://www.cloudflare.com/ips-v6
// Snapshot: 2026-05-07. Update by hand when Cloudflare publishes a new list.
const cloudflareIpv4 = [
'173.245.48.0/20',
'103.21.244.0/22',
'103.22.200.0/22',
'103.31.4.0/22',
'141.101.64.0/18',
'108.162.192.0/18',
'190.93.240.0/20',
'188.114.96.0/20',
'197.234.240.0/22',
'198.41.128.0/17',
'162.158.0.0/15',
'104.16.0.0/13',
'104.24.0.0/14',
'172.64.0.0/13',
'131.0.72.0/22',
];
const cloudflareIpv6 = [
'2400:cb00::/32',
'2606:4700::/32',
'2803:f800::/32',
'2405:b500::/32',
'2405:8100::/32',
'2a06:98c0::/29',
'2c0f:f248::/32',
];
const cloudflareIps = [...cloudflareIpv4, ...cloudflareIpv6];
// What Express's trust-proxy and our CF-Connecting-IP gate both honor.
// 'loopback', 'linklocal', 'uniquelocal' keep local dev and any LAN reverse
// proxy working without further config.
const trustedProxies = ['loopback', 'linklocal', 'uniquelocal', ...cloudflareIps];
module.exports = { cloudflareIpv4, cloudflareIpv6, cloudflareIps, trustedProxies };

View file

@ -16,6 +16,50 @@ db.pragma('foreign_keys = ON');
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema); db.exec(schema);
// Auto-apply Phase 1 multi-tenancy migration if not yet applied. Without this
// a self-hoster who pulls latest and restarts hits a crash in
// migrateFolderWorkspaceIds (queries workspaces table that doesn't exist).
// Pre-existing data is snapshotted to db/remote_display.pre-migration-<ts>.db
// before the migration runs - clear restore path on failure. Fresh installs
// run against empty data (creates tables, no rows to backfill).
function ensureMultitenancyMigration() {
let applied = false;
try {
applied = !!db.prepare(
"SELECT 1 FROM schema_migrations WHERE id = 'phase5_multitenancy_backfill'"
).get();
} catch { /* schema_migrations may not exist yet; treat as not applied */ }
if (applied) return;
console.warn('[boot] Multi-tenancy schema not present - applying migration...');
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const snapshotPath = path.join(dbDir, `remote_display.pre-migration-${ts}.db`);
try {
db.pragma('wal_checkpoint(TRUNCATE)');
fs.copyFileSync(config.dbPath, snapshotPath);
console.warn(`[boot] Pre-migration snapshot: ${snapshotPath}`);
} catch (e) {
console.error(`[boot] Snapshot failed: ${e.message}`);
process.exit(1);
}
try {
const { runMigration } = require('../../scripts/migrate-multitenancy');
runMigration({ db });
console.warn('[boot] Migration complete, continuing startup');
} catch (e) {
console.error(`[boot] Migration FAILED: ${e.message}`);
console.error(`[boot] Restore with: cp ${snapshotPath} ${config.dbPath}`);
process.exit(1);
}
}
// Note: ensureMultitenancyMigration() is called LATER, after the inline
// migrations array has added team_id and workspace_id columns. The Phase 1
// migration script reads team_id from resource tables during its backfill
// loop, so those columns must exist first. Definition kept here near the
// top so the auto-migration logic is easy to find when reading the file.
// Migrations for existing databases // Migrations for existing databases
const migrations = [ const migrations = [
'ALTER TABLE content ADD COLUMN remote_url TEXT', 'ALTER TABLE content ADD COLUMN remote_url TEXT',
@ -60,6 +104,44 @@ const migrations = [
"ALTER TABLE playlists ADD COLUMN is_auto_generated INTEGER NOT NULL DEFAULT 0", "ALTER TABLE playlists ADD COLUMN is_auto_generated INTEGER NOT NULL DEFAULT 0",
// Device authentication token // Device authentication token
"ALTER TABLE devices ADD COLUMN device_token TEXT", "ALTER TABLE devices ADD COLUMN device_token TEXT",
// Phase 3: playlist publish/draft state
"ALTER TABLE playlists ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'",
"ALTER TABLE playlists ADD COLUMN published_snapshot TEXT",
// Phase 4: group scheduling (column add only — full migration with CHECK below)
"ALTER TABLE schedules ADD COLUMN group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL",
// Hierarchical content folders (per-user)
`CREATE TABLE IF NOT EXISTS content_folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES content_folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
"CREATE INDEX IF NOT EXISTS idx_content_folders_user ON content_folders(user_id, parent_id)",
"ALTER TABLE content ADD COLUMN folder_id TEXT REFERENCES content_folders(id) ON DELETE SET NULL",
"CREATE INDEX IF NOT EXISTS idx_content_folder ON content(folder_id)",
// Group-level playlist: when set, devices added to the group inherit it.
"ALTER TABLE device_groups ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
// Wall-level playlist: video walls now play a playlist (not just one content).
"ALTER TABLE video_walls ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
// Free-form canvas layout: walls store a player rect; member devices store
// their own rect. Coordinates are in arbitrary canvas units (effectively px).
"ALTER TABLE video_walls ADD COLUMN player_x REAL",
"ALTER TABLE video_walls ADD COLUMN player_y REAL",
"ALTER TABLE video_walls ADD COLUMN player_width REAL",
"ALTER TABLE video_walls ADD COLUMN player_height REAL",
"ALTER TABLE video_wall_devices ADD COLUMN canvas_x REAL",
"ALTER TABLE video_wall_devices ADD COLUMN canvas_y REAL",
"ALTER TABLE video_wall_devices ADD COLUMN canvas_width REAL",
"ALTER TABLE video_wall_devices ADD COLUMN canvas_height REAL",
// Phase 2.2c: content_folders gets workspace_id. Phase 1 missed this table.
"ALTER TABLE content_folders ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)",
"CREATE INDEX IF NOT EXISTS idx_content_folders_workspace ON content_folders(workspace_id)",
// Phase 2 zone_id regression fix: playlist_items needs zone_id so the
// multi-zone-layout assignment feature works. The Phase 2 assignments->
// playlist_items conversion (migrateAssignmentsToPlaylists) dropped this
// column. Column ADD is idempotent via the surrounding try/catch loop.
"ALTER TABLE playlist_items ADD COLUMN zone_id TEXT REFERENCES layout_zones(id) ON DELETE SET NULL",
]; ];
for (const sql of migrations) { for (const sql of migrations) {
try { db.exec(sql); } catch (e) { /* already exists */ } try { db.exec(sql); } catch (e) { /* already exists */ }
@ -198,6 +280,319 @@ async function migrateAssignmentsToPlaylists() {
migrateAssignmentsToPlaylists().catch(e => console.error('Migration error:', e)); migrateAssignmentsToPlaylists().catch(e => console.error('Migration error:', e));
// Phase 3 migration: snapshot existing playlist items into published_snapshot
const PHASE3_MIGRATION_ID = 'phase3_publish_snapshot';
function migratePublishSnapshots() {
const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE3_MIGRATION_ID);
if (already) return;
const playlists = db.prepare('SELECT id FROM playlists').all();
if (playlists.length === 0) {
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE3_MIGRATION_ID);
return;
}
console.log(`Phase 3 migration: snapshotting ${playlists.length} playlist(s) as published...`);
const getItems = db.prepare(`
SELECT pi.content_id, pi.widget_id, pi.sort_order, pi.duration_sec,
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
c.duration_sec as content_duration, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`);
const updatePlaylist = db.prepare("UPDATE playlists SET status = 'published', published_snapshot = ? WHERE id = ?");
const migrate = db.transaction(() => {
let snapshotted = 0;
for (const playlist of playlists) {
const items = getItems.all(playlist.id);
updatePlaylist.run(JSON.stringify(items), playlist.id);
snapshotted++;
}
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE3_MIGRATION_ID);
console.log(`Phase 3 migration complete: ${snapshotted} playlist(s) snapshotted as published.`);
});
migrate();
}
migratePublishSnapshots();
// Phase 4 migration: add group_id to schedules, make device_id nullable, add CHECK constraint
const PHASE4_MIGRATION_ID = 'phase4_group_schedules';
function migrateGroupSchedules() {
const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE4_MIGRATION_ID);
if (already) return;
console.log('Phase 4 migration: adding group_id to schedules, making device_id nullable...');
const migrate = db.transaction(() => {
db.exec(`
CREATE TABLE schedules_new (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
device_id TEXT REFERENCES devices(id) ON DELETE CASCADE,
group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL,
zone_id TEXT REFERENCES layout_zones(id) ON DELETE CASCADE,
content_id TEXT REFERENCES content(id) ON DELETE CASCADE,
widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE,
layout_id TEXT REFERENCES layouts(id) ON DELETE SET NULL,
playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT '',
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC',
recurrence TEXT,
recurrence_end TEXT,
priority INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
color TEXT DEFAULT '#3B82F6',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
CHECK ((device_id IS NOT NULL AND group_id IS NULL) OR (device_id IS NULL AND group_id IS NOT NULL))
);
INSERT INTO schedules_new (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id,
title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at, updated_at)
SELECT id, user_id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id,
title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at, updated_at
FROM schedules;
DROP TABLE schedules;
ALTER TABLE schedules_new RENAME TO schedules;
CREATE INDEX idx_schedules_device ON schedules(device_id, enabled);
CREATE INDEX idx_schedules_group ON schedules(group_id, enabled);
`);
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE4_MIGRATION_ID);
console.log('Phase 4 migration complete: schedules table rebuilt with group_id support.');
});
migrate();
}
migrateGroupSchedules();
// Phase 1 multi-tenancy migration (auto-applies if not yet run). Must come
// AFTER the inline migrations above so that team_id / workspace_id columns
// exist on resource tables - the Phase 1 backfill loop reads team_id and
// updates workspace_id.
ensureMultitenancyMigration();
// Phase 2.2c migration: backfill content_folders.workspace_id from owner's
// default workspace. The ALTER lives in the migrations array above; this
// one-shot populates the column for any rows that pre-date it.
const PHASE6_MIGRATION_ID = 'phase6_content_folders_workspace';
function migrateFolderWorkspaceIds() {
const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE6_MIGRATION_ID);
if (already) return;
// Belt-and-suspenders: if multi-tenancy tables aren't present (auto-runner
// somehow skipped), skip cleanly instead of crashing on the JOIN below.
const hasWorkspaces = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='workspaces'"
).get();
if (!hasWorkspaces) {
console.warn('migrateFolderWorkspaceIds: workspaces table missing, skipping');
return;
}
// Check the column exists before trying to backfill. (Defensive: on a fresh
// install the schema.sql defines content_folders without the column, the
// ALTER above adds it, and we proceed; but if anything went sideways we
// skip rather than throw.)
const cols = db.prepare("PRAGMA table_info(content_folders)").all();
if (!cols.some(c => c.name === 'workspace_id')) {
console.warn('Phase 2.2c migration: content_folders.workspace_id column missing, skipping backfill');
return;
}
const stmt = db.prepare(`
UPDATE content_folders SET workspace_id = (
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = content_folders.user_id
ORDER BY wm.joined_at ASC LIMIT 1
)
WHERE workspace_id IS NULL AND user_id IS NOT NULL
`);
const tx = db.transaction(() => {
const result = stmt.run();
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE6_MIGRATION_ID);
return result.changes;
});
const changes = tx();
if (changes > 0) console.log(`Phase 2.2c migration: backfilled workspace_id on ${changes} content_folders row(s).`);
}
migrateFolderWorkspaceIds();
const PHASE_2_2_ACTIVITY_STOP_ID = 'phase_2_2_activity_log_stop_bleeding';
// One-time backfill of activity_log rows that were written between the
// Phase 1 schema migration and the writer-leak fix in this commit. Strategy:
// * Rows with device_id: derive workspace_id from devices.workspace_id
// (the activity is about a specific device, so this is unambiguous).
// * Rows with no device_id but a user_id: derive from the user's oldest
// workspace_members row (pre-flight confirmed 0 affected users have
// more than one workspace, so the choice is unambiguous).
// Rows with user_id IS NULL (auth:login_failed and similar pre-tenancy
// system events) are left alone - they have no tenant context.
function backfillActivityLogWorkspace() {
const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE_2_2_ACTIVITY_STOP_ID);
if (already) return;
// Belt-and-suspenders: if multi-tenancy tables aren't present (auto-runner
// somehow skipped), skip cleanly instead of crashing on workspace_members.
const hasMembers = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='workspace_members'"
).get();
if (!hasMembers) {
console.warn('backfillActivityLogWorkspace: workspace_members table missing, skipping');
return;
}
const viaDevice = db.prepare(`
UPDATE activity_log SET workspace_id = (
SELECT workspace_id FROM devices WHERE devices.id = activity_log.device_id
)
WHERE workspace_id IS NULL AND device_id IS NOT NULL
AND EXISTS (SELECT 1 FROM devices WHERE devices.id = activity_log.device_id AND devices.workspace_id IS NOT NULL)
`);
const viaMembers = db.prepare(`
UPDATE activity_log SET workspace_id = (
SELECT wm.workspace_id FROM workspace_members wm
WHERE wm.user_id = activity_log.user_id
ORDER BY wm.joined_at ASC LIMIT 1
)
WHERE workspace_id IS NULL AND user_id IS NOT NULL AND device_id IS NULL
AND EXISTS (SELECT 1 FROM workspace_members wm WHERE wm.user_id = activity_log.user_id)
`);
const tx = db.transaction(() => {
const d = viaDevice.run().changes;
const m = viaMembers.run().changes;
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE_2_2_ACTIVITY_STOP_ID);
return { d, m };
});
const { d, m } = tx();
if (d + m > 0) console.log(`activity_log backfill: ${d} via device.workspace_id, ${m} via workspace_members lookup`);
}
backfillActivityLogWorkspace();
// Phase 2 zone_id backfill. Companion to the ADD COLUMN above. Attempts to
// recover zone_id values for playlist_items rows by joining back to the
// (legacy) assignments table on device+content/widget. On installs where
// assignments is empty or never had zone_id populated this is a no-op; the
// migration row is stamped regardless so it doesn't re-run.
//
// Also regenerates published_snapshot JSON for every published playlist so
// the snapshot the player consumes carries zone_id going forward (the
// player resolves a.zone_id === zone.id in renderZones). Even with zero
// rows backfilled, this republish closes the snapshot-staleness gap.
//
// Pre-migration snapshot is a one-off for this migration only - the general
// "every migration backs up first" framework is tracked as a separate
// concern, not built here.
const PHASE2_ZONE_ID_BACKFILL_ID = 'phase2_zone_id_backfill';
function backfillPlaylistItemsZoneId() {
const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE2_ZONE_ID_BACKFILL_ID);
if (already) return;
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const snapshotPath = path.join(dbDir, `remote_display.pre-zone-id-backfill-${ts}.db`);
try {
db.pragma('wal_checkpoint(TRUNCATE)');
fs.copyFileSync(config.dbPath, snapshotPath);
console.warn(`[zone-id backfill] Pre-migration snapshot: ${snapshotPath}`);
} catch (e) {
console.error(`[zone-id backfill] Snapshot failed: ${e.message}`);
process.exit(1);
}
try {
const tx = db.transaction(() => {
// Backfill: best-effort match playlist_items back to assignments via
// device.playlist_id and content/widget identity. LIMIT 1 covers the
// unlikely "same content assigned twice in different zones on one
// device" edge case. Items with no matching legacy assignment, or
// matches that themselves had zone_id NULL, are left as NULL.
const backfilled = db.prepare(`
UPDATE playlist_items
SET zone_id = (
SELECT a.zone_id FROM assignments a
JOIN devices d ON d.id = a.device_id
WHERE d.playlist_id = playlist_items.playlist_id
AND a.zone_id IS NOT NULL
AND (
(a.content_id IS NOT NULL AND a.content_id = playlist_items.content_id)
OR
(a.widget_id IS NOT NULL AND a.widget_id = playlist_items.widget_id)
)
LIMIT 1
)
WHERE zone_id IS NULL
AND EXISTS (
SELECT 1 FROM assignments a
JOIN devices d ON d.id = a.device_id
WHERE d.playlist_id = playlist_items.playlist_id
AND a.zone_id IS NOT NULL
AND (
(a.content_id IS NOT NULL AND a.content_id = playlist_items.content_id)
OR
(a.widget_id IS NOT NULL AND a.widget_id = playlist_items.widget_id)
)
)
`).run().changes;
// Republish: regenerate published_snapshot for every published playlist
// so the snapshot JSON carries zone_id. Mirrors buildSnapshotItems in
// routes/playlists.js - kept inline here to avoid pulling routes/* in
// at migration time (circular require).
const publishedPlaylists = db.prepare("SELECT id FROM playlists WHERE status = 'published'").all();
const buildSnapshot = db.prepare(`
SELECT pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
c.duration_sec as content_duration, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`);
const updateSnap = db.prepare("UPDATE playlists SET published_snapshot = ?, updated_at = strftime('%s','now') WHERE id = ?");
let republished = 0;
for (const pl of publishedPlaylists) {
const items = buildSnapshot.all(pl.id);
updateSnap.run(JSON.stringify(items), pl.id);
republished++;
}
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE2_ZONE_ID_BACKFILL_ID);
return { backfilled, republished };
});
const { backfilled, republished } = tx();
console.log(`[zone-id backfill] ${backfilled} playlist_items recovered zone_id, ${republished} published_snapshots regenerated`);
} catch (e) {
console.error(`[zone-id backfill] Migration FAILED: ${e.message}`);
console.error(`[zone-id backfill] Restore with: cp ${snapshotPath} ${config.dbPath}`);
process.exit(1);
}
}
backfillPlaylistItemsZoneId();
// Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000) // Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000)
function pruneTelemetry(deviceId) { function pruneTelemetry(deviceId) {
db.prepare(` db.prepare(`

View file

@ -195,7 +195,8 @@ CREATE TABLE IF NOT EXISTS widgets (
CREATE TABLE IF NOT EXISTS schedules ( CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id), user_id TEXT NOT NULL REFERENCES users(id),
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, device_id TEXT REFERENCES devices(id) ON DELETE CASCADE,
group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL,
zone_id TEXT REFERENCES layout_zones(id) ON DELETE CASCADE, zone_id TEXT REFERENCES layout_zones(id) ON DELETE CASCADE,
content_id TEXT REFERENCES content(id) ON DELETE CASCADE, content_id TEXT REFERENCES content(id) ON DELETE CASCADE,
widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE, widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE,
@ -211,10 +212,12 @@ CREATE TABLE IF NOT EXISTS schedules (
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
color TEXT DEFAULT '#3B82F6', color TEXT DEFAULT '#3B82F6',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
CHECK ((device_id IS NOT NULL AND group_id IS NULL) OR (device_id IS NULL AND group_id IS NOT NULL))
); );
CREATE INDEX IF NOT EXISTS idx_schedules_device ON schedules(device_id, enabled); CREATE INDEX IF NOT EXISTS idx_schedules_device ON schedules(device_id, enabled);
-- Note: idx_schedules_group is created by the phase4 migration which rebuilds the table
-- ===================== VIDEO WALLS ===================== -- ===================== VIDEO WALLS =====================
@ -232,6 +235,12 @@ CREATE TABLE IF NOT EXISTS video_walls (
sync_mode TEXT NOT NULL DEFAULT 'leader', sync_mode TEXT NOT NULL DEFAULT 'leader',
leader_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL, leader_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL,
content_id TEXT REFERENCES content(id) ON DELETE SET NULL, content_id TEXT REFERENCES content(id) ON DELETE SET NULL,
playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL,
-- Free-form player rect on the wall canvas (NULL = use bounding box of screens)
player_x REAL,
player_y REAL,
player_width REAL,
player_height REAL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
); );
@ -243,6 +252,11 @@ CREATE TABLE IF NOT EXISTS video_wall_devices (
grid_col INTEGER NOT NULL, grid_col INTEGER NOT NULL,
grid_row INTEGER NOT NULL, grid_row INTEGER NOT NULL,
rotation INTEGER NOT NULL DEFAULT 0, rotation INTEGER NOT NULL DEFAULT 0,
-- Free-form canvas rect (NULL = derive from grid_col/row + bezel as a fallback)
canvas_x REAL,
canvas_y REAL,
canvas_width REAL,
canvas_height REAL,
UNIQUE(wall_id, device_id), UNIQUE(wall_id, device_id),
UNIQUE(wall_id, grid_col, grid_row) UNIQUE(wall_id, grid_col, grid_row)
); );
@ -304,6 +318,7 @@ CREATE TABLE IF NOT EXISTS device_groups (
user_id TEXT NOT NULL REFERENCES users(id), user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL, name TEXT NOT NULL,
color TEXT DEFAULT '#3B82F6', color TEXT DEFAULT '#3B82F6',
playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
); );
@ -321,6 +336,8 @@ CREATE TABLE IF NOT EXISTS playlists (
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT DEFAULT '', description TEXT DEFAULT '',
is_auto_generated INTEGER NOT NULL DEFAULT 0, is_auto_generated INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft',
published_snapshot TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
); );
@ -330,6 +347,7 @@ CREATE TABLE IF NOT EXISTS playlist_items (
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
content_id TEXT REFERENCES content(id) ON DELETE CASCADE, content_id TEXT REFERENCES content(id) ON DELETE CASCADE,
widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE, widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE,
zone_id TEXT REFERENCES layout_zones(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
duration_sec INTEGER NOT NULL DEFAULT 10, duration_sec INTEGER NOT NULL DEFAULT 10,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),

153
server/lib/command-queue.js Normal file
View file

@ -0,0 +1,153 @@
// Short-lived per-device queue for events that target a currently-offline
// device. Designed for the TV-flap case where a device disconnects for a few
// seconds (Engine.IO ping miss, Wi-Fi blip, decode stall) and reconnects via
// Socket.IO's auto-reconnect. Without this queue, any device:command or
// device:playlist-update emitted during the disconnect window goes nowhere -
// the room is empty, the emit is silently dropped.
//
// Two structures, both keyed by device_id, both pruned by TTL:
//
// pendingPlaylistUpdate: Map<deviceId, { expiresAt }>
// We don't store the payload. On flush we rebuild via buildPlaylistPayload
// so the device gets the LATEST DB state, not a stale snapshot from when
// the update was first queued.
//
// pendingCommands: Map<deviceId, Map<type, { payload, expiresAt }>>
// One entry per command type per device. Last-of-type wins (the most
// recent screen_off supersedes any earlier ones). Payloads stored verbatim
// because commands are stateless declarations.
//
// Memory bounds: worst-case ~6 entries per device (1 playlist marker + 5
// command types), each ~200 bytes. 10,000 offline devices = ~12MB. Sweep
// thread prunes empty per-device records every 30s.
const config = require('../config');
const pendingPlaylistUpdate = new Map();
const pendingCommands = new Map();
let _sweepTimer = null;
// Internal helper - drop expired entries for a single device. Called lazily
// from queue/flush paths AND from the sweep thread.
function pruneDevice(deviceId) {
const now = Date.now();
const pu = pendingPlaylistUpdate.get(deviceId);
if (pu && pu.expiresAt <= now) pendingPlaylistUpdate.delete(deviceId);
const cmds = pendingCommands.get(deviceId);
if (cmds) {
for (const [type, entry] of cmds) {
if (entry.expiresAt <= now) cmds.delete(type);
}
if (cmds.size === 0) pendingCommands.delete(deviceId);
}
}
// Mark a pending playlist-update for a device. Caller used to call
// deviceNs.to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
// directly. Now they call queueOrEmitPlaylistUpdate which checks room presence
// first and queues only if the device is offline.
function queueOrEmitPlaylistUpdate(deviceNs, deviceId, buildPayload) {
if (!deviceNs || !deviceId || typeof buildPayload !== 'function') return { delivered: false };
const room = deviceNs.adapter.rooms.get(deviceId);
if (room && room.size > 0) {
deviceNs.to(deviceId).emit('device:playlist-update', buildPayload(deviceId));
return { delivered: true };
}
pendingPlaylistUpdate.set(deviceId, { expiresAt: Date.now() + config.commandQueueTtlMs });
return { delivered: false, queued: true };
}
// Queue a single command for an offline device. Returns true if accepted
// (always true under current logic; reserved for future "rejected because
// stale/full" cases). Used by item 6 in commit D - dashboard command handler
// calls this when the device room is empty.
function queueCommand(deviceId, type, payload) {
if (!deviceId || !type) return false;
let perDevice = pendingCommands.get(deviceId);
if (!perDevice) {
perDevice = new Map();
pendingCommands.set(deviceId, perDevice);
}
perDevice.set(type, { payload: payload || {}, expiresAt: Date.now() + config.commandQueueTtlMs });
return true;
}
// Called on device:register success, after heartbeat.registerConnection and
// socket.join. Drains both queues to the just-reconnected device.
//
// buildPayload is the buildPlaylistPayload function from deviceSocket.js,
// passed in to avoid a circular require. We call it at flush time so the
// playlist reflects current DB state, not whatever it was when queued.
function flushQueue(deviceNs, deviceId, buildPayload) {
if (!deviceNs || !deviceId) return { playlistUpdate: false, commands: 0 };
pruneDevice(deviceId);
let playlistUpdate = false;
let commands = 0;
const pu = pendingPlaylistUpdate.get(deviceId);
if (pu) {
pendingPlaylistUpdate.delete(deviceId);
if (typeof buildPayload === 'function') {
deviceNs.to(deviceId).emit('device:playlist-update', buildPayload(deviceId));
playlistUpdate = true;
}
}
const cmds = pendingCommands.get(deviceId);
if (cmds) {
pendingCommands.delete(deviceId);
for (const [type, entry] of cmds) {
deviceNs.to(deviceId).emit('device:command', { type, payload: entry.payload });
commands++;
}
}
if (playlistUpdate || commands > 0) {
console.log(`Flushed queue for ${deviceId}: playlistUpdate=${playlistUpdate}, commands=${commands}`);
}
return { playlistUpdate, commands };
}
function getQueueDepth(deviceId) {
pruneDevice(deviceId);
const hasPlaylist = pendingPlaylistUpdate.has(deviceId) ? 1 : 0;
const cmdCount = pendingCommands.get(deviceId)?.size || 0;
return hasPlaylist + cmdCount;
}
// Active sweep prunes devices that never come back. Without this, a device
// that goes permanently offline leaves its queue entries in memory until TTL,
// which is fine, but the Map keys themselves linger. Cheap to walk.
function startSweep() {
if (_sweepTimer) return;
_sweepTimer = setInterval(() => {
for (const deviceId of pendingPlaylistUpdate.keys()) pruneDevice(deviceId);
for (const deviceId of pendingCommands.keys()) pruneDevice(deviceId);
}, 30000);
if (_sweepTimer.unref) _sweepTimer.unref();
}
function stopSweep() {
if (_sweepTimer) { clearInterval(_sweepTimer); _sweepTimer = null; }
}
// Test helpers - reset internal state. Not exported via module.exports for
// production callers; bound below for the test harness only.
function _resetForTests() {
pendingPlaylistUpdate.clear();
pendingCommands.clear();
stopSweep();
}
module.exports = {
queueOrEmitPlaylistUpdate,
queueCommand,
flushQueue,
getQueueDepth,
startSweep,
stopSweep,
_resetForTests,
};

123
server/lib/permissions.js Normal file
View file

@ -0,0 +1,123 @@
// Phase 2.1: permission helpers.
//
// Routes call these as Express middleware to gate access, or as predicate
// functions to branch within a handler. They presume resolveTenancy has
// already attached req.workspaceId / req.workspaceRole / req.orgRole /
// req.isPlatformAdmin.
//
// Layering (top wins):
// 1. req.isPlatformAdmin -> allow anything
// 2. req.orgRole in {org_owner, org_admin} -> allow read/write/admin within the org
// org_owner also has billing.write and org.delete (not exposed in 2.1)
// 3. req.workspaceRole in {workspace_admin, workspace_editor, workspace_viewer}
// gates resource access per the role's bands
'use strict';
function canRead(req) {
if (req.isPlatformAdmin) return true;
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
return !!req.workspaceRole; // any workspace_member can read
}
function canWrite(req) {
if (req.isPlatformAdmin) return true;
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
return req.workspaceRole === 'workspace_admin' || req.workspaceRole === 'workspace_editor';
}
function canAdmin(req) {
if (req.isPlatformAdmin) return true;
if (req.orgRole === 'org_owner' || req.orgRole === 'org_admin') return true;
return req.workspaceRole === 'workspace_admin';
}
function isOrgAdmin(req) {
if (req.isPlatformAdmin) return true;
return req.orgRole === 'org_owner' || req.orgRole === 'org_admin';
}
function isOrgOwner(req) {
if (req.isPlatformAdmin) return true;
return req.orgRole === 'org_owner';
}
// ---- middleware variants ----
function requireWorkspace(req, res, next) {
if (!req.workspaceId) {
return res.status(403).json({ error: 'No workspace context' });
}
next();
}
function requireWorkspaceRead(req, res, next) {
if (!canRead(req)) {
return res.status(403).json({ error: 'Workspace access required' });
}
next();
}
function requireWorkspaceWrite(req, res, next) {
if (!canWrite(req)) {
return res.status(403).json({ error: 'Workspace editor or admin required' });
}
next();
}
function requireWorkspaceAdmin(req, res, next) {
if (!canAdmin(req)) {
return res.status(403).json({ error: 'Workspace admin required' });
}
next();
}
function requireOrgAdmin(req, res, next) {
if (!isOrgAdmin(req)) {
return res.status(403).json({ error: 'Organization admin required' });
}
next();
}
function requireOrgOwner(req, res, next) {
if (!isOrgOwner(req)) {
return res.status(403).json({ error: 'Organization owner required' });
}
next();
}
function requirePlatformAdmin(req, res, next) {
if (!req.user || req.user.role !== 'platform_admin') {
return res.status(403).json({ error: 'Platform admin required' });
}
next();
}
// Decoupled "can admin this workspace" predicate. Unlike canAdmin(req) above,
// this takes an explicit (user, workspace) pair instead of reading from req,
// so it works for routes that operate on a target workspace specified by URL
// param (rename, future settings/delete) rather than the caller's currently
// active one. Does its own DB lookups against workspace_members + organization_members.
function canAdminWorkspace(db, user, workspace) {
if (!user || !workspace) return false;
if (user.role === 'platform_admin' || user.role === 'superadmin') return true;
const om = db.prepare('SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?')
.get(workspace.organization_id, user.id);
if (om && (om.role === 'org_owner' || om.role === 'org_admin')) return true;
const wm = db.prepare('SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?')
.get(workspace.id, user.id);
return wm && wm.role === 'workspace_admin';
}
module.exports = {
// boolean predicates
canRead, canWrite, canAdmin, canAdminWorkspace, isOrgAdmin, isOrgOwner,
// express middleware
requireWorkspace,
requireWorkspaceRead,
requireWorkspaceWrite,
requireWorkspaceAdmin,
requireOrgAdmin,
requireOrgOwner,
requirePlatformAdmin,
};

View file

@ -0,0 +1,34 @@
// Phase 2.3: helpers for resolving socket.io room names per workspace /
// device / wall. Extracted from ws/dashboardSocket.js to break a circular
// dependency: dashboardSocket already requires services/heartbeat, so
// heartbeat can't require dashboardSocket. Everything goes through this
// neutral module instead.
const { db } = require('../db/database');
const ROOM_PREFIX = 'workspace:';
function workspaceRoom(workspaceId) {
return workspaceId ? ROOM_PREFIX + workspaceId : null;
}
function deviceRoom(deviceId) {
if (!deviceId) return null;
const d = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId);
return d?.workspace_id ? workspaceRoom(d.workspace_id) : null;
}
function wallRoom(wallId) {
if (!wallId) return null;
const w = db.prepare('SELECT workspace_id FROM video_walls WHERE id = ?').get(wallId);
return w?.workspace_id ? workspaceRoom(w.workspace_id) : null;
}
// Emit to a workspace room with no-op on missing room. Centralized so callers
// don't have to remember the "skip if null room" guard - silent drop is safer
// than the pre-2.3 platform-wide broadcast.
function emitToWorkspace(ns, room, event, payload) {
if (!room) return;
ns.to(room).emit(event, payload);
}
module.exports = { workspaceRoom, deviceRoom, wallRoom, emitToWorkspace };

171
server/lib/tenancy.js Normal file
View file

@ -0,0 +1,171 @@
// Phase 2.1: per-request tenancy resolver.
//
// Runs after requireAuth (which sets req.user and req.jwtWorkspaceId).
// Resolves the active workspace context for this request and attaches:
//
// req.workspaceId string | null the workspace this request operates in
// req.workspace object | null the full workspaces row
// req.organizationId string | null parent org of req.workspace
// req.workspaceRole string | null 'workspace_admin' | 'workspace_editor' | 'workspace_viewer'
// req.orgRole string | null 'org_owner' | 'org_admin'
// req.isPlatformAdmin boolean shortcut for req.user.role === 'platform_admin'
// req.actingAs boolean true when the user reached this workspace via
// org-level or platform-level access rather than
// a direct workspace_members row
//
// Resolution order, top wins:
// 1. X-Workspace-Id header (for explicit per-request override)
// 2. ?workspace_id= query param (same purpose, easier in browser dev)
// 3. JWT current_workspace_id (the user's last switched-to workspace)
// 4. First workspace_members row for user (sorted by joined_at ASC)
// 5. For platform_admin only: any workspace
//
// Steps 1-3 are validated against access. If a stale value (e.g. user was
// removed from the workspace) is found, it's discarded and we fall through.
'use strict';
const { db } = require('../db/database');
function membershipOf(userId, workspaceId) {
return db.prepare(
'SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?'
).get(workspaceId, userId);
}
function orgMembershipOf(userId, organizationId) {
return db.prepare(
'SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?'
).get(organizationId, userId);
}
function loadWorkspace(workspaceId) {
if (!workspaceId) return null;
return db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspaceId);
}
function firstAccessibleWorkspace(userId) {
return db.prepare(`
SELECT w.* FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ?
ORDER BY wm.joined_at ASC
LIMIT 1
`).get(userId);
}
// Check whether userId can access workspace via any path (member, org admin,
// or platform admin). Returns the access context: { workspaceRole, actingAs }
// or null if no access.
function accessContext(userId, role, workspace) {
const isPlatformAdmin = role === 'platform_admin';
const wsMembership = membershipOf(userId, workspace.id);
if (wsMembership) {
return { workspaceRole: wsMembership.role, actingAs: false };
}
const orgMembership = orgMembershipOf(userId, workspace.organization_id);
if (orgMembership && (orgMembership.role === 'org_owner' || orgMembership.role === 'org_admin')) {
return { workspaceRole: null, actingAs: true };
}
if (isPlatformAdmin) {
return { workspaceRole: null, actingAs: true };
}
return null;
}
function resolveTenancy(req, res, next) {
if (!req.user) {
// Should not happen when chained after requireAuth, but tolerate optionalAuth flows.
return next();
}
const isPlatformAdmin = req.user.role === 'platform_admin';
req.isPlatformAdmin = isPlatformAdmin;
// Build the ordered candidate list of workspace_ids to try.
const candidates = [];
const headerWs = (req.headers['x-workspace-id'] || '').trim();
if (headerWs) candidates.push(headerWs);
if (req.query && req.query.workspace_id) candidates.push(String(req.query.workspace_id));
if (req.jwtWorkspaceId) candidates.push(req.jwtWorkspaceId);
let workspace = null;
let context = null;
for (const wsId of candidates) {
const ws = loadWorkspace(wsId);
if (!ws) continue;
const ctx = accessContext(req.user.id, req.user.role, ws);
if (!ctx) continue;
workspace = ws;
context = ctx;
break;
}
if (!workspace) {
// Fall back to the user's first workspace_members row.
const first = firstAccessibleWorkspace(req.user.id);
if (first) {
workspace = first;
const wm = membershipOf(req.user.id, first.id);
context = { workspaceRole: wm.role, actingAs: false };
} else if (isPlatformAdmin) {
// Platform admin with no direct memberships: pick any workspace (acting-as).
const any = db.prepare('SELECT * FROM workspaces LIMIT 1').get();
if (any) {
workspace = any;
context = { workspaceRole: null, actingAs: true };
}
}
}
if (workspace) {
req.workspaceId = workspace.id;
req.workspace = workspace;
req.organizationId = workspace.organization_id;
req.workspaceRole = context.workspaceRole;
req.actingAs = context.actingAs;
const orgMembership = orgMembershipOf(req.user.id, workspace.organization_id);
req.orgRole = orgMembership ? orgMembership.role : null;
} else {
req.workspaceId = null;
req.workspace = null;
req.organizationId = null;
req.workspaceRole = null;
req.orgRole = null;
req.actingAs = false;
}
next();
}
// Enumerate every workspace_id the given user has any path into:
// - direct workspace_members rows
// - any workspace in an org where they are org_owner / org_admin
// - platform_admin / superadmin: every workspace in the system
// Used by socket.io rooms (Phase 2.3) to scope outbound broadcasts. Also a
// candidate to broaden /me's accessible_workspaces query - currently /me only
// returns direct workspace_members for non-admins, missing the org-admin
// path. Future cleanup tracked in the handoff doc.
function accessibleWorkspaceIds(userId, role) {
if (!userId) return [];
if (role === 'platform_admin' || role === 'superadmin') {
return db.prepare('SELECT id FROM workspaces').all().map(r => r.id);
}
return db.prepare(`
SELECT workspace_id AS id FROM workspace_members WHERE user_id = ?
UNION
SELECT w.id FROM workspaces w
JOIN organization_members om ON om.organization_id = w.organization_id
WHERE om.user_id = ? AND om.role IN ('org_owner', 'org_admin')
`).all(userId, userId).map(r => r.id);
}
module.exports = {
resolveTenancy,
// Exported for testing / direct use by routes that need ad-hoc checks.
accessContext,
membershipOf,
orgMembershipOf,
firstAccessibleWorkspace,
accessibleWorkspaceIds,
};

View file

@ -2,9 +2,14 @@ const jwt = require('jsonwebtoken');
const config = require('../config'); const config = require('../config');
const { db } = require('../db/database'); const { db } = require('../db/database');
function generateToken(user) { // Phase 2.1: JWT now optionally carries the user's current workspace_id so
// the tenancy middleware can resolve scope without an extra DB lookup on
// every request. Callers that don't know the workspace yet (legacy paths,
// recovery tokens) pass null and the tenancy resolver falls back to the
// user's first accessible workspace.
function generateToken(user, currentWorkspaceId) {
return jwt.sign( return jwt.sign(
{ id: user.id, email: user.email, role: user.role }, { id: user.id, email: user.email, role: user.role, current_workspace_id: currentWorkspaceId || null },
config.jwtSecret, config.jwtSecret,
{ algorithm: 'HS256', expiresIn: config.jwtExpiry } { algorithm: 'HS256', expiresIn: config.jwtExpiry }
); );
@ -14,6 +19,20 @@ function verifyToken(token) {
return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] }); return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
} }
// Synthetic user record for recovery tokens (scripts/reset-admin.js). Not
// persisted; only exists for the lifetime of the request.
function recoveryUser(decoded) {
return {
id: decoded.id,
email: decoded.email || 'admin@localhost',
name: 'Recovery Admin',
role: decoded.role || 'admin',
auth_provider: 'recovery',
avatar_url: null,
plan_id: 'enterprise'
};
}
// Express middleware - requires valid JWT // Express middleware - requires valid JWT
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
@ -24,9 +43,16 @@ function requireAuth(req, res, next) {
try { try {
const token = authHeader.split(' ')[1]; const token = authHeader.split(' ')[1];
const decoded = verifyToken(token); const decoded = verifyToken(token);
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id); if (decoded.recovery) {
req.user = recoveryUser(decoded);
req.jwtWorkspaceId = null;
return next();
}
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts FROM users WHERE id = ?').get(decoded.id);
if (!user) return res.status(401).json({ error: 'User not found' }); if (!user) return res.status(401).json({ error: 'User not found' });
req.user = user; req.user = user;
// Tenancy middleware reads this on the resolver step.
req.jwtWorkspaceId = decoded.current_workspace_id || null;
next(); next();
} catch (err) { } catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' }); return res.status(401).json({ error: 'Invalid or expired token' });
@ -40,7 +66,10 @@ function optionalAuth(req, res, next) {
try { try {
const token = authHeader.split(' ')[1]; const token = authHeader.split(' ')[1];
const decoded = verifyToken(token); const decoded = verifyToken(token);
req.user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id); req.user = decoded.recovery
? recoveryUser(decoded)
: db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id);
req.jwtWorkspaceId = decoded.current_workspace_id || null;
} catch (err) { } catch (err) {
// Token invalid, continue without user // Token invalid, continue without user
} }
@ -48,20 +77,30 @@ function optionalAuth(req, res, next) {
next(); next();
} }
// Require admin role (admin or superadmin) // Phase 2.1: role rename. Phase 1 renamed 'superadmin' to 'platform_admin' and
// dropped the in-between 'admin' role. These two guards are widened to accept
// either spelling so existing callers keep working without per-route edits.
// New code should prefer requirePlatformAdmin / requireOrgAdmin / workspace
// role guards from server/lib/permissions.js.
const PLATFORM_ROLES = ['superadmin', 'platform_admin'];
const ELEVATED_ROLES = ['admin', 'superadmin', 'platform_admin'];
function requireAdmin(req, res, next) { function requireAdmin(req, res, next) {
if (!req.user || !['admin', 'superadmin'].includes(req.user.role)) { if (!req.user || !ELEVATED_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Admin access required' }); return res.status(403).json({ error: 'Admin access required' });
} }
next(); next();
} }
// Require superadmin role (platform owner only)
function requireSuperAdmin(req, res, next) { function requireSuperAdmin(req, res, next) {
if (!req.user || req.user.role !== 'superadmin') { if (!req.user || !PLATFORM_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Platform admin access required' }); return res.status(403).json({ error: 'Platform admin access required' });
} }
next(); next();
} }
module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin }; // Preferred alias for new code.
const requirePlatformAdmin = requireSuperAdmin;
module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, PLATFORM_ROLES, ELEVATED_ROLES };

View file

@ -8,6 +8,21 @@ const storage = multer.diskStorage({
cb(null, config.contentDir); cb(null, config.contentDir);
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
// busboy decodes the Content-Disposition filename header as latin1 by
// default. Modern clients send raw UTF-8 bytes for non-ASCII filenames
// (e.g. browsers + curl on UTF-8 locales send "Begrussungsscreens.jpg"
// with c3 bc for u-umlaut). Reading those bytes as latin1 produces the
// string "A-tilde + quarter-mark" which JS then re-encodes as 4 UTF-8
// bytes on the way to the DB - classic double-encoding mojibake.
//
// The `defParamCharset: 'utf8'` option below only takes effect for
// RFC 5987 encoded `filename*=...` params, which most clients don't send.
// For the plain `filename="..."` case, re-decode here to recover the
// original UTF-8 byte sequence. Mutating originalname here propagates to
// every downstream consumer (route handlers reading req.file.originalname).
if (file.originalname) {
file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8');
}
const ext = path.extname(file.originalname); const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`); cb(null, `${uuidv4()}${ext}`);
} }
@ -26,10 +41,17 @@ const fileFilter = (req, file, cb) => {
} }
}; };
// `defParamCharset: 'utf8'` only takes effect for RFC 5987 encoded
// `filename*=utf-8''...` params. Most real clients (browsers, curl, programmatic
// HTTP) send the plain `filename="..."` form, where busboy still reads the bytes
// as latin1 regardless of this option. The actual UTF-8 recovery happens in the
// storage.filename callback above via Buffer.from(name,'latin1').toString('utf8').
// Kept here as defense-in-depth for the rare RFC 5987 case.
const upload = multer({ const upload = multer({
storage, storage,
fileFilter, fileFilter,
limits: { fileSize: config.maxFileSize } limits: { fileSize: config.maxFileSize },
defParamCharset: 'utf8'
}); });
module.exports = upload; module.exports = upload;

View file

@ -8,6 +8,7 @@
"name": "remote-display-server", "name": "remote-display-server",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@azure/msal-node": "^5.2.1",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
@ -22,7 +23,29 @@
"socket.io": "^4.7.2", "socket.io": "^4.7.2",
"stripe": "^20.4.1", "stripe": "^20.4.1",
"unzipper": "^0.12.3", "unzipper": "^0.12.3",
"uuid": "^9.0.0" "uuid": "^14.0.0"
}
},
"node_modules/@azure/msal-common": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.1.tgz",
"integrity": "sha512-VxKdEtUwDuLD0F1hOQP7kye0YadZxFJfv37Em440geEf/w9uggKnHpRrqwZJOdxmPUOdhZ9kyRtKuAJW8wUcRg==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-node": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.1.tgz",
"integrity": "sha512-tmQiQ2HvtzaeLqYGy3BemiPOSGPY4wCy1IW5zDWITKSs/s35WEd7Zij/hCxvUdAOzj6U3qnyaGbYXY91ortFEQ==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "16.6.1",
"jsonwebtoken": "^9.0.0"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
@ -2555,9 +2578,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
@ -3443,16 +3466,16 @@
} }
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "9.0.1", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist-node/bin/uuid"
} }
}, },
"node_modules/vary": { "node_modules/vary": {

View file

@ -4,10 +4,11 @@
"description": "ScreenTinker - Digital Signage Management Server", "description": "ScreenTinker - Digital Signage Management Server",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node --env-file-if-exists=.env server.js",
"dev": "node --watch server.js" "dev": "node --watch --env-file-if-exists=.env server.js"
}, },
"dependencies": { "dependencies": {
"@azure/msal-node": "^5.2.1",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
@ -22,6 +23,6 @@
"socket.io": "^4.7.2", "socket.io": "^4.7.2",
"stripe": "^20.4.1", "stripe": "^20.4.1",
"unzipper": "^0.12.3", "unzipper": "^0.12.3",
"uuid": "^9.0.0" "uuid": "^14.0.0"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,55 +1,52 @@
const CACHE_NAME = 'rd-player-v3'; const CACHE_NAME = 'rd-player-v9';
const CONTENT_CACHE = 'rd-content-v1';
// Install: skip waiting to activate immediately // Install: skip waiting to activate immediately
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
self.skipWaiting(); self.skipWaiting();
}); });
// Activate: clean old caches, claim clients // Activate: clean old caches (including old content cache), claim clients
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then(keys => Promise.all( caches.keys().then(keys => Promise.all(
keys.filter(k => k !== CACHE_NAME && k !== CONTENT_CACHE).map(k => caches.delete(k)) keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
)).then(() => self.clients.claim()) )).then(() => self.clients.claim())
); );
}); });
// Fetch handler // Fetch handler — ONLY cache player page and static assets.
// Content files (/uploads/content/) are NOT intercepted — the server sets
// Cache-Control: public, max-age=2592000, immutable which lets the browser
// cache them natively without SW complications (range requests, opaque
// responses, video seeking, etc.)
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url); // Only handle GET requests
if (event.request.method !== 'GET') return;
// Content files (videos, images): cache on first fetch for offline playback const url = new URL(event.request.url);
if (url.pathname.startsWith('/uploads/content/')) {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CONTENT_CACHE).then(cache => cache.put(event.request, clone));
}
return response;
}).catch(() => new Response('Offline', { status: 503 }));
})
);
return;
}
// Player page and static assets: network-first, fall back to cache // Player page and static assets: network-first, fall back to cache
if (url.pathname.startsWith('/player') || url.pathname === '/socket.io/socket.io.js') { if (url.pathname.startsWith('/player') || url.pathname === '/socket.io/socket.io.js') {
event.respondWith( event.respondWith(
fetch(event.request).then(response => { fetch(event.request).then(response => {
if (response.ok) { if (response.ok && response.type !== 'opaque') {
const clone = response.clone(); const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
} }
return response; return response;
}).catch(() => caches.match(event.request).then(cached => cached || new Response('Offline', { status: 503 }))) }).catch(() =>
caches.match(event.request, { ignoreSearch: true }).then(cached =>
cached || new Response('Offline', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain' }
})
)
)
); );
return; return;
} }
// Everything else: network only // Everything else (content files, API calls, etc.): don't intercept.
event.respondWith(fetch(event.request)); // Returning without event.respondWith lets the browser handle it natively.
}); });

View file

@ -1,11 +1,12 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getActivity, pruneActivityLog } = require('../services/activity'); const { getActivity, pruneActivityLog } = require('../services/activity');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Get activity log // Get activity log
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { device_id, limit, offset } = req.query; const { device_id, limit, offset } = req.query;
const isAdmin = req.user.role === 'superadmin'; const isAdmin = PLATFORM_ROLES.includes(req.user.role);
const activity = getActivity({ const activity = getActivity({
userId: isAdmin ? null : req.user.id, userId: isAdmin ? null : req.user.id,
@ -19,7 +20,7 @@ router.get('/', (req, res) => {
// Prune old logs (admin only) // Prune old logs (admin only)
router.delete('/prune', (req, res) => { router.delete('/prune', (req, res) => {
if (!['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Admin only' }); if (!ELEVATED_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Admin only' });
pruneActivityLog(); pruneActivityLog();
res.json({ success: true }); res.json({ success: true });
}); });

View file

@ -2,47 +2,49 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2j: workspace-aware access. Underlying tables (devices, playlists)
// already carry workspace_id from Phase 1; this route can use them even
// though playlists.js itself isn't yet workspace-filtered.
const { accessContext } = require('../lib/tenancy');
// Push playlist update to a connected device via WebSocket // Mark playlist as draft (called after any item mutation)
function pushPlaylistToDevice(req, deviceId) { function markDraft(playlistId) {
try { db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
const io = req.app.get('io');
if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket');
if (!buildPlaylistPayload) return;
const deviceNs = io.of('/device');
deviceNs.to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
} catch (e) {
console.warn('Failed to push playlist update:', e.message);
}
} }
// Check device ownership for device-scoped routes // Phase 2.2j: workspace-aware device access check. Returns access context
function checkDeviceAccess(req, res) { // (with workspaceRole/actingAs) or null. Caller decides if read or write.
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); function checkDeviceAccess(req, res, paramName = 'deviceId', requireWrite = true) {
if (!device) { res.status(404).json({ error: 'Device not found' }); return false; } const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(req.params[paramName]);
if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { if (!device) { res.status(404).json({ error: 'Device not found' }); return null; }
res.status(403).json({ error: 'Access denied' }); return false; if (!device.workspace_id) { res.status(403).json({ error: 'Device not assigned to a workspace' }); return null; }
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
if (requireWrite && !ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
} }
return true; return { device, ctx };
} }
// Ensure device has a playlist; auto-create one if missing // Ensure device has a playlist; auto-create one if missing.
// Phase 2.2j: stamps workspace_id on the auto-created playlist so it remains
// visible once playlists.js migrates. Mirrors the 2.2i fix in device-groups.js.
function ensureDevicePlaylist(deviceId, userId) { function ensureDevicePlaylist(deviceId, userId) {
const device = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(deviceId); const device = db.prepare('SELECT playlist_id, workspace_id, name FROM devices WHERE id = ?').get(deviceId);
if (device?.playlist_id) return device.playlist_id; if (device?.playlist_id) return device.playlist_id;
const deviceRow = db.prepare('SELECT name FROM devices WHERE id = ?').get(deviceId);
const playlistId = uuidv4(); const playlistId = uuidv4();
db.prepare('INSERT INTO playlists (id, user_id, name, is_auto_generated) VALUES (?, ?, ?, 1)') db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, is_auto_generated) VALUES (?, ?, ?, ?, 1)')
.run(playlistId, userId, `${deviceRow?.name || 'Display'} playlist`); .run(playlistId, userId, device?.workspace_id || null, `${device?.name || 'Display'} playlist`);
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId); db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId);
return playlistId; return playlistId;
} }
// Standard item query with joined content/widget info // Standard item query with joined content/widget info
const ITEM_SELECT = ` const ITEM_SELECT = `
SELECT pi.id, pi.playlist_id, pi.content_id, pi.widget_id, pi.sort_order, pi.duration_sec, SELECT pi.id, pi.playlist_id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
pi.created_at, pi.updated_at, pi.created_at, pi.updated_at,
COALESCE(c.filename, w.name) as filename, COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path, c.mime_type, c.filepath, c.thumbnail_path,
@ -55,7 +57,7 @@ const ITEM_SELECT = `
// Get assignments (playlist items) for a device // Get assignments (playlist items) for a device
router.get('/device/:deviceId', (req, res) => { router.get('/device/:deviceId', (req, res) => {
if (!checkDeviceAccess(req, res)) return; if (!checkDeviceAccess(req, res, 'deviceId', false)) return;
const device = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId); const device = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device?.playlist_id) return res.json([]); if (!device?.playlist_id) return res.json([]);
@ -64,23 +66,35 @@ router.get('/device/:deviceId', (req, res) => {
res.json(items); res.json(items);
}); });
// Add content or widget to device playlist // Add content or widget to device playlist.
// Phase 2.2j: closes 2 pre-existing cross-tenant leaks:
// 1. Content gate: today checks content.user_id == caller. A workspace_admin
// who happens to own content in another workspace could push it into a
// device in this workspace. Now: content must be in device's workspace
// (or be a platform-template, workspace_id IS NULL).
// 2. Widget gate: today checks ONLY existence - any user could attach any
// widget UUID to their own device's playlist. Now: widget must be in
// device's workspace (or be a platform-template).
router.post('/device/:deviceId', (req, res) => { router.post('/device/:deviceId', (req, res) => {
if (!checkDeviceAccess(req, res)) return; const access = checkDeviceAccess(req, res, 'deviceId', true);
if (!access) return;
const { content_id, widget_id, zone_id, duration_sec = 10, sort_order } = req.body; const { content_id, widget_id, zone_id, duration_sec = 10, sort_order } = req.body;
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' }); if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
if (content_id) { if (content_id) {
const content = db.prepare('SELECT id, user_id FROM content WHERE id = ?').get(content_id); const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(content_id);
if (!content) return res.status(404).json({ error: 'Content not found' }); if (!content) return res.status(404).json({ error: 'Content not found' });
if (!['admin','superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) { if (content.workspace_id && content.workspace_id !== access.device.workspace_id) {
return res.status(403).json({ error: 'Content not owned by you' }); return res.status(403).json({ error: 'Content is not in this device\'s workspace' });
} }
} }
if (widget_id) { if (widget_id) {
const widget = db.prepare('SELECT id FROM widgets WHERE id = ?').get(widget_id); const widget = db.prepare('SELECT id, workspace_id FROM widgets WHERE id = ?').get(widget_id);
if (!widget) return res.status(404).json({ error: 'Widget not found' }); if (!widget) return res.status(404).json({ error: 'Widget not found' });
if (widget.workspace_id && widget.workspace_id !== access.device.workspace_id) {
return res.status(403).json({ error: 'Widget is not in this device\'s workspace' });
}
} }
const playlistId = ensureDevicePlaylist(req.params.deviceId, req.user.id); const playlistId = ensureDevicePlaylist(req.params.deviceId, req.user.id);
@ -94,14 +108,13 @@ router.post('/device/:deviceId', (req, res) => {
try { try {
const result = db.prepare(` const result = db.prepare(`
INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(playlistId, content_id || null, widget_id || null, order, duration_sec); `).run(playlistId, content_id || null, widget_id || null, zone_id || null, order, duration_sec);
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(playlistId); markDraft(playlistId);
const item = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(result.lastInsertRowid); const item = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(result.lastInsertRowid);
pushPlaylistToDevice(req, req.params.deviceId);
res.status(201).json(item); res.status(201).json(item);
} catch (err) { } catch (err) {
if (err.message.includes('UNIQUE')) { if (err.message.includes('UNIQUE')) {
@ -111,10 +124,25 @@ router.post('/device/:deviceId', (req, res) => {
} }
}); });
// Helper: load a playlist item and check write access via the parent
// playlist's workspace. Returns the item row or null after sending 403/404.
function checkItemWrite(req, res) {
const item = db.prepare('SELECT pi.*, p.workspace_id AS pl_workspace_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id);
if (!item) { res.status(404).json({ error: 'Item not found' }); return null; }
if (!item.pl_workspace_id) { res.status(403).json({ error: 'Playlist not assigned to a workspace' }); return null; }
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(item.pl_workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
}
return item;
}
// Update playlist item // Update playlist item
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const item = db.prepare('SELECT pi.*, p.user_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id); const item = checkItemWrite(req, res);
if (!item) return res.status(404).json({ error: 'Item not found' }); if (!item) return;
const { sort_order, duration_sec, zone_id } = req.body; const { sort_order, duration_sec, zone_id } = req.body;
const updates = []; const updates = [];
@ -122,41 +150,35 @@ router.put('/:id', (req, res) => {
if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); } if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); }
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(duration_sec); } if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(duration_sec); }
// zone_id can be null (clear the zone) - treat undefined as "no change",
// any other value (including null) as "write this".
if (zone_id !== undefined) { updates.push('zone_id = ?'); values.push(zone_id || null); }
if (updates.length > 0) { if (updates.length > 0) {
updates.push("updated_at = strftime('%s','now')"); updates.push("updated_at = strftime('%s','now')");
values.push(req.params.id); values.push(req.params.id);
db.prepare(`UPDATE playlist_items SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE playlist_items SET ${updates.join(', ')} WHERE id = ?`).run(...values);
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(item.playlist_id); markDraft(item.playlist_id);
} }
const updated = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(req.params.id); const updated = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(req.params.id);
// Push to any device using this playlist
const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(item.playlist_id);
for (const d of devices) pushPlaylistToDevice(req, d.id);
res.json(updated); res.json(updated);
}); });
// Delete playlist item // Delete playlist item
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const item = db.prepare('SELECT pi.*, p.user_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id); const item = checkItemWrite(req, res);
if (!item) return res.status(404).json({ error: 'Item not found' }); if (!item) return;
db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.id);
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(item.playlist_id); markDraft(item.playlist_id);
// Push to any device using this playlist
const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(item.playlist_id);
for (const d of devices) pushPlaylistToDevice(req, d.id);
res.json({ success: true, content_id: item.content_id }); res.json({ success: true, content_id: item.content_id });
}); });
// Reorder items for a device's playlist // Reorder items for a device's playlist
router.post('/device/:deviceId/reorder', (req, res) => { router.post('/device/:deviceId/reorder', (req, res) => {
if (!checkDeviceAccess(req, res)) return; if (!checkDeviceAccess(req, res, 'deviceId', true)) return;
const { order } = req.body; const { order } = req.body;
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item IDs' }); if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item IDs' });
@ -171,16 +193,28 @@ router.post('/device/:deviceId/reorder', (req, res) => {
}); });
transaction(); transaction();
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(device.playlist_id); markDraft(device.playlist_id);
const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`) const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`)
.all(device.playlist_id); .all(device.playlist_id);
pushPlaylistToDevice(req, req.params.deviceId);
res.json(items); res.json(items);
}); });
// Copy playlist from one device to another // Copy playlist from one device to another.
// Phase 2.2j: closes a pre-existing cross-tenant leak. Today both deviceIds
// only got the user_id ownership check; a caller with reach into a foreign
// workspace could copy that workspace's playlist into a device in their own
// workspace (or vice versa). Now: both devices must be in the same workspace,
// and the caller must have write access there.
router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => { router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
const sourceAccess = checkDeviceAccess(req, res, 'deviceId', true);
if (!sourceAccess) return;
const targetAccess = checkDeviceAccess(req, res, 'targetDeviceId', true);
if (!targetAccess) return;
if (sourceAccess.device.workspace_id !== targetAccess.device.workspace_id) {
return res.status(403).json({ error: 'Source and target devices must be in the same workspace' });
}
const sourceDevice = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId); const sourceDevice = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!sourceDevice?.playlist_id) return res.status(404).json({ error: 'Source device has no playlist' }); if (!sourceDevice?.playlist_id) return res.status(404).json({ error: 'Source device has no playlist' });
@ -199,17 +233,16 @@ router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM playlist_items WHERE playlist_id = ?') const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM playlist_items WHERE playlist_id = ?')
.get(targetPlaylistId).m || 0; .get(targetPlaylistId).m || 0;
const stmt = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)'); const stmt = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?, ?)');
const transaction = db.transaction(() => { const transaction = db.transaction(() => {
sourceItems.forEach((a, i) => { sourceItems.forEach((a, i) => {
stmt.run(targetPlaylistId, a.content_id, a.widget_id, maxOrder + i + 1, a.duration_sec); stmt.run(targetPlaylistId, a.content_id, a.widget_id, a.zone_id || null, maxOrder + i + 1, a.duration_sec);
}); });
}); });
transaction(); transaction();
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(targetPlaylistId); markDraft(targetPlaylistId);
pushPlaylistToDevice(req, req.params.targetDeviceId);
res.json({ success: true, copied: sourceItems.length }); res.json({ success: true, copied: sourceItems.length });
}); });

View file

@ -5,9 +5,48 @@ const https = require('https');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { OAuth2Client } = require('google-auth-library'); const { OAuth2Client } = require('google-auth-library');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { generateToken, requireAuth, requireAdmin, requireSuperAdmin } = require('../middleware/auth'); const { generateToken, requireAuth, requireAdmin, requireSuperAdmin, PLATFORM_ROLES } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const { logActivity, getClientIp } = require('../services/activity');
const config = require('../config'); const config = require('../config');
// Phase 2.1: find or create the user's default org+workspace. Returns the
// workspace_id to embed in the JWT. Idempotent: if the user already has
// memberships (e.g. migrated from Phase 1), returns the first one without
// creating anything.
function ensureDefaultOrgForUser(user) {
const existing = db.prepare(`
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ?
ORDER BY wm.joined_at ASC LIMIT 1
`).get(user.id);
if (existing) return existing.id;
// No memberships -> mint a fresh org and Default workspace owned by user.
const orgId = uuidv4();
const wsId = uuidv4();
const orgName = (user.name && user.name.trim())
? `${user.name}'s organization`
: `${user.email}'s organization`;
const tx = db.transaction(() => {
db.prepare(`INSERT INTO organizations (
id, name, owner_user_id, plan_id,
stripe_customer_id, stripe_subscription_id,
subscription_status, subscription_ends
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
orgId, orgName, user.id, user.plan_id || 'free',
user.stripe_customer_id || null, user.stripe_subscription_id || null,
user.subscription_status || 'active', user.subscription_ends || null
);
db.prepare(`INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_owner')`).run(orgId, user.id);
db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, 'Default', ?)`).run(wsId, orgId, user.id);
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(wsId, user.id);
});
tx();
return wsId;
}
function logFailedLogin(email, ip, reason) { function logFailedLogin(email, ip, reason) {
try { try {
db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (NULL, ?, ?, ?)') db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (NULL, ?, ?, ?)')
@ -17,16 +56,34 @@ function logFailedLogin(email, ip, reason) {
function logSuccessfulLogin(userId, email, ip) { function logSuccessfulLogin(userId, email, ip) {
try { try {
db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)') // Phase 2.2 writer-leak fix: stamp the user's oldest workspace so this
.run(userId, 'auth:login_success', email, ip); // login event is queryable in tenant-scoped activity views. Multi-workspace
// users still land on one row; the activity dashboard already shows
// per-user context separately from per-workspace context.
const ws = db.prepare(
'SELECT workspace_id FROM workspace_members WHERE user_id = ? ORDER BY joined_at ASC LIMIT 1'
).get(userId);
db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address, workspace_id) VALUES (?, ?, ?, ?, ?)')
.run(userId, 'auth:login_success', email, ip, ws?.workspace_id || null);
db.prepare("UPDATE users SET last_login = strftime('%s','now') WHERE id = ?").run(userId); db.prepare("UPDATE users SET last_login = strftime('%s','now') WHERE id = ?").run(userId);
} catch {} } catch {}
} }
// ==================== Local Auth ==================== // ==================== Local Auth ====================
// Returns true if new account creation is allowed at this moment.
// First-user setup (empty DB) is always allowed so a fresh install can be initialized.
function canRegister() {
if (!config.disableRegistration) return true;
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
return userCount === 0;
}
// Register // Register
router.post('/register', (req, res) => { router.post('/register', (req, res) => {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const { email, password, name } = req.body; const { email, password, name } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
@ -37,9 +94,10 @@ router.post('/register', (req, res) => {
const id = uuidv4(); const id = uuidv4();
const passwordHash = bcrypt.hashSync(password, 10); const passwordHash = bcrypt.hashSync(password, 10);
// First user becomes admin with enterprise plan (self-hosted) or free plan with Pro trial // First user becomes platform_admin with enterprise plan (self-hosted) or free plan with Pro trial.
// Phase 1 renamed the legacy 'superadmin' role to 'platform_admin'; new bootstrap users get the new name directly.
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user'; const role = userCount === 0 ? 'platform_admin' : 'user';
const isFirstUser = userCount === 0; const isFirstUser = userCount === 0;
const plan = (isFirstUser && config.selfHosted) ? 'enterprise' : 'pro'; // Start on Pro trial const plan = (isFirstUser && config.selfHosted) ? 'enterprise' : 'pro'; // Start on Pro trial
const trialStarted = isFirstUser && config.selfHosted ? null : Math.floor(Date.now() / 1000); const trialStarted = isFirstUser && config.selfHosted ? null : Math.floor(Date.now() / 1000);
@ -49,10 +107,11 @@ router.post('/register', (req, res) => {
VALUES (?, ?, ?, ?, 'local', ?, ?, ?, ?) VALUES (?, ?, ?, ?, 'local', ?, ?, ?, ?)
`).run(id, email.toLowerCase(), name || email.split('@')[0], passwordHash, role, plan, trialStarted, trialStarted ? 'pro' : null); `).run(id, email.toLowerCase(), name || email.split('@')[0], passwordHash, role, plan, trialStarted, trialStarted ? 'pro' : null);
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(id); const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, stripe_customer_id, stripe_subscription_id, subscription_status, subscription_ends FROM users WHERE id = ?').get(id);
const token = generateToken(user); const workspaceId = ensureDefaultOrgForUser(user);
const token = generateToken(user, workspaceId);
res.status(201).json({ token, user }); res.status(201).json({ token, user, current_workspace_id: workspaceId });
}); });
// Login // Login
@ -62,19 +121,20 @@ router.post('/login', (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE email = ? AND auth_provider = ?').get(email.toLowerCase(), 'local'); const user = db.prepare('SELECT * FROM users WHERE email = ? AND auth_provider = ?').get(email.toLowerCase(), 'local');
if (!user) { if (!user) {
logFailedLogin(email, req.ip, 'User not found'); logFailedLogin(email, getClientIp(req), 'User not found');
return res.status(401).json({ error: 'Invalid email or password' }); return res.status(401).json({ error: 'Invalid email or password' });
} }
if (!bcrypt.compareSync(password, user.password_hash)) { if (!bcrypt.compareSync(password, user.password_hash)) {
logFailedLogin(email, req.ip, 'Wrong password'); logFailedLogin(email, getClientIp(req), 'Wrong password');
return res.status(401).json({ error: 'Invalid email or password' }); return res.status(401).json({ error: 'Invalid email or password' });
} }
logSuccessfulLogin(user.id, email, req.ip); logSuccessfulLogin(user.id, email, getClientIp(req));
const token = generateToken(user); const workspaceId = ensureDefaultOrgForUser(user);
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user; const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser }); res.json({ token, user: safeUser, current_workspace_id: workspaceId });
}); });
// ==================== Google OAuth ==================== // ==================== Google OAuth ====================
@ -94,9 +154,12 @@ router.post('/google', async (req, res) => {
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()); let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
if (!user) { if (!user) {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const id = uuidv4(); const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user'; const role = userCount === 0 ? 'platform_admin' : 'user';
const isFirst = userCount === 0; const isFirst = userCount === 0;
const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro'; const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000); const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000);
@ -119,9 +182,10 @@ router.post('/google', async (req, res) => {
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
} }
const token = generateToken(user); const workspaceId = ensureDefaultOrgForUser(user);
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user; const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser }); res.json({ token, user: safeUser, current_workspace_id: workspaceId });
} catch (err) { } catch (err) {
console.error('Google auth error:', err); console.error('Google auth error:', err);
res.status(401).json({ error: 'Google authentication failed' }); res.status(401).json({ error: 'Google authentication failed' });
@ -169,9 +233,12 @@ router.post('/microsoft', async (req, res) => {
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email); let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) { if (!user) {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const id = uuidv4(); const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user'; const role = userCount === 0 ? 'platform_admin' : 'user';
const isFirst = userCount === 0; const isFirst = userCount === 0;
const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro'; const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000); const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000);
@ -192,9 +259,10 @@ router.post('/microsoft', async (req, res) => {
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
} }
const token = generateToken(user); const workspaceId = ensureDefaultOrgForUser(user);
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user; const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser }); res.json({ token, user: safeUser, current_workspace_id: workspaceId });
} catch (err) { } catch (err) {
console.error('Microsoft auth error:', err); console.error('Microsoft auth error:', err);
res.status(401).json({ error: 'Microsoft authentication failed' }); res.status(401).json({ error: 'Microsoft authentication failed' });
@ -220,30 +288,136 @@ function getMicrosoftProfile(accessToken) {
// ==================== User Management ==================== // ==================== User Management ====================
// Get current user // Get current user + tenancy context.
router.get('/me', requireAuth, (req, res) => { // Phase 2.1: response shape extended with current_workspace, current_organization,
res.json(req.user); // roles, and the list of accessible workspaces. Legacy fields (user object at
// the top level) are preserved so existing frontend code continues to work.
router.get('/me', requireAuth, resolveTenancy, (req, res) => {
// Platform admins see every workspace in the system (via the LEFT JOIN they
// still get their own workspace_role for direct memberships; NULL elsewhere,
// matching accessContext's actingAs semantics). Regular users see only
// workspaces they have a direct workspace_members row in. Role is read from
// the signed JWT (not user-supplied), so non-admins cannot reach the admin
// branch. No cap on the admin list yet - revisit at 50+ workspaces when
// dropdown UX without search starts to degrade.
//
// Each accessible_workspaces entry also carries `can_admin: bool` so the
// UI can render admin affordances (rename pencil etc.) only where the
// caller has permission. The server still enforces permission on the
// actual mutation routes regardless of this advisory flag.
// device_count: correlated subquery on workspaces.id. Equality fails on NULL
// so unclaimed pair-pool devices (workspace_id IS NULL) are correctly excluded.
// Microseconds per row at current scale (~37 rows worst case for platform_admin);
// not optimizing - revisit if the admin list grows past a few hundred workspaces.
const isPlatformAdmin = req.user.role === 'platform_admin' || req.user.role === 'superadmin';
const accessible = isPlatformAdmin
? db.prepare(`
SELECT w.id, w.name, w.organization_id, o.name AS organization_name,
wm.role AS workspace_role, om.role AS org_role,
(SELECT COUNT(*) FROM devices WHERE workspace_id = w.id) AS device_count
FROM workspaces w
JOIN organizations o ON o.id = w.organization_id
LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ?
LEFT JOIN organization_members om ON om.organization_id = w.organization_id AND om.user_id = ?
ORDER BY o.name, w.name
`).all(req.user.id, req.user.id)
: db.prepare(`
SELECT w.id, w.name, w.organization_id, o.name AS organization_name,
wm.role AS workspace_role, om.role AS org_role,
(SELECT COUNT(*) FROM devices WHERE workspace_id = w.id) AS device_count
FROM workspace_members wm
JOIN workspaces w ON w.id = wm.workspace_id
JOIN organizations o ON o.id = w.organization_id
LEFT JOIN organization_members om ON om.organization_id = w.organization_id AND om.user_id = ?
WHERE wm.user_id = ?
ORDER BY o.name, w.name
`).all(req.user.id, req.user.id);
// Compute can_admin per workspace. Mirrors canAdminWorkspace() in lib/permissions.js
// but uses already-joined org_role to avoid another N+1 query per workspace.
for (const w of accessible) {
w.can_admin = isPlatformAdmin
|| w.org_role === 'org_owner' || w.org_role === 'org_admin'
|| w.workspace_role === 'workspace_admin';
delete w.org_role; // internal-only; don't leak to client
}
const currentOrg = req.organizationId
? db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(req.organizationId)
: null;
res.json({
...req.user,
current_workspace_id: req.workspaceId,
current_workspace: req.workspace ? { id: req.workspace.id, name: req.workspace.name, organization_id: req.workspace.organization_id } : null,
current_organization: currentOrg,
current_workspace_role: req.workspaceRole,
current_org_role: req.orgRole,
is_platform_admin: req.isPlatformAdmin,
acting_as: req.actingAs,
accessible_workspaces: accessible,
});
});
// Switch the active workspace. Validates the user has access (direct
// workspace_member, org-level admin in the parent org, or platform_admin),
// then mints a fresh JWT with the new current_workspace_id.
router.post('/switch-workspace', requireAuth, (req, res) => {
const { workspace_id } = req.body || {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspace_id);
if (!ws) return res.status(404).json({ error: 'Workspace not found' });
const isPlatformAdmin = req.user.role === 'platform_admin' || req.user.role === 'superadmin';
const wsMember = db.prepare('SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?').get(ws.id, req.user.id);
const orgMember = db.prepare(`
SELECT role FROM organization_members WHERE organization_id = ? AND user_id = ?
`).get(ws.organization_id, req.user.id);
const canAct = isPlatformAdmin
|| !!wsMember
|| (orgMember && (orgMember.role === 'org_owner' || orgMember.role === 'org_admin'));
if (!canAct) return res.status(403).json({ error: 'Access denied to that workspace' });
const token = generateToken(req.user, ws.id);
res.json({ token, current_workspace_id: ws.id });
}); });
// Update current user // Update current user
router.put('/me', requireAuth, (req, res) => { router.put('/me', requireAuth, (req, res) => {
const { name, password } = req.body; const { name, password, current_password, email_alerts } = req.body;
if (name) { if (name) {
db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(name, req.user.id); .run(name, req.user.id);
} }
if (password && password.length >= 8) { if (email_alerts !== undefined) {
db.prepare('UPDATE users SET email_alerts = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(email_alerts ? 1 : 0, req.user.id);
}
if (password) {
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const row = db.prepare('SELECT password_hash, auth_provider FROM users WHERE id = ?').get(req.user.id);
if (!row) return res.status(404).json({ error: 'User not found' });
if (row.auth_provider !== 'local') {
return res.status(400).json({ error: `Your account signs in via ${row.auth_provider}. Manage your password there.` });
}
if (row.password_hash) {
if (!current_password || !bcrypt.compareSync(current_password, row.password_hash)) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
}
const hash = bcrypt.hashSync(password, 10); const hash = bcrypt.hashSync(password, 10);
db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(hash, req.user.id); .run(hash, req.user.id);
} }
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts FROM users WHERE id = ?').get(req.user.id);
res.json(user); res.json(user);
}); });
// List users - superadmins see all, admins see team members only // List users - platform admins see all, admins see team members only
router.get('/users', requireAuth, requireAdmin, (req, res) => { router.get('/users', requireAuth, requireAdmin, (req, res) => {
if (req.user.role === 'superadmin') { if (PLATFORM_ROLES.includes(req.user.role)) {
const users = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, created_at, last_login FROM users ORDER BY created_at ASC').all(); const users = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, created_at, last_login FROM users ORDER BY created_at ASC').all();
res.json(users); res.json(users);
} else { } else {
@ -275,6 +449,54 @@ router.put('/users/:id/role', requireAuth, requireSuperAdmin, (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
// Admin password reset for another user.
// Superadmins: can reset any local user. Admins: can reset members of teams
// they own (and never a superadmin). Self-reset routes through PUT /me with
// current_password — this endpoint is the override path.
router.put('/users/:id/password', requireAuth, requireAdmin, (req, res) => {
const { password } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
if (req.params.id === req.user.id) {
return res.status(400).json({ error: 'Use Settings > Change Password for your own account' });
}
const target = db.prepare('SELECT id, email, role, auth_provider FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.auth_provider !== 'local') {
return res.status(400).json({ error: `User signs in via ${target.auth_provider} — password reset does not apply` });
}
if (!PLATFORM_ROLES.includes(req.user.role)) {
// Admin path: must own a team that includes the target, and target must
// be a regular user (cannot reset another admin's or a platform_admin's
// password — that would be a lateral-takeover vector).
if (target.role !== 'user') {
return res.status(403).json({ error: 'Admins can only reset passwords for regular users' });
}
const sharedOwnedTeam = db.prepare(`
SELECT 1 FROM team_members tm_admin
JOIN team_members tm_target ON tm_admin.team_id = tm_target.team_id
WHERE tm_admin.user_id = ? AND tm_admin.role = 'owner'
AND tm_target.user_id = ?
LIMIT 1
`).get(req.user.id, req.params.id);
if (!sharedOwnedTeam) {
return res.status(403).json({ error: 'You can only reset passwords for members of teams you own' });
}
}
const hash = bcrypt.hashSync(password, 10);
db.prepare("UPDATE users SET password_hash = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(hash, req.params.id);
// Explicit audit entry — the generic activity logger captures the route
// and target id, but a labeled detail string makes the audit log readable.
// Never include the password; just who reset whose password.
logActivity(req.user.id, 'password_reset_for_user', `target: ${target.email}`, null, getClientIp(req));
res.json({ success: true });
});
// Get auth config (public - tells frontend which providers are available) // Get auth config (public - tells frontend which providers are available)
router.get('/config', (req, res) => { router.get('/config', (req, res) => {
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
@ -286,6 +508,7 @@ router.get('/config', (req, res) => {
microsoftTenantId: config.microsoftTenantId, microsoftTenantId: config.microsoftTenantId,
localEnabled: true, localEnabled: true,
needsSetup: userCount === 0, needsSetup: userCount === 0,
registration_enabled: !config.disableRegistration || userCount === 0,
}); });
}); });

85
server/routes/contact.js Normal file
View file

@ -0,0 +1,85 @@
// Public (unauthenticated) contact form endpoint. Used by the Enterprise /
// Custom card on the marketing landing page to send a lead to Dan's inbox via
// the existing Microsoft Graph email service.
//
// Honeypot strategy: the form has a hidden 'fax_number' field that real users
// never see (off-screen + aria-hidden + tabindex=-1). If a submission arrives
// with that field populated, we return success to the bot but drop the
// submission silently. Combined with the rate limit applied in server.js
// (5 req/min/IP+path), this is enough friction for a low-traffic public form.
const express = require('express');
const router = express.Router();
const { sendEmail } = require('../services/email');
function isEmail(s) {
return typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
}
function clamp(s, max) {
return String(s || '').slice(0, max);
}
router.post('/enterprise', async (req, res) => {
const { name, email, company, screens, multi_tenant, hosting, message, fax_number } = req.body || {};
// Honeypot. Real users can't see or tab to this field; only bots fill it.
// Return 200 so the bot's retry logic doesn't kick in, but skip the send.
if (fax_number && String(fax_number).trim() !== '') {
console.log(`[contact] honeypot triggered from ${req.ip}; dropping`);
return res.json({ success: true });
}
// Server-side validation. Client validates too but we never trust that.
if (!name || !email || !company || !screens || !multi_tenant || !hosting) {
return res.status(400).json({ error: 'Missing required fields' });
}
if (!isEmail(email)) {
return res.status(400).json({ error: 'Invalid email address' });
}
const screensNum = parseInt(screens);
if (!Number.isFinite(screensNum) || screensNum < 1 || screensNum > 100000) {
return res.status(400).json({ error: 'Screens must be a positive number' });
}
if (!['single', 'multi'].includes(multi_tenant)) {
return res.status(400).json({ error: 'Invalid multi-tenant selection' });
}
if (!['hosted', 'self', 'unsure'].includes(hosting)) {
return res.status(400).json({ error: 'Invalid hosting selection' });
}
// Length caps - keeps a 10MB textarea from filling the mailbox
const cleanName = clamp(name, 200);
const cleanEmail = clamp(email, 200);
const cleanCompany = clamp(company, 200);
const cleanMessage = clamp(message, 5000);
const tenantLabel = multi_tenant === 'multi' ? 'Multiple organizations' : 'Single organization';
const hostingLabel = { hosted: 'Hosted for me', self: 'Self-host', unsure: 'Not sure yet' }[hosting];
const subject = `Enterprise inquiry: ${cleanCompany}`;
const text =
`New enterprise inquiry from ${cleanName} (${cleanEmail})
Company: ${cleanCompany}
Estimated screens: ${screensNum}
Multi-tenant: ${tenantLabel}
Hosting preference: ${hostingLabel}
Message:
${cleanMessage || '(none)'}
---
Submitted from screentinker.com pricing page
Source IP: ${req.ip}
`;
const result = await sendEmail({ to: 'dan@bytetinker.net', subject, text });
if (!result.sent) {
console.error(`[contact] email send failed for ${cleanEmail}: reason=${result.reason} error=${result.error || ''}`);
return res.status(500).json({ error: 'Could not send your message. Please email dan@bytetinker.net directly.' });
}
console.log(`[contact] enterprise inquiry from ${cleanEmail} (${cleanCompany}) delivered`);
res.json({ success: true });
});
module.exports = router;

View file

@ -7,32 +7,88 @@ const { db } = require('../db/database');
const upload = require('../middleware/upload'); const upload = require('../middleware/upload');
const config = require('../config'); const config = require('../config');
const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription'); const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription');
const { sanitizeString } = require('../middleware/sanitize');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2b: workspace-aware access. Mirrors the pattern from devices.js.
const { accessContext } = require('../lib/tenancy');
// List content for current user (admins see all) // Multer captures file.originalname directly from the multipart filename header,
// bypassing sanitizeBody. Apply the same HTML-escape here so a filename like
// `"><img src=x onerror=alert(1)>.jpg` is stored as `&quot;&gt;&lt;img...` and
// renders as text in every UI sink. Umlauts, spaces, dots, and other unicode are
// preserved - sanitizeString only touches `& < > " '`.
//
// .normalize('NFC') first: macOS clients send NFD-decomposed filenames (an
// umlaut like "u" + combining diaeresis U+0308 instead of the precomposed
// "u-umlaut" U+00FC). Linux + most renderers expect NFC; without this, names
// like "Begrussungsscreens.jpg" arrive with the combining char floating and
// display as mojibake. Single-point fix - every user-facing filename storage
// site (POST /, POST /remote, POST /embed, PUT /:id rename) flows through
// safeFilename, so normalizing here covers all paths.
function safeFilename(name) {
return sanitizeString((name || '').normalize('NFC'));
}
// SSRF gate for remote_url. Returns null if valid, else { status, error }.
// Used by both POST /remote and PUT /:id so a user can't bypass the check by
// uploading a benign URL and then PUT-updating it to file:///etc/passwd.
function validateRemoteUrl(url) {
let parsed;
try { parsed = new URL(url); }
catch { return { status: 400, error: 'Invalid URL format' }; }
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { status: 400, error: 'URL must use http or https' };
}
const hostname = parsed.hostname.toLowerCase();
const isPrivate = hostname === 'localhost' || hostname === '0.0.0.0' ||
hostname.startsWith('127.') || hostname.startsWith('10.') ||
hostname.startsWith('192.168.') || hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
hostname.startsWith('fc') || hostname.startsWith('fd') || hostname === '::1' ||
hostname.endsWith('.local') || hostname.endsWith('.internal');
if (isPrivate) return { status: 400, error: 'Internal URLs are not allowed' };
return null;
}
// List content in the caller's current workspace, plus any platform-template
// rows (workspace_id IS NULL) that are shared with all workspaces.
// Phase 2.2b: workspace-scoped. Cross-workspace visibility comes from
// switch-workspace, not a special list filter.
// folder_id filter: omit for everything; "root" or "" for root-level only; <uuid> for that folder.
router.get('/', (req, res) => { router.get('/', (req, res) => {
const isAdmin = req.user.role === 'superadmin'; if (!req.workspaceId) return res.json([]);
const folder = req.query.folder; const folder = req.query.folder;
let sql = `SELECT * FROM content ${isAdmin ? 'WHERE 1=1' : 'WHERE (user_id = ? OR user_id IS NULL)'}`; const folderId = req.query.folder_id;
const params = isAdmin ? [] : [req.user.id]; let sql = 'SELECT * FROM content WHERE (workspace_id = ? OR workspace_id IS NULL)';
const params = [req.workspaceId];
if (folder) { sql += ' AND folder = ?'; params.push(folder); } if (folder) { sql += ' AND folder = ?'; params.push(folder); }
if (folderId !== undefined) {
if (folderId === 'root' || folderId === '') {
sql += ' AND folder_id IS NULL';
} else {
sql += ' AND folder_id = ?';
params.push(folderId);
}
}
sql += ' ORDER BY folder, created_at DESC LIMIT ? OFFSET ?'; sql += ' ORDER BY folder, created_at DESC LIMIT ? OFFSET ?';
params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0); params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0);
const content = db.prepare(sql).all(...params); const content = db.prepare(sql).all(...params);
res.json(content); res.json(content);
}); });
// Get folders list // Get folders list for the caller's current workspace.
router.get('/folders', (req, res) => { router.get('/folders', (req, res) => {
const isAdmin = req.user.role === 'superadmin'; if (!req.workspaceId) return res.json([]);
const folders = db.prepare( const folders = db.prepare(
`SELECT folder, COUNT(*) as count FROM content WHERE folder IS NOT NULL ${isAdmin ? '' : 'AND (user_id = ? OR user_id IS NULL)'} GROUP BY folder ORDER BY folder` 'SELECT folder, COUNT(*) as count FROM content WHERE folder IS NOT NULL AND (workspace_id = ? OR workspace_id IS NULL) GROUP BY folder ORDER BY folder'
).all(...(isAdmin ? [] : [req.user.id])); ).all(req.workspaceId);
res.json(folders); res.json(folders);
}); });
// Upload content // Upload content
router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => { router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => {
try { try {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before uploading.' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const id = uuidv4(); const id = uuidv4();
@ -84,9 +140,9 @@ router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => {
} }
db.prepare(` db.prepare(`
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height) INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.user.id, req.file.originalname, filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height); `).run(id, req.user.id, req.workspaceId, safeFilename(req.file.originalname), filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height);
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
res.status(201).json(content); res.status(201).json(content);
@ -99,37 +155,20 @@ router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => {
// Add remote URL content // Add remote URL content
router.post('/remote', checkRemoteUrl, (req, res) => { router.post('/remote', checkRemoteUrl, (req, res) => {
try { try {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before adding remote content.' });
const { url, name, mime_type } = req.body; const { url, name, mime_type } = req.body;
if (!url) return res.status(400).json({ error: 'url is required' }); if (!url) return res.status(400).json({ error: 'url is required' });
// Validate URL format const urlErr = validateRemoteUrl(url);
try { if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error });
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return res.status(400).json({ error: 'URL must use http or https' });
}
// Block private/internal IPs (SSRF protection)
const hostname = parsed.hostname.toLowerCase();
const isPrivate = hostname === 'localhost' || hostname === '0.0.0.0' ||
hostname.startsWith('127.') || hostname.startsWith('10.') ||
hostname.startsWith('192.168.') || hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) || // 172.16.0.0 - 172.31.255.255
hostname.startsWith('fc') || hostname.startsWith('fd') || hostname === '::1' || // IPv6 private
hostname.endsWith('.local') || hostname.endsWith('.internal');
if (isPrivate) {
return res.status(400).json({ error: 'Internal URLs are not allowed' });
}
} catch {
return res.status(400).json({ error: 'Invalid URL format' });
}
const id = uuidv4(); const id = uuidv4();
const filename = name || url.split('/').pop()?.split('?')[0] || 'remote_content'; const filename = name || url.split('/').pop()?.split('?')[0] || 'remote_content';
const mimeType = mime_type || (url.match(/\.(mp4|webm|mkv|avi|mov)/i) ? 'video/mp4' : 'image/jpeg'); const mimeType = mime_type || (url.match(/\.(mp4|webm|mkv|avi|mov)/i) ? 'video/mp4' : 'image/jpeg');
db.prepare(` db.prepare(`
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url) INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, remote_url)
VALUES (?, ?, ?, '', ?, 0, ?) VALUES (?, ?, ?, ?, '', ?, 0, ?)
`).run(id, req.user.id, filename, mimeType, url); `).run(id, req.user.id, req.workspaceId, safeFilename(filename), mimeType, url);
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
res.status(201).json(content); res.status(201).json(content);
@ -142,6 +181,7 @@ router.post('/remote', checkRemoteUrl, (req, res) => {
// Add YouTube content (available to all plans - no storage used) // Add YouTube content (available to all plans - no storage used)
router.post('/youtube', async (req, res) => { router.post('/youtube', async (req, res) => {
try { try {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before adding YouTube content.' });
const { url, name } = req.body; const { url, name } = req.body;
if (!url) return res.status(400).json({ error: 'url is required' }); if (!url) return res.status(400).json({ error: 'url is required' });
@ -167,9 +207,9 @@ router.post('/youtube', async (req, res) => {
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
db.prepare(` db.prepare(`
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path) INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path)
VALUES (?, ?, ?, '', 'video/youtube', 0, ?, ?) VALUES (?, ?, ?, ?, '', 'video/youtube', 0, ?, ?)
`).run(id, req.user.id, filename, embedUrl, thumbnailUrl); `).run(id, req.user.id, req.workspaceId, safeFilename(filename), embedUrl, thumbnailUrl);
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
res.status(201).json(content); res.status(201).json(content);
@ -191,35 +231,82 @@ function extractYoutubeId(url) {
return null; return null;
} }
// Helper: check content ownership // Phase 2.2b: workspace-aware access. Mirrors the device check pattern.
function checkContentAccess(req, res) { // Platform-template content (workspace_id IS NULL) is readable by anyone
// and writable only by platform_admin.
function checkContentRead(req, res) {
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id); const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id);
if (!content) { res.status(404).json({ error: 'Content not found' }); return null; } if (!content) { res.status(404).json({ error: 'Content not found' }); return null; }
if (!['admin','superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) { // Platform-template row: readable by anyone authenticated.
res.status(403).json({ error: 'Access denied' }); return null; if (!content.workspace_id) return content;
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(content.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
return content;
}
function checkContentWrite(req, res) {
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id);
if (!content) { res.status(404).json({ error: 'Content not found' }); return null; }
// Platform-template row: only platform_admin may write.
if (!content.workspace_id) {
if (!PLATFORM_ROLES.includes(req.user.role)) {
res.status(403).json({ error: 'Platform admin required to modify shared content' }); return null;
}
return content;
}
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(content.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
// Workspace_viewer is read-only; acting-as (platform_admin or org owner/admin) and editor/admin pass.
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
} }
return content; return content;
} }
// Get content metadata // Get content metadata
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentRead(req, res);
if (!content) return; if (!content) return;
res.json(content); res.json(content);
}); });
// Update content metadata // Update content metadata
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentWrite(req, res);
if (!content) return; if (!content) return;
const { filename, mime_type, remote_url, folder } = req.body; const { filename, mime_type, remote_url, folder, folder_id } = req.body;
const updates = []; const updates = [];
const values = []; const values = [];
if (filename !== undefined) { updates.push('filename = ?'); values.push(filename); } if (filename !== undefined) { updates.push('filename = ?'); values.push(safeFilename(filename)); }
if (mime_type !== undefined) { updates.push('mime_type = ?'); values.push(mime_type); } if (mime_type !== undefined) { updates.push('mime_type = ?'); values.push(mime_type); }
if (remote_url !== undefined) { updates.push('remote_url = ?'); values.push(remote_url || null); } if (remote_url !== undefined) {
if (remote_url) {
const urlErr = validateRemoteUrl(remote_url);
if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error });
}
updates.push('remote_url = ?');
values.push(remote_url || null);
}
if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); } if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); }
if (folder_id !== undefined) {
// Phase 2.2c: target folder must live in the same workspace as the
// content row being modified. Strict same-workspace check - no
// platform_admin override, because cross-workspace folder references
// break the isolation model. To move content across workspaces, switch
// workspace first.
if (folder_id) {
const target = db.prepare('SELECT workspace_id FROM content_folders WHERE id = ?').get(folder_id);
if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
if (target.workspace_id !== content.workspace_id) {
return res.status(403).json({ error: 'Cannot move content to a folder in another workspace' });
}
}
updates.push('folder_id = ?');
values.push(folder_id || null);
}
if (updates.length > 0) { if (updates.length > 0) {
values.push(req.params.id); values.push(req.params.id);
@ -231,7 +318,7 @@ router.put('/:id', (req, res) => {
// Replace content file // Replace content file
router.put('/:id/replace', upload.single('file'), async (req, res) => { router.put('/:id/replace', upload.single('file'), async (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentWrite(req, res);
if (!content) return; if (!content) return;
if (!req.file) return res.status(400).json({ error: 'No file provided' }); if (!req.file) return res.status(400).json({ error: 'No file provided' });
@ -272,7 +359,7 @@ router.put('/:id/replace', upload.single('file'), async (req, res) => {
// Serve content file // Serve content file
router.get('/:id/file', (req, res) => { router.get('/:id/file', (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentRead(req, res);
if (!content) return; if (!content) return;
if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' }); if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' });
// Prevent path traversal // Prevent path traversal
@ -283,7 +370,7 @@ router.get('/:id/file', (req, res) => {
// Serve thumbnail // Serve thumbnail
router.get('/:id/thumbnail', (req, res) => { router.get('/:id/thumbnail', (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentRead(req, res);
if (!content) return; if (!content) return;
if (!content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' }); if (!content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' });
const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path)); const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path));
@ -293,7 +380,7 @@ router.get('/:id/thumbnail', (req, res) => {
// Delete content // Delete content
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const content = checkContentAccess(req, res); const content = checkContentWrite(req, res);
if (!content) return; if (!content) return;
// Delete file from disk (skip for remote URL content) // Delete file from disk (skip for remote URL content)
@ -308,14 +395,51 @@ router.delete('/:id', (req, res) => {
if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath); if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
} }
// Get devices that have this content assigned (to notify them) // Get devices that have this content in their playlist (via playlist_items)
const affectedDevices = db.prepare( const affectedDevices = db.prepare(`
'SELECT DISTINCT device_id FROM assignments WHERE content_id = ?' SELECT DISTINCT d.id as device_id FROM devices d
).all(req.params.id); JOIN playlists p ON d.playlist_id = p.id
JOIN playlist_items pi ON pi.playlist_id = p.id
WHERE pi.content_id = ?
`).all(req.params.id);
// Delete from DB (cascades to assignments) // Scrub published snapshots that reference this content
// Validate UUID format to prevent LIKE wildcard injection
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_RE.test(req.params.id)) return res.status(400).json({ error: 'Invalid content ID format' });
// Phase 2.2k: scope snapshot scrubbing by content.workspace_id (was content.user_id).
// Playlists referencing this content live in the same workspace; user_id-keying missed
// cross-user playlists in the same workspace once playlists became workspace-scoped.
const snapshotPlaylists = db.prepare(
"SELECT id, published_snapshot FROM playlists WHERE workspace_id = ? AND published_snapshot LIKE ?"
).all(content.workspace_id, `%${req.params.id}%`);
for (const pl of snapshotPlaylists) {
try {
const items = JSON.parse(pl.published_snapshot);
const filtered = items.filter(item => item.content_id !== req.params.id);
if (filtered.length !== items.length) {
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?')
.run(JSON.stringify(filtered), pl.id);
}
} catch (e) { /* corrupt snapshot, skip */ }
}
// Delete from DB (cascades to playlist_items via ON DELETE CASCADE)
db.prepare('DELETE FROM content WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM content WHERE id = ?').run(req.params.id);
// Push updated snapshots to affected devices
try {
const io = req.app.get('io');
if (io) {
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
const deviceNs = io.of('/device');
for (const d of affectedDevices) {
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, d.device_id, buildPlaylistPayload);
}
}
} catch (e) { /* silent */ }
res.json({ success: true, affectedDevices: affectedDevices.map(d => d.device_id) }); res.json({ success: true, affectedDevices: affectedDevices.map(d => d.device_id) });
}); });

View file

@ -2,44 +2,69 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2i: workspace-aware access. Same pattern as devices/content/widgets.
const { accessContext } = require('../lib/tenancy');
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/; const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
const ALLOWED_COMMANDS = ['screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown']; const ALLOWED_COMMANDS = ['screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown'];
// Verify group belongs to the authenticated user // Phase 2.2i: split read/write access checks. Both attach req.group on success.
function requireGroupOwnership(req, res, next) { function loadGroupAccessCtx(req, res) {
const group = db.prepare('SELECT * FROM device_groups WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const group = db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'group not found' }); if (!group) { res.status(404).json({ error: 'group not found' }); return null; }
req.group = group; if (!group.workspace_id) { res.status(403).json({ error: 'Group not assigned to a workspace' }); return null; }
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(group.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
return { group, ctx };
}
function requireGroupRead(req, res, next) {
const access = loadGroupAccessCtx(req, res);
if (!access) return;
req.group = access.group;
next(); next();
} }
// List groups function requireGroupWrite(req, res, next) {
const access = loadGroupAccessCtx(req, res);
if (!access) return;
if (!access.ctx.actingAs && access.ctx.workspaceRole === 'workspace_viewer') {
return res.status(403).json({ error: 'Read-only access' });
}
req.group = access.group;
next();
}
// List groups in the caller's current workspace.
router.get('/', (req, res) => { router.get('/', (req, res) => {
if (!req.workspaceId) return res.json([]);
const groups = db.prepare(` const groups = db.prepare(`
SELECT g.*, COUNT(dgm.device_id) as device_count SELECT g.*, COUNT(dgm.device_id) as device_count
FROM device_groups g FROM device_groups g
LEFT JOIN device_group_members dgm ON g.id = dgm.group_id LEFT JOIN device_group_members dgm ON g.id = dgm.group_id
WHERE g.user_id = ? WHERE g.workspace_id = ?
GROUP BY g.id GROUP BY g.id
ORDER BY g.name ASC ORDER BY g.name ASC
`).all(req.user.id); `).all(req.workspaceId);
res.json(groups); res.json(groups);
}); });
// Create group // Create group in the caller's current workspace.
router.post('/', (req, res) => { router.post('/', (req, res) => {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating groups.' });
const { name, color } = req.body; const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'name required' }); if (!name) return res.status(400).json({ error: 'name required' });
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' }); if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
const id = uuidv4(); const id = uuidv4();
db.prepare('INSERT INTO device_groups (id, user_id, name, color) VALUES (?, ?, ?, ?)') db.prepare('INSERT INTO device_groups (id, user_id, workspace_id, name, color) VALUES (?, ?, ?, ?, ?)')
.run(id, req.user.id, name, color || '#3B82F6'); .run(id, req.user.id, req.workspaceId, name, color || '#3B82F6');
res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id)); res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id));
}); });
// Update group // Update group
router.put('/:id', requireGroupOwnership, (req, res) => { router.put('/:id', requireGroupWrite, (req, res) => {
const { name, color } = req.body; const { name, color } = req.body;
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' }); if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id); if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id);
@ -47,14 +72,57 @@ router.put('/:id', requireGroupOwnership, (req, res) => {
res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id)); res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
}); });
// Delete group // Delete group — converts group schedules to per-device schedules first
router.delete('/:id', requireGroupOwnership, (req, res) => { router.delete('/:id', requireGroupWrite, (req, res) => {
db.prepare('DELETE FROM device_groups WHERE id = ?').run(req.params.id); const groupId = req.params.id;
res.json({ success: true });
const convert = db.transaction(() => {
// Find group schedules that need conversion
const groupSchedules = db.prepare('SELECT * FROM schedules WHERE group_id = ?').all(groupId);
// Find current group members
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(groupId);
let converted = 0;
if (groupSchedules.length > 0 && members.length > 0) {
const insert = db.prepare(`
INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id,
widget_id, layout_id, playlist_id, title, start_time, end_time, timezone,
recurrence, recurrence_end, priority, enabled, color, created_at, updated_at)
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const schedule of groupSchedules) {
for (const member of members) {
insert.run(
uuidv4(), schedule.user_id, member.device_id,
schedule.zone_id, schedule.content_id, schedule.widget_id,
schedule.layout_id, schedule.playlist_id, schedule.title,
schedule.start_time, schedule.end_time, schedule.timezone,
schedule.recurrence, schedule.recurrence_end, schedule.priority,
schedule.enabled, schedule.color, schedule.created_at, schedule.updated_at
);
}
converted++;
}
}
// Delete group schedules explicitly (before group delete turns group_id to NULL via ON DELETE SET NULL)
db.prepare('DELETE FROM schedules WHERE group_id = ?').run(groupId);
// Delete the group (cascades to device_group_members)
db.prepare('DELETE FROM device_groups WHERE id = ?').run(groupId);
return { converted, devices: members.length };
});
const result = convert();
res.json({ success: true, schedules_converted: result.converted, devices: result.devices });
}); });
// Get devices in a group // Get devices in a group
router.get('/:id/devices', requireGroupOwnership, (req, res) => { router.get('/:id/devices', requireGroupRead, (req, res) => {
const devices = db.prepare(` const devices = db.prepare(`
SELECT d.* FROM devices d SELECT d.* FROM devices d
JOIN device_group_members dgm ON d.id = dgm.device_id JOIN device_group_members dgm ON d.id = dgm.device_id
@ -64,54 +132,112 @@ router.get('/:id/devices', requireGroupOwnership, (req, res) => {
res.json(devices); res.json(devices);
}); });
// Add device to group // Add device to group. If the group has a playlist set (via the assign-playlist
router.post('/:id/devices', requireGroupOwnership, (req, res) => { // dropdown on the dashboard), the new device inherits it — both for drag-drop
// onto the group section and for the Manage modal's checkboxes, which both
// hit this endpoint. Without this, joining a group never auto-assigned the
// group's playlist, leaving the new device on whatever it had before.
//
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
// checked device.user_id == caller; a workspace_admin who happened to own a
// device in another workspace could add it to a group in this workspace.
// Now: the device must belong to the same workspace as the group.
router.post('/:id/devices', requireGroupWrite, (req, res) => {
const { device_id } = req.body; const { device_id } = req.body;
if (!device_id) return res.status(400).json({ error: 'device_id required' }); if (!device_id) return res.status(400).json({ error: 'device_id required' });
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (device.workspace_id !== req.group.workspace_id) {
return res.status(403).json({ error: 'Device is not in this group\'s workspace' });
}
try { try {
db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id); db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id);
res.status(201).json({ success: true });
// Sync device's playlist to the group's: a defined playlist is inherited,
// a group with no playlist clears the device's. The user's mental model
// is "joining a group means using its playlist (or none)" — staying on a
// stale playlist after joining a no-playlist group was the bug we just hit.
const group = db.prepare('SELECT playlist_id FROM device_groups WHERE id = ?').get(req.params.id);
const newPlaylist = group?.playlist_id || null;
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, device_id);
pushPlaylistToDevice(req, device_id);
res.status(201).json({ success: true, playlist_id: newPlaylist });
} catch (e) { } catch (e) {
res.status(400).json({ error: e.message }); res.status(400).json({ error: e.message });
} }
}); });
// Remove device from group // Remove device from group. Sync the device's playlist to whatever its
router.delete('/:id/devices/:deviceId', requireGroupOwnership, (req, res) => { // current group membership implies — symmetric with the join sync above.
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(req.params.deviceId, req.params.id); // - No remaining groups → clear playlist (Ungrouped).
// - Remaining group with a playlist → adopt that playlist.
// - Remaining group(s) but none have a playlist → clear playlist.
// Without this, a device dragged out of a group keeps stale playlist state
// from the group it just left.
router.delete('/:id/devices/:deviceId', requireGroupWrite, (req, res) => {
const deviceId = req.params.deviceId;
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(deviceId, req.params.id);
const remaining = db.prepare(`
SELECT g.playlist_id FROM device_groups g
JOIN device_group_members dgm ON g.id = dgm.group_id
WHERE dgm.device_id = ?
ORDER BY g.playlist_id IS NULL, g.name ASC
LIMIT 1
`).get(deviceId);
const newPlaylist = remaining?.playlist_id || null;
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, deviceId);
pushPlaylistToDevice(req, deviceId);
res.json({ success: true }); res.json({ success: true });
}); });
// Ensure a device has a playlist; auto-create one if missing // Ensure a device has a playlist; auto-create one if missing.
// Phase 2.2i: pre-emptive loop-closer for the future playlists.js migration.
// The auto-created playlist lives in the same workspace as the device, so
// once playlists.js scopes by workspace_id this helper's rows remain visible.
function ensureDevicePlaylist(deviceId, userId) { function ensureDevicePlaylist(deviceId, userId) {
const device = db.prepare('SELECT playlist_id, name FROM devices WHERE id = ?').get(deviceId); const device = db.prepare('SELECT playlist_id, workspace_id, name FROM devices WHERE id = ?').get(deviceId);
if (device?.playlist_id) return device.playlist_id; if (device?.playlist_id) return device.playlist_id;
const playlistId = uuidv4(); const playlistId = uuidv4();
db.prepare('INSERT INTO playlists (id, user_id, name, is_auto_generated) VALUES (?, ?, ?, 1)') db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, is_auto_generated) VALUES (?, ?, ?, ?, 1)')
.run(playlistId, userId, `${device?.name || 'Display'} playlist`); .run(playlistId, userId, device?.workspace_id || null, `${device?.name || 'Display'} playlist`);
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId); db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId);
return playlistId; return playlistId;
} }
// Push playlist update to a device // Mark playlist as draft (called after any item mutation)
function markDraft(playlistId) {
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
}
// Push playlist update to a device (used by assign-playlist which doesn't modify items)
function pushPlaylistToDevice(req, deviceId) { function pushPlaylistToDevice(req, deviceId) {
try { try {
const io = req.app.get('io'); const io = req.app.get('io');
if (!io) return; if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket'); const { buildPlaylistPayload } = require('../ws/deviceSocket');
const deviceNs = io.of('/device'); const commandQueue = require('../lib/command-queue');
deviceNs.to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId)); commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), deviceId, buildPlaylistPayload);
} catch (e) { /* silent */ } } catch (e) { /* silent */ }
} }
// Bulk assign content to all devices in a group (adds to each device's playlist) // Bulk assign content to all devices in a group (adds to each device's playlist).
router.post('/:id/assign-content', requireGroupOwnership, (req, res) => { // Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
// checked content.user_id == caller; the content could live in any workspace
// the caller had any reach into. Now: content must live in the group's
// workspace (or be a platform-template content row, workspace_id IS NULL).
router.post('/:id/assign-content', requireGroupWrite, (req, res) => {
const { content_id, duration_sec } = req.body; const { content_id, duration_sec } = req.body;
if (!content_id) return res.status(400).json({ error: 'content_id required' }); if (!content_id) return res.status(400).json({ error: 'content_id required' });
// Verify content belongs to the user // Verify content lives in the same workspace as the group (or is a
const content = db.prepare('SELECT id FROM content WHERE id = ? AND user_id = ?').get(content_id, req.user.id); // platform-template row).
const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(content_id);
if (!content) return res.status(404).json({ error: 'Content not found' }); if (!content) return res.status(404).json({ error: 'Content not found' });
if (content.workspace_id && content.workspace_id !== req.group.workspace_id) {
return res.status(403).json({ error: 'Content is not in this group\'s workspace' });
}
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id); const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
@ -121,27 +247,38 @@ router.post('/:id/assign-content', requireGroupOwnership, (req, res) => {
const max = db.prepare('SELECT COALESCE(MAX(sort_order),0)+1 as next FROM playlist_items WHERE playlist_id = ?').get(playlistId); const max = db.prepare('SELECT COALESCE(MAX(sort_order),0)+1 as next FROM playlist_items WHERE playlist_id = ?').get(playlistId);
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)') db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)')
.run(playlistId, content_id, max.next, duration_sec || 10); .run(playlistId, content_id, max.next, duration_sec || 10);
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(playlistId); markDraft(playlistId);
} }
}); });
transaction(); transaction();
for (const m of members) pushPlaylistToDevice(req, m.device_id);
res.json({ success: true, devices_updated: members.length }); res.json({ success: true, devices_updated: members.length });
}); });
// Assign an existing playlist to all devices in a group // Assign an existing playlist to all devices in a group, and persist the
router.post('/:id/assign-playlist', requireGroupOwnership, (req, res) => { // choice on the group itself so future joiners inherit it (see POST /:id/devices).
//
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
// checked playlist.user_id == caller; the playlist could live in any
// workspace the caller could reach. Now: playlist must live in the group's
// workspace. Playlists don't currently have a NULL/template path - playlists.js
// migration is deferred, so this check uses the raw workspace_id column that
// 2.2i's ensureDevicePlaylist loop-closer also writes to.
router.post('/:id/assign-playlist', requireGroupWrite, (req, res) => {
const { playlist_id } = req.body; const { playlist_id } = req.body;
if (!playlist_id) return res.status(400).json({ error: 'playlist_id required' }); if (!playlist_id) return res.status(400).json({ error: 'playlist_id required' });
const playlist = db.prepare('SELECT id FROM playlists WHERE id = ? AND user_id = ?').get(playlist_id, req.user.id); const playlist = db.prepare('SELECT id, workspace_id FROM playlists WHERE id = ?').get(playlist_id);
if (!playlist) return res.status(404).json({ error: 'Playlist not found' }); if (!playlist) return res.status(404).json({ error: 'Playlist not found' });
if (playlist.workspace_id && playlist.workspace_id !== req.group.workspace_id) {
return res.status(403).json({ error: 'Playlist is not in this group\'s workspace' });
}
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id); const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?'); const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?');
const transaction = db.transaction(() => { const transaction = db.transaction(() => {
db.prepare('UPDATE device_groups SET playlist_id = ? WHERE id = ?').run(playlist_id, req.params.id);
for (const m of members) stmt.run(playlist_id, m.device_id); for (const m of members) stmt.run(playlist_id, m.device_id);
}); });
transaction(); transaction();
@ -150,8 +287,8 @@ router.post('/:id/assign-playlist', requireGroupOwnership, (req, res) => {
res.json({ success: true, devices_updated: members.length }); res.json({ success: true, devices_updated: members.length });
}); });
// Send command to all devices in a group // Send command to all devices in a group (reboot/shutdown/screen on/off etc.)
router.post('/:id/command', requireGroupOwnership, (req, res) => { router.post('/:id/command', requireGroupWrite, (req, res) => {
const { type, payload } = req.body; const { type, payload } = req.body;
if (!type) return res.status(400).json({ error: 'command type required' }); if (!type) return res.status(400).json({ error: 'command type required' });
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' }); if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });

View file

@ -1,10 +1,21 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2a: workspace-aware access. accessContext returns { workspaceRole, actingAs }
// or null based on the caller's reach into a specific workspace.
const { accessContext } = require('../lib/tenancy');
// List devices for current user (admins see all) // List devices in the caller's current workspace.
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
// workspace is resolved by resolveTenancy middleware from JWT or query/header
// override. Platform_admin and org_owner/admin see whichever workspace they
// are currently switched into (cross-workspace visibility comes from
// switch-workspace, not from a special list filter).
router.get('/', (req, res) => { router.get('/', (req, res) => {
const isAdmin = req.user.role === 'superadmin'; if (!req.workspaceId) return res.json([]);
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const offset = parseInt(req.query.offset) || 0;
const devices = db.prepare(` const devices = db.prepare(`
SELECT d.*, SELECT d.*,
t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb, t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb,
@ -24,16 +35,16 @@ router.get('/', (req, res) => {
INNER JOIN (SELECT device_id, MAX(captured_at) as max_at FROM screenshots GROUP BY device_id) latest INNER JOIN (SELECT device_id, MAX(captured_at) as max_at FROM screenshots GROUP BY device_id) latest
ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at
) s ON d.id = s.device_id ) s ON d.id = s.device_id
${isAdmin ? 'WHERE d.user_id IS NOT NULL' : 'WHERE d.user_id IS NOT NULL AND (d.user_id = ? OR d.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))'} WHERE d.workspace_id = ?
ORDER BY d.created_at ASC ORDER BY d.created_at ASC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`).all(...(isAdmin ? [] : [req.user.id, req.user.id]), Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0); `).all(req.workspaceId, limit, offset);
res.json(devices); res.json(devices);
}); });
// List unclaimed provisioning devices (admin only) // List unclaimed provisioning devices (admin only)
router.get('/unassigned', (req, res) => { router.get('/unassigned', (req, res) => {
if (!['admin', 'superadmin'].includes(req.user.role)) { if (!ELEVATED_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Admin access required' }); return res.status(403).json({ error: 'Admin access required' });
} }
const devices = db.prepare(` const devices = db.prepare(`
@ -49,12 +60,15 @@ router.get('/unassigned', (req, res) => {
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id); const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
// Check access: admin, owner, or team member // Phase 2.2a: workspace-aware read check. accessContext returns null when
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) { // the caller has no path (direct member, org-level acting-as, or platform_admin)
const teamAccess = device.team_id ? db.prepare('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?').get(device.team_id, req.user.id) : null; // to the device's workspace.
if (!teamAccess) return res.status(403).json({ error: 'Access denied' }); if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
device._teamRole = teamAccess.role; // Pass team role for frontend to check const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
} const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
if (ctx.workspaceRole) device._workspaceRole = ctx.workspaceRole; // Pass to frontend
if (ctx.actingAs) device._actingAs = true;
const telemetry = db.prepare( const telemetry = db.prepare(
'SELECT * FROM device_telemetry WHERE device_id = ? ORDER BY reported_at DESC LIMIT 20' 'SELECT * FROM device_telemetry WHERE device_id = ? ORDER BY reported_at DESC LIMIT 20'
@ -64,11 +78,13 @@ router.get('/:id', (req, res) => {
'SELECT * FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1' 'SELECT * FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1'
).get(req.params.id); ).get(req.params.id);
// Get playlist items if device has an assigned playlist // Get playlist items and status if device has an assigned playlist
let assignments = []; let assignments = [];
let playlist_status = null;
let playlist_has_published = false;
if (device.playlist_id) { if (device.playlist_id) {
assignments = db.prepare(` assignments = db.prepare(`
SELECT pi.id, pi.content_id, pi.widget_id, pi.sort_order, pi.duration_sec, SELECT pi.id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
pi.created_at, pi.updated_at, pi.created_at, pi.updated_at,
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.remote_url, c.duration_sec as content_duration, c.remote_url,
@ -79,6 +95,11 @@ router.get('/:id', (req, res) => {
WHERE pi.playlist_id = ? WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC ORDER BY pi.sort_order ASC
`).all(device.playlist_id); `).all(device.playlist_id);
const pl = db.prepare('SELECT status, published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
if (pl) {
playlist_status = pl.status;
playlist_has_published = pl.published_snapshot !== null;
}
} }
// Uptime timeline: get status change events for last 24 hours // Uptime timeline: get status change events for last 24 hours
@ -95,19 +116,24 @@ router.get('/:id', (req, res) => {
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC' 'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
).all(req.params.id, dayAgo).map(r => r.reported_at); ).all(req.params.id, dayAgo).map(r => r.reported_at);
res.json({ ...device, telemetry, screenshot, assignments, uptimeData, statusLog }); res.json({ ...device, telemetry, screenshot, assignments, playlist_status, playlist_has_published, uptimeData, statusLog });
}); });
// Helper: check device ownership // Helper: check device write access via the workspace the device belongs to.
// Phase 2.2a: replaces user_id + team_members check. Allows: platform_admin,
// org_owner/admin of the device's org (acting-as), workspace_admin/editor of
// the device's workspace. Denies workspace_viewer and non-members.
function checkDeviceOwnership(req, res) { function checkDeviceOwnership(req, res) {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id); const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) { res.status(404).json({ error: 'Device not found' }); return null; } if (!device) { res.status(404).json({ error: 'Device not found' }); return null; }
if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { if (!device.workspace_id) { res.status(403).json({ error: 'Device not assigned to a workspace' }); return null; }
// Check team membership const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
const teamAccess = device.team_id ? db.prepare('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?').get(device.team_id, req.user.id) : null; const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!teamAccess || teamAccess.role === 'viewer') { if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
res.status(403).json({ error: 'Access denied' }); return null; // ctx.actingAs covers platform_admin and org_owner/admin paths (always writable).
} // Direct workspace members: workspace_viewer is read-only.
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
} }
return device; return device;
} }
@ -149,10 +175,13 @@ router.delete('/:id', (req, res) => {
db.prepare('DELETE FROM video_wall_devices WHERE device_id = ?').run(req.params.id); db.prepare('DELETE FROM video_wall_devices WHERE device_id = ?').run(req.params.id);
db.prepare('DELETE FROM devices WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM devices WHERE id = ?').run(req.params.id);
// Notify dashboard in real-time // Notify dashboard in real-time. Phase 2.3: scope to the device's
// (now-deleted but still-known) workspace room. `device.workspace_id`
// came from checkDeviceOwnership() above.
const io = req.app.get('io'); const io = req.app.get('io');
if (io) { if (io) {
io.of('/dashboard').emit('dashboard:device-removed', { device_id: req.params.id }); const { workspaceRoom, emitToWorkspace } = require('../lib/socket-rooms');
emitToWorkspace(io.of('/dashboard'), workspaceRoom(device.workspace_id), 'dashboard:device-removed', { device_id: req.params.id });
} }
res.json({ success: true }); res.json({ success: true });

146
server/routes/folders.js Normal file
View file

@ -0,0 +1,146 @@
const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const { PLATFORM_ROLES } = require('../middleware/auth');
// Phase 2.2c: workspace-aware access. Mirrors devices.js / content.js.
const { accessContext } = require('../lib/tenancy');
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Per-workspace folder cap. The route has no rate limit (multer doesn't go
// through the global API limiter chain), so without a count cap a workspace
// could insert millions of rows. 100 is generous for a real org hierarchy.
const MAX_FOLDERS_PER_WORKSPACE = 100;
// Resolve a folder and the caller's access to its workspace. Returns:
// { row, ctx } - access granted; ctx.workspaceRole / ctx.actingAs available
// { row: { id: null } } - root (no folder id supplied) - always accessible
// null - folder not found or no access
//
// Platform-template folders (workspace_id IS NULL) are readable by anyone.
// Writable only by platform_admin (same shape as content.js).
function accessibleFolder(req, folderId, requireWrite = false) {
if (!folderId) return { row: { id: null }, ctx: null };
if (!UUID_RE.test(folderId)) return null;
const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId);
if (!row) return null;
// Platform-template path
if (!row.workspace_id) {
if (requireWrite && !PLATFORM_ROLES.includes(req.user.role)) return null;
return { row, ctx: null };
}
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(row.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) return null;
if (requireWrite && !ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') return null;
return { row, ctx };
}
// List folders accessible to the caller in their current workspace.
// Includes platform-template folders (workspace_id IS NULL) for everyone.
router.get('/', (req, res) => {
if (!req.workspaceId) return res.json([]);
const rows = db.prepare(
'SELECT * FROM content_folders WHERE (workspace_id = ? OR workspace_id IS NULL) ORDER BY name COLLATE NOCASE'
).all(req.workspaceId);
res.json(rows);
});
// Create a folder in the caller's current workspace.
router.post('/', (req, res) => {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating folders.' });
const name = (req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'name is required' });
if (name.length > 100) return res.status(400).json({ error: 'name too long' });
// Per-workspace cap. Platform_admin exempt (cross-workspace admin tooling).
if (!PLATFORM_ROLES.includes(req.user.role)) {
const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE workspace_id = ?').get(req.workspaceId);
if (count >= MAX_FOLDERS_PER_WORKSPACE) {
return res.status(429).json({
error: `Folder limit reached (${MAX_FOLDERS_PER_WORKSPACE}). Delete unused folders before creating more.`
});
}
}
const parentId = req.body.parent_id || null;
if (parentId) {
const parent = accessibleFolder(req, parentId, true);
if (!parent || parent.row.id === null) return res.status(400).json({ error: 'Invalid parent_id' });
// Parent must be in the same workspace as the new folder.
if (parent.row.workspace_id !== req.workspaceId) {
return res.status(400).json({ error: 'Parent folder is in a different workspace' });
}
}
const id = uuidv4();
db.prepare(
'INSERT INTO content_folders (id, user_id, workspace_id, parent_id, name) VALUES (?, ?, ?, ?, ?)'
).run(id, req.user.id, req.workspaceId, parentId, name);
res.status(201).json(db.prepare('SELECT * FROM content_folders WHERE id = ?').get(id));
});
// Rename / move a folder.
router.put('/:id', (req, res) => {
const access = accessibleFolder(req, req.params.id, true);
if (!access || access.row.id === null) return res.status(404).json({ error: 'Folder not found' });
const folder = access.row;
const updates = [];
const values = [];
if (req.body.name !== undefined) {
const name = String(req.body.name).trim();
if (!name) return res.status(400).json({ error: 'name cannot be empty' });
if (name.length > 100) return res.status(400).json({ error: 'name too long' });
updates.push('name = ?');
values.push(name);
}
if (req.body.parent_id !== undefined) {
const newParent = req.body.parent_id || null;
if (newParent === folder.id) return res.status(400).json({ error: 'Folder cannot be its own parent' });
if (newParent) {
const parent = accessibleFolder(req, newParent, true);
if (!parent || parent.row.id === null) return res.status(400).json({ error: 'Invalid parent_id' });
// New parent must be in the same workspace as this folder.
if (parent.row.workspace_id !== folder.workspace_id) {
return res.status(400).json({ error: 'Cannot move folder to a parent in another workspace' });
}
// Reject cycles: walk up from the new parent and ensure we never hit this folder.
let cursor = parent.row;
const seen = new Set([folder.id]);
while (cursor && cursor.parent_id) {
if (seen.has(cursor.parent_id)) {
return res.status(400).json({ error: 'Move would create a cycle' });
}
seen.add(cursor.parent_id);
cursor = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(cursor.parent_id);
}
}
updates.push('parent_id = ?');
values.push(newParent);
}
if (updates.length === 0) return res.json(folder);
values.push(folder.id);
db.prepare(`UPDATE content_folders SET ${updates.join(', ')} WHERE id = ?`).run(...values);
res.json(db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folder.id));
});
// Delete a folder. Content inside it falls back to root via ON DELETE SET NULL.
// Subfolders cascade-delete; if the user wants to keep them they should move them first.
router.delete('/:id', (req, res) => {
const access = accessibleFolder(req, req.params.id, true);
if (!access || access.row.id === null) return res.status(404).json({ error: 'Folder not found' });
db.prepare('DELETE FROM content_folders WHERE id = ?').run(access.row.id);
res.json({ success: true });
});
module.exports = router;

View file

@ -2,6 +2,9 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2e: workspace-aware access. Same pattern as content/widgets/folders.
const { accessContext } = require('../lib/tenancy');
// Escape HTML to prevent XSS // Escape HTML to prevent XSS
function escapeHtml(str) { function escapeHtml(str) {
@ -22,28 +25,52 @@ function safeNumber(val, fallback) {
return isFinite(n) ? n : fallback; return isFinite(n) ? n : fallback;
} }
// List kiosk pages // List kiosk pages in the caller's current workspace plus any platform-template
// rows (workspace_id IS NULL) shared with all workspaces.
// Phase 2.2e: workspace-scoped. Cross-workspace visibility comes from
// switch-workspace, not a special list branch.
router.get('/', (req, res) => { router.get('/', (req, res) => {
const isAdmin = req.user.role === 'superadmin'; if (!req.workspaceId) return res.json([]);
const pages = db.prepare( const pages = db.prepare(
`SELECT * FROM kiosk_pages ${isAdmin ? '' : 'WHERE user_id = ?'} ORDER BY created_at DESC` 'SELECT * FROM kiosk_pages WHERE (workspace_id = ? OR workspace_id IS NULL) ORDER BY created_at DESC'
).all(...(isAdmin ? [] : [req.user.id])); ).all(req.workspaceId);
res.json(pages); res.json(pages);
}); });
// Helper: check kiosk ownership // Phase 2.2e: workspace-aware access. Mirrors widgets/content helpers.
function checkKioskAccess(req, res) { // Platform-template kiosks (workspace_id IS NULL) are readable by anyone
// authenticated and writable only by platform_admin.
function checkKioskRead(req, res) {
const page = db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id); const page = db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id);
if (!page) { res.status(404).json({ error: 'Page not found' }); return null; } if (!page) { res.status(404).json({ error: 'Page not found' }); return null; }
if (req.user && !['admin','superadmin'].includes(req.user.role) && page.user_id !== req.user.id) { if (!page.workspace_id) return page;
res.status(403).json({ error: 'Access denied' }); return null; const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(page.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
return page;
}
function checkKioskWrite(req, res) {
const page = db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id);
if (!page) { res.status(404).json({ error: 'Page not found' }); return null; }
if (!page.workspace_id) {
if (!PLATFORM_ROLES.includes(req.user.role)) {
res.status(403).json({ error: 'Platform admin required to modify shared kiosk pages' }); return null;
}
return page;
}
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(page.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
} }
return page; return page;
} }
// Get kiosk page // Get kiosk page
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
const page = checkKioskAccess(req, res); const page = checkKioskRead(req, res);
if (!page) return; if (!page) return;
res.json(page); res.json(page);
}); });
@ -134,14 +161,14 @@ router.get('/:id/render', (req, res) => {
}); });
}); });
// Idle screen after ${config.idleTimeout || 60} seconds of no interaction // Idle screen after ${safeNumber(config.idleTimeout, 60)} seconds of no interaction
let idleTimer; let idleTimer;
function resetIdleTimer() { function resetIdleTimer() {
document.getElementById('idleOverlay').style.display = 'none'; document.getElementById('idleOverlay').style.display = 'none';
clearTimeout(idleTimer); clearTimeout(idleTimer);
idleTimer = setTimeout(() => { idleTimer = setTimeout(() => {
document.getElementById('idleOverlay').style.display = 'flex'; document.getElementById('idleOverlay').style.display = 'flex';
}, ${(config.idleTimeout || 60) * 1000}); }, ${safeNumber(config.idleTimeout, 60) * 1000});
} }
document.getElementById('idleOverlay').addEventListener('click', resetIdleTimer); document.getElementById('idleOverlay').addEventListener('click', resetIdleTimer);
['touchstart', 'click', 'mousemove'].forEach(e => document.addEventListener(e, resetIdleTimer)); ['touchstart', 'click', 'mousemove'].forEach(e => document.addEventListener(e, resetIdleTimer));
@ -157,21 +184,22 @@ router.get('/:id/render', (req, res) => {
res.send(html); res.send(html);
}); });
// Create kiosk page // Create kiosk page in the caller's current workspace.
router.post('/', (req, res) => { router.post('/', (req, res) => {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating kiosk pages.' });
const { name, config: pageConfig } = req.body; const { name, config: pageConfig } = req.body;
if (!name) return res.status(400).json({ error: 'name required' }); if (!name) return res.status(400).json({ error: 'name required' });
const id = uuidv4(); const id = uuidv4();
db.prepare('INSERT INTO kiosk_pages (id, user_id, name, config) VALUES (?, ?, ?, ?)') db.prepare('INSERT INTO kiosk_pages (id, user_id, workspace_id, name, config) VALUES (?, ?, ?, ?, ?)')
.run(id, req.user.id, name, JSON.stringify(pageConfig || getDefaultKioskConfig())); .run(id, req.user.id, req.workspaceId, name, JSON.stringify(pageConfig || getDefaultKioskConfig()));
res.status(201).json(db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(id)); res.status(201).json(db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(id));
}); });
// Update kiosk page // Update kiosk page
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const page = checkKioskAccess(req, res); const page = checkKioskWrite(req, res);
if (!page) return; if (!page) return;
const { name, config: pageConfig } = req.body; const { name, config: pageConfig } = req.body;
@ -184,7 +212,7 @@ router.put('/:id', (req, res) => {
// Delete kiosk page // Delete kiosk page
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const page = checkKioskAccess(req, res); const page = checkKioskWrite(req, res);
if (!page) return; if (!page) return;
db.prepare('DELETE FROM kiosk_pages WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM kiosk_pages WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });

View file

@ -2,19 +2,28 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2h: workspace-aware access. Templates (is_template=1) are the
// platform-shared pair (NULL user_id, NULL workspace_id) and are visible
// everywhere, writable only by platform_admin.
const { accessContext } = require('../lib/tenancy');
// List layouts (user's + templates) // List layouts in the caller's current workspace plus all templates.
// Phase 2.2h: workspace-scoped. Templates (is_template=1) remain visible to
// everyone; cross-workspace owned-layout visibility comes from switch-workspace.
router.get('/', (req, res) => { router.get('/', (req, res) => {
const showTemplates = req.query.templates === 'true'; const showTemplates = req.query.templates === 'true';
const isAdmin = req.user.role === 'superadmin';
let layouts; let layouts;
if (showTemplates) { if (showTemplates) {
layouts = db.prepare('SELECT * FROM layouts WHERE is_template = 1 ORDER BY template_category, name').all(); layouts = db.prepare('SELECT * FROM layouts WHERE is_template = 1 ORDER BY template_category, name').all();
} else if (!req.workspaceId) {
// No workspace context -> only templates are visible.
layouts = db.prepare('SELECT * FROM layouts WHERE is_template = 1 ORDER BY template_category, name').all();
} else { } else {
layouts = db.prepare( layouts = db.prepare(
`SELECT * FROM layouts WHERE (user_id = ? OR is_template = 1) ${isAdmin ? 'OR 1=1' : ''} ORDER BY is_template DESC, created_at DESC` 'SELECT * FROM layouts WHERE (workspace_id = ? OR is_template = 1) ORDER BY is_template DESC, created_at DESC'
).all(req.user.id); ).all(req.workspaceId);
} }
// Attach zones to each layout // Attach zones to each layout
@ -24,33 +33,64 @@ router.get('/', (req, res) => {
res.json(layouts); res.json(layouts);
}); });
// Helper: check layout access (owner, admin, or template) // Phase 2.2h: workspace-aware access. Mirrors content/widget/kiosk helpers.
function checkLayoutAccess(req, res) { // Templates (is_template=1) are readable by anyone authenticated; writable
// only by platform_admin (kept layered with the existing L78/L94 guards).
function checkLayoutRead(req, res) {
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return null; } if (!layout) { res.status(404).json({ error: 'Layout not found' }); return null; }
if (!layout.is_template && !['admin','superadmin'].includes(req.user.role) && layout.user_id !== req.user.id) { if (layout.is_template) return layout;
res.status(403).json({ error: 'Access denied' }); return null; if (!layout.workspace_id) {
// Owned row with no workspace - treat as inaccessible (shouldn't exist post-migration).
res.status(403).json({ error: 'Layout not assigned to a workspace' }); return null;
}
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(layout.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
return layout;
}
function checkLayoutWrite(req, res) {
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return null; }
if (layout.is_template) {
// Templates: only platform_admin may write. Existing L78/L94 also check
// is_template explicitly with the same intent; this is the layered gate.
if (!PLATFORM_ROLES.includes(req.user.role)) {
res.status(403).json({ error: 'Platform admin required to modify templates' }); return null;
}
return layout;
}
if (!layout.workspace_id) {
res.status(403).json({ error: 'Layout not assigned to a workspace' }); return null;
}
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(layout.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
} }
return layout; return layout;
} }
// Get layout with zones // Get layout with zones
router.get('/:id', (req, res) => { router.get('/:id', (req, res) => {
const layout = checkLayoutAccess(req, res); const layout = checkLayoutRead(req, res);
if (!layout) return; if (!layout) return;
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id); layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id);
res.json(layout); res.json(layout);
}); });
// Create layout // Create layout in the caller's current workspace.
router.post('/', (req, res) => { router.post('/', (req, res) => {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating layouts.' });
const { name, width, height, zones } = req.body; const { name, width, height, zones } = req.body;
if (!name) return res.status(400).json({ error: 'name required' }); if (!name) return res.status(400).json({ error: 'name required' });
const id = uuidv4(); const id = uuidv4();
db.prepare('INSERT INTO layouts (id, user_id, name, width, height) VALUES (?, ?, ?, ?, ?)') db.prepare('INSERT INTO layouts (id, user_id, workspace_id, name, width, height) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, req.user.id, name, width || 1920, height || 1080); .run(id, req.user.id, req.workspaceId, name, width || 1920, height || 1080);
// Create zones if provided // Create zones if provided
if (zones && Array.isArray(zones)) { if (zones && Array.isArray(zones)) {
@ -72,9 +112,9 @@ router.post('/', (req, res) => {
// Update layout // Update layout
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const layout = checkLayoutAccess(req, res); const layout = checkLayoutWrite(req, res);
if (!layout) return; if (!layout) return;
if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' }); if (layout.is_template && !PLATFORM_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' });
const { name, width, height } = req.body; const { name, width, height } = req.body;
if (name) db.prepare('UPDATE layouts SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id); if (name) db.prepare('UPDATE layouts SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id);
@ -88,17 +128,18 @@ router.put('/:id', (req, res) => {
// Delete layout // Delete layout
router.delete('/:id', (req, res) => { router.delete('/:id', (req, res) => {
const layout = checkLayoutAccess(req, res); const layout = checkLayoutWrite(req, res);
if (!layout) return; if (!layout) return;
if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot delete templates' }); if (layout.is_template && !PLATFORM_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Cannot delete templates' });
db.prepare('DELETE FROM layouts WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM layouts WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
}); });
// Add zone to layout // Add zone to layout. Phase 2.2h: tightened to write-access; workspace_viewer
// can read the layout via GET but cannot add zones.
router.post('/:id/zones', (req, res) => { router.post('/:id/zones', (req, res) => {
const layout = checkLayoutAccess(req, res); const layout = checkLayoutWrite(req, res);
if (!layout) return; if (!layout) return;
const { name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color } = req.body; const { name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color } = req.body;
@ -120,7 +161,7 @@ router.post('/:id/zones', (req, res) => {
// Update zone // Update zone
router.put('/:id/zones/:zoneId', (req, res) => { router.put('/:id/zones/:zoneId', (req, res) => {
const layout = checkLayoutAccess(req, res); const layout = checkLayoutWrite(req, res);
if (!layout) return; if (!layout) return;
const zone = db.prepare('SELECT * FROM layout_zones WHERE id = ? AND layout_id = ?').get(req.params.zoneId, req.params.id); const zone = db.prepare('SELECT * FROM layout_zones WHERE id = ? AND layout_id = ?').get(req.params.zoneId, req.params.id);
if (!zone) return res.status(404).json({ error: 'Zone not found' }); if (!zone) return res.status(404).json({ error: 'Zone not found' });
@ -144,23 +185,25 @@ router.put('/:id/zones/:zoneId', (req, res) => {
// Delete zone // Delete zone
router.delete('/:id/zones/:zoneId', (req, res) => { router.delete('/:id/zones/:zoneId', (req, res) => {
const layout = checkLayoutAccess(req, res); const layout = checkLayoutWrite(req, res);
if (!layout) return; if (!layout) return;
db.prepare('DELETE FROM layout_zones WHERE id = ? AND layout_id = ?').run(req.params.zoneId, req.params.id); db.prepare('DELETE FROM layout_zones WHERE id = ? AND layout_id = ?').run(req.params.zoneId, req.params.id);
db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
res.json({ success: true }); res.json({ success: true });
}); });
// Duplicate layout (for using templates) // Duplicate layout (for using templates). Source needs read-access only;
// destination lands in the caller's current workspace.
router.post('/:id/duplicate', (req, res) => { router.post('/:id/duplicate', (req, res) => {
const source = checkLayoutAccess(req, res); if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before duplicating a layout.' });
const source = checkLayoutRead(req, res);
if (!source) return; if (!source) return;
const newId = uuidv4(); const newId = uuidv4();
const name = req.body.name || `${source.name} (Copy)`; const name = req.body.name || `${source.name} (Copy)`;
db.prepare('INSERT INTO layouts (id, user_id, name, width, height) VALUES (?, ?, ?, ?, ?)') db.prepare('INSERT INTO layouts (id, user_id, workspace_id, name, width, height) VALUES (?, ?, ?, ?, ?, ?)')
.run(newId, req.user.id, name, source.width, source.height); .run(newId, req.user.id, req.workspaceId, name, source.width, source.height);
// Copy zones // Copy zones
const zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ?').all(req.params.id); const zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ?').all(req.params.id);
@ -178,12 +221,38 @@ router.post('/:id/duplicate', (req, res) => {
res.status(201).json(layout); res.status(201).json(layout);
}); });
// Assign layout to device // Assign layout to device.
// Phase 2.2h: closes a pre-existing cross-tenant leak. Today the route only
// gated by device-ownership and didn't verify the layout_id at all, so any
// caller with write access to a device could assign another workspace's
// layout to it - the player would then render foreign zones/dimensions.
//
// New rules:
// 1. Caller must have write access to the DEVICE's workspace.
// 2. The layout must be either a template (is_template=1) or live in the
// same workspace as the device.
router.put('/device/:deviceId', (req, res) => { router.put('/device/:deviceId', (req, res) => {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); const device = db.prepare('SELECT user_id, workspace_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
const deviceWs = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
const ctx = deviceWs && accessContext(req.user.id, req.user.role, deviceWs);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
return res.status(403).json({ error: 'Read-only access' });
}
const { layout_id } = req.body; const { layout_id } = req.body;
if (layout_id) {
const layout = db.prepare('SELECT is_template, workspace_id FROM layouts WHERE id = ?').get(layout_id);
if (!layout) return res.status(400).json({ error: 'Invalid layout_id' });
// Layout must be a template, or live in the device's workspace.
if (!layout.is_template && layout.workspace_id !== device.workspace_id) {
return res.status(403).json({ error: 'Layout is not in this device\'s workspace and is not a template' });
}
}
db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?") db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(layout_id || null, req.params.deviceId); .run(layout_id || null, req.params.deviceId);
res.json({ success: true }); res.json({ success: true });

View file

@ -4,6 +4,9 @@ const path = require('path');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
const config = require('../config'); const config = require('../config');
// Phase 2.2k: workspace-aware access. requirePlaylistOwnership is replaced
// by read/write helpers gated on the playlist's workspace_id.
const { accessContext } = require('../lib/tenancy');
// Re-probe video duration with ffprobe if content.duration_sec is missing // Re-probe video duration with ffprobe if content.duration_sec is missing
async function probeAndUpdateDuration(content) { async function probeAndUpdateDuration(content) {
@ -33,42 +36,107 @@ async function probeAndUpdateDuration(content) {
return null; return null;
} }
// Verify playlist belongs to the authenticated user // Phase 2.2k: workspace-aware playlist access. Returns the playlist row (with
function requirePlaylistOwnership(req, res, next) { // req.playlistCtx populated) or sends 403/404. requireWrite=false for reads.
const playlist = db.prepare('SELECT * FROM playlists WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); function loadPlaylistAccess(req, res, requireWrite) {
if (!playlist) return res.status(404).json({ error: 'playlist not found' }); const playlist = db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id);
if (!playlist) { res.status(404).json({ error: 'playlist not found' }); return null; }
if (!playlist.workspace_id) { res.status(403).json({ error: 'Playlist not assigned to a workspace' }); return null; }
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(playlist.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
if (requireWrite && !ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
}
req.playlist = playlist; req.playlist = playlist;
req.playlistCtx = ctx;
return playlist;
}
function requirePlaylistRead(req, res, next) {
if (!loadPlaylistAccess(req, res, false)) return;
next(); next();
} }
// List playlists function requirePlaylistWrite(req, res, next) {
if (!loadPlaylistAccess(req, res, true)) return;
next();
}
// Build the snapshot item list for a playlist (denormalized for device payload)
function buildSnapshotItems(playlistId) {
return db.prepare(`
SELECT pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
c.duration_sec as content_duration, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(playlistId);
}
// Mark playlist as draft (called after item mutations from the playlist detail UI)
function markDraft(playlistId) {
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
}
// Push playlist update to all devices using this playlist
function pushToDevices(playlistId, req) {
try {
const io = req.app.get('io');
if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
const deviceNs = io.of('/device');
const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(playlistId);
for (const d of devices) {
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, d.id, buildPlaylistPayload);
}
} catch (e) { /* silent */ }
}
// Phase 2.2k: list scoped to caller's current workspace. No platform_admin
// bypass - cross-workspace view comes from switch-workspace, matching the
// precedent established across all other migrated routes.
router.get('/', (req, res) => { router.get('/', (req, res) => {
if (!req.workspaceId) return res.json([]);
const playlists = db.prepare(` const playlists = db.prepare(`
SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count
FROM playlists p FROM playlists p
LEFT JOIN playlist_items pi ON p.id = pi.playlist_id LEFT JOIN playlist_items pi ON p.id = pi.playlist_id
LEFT JOIN devices d ON d.playlist_id = p.id LEFT JOIN devices d ON d.playlist_id = p.id
WHERE p.user_id = ? WHERE p.workspace_id = ?
GROUP BY p.id GROUP BY p.id
ORDER BY p.name ASC ORDER BY p.name ASC
`).all(req.user.id); `).all(req.workspaceId);
res.json(playlists); res.json(playlists);
}); });
// Create playlist // Phase 2.2k: create stamps workspace_id from req.workspaceId. Viewer-deny
// gate so workspace_viewers cannot create playlists in their workspace.
router.post('/', (req, res) => { router.post('/', (req, res) => {
if (!req.workspaceId) return res.status(400).json({ error: 'No active workspace' });
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.workspaceId);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
return res.status(403).json({ error: 'Read-only access' });
}
const { name, description } = req.body; const { name, description } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'name required' }); if (!name || !name.trim()) return res.status(400).json({ error: 'name required' });
const id = uuidv4(); const id = uuidv4();
db.prepare('INSERT INTO playlists (id, user_id, name, description) VALUES (?, ?, ?, ?)') db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, description) VALUES (?, ?, ?, ?, ?)')
.run(id, req.user.id, name.trim(), (description || '').trim()); .run(id, req.user.id, req.workspaceId, name.trim(), (description || '').trim());
res.status(201).json(db.prepare(` res.status(201).json(db.prepare(`
SELECT p.*, 0 as item_count, 0 as display_count FROM playlists p WHERE p.id = ? SELECT p.*, 0 as item_count, 0 as display_count FROM playlists p WHERE p.id = ?
`).get(id)); `).get(id));
}); });
// Get single playlist with items // Get single playlist with items
router.get('/:id', requirePlaylistOwnership, (req, res) => { router.get('/:id', requirePlaylistRead, (req, res) => {
const items = db.prepare(` const items = db.prepare(`
SELECT pi.*, SELECT pi.*,
COALESCE(c.filename, w.name) as filename, COALESCE(c.filename, w.name) as filename,
@ -86,7 +154,7 @@ router.get('/:id', requirePlaylistOwnership, (req, res) => {
}); });
// Update playlist // Update playlist
router.put('/:id', requirePlaylistOwnership, (req, res) => { router.put('/:id', requirePlaylistWrite, (req, res) => {
const { name, description } = req.body; const { name, description } = req.body;
const updates = []; const updates = [];
const values = []; const values = [];
@ -107,8 +175,84 @@ router.put('/:id', requirePlaylistOwnership, (req, res) => {
res.json(db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id)); res.json(db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id));
}); });
// Publish playlist — snapshot current items and push to devices
router.post('/:id/publish', requirePlaylistWrite, (req, res) => {
// Snapshot shape (no pi.id) is intentional — published_snapshot is consumed
// by devices and stored as JSON; row IDs there would be misleading.
const snapshotItems = buildSnapshotItems(req.params.id);
db.prepare("UPDATE playlists SET status = 'published', published_snapshot = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(JSON.stringify(snapshotItems), req.params.id);
pushToDevices(req.params.id, req);
// UI response shape must include pi.id so the post-publish render can wire
// per-row delete/duration listeners. TODO: refactor to share this SELECT
// with GET /:id (also duplicated in /discard and POST /:id/items/reorder).
const items = db.prepare(`
SELECT pi.*,
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(req.params.id);
res.json({ ...db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id), items });
});
// Discard draft — revert playlist_items to match published_snapshot
router.post('/:id/discard', requirePlaylistWrite, (req, res) => {
const playlist = req.playlist;
if (!playlist.published_snapshot) {
return res.status(400).json({ error: 'No published version to revert to' });
}
if (playlist.status === 'published') {
return res.status(400).json({ error: 'Playlist has no unpublished changes' });
}
let publishedItems;
try { publishedItems = JSON.parse(playlist.published_snapshot); } catch (e) {
return res.status(500).json({ error: 'Corrupt published snapshot' });
}
const transaction = db.transaction(() => {
// Clear current draft items
db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(req.params.id);
// Re-insert from snapshot, skipping items whose content/widget was deleted
const insert = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?, ?)');
for (const item of publishedItems) {
try {
insert.run(req.params.id, item.content_id || null, item.widget_id || null, item.zone_id || null, item.sort_order, item.duration_sec);
} catch (e) {
if (e.message.includes('FOREIGN KEY')) {
console.warn(`Discard: skipping snapshot item (content_id=${item.content_id}, widget_id=${item.widget_id}) — referenced entity was deleted`);
continue;
}
throw e;
}
}
db.prepare("UPDATE playlists SET status = 'published', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
});
transaction();
const items = db.prepare(`
SELECT pi.*,
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(req.params.id);
res.json({ ...db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id), items });
});
// Delete playlist // Delete playlist
router.delete('/:id', requirePlaylistOwnership, (req, res) => { router.delete('/:id', requirePlaylistWrite, (req, res) => {
db.prepare('DELETE FROM playlists WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM playlists WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
}); });
@ -116,7 +260,7 @@ router.delete('/:id', requirePlaylistOwnership, (req, res) => {
// --- Playlist Items --- // --- Playlist Items ---
// List items // List items
router.get('/:id/items', requirePlaylistOwnership, (req, res) => { router.get('/:id/items', requirePlaylistRead, (req, res) => {
const items = db.prepare(` const items = db.prepare(`
SELECT pi.*, SELECT pi.*,
COALESCE(c.filename, w.name) as filename, COALESCE(c.filename, w.name) as filename,
@ -132,8 +276,15 @@ router.get('/:id/items', requirePlaylistOwnership, (req, res) => {
res.json(items); res.json(items);
}); });
// Add item // Phase 2.2k: add item closes 2 pre-existing cross-tenant leaks:
router.post('/:id/items', requirePlaylistOwnership, async (req, res) => { // 1. Content gate: today checks content.user_id == caller. A workspace_admin
// who owns content in another workspace could push it into a playlist
// in this workspace. Now: content must be in playlist's workspace (or
// be a platform-template, workspace_id IS NULL).
// 2. Widget gate: today checks ONLY existence - any user could attach any
// widget UUID to a playlist they could reach. Now: widget must be in
// playlist's workspace (or be a platform-template).
router.post('/:id/items', requirePlaylistWrite, async (req, res) => {
try { try {
const { content_id, widget_id, sort_order } = req.body; const { content_id, widget_id, sort_order } = req.body;
let { duration_sec } = req.body; let { duration_sec } = req.body;
@ -143,23 +294,24 @@ router.post('/:id/items', requirePlaylistOwnership, async (req, res) => {
return res.status(400).json({ error: 'duration_sec must be a positive integer' }); return res.status(400).json({ error: 'duration_sec must be a positive integer' });
} }
// Validate content ownership; use content's native duration as default for videos
if (content_id) { if (content_id) {
const content = db.prepare('SELECT id, user_id, duration_sec, mime_type, filepath FROM content WHERE id = ?').get(content_id); const content = db.prepare('SELECT id, workspace_id, duration_sec, mime_type, filepath FROM content WHERE id = ?').get(content_id);
if (!content) return res.status(404).json({ error: 'Content not found' }); if (!content) return res.status(404).json({ error: 'Content not found' });
if (!['admin', 'superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) { if (content.workspace_id && content.workspace_id !== req.playlist.workspace_id) {
return res.status(403).json({ error: 'Content not owned by you' }); return res.status(403).json({ error: 'Content is not in this playlist\'s workspace' });
} }
if (duration_sec === undefined || duration_sec === null) { if (duration_sec === undefined || duration_sec === null) {
// Use stored duration, or re-probe if missing (backfills content table too)
const contentDur = await probeAndUpdateDuration(content); const contentDur = await probeAndUpdateDuration(content);
if (contentDur) duration_sec = Math.ceil(contentDur); if (contentDur) duration_sec = Math.ceil(contentDur);
} }
} }
if (duration_sec === undefined || duration_sec === null) duration_sec = 10; if (duration_sec === undefined || duration_sec === null) duration_sec = 10;
if (widget_id) { if (widget_id) {
const widget = db.prepare('SELECT id FROM widgets WHERE id = ?').get(widget_id); const widget = db.prepare('SELECT id, workspace_id FROM widgets WHERE id = ?').get(widget_id);
if (!widget) return res.status(404).json({ error: 'Widget not found' }); if (!widget) return res.status(404).json({ error: 'Widget not found' });
if (widget.workspace_id && widget.workspace_id !== req.playlist.workspace_id) {
return res.status(403).json({ error: 'Widget is not in this playlist\'s workspace' });
}
} }
// Auto-increment sort_order if not specified // Auto-increment sort_order if not specified
@ -175,8 +327,8 @@ router.post('/:id/items', requirePlaylistOwnership, async (req, res) => {
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(req.params.id, content_id || null, widget_id || null, order, duration_sec); `).run(req.params.id, content_id || null, widget_id || null, order, duration_sec);
// Touch playlist updated_at // Mark as draft (items changed since last publish)
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); markDraft(req.params.id);
const item = db.prepare(` const item = db.prepare(`
SELECT pi.*, SELECT pi.*,
@ -198,7 +350,7 @@ router.post('/:id/items', requirePlaylistOwnership, async (req, res) => {
}); });
// Update item // Update item
router.put('/:id/items/:itemId', requirePlaylistOwnership, (req, res) => { router.put('/:id/items/:itemId', requirePlaylistWrite, (req, res) => {
const item = db.prepare('SELECT * FROM playlist_items WHERE id = ? AND playlist_id = ?') const item = db.prepare('SELECT * FROM playlist_items WHERE id = ? AND playlist_id = ?')
.get(req.params.itemId, req.params.id); .get(req.params.itemId, req.params.id);
if (!item) return res.status(404).json({ error: 'item not found' }); if (!item) return res.status(404).json({ error: 'item not found' });
@ -220,7 +372,7 @@ router.put('/:id/items/:itemId', requirePlaylistOwnership, (req, res) => {
updates.push("updated_at = strftime('%s','now')"); updates.push("updated_at = strftime('%s','now')");
values.push(req.params.itemId); values.push(req.params.itemId);
db.prepare(`UPDATE playlist_items SET ${updates.join(', ')} WHERE id = ?`).run(...values); db.prepare(`UPDATE playlist_items SET ${updates.join(', ')} WHERE id = ?`).run(...values);
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); markDraft(req.params.id);
} }
const updated = db.prepare(` const updated = db.prepare(`
@ -238,18 +390,18 @@ router.put('/:id/items/:itemId', requirePlaylistOwnership, (req, res) => {
}); });
// Delete item // Delete item
router.delete('/:id/items/:itemId', requirePlaylistOwnership, (req, res) => { router.delete('/:id/items/:itemId', requirePlaylistWrite, (req, res) => {
const item = db.prepare('SELECT * FROM playlist_items WHERE id = ? AND playlist_id = ?') const item = db.prepare('SELECT * FROM playlist_items WHERE id = ? AND playlist_id = ?')
.get(req.params.itemId, req.params.id); .get(req.params.itemId, req.params.id);
if (!item) return res.status(404).json({ error: 'item not found' }); if (!item) return res.status(404).json({ error: 'item not found' });
db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.itemId); db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.itemId);
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); markDraft(req.params.id);
res.json({ success: true }); res.json({ success: true });
}); });
// Reorder items // Reorder items
router.post('/:id/items/reorder', requirePlaylistOwnership, (req, res) => { router.post('/:id/items/reorder', requirePlaylistWrite, (req, res) => {
const { order } = req.body; const { order } = req.body;
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item IDs' }); if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item IDs' });
@ -261,7 +413,7 @@ router.post('/:id/items/reorder', requirePlaylistOwnership, (req, res) => {
}); });
transaction(); transaction();
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); markDraft(req.params.id);
const items = db.prepare(` const items = db.prepare(`
SELECT pi.*, SELECT pi.*,
@ -278,15 +430,18 @@ router.post('/:id/items/reorder', requirePlaylistOwnership, (req, res) => {
res.json(items); res.json(items);
}); });
// Assign playlist to a device // Assign playlist to a device. Phase 2.2k: closes a pre-existing cross-tenant
router.post('/:id/assign', requirePlaylistOwnership, (req, res) => { // leak. Today checks device.user_id only; a caller with reach into a foreign
// workspace could assign their own playlist to a device in that workspace
// (or vice versa). Now: device must be in the playlist's workspace.
router.post('/:id/assign', requirePlaylistWrite, (req, res) => {
const { device_id } = req.body; const { device_id } = req.body;
if (!device_id) return res.status(400).json({ error: 'device_id required' }); if (!device_id) return res.status(400).json({ error: 'device_id required' });
const device = db.prepare('SELECT id, user_id FROM devices WHERE id = ?').get(device_id); const device = db.prepare('SELECT id, workspace_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin', 'superadmin'].includes(req.user.role) && device.user_id !== req.user.id) { if (device.workspace_id !== req.playlist.workspace_id) {
return res.status(403).json({ error: 'Device not owned by you' }); return res.status(403).json({ error: 'Device is not in this playlist\'s workspace' });
} }
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(req.params.id, device_id); db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(req.params.id, device_id);
@ -296,7 +451,8 @@ router.post('/:id/assign', requirePlaylistOwnership, (req, res) => {
const io = req.app.get('io'); const io = req.app.get('io');
if (io) { if (io) {
const { buildPlaylistPayload } = require('../ws/deviceSocket'); const { buildPlaylistPayload } = require('../ws/deviceSocket');
io.of('/device').to(device_id).emit('device:playlist-update', buildPlaylistPayload(device_id)); const commandQueue = require('../lib/command-queue');
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), device_id, buildPlaylistPayload);
} }
} catch (e) { /* silent */ } } catch (e) { /* silent */ }

View file

@ -2,16 +2,24 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { db } = require('../db/database'); const { db } = require('../db/database');
// Helper: scope reports to user's devices // Phase 2.2g: scope reports to the caller's current workspace.
function getUserDeviceFilter(user) { // No platform_admin bypass - cross-workspace reporting comes from
if (user.role === 'superadmin') return { sql: '', params: [] }; // switch-workspace, not a magic role-based "see all" path. This matches
return { sql: ' AND d.user_id = ?', params: [user.id] }; // the precedent set in devices.js.
function getWorkspaceDeviceFilter(req) {
if (!req.workspaceId) return { sql: ' AND 1=0', params: [] }; // no workspace -> empty result
return { sql: ' AND d.workspace_id = ?', params: [req.workspaceId] };
}
function getWorkspaceDeviceSubquery(req) {
if (!req.workspaceId) return { sql: ' AND device_id IN (SELECT id FROM devices WHERE 1=0)', params: [] };
return { sql: ' AND device_id IN (SELECT id FROM devices WHERE workspace_id = ?)', params: [req.workspaceId] };
} }
// Query play logs // Query play logs
router.get('/plays', (req, res) => { router.get('/plays', (req, res) => {
const { device_id, content_id, start, end, limit: lim } = req.query; const { device_id, content_id, start, end, limit: lim } = req.query;
const scope = getUserDeviceFilter(req.user); const scope = getWorkspaceDeviceFilter(req);
let sql = `SELECT pl.*, d.name as device_name let sql = `SELECT pl.*, d.name as device_name
FROM play_logs pl FROM play_logs pl
JOIN devices d ON pl.device_id = d.id JOIN devices d ON pl.device_id = d.id
@ -35,13 +43,10 @@ router.get('/summary', (req, res) => {
const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400; const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400;
const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000); const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000);
let deviceFilter = ''; // Phase 2.2g: workspace-scope all summary queries, no admin bypass.
const params = [startEpoch, endEpoch]; const wsScope = getWorkspaceDeviceSubquery(req);
// Scope to user's devices (non-admin) let deviceFilter = wsScope.sql;
if (!['admin','superadmin'].includes(req.user.role)) { const params = [startEpoch, endEpoch, ...wsScope.params];
deviceFilter += ' AND device_id IN (SELECT id FROM devices WHERE user_id = ?)';
params.push(req.user.id);
}
if (device_id) { deviceFilter += ' AND device_id = ?'; params.push(device_id); } if (device_id) { deviceFilter += ' AND device_id = ?'; params.push(device_id); }
// Overall stats // Overall stats
@ -111,14 +116,17 @@ router.get('/summary', (req, res) => {
}); });
}); });
// Export CSV // Export CSV. Phase 2.2g: workspace-scoped. Previously this route had no scope
// filter at all - any authenticated user could export the entire platform's
// play_logs. The added WHERE clause closes that pre-existing cross-tenant leak.
router.get('/export', (req, res) => { router.get('/export', (req, res) => {
const { device_id, start, end } = req.query; const { device_id, start, end } = req.query;
const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : 0; const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : 0;
const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000); const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000);
let sql = `SELECT pl.*, d.name as device_name FROM play_logs pl JOIN devices d ON pl.device_id = d.id WHERE pl.started_at >= ? AND pl.started_at <= ?`; const scope = getWorkspaceDeviceFilter(req);
const params = [startEpoch, endEpoch]; let sql = `SELECT pl.*, d.name as device_name FROM play_logs pl JOIN devices d ON pl.device_id = d.id WHERE pl.started_at >= ? AND pl.started_at <= ?${scope.sql}`;
const params = [startEpoch, endEpoch, ...scope.params];
if (device_id) { sql += ' AND pl.device_id = ?'; params.push(device_id); } if (device_id) { sql += ' AND pl.device_id = ?'; params.push(device_id); }
sql += ' ORDER BY pl.started_at ASC'; sql += ' ORDER BY pl.started_at ASC';
@ -136,20 +144,24 @@ router.get('/export', (req, res) => {
res.send(csv); res.send(csv);
}); });
// Device uptime report // Device uptime report. Phase 2.2g: workspace-scoped. Previously this route
// had no scope filter at all - any authenticated user could see telemetry
// summaries for every device on the platform. The added WHERE clause closes
// that pre-existing cross-tenant leak.
router.get('/uptime', (req, res) => { router.get('/uptime', (req, res) => {
const { device_id, start, end } = req.query; const { device_id, start, end } = req.query;
const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400; const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400;
const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000); const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000);
const scope = getWorkspaceDeviceFilter(req);
let sql = `SELECT dt.device_id, d.name as device_name, let sql = `SELECT dt.device_id, d.name as device_name,
COUNT(*) as heartbeat_count, COUNT(*) as heartbeat_count,
MIN(dt.reported_at) as first_seen, MIN(dt.reported_at) as first_seen,
MAX(dt.reported_at) as last_seen MAX(dt.reported_at) as last_seen
FROM device_telemetry dt FROM device_telemetry dt
JOIN devices d ON dt.device_id = d.id JOIN devices d ON dt.device_id = d.id
WHERE dt.reported_at >= ? AND dt.reported_at <= ?`; WHERE dt.reported_at >= ? AND dt.reported_at <= ?${scope.sql}`;
const params = [startEpoch, endEpoch]; const params = [startEpoch, endEpoch, ...scope.params];
if (device_id) { sql += ' AND dt.device_id = ?'; params.push(device_id); } if (device_id) { sql += ' AND dt.device_id = ?'; params.push(device_id); }
sql += ' GROUP BY dt.device_id ORDER BY d.name'; sql += ' GROUP BY dt.device_id ORDER BY d.name';

View file

@ -2,14 +2,99 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
// Phase 2.2m: workspace-aware schedule access. Schedules inherit workspace_id
// from their target (device or device_group). All polymorphic references
// (content / widget / layout / playlist) must live in the same workspace as
// the target. This closes a long-standing leak where POST accepted those
// payload refs with no ownership check at all (only the target was checked).
const { accessContext } = require('../lib/tenancy');
// List schedules (filterable) // Helper: build the expanded schedule query for a device (device-level + group-level)
function getDeviceSchedulesQuery() {
return `
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name,
dg.name as group_name, dg.color as group_color
FROM schedules s
LEFT JOIN content c ON s.content_id = c.id
LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id
LEFT JOIN device_groups dg ON s.group_id = dg.id
WHERE s.enabled = 1
AND (
s.device_id = ?
OR s.group_id IN (
SELECT group_id FROM device_group_members WHERE device_id = ?
)
)
ORDER BY
CASE WHEN s.device_id IS NOT NULL THEN 1 ELSE 0 END DESC,
s.priority DESC,
s.created_at ASC
`;
}
// Load a schedule + access context, sending 403/404 on failure.
function loadScheduleAccess(req, res, requireWrite) {
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
if (!schedule) { res.status(404).json({ error: 'Schedule not found' }); return null; }
if (!schedule.workspace_id) { res.status(403).json({ error: 'Schedule not assigned to a workspace' }); return null; }
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(schedule.workspace_id);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
if (requireWrite && !ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
res.status(403).json({ error: 'Read-only access' }); return null;
}
req.schedule = schedule;
req.scheduleCtx = ctx;
return schedule;
}
function requireScheduleWrite(req, res, next) {
if (!loadScheduleAccess(req, res, true)) return;
next();
}
// Verify caller has at least read access to the given workspace (used when
// resolving the target's workspace before stamping a new schedule).
function workspaceAccess(req, workspaceId) {
if (!workspaceId) return null;
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(workspaceId);
if (!ws) return null;
return accessContext(req.user.id, req.user.role, ws);
}
// Verify a referenced row exists and lives in the given workspace. Returns
// null on success, or { status, error } on failure. Used for content / widget
// / layout / playlist refs (where workspace_id IS NULL is the platform-template
// path and is always allowed) and for devices / device_groups (where
// workspace_id is required - those tables never carry template rows).
function checkRefInWorkspace(table, id, workspaceId, opts = { allowNullWorkspace: false }) {
const row = db.prepare(`SELECT workspace_id FROM ${table} WHERE id = ?`).get(id);
if (!row) return { status: 404, error: `${table.replace(/_/g, ' ').slice(0, -1)} not found` };
if (row.workspace_id === workspaceId) return null;
if (opts.allowNullWorkspace && row.workspace_id == null) return null;
return { status: 403, error: `${table.replace(/_/g, ' ').slice(0, -1)} is not in this workspace` };
}
// List schedules (filterable). Phase 2.2m: workspace-scoped.
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { device_id, start, end } = req.query; if (!req.workspaceId) return res.json([]);
let sql = 'SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name FROM schedules s LEFT JOIN content c ON s.content_id = c.id LEFT JOIN widgets w ON s.widget_id = w.id LEFT JOIN playlists p ON s.playlist_id = p.id WHERE s.user_id = ?'; const { device_id, group_id, start, end } = req.query;
const params = [req.user.id]; let sql = `SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name,
dg.name as group_name, dg.color as group_color
FROM schedules s
LEFT JOIN content c ON s.content_id = c.id
LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id
LEFT JOIN device_groups dg ON s.group_id = dg.id
WHERE s.workspace_id = ?`;
const params = [req.workspaceId];
if (device_id) { sql += ' AND s.device_id = ?'; params.push(device_id); } if (device_id) {
sql += ` AND (s.device_id = ? OR s.group_id IN (SELECT group_id FROM device_group_members WHERE device_id = ?))`;
params.push(device_id, device_id);
}
if (group_id) { sql += ' AND s.group_id = ?'; params.push(group_id); }
if (start) { sql += ' AND s.end_time >= ?'; params.push(start); } if (start) { sql += ' AND s.end_time >= ?'; params.push(start); }
if (end) { sql += ' AND s.start_time <= ?'; params.push(end); } if (end) { sql += ' AND s.start_time <= ?'; params.push(end); }
@ -17,33 +102,28 @@ router.get('/', (req, res) => {
res.json(db.prepare(sql).all(...params)); res.json(db.prepare(sql).all(...params));
}); });
// Get schedules for a device (verify device belongs to user) // Get schedules for a device. Phase 2.2m: device access via workspace_id.
router.get('/device/:deviceId', (req, res) => { router.get('/device/:deviceId', (req, res) => {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
const ctx = workspaceAccess(req, device.workspace_id);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
const schedules = db.prepare(` const schedules = db.prepare(getDeviceSchedulesQuery()).all(req.params.deviceId, req.params.deviceId);
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name
FROM schedules s
LEFT JOIN content c ON s.content_id = c.id
LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id
WHERE s.device_id = ? AND s.enabled = 1
ORDER BY s.priority DESC, s.start_time ASC
`).all(req.params.deviceId);
res.json(schedules); res.json(schedules);
}); });
// Get expanded week view (resolves recurrences into individual events) // Expanded week view (resolves recurrences). Phase 2.2m: device access via workspace.
router.get('/week', (req, res) => { router.get('/week', (req, res) => {
const { date, device_id } = req.query; const { date, device_id } = req.query;
if (!device_id) return res.status(400).json({ error: 'device_id required' }); if (!device_id) return res.status(400).json({ error: 'device_id required' });
// Verify device ownership const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id);
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
const ctx = workspaceAccess(req, device.workspace_id);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
const weekStart = date ? new Date(date) : new Date(); const weekStart = date ? new Date(date) : new Date();
weekStart.setHours(0, 0, 0, 0); weekStart.setHours(0, 0, 0, 0);
@ -51,40 +131,73 @@ router.get('/week', (req, res) => {
const weekEnd = new Date(weekStart); const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 7); weekEnd.setDate(weekEnd.getDate() + 7);
const schedules = db.prepare(` const schedules = db.prepare(getDeviceSchedulesQuery()).all(device_id, device_id);
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name
FROM schedules s
LEFT JOIN content c ON s.content_id = c.id
LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id
WHERE s.device_id = ? AND s.enabled = 1
ORDER BY s.priority DESC, s.start_time ASC
`).all(device_id);
const events = []; const events = [];
for (const s of schedules) { for (const s of schedules) {
const expanded = expandSchedule(s, weekStart, weekEnd); const expanded = expandSchedule(s, weekStart, weekEnd);
events.push(...expanded); events.push(...expanded);
} }
res.json(events); res.json(events);
}); });
// Create schedule // Create schedule. Phase 2.2m: schedule.workspace_id is inherited from the
// target (device or group). Single workspace lookup also enforces caller's
// write access. Closes 4 pre-existing leaks: content / widget / layout /
// playlist were accepted with NO ownership check at all.
router.post('/', (req, res) => { router.post('/', (req, res) => {
const { device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, const { device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time,
timezone, recurrence, recurrence_end, priority, color } = req.body; timezone, recurrence, recurrence_end, priority, color } = req.body;
if (!device_id || !start_time || !end_time) { if (!start_time || !end_time) {
return res.status(400).json({ error: 'device_id, start_time, and end_time required' }); return res.status(400).json({ error: 'start_time and end_time required' });
}
if (device_id && group_id) {
return res.status(400).json({ error: 'Cannot set both device_id and group_id. A schedule applies to one device OR one group.' });
}
if (!device_id && !group_id) {
return res.status(400).json({ error: 'Either device_id or group_id is required' });
}
// Resolve target's workspace_id and verify caller has write access there.
let targetWorkspaceId = null;
if (device_id) {
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!device.workspace_id) return res.status(403).json({ error: 'Device not assigned to a workspace' });
targetWorkspaceId = device.workspace_id;
}
if (group_id) {
const group = db.prepare('SELECT workspace_id FROM device_groups WHERE id = ?').get(group_id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (!group.workspace_id) return res.status(403).json({ error: 'Group not assigned to a workspace' });
targetWorkspaceId = group.workspace_id;
}
const ctx = workspaceAccess(req, targetWorkspaceId);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
return res.status(403).json({ error: 'Read-only access' });
}
// Payload refs must live in the same workspace. Platform templates
// (workspace_id IS NULL) on content / widget / layout / playlist are allowed.
const refChecks = [
['content', content_id, true],
['widgets', widget_id, true],
['layouts', layout_id, true],
['playlists', playlist_id, true],
];
for (const [table, id, allowNull] of refChecks) {
if (!id) continue;
const err = checkRefInWorkspace(table, id, targetWorkspaceId, { allowNullWorkspace: allowNull });
if (err) return res.status(err.status).json({ error: err.error });
} }
const id = uuidv4(); const id = uuidv4();
db.prepare(` db.prepare(`
INSERT INTO schedules (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, INSERT INTO schedules (id, user_id, workspace_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title,
start_time, end_time, timezone, recurrence, recurrence_end, priority, color) start_time, end_time, timezone, recurrence, recurrence_end, priority, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.user.id, device_id, zone_id || null, content_id || null, widget_id || null, `).run(id, req.user.id, targetWorkspaceId, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null,
layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || 'UTC', layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || 'UTC',
recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6'); recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6');
@ -92,13 +205,40 @@ router.post('/', (req, res) => {
res.status(201).json(schedule); res.status(201).json(schedule);
}); });
// Update schedule // Update schedule. Phase 2.2m: every polymorphic target that is changing must
router.put('/:id', (req, res) => { // live in the schedule's workspace. Closes the pre-existing leak where
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id); // verifyOwnership keyed only on user_id (workspace-blind).
if (!schedule) return res.status(404).json({ error: 'Schedule not found' }); router.put('/:id', requireScheduleWrite, (req, res) => {
if (!['admin','superadmin'].includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); const schedule = req.schedule;
const fields = ['device_id', 'zone_id', 'content_id', 'widget_id', 'layout_id', 'playlist_id', 'title', const newDeviceId = req.body.device_id !== undefined ? req.body.device_id : schedule.device_id;
const newGroupId = req.body.group_id !== undefined ? req.body.group_id : schedule.group_id;
if (newDeviceId && newGroupId) {
return res.status(400).json({ error: 'Cannot set both device_id and group_id' });
}
if (!newDeviceId && !newGroupId) {
return res.status(400).json({ error: 'Either device_id or group_id is required' });
}
// For each field changing to a non-null value, verify the referenced row
// lives in the schedule's workspace. Devices and groups must match exactly
// (no NULL workspace path); content / widget / layout / playlist may be
// platform templates (NULL workspace_id).
const ownershipChecks = [
['devices', req.body.device_id, schedule.device_id, false],
['device_groups', req.body.group_id, schedule.group_id, false],
['content', req.body.content_id, schedule.content_id, true],
['widgets', req.body.widget_id, schedule.widget_id, true],
['layouts', req.body.layout_id, schedule.layout_id, true],
['playlists', req.body.playlist_id, schedule.playlist_id, true],
];
for (const [table, newVal, oldVal, allowNull] of ownershipChecks) {
if (newVal === undefined || newVal === oldVal || !newVal) continue;
const err = checkRefInWorkspace(table, newVal, schedule.workspace_id, { allowNullWorkspace: allowNull });
if (err) return res.status(err.status).json({ error: err.error });
}
const fields = ['device_id', 'group_id', 'zone_id', 'content_id', 'widget_id', 'layout_id', 'playlist_id', 'title',
'start_time', 'end_time', 'timezone', 'recurrence', 'recurrence_end', 'priority', 'enabled', 'color']; 'start_time', 'end_time', 'timezone', 'recurrence', 'recurrence_end', 'priority', 'enabled', 'color'];
const updates = []; const updates = [];
const values = []; const values = [];
@ -106,6 +246,13 @@ router.put('/:id', (req, res) => {
if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); } if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); }
}); });
if (req.body.group_id && !updates.some(u => u.startsWith('device_id'))) {
updates.push('device_id = ?'); values.push(null);
}
if (req.body.device_id && !updates.some(u => u.startsWith('group_id'))) {
updates.push('group_id = ?'); values.push(null);
}
if (updates.length > 0) { if (updates.length > 0) {
updates.push("updated_at = strftime('%s','now')"); updates.push("updated_at = strftime('%s','now')");
values.push(req.params.id); values.push(req.params.id);
@ -116,10 +263,7 @@ router.put('/:id', (req, res) => {
}); });
// Delete schedule // Delete schedule
router.delete('/:id', (req, res) => { router.delete('/:id', requireScheduleWrite, (req, res) => {
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
if (!['admin','superadmin'].includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id);
res.json({ success: true }); res.json({ success: true });
}); });
@ -138,7 +282,6 @@ function expandSchedule(schedule, rangeStart, rangeEnd) {
return events; return events;
} }
// Parse simple RRULE
const rule = parseRRule(schedule.recurrence); const rule = parseRRule(schedule.recurrence);
if (!rule) { if (!rule) {
events.push({ ...schedule, instance_start: schedule.start_time, instance_end: schedule.end_time }); events.push({ ...schedule, instance_start: schedule.start_time, instance_end: schedule.end_time });
@ -166,7 +309,6 @@ function expandSchedule(schedule, rangeStart, rangeEnd) {
} }
} }
// Advance
switch (rule.freq) { switch (rule.freq) {
case 'DAILY': current.setDate(current.getDate() + (rule.interval || 1)); break; case 'DAILY': current.setDate(current.getDate() + (rule.interval || 1)); break;
case 'WEEKLY': current.setDate(current.getDate() + 7 * (rule.interval || 1)); break; case 'WEEKLY': current.setDate(current.getDate() + 7 * (rule.interval || 1)); break;

View file

@ -5,6 +5,7 @@ const os = require('os');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const config = require('../config'); const config = require('../config');
const { PLATFORM_ROLES } = require('../middleware/auth');
// Public status page // Public status page
router.get('/', (req, res) => { router.get('/', (req, res) => {
@ -45,7 +46,7 @@ router.get('/backup', (req, res) => {
const config = require('../config'); const config = require('../config');
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(decoded.id); const user = db.prepare('SELECT role FROM users WHERE id = ?').get(decoded.id);
if (!user || user.role !== 'superadmin') return res.status(403).json({ error: 'Superadmin only' }); if (!user || !PLATFORM_ROLES.includes(user.role)) return res.status(403).json({ error: 'Platform admin only' });
} catch { } catch {
return res.status(401).json({ error: 'Invalid token' }); return res.status(401).json({ error: 'Invalid token' });
} }
@ -60,11 +61,13 @@ router.get('/export', (req, res) => {
if (!token) return res.status(401).json({ error: 'Token required' }); if (!token) return res.status(401).json({ error: 'Token required' });
let userId; let userId;
let workspaceId;
try { try {
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../config'); const config = require('../config');
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
userId = decoded.id; userId = decoded.id;
workspaceId = decoded.current_workspace_id || null;
if (!userId) return res.status(401).json({ error: 'Invalid token' }); if (!userId) return res.status(401).json({ error: 'Invalid token' });
} catch { } catch {
return res.status(401).json({ error: 'Invalid token' }); return res.status(401).json({ error: 'Invalid token' });
@ -73,6 +76,17 @@ router.get('/export', (req, res) => {
const user = db.prepare('SELECT id, email, name, role, auth_provider, plan_id, created_at FROM users WHERE id = ?').get(userId); const user = db.prepare('SELECT id, email, name, role, auth_provider, plan_id, created_at FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
// Phase 2.2f: export workspace-scoped branding. Fall back to first-accessible
// workspace if the JWT didn't carry one.
if (!workspaceId) {
const w = db.prepare(`
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ? ORDER BY wm.joined_at ASC LIMIT 1
`).get(userId);
workspaceId = w?.id || null;
}
const devices = db.prepare('SELECT id, name, status, ip_address, android_version, app_version, screen_width, screen_height, created_at FROM devices WHERE user_id = ?').all(userId); const devices = db.prepare('SELECT id, name, status, ip_address, android_version, app_version, screen_width, screen_height, created_at FROM devices WHERE user_id = ?').all(userId);
const deviceIds = devices.map(d => d.id); const deviceIds = devices.map(d => d.id);
const devicePlaceholders = deviceIds.map(() => '?').join(',') || "'__none__'"; const devicePlaceholders = deviceIds.map(() => '?').join(',') || "'__none__'";
@ -89,7 +103,7 @@ router.get('/export', (req, res) => {
const playlistPlaceholders = playlistIds.map(() => '?').join(',') || "'__none__'"; const playlistPlaceholders = playlistIds.map(() => '?').join(',') || "'__none__'";
const playlistItems = playlistIds.length ? db.prepare(`SELECT id, playlist_id, content_id, widget_id, sort_order, duration_sec FROM playlist_items WHERE playlist_id IN (${playlistPlaceholders})`).all(...playlistIds) : []; const playlistItems = playlistIds.length ? db.prepare(`SELECT id, playlist_id, content_id, widget_id, sort_order, duration_sec FROM playlist_items WHERE playlist_id IN (${playlistPlaceholders})`).all(...playlistIds) : [];
const schedules = db.prepare('SELECT id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at FROM schedules WHERE user_id = ?').all(userId); const schedules = db.prepare('SELECT id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at FROM schedules WHERE user_id = ?').all(userId);
const videoWalls = db.prepare('SELECT * FROM video_walls WHERE user_id = ?').all(userId); const videoWalls = db.prepare('SELECT * FROM video_walls WHERE user_id = ?').all(userId);
const wallIds = videoWalls.map(w => w.id); const wallIds = videoWalls.map(w => w.id);
const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'"; const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'";
@ -101,7 +115,7 @@ router.get('/export', (req, res) => {
const groupPlaceholders = groupIds.map(() => '?').join(',') || "'__none__'"; const groupPlaceholders = groupIds.map(() => '?').join(',') || "'__none__'";
const groupMembers = groupIds.length ? db.prepare(`SELECT * FROM device_group_members WHERE group_id IN (${groupPlaceholders})`).all(...groupIds) : []; const groupMembers = groupIds.length ? db.prepare(`SELECT * FROM device_group_members WHERE group_id IN (${groupPlaceholders})`).all(...groupIds) : [];
const alertConfigs = db.prepare('SELECT id, alert_type, enabled, config, created_at FROM alert_configs WHERE user_id = ?').all(userId); const alertConfigs = db.prepare('SELECT id, alert_type, enabled, config, created_at FROM alert_configs WHERE user_id = ?').all(userId);
const whiteLabel = db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(userId); const whiteLabel = workspaceId ? db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(workspaceId) : null;
const exportData = { const exportData = {
format: 'screentinker-export-v2', format: 'screentinker-export-v2',
@ -178,11 +192,13 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Token required' }); if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Token required' });
let userId; let userId;
let workspaceId;
try { try {
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const jwtConfig = require('../config'); const jwtConfig = require('../config');
const decoded = jwt.verify(authHeader.split(' ')[1], jwtConfig.jwtSecret); const decoded = jwt.verify(authHeader.split(' ')[1], jwtConfig.jwtSecret);
userId = decoded.id; userId = decoded.id;
workspaceId = decoded.current_workspace_id || null;
if (!userId) return res.status(401).json({ error: 'Invalid token' }); if (!userId) return res.status(401).json({ error: 'Invalid token' });
} catch { } catch {
return res.status(401).json({ error: 'Invalid token' }); return res.status(401).json({ error: 'Invalid token' });
@ -191,6 +207,19 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId); const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
// Phase 2.2b: imports stamp workspace_id on devices and content so the
// rows are visible to the workspace-filtered list endpoints. Fall back to
// the importer's first accessible workspace if the JWT didn't carry one.
if (!workspaceId) {
const w = db.prepare(`
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ? ORDER BY wm.joined_at ASC LIMIT 1
`).get(userId);
workspaceId = w?.id || null;
}
if (!workspaceId) return res.status(403).json({ error: 'No workspace context for import. Switch to a workspace first.' });
let data; let data;
let extractedFiles = {}; // Map of old content ID -> { filepath, thumbnail } let extractedFiles = {}; // Map of old content ID -> { filepath, thumbnail }
@ -261,7 +290,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
const newId = uuid.v4(); const newId = uuid.v4();
idMap.devices[d.id] = newId; idMap.devices[d.id] = newId;
const pairingCode = String(Math.floor(100000 + Math.random() * 900000)); const pairingCode = String(Math.floor(100000 + Math.random() * 900000));
db.prepare(`INSERT INTO devices (id, user_id, name, pairing_code, status, screen_width, screen_height, created_at) VALUES (?, ?, ?, ?, 'provisioning', ?, ?, ?)`).run(newId, userId, d.name, pairingCode, d.screen_width || null, d.screen_height || null, d.created_at || Math.floor(Date.now() / 1000)); db.prepare(`INSERT INTO devices (id, user_id, workspace_id, name, pairing_code, status, screen_width, screen_height, created_at) VALUES (?, ?, ?, ?, ?, 'provisioning', ?, ?, ?)`).run(newId, userId, workspaceId, d.name, pairingCode, d.screen_width || null, d.screen_height || null, d.created_at || Math.floor(Date.now() / 1000));
stats.devices++; stats.devices++;
} }
@ -298,7 +327,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
} }
} }
db.prepare(`INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, remote_url, thumbnail_path, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, c.filename, newFilepath, c.mime_type, c.file_size || 0, c.duration_sec || null, c.remote_url || null, newThumbnail, c.width || null, c.height || null, c.created_at || Math.floor(Date.now() / 1000)); db.prepare(`INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, remote_url, thumbnail_path, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, c.filename, newFilepath, c.mime_type, c.file_size || 0, c.duration_sec || null, c.remote_url || null, newThumbnail, c.width || null, c.height || null, c.created_at || Math.floor(Date.now() / 1000));
stats.content++; stats.content++;
} }
@ -307,7 +336,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
const newId = uuid.v4(); const newId = uuid.v4();
idMap.widgets[w.id] = newId; idMap.widgets[w.id] = newId;
const config = typeof w.config === 'string' ? w.config : JSON.stringify(w.config || {}); const config = typeof w.config === 'string' ? w.config : JSON.stringify(w.config || {});
db.prepare(`INSERT INTO widgets (id, user_id, widget_type, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, w.widget_type, w.name, config, w.created_at || Math.floor(Date.now() / 1000)); db.prepare(`INSERT INTO widgets (id, user_id, workspace_id, widget_type, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, w.widget_type, w.name, config, w.created_at || Math.floor(Date.now() / 1000));
stats.widgets++; stats.widgets++;
} }
@ -315,7 +344,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
for (const l of (data.layouts || [])) { for (const l of (data.layouts || [])) {
const newId = uuid.v4(); const newId = uuid.v4();
idMap.layouts[l.id] = newId; idMap.layouts[l.id] = newId;
db.prepare(`INSERT INTO layouts (id, user_id, name, width, height, is_template, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)`).run(newId, userId, l.name, l.width || 1920, l.height || 1080, l.created_at || Math.floor(Date.now() / 1000)); db.prepare(`INSERT INTO layouts (id, user_id, workspace_id, name, width, height, is_template, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)`).run(newId, userId, workspaceId, l.name, l.width || 1920, l.height || 1080, l.created_at || Math.floor(Date.now() / 1000));
stats.layouts++; stats.layouts++;
} }
for (const z of (data.layout_zones || [])) { for (const z of (data.layout_zones || [])) {
@ -331,7 +360,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
for (const p of (data.playlists || [])) { for (const p of (data.playlists || [])) {
const newId = uuid.v4(); const newId = uuid.v4();
idMap.playlists[p.id] = newId; idMap.playlists[p.id] = newId;
db.prepare('INSERT INTO playlists (id, user_id, name, description, is_auto_generated, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(newId, userId, p.name, p.description || '', p.is_auto_generated || 0, p.created_at || Math.floor(Date.now() / 1000), p.updated_at || Math.floor(Date.now() / 1000)); db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, description, is_auto_generated, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(newId, userId, workspaceId, p.name, p.description || '', p.is_auto_generated || 0, p.created_at || Math.floor(Date.now() / 1000), p.updated_at || Math.floor(Date.now() / 1000));
stats.playlists++; stats.playlists++;
} }
for (const pi of (data.playlist_items || [])) { for (const pi of (data.playlist_items || [])) {
@ -355,11 +384,13 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
// Import schedules // Import schedules
for (const s of (data.schedules || [])) { for (const s of (data.schedules || [])) {
const devId = idMap.devices[s.device_id]; const devId = s.device_id ? (idMap.devices[s.device_id] || null) : null;
if (!devId) continue; const grpId = s.group_id ? (idMap.groups[s.group_id] || null) : null;
// Must have either a mapped device or group target
if (!devId && !grpId) continue;
const newId = uuid.v4(); const newId = uuid.v4();
const playlistId = s.playlist_id ? (idMap.playlists[s.playlist_id] || null) : null; const playlistId = s.playlist_id ? (idMap.playlists[s.playlist_id] || null) : null;
db.prepare(`INSERT INTO schedules (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000)); db.prepare(`INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, grpId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000));
stats.schedules++; stats.schedules++;
} }
@ -382,7 +413,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
const newId = uuid.v4(); const newId = uuid.v4();
idMap.kiosk[k.id] = newId; idMap.kiosk[k.id] = newId;
const config = typeof k.config === 'string' ? k.config : JSON.stringify(k.config || {}); const config = typeof k.config === 'string' ? k.config : JSON.stringify(k.config || {});
db.prepare(`INSERT INTO kiosk_pages (id, user_id, name, config, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId, userId, k.name, config, k.created_at || Math.floor(Date.now() / 1000)); db.prepare(`INSERT INTO kiosk_pages (id, user_id, workspace_id, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, k.name, config, k.created_at || Math.floor(Date.now() / 1000));
stats.kiosk_pages++; stats.kiosk_pages++;
} }
@ -390,7 +421,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
for (const g of (data.device_groups || [])) { for (const g of (data.device_groups || [])) {
const newId = uuid.v4(); const newId = uuid.v4();
idMap.groups[g.id] = newId; idMap.groups[g.id] = newId;
db.prepare(`INSERT INTO device_groups (id, user_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId, userId, g.name, g.color || '#3B82F6', g.created_at || Math.floor(Date.now() / 1000)); db.prepare(`INSERT INTO device_groups (id, user_id, workspace_id, name, color, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, g.name, g.color || '#3B82F6', g.created_at || Math.floor(Date.now() / 1000));
stats.device_groups++; stats.device_groups++;
} }
for (const gm of (data.device_group_members || [])) { for (const gm of (data.device_group_members || [])) {
@ -407,14 +438,14 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
db.prepare(`INSERT INTO alert_configs (id, user_id, alert_type, enabled, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, a.alert_type, a.enabled !== undefined ? a.enabled : 1, config, a.created_at || Math.floor(Date.now() / 1000)); db.prepare(`INSERT INTO alert_configs (id, user_id, alert_type, enabled, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, a.alert_type, a.enabled !== undefined ? a.enabled : 1, config, a.created_at || Math.floor(Date.now() / 1000));
} }
// Import white label // Import white label - UPSERT into the importer's current workspace.
if (data.white_label) { if (data.white_label && workspaceId) {
const wl = data.white_label; const wl = data.white_label;
const existing = db.prepare('SELECT id FROM white_labels WHERE user_id = ?').get(userId); const existing = db.prepare('SELECT id FROM white_labels WHERE workspace_id = ?').get(workspaceId);
if (existing) { if (existing) {
db.prepare(`UPDATE white_labels SET brand_name=?, logo_url=?, favicon_url=?, primary_color=?, bg_color=?, custom_domain=?, custom_css=?, hide_branding=?, updated_at=strftime('%s','now') WHERE user_id=?`).run(wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0, userId); db.prepare(`UPDATE white_labels SET brand_name=?, logo_url=?, favicon_url=?, primary_color=?, bg_color=?, custom_domain=?, custom_css=?, hide_branding=?, updated_at=strftime('%s','now') WHERE workspace_id=?`).run(wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0, workspaceId);
} else { } else {
db.prepare(`INSERT INTO white_labels (id, user_id, brand_name, logo_url, favicon_url, primary_color, bg_color, custom_domain, custom_css, hide_branding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid.v4(), userId, wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0); db.prepare(`INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, bg_color, custom_domain, custom_css, hide_branding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid.v4(), userId, workspaceId, wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0);
} }
} }
}); });
@ -471,8 +502,8 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
items.push({ contentId, widgetId, sort_order: a.sort_order || 0, duration }); items.push({ contentId, widgetId, sort_order: a.sort_order || 0, duration });
} }
db.prepare('INSERT INTO playlists (id, user_id, name, description, is_auto_generated) VALUES (?, ?, ?, ?, 1)') db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, description, is_auto_generated) VALUES (?, ?, ?, ?, ?, 1)')
.run(playlistId, userId, `${devName} (imported)`, 'Converted from v1 assignments'); .run(playlistId, userId, workspaceId, `${devName} (imported)`, 'Converted from v1 assignments');
for (const item of items) { for (const item of items) {
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)') db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)')
.run(playlistId, item.contentId, item.widgetId, item.sort_order, item.duration); .run(playlistId, item.contentId, item.widgetId, item.sort_order, item.duration);

View file

@ -1,189 +1,26 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
// List user's teams // Teams API temporarily disabled while the feature is redesigned as a
router.get('/', (req, res) => { // user-grouping primitive within the new Workspaces architecture. The
const teams = db.prepare(` // original Teams data model had no workspace-awareness and was effectively
SELECT t.*, tm.role as my_role, // non-functional after Phase 2.2 (every resource route migrated away from
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) as member_count // team_id), but the UI remained reachable and let users accumulate orphan
FROM teams t // data while believing they were configuring access control.
JOIN team_members tm ON t.id = tm.team_id AND tm.user_id = ? //
ORDER BY t.created_at ASC // All inbound methods now return 503 Service Unavailable with a message
`).all(req.user.id); // pointing at the in-progress redesign. The teams / team_members /
res.json(teams); // team_invites tables are preserved indefinitely for forward migration
// to the future Teams design - do NOT drop them.
//
// When the new design lands, this router file is the replacement point:
// drop in the new handlers and remove the catch-all below.
router.all('*', (req, res) => {
res.status(503).json({
error: 'Teams temporarily unavailable',
message: 'The Teams feature is being redesigned to work within the new Workspaces system. It will return in a future release. Existing team data is preserved and will be migrated forward.',
reason: 'feature_redesign_in_progress',
}); });
// Create team
router.post('/', (req, res) => {
const { name } = req.body;
if (!name) return res.status(400).json({ error: 'name required' });
const id = uuidv4();
db.prepare('INSERT INTO teams (id, name, owner_id) VALUES (?, ?, ?)').run(id, name, req.user.id);
db.prepare('INSERT INTO team_members (team_id, user_id, role) VALUES (?, ?, ?)').run(id, req.user.id, 'owner');
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(id);
res.status(201).json(team);
});
// Get team with members
router.get('/:id', (req, res) => {
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!membership && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Not a member' });
team.members = db.prepare(`
SELECT tm.*, u.email, u.name as user_name, u.avatar_url
FROM team_members tm JOIN users u ON tm.user_id = u.id
WHERE tm.team_id = ?
ORDER BY tm.role DESC, tm.joined_at ASC
`).all(req.params.id);
team.invites = db.prepare('SELECT * FROM team_invites WHERE team_id = ? AND expires_at > ?')
.all(req.params.id, Math.floor(Date.now() / 1000));
res.json(team);
});
// Update team
router.put('/:id', (req, res) => {
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
if (team.owner_id !== req.user.id && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Owner only' });
if (req.body.name) {
db.prepare('UPDATE teams SET name = ? WHERE id = ?').run(req.body.name, req.params.id);
}
res.json(db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id));
});
// Delete team
router.delete('/:id', (req, res) => {
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
if (team.owner_id !== req.user.id && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Owner only' });
db.prepare('DELETE FROM teams WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// Invite user
router.post('/:id/invite', (req, res) => {
const { email, role } = req.body;
if (!email) return res.status(400).json({ error: 'email required' });
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
// Check if already a member
const user = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (user) {
const existing = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?')
.get(req.params.id, user.id);
if (existing) return res.status(409).json({ error: 'Already a member' });
// Direct add if user exists
db.prepare('INSERT INTO team_members (team_id, user_id, role, invited_by) VALUES (?, ?, ?, ?)')
.run(req.params.id, user.id, role || 'viewer', req.user.id);
return res.status(201).json({ success: true, added: true });
}
// Create invite for non-existing user
const id = uuidv4();
const expiresAt = Math.floor(Date.now() / 1000) + 7 * 86400; // 7 days
db.prepare('INSERT INTO team_invites (id, team_id, email, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, req.params.id, email.toLowerCase(), role || 'viewer', req.user.id, expiresAt);
res.status(201).json({ success: true, invite_id: id, invited: true });
});
// Accept invite
router.post('/accept/:inviteId', (req, res) => {
const invite = db.prepare('SELECT * FROM team_invites WHERE id = ? AND expires_at > ?')
.get(req.params.inviteId, Math.floor(Date.now() / 1000));
if (!invite) return res.status(404).json({ error: 'Invite not found or expired' });
if (invite.email !== req.user.email) return res.status(403).json({ error: 'Invite is for a different email' });
db.prepare('INSERT OR IGNORE INTO team_members (team_id, user_id, role, invited_by) VALUES (?, ?, ?, ?)')
.run(invite.team_id, req.user.id, invite.role, invite.invited_by);
db.prepare('DELETE FROM team_invites WHERE id = ?').run(req.params.inviteId);
res.json({ success: true });
});
// Change member role (owner only)
router.put('/:id/members/:userId', (req, res) => {
const { role } = req.body;
if (!['viewer', 'editor', 'owner'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
// Only team owner or admin can change roles
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!['admin','superadmin'].includes(req.user.role) && (!membership || membership.role !== 'owner')) {
return res.status(403).json({ error: 'Only team owner can change roles' });
}
db.prepare('UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?')
.run(role, req.params.id, req.params.userId);
res.json({ success: true });
});
// Remove member (owner only)
router.delete('/:id/members/:userId', (req, res) => {
const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id);
if (!team) return res.status(404).json({ error: 'Team not found' });
if (team.owner_id === req.params.userId) return res.status(400).json({ error: 'Cannot remove owner' });
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!['admin','superadmin'].includes(req.user.role) && (!membership || membership.role !== 'owner')) {
return res.status(403).json({ error: 'Only team owner can remove members' });
}
db.prepare('DELETE FROM team_members WHERE team_id = ? AND user_id = ?')
.run(req.params.id, req.params.userId);
res.json({ success: true });
});
// Check team membership or admin role
function checkTeamAccess(req, res) {
const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!membership && !['admin','superadmin'].includes(req.user.role)) {
res.status(403).json({ error: 'Not a team member' });
return false;
}
return true;
}
// Assign device to team
router.post('/:id/devices', (req, res) => {
if (!checkTeamAccess(req, res)) return;
const { device_id } = req.body;
if (!device_id) return res.status(400).json({ error: 'device_id required' });
db.prepare('UPDATE devices SET team_id = ? WHERE id = ?').run(req.params.id, device_id);
res.json({ success: true });
});
// Remove device from team
router.delete('/:id/devices/:deviceId', (req, res) => {
if (!checkTeamAccess(req, res)) return;
db.prepare('UPDATE devices SET team_id = NULL WHERE id = ? AND team_id = ?').run(req.params.deviceId, req.params.id);
res.json({ success: true });
});
// Get team's devices
router.get('/:id/devices', (req, res) => {
if (!checkTeamAccess(req, res)) return;
const devices = db.prepare('SELECT * FROM devices WHERE team_id = ?').all(req.params.id);
res.json(devices);
}); });
module.exports = router; module.exports = router;

Some files were not shown because too many files have changed in this diff Show more