Portrait templates, a canvas that matches the layout, and a playlist mockup

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-08-05 13:23:15 -05:00
parent 604c390a55
commit 803f4ec26d
6 changed files with 143 additions and 2 deletions

View file

@ -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.',

View file

@ -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) {
<div style="display:flex;gap:20px">
<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="canvas" style="position:relative;width:100%;padding-top:56.25%">
<!-- Canvas mirrors THIS layout's shape, not a fixed 16:9. It was hardcoded to 56.25%
(the padding-ratio trick for 16:9), so authoring a portrait layout meant dragging
zones on a landscape canvas: correct on the panel, wrong everywhere you designed it. -->
<div id="canvas" style="position:relative;width:100%;padding-top:${canvasRatioPct(layout)}%">
</div>
</div>
</div>

View file

@ -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 `<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('playlist.layout_fullscreen')}</div>`;
}
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 `<div title="${esc(z.name)}${filled ? `${n}` : ''}" style="
position:absolute;
left:${z.x_percent}%; top:${z.y_percent}%;
width:${z.width_percent}%; height:${z.height_percent}%;
box-sizing:border-box;
border:1px solid ${filled ? 'var(--accent)' : 'var(--border)'};
background:${filled ? 'color-mix(in srgb, var(--accent) 18%, transparent)' : 'transparent'};
display:flex;align-items:center;justify-content:center;
font-size:9px;line-height:1;color:var(--text-muted);overflow:hidden;
">${filled ? n : ''}</div>`;
}).join('');
return `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<div style="position:relative;width:${boxW}px;height:${boxH}px;background:var(--bg-primary);border:1px solid var(--border);border-radius:4px;flex:none">
${zones}
</div>
<div style="font-size:12px;color:var(--text-muted)">
<div>${esc(layout.name || '')} &middot; ${w}&times;${h}${portrait ? ' (portrait)' : ''}</div>
<div>${tn('playlist.zones_count', layout.zones.length)}</div>
${layout._preview_ambiguous ? `<div style="color:var(--warning)">${t('playlist.layout_ambiguous')}</div>` : ''}
</div>
</div>`;
}
function renderDetailContent(container, playlist) {
const isDraft = playlist.status === 'draft';
const hasPublished = !!playlist.published_snapshot;
@ -288,6 +349,8 @@ function renderDetailContent(container, playlist) {
</div>
</div>
${layoutMockup(playlist)}
<div id="playlistItems" style="display:flex;flex-direction:column;gap:8px">
</div>
`;

View file

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

View file

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

View file

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