screentinker/frontend/index.html
ScreenTinker 52ab04204a Preview a rotated display the way people see it, not the way its framebuffer is
#238: the dashboard preview of a 90/270 display was sideways while the panel on the
wall was right — the split that makes a preview useless, because a designer checking
portrait content can no longer tell a real fault from an artefact of the tool.

A portrait panel is a landscape framebuffer that the player rotates content INSIDE
(+90), hung turned the other way (-90); the two cancel and the viewer sees upright
portrait. The dashboard modelled only the first half. It iframed the player into a
box it had already given the finished 9/16 shape, so the player rotated a second time
inside a box that was pretending to be the finished picture, and nothing anywhere
stood in for the mount. Screenshots had the opposite half missing: they are the raw
framebuffer, shown untouched, so every portrait screen looked wrong on the cards and
in Now Playing too.

So each surface now has a stage (the panel's face) and a frame (its framebuffer),
with the frame turned by the INVERSE of the player's angle. Turning it the same way
is the tempting mistake and the worst kind of wrong: 90+90 lands upside-down, which
reads as nearly-right. The dimension swap is not cosmetic either — composing into the
real framebuffer shape is what makes the player lay content out in the same portrait
box the panel uses; hand it a portrait viewport instead and every zone and object-fit
decision is computed for a canvas no panel has.

The geometry is the players' own rule (server/lib/orientation-style.js), served to the
dashboard rather than re-derived, since a second copy of a rotation rule is exactly how
the two came to disagree. Covers the device preview modal, the playlist preview's
portrait toggle (same fault), Now Playing and the device cards. The Remote canvas stays
raw on purpose: taps are sent as fractions of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:38:31 -05:00

245 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#111827">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/assets/icon-192.png">
<link rel="apple-touch-icon" href="/assets/icon-192.png">
<title>ScreenTinker</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/reset.css">
<link rel="stylesheet" href="/css/main.css">
<script src="/socket.io/socket.io.js"></script>
<!-- #238: the players' own rotation rule (server/lib/orientation-style.js), served under /player
because it IS the player's — the dashboard previews of rotated screens went sideways for as
long as this side derived its own geometry. Classic script: it publishes window.OrientationStyle
for both the player and the ES-module dashboard. -->
<script src="/player/orientation-style.js"></script>
<!-- OAuth providers loaded on-demand by login.js when needed -->
</head>
<body>
<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>
</button>
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="28" height="28" 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"/>
</svg>
<span id="brandName">ScreenTinker</span>
</div>
<!-- #38: apply cached white-label before first paint (no ScreenTinker flash) -->
<script src="/js/brand-prime.js"></script>
<div class="workspace-switcher" id="workspaceSwitcher"></div>
</div>
<ul class="nav-links">
<li><a href="#/" class="nav-link active" data-view="dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
<span>Displays</span>
</a></li>
<li><a href="#/content" class="nav-link" data-view="content">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
</svg>
<span>Content</span>
</a></li>
<li><a href="#/playlists" class="nav-link" data-view="playlists">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
</svg>
<span>Playlists</span>
</a></li>
<li><a href="#/layouts" class="nav-link" data-view="layouts">
<svg width="18" height="18" 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"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>
</svg>
<span>Layouts</span>
</a></li>
<li><a href="#/widgets" class="nav-link" data-view="widgets">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="8" height="8" rx="1"/><rect x="14" y="2" width="8" height="8" rx="1"/>
<rect x="2" y="14" width="8" height="8" rx="1"/><rect x="14" y="14" width="8" height="8" rx="1"/>
</svg>
<span>Widgets</span>
</a></li>
<li><a href="#/schedule" class="nav-link" data-view="schedule">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span>Schedule</span>
</a></li>
<li><a href="#/walls" class="nav-link" data-view="walls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="9" height="8" rx="1"/><rect x="13" y="3" width="9" height="8" rx="1"/>
<rect x="2" y="13" width="9" height="8" rx="1"/><rect x="13" y="13" width="9" height="8" rx="1"/>
</svg>
<span>Video Walls</span>
</a></li>
<li><a href="#/reports" class="nav-link" data-view="reports">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>
</svg>
<span>Reports</span>
</a></li>
<li><a href="#/kiosk" class="nav-link" data-view="kiosk">
<svg width="18" height="18" 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"/><path d="M8 21h8"/><path d="M12 17v4"/>
<circle cx="12" cy="10" r="1"/>
</svg>
<span>Kiosk</span>
</a></li>
<li><a href="#/designer" class="nav-link" data-view="designer">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/>
<circle cx="11" cy="11" r="2"/>
</svg>
<span>Designer</span>
</a></li>
<li><a href="#/activity" class="nav-link" data-view="activity">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<span>Activity</span>
</a></li>
<!-- 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">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span>Teams</span>
</a></li>
<!-- Members: inviting a colleague is a core action, and the only route to it used to be
an unlabelled icon beside the workspace name. #/members resolves to the ACTIVE
workspace so this link can stay static. -->
<li><a href="#/members" class="nav-link" data-view="members">
<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="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span>Members</span>
</a></li>
<li><a href="#/help" class="nav-link" data-view="help">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>Help</span>
</a></li>
<li><a href="#/settings" class="nav-link" data-view="settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>Settings</span>
</a></li>
<li id="billingNavItem"><a href="#/billing" class="nav-link" data-view="billing">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/>
<line x1="1" y1="10" x2="23" y2="10"/>
</svg>
<span>Subscription</span>
</a></li>
<li id="adminNavItem" style="display:none"><a href="#/admin" class="nav-link" data-view="admin">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>Admin</span>
</a></li>
</ul>
<div class="sidebar-footer">
<div class="connection-status" id="connectionStatus">
<span class="status-dot offline"></span>
<span>Disconnected</span>
</div>
<div class="version-status" id="versionStatus">
<span id="versionLabel"></span>
<span class="version-badge" id="versionBadge" hidden>Update</span>
</div>
</div>
</nav>
<main class="content" id="app">
<!-- Views rendered here -->
</main>
<!-- Add Device Modal -->
<div class="modal-overlay" id="addDeviceModal" style="display:none">
<div class="modal" style="max-width:560px">
<div class="modal-header">
<h3 data-i18n="add_display.title">Add Display</h3>
<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">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<p 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">
<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">
</div>
<div class="form-group">
<label data-i18n="add_display.display_name">Display Name (optional)</label>
<input type="text" id="deviceNameInput" data-i18n-placeholder="add_display.name_placeholder" placeholder="e.g., Lobby TV" class="input">
</div>
<div style="background:var(--bg-input,#0f172a);border-radius:6px;padding:10px 12px;margin-top:16px;border:1px solid var(--border,#1e293b)">
<span style="font-size:11px;color:var(--text-muted,#64748b);font-weight:500" data-i18n="add_display.server_url_label">Server URL</span>
<code id="addDeviceServerUrl" style="display:block;font-size:13px;margin:2px 0;color:var(--primary,#3b82f6);word-break:break-all"></code>
<span style="font-size:11px;color:var(--text-muted,#64748b)" data-i18n="add_display.server_url_hint">Use this URL when configuring the Android app</span>
</div>
<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" 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">
<a href="/download/apk" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
&#129302; <span data-i18n="add_display.android_apk">Android APK</span>
</a>
<a href="/player" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
&#127760; <span data-i18n="add_display.web_player">Web Player</span>
</a>
<a href="/scripts/raspberry-pi-setup.sh" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
&#129359; <span data-i18n="add_display.raspberry_pi">Raspberry Pi</span>
</a>
<a href="/scripts/windows-setup.bat" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
&#128187; <span data-i18n="add_display.windows">Windows</span>
</a>
</div>
<p style="font-size:11px;color:var(--text-muted,#64748b);margin-top:8px;line-height:1.5"><span data-i18n="add_display.smart_tv_prefix">Smart TVs (LG/Samsung): open the built-in browser and navigate to</span> <code id="addDeviceSmartTvUrl" style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px;white-space:nowrap">/player</code></p>
</div>
<div style="border-top:1px solid var(--border,#1e293b);margin-top:16px;padding-top:16px">
<p style="font-size:12px;color:var(--text-muted,#64748b);margin-bottom:10px;font-weight:500" data-i18n="add_display.owner_prompt">Provisioning a fresh / factory-reset Android panel? Enroll it as device owner for silent updates, reboot &amp; kiosk:</p>
<button type="button" class="btn btn-secondary btn-sm" id="deviceOwnerQrBtn" style="width:100%;justify-content:center;font-size:12px">
&#128242; <span data-i18n="add_display.owner_qr_btn">Show device-owner QR</span>
</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-close-modal="addDeviceModal" data-i18n="common.cancel">Cancel</button>
<button class="btn btn-primary" id="pairDeviceBtn" data-i18n="add_display.pair_btn">Pair Display</button>
</div>
</div>
</div>
<!-- Toast container -->
<div id="toastContainer" class="toast-container"></div>
<script type="module" src="/js/app.js"></script>
</body>
</html>