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