mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 22:33:12 -06:00
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:
parent
604c390a55
commit
803f4ec26d
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 || '')} · ${w}×${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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue