@sprid/cli 1.0.0 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README-post.md +21 -15
- package/package.json +1 -1
- package/src/docs/guides.generated.mjs +6 -6
- package/src/post/preview.mjs +382 -621
- package/src/post/recipes/screen.mjs +2 -1
- package/src/post/rules/video-safe-zones.d.mts +6 -0
- package/src/post/rules/video-safe-zones.mjs +27 -0
- package/src/post/screen/compose.mjs +6 -3
package/src/post/preview.mjs
CHANGED
|
@@ -1,660 +1,421 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// of the page cannot tell them apart.
|
|
6
|
-
//
|
|
7
|
-
// **The registry already knows everything this needs**, which is the reason it
|
|
8
|
-
// belongs in the package rather than in a consuming repo's one-off script: a
|
|
9
|
-
// manifest carries the lane, the length, the loudness, the check result and the
|
|
10
|
-
// Sprid state, and the caption sits next to it verbatim. So the preview is a
|
|
11
|
-
// pure function of `social/` plus whatever media is currently on disk, and it
|
|
12
|
-
// costs nothing to throw away and regenerate.
|
|
13
|
-
//
|
|
14
|
-
// Two views, because they answer two different questions:
|
|
15
|
-
//
|
|
16
|
-
// GRID is this a batch? Everything at once, small, scannable. What you read
|
|
17
|
-
// here is variety, repetition, an odd one out, a caption that ran long.
|
|
18
|
-
// FEED is this a post? One reel at a time at reading size with its caption
|
|
19
|
-
// under it, arrow keys between them. It is the only view that answers
|
|
20
|
-
// "would I stop on this", because that is the shape a reader meets.
|
|
21
|
-
//
|
|
22
|
-
// **A card shows the caption and nothing derived from it.** An earlier hand
|
|
23
|
-
// written version of this page split the caption's first line into a title and
|
|
24
|
-
// printed the remainder as a subtitle above the caption itself, so every card
|
|
25
|
-
// carried the same text twice and neither copy was the thing that ships. The
|
|
26
|
-
// caption IS the record; there is no second description to show.
|
|
27
|
-
|
|
28
|
-
const esc = (s) =>
|
|
29
|
-
String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
30
|
-
|
|
1
|
+
import { SAFE_ZONE_BANDS } from "./rules/video-safe-zones.mjs";
|
|
2
|
+
// A self-contained local review: artwork first, post details on demand.
|
|
3
|
+
// Inline icons and system fonts keep the saved page usable offline.
|
|
4
|
+
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
31
5
|
const scriptJson = value => JSON.stringify(value).replace(/</g, '\\u003c');
|
|
6
|
+
const secs = ms => `${(ms / 1000).toFixed(1)}s`;
|
|
7
|
+
const ratio = item => /^\d+(?:\.\d+)?[:/]\d+(?:\.\d+)?$/.test(item.aspect ?? '') ? item.aspect.replace(':', '/') : '9/16';
|
|
8
|
+
const hasMedia = item => Boolean(item.src || item.slides?.length);
|
|
9
|
+
|
|
10
|
+
function issues(item) {
|
|
11
|
+
return [
|
|
12
|
+
...(item.checks === 'fail' ? item.failures?.length ? item.failures : ['Checks failed'] : []),
|
|
13
|
+
...(!hasMedia(item) ? ['Media not on disk'] : []),
|
|
14
|
+
...(!item.caption ? ['No caption'] : []),
|
|
15
|
+
...(item.stale ? ['File differs from its record'] : []),
|
|
16
|
+
...(item.captionDrifted ? ['Caption changed since draft'] : []),
|
|
17
|
+
];
|
|
18
|
+
}
|
|
32
19
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
20
|
+
const paths = {
|
|
21
|
+
grid: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
|
|
22
|
+
list: '<path d="M9 5h12M9 12h12M9 19h12M3 5h.01M3 12h.01M3 19h.01"/>',
|
|
23
|
+
search: '<circle cx="10.5" cy="10.5" r="7.5"/><path d="m16 16 5 5"/>',
|
|
24
|
+
chevron: '<path d="m9 5 7 7-7 7"/>',
|
|
25
|
+
down: '<path d="m6 9 6 6 6-6"/>',
|
|
26
|
+
close: '<path d="m6 6 12 12M6 18 18 6"/>',
|
|
27
|
+
play: '<path d="m8 4 12 8-12 8Z"/>',
|
|
28
|
+
pause: '<path d="M8 5v14M16 5v14"/>',
|
|
29
|
+
copy: '<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/>',
|
|
30
|
+
check: '<path d="m5 12 4 4L19 6"/>',
|
|
31
|
+
warning: '<path d="m10.3 3.8-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3.2l-8-14a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/>',
|
|
32
|
+
layers: '<rect x="3" y="7" width="14" height="14" rx="2"/><path d="M7 3h12a2 2 0 0 1 2 2v12"/>',
|
|
33
|
+
volume: '<path d="m11 5-6 4H2v6h3l6 4ZM15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14"/>',
|
|
34
|
+
muted: '<path d="m11 5-6 4H2v6h3l6 4ZM17 9l5 6M17 15l5-6"/>',
|
|
35
|
+
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z"/>',
|
|
36
|
+
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5l1.5 1.5M5 19l1.5-1.5M17.5 6.5l1.5-1.5"/>',
|
|
37
|
+
feed: '<rect x="6" y="2" width="12" height="20" rx="2"/><path d="m10 8 5 4-5 4Z"/>',
|
|
38
|
+
safe: '<rect x="3" y="2" width="18" height="20" rx="2"/><path d="M3 18h18M17 9v9M5 20l2-2M10 20l2-2M15 20l2-2M19 13l2-2M19 17l2-2"/>',
|
|
39
|
+
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
40
|
+
code: '<path d="m8 6-6 6 6 6M16 6l6 6-6 6M14 3l-4 18"/>',
|
|
41
|
+
help: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 .5c0 1.5-2.5 2-2.5 3.5M12 17h.01"/>',
|
|
42
|
+
};
|
|
43
|
+
const icon = name => `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name]}</svg>`;
|
|
44
|
+
const iconButton = (id, name, label, extra = '') => `<button type="button" id="${id}" class="icon-button" title="${label}" aria-label="${label}" ${extra}>${icon(name)}</button>`;
|
|
45
|
+
|
|
46
|
+
/** Render one portable HTML page from the local media registry. */
|
|
40
47
|
export function renderPreview({ account, items, generatedAt }) {
|
|
41
|
-
const lanes = [...new Set(items.map(
|
|
42
|
-
const
|
|
43
|
-
|
|
48
|
+
const lanes = [...new Set(items.map(item => item.lane).filter(Boolean))].sort();
|
|
49
|
+
const data = items.map(item => ({ ...item, ar: ratio(item), issues: issues(item) }));
|
|
50
|
+
const attentionCount = data.filter(item => item.issues.length).length;
|
|
44
51
|
return `<!doctype html>
|
|
45
52
|
<html lang="en">
|
|
53
|
+
<head>
|
|
46
54
|
<meta charset="utf-8">
|
|
47
55
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
48
|
-
<title>${esc(account)}
|
|
56
|
+
<title>${esc(account)} posts | Sprid</title>
|
|
49
57
|
<style>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
the caption of the one you are on. Stacking both on the vertical axis meant
|
|
123
|
-
scrolling to finish a caption was also the gesture for "next". */
|
|
124
|
-
#feed { position:fixed; inset:0; z-index:40; background:#0a0a0c; display:none;
|
|
125
|
-
overflow-x:auto; overflow-y:hidden; scroll-snap-type:x mandatory; gap:34px; }
|
|
126
|
-
#feed.open { display:flex; }
|
|
127
|
-
/* The two rails that let the first and last post sit centred. They are flex
|
|
128
|
-
items because a pseudo-element on a flex container is one. */
|
|
129
|
-
#feed::before, #feed::after { content:""; flex:0 0 calc((100vw - var(--pw)) / 2); }
|
|
130
|
-
/* **min-width:0, or the head sets the width.** A flex item's automatic
|
|
131
|
-
minimum is its content, and a nowrap slug is wider than a post: without
|
|
132
|
-
this, posts with long slugs came out wider than the others, so no two snap
|
|
133
|
-
points were the same distance apart and the feed settled off-centre. */
|
|
134
|
-
.post { flex:0 0 var(--pw); min-width:0; scroll-snap-align:center; scroll-snap-stop:always;
|
|
135
|
-
height:100vh; padding:64px 0 18px; display:flex; flex-direction:column;
|
|
136
|
-
/* The neighbours are visible and clearly not the subject. */
|
|
137
|
-
opacity:.22; transition:opacity .18s ease; }
|
|
138
|
-
.post.current { opacity:1; }
|
|
139
|
-
/* No radius, no border: the frame is the reel, and anything drawn around it
|
|
140
|
-
is a thing the reader will never see. */
|
|
141
|
-
/* **Reading the caption slides the reel up.** The video is the whole post
|
|
142
|
-
until you start reading, and then it is a thumbnail of what you are reading
|
|
143
|
-
about; --shift is set from the caption's own scroll, so one gesture does
|
|
144
|
-
both. The margin (rather than a transform) is what makes the caption box
|
|
145
|
-
actually grow into the space, instead of the video sliding over it. */
|
|
146
|
-
.post video, .post .deck { width:100%; height:auto; aspect-ratio:var(--ar, 9/16);
|
|
147
|
-
background:#131317; display:block; position:relative;
|
|
148
|
-
margin-top:calc(-1 * var(--shift, 0px)); }
|
|
149
|
-
.post .deck img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; opacity:0; }
|
|
150
|
-
.post .deck img.on { opacity:1; }
|
|
151
|
-
/* Which slide you are on, and how many are left. Instagram's dots, because
|
|
152
|
-
the thing they answer - "is this deck too long" - is a real review question. */
|
|
153
|
-
.dots { display:flex; gap:5px; justify-content:center; padding:8px 0 0; }
|
|
154
|
-
.dot { width:5px; height:5px; border-radius:99px; background:#3a3a42; cursor:pointer; }
|
|
155
|
-
.dot.on { background:var(--fg); }
|
|
156
|
-
/* overflow-x:clip rather than the default: a box with overflow-y:auto counts
|
|
157
|
-
as a horizontal scroller too, and it was swallowing sideways swipes that
|
|
158
|
-
belong to the feed. Clip is not a scroll container, so they chain. */
|
|
159
|
-
.post .body { flex:1; min-height:0; overflow-y:auto; overflow-x:clip;
|
|
160
|
-
padding:12px 2px 0; overscroll-behavior-y:contain; }
|
|
161
|
-
.post .body::-webkit-scrollbar { width:0; }
|
|
162
|
-
.post .caption { white-space:pre-wrap; font-size:13.5px; line-height:1.6; color:#dcdce2;
|
|
163
|
-
max-height:none; mask-image:none; }
|
|
164
|
-
#feedbar { position:fixed; top:0; left:0; right:0; z-index:50; display:flex; gap:12px;
|
|
165
|
-
align-items:center; padding:10px 16px; background:rgba(10,10,12,.92);
|
|
166
|
-
backdrop-filter:blur(8px); border-bottom:1px solid var(--line);
|
|
167
|
-
pointer-events:none; }
|
|
168
|
-
#feedbar > * { pointer-events:auto; }
|
|
169
|
-
#feedpos { color:var(--dim); font-size:12px; }
|
|
170
|
-
.keys { color:var(--dim); font-size:11.5px; }
|
|
171
|
-
.keys kbd { background:#1c1c20; border:1px solid var(--line); border-bottom-width:2px;
|
|
172
|
-
border-radius:4px; padding:0 4px; font:11px ui-monospace,Menlo,monospace; }
|
|
173
|
-
@media (max-width:640px) { .keys { display:none; } }
|
|
58
|
+
:root { color-scheme:light dark; --paper:#F1EEE7; --raised:#FBFAF7; --block:#E6E2D8; --ink:#151412; --soft:#3A3833; --muted:#6E6A61; --line:#DAD6CB; --hover:rgba(21,20,18,.06); --negative:#B3261E; --caution:#B7791F; --positive:#3B7A2A; --scrim:rgba(21,20,18,.45); --pw:min(400px,78vw,calc((100dvh - 230px)*9/16)); }
|
|
59
|
+
@media(prefers-color-scheme:dark) { :root:not([data-theme="light"]) { --paper:#141414; --raised:#202020; --block:#242424; --ink:#F3F3F3; --soft:#D4D4D4; --muted:#A3A3A3; --line:#303030; --hover:rgba(243,243,243,.07); --negative:#F27E74; --caution:#E8B84A; --positive:#8FD46E; --scrim:rgba(0,0,0,.55); } }
|
|
60
|
+
:root[data-theme="dark"] { --paper:#141414; --raised:#202020; --block:#242424; --ink:#F3F3F3; --soft:#D4D4D4; --muted:#A3A3A3; --line:#303030; --hover:rgba(243,243,243,.07); --negative:#F27E74; --caution:#E8B84A; --positive:#8FD46E; --scrim:rgba(0,0,0,.55); }
|
|
61
|
+
:root[data-theme="light"] { color-scheme:light; } :root[data-theme="dark"] { color-scheme:dark; }
|
|
62
|
+
* { box-sizing:border-box; } [hidden] { display:none !important; }
|
|
63
|
+
body { margin:0; background:var(--paper); color:var(--ink); font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
|
64
|
+
button,input,select { font:inherit; color:inherit; } button { cursor:pointer; border:0; background:none; } button:disabled { opacity:.3; cursor:default; }
|
|
65
|
+
button,a,input,select,summary { -webkit-tap-highlight-color:transparent; } :focus-visible { outline:2px solid var(--ink); outline-offset:3px; }
|
|
66
|
+
.icon { flex:none; display:block; } .icon-button { width:36px; height:36px; display:inline-flex; align-items:center; justify-content:center; border-radius:50%; flex:none; }
|
|
67
|
+
.icon-button:hover:not(:disabled) { background:var(--hover); } .muted { color:var(--muted); } .spacer { flex:1; }
|
|
68
|
+
header { position:sticky; top:0; z-index:10; background:var(--paper); padding:24px 24px 16px; }
|
|
69
|
+
.heading { display:flex; align-items:center; gap:12px; margin-bottom:20px; }
|
|
70
|
+
h1 { margin:0; font-size:28px; line-height:1.2; font-weight:650; letter-spacing:-.8px; } .account { font-size:14px; color:var(--muted); }
|
|
71
|
+
.local { display:inline-flex; align-items:center; gap:6px; color:var(--muted); font-size:12px; } .local::before { content:""; width:5px; height:5px; border-radius:50%; background:currentColor; }
|
|
72
|
+
.toolbar { display:flex; gap:12px; align-items:center; }
|
|
73
|
+
.filters { display:flex; gap:8px; align-items:center; min-width:0; } .pill { display:inline-flex; align-items:center; gap:7px; white-space:nowrap; height:36px; padding:0 13px; border-radius:999px; background:var(--block); font-size:13px; }
|
|
74
|
+
.pill:hover { background:var(--hover); } .pill[aria-pressed="true"] { background:var(--ink); color:var(--paper); } .pill .icon { width:16px; height:16px; }
|
|
75
|
+
.count { color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums; } .pill[aria-pressed="true"] .count { color:inherit; opacity:.7; }
|
|
76
|
+
.select-wrap { position:relative; } select { appearance:none; border:0; border-radius:999px; background:var(--block); padding:0 34px 0 13px; max-width:230px; height:36px; cursor:pointer; font-size:13px; } .select-wrap .icon { position:absolute; right:12px; top:10px; pointer-events:none; width:16px; height:16px; }
|
|
77
|
+
.search { display:flex; align-items:center; gap:8px; padding:0 12px; height:36px; border-radius:999px; background:var(--block); width:230px; }
|
|
78
|
+
.search .icon { width:16px; height:16px; color:var(--muted); } .search input { background:none; border:0; outline:none; width:100%; min-width:0; font-size:13px; } .search:focus-within { box-shadow:0 0 0 1px var(--muted); } input::placeholder { color:var(--muted); }
|
|
79
|
+
.view-switch { display:flex; padding:3px; background:var(--block); border-radius:999px; gap:2px; } .view-switch .icon-button { width:30px; height:30px; } .view-switch [aria-pressed="true"] { background:var(--raised); }
|
|
80
|
+
#result-count { white-space:nowrap; min-width:50px; text-align:right; }
|
|
81
|
+
.grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:4px; padding:0 16px 16px; }
|
|
82
|
+
@media(min-width:640px) { .grid { grid-template-columns:repeat(3,minmax(0,1fr)); } }
|
|
83
|
+
@media(min-width:768px) { .grid { grid-template-columns:repeat(4,minmax(0,1fr)); } }
|
|
84
|
+
@media(min-width:1024px) { .grid { grid-template-columns:repeat(5,minmax(0,1fr)); } }
|
|
85
|
+
@media(min-width:1280px) { .grid { grid-template-columns:repeat(6,minmax(0,1fr)); } }
|
|
86
|
+
@media(min-width:1536px) { .grid { grid-template-columns:repeat(8,minmax(0,1fr)); } }
|
|
87
|
+
.card { min-width:0; margin:0; position:relative; aspect-ratio:9/16; }
|
|
88
|
+
.frame { position:absolute; width:100%; top:50%; transform:translateY(-50%); aspect-ratio:var(--ar); max-height:100%; overflow:hidden; background:var(--block); cursor:pointer; padding:0; border-radius:0; }
|
|
89
|
+
.frame video,.frame img { width:100%; height:100%; display:block; object-fit:contain; } .frame img { position:absolute; inset:0; opacity:0; } .frame img.on { opacity:1; }
|
|
90
|
+
.frame::after { content:""; position:absolute; inset:50% 0 0; background:linear-gradient(transparent,rgba(0,0,0,.68)); opacity:0; transition:opacity .18s; pointer-events:none; }
|
|
91
|
+
.card:hover .frame::after,.card:focus-within .frame::after { opacity:1; }
|
|
92
|
+
.tile-kind { position:absolute; top:10px; right:10px; color:#fff; background:rgba(0,0,0,.45); border-radius:99px; padding:4px 7px; display:flex; align-items:center; gap:5px; font-size:11px; } .tile-kind .icon { width:13px; height:13px; }
|
|
93
|
+
.tile-info { position:absolute; z-index:1; left:12px; bottom:12px; right:12px; color:#fff; text-align:left; opacity:0; transform:translateY(5px); transition:opacity .18s,transform .18s; }
|
|
94
|
+
.card:hover .tile-info,.card:focus-within .tile-info { opacity:1; transform:none; } .tile-slug { font-size:13px; font-weight:550; display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .tile-meta { font-size:11px; opacity:.8; display:block; margin-top:3px; }
|
|
95
|
+
.tile-alert { position:absolute; bottom:10px; left:10px; display:flex; align-items:center; gap:5px; background:var(--raised); color:var(--negative); padding:5px 8px; border-radius:99px; font-size:11px; z-index:2; } .tile-alert .icon { width:14px; height:14px; } .card:hover .tile-alert { bottom:auto; top:10px; }
|
|
96
|
+
.gone { width:100%; height:100%; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:12px; padding:24px; color:var(--muted); text-align:center; }
|
|
97
|
+
.gone .icon { width:28px; height:28px; } .gone strong { font-size:13px; font-weight:500; } .gone span { font-size:11px; overflow-wrap:anywhere; }
|
|
98
|
+
.list-head { display:none; } .grid.rows { display:flex; flex-direction:column; gap:0; padding:0 24px; }
|
|
99
|
+
.grid.rows .card { aspect-ratio:auto; display:grid; grid-template-columns:56px minmax(0,1fr) 180px 90px 130px; gap:18px; align-items:center; min-height:88px; padding:10px 0; border-bottom:1px solid var(--line); }
|
|
100
|
+
.grid.rows .frame { position:relative; top:auto; transform:none; width:44px; height:64px; aspect-ratio:auto; background:none; border-radius:3px; } .grid.rows .frame::after,.grid.rows .tile-kind,.grid.rows .tile-info,.grid.rows .tile-alert { display:none; }
|
|
101
|
+
.row-info,.row-lane,.row-length,.row-state { display:none; } .grid.rows .row-info { display:block; text-align:left; padding:0; min-width:0; } .row-title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:14px; font-weight:500; } .row-slug { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
102
|
+
.grid.rows .row-lane,.grid.rows .row-length,.grid.rows .row-state { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--muted); } .grid.rows .row-length { justify-content:flex-end; font-variant-numeric:tabular-nums; } .row-state .icon { width:15px; height:15px; } .grid.rows .row-state.attention { color:var(--negative); }
|
|
103
|
+
.list-head.visible { display:grid; grid-template-columns:56px minmax(0,1fr) 180px 90px 130px; gap:18px; padding:0 24px 8px; color:var(--muted); font-size:12px; } .list-head span:nth-child(4) { text-align:right; }
|
|
104
|
+
.empty { padding:80px 24px; text-align:center; color:var(--muted); } .empty p { margin:0 0 16px; } footer { padding:14px 24px 24px; color:var(--muted); font-size:11px; }
|
|
105
|
+
#toast { position:fixed; bottom:24px; left:50%; transform:translateX(-50%); z-index:100; background:var(--ink); color:var(--paper); padding:10px 18px; border-radius:999px; font-size:13px; box-shadow:0 4px 24px var(--scrim); }
|
|
106
|
+
#viewer { position:fixed; inset:0; z-index:30; background:var(--scrim); display:flex; align-items:center; justify-content:center; padding:28px; }
|
|
107
|
+
.viewer-shell { width:min(1160px,100%); height:min(900px,calc(100dvh - 56px)); background:var(--raised); border-radius:16px; overflow:hidden; display:flex; flex-direction:column; box-shadow:0 16px 80px var(--scrim); }
|
|
108
|
+
.viewer-bar { height:64px; display:flex; align-items:center; gap:8px; padding:12px 16px; flex:none; } .viewer-title { min-width:0; flex:1; } #viewer-slug { font-size:14px; font-weight:550; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; } #viewer-position { font-size:12px; color:var(--muted); }
|
|
109
|
+
#copy-caption { display:none; } .feed #copy-caption { display:inline-flex; }
|
|
110
|
+
#posts { flex:1; min-height:0; overflow:hidden; position:relative; } .post { display:none; height:100%; } .post.current { display:grid; grid-template-columns:minmax(0,1fr) 360px; }
|
|
111
|
+
.stage { min-height:0; min-width:0; background:var(--paper); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:20px; }
|
|
112
|
+
.media { width:100%; min-height:0; flex:1; position:relative; display:flex; align-items:center; justify-content:center; overflow:hidden; } .media video { max-width:100%; max-height:100%; aspect-ratio:var(--ar); display:block; }
|
|
113
|
+
.safe-zones { position:absolute; pointer-events:none; z-index:2; overflow:hidden; } .safe-zone-band { position:absolute; background:repeating-linear-gradient(45deg,transparent 0 5px,color-mix(in srgb,var(--caution) 45%,transparent) 5px 7px); } #safe-zones[aria-pressed="true"] { color:var(--caution); background:var(--hover); }
|
|
114
|
+
.deck { position:relative; aspect-ratio:var(--ar); height:100%; max-width:100%; max-height:100%; } .deck img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; opacity:0; } .deck img.on { opacity:1; }
|
|
115
|
+
.slide-nav { position:absolute; top:50%; transform:translateY(-50%); background:rgba(0,0,0,.45); color:#fff; width:32px; height:32px; } .slide-nav:hover { background:rgba(0,0,0,.65)!important; } .slide-prev { left:8px; } .slide-next { right:8px; }
|
|
116
|
+
.prev .icon,.slide-prev .icon { transform:rotate(180deg); }
|
|
117
|
+
.dots { display:flex; align-items:center; justify-content:center; gap:3px; flex:none; } .dot { padding:8px; border-radius:50%; } .dot::before { content:""; display:block; width:5px; height:5px; border-radius:50%; background:var(--muted); opacity:.35; } .dot[aria-current="true"]::before { opacity:1; background:var(--ink); }
|
|
118
|
+
.body { min-height:0; overflow-y:auto; padding:24px; } .detail-title { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; font-size:15px; font-weight:550; } .caption { white-space:pre-wrap; overflow-wrap:anywhere; line-height:1.65; font-size:14px; color:var(--soft); } .no-caption { color:var(--negative); }
|
|
119
|
+
.state { display:flex; gap:7px; align-items:center; margin:24px 0 0; font-size:12px; color:var(--muted); } .state .icon { width:16px; height:16px; } .issues { padding:14px; border-radius:10px; margin-top:24px; background:var(--paper); font-size:12px; color:var(--negative); } .issues p { margin:6px 0; }
|
|
120
|
+
.details { margin-top:24px; font-size:12px; color:var(--muted); } summary { cursor:pointer; list-style:none; display:flex; align-items:center; gap:7px; } summary .icon { width:15px; height:15px; } details[open] summary .icon { transform:rotate(90deg); }
|
|
121
|
+
.metadata { margin:14px 0 0; } .metadata div { display:flex; gap:16px; justify-content:space-between; padding:6px 0; } dt,dd { margin:0; } dd { color:var(--soft); text-align:right; overflow-wrap:anywhere; min-width:0; } .build { margin-top:12px; width:100%; display:flex; gap:10px; align-items:flex-start; padding:12px; text-align:left; background:var(--paper); border-radius:8px; } .build code { flex:1; min-width:0; overflow-wrap:anywhere; font:11px/1.6 ui-monospace,monospace; } .build .icon { width:14px; height:14px; margin-top:2px; }
|
|
122
|
+
#help { position:fixed; top:72px; right:24px; z-index:60; background:var(--raised); padding:20px; border-radius:12px; box-shadow:0 8px 40px var(--scrim); width:290px; } #help p { margin:0 0 14px; font-weight:550; } #help div { display:flex; align-items:center; justify-content:space-between; margin-top:10px; font-size:12px; color:var(--muted); } kbd { font:11px ui-monospace,monospace; background:var(--block); padding:3px 6px; border-radius:4px; color:var(--ink); }
|
|
123
|
+
#viewer.feed { padding:0; background:var(--paper); } .feed .viewer-shell { width:100%; height:100dvh; border-radius:0; box-shadow:none; background:var(--paper); } .feed #posts { display:flex; overflow-x:auto; overflow-y:hidden; scroll-snap-type:x mandatory; gap:34px; } .feed #posts::before,.feed #posts::after { content:""; flex:0 0 calc((100vw - var(--pw))/2 - 34px); } .feed .post { display:flex; flex-direction:column; flex:0 0 var(--pw); min-width:0; scroll-snap-align:center; scroll-snap-stop:always; opacity:.25; transition:opacity .18s; } .feed .post.current { opacity:1; }
|
|
124
|
+
.feed .stage { display:block; flex:none; padding:0; background:none; margin-top:calc(-1 * var(--shift,0px)); } .feed .media { width:100%; aspect-ratio:var(--ar); overflow:visible; } .feed .media video,.feed .deck { width:100%; height:100%; object-fit:contain; }
|
|
125
|
+
.feed .body { padding:16px 0 24px; flex:1; overflow-x:clip; overscroll-behavior-y:contain; } .feed .detail-title,.feed .details,.feed .state,.feed .issues { display:none; } .feed .dots { padding-top:8px; }
|
|
126
|
+
@media(hover:none) { .tile-info { opacity:1; transform:none; } .frame::after { opacity:1; } .tile-alert { top:10px; bottom:auto; } }
|
|
127
|
+
@media(max-width:800px) { header { padding:20px 16px 12px; } .heading { margin-bottom:16px; } .toolbar { flex-wrap:wrap; gap:10px; } .filters { flex:1; } .search { width:180px; } #result-count { display:none; } .grid.rows .card,.list-head.visible { grid-template-columns:44px minmax(0,1fr) 100px; gap:12px; } .grid.rows .row-lane,.grid.rows .row-length,.list-head span:nth-child(3),.list-head span:nth-child(4) { display:none; } .grid.rows { padding:0 16px; } .list-head.visible { padding:0 16px 8px; } #viewer { padding:12px; } .viewer-shell { height:calc(100dvh - 24px); } .post.current { grid-template-columns:minmax(0,1fr) 300px; } .body { padding:20px; } }
|
|
128
|
+
@media(max-width:600px) { h1 { font-size:25px; } .local { display:none; } .toolbar { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:10px 8px; } .search { order:0; width:100%; } .toolbar .spacer { display:none; } .filters { grid-column:1/-1; gap:6px; overflow-x:auto; padding:2px; } select { max-width:170px; } .pill { padding:0 10px; } .pill-label { display:none; } #viewer { padding:0; } .viewer-shell { height:100dvh; border-radius:0; } .viewer-bar { height:60px; padding:10px 8px; gap:2px; } .viewer-bar .icon-button { width:32px; height:32px; } #posts { overflow-y:auto; } .post.current { display:flex; flex-direction:column; height:auto; min-height:100%; } .stage { height:64dvh; flex:none; padding:12px; } .body { overflow:visible; padding:20px; } .feed .post.current,.feed .post { height:100%; min-height:0; } .feed .stage { height:auto; } .feed .body { overflow-y:auto; } #help { top:70px; right:12px; } }
|
|
129
|
+
@media(prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition:none!important; } }
|
|
174
130
|
</style>
|
|
175
|
-
|
|
131
|
+
</head>
|
|
132
|
+
<body>
|
|
176
133
|
<header>
|
|
177
|
-
<h1>${esc(account)}
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<span class="keys"><kbd>f</kbd> feed · <kbd>/</kbd> filter</span>
|
|
187
|
-
</header>
|
|
188
|
-
|
|
189
|
-
<div class="grid" id="grid">
|
|
190
|
-
${items.map(card).join("\n")}
|
|
191
|
-
</div>
|
|
192
|
-
|
|
193
|
-
<div id="feed">
|
|
194
|
-
<div id="feedbar">
|
|
195
|
-
<button id="tofeedclose">← Grid</button>
|
|
196
|
-
<span id="feedpos"></span>
|
|
197
|
-
<button id="sound">🔇 Sound off</button>
|
|
198
|
-
<button id="copyfeed">Copy caption</button>
|
|
199
|
-
<span class="keys"><kbd>←</kbd><kbd>→</kbd> posts · <kbd>↑</kbd><kbd>↓</kbd> caption · <kbd>[</kbd><kbd>]</kbd> slides · <kbd>space</kbd> pause · <kbd>m</kbd> sound · <kbd>esc</kbd> back</span>
|
|
134
|
+
<div class="heading"><h1>Posts</h1><span class="account">${esc(account)}</span><span class="spacer"></span><span class="local" title="Local files. Nothing is published from this preview.">Local preview</span>${iconButton('grid-sound', 'muted', 'Unmute previews (M)', 'aria-pressed="false"')}${iconButton('help-toggle', 'help', 'Keyboard shortcuts', 'aria-expanded="false" aria-controls="help"')}${iconButton('theme', 'moon', 'Switch theme')}</div>
|
|
135
|
+
<div class="toolbar">
|
|
136
|
+
<div class="filters"><button class="pill" id="all" aria-pressed="true">All <span class="count">${items.length}</span></button><button class="pill" id="attention" aria-pressed="false" title="Failed checks, missing media or captions, and changed files" aria-label="Needs attention">${icon('warning')}<span class="pill-label">Needs attention</span><span class="count">${attentionCount}</span></button>
|
|
137
|
+
${lanes.length > 1 ? `<label class="select-wrap"><select id="lane" aria-label="Filter by lane"><option value="">All lanes</option>${lanes.map(lane => `<option value="${esc(lane)}">${esc(lane)} (${items.filter(item => item.lane === lane).length})</option>`).join('')}</select>${icon('down')}</label>` : ''}
|
|
138
|
+
</div>
|
|
139
|
+
<span class="spacer"></span><span class="count" id="result-count"></span>
|
|
140
|
+
<label class="search">${icon('search')}<input id="q" type="search" aria-label="Search posts" placeholder="Search posts"></label>
|
|
141
|
+
<div class="view-switch" role="group" aria-label="Post views">${iconButton('grid-view', 'grid', 'Grid view', 'aria-pressed="true"')}${iconButton('list-view', 'list', 'List view', 'aria-pressed="false"')}</div>
|
|
142
|
+
${iconButton('tofeed', 'feed', 'Feed view (F)')}
|
|
200
143
|
</div>
|
|
144
|
+
</header>
|
|
145
|
+
<div class="list-head" id="list-head" aria-hidden="true"><span></span><span>Post</span><span>Lane</span><span>Length</span><span>State</span></div>
|
|
146
|
+
<main class="grid" id="grid" aria-label="Posts">${data.map(card).join('\n')}</main>
|
|
147
|
+
<div class="empty" id="empty" hidden><p>No posts match these filters.</p><button class="pill" id="reset">Clear filters</button></div>
|
|
148
|
+
<footer>Updated ${esc(generatedAt)} · <code>sprid post preview</code></footer>
|
|
149
|
+
<div id="viewer" role="dialog" aria-modal="true" aria-label="Post preview" hidden>
|
|
150
|
+
<div class="viewer-shell"><div class="viewer-bar">
|
|
151
|
+
${iconButton('previous', 'chevron', 'Previous post')}
|
|
152
|
+
${iconButton('next', 'chevron', 'Next post')}
|
|
153
|
+
<div class="viewer-title"><div id="viewer-slug"></div><div id="viewer-position"></div></div>
|
|
154
|
+
${iconButton('playback', 'pause', 'Pause (Space)')}${iconButton('sound', 'muted', 'Turn sound on (M)', 'aria-pressed="false"')}${iconButton('safe-zones', 'safe', 'Safe zones: approximate platform interface', 'aria-pressed="false"')}${iconButton('copy-caption', 'copy', 'Copy caption')}${iconButton('viewer-mode', 'feed', 'Feed view')}${iconButton('close', 'close', 'Close preview (Escape)')}
|
|
155
|
+
</div><div id="posts"></div></div>
|
|
201
156
|
</div>
|
|
202
|
-
|
|
157
|
+
<div id="help" hidden><p>Keyboard shortcuts</p><div>Search <kbd>/</kbd></div><div>Feed view <kbd>F</kbd></div><div>Previous / next post <kbd>← →</kbd></div><div>Previous / next slide <kbd>[ ]</kbd></div><div>Pause / play <kbd>Space</kbd></div><div>Sound <kbd>M</kbd></div><div>Close <kbd>Esc</kbd></div></div>
|
|
158
|
+
<div id="toast" role="status" aria-live="polite" hidden></div>
|
|
203
159
|
<script>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
160
|
+
const ITEMS = ${scriptJson(data)};
|
|
161
|
+
const SAFE_ZONE_BANDS = ${scriptJson(SAFE_ZONE_BANDS)};
|
|
162
|
+
const ICONS = ${scriptJson(Object.fromEntries(Object.keys(paths).map(name => [name, icon(name)])))};
|
|
163
|
+
const escapeHtml = ${esc.toString()};
|
|
164
|
+
const $ = id => document.getElementById(id);
|
|
165
|
+
const grid = $('grid'), viewer = $('viewer'), posts = $('posts');
|
|
166
|
+
let filtered = ITEMS.map((_, n) => n), selected = 0, mode = 'detail', muted = true, deckTimer = null, originFocus = null, toastTimer, scrollTimer, view = 'grid';
|
|
167
|
+
const reducedMotion = matchMedia('(prefers-reduced-motion:reduce)');
|
|
168
|
+
const preferredDark = matchMedia('(prefers-color-scheme:dark)');
|
|
169
|
+
let theme;
|
|
170
|
+
try { theme = localStorage.getItem('sprid-preview-theme'); } catch {}
|
|
171
|
+
function paintTheme() {
|
|
172
|
+
if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme;
|
|
173
|
+
const dark = theme ? theme === 'dark' : preferredDark.matches;
|
|
174
|
+
$('theme').innerHTML = ICONS[dark ? 'sun' : 'moon'];
|
|
175
|
+
$('theme').title = $('theme').ariaLabel = dark ? 'Switch to light theme' : 'Switch to dark theme';
|
|
209
176
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
/* ── grid behaviour ─────────────────────────────────────────────────────── */
|
|
225
|
-
const grid = document.getElementById("grid");
|
|
226
|
-
const q = document.getElementById("q");
|
|
227
|
-
let laneFilter = null, failsOnly = false;
|
|
228
|
-
|
|
229
|
-
function applyFilter() {
|
|
230
|
-
const needle = q.value.trim().toLowerCase();
|
|
231
|
-
for (const card of grid.children) {
|
|
232
|
-
const hay = card.dataset.hay;
|
|
233
|
-
const ok =
|
|
234
|
-
(!needle || hay.includes(needle)) &&
|
|
235
|
-
(!laneFilter || card.dataset.lane === laneFilter) &&
|
|
236
|
-
(!failsOnly || card.dataset.checks === "fail");
|
|
237
|
-
card.hidden = !ok;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
q.addEventListener("input", applyFilter);
|
|
241
|
-
document.getElementById("lanes")?.addEventListener("click", (e) => {
|
|
242
|
-
const b = e.target.closest("button"); if (!b) return;
|
|
243
|
-
laneFilter = laneFilter === b.dataset.lane ? null : b.dataset.lane;
|
|
244
|
-
for (const x of e.currentTarget.children) x.classList.toggle("on", x.dataset.lane === laneFilter);
|
|
245
|
-
applyFilter();
|
|
246
|
-
});
|
|
247
|
-
document.getElementById("failsonly").addEventListener("click", (e) => {
|
|
248
|
-
failsOnly = !failsOnly; e.target.classList.toggle("on", failsOnly); applyFilter();
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
// Hover previews the clip, click opens it in the feed. Both muted: a grid that
|
|
252
|
-
// makes noise is a grid nobody leaves open.
|
|
253
|
-
for (const card of grid.children) {
|
|
254
|
-
const frame = card.querySelector(".frame");
|
|
255
|
-
const v = card.querySelector("video");
|
|
256
|
-
const imgs = [...card.querySelectorAll(".frame img")];
|
|
257
|
-
frame.addEventListener("click", () => openFeed(card.dataset.slug));
|
|
258
|
-
if (v) {
|
|
259
|
-
frame.addEventListener("mouseenter", () => { v.play().catch(() => {}); });
|
|
260
|
-
frame.addEventListener("mouseleave", () => { v.pause(); v.currentTime = 0; });
|
|
261
|
-
} else if (imgs.length > 1) {
|
|
262
|
-
let t = null, at = 0;
|
|
263
|
-
frame.addEventListener("mouseenter", () => {
|
|
264
|
-
t = setInterval(() => {
|
|
265
|
-
imgs[at].classList.remove("on");
|
|
266
|
-
at = (at + 1) % imgs.length;
|
|
267
|
-
imgs[at].classList.add("on");
|
|
268
|
-
}, 700);
|
|
269
|
-
});
|
|
270
|
-
frame.addEventListener("mouseleave", () => {
|
|
271
|
-
clearInterval(t);
|
|
272
|
-
imgs[at].classList.remove("on"); at = 0; imgs[0].classList.add("on");
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
grid.addEventListener("click", (e) => {
|
|
277
|
-
const b = e.target.closest("button"); if (!b) return;
|
|
278
|
-
if (b.dataset.copy != null) {
|
|
279
|
-
navigator.clipboard.writeText(b.closest("figcaption").querySelector(".caption").textContent);
|
|
280
|
-
b.textContent = "Copied"; setTimeout(() => (b.textContent = "Copy caption"), 1200);
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
grid.addEventListener("click", (e) => {
|
|
284
|
-
const cap = e.target.closest(".caption");
|
|
285
|
-
if (cap) cap.classList.toggle("tall");
|
|
286
|
-
for (const sel of [".slug", ".build"]) {
|
|
287
|
-
const el = e.target.closest(sel);
|
|
288
|
-
if (!el) continue;
|
|
289
|
-
navigator.clipboard.writeText(el.title);
|
|
290
|
-
el.dataset.copied = "1";
|
|
291
|
-
setTimeout(() => delete el.dataset.copied, 1000);
|
|
292
|
-
}
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
/* ── feed ───────────────────────────────────────────────────────────────── */
|
|
296
|
-
const feed = document.getElementById("feed");
|
|
297
|
-
const pos = document.getElementById("feedpos");
|
|
298
|
-
let built = false, index = 0, visual = -1, muted = true, settling = false, settleTimer = null;
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Centre one post, by arithmetic.
|
|
302
|
-
*
|
|
303
|
-
* **scrollIntoView({ inline: "center" }) does not centre this container**: with
|
|
304
|
-
* the two rails that let the first and last post sit in the middle, it settled
|
|
305
|
-
* a full rail short every time, so the reel you had selected sat off to the
|
|
306
|
-
* right and its neighbour held the centre. The sum is three terms long and it
|
|
307
|
-
* is right in every browser.
|
|
308
|
-
*/
|
|
309
|
-
function scrollToIndex(behavior) {
|
|
310
|
-
const el = feed.querySelector(\`.post[data-n="\${index}"]\`);
|
|
311
|
-
if (!el) return;
|
|
312
|
-
settling = true;
|
|
313
|
-
clearTimeout(settleTimer);
|
|
314
|
-
settleTimer = setTimeout(() => (settling = false), behavior === "smooth" ? 520 : 140);
|
|
315
|
-
feed.scrollTo({ left: el.offsetLeft - (feed.clientWidth - el.clientWidth) / 2, behavior });
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function build() {
|
|
319
|
-
if (built) return; built = true;
|
|
320
|
-
feed.insertAdjacentHTML(
|
|
321
|
-
"beforeend",
|
|
322
|
-
ITEMS.map((it, n) => \`
|
|
323
|
-
<section class="post" data-n="\${n}" data-slug="\${it.slug}" data-kind="\${it.kind}" style="--ar:\${it.ar}">
|
|
324
|
-
\${
|
|
325
|
-
it.kind === "carousel"
|
|
326
|
-
? \`<div class="deck">\${it.slides.map((src, i) => \`<img alt="" data-src="\${src}" class="\${i === 0 ? "on" : ""}">\`).join("")}</div>
|
|
327
|
-
<div class="dots">\${it.slides.map((_, i) => \`<span class="dot \${i === 0 ? "on" : ""}" data-i="\${i}"></span>\`).join("")}</div>\`
|
|
328
|
-
: \`<video preload="none" loop playsinline muted data-src="\${it.src}"></video>\`
|
|
329
|
-
}
|
|
330
|
-
<div class="body"><div class="caption"></div></div>
|
|
331
|
-
</section>\`).join(""),
|
|
332
|
-
);
|
|
333
|
-
// The caption goes in as text, never as markup: it is prose from a file and
|
|
334
|
-
// an ampersand in it must survive.
|
|
335
|
-
feed.querySelectorAll(".post").forEach((p, n) => { p.querySelector(".caption").textContent = ITEMS[n].caption; });
|
|
336
|
-
// A tap on a neighbour goes to it, which is what a half-visible thing invites.
|
|
337
|
-
// On the post you are already on, a tap on a deck steps through its slides.
|
|
338
|
-
feed.addEventListener("click", (e) => {
|
|
339
|
-
const p = e.target.closest(".post");
|
|
340
|
-
if (!p) return;
|
|
341
|
-
if (Number(p.dataset.n) !== index) return go(Number(p.dataset.n) - index);
|
|
342
|
-
const dot = e.target.closest(".dot");
|
|
343
|
-
if (dot) return showSlide(p, Number(dot.dataset.i));
|
|
344
|
-
const deck = e.target.closest(".deck");
|
|
345
|
-
if (deck) {
|
|
346
|
-
const r = deck.getBoundingClientRect();
|
|
347
|
-
stepSlide(p, e.clientX - r.left < r.width / 2 ? -1 : 1);
|
|
348
|
-
}
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
// Only the posts near the viewport hold a src. Seventy-one <video src> at
|
|
352
|
-
// once is a browser that stalls before it plays anything.
|
|
353
|
-
// **Which post is the current one is answered twice, at two speeds.**
|
|
354
|
-
// The look of it - the bright one, the one that plays, the name in the bar -
|
|
355
|
-
// changes the moment a new post takes the middle of the screen, because a
|
|
356
|
-
// selection that waits for the scroll to stop feels like the page lagging
|
|
357
|
-
// behind your hand. The expensive half (loading and dropping video sources,
|
|
358
|
-
// the address in the URL) waits for the scroll to settle, since doing that
|
|
359
|
-
// for every post a flick passes over is work nobody sees.
|
|
360
|
-
let scrollTimer = null, raf = 0;
|
|
361
|
-
feed.addEventListener("scroll", () => {
|
|
362
|
-
if (!raf) raf = requestAnimationFrame(() => { raf = 0; markCurrent(nearest()); });
|
|
363
|
-
clearTimeout(scrollTimer);
|
|
364
|
-
scrollTimer = setTimeout(settle, 90);
|
|
365
|
-
});
|
|
366
|
-
if ("onscrollend" in feed) feed.addEventListener("scrollend", settle);
|
|
367
|
-
|
|
368
|
-
// **A sideways gesture is the browser's, and only the browser's.** Taking it
|
|
369
|
-
// over cost the trackpad its momentum and its snap - a two-finger swipe moved
|
|
370
|
-
// the feed in dead little steps - so the handler now touches the vertical
|
|
371
|
-
// half only, where it has something to add: down reads the caption of the
|
|
372
|
-
// post you are on, wherever the pointer happens to be. The caption box is
|
|
373
|
-
// kept out of the way of the other axis in CSS (overflow-x:clip and
|
|
374
|
-
// overscroll-behavior-y), not here.
|
|
375
|
-
feed.addEventListener(
|
|
376
|
-
"wheel",
|
|
377
|
-
(e) => {
|
|
378
|
-
if (Math.abs(e.deltaX) >= Math.abs(e.deltaY)) return;
|
|
379
|
-
e.preventDefault();
|
|
380
|
-
nudge(e.deltaY);
|
|
381
|
-
},
|
|
382
|
-
{ passive: false },
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/** The post whose centre is nearest the middle of the scrollport. */
|
|
387
|
-
function nearest() {
|
|
388
|
-
const mid = feed.scrollLeft + feed.clientWidth / 2;
|
|
389
|
-
let best = 0, bestD = Infinity;
|
|
390
|
-
feed.querySelectorAll(".post").forEach((p, n) => {
|
|
391
|
-
const d = Math.abs(p.offsetLeft + p.clientWidth / 2 - mid);
|
|
392
|
-
if (d < bestD) { bestD = d; best = n; }
|
|
177
|
+
paintTheme(); preferredDark.addEventListener('change', paintTheme);
|
|
178
|
+
$('theme').onclick = () => { theme = (theme ? theme === 'dark' : preferredDark.matches) ? 'light' : 'dark'; try { localStorage.setItem('sprid-preview-theme', theme); } catch {} paintTheme(); };
|
|
179
|
+
if (location.protocol.startsWith('http')) { const es = new EventSource('/__reload'); es.onmessage = () => location.reload(); }
|
|
180
|
+
function toast(message) { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => $('toast').hidden = true, 1800); }
|
|
181
|
+
async function copy(text, label = 'Caption copied') { try { await navigator.clipboard.writeText(text); toast(label); } catch { toast('Copy unavailable. Select and copy the text.'); } }
|
|
182
|
+
function filter() {
|
|
183
|
+
const needle = $('q').value.trim().toLowerCase(), lane = $('lane')?.value;
|
|
184
|
+
const attention = $('attention').getAttribute('aria-pressed') === 'true';
|
|
185
|
+
filtered = [];
|
|
186
|
+
[...grid.children].forEach((card, n) => {
|
|
187
|
+
const item = ITEMS[n];
|
|
188
|
+
card.hidden = Boolean((needle && !(item.slug + ' ' + (item.lane || '') + ' ' + (item.caption || '')).toLowerCase().includes(needle)) || (lane && item.lane !== lane) || (attention && !item.issues.length));
|
|
189
|
+
if (!card.hidden) filtered.push(n);
|
|
393
190
|
});
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* A deck plays too - it just plays slowly.
|
|
399
|
-
*
|
|
400
|
-
* **The current carousel advances by itself**, at reading pace, because the
|
|
401
|
-
* question a batch review asks of a deck is whether it holds together as one
|
|
402
|
-
* set, and that is a thing you see rather than click through. Space stops it,
|
|
403
|
-
* the same key that pauses a reel, so the feed has one idea of "playing".
|
|
404
|
-
*/
|
|
405
|
-
let deckTimer = null;
|
|
406
|
-
|
|
407
|
-
function slidesOf(post) {
|
|
408
|
-
return [...post.querySelectorAll(".deck img")];
|
|
191
|
+
$('result-count').textContent = filtered.length + (filtered.length === 1 ? ' post' : ' posts');
|
|
192
|
+
$('empty').hidden = filtered.length > 0; $('tofeed').disabled = !filtered.length;
|
|
409
193
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
194
|
+
$('q').oninput = filter; if ($('lane')) $('lane').onchange = filter;
|
|
195
|
+
$('attention').onclick = () => { $('attention').setAttribute('aria-pressed', 'true'); $('all').setAttribute('aria-pressed', 'false'); filter(); };
|
|
196
|
+
$('all').onclick = () => { $('attention').setAttribute('aria-pressed', 'false'); $('all').setAttribute('aria-pressed', 'true'); filter(); };
|
|
197
|
+
$('reset').onclick = () => { $('q').value = ''; if ($('lane')) $('lane').value = ''; $('all').click(); };
|
|
198
|
+
function setView(next) { view = next; grid.classList.toggle('rows', view === 'list'); $('list-head').classList.toggle('visible', view === 'list'); $('grid-view').setAttribute('aria-pressed', String(view === 'grid')); $('list-view').setAttribute('aria-pressed', String(view === 'list')); }
|
|
199
|
+
$('grid-view').onclick = () => setView('grid'); $('list-view').onclick = () => setView('list');
|
|
200
|
+
$('help-toggle').onclick = () => { $('help').hidden = !$('help').hidden; $('help-toggle').setAttribute('aria-expanded', String(!$('help').hidden)); };
|
|
201
|
+
|
|
202
|
+
// Load only nearby grid media; pausing a clip never leaves sound behind.
|
|
203
|
+
const observer = new IntersectionObserver(entries => entries.forEach(entry => {
|
|
204
|
+
const frame = entry.target, video = frame.querySelector('video');
|
|
205
|
+
if (entry.isIntersecting) {
|
|
206
|
+
frame.querySelectorAll('[data-src]').forEach(media => { if (!media.getAttribute('src')) media.src = media.dataset.src; });
|
|
207
|
+
} else video?.pause();
|
|
208
|
+
}), { rootMargin:'300px' });
|
|
209
|
+
for (const card of grid.children) {
|
|
210
|
+
const frame = card.querySelector('.frame'), video = frame.querySelector('video');
|
|
211
|
+
observer.observe(frame);
|
|
212
|
+
let hoverTimer = null, slide = 0;
|
|
213
|
+
const images = [...frame.querySelectorAll('img')];
|
|
214
|
+
frame.addEventListener('mouseenter', () => {
|
|
215
|
+
if (reducedMotion.matches || view !== 'grid') return;
|
|
216
|
+
if (video) { video.muted = muted; video.play().catch(() => {}); }
|
|
217
|
+
else if (images.length > 1) hoverTimer = setInterval(() => { images[slide].classList.remove('on'); slide = (slide + 1) % images.length; images[slide].classList.add('on'); }, 900);
|
|
419
218
|
});
|
|
420
|
-
|
|
421
|
-
|
|
219
|
+
frame.addEventListener('mouseleave', () => { if (video) { video.pause(); video.currentTime = 0; } clearInterval(hoverTimer); if (images.length) { images[slide].classList.remove('on'); slide = 0; images[0].classList.add('on'); } });
|
|
220
|
+
frame.onclick = () => open(Number(card.dataset.n), 'detail');
|
|
221
|
+
card.querySelector('.row-info').onclick = () => open(Number(card.dataset.n), 'detail');
|
|
422
222
|
}
|
|
423
223
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
224
|
+
function postMarkup(item, n) {
|
|
225
|
+
const e = escapeHtml;
|
|
226
|
+
const media = item.slides?.length ? '<div class="deck">' + item.slides.map((src, i) => '<img data-src="' + e(src) + '" alt="Slide ' + (i + 1) + ' of ' + item.slides.length + '" class="' + (i === 0 ? 'on' : '') + '">').join('') +
|
|
227
|
+
(item.slides.length > 1 ? '<button class="icon-button slide-nav slide-prev" aria-label="Previous slide">' + ICONS.chevron + '</button><button class="icon-button slide-nav slide-next" aria-label="Next slide">' + ICONS.chevron + '</button>' : '') + '</div>' : item.src ? '<video data-src="' + e(item.src) + '" preload="none" loop playsinline muted controls aria-label="' + e(item.slug) + '"></video>' : '<div class="gone">' + ICONS.warning + '<strong>Media not on disk</strong><span>' + e(item.source) + '</span></div>';
|
|
228
|
+
const guide = item.src && !item.slides?.length ? '<div class="safe-zones" hidden aria-hidden="true">' + SAFE_ZONE_BANDS.map(b => '<div class="safe-zone-band" data-band="' + b.key + '" style="left:' + (b.left*100) + '%;top:' + (b.top*100) + '%;width:' + (b.width*100) + '%;height:' + (b.height*100) + '%"></div>').join('') + '</div>' : '';
|
|
229
|
+
const dots = item.slides?.length > 1 ? '<div class="dots" aria-label="Slides">' + item.slides.map((_, i) => '<button class="dot" data-slide="' + i + '" aria-label="Slide ' + (i + 1) + '" aria-current="' + (i === 0) + '"></button>').join('') + '</div>' : '';
|
|
230
|
+
const rows = [['Lane', item.lane], [item.kind === 'carousel' ? 'Slides' : 'Length', item.kind === 'carousel' ? item.slides?.length || item.slideCount : item.durationMs ? (item.durationMs / 1000).toFixed(1) + 's' : null], ['File size', item.sizeBytes ? (item.sizeBytes / 1e6).toFixed(1) + ' MB' : null], ['Loudness', typeof item.lufs === 'number' ? item.lufs.toFixed(1) + ' LUFS' : null], ['Aspect ratio', item.aspect], ['Local ID', item.slug]];
|
|
231
|
+
const state = item.spridStatus && item.spridStatus !== 'none' ? item.spridStatus : 'Local only';
|
|
232
|
+
return '<section class="post" data-n="' + n + '" style="--ar:' + item.ar + '"><div class="stage"><div class="media">' + media + guide + '</div>' + dots + '</div><div class="body"><div class="detail-title">Caption<button class="icon-button post-copy" aria-label="Copy caption" title="Copy caption">' + ICONS.copy + '</button></div><div class="caption' + (!item.caption ? ' no-caption' : '') + '">' + e(item.caption || 'No caption in the registry.') + '</div><div class="state">' + ICONS.layers + e(state) + '</div>' + (item.issues.length ? '<div class="issues">' + item.issues.map(issue => '<p>' + e(issue) + '</p>').join('') + '</div>' : '') + '<details class="details"><summary>' + ICONS.chevron + 'File details</summary><dl class="metadata">' + rows.filter(([, value]) => value != null && value !== '').map(([label, value]) => '<div><dt>' + label + '</dt><dd>' + e(value) + '</dd></div>').join('') + '<div><dt>Checks</dt><dd>' + e(item.checks || 'Not checked') + '</dd></div></dl>' + (item.checks === 'pass' && item.passed?.length ? '<p>' + e(item.passed.join(' · ')) + '</p>' : '') + (item.build ? '<button class="build" aria-label="Copy build command" title="Copy build command"><code>' + e(item.build) + '</code>' + ICONS.copy + '</button>' : '') + '</details></div></section>';
|
|
430
233
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
234
|
+
let safeZones = false;
|
|
235
|
+
function paintSafeZones() {
|
|
236
|
+
$('safe-zones').setAttribute('aria-pressed', String(safeZones));
|
|
237
|
+
for (const post of posts.children) {
|
|
238
|
+
const video = post.querySelector('video'), overlay = post.querySelector('.safe-zones');
|
|
239
|
+
if (!overlay || !video) continue;
|
|
240
|
+
overlay.hidden = !safeZones;
|
|
241
|
+
if (!safeZones || !video.getClientRects().length) continue;
|
|
242
|
+
// Use the contained picture, so letterboxing never stretches the guide.
|
|
243
|
+
const media = post.querySelector('.media').getBoundingClientRect(), box = video.getBoundingClientRect();
|
|
244
|
+
const ratio = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : Number(ITEMS[Number(post.dataset.n)].ar.split('/')[0]) / Number(ITEMS[Number(post.dataset.n)].ar.split('/')[1]);
|
|
245
|
+
const width = Math.min(box.width, box.height * ratio), height = width / ratio;
|
|
246
|
+
Object.assign(overlay.style, {left:(box.left - media.left + (box.width - width)/2)+'px', top:(box.top - media.top + (box.height - height)/2)+'px', width:width+'px', height:height+'px'});
|
|
444
247
|
}
|
|
445
|
-
|
|
248
|
+
}
|
|
249
|
+
const safeZoneResize = new ResizeObserver(paintSafeZones);
|
|
250
|
+
$('safe-zones').onclick = () => { safeZones = !safeZones; paintSafeZones(); };
|
|
251
|
+
let queue = [];
|
|
252
|
+
function open(n, nextMode) {
|
|
253
|
+
if (viewer.hidden) originFocus = document.activeElement;
|
|
254
|
+
queue = filtered.includes(n) ? [...filtered] : [n];
|
|
255
|
+
selected = queue.indexOf(n); mode = nextMode;
|
|
256
|
+
safeZoneResize.disconnect();
|
|
257
|
+
grid.querySelectorAll('video').forEach(v => v.pause());
|
|
258
|
+
posts.querySelectorAll('video').forEach(v => v.pause());
|
|
446
259
|
clearInterval(deckTimer);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
260
|
+
posts.innerHTML = queue.map(n => postMarkup(ITEMS[n], n)).join('');
|
|
261
|
+
viewer.hidden = false; viewer.classList.toggle('feed', mode === 'feed');
|
|
262
|
+
posts.querySelectorAll('.media').forEach(media => safeZoneResize.observe(media));
|
|
263
|
+
posts.querySelectorAll('video').forEach(video => video.addEventListener('loadedmetadata', paintSafeZones));
|
|
264
|
+
document.body.style.overflow = 'hidden'; grid.inert = true; document.querySelector('header').inert = true;
|
|
265
|
+
$('help').hidden = true; $('help-toggle').setAttribute('aria-expanded', 'false');
|
|
266
|
+
$('previous').classList.add('prev');
|
|
267
|
+
$('viewer-mode').innerHTML = ICONS[mode === 'feed' ? 'grid' : 'feed'];
|
|
268
|
+
$('viewer-mode').ariaLabel = $('viewer-mode').title = mode === 'feed' ? 'Post details' : 'Feed view';
|
|
269
|
+
activate(); if (mode === 'feed') scrollToSelected('instant'); $('close').focus();
|
|
457
270
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
sync();
|
|
271
|
+
function section() { return posts.querySelector('.post[data-n="' + queue[selected] + '"]'); }
|
|
272
|
+
function slideTo(post, n) {
|
|
273
|
+
const images = [...post.querySelectorAll('.deck img')]; if (!images.length) return;
|
|
274
|
+
const at = (n + images.length) % images.length;
|
|
275
|
+
images.forEach((img, i) => { if (Math.abs(i - at) <= 1 && !img.getAttribute('src')) img.src = img.dataset.src; img.classList.toggle('on', i === at); });
|
|
276
|
+
post.querySelectorAll('.dot').forEach((dot, i) => dot.setAttribute('aria-current', String(i === at)));
|
|
277
|
+
post.dataset.slide = at;
|
|
466
278
|
}
|
|
467
|
-
|
|
468
|
-
function
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
if (!near && v.src) { v.pause(); v.removeAttribute("src"); v.load(); }
|
|
489
|
-
v.muted = muted;
|
|
490
|
-
if (n === index) v.play().catch(() => {}); else v.pause();
|
|
279
|
+
function stepSlide(post, delta) { clearInterval(deckTimer); deckTimer = null; slideTo(post, Number(post.dataset.slide || 0) + delta); paintPlayback(); }
|
|
280
|
+
function playDeck(post) { clearInterval(deckTimer); deckTimer = setInterval(() => slideTo(post, Number(post.dataset.slide || 0) + 1), 2200); paintPlayback(); }
|
|
281
|
+
function paintPlayback() { const video = section()?.querySelector('video'); const paused = video ? video.paused : !deckTimer; $('playback').innerHTML = ICONS[paused ? 'play' : 'pause']; $('playback').ariaLabel = $('playback').title = paused ? 'Play (Space)' : 'Pause (Space)'; }
|
|
282
|
+
function prepareMedia(trim = false) {
|
|
283
|
+
const current = section();
|
|
284
|
+
if (!current) return;
|
|
285
|
+
const gap = parseFloat(getComputedStyle(posts).columnGap) || 0;
|
|
286
|
+
const buffer = current.clientWidth + gap;
|
|
287
|
+
// Include every visible card and one more beyond each edge, at both the
|
|
288
|
+
// current scroll position and its destination. Keep outgoing frames until rest.
|
|
289
|
+
const radius = mode === 'feed' ? Math.ceil((posts.clientWidth + current.clientWidth) / (2 * buffer)) : 0;
|
|
290
|
+
const viewport = posts.getBoundingClientRect();
|
|
291
|
+
[...posts.children].forEach((post, at) => {
|
|
292
|
+
const box = post.getBoundingClientRect();
|
|
293
|
+
const inView = mode === 'feed' && box.right > viewport.left - buffer && box.left < viewport.right + buffer;
|
|
294
|
+
const keep = Math.abs(at - selected) <= radius || inView;
|
|
295
|
+
const video = post.querySelector('video');
|
|
296
|
+
if (video) {
|
|
297
|
+
if (keep && !video.getAttribute('src')) { video.preload = 'auto'; video.src = video.dataset.src; }
|
|
298
|
+
else if (trim && !keep && video.getAttribute('src')) { video.removeAttribute('src'); video.load(); }
|
|
299
|
+
} else if (keep) slideTo(post, Number(post.dataset.slide || 0));
|
|
491
300
|
});
|
|
492
301
|
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Scrolling a post, in that order: the reel gives way, then the text moves.
|
|
496
|
-
*
|
|
497
|
-
* **The first line of a caption is the one that has to stay put.** It carries
|
|
498
|
-
* the architect, the building and the year, and a plain scroll took it off the
|
|
499
|
-
* top of the box while the video slid away, so the reader lost the label of
|
|
500
|
-
* what they were reading. So the first ~55% of the video's height is spent
|
|
501
|
-
* shrinking the video, with the caption held still, and only what is left over
|
|
502
|
-
* scrolls the text.
|
|
503
|
-
*/
|
|
504
|
-
function nudge(dy) {
|
|
505
|
-
const post = feed.querySelector(\`.post[data-n="\${index}"]\`);
|
|
506
|
-
if (!post) return;
|
|
507
|
-
const body = post.querySelector(".body");
|
|
508
|
-
const video = post.querySelector("video");
|
|
509
|
-
const max = video.getBoundingClientRect().height * 0.55;
|
|
510
|
-
let shift = parseFloat(post.style.getPropertyValue("--shift") || 0);
|
|
511
|
-
|
|
512
|
-
if (dy > 0) {
|
|
513
|
-
const use = Math.min(dy, Math.max(0, max - shift));
|
|
514
|
-
shift += use;
|
|
515
|
-
body.scrollTop += dy - use;
|
|
516
|
-
} else {
|
|
517
|
-
const room = body.scrollTop;
|
|
518
|
-
const use = Math.min(-dy, room);
|
|
519
|
-
body.scrollTop -= use;
|
|
520
|
-
shift = Math.max(0, shift + dy + use);
|
|
521
|
-
}
|
|
522
|
-
post.style.setProperty("--shift", shift + "px");
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
function openFeed(slug) {
|
|
526
|
-
build();
|
|
527
|
-
const n = ITEMS.findIndex((i) => i.slug === slug);
|
|
528
|
-
index = n < 0 ? 0 : n;
|
|
529
|
-
visual = -1;
|
|
530
|
-
feed.classList.add("open");
|
|
531
|
-
document.body.style.overflow = "hidden";
|
|
532
|
-
sync();
|
|
533
|
-
feed.querySelector(\`.post[data-n="\${index}"]\`).scrollIntoView({ behavior: "instant", inline: "center", block: "nearest" });
|
|
534
|
-
}
|
|
535
|
-
function closeFeed() {
|
|
302
|
+
function activate() {
|
|
536
303
|
clearInterval(deckTimer); deckTimer = null;
|
|
537
|
-
|
|
538
|
-
feed
|
|
539
|
-
|
|
540
|
-
|
|
304
|
+
const current = section();
|
|
305
|
+
prepareMedia(mode !== 'feed');
|
|
306
|
+
[...posts.children].forEach((post, at) => {
|
|
307
|
+
const active = at === selected;
|
|
308
|
+
post.classList.toggle('current', active); post.inert = !active && mode !== 'feed'; post.querySelector('.body').inert = !active;
|
|
309
|
+
const video = post.querySelector('video');
|
|
310
|
+
if (video) {
|
|
311
|
+
video.muted = muted; video.controls = active;
|
|
312
|
+
if (active && !reducedMotion.matches) video.play().catch(() => {}); else video.pause();
|
|
313
|
+
}
|
|
314
|
+
if (!active) { post.style.setProperty('--shift','0px'); post.querySelector('.body').scrollTop = 0; }
|
|
315
|
+
});
|
|
316
|
+
const item = ITEMS[queue[selected]];
|
|
317
|
+
$('viewer-slug').textContent = item.slug;
|
|
318
|
+
$('viewer-position').textContent = (selected + 1) + ' / ' + queue.length + (item.lane ? ' · ' + item.lane : '');
|
|
319
|
+
$('previous').disabled = selected === 0; $('next').disabled = selected === queue.length - 1;
|
|
320
|
+
$('sound').disabled = !item.src; $('playback').disabled = !item.src && !(item.slides?.length > 1); $('copy-caption').disabled = !item.caption;
|
|
321
|
+
current.querySelector('.post-copy').disabled = !item.caption;
|
|
322
|
+
if (mode === 'feed' && item.slides?.length > 1 && !reducedMotion.matches) playDeck(current);
|
|
323
|
+
$('safe-zones').disabled = !current.querySelector('video'); paintSafeZones();
|
|
324
|
+
paintPlayback(); history.replaceState(null,'','#' + encodeURIComponent(item.slug));
|
|
541
325
|
}
|
|
326
|
+
function scrollToSelected(behavior) { const post = section(); posts.scrollTo({ left:post.offsetLeft - (posts.clientWidth - post.clientWidth)/2, behavior:reducedMotion.matches ? 'instant' : behavior }); }
|
|
542
327
|
function go(delta) {
|
|
543
|
-
const next = Math.min(
|
|
544
|
-
if (
|
|
545
|
-
index = next; sync();
|
|
546
|
-
feed.querySelector(\`.post[data-n="\${index}"]\`).scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
|
|
328
|
+
const next = Math.max(0,Math.min(queue.length - 1,selected + delta)); if (next === selected) return;
|
|
329
|
+
selected = next; activate(); if (mode === 'feed') scrollToSelected('smooth'); else posts.scrollTop = 0;
|
|
547
330
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
331
|
+
function close() {
|
|
332
|
+
clearInterval(deckTimer); deckTimer = null; clearTimeout(scrollTimer);
|
|
333
|
+
posts.querySelectorAll('video').forEach(video => { video.pause(); video.removeAttribute('src'); video.load(); });
|
|
334
|
+
viewer.hidden = true; posts.innerHTML = '';
|
|
335
|
+
document.body.style.overflow = ''; grid.inert = false; document.querySelector('header').inert = false;
|
|
336
|
+
if (location.hash) history.replaceState(null,'',location.pathname + location.search);
|
|
337
|
+
originFocus?.focus();
|
|
338
|
+
}
|
|
339
|
+
$('previous').onclick = () => go(-1); $('next').onclick = () => go(1); $('close').onclick = close;
|
|
340
|
+
$('viewer-mode').onclick = () => open(queue[selected], mode === 'detail' ? 'feed' : 'detail');
|
|
341
|
+
$('tofeed').onclick = () => { if (filtered.length) open(filtered[0],'feed'); };
|
|
342
|
+
$('copy-caption').onclick = () => copy(ITEMS[queue[selected]].caption);
|
|
343
|
+
function paintSound() {
|
|
344
|
+
for (const id of ['grid-sound', 'sound']) {
|
|
345
|
+
const button = $(id);
|
|
346
|
+
button.innerHTML = ICONS[muted ? 'muted' : 'volume'];
|
|
347
|
+
button.ariaLabel = button.title = muted ? 'Unmute previews (M)' : 'Mute previews (M)';
|
|
348
|
+
button.setAttribute('aria-pressed', String(!muted));
|
|
349
|
+
}
|
|
558
350
|
}
|
|
559
|
-
addEventListener("hashchange", fromHash);
|
|
560
|
-
addEventListener("DOMContentLoaded", fromHash);
|
|
561
|
-
fromHash();
|
|
562
|
-
document.getElementById("tofeedclose").addEventListener("click", closeFeed);
|
|
563
|
-
document.getElementById("sound").addEventListener("click", toggleSound);
|
|
564
|
-
document.getElementById("copyfeed").addEventListener("click", (e) => {
|
|
565
|
-
navigator.clipboard.writeText(ITEMS[index].caption);
|
|
566
|
-
e.target.textContent = "Copied"; setTimeout(() => (e.target.textContent = "Copy caption"), 1200);
|
|
567
|
-
});
|
|
568
351
|
function toggleSound() {
|
|
569
352
|
muted = !muted;
|
|
570
|
-
document.
|
|
571
|
-
|
|
353
|
+
document.querySelectorAll('video').forEach(video => video.muted = muted);
|
|
354
|
+
paintSound();
|
|
572
355
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (e.
|
|
579
|
-
if (!
|
|
580
|
-
if (
|
|
581
|
-
if (e.
|
|
582
|
-
if (e.
|
|
583
|
-
|
|
584
|
-
if (e.
|
|
585
|
-
|
|
586
|
-
if (!body) return;
|
|
587
|
-
e.preventDefault();
|
|
588
|
-
const step = e.key.startsWith("Page") ? body.clientHeight * 0.8 : 90;
|
|
589
|
-
nudge(e.key === "ArrowUp" || e.key === "PageUp" ? -step : step);
|
|
590
|
-
}
|
|
591
|
-
if (e.key === "m") toggleSound();
|
|
592
|
-
if (e.key === " ") {
|
|
593
|
-
e.preventDefault();
|
|
594
|
-
const post = feed.querySelector(\`.post[data-n="\${index}"]\`);
|
|
595
|
-
const v = post.querySelector("video");
|
|
596
|
-
if (v) return void (v.paused ? v.play().catch(() => {}) : v.pause());
|
|
597
|
-
if (deckTimer) { clearInterval(deckTimer); deckTimer = null; } else playDeck(post);
|
|
598
|
-
}
|
|
599
|
-
// A deck steps by hand with the bracket keys, since the arrows belong to the
|
|
600
|
-
// feed and to the caption.
|
|
601
|
-
if (e.key === "[" || e.key === "]") {
|
|
602
|
-
const post = feed.querySelector(\`.post[data-n="\${index}"]\`);
|
|
603
|
-
if (post.dataset.kind === "carousel") { clearInterval(deckTimer); deckTimer = null; stepSlide(post, e.key === "]" ? 1 : -1); }
|
|
604
|
-
}
|
|
356
|
+
$('grid-sound').onclick = $('sound').onclick = toggleSound;
|
|
357
|
+
paintSound();
|
|
358
|
+
function togglePlayback() { const post = section(), video = post.querySelector('video'); if (video) { if (video.paused) video.play().catch(() => {}); else video.pause(); } else if (post.querySelectorAll('.deck img').length > 1) { if (deckTimer) { clearInterval(deckTimer); deckTimer = null; paintPlayback(); } else playDeck(post); } }
|
|
359
|
+
$('playback').onclick = togglePlayback;
|
|
360
|
+
viewer.addEventListener('click', e => {
|
|
361
|
+
if (e.target === viewer) return close();
|
|
362
|
+
const post = e.target.closest('.post'); if (!post) return;
|
|
363
|
+
if (!post.classList.contains('current')) return go(queue.indexOf(Number(post.dataset.n)) - selected);
|
|
364
|
+
if (e.target.closest('.post-copy')) return copy(ITEMS[queue[selected]].caption);
|
|
365
|
+
if (e.target.closest('.build')) return copy(ITEMS[queue[selected]].build,'Build command copied');
|
|
366
|
+
const dot = e.target.closest('.dot'); if (dot) { clearInterval(deckTimer); deckTimer = null; slideTo(post,Number(dot.dataset.slide)); paintPlayback(); }
|
|
367
|
+
if (e.target.closest('.slide-prev')) stepSlide(post,-1);
|
|
368
|
+
if (e.target.closest('.slide-next')) stepSlide(post,1);
|
|
605
369
|
});
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
370
|
+
posts.addEventListener('play', paintPlayback, true); posts.addEventListener('pause', paintPlayback, true);
|
|
371
|
+
// Horizontal scrolling owns post navigation. Vertical scrolling reads the caption.
|
|
372
|
+
posts.addEventListener('scroll', () => {
|
|
373
|
+
if (mode !== 'feed' || viewer.hidden) return;
|
|
374
|
+
prepareMedia();
|
|
375
|
+
clearTimeout(scrollTimer); scrollTimer = setTimeout(() => {
|
|
376
|
+
const mid = posts.scrollLeft + posts.clientWidth/2;
|
|
377
|
+
let best = selected, distance = Infinity;
|
|
378
|
+
[...posts.children].forEach((post, n) => { const d = Math.abs(post.offsetLeft + post.clientWidth/2 - mid); if (d < distance) { best = n; distance = d; } });
|
|
379
|
+
if (best !== selected) { selected = best; activate(); }
|
|
380
|
+
prepareMedia(true);
|
|
381
|
+
}, 100);
|
|
382
|
+
});
|
|
383
|
+
function nudge(dy) {
|
|
384
|
+
const post = section(), body = post.querySelector('.body'), media = post.querySelector('.media');
|
|
385
|
+
const max = media.getBoundingClientRect().height * .55;
|
|
386
|
+
let shift = parseFloat(post.style.getPropertyValue('--shift') || 0);
|
|
387
|
+
if (dy > 0) { const use = Math.min(dy,Math.max(0,max-shift)); shift += use; body.scrollTop += dy-use; }
|
|
388
|
+
else { const use = Math.min(-dy,body.scrollTop); body.scrollTop -= use; shift = Math.max(0,shift+dy+use); }
|
|
389
|
+
post.style.setProperty('--shift',shift+'px');
|
|
612
390
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
391
|
+
posts.addEventListener('wheel', e => { if (mode !== 'feed' || Math.abs(e.deltaX) >= Math.abs(e.deltaY) || e.ctrlKey) return; e.preventDefault(); nudge(e.deltaY); }, {passive:false});
|
|
392
|
+
function fromHash() { let slug; try { slug = decodeURIComponent(location.hash.slice(1)); } catch { return; } if (!slug) { if (!viewer.hidden) close(); return; } const n = ITEMS.findIndex(item => item.slug === slug); if (n >= 0) open(n,'detail'); }
|
|
393
|
+
addEventListener('hashchange',fromHash);
|
|
394
|
+
addEventListener('resize', () => { if (!viewer.hidden && mode === 'feed') { prepareMedia(); scrollToSelected('instant'); } });
|
|
395
|
+
addEventListener('keydown', e => {
|
|
396
|
+
if (e.key === 'Escape') { $('help').hidden = true; $('help-toggle').setAttribute('aria-expanded','false'); if (!viewer.hidden) close(); else if (e.target.tagName === 'INPUT') e.target.blur(); return; }
|
|
397
|
+
if (['INPUT','SELECT','TEXTAREA'].includes(e.target.tagName) || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
398
|
+
if (viewer.hidden) { if (e.key === 'm') toggleSound(); if (e.key === '/') { e.preventDefault(); $('q').focus(); } if (e.key === 'f' && filtered.length) open(filtered[0],'feed'); return; }
|
|
399
|
+
if (e.key === 'Tab') { const focusable = [...viewer.querySelectorAll('button:not(:disabled),video[controls],summary')].filter(el => el.getClientRects().length && !el.closest('[inert]') && (!el.closest('.post') || el.closest('.post.current'))); const first = focusable[0], last = focusable.at(-1); if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } return; }
|
|
400
|
+
if (e.key === 'ArrowRight' || e.key === 'j') { e.preventDefault(); go(1); } if (e.key === 'ArrowLeft' || e.key === 'k') { e.preventDefault(); go(-1); }
|
|
401
|
+
if (e.key === '[' || e.key === ']') { e.preventDefault(); stepSlide(section(), e.key === ']' ? 1 : -1); }
|
|
402
|
+
if (e.key === 'm' && !$('sound').disabled) toggleSound();
|
|
403
|
+
if (e.key === ' ') { e.preventDefault(); togglePlayback(); }
|
|
404
|
+
if (mode === 'feed' && ['ArrowUp','ArrowDown','PageUp','PageDown'].includes(e.key)) { e.preventDefault(); const step = e.key.startsWith('Page') ? section().querySelector('.body').clientHeight*.8 : 90; nudge(e.key === 'ArrowUp' || e.key === 'PageUp' ? -step : step); }
|
|
405
|
+
});
|
|
406
|
+
filter(); fromHash();
|
|
407
|
+
</script>
|
|
408
|
+
</body>
|
|
409
|
+
</html>`;
|
|
627
410
|
}
|
|
628
411
|
|
|
629
|
-
function card(
|
|
630
|
-
const
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
if (i.stale) marks.push(`<span class="warn">file differs from its record</span>`);
|
|
639
|
-
|
|
640
|
-
const frame = i.slides
|
|
641
|
-
? i.slides.map((src, n) => `<img src="${esc(src)}" alt="" class="${n === 0 ? "on" : ""}" loading="lazy">`).join("") +
|
|
642
|
-
`<span class="count">${i.slides.length}</span>`
|
|
643
|
-
: i.src
|
|
644
|
-
? `<video src="${esc(i.src)}" preload="metadata" muted loop playsinline></video>`
|
|
645
|
-
: `<div class="gone">media not on disk<br><span class="mono">${esc(i.source ?? "")}</span></div>`;
|
|
646
|
-
|
|
647
|
-
return `<figure class="card" data-slug="${esc(i.slug)}" data-lane="${esc(i.lane ?? "")}" data-checks="${i.checks ?? "none"}" data-hay="${esc(hay)}" style="--ar:${cssRatio(i)}">
|
|
648
|
-
<div class="frame">${frame}</div>
|
|
649
|
-
<figcaption>
|
|
650
|
-
<div class="slug" title="${esc(i.slug)}">${esc(i.slug)}</div>
|
|
651
|
-
<div class="line">${esc(i.kind === "carousel" ? `${i.slideCount ?? 0} slides` : i.durationMs ? secs(i.durationMs) : "")}${marks.length ? " " + marks.join(" ") : ""}</div>
|
|
652
|
-
${
|
|
653
|
-
i.caption
|
|
654
|
-
? `<div class="caption">${esc(i.caption)}</div><button class="copy" data-copy>Copy caption</button>`
|
|
655
|
-
: `<div class="nocap">no caption in the registry</div>`
|
|
656
|
-
}
|
|
657
|
-
${i.build ? `<div class="build" title="${esc(i.build)}">${esc(i.build)}</div>` : ""}
|
|
658
|
-
</figcaption>
|
|
659
|
-
</figure>`;
|
|
412
|
+
function card(item, n) {
|
|
413
|
+
const carousel = Boolean(item.slides?.length);
|
|
414
|
+
const length = carousel ? `${item.slides.length} slide${item.slides.length === 1 ? '' : 's'}` : item.durationMs ? secs(item.durationMs) : '';
|
|
415
|
+
const media = carousel ? item.slides.map((src, i) => `<img data-src="${esc(src)}" alt="" class="${i === 0 ? 'on' : ''}" loading="lazy">`).join('') : item.src ? `<video data-src="${esc(item.src)}" preload="metadata" muted loop playsinline></video>` : `<span class="gone">${icon('warning')}<strong>Media not on disk</strong><span>${esc(item.slug)}</span></span>`;
|
|
416
|
+
const state = item.issues.length ? 'Needs attention' : item.spridStatus && item.spridStatus !== 'none' ? item.spridStatus : item.checks === 'pass' ? 'Checked' : 'Not checked';
|
|
417
|
+
return `<article class="card" data-n="${n}" style="--ar:${item.ar}">
|
|
418
|
+
<button class="frame" aria-label="Preview ${esc(item.slug)}" title="${esc(item.slug)}">${media}<span class="tile-kind">${icon(carousel ? 'layers' : 'play')}${esc(length)}</span><span class="tile-info"><span class="tile-slug">${esc(item.slug)}</span><span class="tile-meta">${esc(item.lane || '')}</span></span>${item.issues.length ? `<span class="tile-alert" title="${esc(item.issues.join(' · '))}">${icon('warning')}${item.issues.length}</span>` : ''}</button>
|
|
419
|
+
<button class="row-info" aria-label="Open ${esc(item.slug)}"><span class="row-title">${esc(item.caption?.split('\n')[0] || item.slug)}</span><span class="row-slug">${esc(item.slug)}</span></button><span class="row-lane">${esc(item.lane || '')}</span><span class="row-length">${esc(length)}</span><span class="row-state${item.issues.length ? ' attention' : ''}">${icon(item.issues.length ? 'warning' : item.checks === 'pass' ? 'check' : 'clock')}${esc(state)}</span>
|
|
420
|
+
</article>`;
|
|
660
421
|
}
|