@mevdragon/vidfarm-devcli 0.18.0 → 0.18.1

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.
Files changed (53) hide show
  1. package/.agents/skills/vidfarm-media/SKILL.md +41 -4
  2. package/SKILL.director.md +17 -3
  3. package/SKILL.platform.md +3 -3
  4. package/demo/dist/app.js +69 -69
  5. package/demo/dist/favicon.ico +0 -0
  6. package/dist/src/app.js +1550 -187
  7. package/dist/src/cli.js +227 -7
  8. package/dist/src/devcli/clip-store.js +29 -2
  9. package/dist/src/devcli/clips.js +52 -9
  10. package/dist/src/devcli/composition-edit.js +262 -0
  11. package/dist/src/devcli/timeline-edit.js +283 -0
  12. package/dist/src/editor-chat.js +29 -6
  13. package/dist/src/frontend/discover-client.js +130 -0
  14. package/dist/src/frontend/discover-store.js +23 -0
  15. package/dist/src/frontend/file-directory.js +744 -0
  16. package/dist/src/frontend/template-editor-chat.js +22 -19
  17. package/dist/src/landing-page.js +24 -7
  18. package/dist/src/page-shell.js +25 -1
  19. package/dist/src/reskin/agency-page.js +1 -1
  20. package/dist/src/reskin/calendar-page.js +2 -1
  21. package/dist/src/reskin/chat-page.js +319 -72
  22. package/dist/src/reskin/discover-page.js +486 -31
  23. package/dist/src/reskin/document.js +1171 -384
  24. package/dist/src/reskin/help-page.js +1 -0
  25. package/dist/src/reskin/inpaint-page.js +10 -4
  26. package/dist/src/reskin/library-page.js +918 -220
  27. package/dist/src/reskin/login-page.js +6 -6
  28. package/dist/src/reskin/pricing-page.js +2 -0
  29. package/dist/src/reskin/settings-page.js +55 -10
  30. package/dist/src/reskin/theme.js +337 -17
  31. package/dist/src/services/clip-curation/gemini.js +5 -0
  32. package/dist/src/services/clip-curation/hunt.js +79 -1
  33. package/dist/src/services/clip-curation/index.js +2 -1
  34. package/dist/src/services/clip-curation/local-agent.js +4 -3
  35. package/dist/src/services/clip-curation/media-select.js +85 -0
  36. package/dist/src/services/clip-curation/query.js +5 -1
  37. package/dist/src/services/clip-curation/refine.js +50 -20
  38. package/dist/src/services/clip-curation/scan.js +10 -3
  39. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  40. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  41. package/dist/src/services/clip-records.js +14 -1
  42. package/dist/src/services/clip-search.js +43 -13
  43. package/dist/src/services/file-directory.js +114 -0
  44. package/dist/src/services/storage.js +24 -1
  45. package/dist/src/services/upstream.js +5 -5
  46. package/dist/src/template-editor-shell.js +16 -2
  47. package/package.json +1 -1
  48. package/public/assets/discover-client-app.js +1 -0
  49. package/public/assets/file-directory-app.js +2 -0
  50. package/public/assets/homepage-client-app.js +12 -12
  51. package/public/assets/page-runtime-client-app.js +24 -24
  52. package/src/assets/favicon.ico +0 -0
  53. package/src/assets/logo-vidfarm.png +0 -0
@@ -41,6 +41,8 @@
41
41
  // live editorHref) and override any endpoint.
42
42
  import { reskinDocument, rkEscape } from "./document.js";
43
43
  import { POPULAR_INSPIRATION_GROUPS } from "../frontend/homepage-shared.js";
44
+ /** Config-only defaults (the data fields feedInitial/feedFetchedAt are seeded
45
+ * per-request, never defaulted). */
44
46
  const DEFAULT_INPUT = {
45
47
  accountId: "",
46
48
  isLoggedIn: false,
@@ -69,6 +71,8 @@ const MODE_COPY = {
69
71
  };
70
72
  // Functional (non-decorative) SVG glyphs.
71
73
  const EDIT_ICON = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>`;
74
+ const COPY_ICON = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/></svg>`;
75
+ const IDCHECK_ICON = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 12.5l5 5 10-11"/></svg>`;
72
76
  const BOOKMARK_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21l-5-3.4L7 21V5.2A2.2 2.2 0 0 1 9.2 3h5.6A2.2 2.2 0 0 1 17 5.2z"/></svg>`;
73
77
  const CHECK_ICON = `<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 12.5l5 5 10-11"/></svg>`;
74
78
  const CROSS_ICON = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6l12 12M18 6L6 18"/></svg>`;
@@ -77,6 +81,8 @@ const SOUND_OFF_ICON = `<svg width="17" height="17" viewBox="0 0 24 24" fill="no
77
81
  const CHEV_UP_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m5 14.5 7-7 7 7"/></svg>`;
78
82
  const CHEV_DOWN_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m5 9.5 7 7 7-7"/></svg>`;
79
83
  const PLAY_ICON = `<svg width="56" height="56" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.4v13.2L19 12z"/></svg>`;
84
+ const PLUS_ICON = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`;
85
+ const UPLOAD_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>`;
80
86
  /** Safe JSON embedding inside a <script> tag (mirrors page-shell.escapeJsonForHtml). */
81
87
  function jsonForScript(value) {
82
88
  return JSON.stringify(value ?? null)
@@ -90,7 +96,7 @@ function jsonForScript(value) {
90
96
  function modeToggle(active) {
91
97
  const links = MODES.map((m) => {
92
98
  const on = m.mode === active ? " is-active" : "";
93
- return `<a class="rk-discover-mode${on}" href="/discover${m.mode === "feed" ? "" : "?view=" + m.mode}"${m.mode === active ? ' aria-current="page"' : ""}>${m.label}</a>`;
99
+ return `<a class="rk-discover-mode${on}" href="/discover/${m.mode}"${m.mode === active ? ' aria-current="page"' : ""}>${m.label}</a>`;
94
100
  }).join("");
95
101
  return `<nav class="rk-discover-modes" aria-label="Discover mode">${links}</nav>`;
96
102
  }
@@ -139,9 +145,59 @@ function viewerHtml() {
139
145
  </div>
140
146
  </div>`;
141
147
  }
148
+ // The "Add New" modal (feed mode only) — paste a social video URL or upload a
149
+ // video, adding it to the caller's private Discover feed via the same real
150
+ // endpoints the old React SPA used (POST /discover/templates + the upload
151
+ // presign pair). Mirrors the old UI's Add Template modal, farmville-styled.
152
+ function addModalHtml() {
153
+ return `
154
+ <div class="rk-add-modal" data-rk-add-modal hidden role="dialog" aria-modal="true" aria-labelledby="rk-add-title">
155
+ <div class="rk-add-scrim" data-rk-add-close></div>
156
+ <div class="rk-add-card">
157
+ <div class="rk-add-head">
158
+ <h2 class="rk-add-title" id="rk-add-title">Add to your feed</h2>
159
+ <button type="button" class="rk-add-x" data-rk-add-close aria-label="Close">${CROSS_ICON}</button>
160
+ </div>
161
+ <p class="rk-add-sub">Paste a TikTok, YouTube, Twitter/X, or Instagram video link &mdash; or upload a video from your computer. It's added to your Discover feed, visible only to your account.</p>
162
+ <form class="rk-add-form" data-rk-add-form>
163
+ <div class="rk-add-field">
164
+ <label class="rk-add-label" for="rk-add-url">Video URL</label>
165
+ <input class="rk-add-input" id="rk-add-url" type="url" placeholder="https://www.tiktok.com/@&hellip;" autocomplete="off" data-rk-add-url />
166
+ </div>
167
+ <div class="rk-add-or"><span>or</span></div>
168
+ <label class="rk-add-file" data-rk-add-filelabel>
169
+ <input type="file" accept="video/*,.mp4,.mov,.webm,.m4v" data-rk-add-file />
170
+ <span class="rk-add-file-ic">${UPLOAD_ICON}</span>
171
+ <span class="rk-add-file-text" data-rk-add-filetext>Upload a video<span class="rk-add-optional">MP4 / MOV / WebM, up to 200 MB</span></span>
172
+ </label>
173
+ <div class="rk-add-field">
174
+ <label class="rk-add-label" for="rk-add-tagline">Tagline <span class="rk-add-optional">optional</span></label>
175
+ <input class="rk-add-input" id="rk-add-tagline" type="text" placeholder="What makes it work" autocomplete="off" data-rk-add-tagline />
176
+ </div>
177
+ <div class="rk-add-field">
178
+ <label class="rk-add-label" for="rk-add-notes">Notes <span class="rk-add-optional">optional</span></label>
179
+ <textarea class="rk-add-input rk-add-textarea" id="rk-add-notes" rows="2" placeholder="Anything to remember about this one" data-rk-add-notes></textarea>
180
+ </div>
181
+ <p class="rk-add-error" data-rk-add-error hidden></p>
182
+ <div class="rk-add-actions">
183
+ <button type="button" class="rk-btn rk-btn-ghost" data-rk-add-close>Cancel</button>
184
+ <button type="submit" class="rk-btn rk-btn-gold" data-rk-add-submit>Add to feed</button>
185
+ </div>
186
+ </form>
187
+ </div>
188
+ </div>`;
189
+ }
142
190
  export function renderReskinDiscover(mode = "feed", input = {}) {
143
191
  const copy = MODE_COPY[mode];
144
- const boot = { ...DEFAULT_INPUT, ...input };
192
+ // feedInitial/feedFetchedAt only make sense in feed mode; drop them elsewhere
193
+ // so swipe/categories boots stay lean.
194
+ const { feedInitial, feedFetchedAt, ...cfg } = input;
195
+ const boot = {
196
+ ...DEFAULT_INPUT,
197
+ ...cfg,
198
+ feedInitial: mode === "feed" ? feedInitial ?? null : null,
199
+ feedFetchedAt: mode === "feed" ? feedFetchedAt ?? null : null
200
+ };
145
201
  let modeBody = "";
146
202
  // ── FEED ────────────────────────────────────────────────────────────────
147
203
  if (mode === "feed") {
@@ -158,8 +214,7 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
158
214
  <span class="rk-discover-search-ic" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8.5" cy="8.5" r="5.8"/><path d="m13 13 4.2 4.2"/></svg></span>
159
215
  <input type="search" data-rk-search placeholder="Search templates" autocomplete="off" aria-label="Search templates" />
160
216
  </label>
161
- <button type="button" class="rk-discover-bmfilter" data-rk-bmfilter aria-pressed="false" title="Show bookmarked templates only">${BOOKMARK_ICON}<span class="rk-discover-bmfilter-count" data-rk-bmcount></span></button>
162
- <span class="rk-hint rk-discover-count" data-rk-count></span>`
217
+ <button type="button" class="rk-discover-bmfilter" data-rk-bmfilter aria-pressed="false" title="Show bookmarked templates only">${BOOKMARK_ICON}<span class="rk-discover-bmfilter-count" data-rk-bmcount></span></button>`
163
218
  : "";
164
219
  // Categories' in-page group reveal header ("← All categories" + the group
165
220
  // title) lives IN the toolbar row, next to the mode toggle (user feedback);
@@ -177,6 +232,7 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
177
232
  <div class="rk-discover-stage">
178
233
  <button type="button" class="rk-discover-act rk-discover-act-skip" data-swipe-skip data-swipe-act hidden aria-label="Skip">${CROSS_ICON}</button>
179
234
  <div class="rk-discover-deck" data-deck>
235
+ <button type="button" class="rk-discover-sound-toggle" data-swipe-sound hidden aria-label="Unmute" aria-pressed="false">${SOUND_OFF_ICON}</button>
180
236
  <div class="rk-discover-swipe-state" data-rk-swipe-state>Loading your deck&hellip;</div>
181
237
  </div>
182
238
  <button type="button" class="rk-discover-act rk-discover-act-fork" data-swipe-fork data-swipe-act hidden aria-label="Fork">${CHECK_ICON}</button>
@@ -195,11 +251,21 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
195
251
  <div class="rk-discover-state rk-discover-cats-state" data-rk-cats-state hidden></div>
196
252
  </div>${viewerHtml()}`;
197
253
  }
254
+ // "Add New" lives top-right of the page head (feed mode only) — opens the
255
+ // Add modal when signed in, else routes to /login (mirrors the old UI).
256
+ const addNewBtn = mode === "feed"
257
+ ? (boot.isLoggedIn
258
+ ? `<button type="button" class="rk-btn rk-btn-gold rk-discover-add" data-rk-add>${PLUS_ICON}<span>Add New</span></button>`
259
+ : `<a class="rk-btn rk-btn-gold rk-discover-add" href="/login">${PLUS_ICON}<span>Add New</span></a>`)
260
+ : "";
198
261
  const body = `
199
262
  <main class="rk-container rk-page">
200
263
  <div class="rk-page-head rk-discover-head">
201
- <h1 class="rk-h1">Discover</h1>
202
- <p class="rk-sub">${copy.sub}</p>
264
+ <div class="rk-discover-headtext">
265
+ <h1 class="rk-h1">Discover</h1>
266
+ <p class="rk-sub">${copy.sub}</p>
267
+ </div>
268
+ ${addNewBtn}
203
269
  </div>
204
270
  <div class="rk-discover-toolbar">
205
271
  ${modeToggle(mode)}
@@ -207,10 +273,58 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
207
273
  ${toolbarSearch}
208
274
  </div>
209
275
  ${modeBody}
276
+ ${mode === "feed" ? addModalHtml() : ""}
210
277
  <script type="application/json" data-rk-discover-boot>${jsonForScript(boot)}</script>
211
278
  </main>`;
212
279
  const pageCss = `
213
- .rk-discover-head{margin-bottom:22px}
280
+ /* head row: title block on the left, "Add New" CTA pinned top-right */
281
+ .rk-discover-head{margin-bottom:22px;display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
282
+ .rk-discover-headtext{display:grid;gap:4px;min-width:0}
283
+ .rk-discover-add{flex:none;align-self:flex-start}
284
+ .rk-discover-add svg{margin-top:-1px}
285
+ @media(max-width:520px){
286
+ .rk-discover-add span{display:none}
287
+ .rk-discover-add{padding:11px}
288
+ }
289
+
290
+ /* ── "Add New" modal ── */
291
+ .rk-add-modal{position:fixed;inset:0;z-index:140;display:grid;place-items:center;padding:20px}
292
+ .rk-add-modal[hidden]{display:none}
293
+ .rk-add-scrim{position:absolute;inset:0;background:rgba(15,12,5,.5);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
294
+ .rk-add-card{position:relative;z-index:1;width:min(480px,100%);max-height:calc(100vh - 40px);overflow:auto;
295
+ background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-xl);padding:24px 24px 22px}
296
+ .rk-add-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:6px}
297
+ .rk-add-title{font-family:var(--rk-font-display);font-size:var(--rk-text-xl);font-weight:800;color:var(--rk-ink);letter-spacing:-.02em}
298
+ .rk-add-x{flex:none;width:34px;height:34px;border-radius:var(--rk-r-full);border:1px solid var(--rk-border);background:#fff;color:var(--rk-n-600);
299
+ display:grid;place-items:center;cursor:pointer;transition:color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
300
+ .rk-add-x:hover{color:var(--rk-ink);border-color:var(--rk-border-strong)}
301
+ .rk-add-x svg{width:16px;height:16px}
302
+ .rk-add-sub{font-size:13px;color:var(--rk-text-muted);line-height:1.5;margin-bottom:18px}
303
+ .rk-add-form{display:grid;gap:14px}
304
+ .rk-add-field{display:grid;gap:6px}
305
+ .rk-add-label{font-size:12.5px;font-weight:700;color:var(--rk-n-700)}
306
+ .rk-add-optional{font-weight:500;color:var(--rk-text-faint)}
307
+ .rk-add-input{width:100%;box-sizing:border-box;border:1px solid var(--rk-border-strong);border-radius:var(--rk-r-lg);
308
+ background:#fff;padding:11px 14px;font-family:var(--rk-font-body);font-size:14px;color:var(--rk-ink);outline:none;
309
+ transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
310
+ .rk-add-input::placeholder{color:var(--rk-n-400)}
311
+ .rk-add-input:focus{border-color:var(--rk-gold-600);box-shadow:var(--rk-ring-gold)}
312
+ .rk-add-textarea{resize:vertical;min-height:52px;line-height:1.45}
313
+ .rk-add-or{display:flex;align-items:center;gap:12px;color:var(--rk-text-faint);font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}
314
+ .rk-add-or::before,.rk-add-or::after{content:"";height:1px;flex:1;background:var(--rk-border)}
315
+ .rk-add-file{display:flex;align-items:center;gap:12px;border:1px dashed var(--rk-border-strong);border-radius:var(--rk-r-lg);
316
+ padding:13px 14px;cursor:pointer;color:var(--rk-n-700);
317
+ transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
318
+ .rk-add-file:hover{border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
319
+ .rk-add-file input[type=file]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
320
+ .rk-add-file-ic{flex:none;color:var(--rk-gold-700);display:grid;place-items:center}
321
+ .rk-add-file-text{font-size:13.5px;font-weight:600;display:grid;gap:2px;min-width:0}
322
+ .rk-add-file-text .rk-add-optional{font-size:11.5px;font-weight:500}
323
+ .rk-add-file.is-picked{border-style:solid;border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
324
+ .rk-add-error{font-size:13px;color:#b91c1c;background:var(--rk-red-tint);border:1px solid #fecaca;border-radius:var(--rk-r-lg);padding:9px 12px;font-weight:500;line-height:1.4}
325
+ .rk-add-error[hidden]{display:none}
326
+ .rk-add-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}
327
+ .rk-add-actions .rk-btn[disabled]{opacity:.6;cursor:default;transform:none}
214
328
 
215
329
  /* toolbar row: mode toggle + (feed) quick search share one line, wrap on mobile */
216
330
  .rk-discover-toolbar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:26px}
@@ -233,11 +347,10 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
233
347
  .rk-discover-search-ic{color:var(--rk-text-faint);display:grid;place-items:center;flex:none}
234
348
  .rk-discover-search input{flex:1;min-width:0;border:0;outline:none;background:transparent;font-family:var(--rk-font-body);font-size:14px;color:var(--rk-ink)}
235
349
  .rk-discover-search input::placeholder{color:var(--rk-n-400)}
236
- .rk-discover-count{white-space:nowrap}
237
350
 
238
351
  .rk-discover-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(212px,1fr));gap:22px}
239
352
  @media(max-width:520px){.rk-discover-grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px}}
240
- .rk-discover-card{background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);overflow:hidden;box-shadow:var(--rk-shadow-card);display:flex;flex-direction:column}
353
+ .rk-discover-card{background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);overflow:hidden;box-shadow:var(--rk-shadow-card);display:flex;flex-direction:column;min-width:0}
241
354
  .rk-discover-poster{position:relative;display:block;aspect-ratio:9/16;overflow:hidden;background:var(--rk-n-100);color:inherit}
242
355
  .rk-discover-poster img,.rk-discover-poster video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
243
356
  .rk-discover-media-empty{position:absolute;inset:0;display:grid;place-items:center;font-size:12.5px;font-weight:600;color:var(--rk-text-faint);
@@ -252,15 +365,28 @@ export function renderReskinDiscover(mode = "feed", input = {}) {
252
365
  display:none;place-items:center;color:#fff;background:rgba(0,0,0,.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);cursor:pointer;
253
366
  transition:transform var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
254
367
  .rk-discover-posterwrap:hover .rk-discover-bm{display:grid}
368
+ /* touch has no hover, so always show the bookmark button there (else you can
369
+ never save a not-yet-bookmarked card from the grid) */
370
+ @media(hover:none){.rk-discover-bm{display:grid}}
255
371
  .rk-discover-bm:hover{transform:scale(1.08);background:rgba(0,0,0,.6)}
256
372
  .rk-discover-bm.is-on{display:grid;background:var(--rk-gold-500);color:var(--rk-ink)}
257
373
  .rk-discover-bm.is-on svg{fill:currentColor}
258
- .rk-discover-body{padding:15px 16px 16px;display:grid;gap:13px}
259
- .rk-discover-titlewrap{display:grid;gap:6px}
374
+ .rk-discover-body{padding:15px 16px 16px;display:grid;gap:13px;min-width:0}
375
+ .rk-discover-titlewrap{display:grid;gap:6px;min-width:0}
260
376
  .rk-discover-title{font-family:var(--rk-font-display);font-size:1.02rem;font-weight:700;letter-spacing:-.01em;color:var(--rk-ink);line-height:1.2;
261
377
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
262
378
  .rk-discover-desc{font-size:12.5px;font-weight:500;color:var(--rk-text-muted);line-height:1.4;
263
379
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
380
+ .rk-discover-idchip{justify-self:start;display:inline-flex;align-items:center;gap:6px;max-width:100%;min-width:0;
381
+ padding:3px 8px;border-radius:999px;background:var(--rk-surface-2,rgba(0,0,0,.05));
382
+ font-family:var(--rk-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:11px;font-weight:600;
383
+ color:var(--rk-text-muted);cursor:pointer;border:0;line-height:1.4;transition:background .15s,color .15s;
384
+ -webkit-user-select:none;user-select:none;box-sizing:border-box}
385
+ .rk-discover-idchip:hover{background:var(--rk-surface-3,rgba(0,0,0,.09));color:var(--rk-ink)}
386
+ .rk-discover-idchip svg{flex:0 0 auto;opacity:.7}
387
+ .rk-discover-idchip .rk-idchip-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
388
+ .rk-discover-idchip.is-copied{background:rgba(52,168,110,.16);color:#1f7a4d}
389
+ .rk-discover-idchip.is-copied svg{opacity:1;color:#1f7a4d}
264
390
  .rk-discover-fork{gap:7px}
265
391
  .rk-discover-fork svg{margin-top:-1px}
266
392
  .rk-discover-fork-disabled{opacity:.6;cursor:default;pointer-events:none}
@@ -358,12 +484,22 @@ video.rk-discover-viewer-media{cursor:pointer}
358
484
  transition:transform .38s var(--rk-ease),opacity .38s var(--rk-ease)}
359
485
  .rk-discover-swipe-frame{position:absolute;inset:0;width:100%;height:100%;border:0;display:block;pointer-events:none;background:#000}
360
486
  .rk-discover-swipe-fallback{position:absolute;inset:0;display:grid;place-items:center;padding:24px;text-align:center;
361
- background:linear-gradient(155deg,#3a3a3a,#111);color:#fff;font-family:var(--rk-font-display);font-weight:700;font-size:1.1rem;line-height:1.15}
487
+ background:linear-gradient(155deg,#3a3a3a,#111);color:#fff;font-family:var(--rk-font-display);font-weight:700;font-size:1.1rem;line-height:1.15;
488
+ transition:opacity .4s var(--rk-ease)}
362
489
  .rk-discover-swipe-scrim{position:absolute;inset:0;z-index:1;pointer-events:none;background:linear-gradient(to top,rgba(0,0,0,.62) 0%,rgba(0,0,0,.16) 46%,transparent 74%)}
363
490
  .rk-discover-swipe-info{position:absolute;left:0;right:0;bottom:0;z-index:2;padding:20px;display:grid;gap:6px;pointer-events:none}
364
491
  .rk-discover-swipe-title{font-family:var(--rk-font-display);font-weight:700;font-size:1.05rem;color:#fff;letter-spacing:-.01em;line-height:1.18}
365
492
  .rk-discover-swipe-caption{font-size:12.5px;font-weight:500;color:rgba(255,255,255,.84);line-height:1.35;
366
493
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
494
+ /* audio toggle — overlays the top-of-deck card, top-right; global mute state */
495
+ .rk-discover-sound-toggle{position:absolute;top:12px;right:12px;z-index:40;width:38px;height:38px;border-radius:var(--rk-r-full);
496
+ border:0;display:grid;place-items:center;cursor:pointer;color:#fff;background:rgba(15,15,15,.5);backdrop-filter:blur(6px);
497
+ -webkit-backdrop-filter:blur(6px);box-shadow:0 2px 8px -2px rgba(0,0,0,.5);
498
+ transition:background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
499
+ .rk-discover-sound-toggle[hidden]{display:none}
500
+ .rk-discover-sound-toggle:hover{background:rgba(15,15,15,.72);transform:scale(1.06)}
501
+ .rk-discover-sound-toggle.is-on{background:var(--rk-gold-500);color:var(--rk-ink)}
502
+ .rk-discover-sound-toggle.is-on:hover{background:var(--rk-gold-700)}
367
503
 
368
504
  /* deck flanked by skip/fork — buttons ride the card's vertical center, 1fr gutters absorb spare width.
369
505
  Explicit column placement keeps the deck centered when the buttons are display:none (empty/caught-up states). */
@@ -382,6 +518,17 @@ video.rk-discover-viewer-media{cursor:pointer}
382
518
  .rk-discover-act-skip{grid-area:skip;justify-self:center}
383
519
  .rk-discover-act-fork{grid-area:fork;justify-self:center}
384
520
  }
521
+ /* short viewports (landscape phones, small laptops): the deck is sized by width
522
+ with a locked 9/16 ratio → ~570px tall, taller than the screen, so the card +
523
+ its flanking Skip/Fork controls fall off-screen. Cap it by HEIGHT instead and
524
+ let width follow the ratio (mirrors how the fullscreen viewer stage sizes). */
525
+ @media(max-height:720px){
526
+ .rk-discover-deck{height:min(74vh,572px);width:auto}
527
+ }
528
+ @media(max-height:520px){
529
+ .rk-discover-head{margin-bottom:10px}
530
+ .rk-discover-swipe-wrap{gap:10px;padding:0}
531
+ }
385
532
  .rk-discover-act:hover{transform:translateY(-2px);box-shadow:var(--rk-shadow-lg)}
386
533
  .rk-discover-act:disabled{opacity:.5;cursor:default;transform:none}
387
534
  .rk-discover-act-skip{color:var(--rk-n-500)}
@@ -447,6 +594,61 @@ video.rk-discover-viewer-media{cursor:pointer}
447
594
  var u=editorBase+'/'+encodeURIComponent(String(id));
448
595
  return accountId? u+'?account='+encodeURIComponent(accountId) : u;
449
596
  }
597
+ // Feed cards browse INSPIRATIONS: an entry may have no template yet. The real
598
+ // template id (if decomposed) opens the editor directly; otherwise the card
599
+ // carries an inspirationId and its CTA decomposes on demand (preview → Remix).
600
+ function realTemplateId(t){ var id=t.templateId||t.template_id||''; return /^template_/.test(String(id))? String(id):''; }
601
+ function inspirationIdOf(t){ return String(t.inspirationId||t.inspiration_id||''); }
602
+ function domId(t){ return realTemplateId(t)||inspirationIdOf(t)||''; }
603
+ function needsDecompose(t){ return !realTemplateId(t) && Boolean(inspirationIdOf(t)); }
604
+ // POST the lazy decompose, then hand off to the editor. Shared by card +
605
+ // viewer Remix buttons via a delegated [data-remix] listener.
606
+ function remixInspiration(btn){
607
+ var inspId=btn.getAttribute('data-remix'); if(!inspId||btn.getAttribute('data-busy')) return;
608
+ btn.setAttribute('data-busy','1');
609
+ var label=btn.innerHTML; btn.innerHTML='Preparing&hellip;';
610
+ fetch(withAccount('/discover/inspirations/'+encodeURIComponent(inspId)+'/decompose'),{method:'POST',credentials:'same-origin',headers:{Accept:'application/json'}})
611
+ .then(function(r){ return r.json().then(function(d){ return {ok:r.ok,data:d}; }); })
612
+ .then(function(res){
613
+ if(res.ok&&res.data&&res.data.templateId){ window.location.href=editorUrl(res.data.templateId); return; }
614
+ btn.removeAttribute('data-busy'); btn.innerHTML=label;
615
+ window.alert((res.data&&res.data.error)||'Could not open this inspiration — please try again.');
616
+ })
617
+ .catch(function(){ btn.removeAttribute('data-busy'); btn.innerHTML=label; window.alert('Network error — please try again.'); });
618
+ }
619
+ document.addEventListener('click',function(e){
620
+ var b=e.target&&e.target.closest?e.target.closest('[data-remix]'):null;
621
+ if(!b) return; e.preventDefault(); remixInspiration(b);
622
+ });
623
+ // Click the template-id chip on a feed card to copy the id to the clipboard,
624
+ // with a brief inline "Copied" swap (icon + label) as subtle confirmation.
625
+ function copyText(text){
626
+ if(navigator.clipboard&&navigator.clipboard.writeText){ return navigator.clipboard.writeText(text); }
627
+ return new Promise(function(resolve,reject){
628
+ try{
629
+ var ta=document.createElement('textarea'); ta.value=text;
630
+ ta.style.position='fixed'; ta.style.top='-9999px'; document.body.appendChild(ta);
631
+ ta.focus(); ta.select(); var ok=document.execCommand('copy'); document.body.removeChild(ta);
632
+ ok?resolve():reject(new Error('copy failed'));
633
+ }catch(err){ reject(err); }
634
+ });
635
+ }
636
+ document.addEventListener('click',function(e){
637
+ var chip=e.target&&e.target.closest?e.target.closest('[data-copy-id]'):null;
638
+ if(!chip) return;
639
+ e.preventDefault(); e.stopPropagation();
640
+ var id=chip.getAttribute('data-copy-id')||'';
641
+ if(!id||chip.getAttribute('data-copied')) return;
642
+ copyText(id).then(function(){
643
+ var txt=chip.querySelector('.rk-idchip-text'); var prev=txt?txt.textContent:'';
644
+ chip.setAttribute('data-copied','1'); chip.classList.add('is-copied');
645
+ chip.innerHTML=IDCHECK_ICON+'<span class="rk-idchip-text">Copied</span>';
646
+ window.setTimeout(function(){
647
+ chip.classList.remove('is-copied'); chip.removeAttribute('data-copied');
648
+ chip.innerHTML=COPY_ICON+'<span class="rk-idchip-text">'+esc(prev)+'</span>';
649
+ },1300);
650
+ }).catch(function(){});
651
+ });
450
652
  function fmtDuration(s){
451
653
  s=Number(s); if(!s||!isFinite(s)||s<=0) return '';
452
654
  if(s>=60){ var m=Math.floor(s/60); var r=Math.round(s%60); return m+':'+(r<10?'0':'')+r; }
@@ -455,6 +657,8 @@ video.rk-discover-viewer-media{cursor:pointer}
455
657
  var SOURCE_MAP={tiktok:'TikTok','tiktok.com':'TikTok',youtube:'YouTube','youtube.com':'YouTube','youtu.be':'YouTube',instagram:'Instagram','instagram.com':'Instagram',twitter:'Twitter/X',x:'Twitter/X','x.com':'Twitter/X'};
456
658
  function sourceLabel(s){ s=String(s||'').toLowerCase(); if(!s) return ''; if(SOURCE_MAP[s]) return SOURCE_MAP[s]; var host=s.replace(/^www\\./,'').split('.')[0]; return host.charAt(0).toUpperCase()+host.slice(1); }
457
659
  var EDIT_ICON=${JSON.stringify(EDIT_ICON)};
660
+ var COPY_ICON=${JSON.stringify(COPY_ICON)};
661
+ var IDCHECK_ICON=${JSON.stringify(IDCHECK_ICON)};
458
662
  var BOOKMARK_ICON=${JSON.stringify(BOOKMARK_ICON)};
459
663
  var SOUND_ON_ICON=${JSON.stringify(SOUND_ON_ICON)};
460
664
  var SOUND_OFF_ICON=${JSON.stringify(SOUND_OFF_ICON)};
@@ -528,32 +732,41 @@ video.rk-discover-viewer-media{cursor:pointer}
528
732
  // opens the editor via the untouched href). No platform/source pill on the
529
733
  // poster (user feedback) — source shows only in the viewer's meta line.
530
734
  function posterCardHtml(t, viewIdx){
531
- var id=t.templateId||t.template_id||'';
735
+ var id=domId(t);
736
+ var tid=realTemplateId(t);
737
+ var remix=needsDecompose(t);
532
738
  var url=t.previewUrl||t.preview_url||'';
533
739
  var hasVideo=Boolean(url)&&!isImage(url);
534
740
  var pending=(t.status==='processing'||t.status==='failed');
535
741
  var viewable=!pending&&Boolean(url)&&typeof viewIdx==='number'&&viewIdx>=0;
536
742
  var dur=fmtDuration(t.durationSeconds);
537
743
  var desc=String(t.viralDna||t.summary||'').trim();
744
+ // Real templates deep-link to the editor; inspiration-only cards open the
745
+ // preview viewer instead (a plain anchor href would 404 the editor), and the
746
+ // CTA decomposes on demand.
747
+ var posterHref=remix? '#' : editorUrl(tid);
538
748
  var posterInner=mediaHtml(t)
539
749
  +(dur?'<span class="rk-discover-dur">'+esc(dur)+'</span>':'')
540
750
  +'<div class="rk-discover-scrim"></div>'
541
751
  +(hasVideo&&!pending?'<span class="rk-discover-sound" aria-hidden="true"><span class="rk-ds-on">'+SOUND_ON_ICON+'</span><span class="rk-ds-off">'+SOUND_OFF_ICON+'</span></span>':'');
542
752
  var poster=pending
543
753
  ? '<div class="rk-discover-poster">'+posterInner+'</div>'
544
- : '<a class="rk-discover-poster" href="'+attr(editorUrl(id))+'"'+(viewable?' data-rk-view="'+viewIdx+'"':'')+' aria-label="'+attr((viewable?'Preview ':'Open ')+(t.title||id)+(viewable?'':' in the editor'))+'">'+posterInner+'</a>';
754
+ : '<a class="rk-discover-poster" href="'+attr(posterHref)+'"'+(viewable?' data-rk-view="'+viewIdx+'"':'')+' aria-label="'+attr((viewable?'Preview ':'Open ')+(t.title||id)+(viewable?'':' in the editor'))+'">'+posterInner+'</a>';
545
755
  // bookmark sits NEXT TO the anchor inside the wrap (button-in-anchor is
546
756
  // invalid HTML); the wrap is the shared hover context for sound + bookmark
547
757
  poster='<div class="rk-discover-posterwrap">'+poster+(pending?'':bmBtnHtml(id,'rk-discover-bm'))+'</div>';
548
758
  var cta=pending
549
759
  ? '<span class="rk-btn rk-btn-ghost rk-btn-sm rk-btn-block rk-discover-fork rk-discover-fork-disabled" aria-disabled="true">'+(t.status==='failed'?'Download failed':'Preparing&hellip;')+'</span>'
550
- : '<a class="rk-btn rk-btn-gold rk-btn-sm rk-btn-block rk-discover-fork" href="'+attr(editorUrl(id))+'">'+EDIT_ICON+' Open Editor</a>';
760
+ : remix
761
+ ? '<button type="button" class="rk-btn rk-btn-gold rk-btn-sm rk-btn-block rk-discover-fork" data-remix="'+attr(inspirationIdOf(t))+'">'+EDIT_ICON+' Remix</button>'
762
+ : '<a class="rk-btn rk-btn-gold rk-btn-sm rk-btn-block rk-discover-fork" href="'+attr(editorUrl(tid))+'">'+EDIT_ICON+' Open Editor</a>';
551
763
  return '<article class="rk-discover-card rk-card-hover">'
552
764
  + poster
553
765
  + '<div class="rk-discover-body">'
554
766
  + '<div class="rk-discover-titlewrap">'
555
767
  + '<h3 class="rk-discover-title">'+esc(t.title||id)+'</h3>'
556
768
  + (desc?'<p class="rk-discover-desc">'+esc(desc)+'</p>':'')
769
+ + (tid?'<button type="button" class="rk-discover-idchip" data-copy-id="'+attr(tid)+'" title="Copy template ID">'+COPY_ICON+'<span class="rk-idchip-text">'+esc(tid)+'</span></button>':'')
557
770
  + '</div>'
558
771
  + cta
559
772
  + '</div>'
@@ -610,7 +823,9 @@ video.rk-discover-viewer-media{cursor:pointer}
610
823
  var vItems=[]; var vOpen=false; var vActive=-1; var vSound=true; var vIO=null; var parkEl=null;
611
824
 
612
825
  function viewerSlideHtml(t,i){
613
- var id=t.templateId||t.template_id||'';
826
+ var id=domId(t);
827
+ var tid=realTemplateId(t);
828
+ var remix=needsDecompose(t);
614
829
  var url=t.previewUrl||t.preview_url||'';
615
830
  var media=isImage(url)
616
831
  ? '<img class="rk-discover-viewer-media" src="'+attr(url)+'" alt="'+attr((t.title||'Template')+' preview')+'">'
@@ -629,7 +844,9 @@ video.rk-discover-viewer-media{cursor:pointer}
629
844
  +(meta.length?'<div class="rk-discover-viewer-meta">'+meta.join(' &middot; ')+'</div>':'')
630
845
  +'<div class="rk-discover-viewer-title">'+esc(t.title||id)+'</div>'
631
846
  +(desc?'<div class="rk-discover-viewer-desc">'+esc(desc)+'</div>':'')
632
- +'<a class="rk-btn rk-btn-gold rk-btn-sm rk-discover-viewer-fork" href="'+attr(editorUrl(id))+'">'+EDIT_ICON+' Open Editor</a>'
847
+ +(remix
848
+ ? '<button type="button" class="rk-btn rk-btn-gold rk-btn-sm rk-discover-viewer-fork" data-remix="'+attr(inspirationIdOf(t))+'">'+EDIT_ICON+' Remix</button>'
849
+ : '<a class="rk-btn rk-btn-gold rk-btn-sm rk-discover-viewer-fork" href="'+attr(editorUrl(tid))+'">'+EDIT_ICON+' Open Editor</a>')
633
850
  +'</div>'
634
851
  +'</figure>'
635
852
  +'</section>';
@@ -764,9 +981,18 @@ video.rk-discover-viewer-media{cursor:pointer}
764
981
  if(feedRoot){
765
982
  var gridEl=feedRoot.querySelector('[data-rk-grid]');
766
983
  var stateEl=feedRoot.querySelector('[data-rk-state]');
767
- // search + count live in the shared toolbar row (outside [data-rk-feed])
768
- var countEl=root.querySelector('[data-rk-count]');
984
+ // search lives in the shared toolbar row (outside [data-rk-feed]); the
985
+ // visible-count is folded into the search placeholder rather than shown as
986
+ // a separate label.
769
987
  var searchEl=root.querySelector('[data-rk-search]');
988
+ // caption the placeholder with the count, but only while the box is empty —
989
+ // a typed query keeps its own placeholder-less state.
990
+ function setCountHint(n){
991
+ if(!searchEl) return;
992
+ searchEl.placeholder = (n==null)
993
+ ? 'Search templates'
994
+ : 'Search '+n+' template'+(n===1?'':'s');
995
+ }
770
996
  var bmChip=root.querySelector('[data-rk-bmfilter]');
771
997
  var bmCountEl=root.querySelector('[data-rk-bmcount]');
772
998
  var bmFilter=false;
@@ -776,7 +1002,7 @@ video.rk-discover-viewer-media{cursor:pointer}
776
1002
  function feedSetState(msg,isError){
777
1003
  if(gridEl) gridEl.innerHTML='';
778
1004
  if(stateEl){ stateEl.hidden=false; stateEl.innerHTML=msg; stateEl.classList.toggle('is-error',!!isError); }
779
- if(countEl) countEl.textContent='';
1005
+ setCountHint(null);
780
1006
  }
781
1007
  function updateBmChip(){
782
1008
  if(!bmChip) return;
@@ -798,7 +1024,7 @@ video.rk-discover-viewer-media{cursor:pointer}
798
1024
  }
799
1025
  if(stateEl) stateEl.hidden=true;
800
1026
  if(gridEl) gridEl.innerHTML=vis.map(function(t,i){ return posterCardHtml(t,i); }).join('');
801
- if(countEl) countEl.textContent=vis.length+' template'+(vis.length===1?'':'s');
1027
+ setCountHint(vis.length);
802
1028
  }
803
1029
  if(bmChip) bmChip.addEventListener('click',function(){ bmFilter=!bmFilter; feedRender(); });
804
1030
  bmChangeHandlers.push(function(){
@@ -807,17 +1033,42 @@ video.rk-discover-viewer-media{cursor:pointer}
807
1033
  // re-render restarts muted autoplay, so re-park it if the viewer is open
808
1034
  if(bmFilter){ feedRender(); if(viewerCtl&&viewerCtl.isOpen()) parkVideos(gridEl); }
809
1035
  });
810
- function feedLoad(query){
811
- var my=++feedReq;
812
- feedState.loading=true;
813
- feedSetState('Loading templates&hellip;',false);
1036
+ function applyFeedItems(items){
1037
+ feedState.items=Array.isArray(items)?items:[];
1038
+ feedState.loading=false;
1039
+ feedRender();
1040
+ }
1041
+ // Direct same-origin fetch — the fallback for when the Zustand bridge isn't
1042
+ // on the page yet (its deferred module is still loading) or is disabled.
1043
+ function feedFetchDirect(query,my){
814
1044
  var url=feedEndpoint;
815
1045
  try{ var u=new URL(feedEndpoint, window.location.href); if(query) u.searchParams.set('q', query); if(accountId) u.searchParams.set('account', accountId); url=u.toString(); }catch(e){ url=feedEndpoint; }
816
1046
  fetch(url,{credentials:'same-origin',headers:{'accept':'application/json'}})
817
1047
  .then(function(r){ if(!r.ok){ throw new Error(r.status===401?'Sign in to browse the template feed.':("Couldn't load templates ("+r.status+").")); } return r.json(); })
818
- .then(function(j){ if(my!==feedReq) return; feedState.items=(j&&Array.isArray(j.templates))?j.templates:[]; feedState.loading=false; feedRender(); })
1048
+ .then(function(j){ if(my!==feedReq) return; applyFeedItems((j&&Array.isArray(j.templates))?j.templates:[]); })
819
1049
  .catch(function(err){ if(my!==feedReq) return; feedState.loading=false; feedSetState(esc(friendlyError(err)),true); });
820
1050
  }
1051
+ // Feed loads flow through the Zustand store bridge (SSR-seed + SWR cache);
1052
+ // it may call onResult twice for the default feed (cached snapshot first,
1053
+ // then a background-revalidated fresh copy) — both are honored under the
1054
+ // same request token so a newer search still wins.
1055
+ function feedLoad(query){
1056
+ var my=++feedReq;
1057
+ feedState.loading=true;
1058
+ // Only flash the loading state when nothing's painted yet — a
1059
+ // revalidate must never blank an already-good grid.
1060
+ if(!feedState.items.length) feedSetState('Loading templates&hellip;',false);
1061
+ var bridge=window.__vidfarmDiscover;
1062
+ if(bridge&&bridge.loadFeed){
1063
+ bridge.loadFeed(query,{
1064
+ onLoading:function(){ if(my===feedReq && !feedState.items.length) feedSetState('Loading templates&hellip;',false); },
1065
+ onResult:function(items){ if(my!==feedReq) return; applyFeedItems(items); },
1066
+ onError:function(err){ if(my!==feedReq) return; feedState.loading=false; feedSetState(esc(friendlyError(err)),true); }
1067
+ });
1068
+ } else {
1069
+ feedFetchDirect(query,my);
1070
+ }
1071
+ }
821
1072
  // Honor a ?q= (e.g. from a category link) in the page URL.
822
1073
  var initialQuery='';
823
1074
  try{ initialQuery=(new URL(window.location.href)).searchParams.get('q')||''; }catch(e){ initialQuery=''; }
@@ -835,7 +1086,140 @@ video.rk-discover-viewer-media{cursor:pointer}
835
1086
  // plain click on a card → shared viewer over the CURRENT visible feed
836
1087
  // (search + bookmark filter applied)
837
1088
  bindViewerOpens(gridEl, function(){ return feedState.visible; });
838
- feedLoad(initialQuery.trim());
1089
+ // SSR-seed: if the server inlined the first page of the default feed AND
1090
+ // there's no ?q= search, paint it immediately with NO initial fetch — this
1091
+ // is what kills the redundant SSR-render → client-fetch round trip.
1092
+ var seeded=false;
1093
+ if(!initialQuery && boot.feedInitial && Array.isArray(boot.feedInitial) && boot.feedInitial.length){
1094
+ applyFeedItems(boot.feedInitial);
1095
+ seeded=true;
1096
+ }
1097
+ if(!seeded){
1098
+ feedLoad(initialQuery.trim());
1099
+ }
1100
+ // A background revalidation (stale snapshot / BFCache restore) from the
1101
+ // store bridge re-paints the grid — but only while not mid-search.
1102
+ window.addEventListener('vidfarm:discover-feed-updated',function(ev){
1103
+ var q=searchEl?searchEl.value.trim():'';
1104
+ if(q) return;
1105
+ var items=ev&&ev.detail&&ev.detail.items;
1106
+ if(Array.isArray(items)) applyFeedItems(items);
1107
+ });
1108
+
1109
+ // ── "Add New" modal — paste a social URL or upload a video, add it to the
1110
+ // caller's private feed (real POST /discover/templates + upload presign
1111
+ // pair), then reload the feed so the new pending card appears. In feed
1112
+ // scope so it can reach feedLoad + the current search value.
1113
+ var addModal=root.querySelector('[data-rk-add-modal]');
1114
+ var addBtn=root.querySelector('[data-rk-add]');
1115
+ if(addModal&&addBtn){
1116
+ var addForm=addModal.querySelector('[data-rk-add-form]');
1117
+ var addUrl=addModal.querySelector('[data-rk-add-url]');
1118
+ var addFile=addModal.querySelector('[data-rk-add-file]');
1119
+ var addFileLabel=addModal.querySelector('[data-rk-add-filelabel]');
1120
+ var addFileText=addModal.querySelector('[data-rk-add-filetext]');
1121
+ var addTagline=addModal.querySelector('[data-rk-add-tagline]');
1122
+ var addNotes=addModal.querySelector('[data-rk-add-notes]');
1123
+ var addErr=addModal.querySelector('[data-rk-add-error]');
1124
+ var addSubmit=addModal.querySelector('[data-rk-add-submit]');
1125
+ var MAX_UPLOAD=200*1024*1024;
1126
+ var defaultFileText=addFileText?addFileText.innerHTML:'';
1127
+ var addBusy=false;
1128
+
1129
+ function showAddErr(msg){ if(addErr){ addErr.textContent=msg||''; addErr.hidden=!msg; } }
1130
+ function openAdd(){
1131
+ addModal.hidden=false;
1132
+ document.documentElement.classList.add('rk-noscroll');
1133
+ if(addUrl){ try{ addUrl.focus(); }catch(e){} }
1134
+ }
1135
+ function closeAdd(){
1136
+ if(addBusy) return;
1137
+ addModal.hidden=true;
1138
+ document.documentElement.classList.remove('rk-noscroll');
1139
+ }
1140
+ function resetAdd(){
1141
+ if(addForm) addForm.reset();
1142
+ if(addFileLabel) addFileLabel.classList.remove('is-picked');
1143
+ if(addFileText) addFileText.innerHTML=defaultFileText;
1144
+ showAddErr('');
1145
+ }
1146
+ function setAddBusy(on,label){
1147
+ addBusy=on;
1148
+ if(addSubmit){ addSubmit.disabled=on; addSubmit.textContent=on?(label||'Adding\\u2026'):'Add to feed'; }
1149
+ }
1150
+
1151
+ addBtn.addEventListener('click',openAdd);
1152
+ addModal.querySelectorAll('[data-rk-add-close]').forEach(function(b){ b.addEventListener('click',closeAdd); });
1153
+ document.addEventListener('keydown',function(e){ if(e.key==='Escape'&&!addModal.hidden) closeAdd(); });
1154
+
1155
+ if(addFile){
1156
+ addFile.addEventListener('change',function(){
1157
+ var f=addFile.files&&addFile.files[0];
1158
+ if(addFileLabel) addFileLabel.classList.toggle('is-picked',!!f);
1159
+ if(addFileText) addFileText.innerHTML=f?esc(f.name):defaultFileText;
1160
+ showAddErr('');
1161
+ });
1162
+ }
1163
+
1164
+ function ingest(uploadRef){
1165
+ var tagline=addTagline?addTagline.value.trim():'';
1166
+ var notes=addNotes?addNotes.value.trim():'';
1167
+ var payload=uploadRef
1168
+ ? { upload:uploadRef, tagline:tagline||null, notes:notes||null }
1169
+ : { source_url:(addUrl?addUrl.value.trim():''), tagline:tagline||null, notes:notes||null };
1170
+ return fetch(withAccount('/discover/templates'),{
1171
+ method:'POST',credentials:'same-origin',
1172
+ headers:{'content-type':'application/json',Accept:'application/json'},
1173
+ body:JSON.stringify(payload)
1174
+ }).then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,status:r.status,data:d}; }); });
1175
+ }
1176
+ function uploadFile(file){
1177
+ return fetch(withAccount('/discover/templates/upload/presign'),{
1178
+ method:'POST',credentials:'same-origin',
1179
+ headers:{'content-type':'application/json',Accept:'application/json'},
1180
+ body:JSON.stringify({ file_name:file.name, content_type:file.type||null, size_bytes:file.size })
1181
+ }).then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,data:d}; }); })
1182
+ .then(function(res){
1183
+ if(!res.ok) throw new Error((res.data&&res.data.error)||'Unable to start the video upload.');
1184
+ var p=res.data||{};
1185
+ if(p.transport==='presigned'&&p.upload&&p.upload.url&&p.storage_key){
1186
+ return fetch(p.upload.url,{ method:(p.upload.method||'PUT'), headers:(p.upload.headers||{}), body:file })
1187
+ .then(function(put){ if(!put.ok) throw new Error('Video upload failed ('+put.status+'). Try again.');
1188
+ return { storage_key:p.storage_key, file_name:p.file_name||file.name }; });
1189
+ }
1190
+ var form=new FormData(); form.append('file',file,p.file_name||file.name);
1191
+ return fetch((p.upload&&p.upload.url)||'/discover/templates/upload',{ method:'POST',credentials:'same-origin',body:form })
1192
+ .then(function(direct){ return direct.json().catch(function(){ return {}; }).then(function(dd){
1193
+ if(!direct.ok||!dd.storage_key) throw new Error(dd.error||'Video upload failed. Try again.');
1194
+ return { storage_key:dd.storage_key, file_name:dd.file_name||file.name }; }); });
1195
+ });
1196
+ }
1197
+
1198
+ if(addForm){
1199
+ addForm.addEventListener('submit',function(e){
1200
+ e.preventDefault();
1201
+ if(addBusy) return;
1202
+ showAddErr('');
1203
+ var file=addFile&&addFile.files?addFile.files[0]:null;
1204
+ var url=addUrl?addUrl.value.trim():'';
1205
+ if(!file&&!url){ showAddErr('Paste a TikTok, YouTube, Twitter/X, or Instagram video URL \\u2014 or upload a video file.'); return; }
1206
+ if(file&&file.size>MAX_UPLOAD){ showAddErr('Video uploads can be at most 200 MB.'); return; }
1207
+ setAddBusy(true,file?'Uploading\\u2026':'Adding\\u2026');
1208
+ var chain=file?uploadFile(file):Promise.resolve(null);
1209
+ chain.then(function(uploadRef){ return ingest(uploadRef); })
1210
+ .then(function(res){
1211
+ setAddBusy(false);
1212
+ if(!res.ok){ showAddErr((res.data&&res.data.error)||'Unable to add that video.'); return; }
1213
+ resetAdd(); closeAdd();
1214
+ // A new inspiration just landed — drop the cached snapshot so the
1215
+ // reload fetches fresh (no stale-cache flash of the old feed).
1216
+ if(window.__vidfarmDiscover&&window.__vidfarmDiscover.invalidate) window.__vidfarmDiscover.invalidate();
1217
+ feedLoad(searchEl?searchEl.value.trim():'');
1218
+ })
1219
+ .catch(function(err){ setAddBusy(false); showAddErr((err&&err.message)?err.message:'Unable to add that video.'); });
1220
+ });
1221
+ }
1222
+ }
839
1223
  }
840
1224
 
841
1225
  // ════════════════════════════════════════════════════════════════════════
@@ -849,9 +1233,32 @@ video.rk-discover-viewer-media{cursor:pointer}
849
1233
  var hint=swipeRoot.querySelector('[data-actions-hint]');
850
1234
  var skipBtn=swipeRoot.querySelector('[data-swipe-skip]');
851
1235
  var forkBtn=swipeRoot.querySelector('[data-swipe-fork]');
852
- var sw={target:5,customizing:0,productSet:false,busy:false,refilling:false,loaded:false};
1236
+ var soundBtn=swipeRoot.querySelector('[data-swipe-sound]');
1237
+ var sw={target:5,customizing:0,productSet:false,busy:false,refilling:false,loaded:false,soundOn:false,known:{}};
853
1238
  var nodes=[]; // live card DOM nodes, top-of-deck first
854
1239
 
1240
+ // ── audio: the same-origin preview iframes poll these window flags (see
1241
+ // injectSwipePreviewFit). Only the TOP card's id is named audible so the
1242
+ // preloaded 2nd iframe stays silent (audio never doubles). Deck previews
1243
+ // always start muted (the only autoplay browsers permit); the toggle is the
1244
+ // user gesture that unlocks sound.
1245
+ function syncSound(){
1246
+ try{
1247
+ window.__VF_SWIPE_SOUND__ = sw.soundOn===true;
1248
+ window.__VF_SWIPE_SOUND_TOP__ = nodes.length? String(nodes[0]._card.id): null;
1249
+ }catch(e){}
1250
+ }
1251
+ function updateSoundBtn(){
1252
+ if(!soundBtn) return;
1253
+ var show=nodes.length>0;
1254
+ soundBtn.hidden=!show;
1255
+ soundBtn.classList.toggle('is-on', sw.soundOn);
1256
+ soundBtn.setAttribute('aria-pressed', sw.soundOn?'true':'false');
1257
+ soundBtn.setAttribute('aria-label', sw.soundOn?'Mute':'Unmute');
1258
+ soundBtn.innerHTML = sw.soundOn? SOUND_ON_ICON : SOUND_OFF_ICON;
1259
+ }
1260
+ if(soundBtn) soundBtn.addEventListener('click',function(){ sw.soundOn=!sw.soundOn; syncSound(); updateSoundBtn(); });
1261
+
855
1262
  function swShowState(html,kind){
856
1263
  if(swStateEl){ swStateEl.hidden=false; swStateEl.innerHTML=html; swStateEl.classList.toggle('is-error',kind==='error'); }
857
1264
  actionBtns.forEach(function(b){ b.hidden=true; });
@@ -860,6 +1267,7 @@ video.rk-discover-viewer-media{cursor:pointer}
860
1267
  function swHideState(){ if(swStateEl) swStateEl.hidden=true; }
861
1268
 
862
1269
  function makeCard(card){
1270
+ if(card&&card.id) sw.known[String(card.id)]=true;
863
1271
  var el=document.createElement('article');
864
1272
  el.className='rk-discover-swipe';
865
1273
  el._card=card; el._iframed=false;
@@ -883,6 +1291,13 @@ video.rk-discover-viewer-media{cursor:pointer}
883
1291
  frame.setAttribute('loading','lazy');
884
1292
  frame.setAttribute('scrolling','no');
885
1293
  frame.setAttribute('allow','autoplay');
1294
+ // The fallback is an OPAQUE dark placeholder that sits later in the DOM,
1295
+ // so it paints over the iframe. Reveal the live preview once it loads by
1296
+ // fading the placeholder out (kept as a load state until then). A timed
1297
+ // fallback covers the case where 'load' never fires.
1298
+ function revealFrame(){ if(fb){ fb.style.opacity='0'; fb.style.pointerEvents='none'; } }
1299
+ frame.addEventListener('load', function(){ window.setTimeout(revealFrame, 120); });
1300
+ window.setTimeout(revealFrame, 2600);
886
1301
  if(fb&&fb.parentNode) fb.parentNode.insertBefore(frame, fb); else el.insertBefore(frame, el.firstChild);
887
1302
  el._iframed=true;
888
1303
  }
@@ -898,6 +1313,7 @@ video.rk-discover-viewer-media{cursor:pointer}
898
1313
  var has=nodes.length>0;
899
1314
  actionBtns.forEach(function(b){ b.hidden=!has; });
900
1315
  if(hint) hint.hidden=!has;
1316
+ syncSound(); updateSoundBtn();
901
1317
  if(!has) swCaughtUp();
902
1318
  }
903
1319
  function swCaughtUp(){
@@ -935,8 +1351,43 @@ video.rk-discover-viewer-media{cursor:pointer}
935
1351
  if(j.filled&&j.record){ makeCard(j.record); restack(); }
936
1352
  var more=j.filled&&nodes.length<sw.target&&j.reason!=='no_eligible'&&j.reason!=='at_target';
937
1353
  if(more) refill();
1354
+ // Server said it's still working (no card yet, not "done") — the
1355
+ // customization keeps running Lambda-side even if THIS request replies
1356
+ // late; poll the stack so a finished card appears without a refresh.
1357
+ else if(!j.filled&&j.reason!=='no_eligible'&&j.reason!=='at_target') schedulePoll();
1358
+ })
1359
+ .catch(function(){
1360
+ // Timeout / 504 / network drop: customizeOneMore often runs past the
1361
+ // gateway's reply window, so the card lands server-side after we gave
1362
+ // up here. Poll the stack to pick it up (this is the "504 on first
1363
+ // load, works after a manual refresh" case — now it self-heals).
1364
+ sw.refilling=false; schedulePoll();
1365
+ });
1366
+ }
1367
+ // ── stack re-poll: recover cards the refill Lambda produced after its HTTP
1368
+ // reply timed out. Backs off after a fixed number of empty tries so we never
1369
+ // poll forever.
1370
+ var pollTimer=null, pollTries=0;
1371
+ function schedulePoll(){
1372
+ if(pollTimer||pollTries>=12) return;
1373
+ if(nodes.length>=sw.target) return;
1374
+ pollTimer=window.setTimeout(function(){ pollTimer=null; pollStack(); }, 5000);
1375
+ }
1376
+ function pollStack(){
1377
+ if(nodes.length>=sw.target) return;
1378
+ fetch(withAccount(swipeStackEndpoint),{credentials:'same-origin',headers:{'accept':'application/json'}})
1379
+ .then(function(r){ return r.ok? r.json(): null; })
1380
+ .then(function(j){
1381
+ if(!j){ pollTries++; schedulePoll(); return; }
1382
+ sw.customizing=Number(j&&j.customizing)||0;
1383
+ var stack=(j&&Array.isArray(j.stack))?j.stack:[];
1384
+ var added=0;
1385
+ stack.forEach(function(card){ if(card&&card.id&&!sw.known[String(card.id)]){ nodes.push(makeCard(card)); added++; } });
1386
+ if(added){ pollTries=0; swHideState(); restack(); }
1387
+ else { pollTries++; if(!nodes.length) swCaughtUp(); }
1388
+ if((sw.customizing>0||nodes.length<sw.target)&&nodes.length<sw.target) schedulePoll();
938
1389
  })
939
- .catch(function(){ sw.refilling=false; });
1390
+ .catch(function(){ pollTries++; schedulePoll(); });
940
1391
  }
941
1392
  function loadStack(){
942
1393
  swShowState('<div class="rk-discover-spinner" aria-hidden="true"></div><div>Loading your deck&hellip;</div>');
@@ -953,7 +1404,7 @@ video.rk-discover-viewer-media{cursor:pointer}
953
1404
  sw.productSet=Boolean(j&&j.product_description_set);
954
1405
  var stack=(j&&Array.isArray(j.stack))?j.stack:[];
955
1406
  if(!sw.productSet){
956
- swShowState('<div class="rk-discover-swipe-state-title">Tell us about your product</div><div>Swipe auto-recuts viral templates into ads for your product. Add a product / offer description in Settings to start the deck.</div><a class="rk-btn rk-btn-gold rk-btn-sm" href="/settings#profile">Open Settings</a>');
1407
+ swShowState('<div class="rk-discover-swipe-state-title">Tell us about your product</div><div>Swipe auto-recuts viral templates into ads for your product. Add a product / offer description in Settings to start the deck.</div><a class="rk-btn rk-btn-gold rk-btn-sm" href="/settings/profile">Open Settings</a>');
957
1408
  return;
958
1409
  }
959
1410
  nodes=stack.map(makeCard);
@@ -1088,9 +1539,13 @@ video.rk-discover-viewer-media{cursor:pointer}
1088
1539
  title: `vidfarm reskin — Discover (${mode})`,
1089
1540
  description: "Reskinned vidfarm Discover — real Feed / Swipe / Categories, live template posters, warm sunny SaaS.",
1090
1541
  activeSlug: "discover",
1542
+ account: { name: input.name, email: input.email },
1091
1543
  pageCss,
1092
1544
  body,
1093
- script
1545
+ script,
1546
+ // Feed mode hydrates a Zustand store (SSR-seed + stale-while-revalidate);
1547
+ // the other modes don't need it.
1548
+ moduleScripts: mode === "feed" ? ["/assets/discover-client-app.js"] : undefined
1094
1549
  });
1095
1550
  }
1096
1551
  //# sourceMappingURL=discover-page.js.map