playwright-director 0.2.0
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/README.md +860 -0
- package/dist/Tutorial.d.ts +108 -0
- package/dist/Tutorial.d.ts.map +1 -0
- package/dist/Tutorial.js +767 -0
- package/dist/Tutorial.js.map +1 -0
- package/dist/bin/export-transcript.d.ts +3 -0
- package/dist/bin/export-transcript.d.ts.map +1 -0
- package/dist/bin/export-transcript.js +111 -0
- package/dist/bin/export-transcript.js.map +1 -0
- package/dist/cursor.d.ts +24 -0
- package/dist/cursor.d.ts.map +1 -0
- package/dist/cursor.js +96 -0
- package/dist/cursor.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +3 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +183 -0
- package/dist/init.js.map +1 -0
- package/dist/merge.d.ts +26 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +65 -0
- package/dist/merge.js.map +1 -0
- package/dist/music.d.ts +29 -0
- package/dist/music.d.ts.map +1 -0
- package/dist/music.js +107 -0
- package/dist/music.js.map +1 -0
- package/dist/overlay-html.d.ts +44 -0
- package/dist/overlay-html.d.ts.map +1 -0
- package/dist/overlay-html.js +150 -0
- package/dist/overlay-html.js.map +1 -0
- package/dist/overlay.d.ts +39 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +134 -0
- package/dist/overlay.js.map +1 -0
- package/dist/postinstall.d.ts +3 -0
- package/dist/postinstall.d.ts.map +1 -0
- package/dist/postinstall.js +38 -0
- package/dist/postinstall.js.map +1 -0
- package/dist/reporter.d.ts +22 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +166 -0
- package/dist/reporter.js.map +1 -0
- package/dist/site/build-site.d.ts +2 -0
- package/dist/site/build-site.d.ts.map +1 -0
- package/dist/site/build-site.js +82 -0
- package/dist/site/build-site.js.map +1 -0
- package/dist/site/embed.d.ts +48 -0
- package/dist/site/embed.d.ts.map +1 -0
- package/dist/site/embed.js +45 -0
- package/dist/site/embed.js.map +1 -0
- package/dist/site/generate-manifest.d.ts +15 -0
- package/dist/site/generate-manifest.d.ts.map +1 -0
- package/dist/site/generate-manifest.js +106 -0
- package/dist/site/generate-manifest.js.map +1 -0
- package/dist/site/scaffold.d.ts +3 -0
- package/dist/site/scaffold.d.ts.map +1 -0
- package/dist/site/scaffold.js +70 -0
- package/dist/site/scaffold.js.map +1 -0
- package/dist/site/scan-tutorials.d.ts +12 -0
- package/dist/site/scan-tutorials.d.ts.map +1 -0
- package/dist/site/scan-tutorials.js +54 -0
- package/dist/site/scan-tutorials.js.map +1 -0
- package/dist/site/types.d.ts +75 -0
- package/dist/site/types.d.ts.map +1 -0
- package/dist/site/types.js +2 -0
- package/dist/site/types.js.map +1 -0
- package/dist/skill-stamp.d.ts +21 -0
- package/dist/skill-stamp.d.ts.map +1 -0
- package/dist/skill-stamp.js +39 -0
- package/dist/skill-stamp.js.map +1 -0
- package/dist/slugify.d.ts +19 -0
- package/dist/slugify.d.ts.map +1 -0
- package/dist/slugify.js +29 -0
- package/dist/slugify.js.map +1 -0
- package/dist/stage-presets.d.ts +51 -0
- package/dist/stage-presets.d.ts.map +1 -0
- package/dist/stage-presets.js +45 -0
- package/dist/stage-presets.js.map +1 -0
- package/dist/styles.css +637 -0
- package/dist/timeline.d.ts +87 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +108 -0
- package/dist/timeline.js.map +1 -0
- package/dist/transcript.d.ts +87 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +291 -0
- package/dist/transcript.js.map +1 -0
- package/dist/tts-provider.d.ts +69 -0
- package/dist/tts-provider.d.ts.map +1 -0
- package/dist/tts-provider.js +182 -0
- package/dist/tts-provider.js.map +1 -0
- package/dist/types.d.ts +109 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/voice-prerender.d.ts +41 -0
- package/dist/voice-prerender.d.ts.map +1 -0
- package/dist/voice-prerender.js +122 -0
- package/dist/voice-prerender.js.map +1 -0
- package/dist/voice.d.ts +76 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +244 -0
- package/dist/voice.js.map +1 -0
- package/package.json +87 -0
- package/skills/tutorialize/SKILL.md +54 -0
- package/skills/tutorialize/agent.md +58 -0
- package/skills/tutorialize/references/api.md +489 -0
- package/skills/tutorialize/references/storytelling.md +193 -0
- package/src/styles.css +637 -0
- package/templates/site/public/.gitkeep +0 -0
- package/templates/site/public/widget.js +273 -0
- package/templates/site/src/components/TutorialsHome.astro +141 -0
- package/templates/site/src/components/VideoCard.astro +192 -0
- package/templates/site/src/components/VideoModal.astro +170 -0
- package/templates/site/src/data/.gitkeep +0 -0
- package/templates/site/src/layouts/Base.astro +56 -0
- package/templates/site/src/pages/[slug].astro +745 -0
- package/templates/site/src/pages/index.astro +5 -0
- package/templates/site/src/styles/tutorials.css +303 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* playwright-director — embeddable in-app tutorial widget.
|
|
3
|
+
*
|
|
4
|
+
* Hosted at the root of the generated gallery site. A consuming app loads it
|
|
5
|
+
* with a single script tag and marks any element with data-tutorial="<slug>":
|
|
6
|
+
*
|
|
7
|
+
* <script src="https://tutorials.example.com/widget.js" defer></script>
|
|
8
|
+
* <button data-tutorial="create-account">How does this work?</button>
|
|
9
|
+
*
|
|
10
|
+
* Clicking the element opens an overlay with the tutorial video and its
|
|
11
|
+
* step-by-step guide, fetched from <site>/embed/<slug>.json (generated by
|
|
12
|
+
* `playwright-director build-site` next to this file).
|
|
13
|
+
*
|
|
14
|
+
* Zero dependencies, styles isolated in a shadow root. Programmatic API:
|
|
15
|
+
* window.PlaywrightDirector.open(slug) / window.PlaywrightDirector.close().
|
|
16
|
+
*
|
|
17
|
+
* Theming: the site's primaryColor is the default accent; override it from
|
|
18
|
+
* the host page with `.playwright-director-widget { --playwright-director-accent: #16a34a }`.
|
|
19
|
+
*/
|
|
20
|
+
(function () {
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
if (window.PlaywrightDirector) return; // loaded twice — keep the first instance
|
|
24
|
+
|
|
25
|
+
// The site base is wherever this script is served from.
|
|
26
|
+
var script = document.currentScript || document.querySelector('script[src*="widget.js"]');
|
|
27
|
+
var BASE = script && script.src ? script.src.replace(/widget\.js(\?.*)?$/, '') : '/';
|
|
28
|
+
|
|
29
|
+
var RTL_LANGS = ['ar', 'he', 'fa', 'ur'];
|
|
30
|
+
var cache = {};
|
|
31
|
+
var host = null;
|
|
32
|
+
var root = null;
|
|
33
|
+
var accentStyle = null;
|
|
34
|
+
var lastFocus = null;
|
|
35
|
+
var seq = 0; // bumped on every open/close so a stale fetch can't reopen a dismissed dialog
|
|
36
|
+
|
|
37
|
+
function fetchTutorial(slug) {
|
|
38
|
+
if (cache[slug]) return cache[slug];
|
|
39
|
+
cache[slug] = fetch(BASE + 'embed/' + encodeURIComponent(slug) + '.json').then(function (res) {
|
|
40
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
41
|
+
return res.json();
|
|
42
|
+
});
|
|
43
|
+
cache[slug].catch(function () { delete cache[slug]; });
|
|
44
|
+
return cache[slug];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
var CSS = [
|
|
48
|
+
':host { all: initial; --playwright-director-accent: #6366f1; }',
|
|
49
|
+
'* { box-sizing: border-box; margin: 0; padding: 0; }',
|
|
50
|
+
'.backdrop { position: fixed; inset: 0; z-index: 2147483000; background: rgba(15, 23, 42, 0.55);',
|
|
51
|
+
' display: flex; align-items: center; justify-content: center; padding: 1.5rem;',
|
|
52
|
+
' font-family: inherit; font-size: 16px; line-height: 1.5; color: #0f172a; }',
|
|
53
|
+
'.panel { background: #fff; border-radius: 12px; width: min(720px, 100%); max-height: 92vh;',
|
|
54
|
+
' display: flex; flex-direction: column; overflow: hidden;',
|
|
55
|
+
' box-shadow: 0 25px 60px rgba(0, 0, 0, 0.35); }',
|
|
56
|
+
'.head { display: flex; align-items: center; gap: 0.75rem; padding: 0.9rem 1.25rem;',
|
|
57
|
+
' border-bottom: 1px solid #e2e8f0; }',
|
|
58
|
+
'.head h2 { font-size: 1.05rem; font-weight: 700; flex: 1; min-width: 0; }',
|
|
59
|
+
'.duration { flex-shrink: 0; color: #64748b; font-size: 0.8rem; }',
|
|
60
|
+
'.close { flex-shrink: 0; border: 0; background: none; cursor: pointer; font-size: 1.4rem;',
|
|
61
|
+
' line-height: 1; color: #64748b; padding: 0.25rem 0.4rem; border-radius: 6px; }',
|
|
62
|
+
'.close:hover { background: #f1f5f9; color: #0f172a; }',
|
|
63
|
+
'.body { overflow-y: auto; padding: 1.25rem; }',
|
|
64
|
+
'video { width: 100%; display: block; border-radius: 8px; background: #000; margin-bottom: 1rem; }',
|
|
65
|
+
'.desc { color: #475569; font-size: 0.92rem; margin-bottom: 1rem; }',
|
|
66
|
+
'.steps { display: flex; flex-direction: column; gap: 0.6rem; }',
|
|
67
|
+
'.step { border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }',
|
|
68
|
+
'.step-head { display: flex; align-items: flex-start; gap: 0.75rem; width: 100%;',
|
|
69
|
+
' padding: 0.7rem 0.9rem; border: 0; background: none; text-align: inherit;',
|
|
70
|
+
' font: inherit; color: inherit; }',
|
|
71
|
+
'.step.has-shot .step-head { cursor: pointer; }',
|
|
72
|
+
'.step.has-shot .step-head:hover { background: color-mix(in srgb, var(--playwright-director-accent) 6%, transparent); }',
|
|
73
|
+
'.num { flex-shrink: 0; width: 1.6rem; height: 1.6rem; display: flex; align-items: center;',
|
|
74
|
+
' justify-content: center; border-radius: 50%; background: var(--playwright-director-accent);',
|
|
75
|
+
' color: #fff; font-weight: 700; font-size: 0.8rem; }',
|
|
76
|
+
'.copy { flex: 1; min-width: 0; }',
|
|
77
|
+
'.step-title { display: block; font-weight: 600; font-size: 0.95rem; }',
|
|
78
|
+
'.step-text { display: block; color: #64748b; font-size: 0.88rem; margin-top: 0.15rem; }',
|
|
79
|
+
'.chevron { flex-shrink: 0; align-self: center; color: #94a3b8; transition: transform 0.2s; }',
|
|
80
|
+
'.step.open .chevron { transform: rotate(180deg); }',
|
|
81
|
+
'.shot { display: none; padding: 0 0.9rem 0.9rem; }',
|
|
82
|
+
'.step.open .shot { display: block; }',
|
|
83
|
+
'.shot img { width: 100%; display: block; border-radius: 6px; border: 1px solid #e2e8f0; }',
|
|
84
|
+
'.context { color: #64748b; font-style: italic; font-size: 0.88rem;',
|
|
85
|
+
' border-inline-start: 3px solid var(--playwright-director-accent); padding-inline-start: 0.75rem; }',
|
|
86
|
+
'.foot { padding: 0.8rem 1.25rem; border-top: 1px solid #e2e8f0; text-align: end; }',
|
|
87
|
+
'.foot a { color: var(--playwright-director-accent); font-size: 0.88rem; font-weight: 600;',
|
|
88
|
+
' text-decoration: none; }',
|
|
89
|
+
'.foot a:hover { text-decoration: underline; }',
|
|
90
|
+
'.state { padding: 2.5rem 1.5rem; text-align: center; color: #64748b; font-size: 0.92rem; }'
|
|
91
|
+
].join('\n');
|
|
92
|
+
|
|
93
|
+
function el(tag, className, text) {
|
|
94
|
+
var node = document.createElement(tag);
|
|
95
|
+
if (className) node.className = className;
|
|
96
|
+
if (text) node.textContent = text;
|
|
97
|
+
return node;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ensureHost() {
|
|
101
|
+
if (host) return;
|
|
102
|
+
host = el('div', 'playwright-director-widget');
|
|
103
|
+
root = host.attachShadow({ mode: 'open' });
|
|
104
|
+
var style = document.createElement('style');
|
|
105
|
+
style.textContent = CSS;
|
|
106
|
+
root.appendChild(style);
|
|
107
|
+
// The payload accent goes into its own :host rule (after the default,
|
|
108
|
+
// so it wins inside the shadow tree) — page rules targeting
|
|
109
|
+
// .playwright-director-widget still override any :host rule, which is what
|
|
110
|
+
// makes --playwright-director-accent themable from the host app.
|
|
111
|
+
accentStyle = document.createElement('style');
|
|
112
|
+
root.appendChild(accentStyle);
|
|
113
|
+
document.body.appendChild(host);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function close() {
|
|
117
|
+
seq++;
|
|
118
|
+
if (!host) return;
|
|
119
|
+
var backdrop = root.querySelector('.backdrop');
|
|
120
|
+
if (backdrop) backdrop.remove();
|
|
121
|
+
document.body.style.overflow = '';
|
|
122
|
+
document.removeEventListener('keydown', onKeydown);
|
|
123
|
+
if (lastFocus && lastFocus.focus) lastFocus.focus();
|
|
124
|
+
lastFocus = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function onKeydown(e) {
|
|
128
|
+
if (e.key === 'Escape') close();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderShell(title) {
|
|
132
|
+
ensureHost();
|
|
133
|
+
close(); // one dialog at a time
|
|
134
|
+
|
|
135
|
+
var backdrop = el('div', 'backdrop');
|
|
136
|
+
backdrop.addEventListener('click', function (e) {
|
|
137
|
+
if (e.target === backdrop) close();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
var panel = el('div', 'panel');
|
|
141
|
+
panel.setAttribute('role', 'dialog');
|
|
142
|
+
panel.setAttribute('aria-modal', 'true');
|
|
143
|
+
panel.setAttribute('aria-label', title || 'Tutorial');
|
|
144
|
+
|
|
145
|
+
var head = el('div', 'head');
|
|
146
|
+
var h = el('h2', null, title || '');
|
|
147
|
+
h.setAttribute('dir', 'auto');
|
|
148
|
+
var closeBtn = el('button', 'close', '×');
|
|
149
|
+
closeBtn.setAttribute('aria-label', 'Close');
|
|
150
|
+
closeBtn.addEventListener('click', close);
|
|
151
|
+
head.appendChild(h);
|
|
152
|
+
head.appendChild(closeBtn);
|
|
153
|
+
panel.appendChild(head);
|
|
154
|
+
backdrop.appendChild(panel);
|
|
155
|
+
root.appendChild(backdrop);
|
|
156
|
+
|
|
157
|
+
lastFocus = document.activeElement;
|
|
158
|
+
document.body.style.overflow = 'hidden';
|
|
159
|
+
document.addEventListener('keydown', onKeydown);
|
|
160
|
+
closeBtn.focus();
|
|
161
|
+
|
|
162
|
+
return { panel: panel, head: head, closeBtn: closeBtn };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderTutorial(data) {
|
|
166
|
+
var shell = renderShell(data.title);
|
|
167
|
+
if (RTL_LANGS.indexOf(data.lang) !== -1) shell.panel.setAttribute('dir', 'rtl');
|
|
168
|
+
if (data.accent && /^[#a-zA-Z0-9(),.%\s-]+$/.test(data.accent)) {
|
|
169
|
+
accentStyle.textContent = ':host { --playwright-director-accent: ' + data.accent + '; }';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (data.duration) {
|
|
173
|
+
shell.head.insertBefore(el('span', 'duration', '🕓 ' + data.duration), shell.closeBtn);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
var body = el('div', 'body');
|
|
177
|
+
|
|
178
|
+
var video = document.createElement('video');
|
|
179
|
+
video.controls = true;
|
|
180
|
+
video.preload = 'metadata';
|
|
181
|
+
video.src = BASE + data.video;
|
|
182
|
+
if (data.poster) video.poster = BASE + data.poster;
|
|
183
|
+
body.appendChild(video);
|
|
184
|
+
|
|
185
|
+
if (data.description) {
|
|
186
|
+
var desc = el('p', 'desc', data.description);
|
|
187
|
+
desc.setAttribute('dir', 'auto');
|
|
188
|
+
body.appendChild(desc);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (data.steps && data.steps.length) {
|
|
192
|
+
var steps = el('div', 'steps');
|
|
193
|
+
data.steps.forEach(function (s) {
|
|
194
|
+
if (s.context) {
|
|
195
|
+
var ctx = el('p', 'context', s.text || '');
|
|
196
|
+
ctx.setAttribute('dir', 'auto');
|
|
197
|
+
steps.appendChild(ctx);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
var step = el('div', 'step' + (s.image ? ' has-shot' : ''));
|
|
201
|
+
var headBtn = el('button', 'step-head');
|
|
202
|
+
headBtn.disabled = !s.image;
|
|
203
|
+
headBtn.setAttribute('aria-expanded', 'false');
|
|
204
|
+
if (s.num !== undefined) headBtn.appendChild(el('span', 'num', String(s.num)));
|
|
205
|
+
var copy = el('span', 'copy');
|
|
206
|
+
copy.setAttribute('dir', 'auto');
|
|
207
|
+
if (s.title) copy.appendChild(el('span', 'step-title', s.title));
|
|
208
|
+
if (s.text) copy.appendChild(el('span', 'step-text', s.text));
|
|
209
|
+
headBtn.appendChild(copy);
|
|
210
|
+
if (s.image) {
|
|
211
|
+
headBtn.appendChild(el('span', 'chevron', '▾'));
|
|
212
|
+
headBtn.addEventListener('click', function () {
|
|
213
|
+
var open = step.classList.toggle('open');
|
|
214
|
+
headBtn.setAttribute('aria-expanded', String(open));
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
step.appendChild(headBtn);
|
|
218
|
+
if (s.image) {
|
|
219
|
+
var shot = el('div', 'shot');
|
|
220
|
+
var img = document.createElement('img');
|
|
221
|
+
img.loading = 'lazy';
|
|
222
|
+
img.src = BASE + s.image;
|
|
223
|
+
img.alt = s.title || (s.num !== undefined ? 'Step ' + s.num : '');
|
|
224
|
+
shot.appendChild(img);
|
|
225
|
+
step.appendChild(shot);
|
|
226
|
+
}
|
|
227
|
+
steps.appendChild(step);
|
|
228
|
+
});
|
|
229
|
+
body.appendChild(steps);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
shell.panel.appendChild(body);
|
|
233
|
+
|
|
234
|
+
var foot = el('div', 'foot');
|
|
235
|
+
var link = document.createElement('a');
|
|
236
|
+
link.href = BASE + data.page;
|
|
237
|
+
link.target = '_blank';
|
|
238
|
+
link.rel = 'noopener';
|
|
239
|
+
link.textContent = 'Open full tutorial ↗';
|
|
240
|
+
foot.appendChild(link);
|
|
241
|
+
shell.panel.appendChild(foot);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function open(slug) {
|
|
245
|
+
var shell = renderShell('');
|
|
246
|
+
var my = ++seq;
|
|
247
|
+
shell.panel.appendChild(el('div', 'state', 'Loading…'));
|
|
248
|
+
fetchTutorial(slug).then(
|
|
249
|
+
function (data) {
|
|
250
|
+
if (my !== seq) return;
|
|
251
|
+
renderTutorial(data);
|
|
252
|
+
},
|
|
253
|
+
function () {
|
|
254
|
+
if (my !== seq) return;
|
|
255
|
+
var failed = renderShell('Tutorial unavailable');
|
|
256
|
+
failed.panel.appendChild(
|
|
257
|
+
el('div', 'state', 'Could not load tutorial "' + slug + '". Check the slug against ' + BASE + 'embed/index.json')
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Event delegation: works for elements added after load, any framework.
|
|
264
|
+
document.addEventListener('click', function (e) {
|
|
265
|
+
if (!e.target || !e.target.closest) return;
|
|
266
|
+
var trigger = e.target.closest('[data-tutorial]');
|
|
267
|
+
if (!trigger) return;
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
open(trigger.getAttribute('data-tutorial'));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
window.PlaywrightDirector = { open: open, close: close, base: BASE };
|
|
273
|
+
})();
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { readdirSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import Base from '../layouts/Base.astro';
|
|
5
|
+
import VideoCard from './VideoCard.astro';
|
|
6
|
+
import VideoModal from './VideoModal.astro';
|
|
7
|
+
import tutorials from '../data/tutorials.json';
|
|
8
|
+
|
|
9
|
+
const { categories, videos, ui } = tutorials as any;
|
|
10
|
+
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
|
|
11
|
+
|
|
12
|
+
// Scan for step screenshots at build time
|
|
13
|
+
const videosDir = join(process.cwd(), 'public/videos');
|
|
14
|
+
type StepEntry = { n: number; url: string };
|
|
15
|
+
const stepsByVideo: Record<string, string[]> = {};
|
|
16
|
+
if (existsSync(videosDir)) {
|
|
17
|
+
const staging: Record<string, StepEntry[]> = {};
|
|
18
|
+
for (const file of readdirSync(videosDir)) {
|
|
19
|
+
const m = file.match(/^(.+)-step-(\d+)\.(png|jpe?g|webp|avif)$/);
|
|
20
|
+
if (!m) continue;
|
|
21
|
+
const [, id, n] = m;
|
|
22
|
+
(staging[id] ??= []).push({ n: parseInt(n), url: `${base}/videos/${file}` });
|
|
23
|
+
}
|
|
24
|
+
for (const [id, entries] of Object.entries(staging)) {
|
|
25
|
+
stepsByVideo[id] = entries.sort((a, b) => a.n - b.n).map(s => s.url);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const getSteps = (file: string): string[] => {
|
|
30
|
+
const base = file.replace(/\.[^.]+$/, '');
|
|
31
|
+
return stepsByVideo[base] ?? [];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const videosByCategory = videos.reduce((acc: Record<string, any[]>, video: any) => {
|
|
35
|
+
if (!acc[video.category]) acc[video.category] = [];
|
|
36
|
+
acc[video.category].push(video);
|
|
37
|
+
return acc;
|
|
38
|
+
}, {} as Record<string, any[]>);
|
|
39
|
+
|
|
40
|
+
// Variant filter (desktop / mobile / …) — only shown when at least one video
|
|
41
|
+
// was recorded in a variant.
|
|
42
|
+
const variants: string[] = [...new Set(videos.map((v: any) => v.variant ?? ''))] as string[];
|
|
43
|
+
const hasVariants = variants.some((v) => v !== '');
|
|
44
|
+
const variantLabel = (v: string) => (v === '' ? 'Desktop' : v.charAt(0).toUpperCase() + v.slice(1));
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<Base title={ui.heroTitle}>
|
|
48
|
+
<section class="hero">
|
|
49
|
+
<h1>{ui.heroTitle}</h1>
|
|
50
|
+
<p>{ui.heroSubtitle}</p>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
{hasVariants && (
|
|
54
|
+
<nav class="variant-filter" aria-label="Filter by variant">
|
|
55
|
+
<button class="variant-btn active" data-variant="all">All</button>
|
|
56
|
+
{variants.sort().map((v) => (
|
|
57
|
+
<button class="variant-btn" data-variant={v}>{variantLabel(v)}</button>
|
|
58
|
+
))}
|
|
59
|
+
</nav>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
{Object.entries(categories).map(([catId, cat]: [string, any]) => {
|
|
63
|
+
const catVideos = videosByCategory[catId];
|
|
64
|
+
if (!catVideos?.length) return null;
|
|
65
|
+
return (
|
|
66
|
+
<section class="category">
|
|
67
|
+
<div class="category-header">
|
|
68
|
+
<span class="category-icon">{cat.icon}</span>
|
|
69
|
+
<h2>{cat.label}</h2>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="video-grid">
|
|
72
|
+
{catVideos.map((video: any) => (
|
|
73
|
+
<VideoCard
|
|
74
|
+
id={video.id}
|
|
75
|
+
title={video.title}
|
|
76
|
+
duration={video.duration}
|
|
77
|
+
videoUrl={`${base}/videos/${video.file}`}
|
|
78
|
+
steps={getSteps(video.file)}
|
|
79
|
+
href={`${base}/${video.id}`}
|
|
80
|
+
variant={video.variant}
|
|
81
|
+
/>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
|
|
88
|
+
<VideoModal />
|
|
89
|
+
</Base>
|
|
90
|
+
|
|
91
|
+
<style>
|
|
92
|
+
.variant-filter {
|
|
93
|
+
display: flex;
|
|
94
|
+
justify-content: center;
|
|
95
|
+
gap: 0.5rem;
|
|
96
|
+
margin: 0 0 1.5rem;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.variant-btn {
|
|
100
|
+
padding: 0.4rem 1rem;
|
|
101
|
+
border: 1px solid var(--color-border, #ddd);
|
|
102
|
+
border-radius: 999px;
|
|
103
|
+
background: transparent;
|
|
104
|
+
color: var(--color-text-muted, #666);
|
|
105
|
+
font-size: 0.9rem;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
transition: all 0.2s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.variant-btn:hover {
|
|
111
|
+
border-color: var(--color-primary, #0066fd);
|
|
112
|
+
color: var(--color-primary, #0066fd);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.variant-btn.active {
|
|
116
|
+
background: var(--color-primary, #0066fd);
|
|
117
|
+
border-color: var(--color-primary, #0066fd);
|
|
118
|
+
color: white;
|
|
119
|
+
}
|
|
120
|
+
</style>
|
|
121
|
+
|
|
122
|
+
<script>
|
|
123
|
+
const bar = document.querySelector('.variant-filter');
|
|
124
|
+
bar?.addEventListener('click', (e) => {
|
|
125
|
+
const btn = (e.target as HTMLElement).closest<HTMLElement>('.variant-btn');
|
|
126
|
+
if (!btn) return;
|
|
127
|
+
const filter = btn.dataset.variant!;
|
|
128
|
+
bar.querySelectorAll('.variant-btn').forEach((b) => b.classList.toggle('active', b === btn));
|
|
129
|
+
document.querySelectorAll<HTMLElement>('.video-card').forEach((card) => {
|
|
130
|
+
const match = filter === 'all' || (card.dataset.variant ?? '') === filter;
|
|
131
|
+
card.style.display = match ? '' : 'none';
|
|
132
|
+
});
|
|
133
|
+
// Hide categories left with no visible card
|
|
134
|
+
document.querySelectorAll<HTMLElement>('section.category').forEach((section) => {
|
|
135
|
+
const anyVisible = [...section.querySelectorAll<HTMLElement>('.video-card')].some(
|
|
136
|
+
(card) => card.style.display !== 'none'
|
|
137
|
+
);
|
|
138
|
+
section.style.display = anyVisible ? '' : 'none';
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
</script>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
duration: string;
|
|
6
|
+
videoUrl: string;
|
|
7
|
+
steps?: string[];
|
|
8
|
+
href?: string;
|
|
9
|
+
/** Recording variant (e.g. 'mobile'); absent = regular/desktop */
|
|
10
|
+
variant?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { id, title, duration, videoUrl, steps = [], href, variant } = Astro.props;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<div class="video-card" data-video-url={videoUrl} data-video-id={id} data-variant={variant ?? ''}>
|
|
17
|
+
<div class="video-thumbnail">
|
|
18
|
+
{steps.length > 0 ? (
|
|
19
|
+
<div class="step-carousel">
|
|
20
|
+
{steps.map((src, i) => (
|
|
21
|
+
<img
|
|
22
|
+
src={src}
|
|
23
|
+
alt={`Step ${i + 1}`}
|
|
24
|
+
loading={i === 0 ? 'eager' : 'lazy'}
|
|
25
|
+
class:list={['step-img', { active: i === 0 }]}
|
|
26
|
+
/>
|
|
27
|
+
))}
|
|
28
|
+
{steps.length > 1 && (
|
|
29
|
+
<>
|
|
30
|
+
<button class="step-prev" aria-label="Previous step">‹</button>
|
|
31
|
+
<button class="step-next" aria-label="Next step">›</button>
|
|
32
|
+
<div class="step-dots">
|
|
33
|
+
{steps.map((_, i) => (
|
|
34
|
+
<span class:list={['step-dot', { active: i === 0 }]}></span>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
</>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
) : (
|
|
41
|
+
<div class="placeholder-thumb"></div>
|
|
42
|
+
)}
|
|
43
|
+
<div class="play-button">▶</div>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="video-info">
|
|
46
|
+
<div class="video-title">{title}</div>
|
|
47
|
+
<div class="video-meta">
|
|
48
|
+
<span class="video-duration">🕐 {duration}</span>
|
|
49
|
+
{variant && <span class="video-variant">{variant === 'mobile' ? '📱 ' : ''}{variant}</span>}
|
|
50
|
+
{href && (
|
|
51
|
+
<a href={href} class="card-page-link" onclick="event.stopPropagation()">→</a>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<style>
|
|
58
|
+
.video-variant {
|
|
59
|
+
font-size: 0.75rem;
|
|
60
|
+
color: var(--color-text-muted, #666);
|
|
61
|
+
border: 1px solid var(--color-border, #ddd);
|
|
62
|
+
border-radius: 999px;
|
|
63
|
+
padding: 0.1rem 0.5rem;
|
|
64
|
+
text-transform: capitalize;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.card-page-link {
|
|
68
|
+
margin-left: auto;
|
|
69
|
+
font-size: 0.9rem;
|
|
70
|
+
color: var(--color-text-muted);
|
|
71
|
+
text-decoration: none;
|
|
72
|
+
transition: color 0.2s;
|
|
73
|
+
flex-shrink: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.card-page-link:hover {
|
|
77
|
+
color: var(--color-primary);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.placeholder-thumb {
|
|
81
|
+
width: 100%;
|
|
82
|
+
height: 100%;
|
|
83
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.step-carousel {
|
|
87
|
+
position: absolute;
|
|
88
|
+
inset: 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.step-img {
|
|
92
|
+
position: absolute;
|
|
93
|
+
inset: 0;
|
|
94
|
+
width: 100%;
|
|
95
|
+
height: 100%;
|
|
96
|
+
object-fit: cover;
|
|
97
|
+
opacity: 0;
|
|
98
|
+
transition: opacity 0.3s;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.step-img.active {
|
|
102
|
+
opacity: 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.step-prev,
|
|
106
|
+
.step-next {
|
|
107
|
+
position: absolute;
|
|
108
|
+
top: 50%;
|
|
109
|
+
transform: translateY(-50%);
|
|
110
|
+
background: rgba(0, 0, 0, 0.5);
|
|
111
|
+
color: white;
|
|
112
|
+
border: none;
|
|
113
|
+
width: 28px;
|
|
114
|
+
height: 28px;
|
|
115
|
+
border-radius: 50%;
|
|
116
|
+
font-size: 1.2rem;
|
|
117
|
+
line-height: 1;
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
justify-content: center;
|
|
122
|
+
opacity: 0;
|
|
123
|
+
transition: opacity 0.2s;
|
|
124
|
+
z-index: 2;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.step-prev { left: 6px; }
|
|
128
|
+
.step-next { right: 6px; }
|
|
129
|
+
|
|
130
|
+
.video-card:hover .step-prev,
|
|
131
|
+
.video-card:hover .step-next {
|
|
132
|
+
opacity: 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.step-dots {
|
|
136
|
+
position: absolute;
|
|
137
|
+
bottom: 6px;
|
|
138
|
+
left: 50%;
|
|
139
|
+
transform: translateX(-50%);
|
|
140
|
+
display: flex;
|
|
141
|
+
gap: 4px;
|
|
142
|
+
z-index: 2;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.step-dot {
|
|
146
|
+
width: 5px;
|
|
147
|
+
height: 5px;
|
|
148
|
+
border-radius: 50%;
|
|
149
|
+
background: rgba(255, 255, 255, 0.4);
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
transition: background 0.2s;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.step-dot.active {
|
|
155
|
+
background: white;
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
158
|
+
|
|
159
|
+
<script>
|
|
160
|
+
document.querySelectorAll<HTMLElement>('.step-carousel').forEach(carousel => {
|
|
161
|
+
const imgs = carousel.querySelectorAll<HTMLElement>('.step-img');
|
|
162
|
+
const dots = carousel.querySelectorAll<HTMLElement>('.step-dot');
|
|
163
|
+
const prev = carousel.querySelector('.step-prev');
|
|
164
|
+
const next = carousel.querySelector('.step-next');
|
|
165
|
+
const card = carousel.closest('.video-card') as HTMLElement;
|
|
166
|
+
let current = 0;
|
|
167
|
+
let autoMoved = false;
|
|
168
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
169
|
+
|
|
170
|
+
const show = (n: number) => {
|
|
171
|
+
imgs[current].classList.remove('active');
|
|
172
|
+
dots[current]?.classList.remove('active');
|
|
173
|
+
current = ((n % imgs.length) + imgs.length) % imgs.length;
|
|
174
|
+
imgs[current].classList.add('active');
|
|
175
|
+
dots[current]?.classList.add('active');
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
prev?.addEventListener('click', e => { e.stopPropagation(); show(current - 1); });
|
|
179
|
+
next?.addEventListener('click', e => { e.stopPropagation(); show(current + 1); });
|
|
180
|
+
dots.forEach((dot, i) => dot.addEventListener('click', e => { e.stopPropagation(); show(i); }));
|
|
181
|
+
|
|
182
|
+
card?.addEventListener('mouseenter', () => {
|
|
183
|
+
if (imgs.length < 2) return;
|
|
184
|
+
autoMoved = false;
|
|
185
|
+
timer = setInterval(() => { autoMoved = true; show(current + 1); }, 800);
|
|
186
|
+
});
|
|
187
|
+
card?.addEventListener('mouseleave', () => {
|
|
188
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
189
|
+
if (autoMoved) show(0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
</script>
|