From 803f4ec26d3efee29aa020c17e1e039b60756796 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 5 Aug 2026 13:23:15 -0500 Subject: [PATCH] Portrait templates, a canvas that matches the layout, and a playlist mockup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related pieces. Zones were already stored as percentages and layouts already carried their own width/height, so this is mostly design work rather than plumbing. SIX PORTRAIT TEMPLATES at 1080x1920. Deliberately not the landscape set turned sideways: "Three Column" at 33% each becomes three tall slivers, and a 15% ticker that reads well across 1080px is a 288px band on a 1920px-tall panel, so the portrait ticker is 12% and the PiP window is wider than tall (a 30x30 box is square on 16:9 and 324x576 in portrait). Seeded in schema.sql for fresh installs AND as a migration, because schema.sql never runs on an existing database — and upgraded instances are exactly the ones with portrait panels already deployed. THE EDITOR CANVAS followed a hardcoded padding-top:56.25% — the 16:9 ratio trick. Authoring a portrait layout meant dragging zones on a landscape canvas: the percentages landed correctly on the panel and looked wrong everywhere you designed them. It now derives from the layout's own height/width, clamped so a pathological row cannot produce an unusable editor. THE PLAYLIST PAGE now draws where content actually lands. A playlist has no intrinsic layout, so the server reuses #104's derivation from the items' own zone bindings and returns it. Previously an item could be tagged "Bottom Ticker" with nothing to say the ticker is a thin strip along the bottom — people assigned by zone name and found out by looking at a screen. Empty zones are dimmed, because an empty zone shows its background colour on a real panel and that is worth seeing before publishing rather than after. Verified against a copy of prod: 6 templates and 12 zones created, the 7 landscape templates untouched, no errors at boot, and a second boot changes nothing. Each stacked template's zone heights sum to exactly 100%. 1074 pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- frontend/js/i18n/en.js | 4 ++ frontend/js/views/layout-editor.js | 19 ++++++++- frontend/js/views/playlists.js | 63 ++++++++++++++++++++++++++++++ server/db/database.js | 25 ++++++++++++ server/db/schema.sql | 26 ++++++++++++ server/routes/playlists.js | 8 +++- 6 files changed, 143 insertions(+), 2 deletions(-) diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index b358061..22ecbe0 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1142,6 +1142,10 @@ export default { 'playlist.click_to_edit_desc': 'Click to edit description', 'playlist.add_content': '+ Add Content', 'playlist.delete_playlist': 'Delete Playlist', + 'playlist.layout_fullscreen': 'Fullscreen — all content shares the whole screen', + 'playlist.zones_count_one': '1 zone', + 'playlist.zones_count_other': '{n} zones', + 'playlist.layout_ambiguous': 'Items reference zones from more than one layout', 'playlist.back': 'Back', 'playlist.items_empty': 'This playlist is empty', 'playlist.items_empty_hint': 'Click "Add Content" to add items.', diff --git a/frontend/js/views/layout-editor.js b/frontend/js/views/layout-editor.js index 2279ca9..2f78e0a 100644 --- a/frontend/js/views/layout-editor.js +++ b/frontend/js/views/layout-editor.js @@ -117,6 +117,20 @@ function renderLayoutCard(layout, isTemplate) { `; } +/* + * Canvas aspect as a padding-top percentage: the layout's own height/width. + * + * Falls back to 16:9 when a layout carries no usable dimensions, and clamps so a pathological + * value cannot produce a canvas taller than the screen or thinner than a line — these rows are + * user-editable, and an unusable editor is worse than a slightly wrong aspect. + */ +function canvasRatioPct(layout) { + const w = Number(layout && layout.width) || 1920; + const h = Number(layout && layout.height) || 1080; + if (!(w > 0 && h > 0)) return 56.25; + return Math.min(300, Math.max(20, (h / w) * 100)); +} + async function renderEditor(container, layoutId) { let layout; try { @@ -143,7 +157,10 @@ async function renderEditor(container, layoutId) {
-
+ +
diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js index e6bb700..ec3fc10 100644 --- a/frontend/js/views/playlists.js +++ b/frontend/js/views/playlists.js @@ -251,6 +251,67 @@ function showPlaylistPreview(playlist) { }); } +/* + * A small picture of where this playlist's content actually lands. + * + * A playlist has no intrinsic layout — the server derives one from the items' own zone bindings + * (#104) — so the page could previously show an item tagged "Bottom Ticker" with no indication + * that the ticker is a thin strip along the bottom. People assigned content to zones by name and + * found out where it went by looking at a screen. + * + * Drawn from the zone percentages, so it is correct for any layout including portrait ones without + * a stored thumbnail. Zones with no items are dimmed: an empty zone on a real panel shows its + * background colour, and that is worth seeing BEFORE publishing rather than after. + */ +function layoutMockup(playlist) { + const layout = playlist && playlist.layout; + const items = (playlist && playlist.items) || []; + + // No layout means fullscreen — every item shares one frame. Drawing a single empty box would + // imply a choice was made; say it in words instead. + if (!layout || !Array.isArray(layout.zones) || layout.zones.length === 0) { + return `
${t('playlist.layout_fullscreen')}
`; + } + + const counts = {}; + for (const it of items) if (it.zone_id) counts[it.zone_id] = (counts[it.zone_id] || 0) + 1; + + const w = Number(layout.width) || 1920; + const h = Number(layout.height) || 1080; + const portrait = h > w; + // Fixed short edge, long edge derived — a portrait mockup must not be as wide as a landscape one + // or it dominates the page. + const boxW = portrait ? 90 : 200; + const boxH = Math.round(boxW * (h / w)); + + const zones = layout.zones.map((z) => { + const n = counts[z.id] || 0; + const filled = n > 0; + return `
${filled ? n : ''}
`; + }).join(''); + + return ` +
+
+ ${zones} +
+
+
${esc(layout.name || '')} · ${w}×${h}${portrait ? ' (portrait)' : ''}
+
${tn('playlist.zones_count', layout.zones.length)}
+ ${layout._preview_ambiguous ? `
${t('playlist.layout_ambiguous')}
` : ''} +
+
`; +} + function renderDetailContent(container, playlist) { const isDraft = playlist.status === 'draft'; const hasPublished = !!playlist.published_snapshot; @@ -288,6 +349,8 @@ function renderDetailContent(container, playlist) {
+ ${layoutMockup(playlist)} +
`; diff --git a/server/db/database.js b/server/db/database.js index 91f7e1f..4de3c8b 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -483,6 +483,31 @@ const migrations = [ note TEXT )`, "CREATE INDEX IF NOT EXISTS idx_recovery_grants_expires ON recovery_grants(expires_at)", + // Portrait templates for existing installs. schema.sql only runs on a fresh database, so without + // this an upgraded instance has landscape templates only — and portrait panels are exactly the + // fleets that need a starting point. INSERT OR IGNORE, so re-running is free and an operator who + // edited one of these keeps their version. + `INSERT OR IGNORE INTO layouts (id, user_id, name, width, height, is_template, template_category) VALUES + ('tpl-p-full', NULL, 'Portrait Fullscreen', 1080, 1920, 1, 'basic'), + ('tpl-p-halves', NULL, 'Portrait Split', 1080, 1920, 1, 'split'), + ('tpl-p-ticker', NULL, 'Portrait with Ticker', 1080, 1920, 1, 'news'), + ('tpl-p-banner', NULL, 'Portrait Banner + Body', 1080, 1920, 1, 'news'), + ('tpl-p-thirds', NULL, 'Portrait Three Stacked', 1080, 1920, 1, 'grid'), + ('tpl-p-pip', NULL, 'Portrait Picture in Picture', 1080, 1920, 1, 'overlay')`, + `INSERT OR IGNORE INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, sort_order) VALUES + ('z-pf-1', 'tpl-p-full', 'Main', 0, 0, 100, 100, 0, 0), + ('z-ph-1', 'tpl-p-halves', 'Top', 0, 0, 100, 50, 0, 0), + ('z-ph-2', 'tpl-p-halves', 'Bottom', 0, 50, 100, 50, 0, 1), + ('z-pt-1', 'tpl-p-ticker', 'Main Content', 0, 0, 100, 88, 0, 0), + ('z-pt-2', 'tpl-p-ticker', 'Bottom Ticker', 0, 88, 100, 12, 1, 1), + ('z-pb-1', 'tpl-p-banner', 'Top Banner', 0, 0, 100, 15, 0, 0), + ('z-pb-2', 'tpl-p-banner', 'Body', 0, 15, 100, 85, 0, 1), + ('z-p3-1', 'tpl-p-thirds', 'Top', 0, 0, 100, 33.33, 0, 0), + ('z-p3-2', 'tpl-p-thirds', 'Middle', 0, 33.33, 100, 33.34, 0, 1), + ('z-p3-3', 'tpl-p-thirds', 'Bottom', 0, 66.67, 100, 33.33, 0, 2), + ('z-pp-1', 'tpl-p-pip', 'Background', 0, 0, 100, 100, 0, 0), + ('z-pp-2', 'tpl-p-pip', 'PiP Window', 58, 4, 38, 20, 1, 1)`, + ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/db/schema.sql b/server/db/schema.sql index 513758d..ab886bb 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -196,6 +196,32 @@ INSERT OR IGNORE INTO layout_zones (id, layout_id, name, x_percent, y_percent, w ('z-q-3', 'tpl-quad', 'Bottom Left', 0, 50, 50, 50, 0, 2), ('z-q-4', 'tpl-quad', 'Bottom Right', 50, 50, 50, 50, 0, 3); +-- Portrait templates. Zones are percentages, so these differ from the landscape set only in the +-- layout's own width/height and in PROPORTIONS chosen for a tall screen. A landscape template +-- rotated is not a portrait template: "Three Column" at 33% each becomes three tall slivers, and a +-- 15%-tall ticker that reads well across 1080px is a 288px band on a 1920px-tall panel. +INSERT OR IGNORE INTO layouts (id, user_id, name, width, height, is_template, template_category) VALUES + ('tpl-p-full', NULL, 'Portrait Fullscreen', 1080, 1920, 1, 'basic'), + ('tpl-p-halves', NULL, 'Portrait Split', 1080, 1920, 1, 'split'), + ('tpl-p-ticker', NULL, 'Portrait with Ticker', 1080, 1920, 1, 'news'), + ('tpl-p-banner', NULL, 'Portrait Banner + Body', 1080, 1920, 1, 'news'), + ('tpl-p-thirds', NULL, 'Portrait Three Stacked', 1080, 1920, 1, 'grid'), + ('tpl-p-pip', NULL, 'Portrait Picture in Picture', 1080, 1920, 1, 'overlay'); + +INSERT OR IGNORE INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, sort_order) VALUES + ('z-pf-1', 'tpl-p-full', 'Main', 0, 0, 100, 100, 0, 0), + ('z-ph-1', 'tpl-p-halves', 'Top', 0, 0, 100, 50, 0, 0), + ('z-ph-2', 'tpl-p-halves', 'Bottom', 0, 50, 100, 50, 0, 1), + ('z-pt-1', 'tpl-p-ticker', 'Main Content', 0, 0, 100, 88, 0, 0), + ('z-pt-2', 'tpl-p-ticker', 'Bottom Ticker', 0, 88, 100, 12, 1, 1), + ('z-pb-1', 'tpl-p-banner', 'Top Banner', 0, 0, 100, 15, 0, 0), + ('z-pb-2', 'tpl-p-banner', 'Body', 0, 15, 100, 85, 0, 1), + ('z-p3-1', 'tpl-p-thirds', 'Top', 0, 0, 100, 33.33, 0, 0), + ('z-p3-2', 'tpl-p-thirds', 'Middle', 0, 33.33, 100, 33.34, 0, 1), + ('z-p3-3', 'tpl-p-thirds', 'Bottom', 0, 66.67, 100, 33.33, 0, 2), + ('z-pp-1', 'tpl-p-pip', 'Background', 0, 0, 100, 100, 0, 0), + ('z-pp-2', 'tpl-p-pip', 'PiP Window', 58, 4, 38, 20, 1, 1); + -- ===================== WIDGETS ===================== CREATE TABLE IF NOT EXISTS widgets ( diff --git a/server/routes/playlists.js b/server/routes/playlists.js index 3419581..1eea01e 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -225,7 +225,13 @@ router.get('/:id', requirePlaylistRead, (req, res) => { `).all(req.params.id); const displayCount = db.prepare('SELECT COUNT(*) as count FROM devices WHERE playlist_id = ?').get(req.params.id).count; for (const it of items) it.schedules = schedulesForItem(it.id); // #156: editor read-path needs the blocks (mirror :351) - res.json({ ...req.playlist, items, item_count: items.length, display_count: displayCount }); + // #104's layout derivation, reused so the editor can SHOW where each item lands. A playlist + // has no intrinsic layout — it is inferred from its own zone-bound items — so without this + // the page lists a zone NAME with no sense of where that zone sits on the screen. Null (no + // zoned items) means fullscreen, which the UI draws as a single frame. + let layout = null; + try { layout = derivePreviewLayout(items); } catch (e) { layout = null; } + res.json({ ...req.playlist, items, item_count: items.length, display_count: displayCount, layout }); }); // #104: device-free draft preview payload. Same shape the device player consumes