Commit graph

151 commits

Author SHA1 Message Date
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