@mevdragon/vidfarm-devcli 0.20.5 → 0.20.8

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 (154) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +15 -1
  2. package/.agents/skills/vidfarm-director/SKILL.md +106 -0
  3. package/.agents/skills/vidfarm-director/recipes/find-and-fork-template.md +16 -0
  4. package/.agents/skills/vidfarm-director/recipes/local-edit-render-approve.md +13 -0
  5. package/.agents/skills/vidfarm-director/recipes/onboard-a-new-director.md +13 -0
  6. package/.agents/skills/vidfarm-director/recipes/retheme-template.md +17 -0
  7. package/.agents/skills/vidfarm-director/references/assets-and-sourcing.md +111 -0
  8. package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +243 -0
  9. package/.agents/skills/vidfarm-director/references/core-workflows.md +273 -0
  10. package/.agents/skills/vidfarm-director/references/editor-workflows.md +365 -0
  11. package/.agents/skills/vidfarm-director/references/onboarding.md +28 -0
  12. package/.agents/skills/vidfarm-director/references/primitives.md +265 -0
  13. package/SKILL.director.md +521 -261
  14. package/dist/src/cli.js +404 -96
  15. package/dist/src/devcli/clips.js +15 -10
  16. package/dist/src/devcli/doctor.js +2 -2
  17. package/dist/src/devcli/local-backend.js +46 -2
  18. package/dist/src/services/clip-curation/cost.js +5 -1
  19. package/dist/src/services/clip-curation/gemini.js +10 -0
  20. package/package.json +30 -10
  21. package/SKILL.platform.md +0 -432
  22. package/demo/README.md +0 -28
  23. package/demo/dist/app.css +0 -1
  24. package/demo/dist/app.js +0 -1850
  25. package/demo/dist/chunks/chunk-DXB73IDG.js +0 -1
  26. package/demo/dist/chunks/chunk-S7OWAJDS.js +0 -36
  27. package/demo/dist/chunks/chunk-VTIBZ6AN.js +0 -1
  28. package/demo/dist/chunks/dist-ADSJKBVE.js +0 -332
  29. package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +0 -1
  30. package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +0 -425
  31. package/demo/dist/chunks/lib-XAQ37YOE.js +0 -1
  32. package/demo/dist/chunks/src-TJ2QYA4U.js +0 -207
  33. package/demo/dist/favicon.ico +0 -0
  34. package/demo/dist/icons/timeline/audio.svg +0 -7
  35. package/demo/dist/icons/timeline/captions.svg +0 -5
  36. package/demo/dist/icons/timeline/composition.svg +0 -12
  37. package/demo/dist/icons/timeline/image.svg +0 -18
  38. package/demo/dist/icons/timeline/music.svg +0 -10
  39. package/demo/dist/icons/timeline/text.svg +0 -3
  40. package/demo/dist/index.html +0 -15
  41. package/dist/src/account-pages-legacy.js +0 -9396
  42. package/dist/src/account-pages.js +0 -61
  43. package/dist/src/app.js +0 -21602
  44. package/dist/src/composition-runtime.js +0 -1053
  45. package/dist/src/config.js +0 -217
  46. package/dist/src/context.js +0 -447
  47. package/dist/src/dev-app-legacy.js +0 -739
  48. package/dist/src/dev-app.js +0 -6
  49. package/dist/src/devcli/migrate-local.js +0 -140
  50. package/dist/src/devcli/sync.js +0 -368
  51. package/dist/src/domain.js +0 -5
  52. package/dist/src/editor-chat-history.js +0 -82
  53. package/dist/src/editor-chat.js +0 -756
  54. package/dist/src/editor-dark-theme.js +0 -1128
  55. package/dist/src/frontend/debug.js +0 -71
  56. package/dist/src/frontend/discover-client.js +0 -130
  57. package/dist/src/frontend/discover-store.js +0 -23
  58. package/dist/src/frontend/file-directory.js +0 -1018
  59. package/dist/src/frontend/flockposter-cache-store.js +0 -124
  60. package/dist/src/frontend/homepage-client.js +0 -446
  61. package/dist/src/frontend/homepage-shared.js +0 -201
  62. package/dist/src/frontend/homepage-store.js +0 -66
  63. package/dist/src/frontend/homepage-view.js +0 -705
  64. package/dist/src/frontend/page-runtime-client.js +0 -132
  65. package/dist/src/frontend/page-runtime-store.js +0 -9
  66. package/dist/src/frontend/sentry.js +0 -42
  67. package/dist/src/frontend/template-editor-chat.js +0 -4181
  68. package/dist/src/help-page.js +0 -346
  69. package/dist/src/homepage.js +0 -1458
  70. package/dist/src/index.js +0 -16
  71. package/dist/src/instrument.js +0 -30
  72. package/dist/src/landing-page.js +0 -384
  73. package/dist/src/page-runtime.js +0 -2
  74. package/dist/src/page-shell.js +0 -1452
  75. package/dist/src/primitive-context.js +0 -416
  76. package/dist/src/primitive-registry.js +0 -3940
  77. package/dist/src/primitive-sdk.js +0 -4
  78. package/dist/src/primitives/hyperframes-media.js +0 -108
  79. package/dist/src/react-page-shell.js +0 -35
  80. package/dist/src/ready-post-schedule-component.js +0 -1540
  81. package/dist/src/registry.js +0 -296
  82. package/dist/src/reskin/agency-page.js +0 -299
  83. package/dist/src/reskin/calendar-page.js +0 -568
  84. package/dist/src/reskin/chat-page.js +0 -942
  85. package/dist/src/reskin/discover-page.js +0 -1788
  86. package/dist/src/reskin/document.js +0 -1587
  87. package/dist/src/reskin/help-page.js +0 -357
  88. package/dist/src/reskin/index-page.js +0 -62
  89. package/dist/src/reskin/inpaint-clipper-page.js +0 -890
  90. package/dist/src/reskin/inpaint-page.js +0 -2554
  91. package/dist/src/reskin/inpaint-video-page.js +0 -1339
  92. package/dist/src/reskin/job-runs-page.js +0 -477
  93. package/dist/src/reskin/library-page.js +0 -1634
  94. package/dist/src/reskin/login-page.js +0 -262
  95. package/dist/src/reskin/portfolio-page.js +0 -687
  96. package/dist/src/reskin/pricing-page.js +0 -390
  97. package/dist/src/reskin/settings-page.js +0 -732
  98. package/dist/src/reskin/theme.js +0 -711
  99. package/dist/src/runtime.js +0 -35
  100. package/dist/src/services/api-call-history.js +0 -249
  101. package/dist/src/services/auth.js +0 -152
  102. package/dist/src/services/billing-pricing.js +0 -39
  103. package/dist/src/services/billing.js +0 -241
  104. package/dist/src/services/cast.js +0 -127
  105. package/dist/src/services/chat-threads.js +0 -92
  106. package/dist/src/services/clip-records.js +0 -250
  107. package/dist/src/services/clip-search.js +0 -77
  108. package/dist/src/services/clip-vectors.js +0 -125
  109. package/dist/src/services/composition-sanitize.js +0 -124
  110. package/dist/src/services/composition-watch.js +0 -79
  111. package/dist/src/services/elevenlabs.js +0 -222
  112. package/dist/src/services/file-directory.js +0 -117
  113. package/dist/src/services/fork-access.js +0 -93
  114. package/dist/src/services/fork-manifest.js +0 -42
  115. package/dist/src/services/ghostcut.js +0 -179
  116. package/dist/src/services/hyperframes.js +0 -3517
  117. package/dist/src/services/job-capacity.js +0 -14
  118. package/dist/src/services/job-logs.js +0 -197
  119. package/dist/src/services/jobs.js +0 -136
  120. package/dist/src/services/local-dynamo.js +0 -0
  121. package/dist/src/services/media-processing.js +0 -766
  122. package/dist/src/services/primitive-media-lambda.js +0 -280
  123. package/dist/src/services/providers.js +0 -2748
  124. package/dist/src/services/rate-limits.js +0 -262
  125. package/dist/src/services/scene-annotations.js +0 -32
  126. package/dist/src/services/serverless-auth.js +0 -382
  127. package/dist/src/services/serverless-jobs.js +0 -1084
  128. package/dist/src/services/serverless-provider-keys.js +0 -409
  129. package/dist/src/services/serverless-records.js +0 -1515
  130. package/dist/src/services/serverless-template-configs.js +0 -75
  131. package/dist/src/services/storage.js +0 -461
  132. package/dist/src/services/swipe-customize.js +0 -437
  133. package/dist/src/services/template-certification.js +0 -413
  134. package/dist/src/services/template-loader.js +0 -99
  135. package/dist/src/services/template-runtime-bundles.js +0 -217
  136. package/dist/src/services/template-sources.js +0 -1017
  137. package/dist/src/services/upstream.js +0 -248
  138. package/dist/src/services/video-normalization.js +0 -2
  139. package/dist/src/services/webhooks.js +0 -62
  140. package/dist/src/template-editor-pages.js +0 -2576
  141. package/dist/src/template-editor-shell.js +0 -2893
  142. package/dist/src/template-sdk.js +0 -4
  143. package/dist/src/worker.js +0 -17
  144. package/public/assets/discover-client-app.js +0 -1
  145. package/public/assets/file-directory-app.js +0 -3
  146. package/public/assets/homepage-app.js +0 -54
  147. package/public/assets/homepage-client-app.js +0 -80
  148. package/public/assets/page-runtime-client-app.js +0 -94
  149. package/public/assets/placeholders/scene-placeholder.png +0 -0
  150. package/src/assets/SELLING_AWARENESS_STAGES.md +0 -579
  151. package/src/assets/SELLING_WITH_HOOKS.md +0 -377
  152. package/src/assets/SELLING_WITH_VSLS.md +0 -606
  153. package/src/assets/favicon.ico +0 -0
  154. package/src/assets/logo-vidfarm.png +0 -0
@@ -1,1788 +0,0 @@
1
- // /reskin/discover — the template discovery surface, MIGRATED to the REAL
2
- // production feed (part of the /reskin/* UX migration; reference:
3
- // job-runs-page.ts for the "render a shell → client-fetch a real endpoint"
4
- // pattern, settings-page.ts for the real-data render pattern).
5
- //
6
- // Three server-routed modes, each its own URL, surfaced as a Feed / Swipe /
7
- // Categories segmented toggle (three real <a> links):
8
- // • feed → /reskin/discover/feed — real /discover/feed poster grid
9
- // • swipe → /reskin/discover/swipe — real /discover/swipe/* Tinder deck
10
- // • categories → /reskin/discover/categories — real /discover/popular groups
11
- //
12
- // Every mode renders the farmville shell + a self-contained module script that
13
- // fetches the SAME real, same-origin endpoints the live React SPA hits, with
14
- // credentials, and renders real poster cards (real title, real thumbnail/video
15
- // from the real url, real source + duration) that fork/open into the real
16
- // editor at /editor/<template_id>. Loading / empty / error states are all
17
- // guarded so an offline fetch degrades to a friendly state instead of throwing.
18
- //
19
- // Poster-grid extras (feed + categories reveal, which share posterCardHtml):
20
- // • hover audio — mousing over a card's (muted, autoplaying) video unmutes
21
- // it while the pointer stays, with an autoplay-policy fallback that
22
- // reverts to muted playback if the browser blocks unmuted play.
23
- // • TikTok-style viewer — clicking a card opens a fullscreen vertical
24
- // scroll-snap deck (swipe / wheel / arrow keys between templates, sound
25
- // on, tap video to pause, mute toggle) with an "Open Editor" CTA per
26
- // slide. The card's "Open Editor" button still deep-links the editor.
27
- // The viewer is shared: feed swipes the (filtered) feed, categories
28
- // swipes the revealed group.
29
- // • bookmarks — one-tap bookmark on each viewer slide + on cards on hover,
30
- // persisted client-side in localStorage ('rk-discover-bookmarks'; there
31
- // is no backend favorite endpoint), plus a "bookmarked only" toolbar
32
- // filter chip on the feed so saved templates are findable.
33
- // • no platform pill on posters (user feedback) — source only shows as the
34
- // small meta line inside the viewer slide.
35
- //
36
- // The renderer keeps its `mode` signature (so the existing zero-arg
37
- // `/reskin/discover/:mode` route keeps working — the client fetches the live
38
- // endpoints same-origin using the browser session cookie) and adds an optional
39
- // 2nd `input` arg (defaulted) so the parent route can, if it wants, pass the
40
- // signed-in account id (→ ?account= on fetches + editor links, matching the
41
- // live editorHref) and override any endpoint.
42
- import { reskinDocument, rkEscape } from "./document.js";
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). */
46
- const DEFAULT_INPUT = {
47
- accountId: "",
48
- isLoggedIn: false,
49
- feedEndpoint: "/discover/feed",
50
- popularEndpoint: "/discover/popular",
51
- swipeStackEndpoint: "/discover/swipe/stack",
52
- swipeRefillEndpoint: "/discover/swipe/refill",
53
- swipeBase: "/discover/swipe",
54
- editorBase: "/editor"
55
- };
56
- const MODES = [
57
- { mode: "feed", label: "Feed" },
58
- { mode: "swipe", label: "Swipe" },
59
- { mode: "categories", label: "Categories" }
60
- ];
61
- const MODE_COPY = {
62
- feed: {
63
- sub: "Browse proven short-form formats, then open one in the editor and make it yours in an afternoon."
64
- },
65
- swipe: {
66
- sub: "One template at a time — keep the ones that fit and we'll recut them into ads for your brand."
67
- },
68
- categories: {
69
- sub: "Jump straight to the style you need — every short-form format, neatly grouped."
70
- }
71
- };
72
- // Functional (non-decorative) SVG glyphs.
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>`;
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>`;
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>`;
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>`;
79
- const SOUND_ON_ICON = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 6a9 9 0 0 1 0 12"/></svg>`;
80
- const SOUND_OFF_ICON = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9.5 5 5M21 9.5l-5 5"/></svg>`;
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>`;
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>`;
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>`;
86
- /** Safe JSON embedding inside a <script> tag (mirrors page-shell.escapeJsonForHtml). */
87
- function jsonForScript(value) {
88
- return JSON.stringify(value ?? null)
89
- .replace(/</g, "\\u003c")
90
- .replace(/>/g, "\\u003e")
91
- .replace(/&/g, "\\u0026")
92
- .replace(/\u2028/g, "\\u2028")
93
- .replace(/\u2029/g, "\\u2029");
94
- }
95
- // The segmented Feed / Swipe / Categories toggle (three real links).
96
- function modeToggle(active) {
97
- const links = MODES.map((m) => {
98
- const on = m.mode === active ? " is-active" : "";
99
- return `<a class="rk-discover-mode${on}" href="/discover/${m.mode}"${m.mode === active ? ' aria-current="page"' : ""}>${m.label}</a>`;
100
- }).join("");
101
- return `<nav class="rk-discover-modes" aria-label="Discover mode">${links}</nav>`;
102
- }
103
- // Server-rendered category skeleton from the real POPULAR_INSPIRATION_GROUPS
104
- // manifest — always renders (real group labels + real format counts) even
105
- // before/without the /discover/popular fetch, so categories mode is never blank
106
- // and degrades gracefully offline. The client enriches each card with real
107
- // thumbnails + wires an in-page reveal of that group's real templates.
108
- function categorySkeleton() {
109
- return POPULAR_INSPIRATION_GROUPS.map((g) => {
110
- const count = g.items.length;
111
- const blurb = g.items.slice(0, 4).map((i) => i.title).join(" · ");
112
- const fallbackQuery = encodeURIComponent(g.label);
113
- // Placeholder thumb tiles (no media yet) keep the card height stable; the
114
- // client swaps in real poster media once /discover/popular resolves.
115
- const thumbs = Array.from({ length: 4 })
116
- .map(() => `<span class="rk-discover-mini rk-discover-mini-ph"></span>`)
117
- .join("");
118
- return `<a class="rk-discover-cat rk-card-hover" href="/discover?q=${fallbackQuery}" data-rk-cat="${rkEscape(g.label)}">
119
- <div class="rk-discover-cat-head">
120
- <div class="rk-discover-cat-titlewrap">
121
- <h3 class="rk-discover-cat-title">${rkEscape(g.label)}</h3>
122
- <p class="rk-discover-cat-count" data-rk-cat-count>${count} format${count === 1 ? "" : "s"}</p>
123
- </div>
124
- <span class="rk-discover-cat-arrow" aria-hidden="true">&rarr;</span>
125
- </div>
126
- <div class="rk-discover-cat-thumbs" data-rk-cat-thumbs>${thumbs}</div>
127
- <p class="rk-discover-cat-blurb">${rkEscape(blurb)}</p>
128
- </a>`;
129
- }).join("");
130
- }
131
- // The fullscreen TikTok-style viewer shell — shared by feed and categories
132
- // (the client script fills [data-viewer-track] with slides on open).
133
- function viewerHtml() {
134
- return `
135
- <div class="rk-discover-viewer" data-rk-viewer hidden role="dialog" aria-modal="true" aria-label="Template viewer">
136
- <div class="rk-discover-viewer-scrim" data-viewer-close></div>
137
- <div class="rk-discover-viewer-track" data-viewer-track></div>
138
- <div class="rk-discover-viewer-ui">
139
- <button type="button" class="rk-discover-viewer-btn" data-viewer-mute aria-label="Toggle sound"><span class="rk-dvm-on">${SOUND_ON_ICON}</span><span class="rk-dvm-off">${SOUND_OFF_ICON}</span></button>
140
- <button type="button" class="rk-discover-viewer-btn" data-viewer-close aria-label="Close viewer">${CROSS_ICON}</button>
141
- </div>
142
- <div class="rk-discover-viewer-nav">
143
- <button type="button" class="rk-discover-viewer-btn" data-viewer-prev aria-label="Previous template">${CHEV_UP_ICON}</button>
144
- <button type="button" class="rk-discover-viewer-btn" data-viewer-next aria-label="Next template">${CHEV_DOWN_ICON}</button>
145
- </div>
146
- </div>`;
147
- }
148
- // The fullscreen TikTok-style viewer for the SWIPE deck — a sibling of the feed
149
- // viewer above but purpose-built for swiping: slides carry live composition
150
- // iframes (not video/img), and a bottom action bar reject/approves the ACTIVE
151
- // card, driving the same POST /discover/swipe/:id/{approve,reject} the deck uses.
152
- // The client script (swipe block) fills [data-sv-track] with slides on open.
153
- function swipeViewerHtml() {
154
- return `
155
- <div class="rk-discover-viewer rk-swipe-viewer" data-rk-swipe-viewer hidden role="dialog" aria-modal="true" aria-label="Swipe preview">
156
- <div class="rk-discover-viewer-scrim" data-sv-close></div>
157
- <div class="rk-discover-viewer-track rk-swipe-viewer-track" data-sv-track></div>
158
- <div class="rk-discover-viewer-ui">
159
- <button type="button" class="rk-discover-viewer-btn" data-sv-mute aria-label="Toggle sound"><span class="rk-dvm-on">${SOUND_ON_ICON}</span><span class="rk-dvm-off">${SOUND_OFF_ICON}</span></button>
160
- <button type="button" class="rk-discover-viewer-btn" data-sv-close aria-label="Close viewer">${CROSS_ICON}</button>
161
- </div>
162
- <div class="rk-discover-viewer-nav">
163
- <button type="button" class="rk-discover-viewer-btn" data-sv-prev aria-label="Previous">${CHEV_UP_ICON}</button>
164
- <button type="button" class="rk-discover-viewer-btn" data-sv-next aria-label="Next">${CHEV_DOWN_ICON}</button>
165
- </div>
166
- <div class="rk-swipe-viewer-actions" data-sv-actions>
167
- <button type="button" class="rk-discover-act rk-discover-act-skip" data-sv-reject aria-label="Reject">${CROSS_ICON}</button>
168
- <button type="button" class="rk-discover-act rk-discover-act-fork" data-sv-approve aria-label="Approve">${CHECK_ICON}</button>
169
- </div>
170
- </div>`;
171
- }
172
- // The "Add New" modal (feed mode only) — paste a social video URL or upload a
173
- // video, adding it to the caller's private Discover feed via the same real
174
- // endpoints the old React SPA used (POST /discover/templates + the upload
175
- // presign pair). Mirrors the old UI's Add Template modal, farmville-styled.
176
- function addModalHtml() {
177
- return `
178
- <div class="rk-add-modal" data-rk-add-modal hidden role="dialog" aria-modal="true" aria-labelledby="rk-add-title">
179
- <div class="rk-add-scrim" data-rk-add-close></div>
180
- <div class="rk-add-card">
181
- <div class="rk-add-head">
182
- <h2 class="rk-add-title" id="rk-add-title">Add to your feed</h2>
183
- <button type="button" class="rk-add-x" data-rk-add-close aria-label="Close">${CROSS_ICON}</button>
184
- </div>
185
- <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>
186
- <form class="rk-add-form" data-rk-add-form>
187
- <div class="rk-add-field">
188
- <label class="rk-add-label" for="rk-add-url">Video URL</label>
189
- <input class="rk-add-input" id="rk-add-url" type="url" placeholder="https://www.tiktok.com/@&hellip;" autocomplete="off" data-rk-add-url />
190
- </div>
191
- <div class="rk-add-or"><span>or</span></div>
192
- <label class="rk-add-file" data-rk-add-filelabel>
193
- <input type="file" accept="video/*,.mp4,.mov,.webm,.m4v" data-rk-add-file />
194
- <span class="rk-add-file-ic">${UPLOAD_ICON}</span>
195
- <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>
196
- </label>
197
- <div class="rk-add-field">
198
- <label class="rk-add-label" for="rk-add-tagline">Tagline <span class="rk-add-optional">optional</span></label>
199
- <input class="rk-add-input" id="rk-add-tagline" type="text" placeholder="What makes it work" autocomplete="off" data-rk-add-tagline />
200
- </div>
201
- <div class="rk-add-field">
202
- <label class="rk-add-label" for="rk-add-notes">Notes <span class="rk-add-optional">optional</span></label>
203
- <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>
204
- </div>
205
- <p class="rk-add-error" data-rk-add-error hidden></p>
206
- <div class="rk-add-actions">
207
- <button type="button" class="rk-btn rk-btn-ghost" data-rk-add-close>Cancel</button>
208
- <button type="submit" class="rk-btn rk-btn-gold" data-rk-add-submit>Add to feed</button>
209
- </div>
210
- </form>
211
- </div>
212
- </div>`;
213
- }
214
- export function renderReskinDiscover(mode = "feed", input = {}) {
215
- const copy = MODE_COPY[mode];
216
- // feedInitial/feedFetchedAt only make sense in feed mode; drop them elsewhere
217
- // so swipe/categories boots stay lean.
218
- const { feedInitial, feedFetchedAt, ...cfg } = input;
219
- const boot = {
220
- ...DEFAULT_INPUT,
221
- ...cfg,
222
- feedInitial: mode === "feed" ? feedInitial ?? null : null,
223
- feedFetchedAt: mode === "feed" ? feedFetchedAt ?? null : null
224
- };
225
- let modeBody = "";
226
- // ── FEED ────────────────────────────────────────────────────────────────
227
- if (mode === "feed") {
228
- modeBody = `
229
- <div class="rk-discover-feed" data-rk-feed>
230
- <div class="rk-discover-grid" data-rk-grid></div>
231
- <div class="rk-discover-state" data-rk-state>Loading templates&hellip;</div>
232
- </div>${viewerHtml()}`;
233
- }
234
- // Feed's quick search + bookmarked-only chip share the toolbar row with the
235
- // mode toggle.
236
- const toolbarSearch = mode === "feed"
237
- ? `<label class="rk-discover-search">
238
- <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>
239
- <input type="search" data-rk-search placeholder="Search templates" autocomplete="off" aria-label="Search templates" />
240
- </label>
241
- <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>`
242
- : "";
243
- // Categories' in-page group reveal header ("← All categories" + the group
244
- // title) lives IN the toolbar row, next to the mode toggle (user feedback);
245
- // the client unhides it while a group is revealed.
246
- const toolbarReveal = mode === "categories"
247
- ? `<div class="rk-discover-reveal-head" data-rk-reveal-head hidden>
248
- <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm" data-rk-cat-back>&larr; All categories</button>
249
- <h2 class="rk-discover-reveal-title" data-rk-reveal-title></h2>
250
- </div>`
251
- : "";
252
- // ── SWIPE ───────────────────────────────────────────────────────────────
253
- if (mode === "swipe") {
254
- modeBody = `
255
- <div class="rk-discover-swipe-wrap" data-rk-swipe>
256
- <div class="rk-discover-stage">
257
- <button type="button" class="rk-discover-act rk-discover-act-skip" data-swipe-skip data-swipe-act hidden aria-label="Skip">${CROSS_ICON}</button>
258
- <div class="rk-discover-deck" data-deck>
259
- <button type="button" class="rk-discover-sound-toggle" data-swipe-sound hidden aria-label="Unmute" aria-pressed="false">${SOUND_OFF_ICON}</button>
260
- <div class="rk-discover-swipe-state" data-rk-swipe-state>Loading your deck&hellip;</div>
261
- </div>
262
- <button type="button" class="rk-discover-act rk-discover-act-fork" data-swipe-fork data-swipe-act hidden aria-label="Fork">${CHECK_ICON}</button>
263
- </div>
264
- <p class="rk-discover-act-hint" data-actions-hint hidden>Tap the card to preview full-screen. Swipe to customize into an ad for your brand.</p>
265
- </div>${swipeViewerHtml()}`;
266
- }
267
- // ── CATEGORIES ──────────────────────────────────────────────────────────
268
- if (mode === "categories") {
269
- modeBody = `
270
- <div class="rk-discover-catwrap" data-rk-cats>
271
- <div class="rk-discover-cats" data-rk-cats-grid>${categorySkeleton()}</div>
272
- <section class="rk-discover-cat-reveal" data-rk-cat-reveal hidden>
273
- <div class="rk-discover-grid" data-rk-reveal-grid></div>
274
- </section>
275
- <div class="rk-discover-state rk-discover-cats-state" data-rk-cats-state hidden></div>
276
- </div>${viewerHtml()}`;
277
- }
278
- // "Add New" lives top-right of the page head (feed mode only) — opens the
279
- // Add modal when signed in, else routes to /login (mirrors the old UI).
280
- const addNewBtn = mode === "feed"
281
- ? (boot.isLoggedIn
282
- ? `<button type="button" class="rk-btn rk-btn-gold rk-discover-add" data-rk-add>${PLUS_ICON}<span>Add New</span></button>`
283
- : `<a class="rk-btn rk-btn-gold rk-discover-add" href="/login">${PLUS_ICON}<span>Add New</span></a>`)
284
- : "";
285
- const body = `
286
- <main class="rk-container rk-page">
287
- <div class="rk-page-head rk-discover-head">
288
- <div class="rk-discover-headtext">
289
- <h1 class="rk-h1">Discover</h1>
290
- <p class="rk-sub">${copy.sub}</p>
291
- </div>
292
- ${addNewBtn}
293
- </div>
294
- <div class="rk-discover-toolbar">
295
- ${modeToggle(mode)}
296
- ${toolbarReveal}
297
- ${toolbarSearch}
298
- </div>
299
- ${modeBody}
300
- ${mode === "feed" ? addModalHtml() : ""}
301
- <script type="application/json" data-rk-discover-boot>${jsonForScript(boot)}</script>
302
- </main>`;
303
- const pageCss = `
304
- /* head row: title block on the left, "Add New" CTA pinned top-right */
305
- .rk-discover-head{margin-bottom:22px;display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
306
- .rk-discover-headtext{display:grid;gap:4px;min-width:0}
307
- .rk-discover-add{flex:none;align-self:flex-start}
308
- .rk-discover-add svg{margin-top:-1px}
309
- @media(max-width:520px){
310
- .rk-discover-add span{display:none}
311
- .rk-discover-add{padding:11px}
312
- }
313
-
314
- /* ── "Add New" modal ── */
315
- .rk-add-modal{position:fixed;inset:0;z-index:140;display:grid;place-items:center;padding:20px}
316
- .rk-add-modal[hidden]{display:none}
317
- .rk-add-scrim{position:absolute;inset:0;background:rgba(15,12,5,.5);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
318
- .rk-add-card{position:relative;z-index:1;width:min(480px,100%);max-height:calc(100vh - 40px);overflow:auto;
319
- 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}
320
- .rk-add-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:6px}
321
- .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}
322
- .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);
323
- display:grid;place-items:center;cursor:pointer;transition:color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
324
- .rk-add-x:hover{color:var(--rk-ink);border-color:var(--rk-border-strong)}
325
- .rk-add-x svg{width:16px;height:16px}
326
- .rk-add-sub{font-size:13px;color:var(--rk-text-muted);line-height:1.5;margin-bottom:18px}
327
- .rk-add-form{display:grid;gap:14px}
328
- .rk-add-field{display:grid;gap:6px}
329
- .rk-add-label{font-size:12.5px;font-weight:700;color:var(--rk-n-700)}
330
- .rk-add-optional{font-weight:500;color:var(--rk-text-faint)}
331
- .rk-add-input{width:100%;box-sizing:border-box;border:1px solid var(--rk-border-strong);border-radius:var(--rk-r-lg);
332
- background:#fff;padding:11px 14px;font-family:var(--rk-font-body);font-size:14px;color:var(--rk-ink);outline:none;
333
- transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
334
- .rk-add-input::placeholder{color:var(--rk-n-400)}
335
- .rk-add-input:focus{border-color:var(--rk-gold-600);box-shadow:var(--rk-ring-gold)}
336
- .rk-add-textarea{resize:vertical;min-height:52px;line-height:1.45}
337
- .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}
338
- .rk-add-or::before,.rk-add-or::after{content:"";height:1px;flex:1;background:var(--rk-border)}
339
- .rk-add-file{display:flex;align-items:center;gap:12px;border:1px dashed var(--rk-border-strong);border-radius:var(--rk-r-lg);
340
- padding:13px 14px;cursor:pointer;color:var(--rk-n-700);
341
- transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
342
- .rk-add-file:hover{border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
343
- .rk-add-file input[type=file]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
344
- .rk-add-file-ic{flex:none;color:var(--rk-gold-700);display:grid;place-items:center}
345
- .rk-add-file-text{font-size:13.5px;font-weight:600;display:grid;gap:2px;min-width:0}
346
- .rk-add-file-text .rk-add-optional{font-size:11.5px;font-weight:500}
347
- .rk-add-file.is-picked{border-style:solid;border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
348
- .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}
349
- .rk-add-error[hidden]{display:none}
350
- .rk-add-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}
351
- .rk-add-actions .rk-btn[disabled]{opacity:.6;cursor:default;transform:none}
352
-
353
- /* toolbar row: mode toggle + (feed) quick search share one line, wrap on mobile */
354
- .rk-discover-toolbar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:26px}
355
-
356
- /* segmented Feed / Swipe / Categories toggle — three real links, gold active */
357
- .rk-discover-modes{display:inline-flex;flex:none;gap:4px;background:var(--rk-n-100);border:1px solid var(--rk-border);
358
- border-radius:var(--rk-r-full);padding:4px;box-shadow:var(--rk-shadow-xs);max-width:100%;overflow:auto;scrollbar-width:none}
359
- .rk-discover-modes::-webkit-scrollbar{display:none}
360
- .rk-discover-mode{display:inline-flex;align-items:center;gap:7px;padding:9px 18px;border-radius:var(--rk-r-full);
361
- font-size:14px;font-weight:600;color:var(--rk-n-600);white-space:nowrap;
362
- transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
363
- .rk-discover-mode:hover{color:var(--rk-ink)}
364
- .rk-discover-mode.is-active{background:var(--rk-gold-500);color:var(--rk-ink);font-weight:700;box-shadow:var(--rk-shadow-sm)}
365
-
366
- /* ── feed: quick search (lives in the toolbar row) + poster grid ── */
367
- .rk-discover-search{flex:1;min-width:220px;display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--rk-border-strong);
368
- border-radius:var(--rk-r-full);padding:10px 16px;box-shadow:var(--rk-shadow-xs);
369
- transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
370
- .rk-discover-search:focus-within{border-color:var(--rk-gold-600);box-shadow:var(--rk-ring-gold)}
371
- .rk-discover-search-ic{color:var(--rk-text-faint);display:grid;place-items:center;flex:none}
372
- .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)}
373
- .rk-discover-search input::placeholder{color:var(--rk-n-400)}
374
-
375
- .rk-discover-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(212px,1fr));gap:22px}
376
- @media(max-width:520px){.rk-discover-grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px}}
377
- .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}
378
- .rk-discover-poster{position:relative;display:block;aspect-ratio:9/16;overflow:hidden;background:var(--rk-n-100);color:inherit}
379
- .rk-discover-poster img,.rk-discover-poster video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
380
- .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);
381
- background:repeating-linear-gradient(135deg,var(--rk-n-100),var(--rk-n-100) 12px,var(--rk-n-50) 12px,var(--rk-n-50) 24px)}
382
- .rk-discover-dur{position:absolute;top:12px;right:12px;z-index:3;font-family:var(--rk-font-body);font-size:11.5px;font-weight:700;color:#fff;
383
- background:rgba(0,0,0,.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);padding:4px 9px;border-radius:var(--rk-r-full)}
384
- .rk-discover-scrim{position:absolute;inset:0;z-index:1;background:linear-gradient(to top,rgba(0,0,0,.28) 0%,transparent 42%)}
385
- /* wrap = positioning context so the bookmark button can sit NEXT TO (not
386
- inside) the poster anchor — a button inside an <a> is invalid HTML */
387
- .rk-discover-posterwrap{position:relative}
388
- .rk-discover-bm{position:absolute;right:10px;bottom:10px;z-index:4;width:34px;height:34px;border:0;border-radius:var(--rk-r-full);
389
- display:none;place-items:center;color:#fff;background:rgba(0,0,0,.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);cursor:pointer;
390
- transition:transform var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
391
- .rk-discover-posterwrap:hover .rk-discover-bm{display:grid}
392
- /* touch has no hover, so always show the bookmark button there (else you can
393
- never save a not-yet-bookmarked card from the grid) */
394
- @media(hover:none){.rk-discover-bm{display:grid}}
395
- .rk-discover-bm:hover{transform:scale(1.08);background:rgba(0,0,0,.6)}
396
- .rk-discover-bm.is-on{display:grid;background:var(--rk-gold-500);color:var(--rk-ink)}
397
- .rk-discover-bm.is-on svg{fill:currentColor}
398
- .rk-discover-body{padding:15px 16px 16px;display:grid;gap:13px;min-width:0}
399
- .rk-discover-titlewrap{display:grid;gap:6px;min-width:0}
400
- .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;
401
- display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
402
- .rk-discover-desc{font-size:12.5px;font-weight:500;color:var(--rk-text-muted);line-height:1.4;
403
- display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
404
- .rk-discover-idchip{justify-self:start;display:inline-flex;align-items:center;gap:6px;max-width:100%;min-width:0;
405
- padding:3px 8px;border-radius:999px;background:var(--rk-surface-2,rgba(0,0,0,.05));
406
- font-family:var(--rk-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:11px;font-weight:600;
407
- color:var(--rk-text-muted);cursor:pointer;border:0;line-height:1.4;transition:background .15s,color .15s;
408
- -webkit-user-select:none;user-select:none;box-sizing:border-box}
409
- .rk-discover-idchip:hover{background:var(--rk-surface-3,rgba(0,0,0,.09));color:var(--rk-ink)}
410
- .rk-discover-idchip svg{flex:0 0 auto;opacity:.7}
411
- .rk-discover-idchip .rk-idchip-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
412
- .rk-discover-idchip.is-copied{background:rgba(52,168,110,.16);color:#1f7a4d}
413
- .rk-discover-idchip.is-copied svg{opacity:1;color:#1f7a4d}
414
- .rk-discover-fork{gap:7px}
415
- .rk-discover-fork svg{margin-top:-1px}
416
- .rk-discover-fork-disabled{opacity:.6;cursor:default;pointer-events:none}
417
- .rk-discover-badges{display:flex;flex-wrap:wrap;gap:6px}
418
-
419
- /* hover-to-unmute speaker badge (video posters only): hidden until hover,
420
- flips to a gold "sound on" chip once the browser actually allows audio */
421
- .rk-discover-sound{position:absolute;left:12px;bottom:12px;z-index:3;width:32px;height:32px;display:none;place-items:center;color:#fff;
422
- background:rgba(0,0,0,.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);border-radius:var(--rk-r-full);pointer-events:none}
423
- .rk-discover-sound span{display:grid;place-items:center;line-height:0}
424
- .rk-discover-posterwrap:hover .rk-discover-sound{display:grid}
425
- .rk-discover-sound .rk-ds-on{display:none}
426
- .rk-discover-posterwrap.is-sound .rk-discover-sound{display:grid;background:var(--rk-gold-500);color:var(--rk-ink)}
427
- .rk-discover-posterwrap.is-sound .rk-ds-on{display:grid}
428
- .rk-discover-posterwrap.is-sound .rk-ds-off{display:none}
429
-
430
- /* bookmarked-only toolbar chip (feed) — count span collapses when empty */
431
- .rk-discover-bmfilter{flex:none;display:inline-flex;align-items:center;gap:7px;padding:10px 15px;border-radius:var(--rk-r-full);
432
- border:1px solid var(--rk-border-strong);background:#fff;color:var(--rk-n-600);font-family:var(--rk-font-body);font-size:13px;font-weight:700;
433
- cursor:pointer;box-shadow:var(--rk-shadow-xs);
434
- transition:color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
435
- .rk-discover-bmfilter:hover{color:var(--rk-ink);border-color:var(--rk-gold-600)}
436
- .rk-discover-bmfilter.is-active{background:var(--rk-gold-500);border-color:transparent;color:var(--rk-ink)}
437
- .rk-discover-bmfilter.is-active svg{fill:currentColor}
438
- .rk-discover-bmfilter-count:empty{display:none}
439
-
440
- /* load / empty / error state (feed + categories) */
441
- .rk-discover-state{padding:48px 16px;text-align:center;color:var(--rk-text-muted);font-size:14px;
442
- background:#fff;border:1px solid var(--rk-border);border-radius:var(--rk-r-2xl);box-shadow:var(--rk-shadow-xs)}
443
- .rk-discover-state[hidden]{display:none}
444
- .rk-discover-state.is-error{color:#b91c1c;background:var(--rk-red-tint);border-color:#fecaca;font-weight:500}
445
- .rk-discover-state .rk-btn{margin-top:14px}
446
-
447
- /* ── feed: TikTok-style fullscreen viewer (click a card → vertical deck) ── */
448
- html.rk-noscroll,html.rk-noscroll body{overflow:hidden}
449
- .rk-discover-viewer{position:fixed;inset:0;z-index:120}
450
- .rk-discover-viewer[hidden]{display:none}
451
- .rk-discover-viewer-scrim{position:absolute;inset:0;background:rgba(15,12,5,.88);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
452
- .rk-discover-viewer-track{position:absolute;inset:0;overflow-y:auto;scroll-snap-type:y mandatory;overscroll-behavior:contain;scrollbar-width:none}
453
- .rk-discover-viewer-track::-webkit-scrollbar{display:none}
454
- .rk-discover-viewer-slide{height:100%;display:grid;place-items:center;scroll-snap-align:start;scroll-snap-stop:always;padding:20px 0}
455
- .rk-discover-viewer-stage{position:relative;width:min(92vw,calc(93vh * 9 / 16));aspect-ratio:9/16;max-height:100%;margin:0;
456
- border-radius:var(--rk-r-3xl);overflow:hidden;background:#000;box-shadow:var(--rk-shadow-xl)}
457
- .rk-discover-viewer-media{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block}
458
- video.rk-discover-viewer-media{cursor:pointer}
459
- .rk-discover-viewer-pause{position:absolute;inset:0;z-index:2;display:none;place-items:center;color:rgba(255,255,255,.92);pointer-events:none;
460
- filter:drop-shadow(0 4px 14px rgba(0,0,0,.55))}
461
- .rk-discover-viewer-slide.is-paused .rk-discover-viewer-pause{display:grid}
462
- .rk-discover-viewer-shade{position:absolute;left:0;right:0;bottom:0;height:46%;z-index:1;pointer-events:none;
463
- background:linear-gradient(to top,rgba(0,0,0,.74) 0%,rgba(0,0,0,.22) 62%,transparent 100%)}
464
- /* right padding reserves the bookmark rail so text never runs under it */
465
- .rk-discover-viewer-info{position:absolute;left:0;right:0;bottom:0;z-index:3;padding:20px 78px 20px 20px;display:grid;gap:7px;justify-items:start;pointer-events:none}
466
- .rk-discover-viewer-bm{position:absolute;right:14px;bottom:22px;z-index:4;width:48px;height:48px;border-radius:var(--rk-r-full);
467
- border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.14);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
468
- color:#fff;display:grid;place-items:center;cursor:pointer;
469
- transition:background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
470
- .rk-discover-viewer-bm:hover{background:rgba(255,255,255,.26);transform:scale(1.06)}
471
- .rk-discover-viewer-bm.is-on{background:var(--rk-gold-500);border-color:transparent;color:var(--rk-ink)}
472
- .rk-discover-viewer-bm.is-on svg{fill:currentColor}
473
- .rk-discover-viewer-meta{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:rgba(255,255,255,.72)}
474
- .rk-discover-viewer-title{font-family:var(--rk-font-display);font-weight:700;font-size:1.12rem;line-height:1.2;letter-spacing:-.01em;color:#fff}
475
- .rk-discover-viewer-desc{font-size:12.5px;font-weight:500;color:rgba(255,255,255,.82);line-height:1.4;
476
- display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
477
- .rk-discover-viewer-fork{pointer-events:auto;gap:7px;margin-top:5px}
478
- .rk-discover-viewer-ui{position:absolute;top:max(16px,env(safe-area-inset-top,0px));right:16px;z-index:5;display:flex;gap:10px}
479
- .rk-discover-viewer-btn{width:44px;height:44px;border-radius:var(--rk-r-full);border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.12);
480
- backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;display:grid;place-items:center;cursor:pointer;
481
- transition:background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
482
- .rk-discover-viewer-btn:hover{background:rgba(255,255,255,.24);transform:scale(1.06)}
483
- .rk-discover-viewer-btn span{display:grid;place-items:center;line-height:0}
484
- .rk-discover-viewer .rk-dvm-off{display:none}
485
- .rk-discover-viewer.is-muted .rk-dvm-on{display:none}
486
- .rk-discover-viewer.is-muted .rk-dvm-off{display:grid}
487
- .rk-discover-viewer-nav{position:absolute;right:18px;top:50%;transform:translateY(-50%);z-index:5;display:grid;gap:12px}
488
- @media(hover:none){.rk-discover-viewer-nav{display:none}}
489
- @media(max-width:640px){
490
- .rk-discover-viewer-nav{display:none}
491
- .rk-discover-viewer-slide{padding:0}
492
- .rk-discover-viewer-stage{width:100%;height:100%;max-height:none;aspect-ratio:auto;border-radius:0;box-shadow:none}
493
- }
494
-
495
- /* ── swipe: single-card deck ── */
496
- .rk-discover-swipe-wrap{display:grid;justify-items:center;gap:18px;padding:4px 0 8px}
497
- .rk-discover-deck{position:relative;width:min(322px,84vw);aspect-ratio:9/16;margin:6px 0}
498
- .rk-discover-swipe-state{position:absolute;inset:0;z-index:1;display:grid;place-content:center;justify-items:center;gap:10px;text-align:center;padding:28px;
499
- background:#fff;border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-card);color:var(--rk-text-muted);font-size:14px}
500
- .rk-discover-swipe-state[hidden]{display:none}
501
- .rk-discover-swipe-state.is-error{color:#b91c1c}
502
- .rk-discover-swipe-state-title{font-family:var(--rk-font-display);font-size:var(--rk-text-lg);font-weight:700;color:var(--rk-ink)}
503
- .rk-discover-swipe-state .rk-btn{margin-top:6px}
504
- .rk-discover-spinner{width:30px;height:30px;border-radius:50%;border:3px solid var(--rk-gold-tint2);border-top-color:var(--rk-gold-700);animation:rk-discover-spin .8s linear infinite}
505
- @keyframes rk-discover-spin{to{transform:rotate(360deg)}}
506
- .rk-discover-swipe{position:absolute;inset:0;border-radius:var(--rk-r-3xl);overflow:hidden;border:1px solid var(--rk-border);background:#0b0b0b;
507
- box-shadow:var(--rk-shadow-xl);transform-origin:center bottom;will-change:transform;
508
- transition:transform .38s var(--rk-ease),opacity .38s var(--rk-ease)}
509
- .rk-discover-swipe-frame{position:absolute;inset:0;width:100%;height:100%;border:0;display:block;pointer-events:none;background:#000}
510
- .rk-discover-swipe-fallback{position:absolute;inset:0;display:grid;place-items:center;padding:24px;text-align:center;
511
- 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;
512
- transition:opacity .4s var(--rk-ease)}
513
- .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%)}
514
- .rk-discover-swipe-info{position:absolute;left:0;right:0;bottom:0;z-index:2;padding:20px;display:grid;gap:6px;pointer-events:none}
515
- .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}
516
- .rk-discover-swipe-caption{font-size:12.5px;font-weight:500;color:rgba(255,255,255,.84);line-height:1.35;
517
- display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
518
- /* audio toggle — overlays the top-of-deck card, top-right; global mute state */
519
- .rk-discover-sound-toggle{position:absolute;top:12px;right:12px;z-index:40;width:38px;height:38px;border-radius:var(--rk-r-full);
520
- border:0;display:grid;place-items:center;cursor:pointer;color:#fff;background:rgba(15,15,15,.5);backdrop-filter:blur(6px);
521
- -webkit-backdrop-filter:blur(6px);box-shadow:0 2px 8px -2px rgba(0,0,0,.5);
522
- transition:background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
523
- .rk-discover-sound-toggle[hidden]{display:none}
524
- .rk-discover-sound-toggle:hover{background:rgba(15,15,15,.72);transform:scale(1.06)}
525
- .rk-discover-sound-toggle.is-on{background:var(--rk-gold-500);color:var(--rk-ink)}
526
- .rk-discover-sound-toggle.is-on:hover{background:var(--rk-gold-700)}
527
-
528
- /* deck flanked by skip/fork — buttons ride the card's vertical center, 1fr gutters absorb spare width.
529
- Explicit column placement keeps the deck centered when the buttons are display:none (empty/caught-up states). */
530
- .rk-discover-stage{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:20px;width:100%}
531
- .rk-discover-deck{grid-column:2}
532
- .rk-discover-act{width:60px;height:60px;flex:none;border-radius:var(--rk-r-full);border:1px solid var(--rk-border);background:#fff;
533
- display:grid;place-items:center;cursor:pointer;box-shadow:var(--rk-shadow-md);
534
- transition:transform var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
535
- .rk-discover-act[hidden]{display:none}
536
- .rk-discover-act-skip{grid-column:1;justify-self:end}
537
- .rk-discover-act-fork{grid-column:3;justify-self:start}
538
- /* no side room on small phones — card spans, buttons drop under it as a pair */
539
- @media(max-width:560px){
540
- .rk-discover-stage{grid-template-columns:auto auto;grid-template-areas:"deck deck" "skip fork";justify-content:center;column-gap:26px;row-gap:16px}
541
- .rk-discover-deck{grid-area:deck}
542
- .rk-discover-act-skip{grid-area:skip;justify-self:center}
543
- .rk-discover-act-fork{grid-area:fork;justify-self:center}
544
- }
545
- /* short viewports (landscape phones, small laptops): the deck is sized by width
546
- with a locked 9/16 ratio → ~570px tall, taller than the screen, so the card +
547
- its flanking Skip/Fork controls fall off-screen. Cap it by HEIGHT instead and
548
- let width follow the ratio (mirrors how the fullscreen viewer stage sizes). */
549
- @media(max-height:720px){
550
- .rk-discover-deck{height:min(74vh,572px);width:auto}
551
- }
552
- @media(max-height:520px){
553
- .rk-discover-head{margin-bottom:10px}
554
- .rk-discover-swipe-wrap{gap:10px;padding:0}
555
- }
556
- .rk-discover-act:hover{transform:translateY(-2px);box-shadow:var(--rk-shadow-lg)}
557
- .rk-discover-act:disabled{opacity:.5;cursor:default;transform:none}
558
- .rk-discover-act-skip{color:var(--rk-n-500)}
559
- .rk-discover-act-skip:hover{color:var(--rk-red);border-color:var(--rk-red-tint)}
560
- .rk-discover-act-fork{background:var(--rk-gold-500);color:var(--rk-ink);border-color:transparent}
561
- .rk-discover-act-fork:hover{background:var(--rk-gold-700);box-shadow:0 8px 22px -6px rgba(252,185,0,.6)}
562
- .rk-discover-act-hint{font-size:12.5px;font-weight:600;color:var(--rk-text-faint);text-align:center;letter-spacing:.01em}
563
- .rk-discover-act-hint[hidden]{display:none}
564
- /* tapping the front card opens the fullscreen swipe viewer */
565
- .rk-discover-deck .rk-discover-swipe{cursor:pointer}
566
- /* ── swipe: fullscreen TikTok viewer — reuses .rk-discover-viewer* shell; slides
567
- hold live composition iframes (.rk-discover-swipe-frame) and a bottom bar
568
- reject/approves the active card. */
569
- .rk-swipe-viewer-stage{background:#000}
570
- .rk-swipe-viewer-slide .rk-discover-swipe-frame{border-radius:inherit}
571
- .rk-swipe-viewer .rk-discover-viewer-info{padding-bottom:112px}
572
- .rk-swipe-viewer-actions{position:absolute;left:0;right:0;bottom:max(24px,env(safe-area-inset-bottom,0px));z-index:6;
573
- display:flex;gap:40px;justify-content:center;align-items:center;pointer-events:none}
574
- .rk-swipe-viewer-actions .rk-discover-act{pointer-events:auto;width:64px;height:64px}
575
- .rk-swipe-viewer-actions .rk-discover-act[hidden]{display:none}
576
- @media(max-width:560px){.rk-swipe-viewer-actions{gap:30px}}
577
-
578
- /* ── categories: browse-by-format cards ── */
579
- .rk-discover-cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(268px,1fr));gap:20px}
580
- @media(max-width:560px){.rk-discover-cats{grid-template-columns:1fr}}
581
- .rk-discover-cats[hidden]{display:none}
582
- .rk-discover-cat{display:flex;flex-direction:column;gap:14px;background:var(--rk-surface);border:1px solid var(--rk-border);
583
- border-radius:var(--rk-r-3xl);padding:22px;box-shadow:var(--rk-shadow-card);color:inherit}
584
- .rk-discover-cat-head{display:flex;align-items:center;gap:14px}
585
- .rk-discover-cat-titlewrap{flex:1;min-width:0;display:grid;gap:2px}
586
- .rk-discover-cat-title{font-family:var(--rk-font-display);font-size:var(--rk-text-lg);font-weight:700;color:var(--rk-ink);letter-spacing:-.01em}
587
- .rk-discover-cat-count{font-size:12.5px;font-weight:600;color:var(--rk-text-muted)}
588
- .rk-discover-cat-arrow{flex:none;font-size:18px;color:var(--rk-n-400);transition:transform var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
589
- .rk-discover-cat:hover .rk-discover-cat-arrow{transform:translateX(3px);color:var(--rk-gold-700)}
590
- .rk-discover-cat-blurb{font-size:13px;color:var(--rk-text-muted);line-height:1.45;
591
- display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
592
- .rk-discover-cat-thumbs{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:8px}
593
- .rk-discover-mini{position:relative;aspect-ratio:9/16;border-radius:var(--rk-r-lg);overflow:hidden;background:var(--rk-n-100);box-shadow:var(--rk-shadow-xs)}
594
- .rk-discover-mini img,.rk-discover-mini video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
595
- .rk-discover-mini-ph{background:linear-gradient(135deg,var(--rk-n-100),var(--rk-n-200))}
596
-
597
- .rk-discover-cat-reveal[hidden]{display:none}
598
- /* reveal header shares the toolbar row with the mode toggle (user feedback) */
599
- .rk-discover-reveal-head{display:flex;align-items:center;gap:12px;min-width:0}
600
- .rk-discover-reveal-head[hidden]{display:none}
601
- .rk-discover-reveal-title{font-family:var(--rk-font-display);font-size:var(--rk-text-xl);font-weight:800;color:var(--rk-ink);letter-spacing:-.02em;
602
- white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
603
- .rk-discover-cats-state[hidden]{display:none}
604
- `;
605
- const script = `
606
- (function(){
607
- var root=document;
608
- var bootEl=root.querySelector('[data-rk-discover-boot]');
609
- var boot={};
610
- try{ boot=JSON.parse(bootEl&&bootEl.textContent?bootEl.textContent:'{}')||{}; }catch(e){ boot={}; }
611
- var feedEndpoint=boot.feedEndpoint||'/discover/feed';
612
- var popularEndpoint=boot.popularEndpoint||'/discover/popular';
613
- var swipeStackEndpoint=boot.swipeStackEndpoint||'/discover/swipe/stack';
614
- var swipeRefillEndpoint=boot.swipeRefillEndpoint||'/discover/swipe/refill';
615
- var swipeBase=boot.swipeBase||'/discover/swipe';
616
- var editorBase=boot.editorBase||'/editor';
617
- var accountId=boot.accountId||'';
618
-
619
- function esc(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
620
- function attr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
621
- function isVideo(u){ return Boolean(u)&&/\\.(mp4|webm|ogg|mov)(\\?|#|$)/i.test(u); }
622
- // previewUrl falls back to the template's raw videoUrl, which is often a
623
- // signed/extension-less URL — so treat "not a known image" as video, never
624
- // the other way round, or those templates render a broken <img> (no preview).
625
- function isImage(u){ return Boolean(u)&&/\\.(png|jpe?g|gif|webp|avif|svg)(\\?|#|$)/i.test(u); }
626
- function withAccount(u){
627
- try{ var url=new URL(u, window.location.href); if(accountId) url.searchParams.set('account', accountId); return url.toString(); }
628
- catch(e){ return u; }
629
- }
630
- function editorUrl(id){
631
- var u=editorBase+'/'+encodeURIComponent(String(id));
632
- return accountId? u+'?account='+encodeURIComponent(accountId) : u;
633
- }
634
- // Feed cards browse INSPIRATIONS: an entry may have no template yet. The real
635
- // template id (if decomposed) opens the editor directly; otherwise the card
636
- // carries an inspirationId and its CTA decomposes on demand (preview → Remix).
637
- function realTemplateId(t){ var id=t.templateId||t.template_id||''; return /^template_/.test(String(id))? String(id):''; }
638
- function inspirationIdOf(t){ return String(t.inspirationId||t.inspiration_id||''); }
639
- function domId(t){ return realTemplateId(t)||inspirationIdOf(t)||''; }
640
- function needsDecompose(t){ return !realTemplateId(t) && Boolean(inspirationIdOf(t)); }
641
- // POST the lazy decompose, then hand off to the editor. Shared by card +
642
- // viewer Remix buttons via a delegated [data-remix] listener.
643
- function remixInspiration(btn){
644
- var inspId=btn.getAttribute('data-remix'); if(!inspId||btn.getAttribute('data-busy')) return;
645
- btn.setAttribute('data-busy','1');
646
- var label=btn.innerHTML; btn.innerHTML='Preparing&hellip;';
647
- fetch(withAccount('/discover/inspirations/'+encodeURIComponent(inspId)+'/decompose'),{method:'POST',credentials:'same-origin',headers:{Accept:'application/json'}})
648
- .then(function(r){ return r.json().then(function(d){ return {ok:r.ok,data:d}; }); })
649
- .then(function(res){
650
- if(res.ok&&res.data&&res.data.templateId){ window.location.href=editorUrl(res.data.templateId); return; }
651
- btn.removeAttribute('data-busy'); btn.innerHTML=label;
652
- window.alert((res.data&&res.data.error)||'Could not open this inspiration — please try again.');
653
- })
654
- .catch(function(){ btn.removeAttribute('data-busy'); btn.innerHTML=label; window.alert('Network error — please try again.'); });
655
- }
656
- document.addEventListener('click',function(e){
657
- var b=e.target&&e.target.closest?e.target.closest('[data-remix]'):null;
658
- if(!b) return; e.preventDefault(); remixInspiration(b);
659
- });
660
- // Click the template-id chip on a feed card to copy the id to the clipboard,
661
- // with a brief inline "Copied" swap (icon + label) as subtle confirmation.
662
- function copyText(text){
663
- if(navigator.clipboard&&navigator.clipboard.writeText){ return navigator.clipboard.writeText(text); }
664
- return new Promise(function(resolve,reject){
665
- try{
666
- var ta=document.createElement('textarea'); ta.value=text;
667
- ta.style.position='fixed'; ta.style.top='-9999px'; document.body.appendChild(ta);
668
- ta.focus(); ta.select(); var ok=document.execCommand('copy'); document.body.removeChild(ta);
669
- ok?resolve():reject(new Error('copy failed'));
670
- }catch(err){ reject(err); }
671
- });
672
- }
673
- document.addEventListener('click',function(e){
674
- var chip=e.target&&e.target.closest?e.target.closest('[data-copy-id]'):null;
675
- if(!chip) return;
676
- e.preventDefault(); e.stopPropagation();
677
- var id=chip.getAttribute('data-copy-id')||'';
678
- if(!id||chip.getAttribute('data-copied')) return;
679
- copyText(id).then(function(){
680
- var txt=chip.querySelector('.rk-idchip-text'); var prev=txt?txt.textContent:'';
681
- chip.setAttribute('data-copied','1'); chip.classList.add('is-copied');
682
- chip.innerHTML=IDCHECK_ICON+'<span class="rk-idchip-text">Copied</span>';
683
- window.setTimeout(function(){
684
- chip.classList.remove('is-copied'); chip.removeAttribute('data-copied');
685
- chip.innerHTML=COPY_ICON+'<span class="rk-idchip-text">'+esc(prev)+'</span>';
686
- },1300);
687
- }).catch(function(){});
688
- });
689
- function fmtDuration(s){
690
- s=Number(s); if(!s||!isFinite(s)||s<=0) return '';
691
- if(s>=60){ var m=Math.floor(s/60); var r=Math.round(s%60); return m+':'+(r<10?'0':'')+r; }
692
- return (s>=10? s.toFixed(0) : s.toFixed(1))+'s';
693
- }
694
- 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'};
695
- 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); }
696
- var EDIT_ICON=${JSON.stringify(EDIT_ICON)};
697
- var COPY_ICON=${JSON.stringify(COPY_ICON)};
698
- var IDCHECK_ICON=${JSON.stringify(IDCHECK_ICON)};
699
- var BOOKMARK_ICON=${JSON.stringify(BOOKMARK_ICON)};
700
- var SOUND_ON_ICON=${JSON.stringify(SOUND_ON_ICON)};
701
- var SOUND_OFF_ICON=${JSON.stringify(SOUND_OFF_ICON)};
702
- var PLAY_ICON=${JSON.stringify(PLAY_ICON)};
703
-
704
- // ── bookmarks: client-side saves in localStorage (no backend favorite
705
- // endpoint exists), shared by feed cards, category reveal cards and the
706
- // viewer slides. One delegated document listener toggles any [data-bm].
707
- var BM_KEY='rk-discover-bookmarks';
708
- var bookmarks=(function(){
709
- try{ var v=JSON.parse(window.localStorage.getItem(BM_KEY)||'[]'); return new Set(Array.isArray(v)?v.map(String):[]); }
710
- catch(e){ return new Set(); }
711
- })();
712
- var bmChangeHandlers=[];
713
- function isBookmarked(id){ return Boolean(id)&&bookmarks.has(String(id)); }
714
- function syncBmButtons(){
715
- root.querySelectorAll('[data-bm]').forEach(function(b){
716
- var on=isBookmarked(b.getAttribute('data-bm'));
717
- b.classList.toggle('is-on',on);
718
- b.setAttribute('aria-pressed',on?'true':'false');
719
- b.setAttribute('aria-label',on?'Remove bookmark':'Bookmark');
720
- });
721
- }
722
- function toggleBookmark(id){
723
- id=String(id||''); if(!id) return;
724
- if(bookmarks.has(id)) bookmarks.delete(id); else bookmarks.add(id);
725
- try{ window.localStorage.setItem(BM_KEY, JSON.stringify(Array.from(bookmarks))); }catch(e){}
726
- syncBmButtons();
727
- bmChangeHandlers.forEach(function(fn){ try{ fn(id); }catch(e){} });
728
- }
729
- function bmBtnHtml(id, cls){
730
- if(!id) return '';
731
- var on=isBookmarked(id);
732
- return '<button type="button" class="'+cls+(on?' is-on':'')+'" data-bm="'+attr(id)+'" aria-pressed="'+(on?'true':'false')+'" aria-label="'+(on?'Remove bookmark':'Bookmark')+'">'+BOOKMARK_ICON+'</button>';
733
- }
734
- document.addEventListener('click',function(e){
735
- var b=(e.target&&e.target.closest)?e.target.closest('[data-bm]'):null;
736
- if(!b) return;
737
- e.preventDefault();
738
- toggleBookmark(b.getAttribute('data-bm'));
739
- });
740
-
741
- // ── shared: pause/resume a grid's muted autoplay (while the viewer is open) ──
742
- function parkVideos(container){
743
- if(!container) return;
744
- container.querySelectorAll('video').forEach(function(o){ o.muted=true; o.pause(); });
745
- container.querySelectorAll('.rk-discover-posterwrap.is-sound').forEach(function(p){ p.classList.remove('is-sound'); });
746
- }
747
- function resumeVideos(container){
748
- if(!container) return;
749
- container.querySelectorAll('video').forEach(function(o){ o.muted=true; var pr=o.play(); if(pr&&pr.catch) pr.catch(function(){}); });
750
- }
751
-
752
- function friendlyError(err){
753
- var msg=(err&&err.message)?String(err.message):'';
754
- if(!msg||/failed to fetch|networkerror|load failed/i.test(msg)) return "Couldn't reach the server — check your connection, or sign in to keep browsing.";
755
- return msg;
756
- }
757
-
758
- function mediaHtml(t){
759
- var url=t.previewUrl||t.preview_url||'';
760
- if(!url) return '<div class="rk-discover-media-empty">'+(t.status==='processing'?'Downloading&hellip;':'No preview')+'</div>';
761
- if(isImage(url)) return '<img src="'+attr(url)+'" alt="'+attr((t.title||'Template')+' preview')+'" loading="lazy" decoding="async">';
762
- // #t=0.1 paints the first frame even where autoplay is blocked.
763
- return '<video src="'+attr(url+'#t=0.1')+'" muted loop autoplay playsinline preload="metadata" tabindex="-1"></video>';
764
- }
765
-
766
- // Real feed/category poster card. The "Open Editor" CTA always deep-links
767
- // into the real editor; the poster itself carries data-rk-view=<index> so a
768
- // plain click opens the TikTok-style viewer instead (cmd/middle-click still
769
- // opens the editor via the untouched href). No platform/source pill on the
770
- // poster (user feedback) — source shows only in the viewer's meta line.
771
- function posterCardHtml(t, viewIdx){
772
- var id=domId(t);
773
- var tid=realTemplateId(t);
774
- var remix=needsDecompose(t);
775
- var url=t.previewUrl||t.preview_url||'';
776
- var hasVideo=Boolean(url)&&!isImage(url);
777
- var pending=(t.status==='processing'||t.status==='failed');
778
- var viewable=!pending&&Boolean(url)&&typeof viewIdx==='number'&&viewIdx>=0;
779
- var dur=fmtDuration(t.durationSeconds);
780
- var desc=String(t.viralDna||t.summary||'').trim();
781
- // Real templates deep-link to the editor; inspiration-only cards open the
782
- // preview viewer instead (a plain anchor href would 404 the editor), and the
783
- // CTA decomposes on demand.
784
- var posterHref=remix? '#' : editorUrl(tid);
785
- var posterInner=mediaHtml(t)
786
- +(dur?'<span class="rk-discover-dur">'+esc(dur)+'</span>':'')
787
- +'<div class="rk-discover-scrim"></div>'
788
- +(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>':'');
789
- var poster=pending
790
- ? '<div class="rk-discover-poster">'+posterInner+'</div>'
791
- : '<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>';
792
- // bookmark sits NEXT TO the anchor inside the wrap (button-in-anchor is
793
- // invalid HTML); the wrap is the shared hover context for sound + bookmark
794
- poster='<div class="rk-discover-posterwrap">'+poster+(pending?'':bmBtnHtml(id,'rk-discover-bm'))+'</div>';
795
- var cta=pending
796
- ? '<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>'
797
- : remix
798
- ? '<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>'
799
- : '<a class="rk-btn rk-btn-gold rk-btn-sm rk-btn-block rk-discover-fork" href="'+attr(editorUrl(tid))+'" target="_blank" rel="noopener">'+EDIT_ICON+' Open Editor</a>';
800
- return '<article class="rk-discover-card rk-card-hover">'
801
- + poster
802
- + '<div class="rk-discover-body">'
803
- + '<div class="rk-discover-titlewrap">'
804
- + '<h3 class="rk-discover-title">'+esc(t.title||id)+'</h3>'
805
- + (desc?'<p class="rk-discover-desc">'+esc(desc)+'</p>':'')
806
- + (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>':'')
807
- + '</div>'
808
- + cta
809
- + '</div>'
810
- + '</article>';
811
- }
812
-
813
- // ── hover audio: unmute a poster's (muted, autoplaying) video while the
814
- // pointer is over it; only one card is ever audible at a time. Browsers may
815
- // refuse unmuted playback before the user's first click (autoplay policy) —
816
- // in that case we verify shortly after and fall back to muted playback
817
- // instead of leaving a frozen poster. Delegated, so grid re-renders keep it.
818
- function bindHoverAudio(container){
819
- if(!container) return;
820
- function posterOf(e){ var t=e.target; return (t&&t.closest)?t.closest('.rk-discover-posterwrap'):null; }
821
- function setSound(poster,on){
822
- var v=poster.querySelector('video'); if(!v) return;
823
- if(on){
824
- container.querySelectorAll('video').forEach(function(o){ if(o!==v) o.muted=true; });
825
- container.querySelectorAll('.rk-discover-posterwrap.is-sound').forEach(function(p){ if(p!==poster) p.classList.remove('is-sound'); });
826
- v.muted=false;
827
- if(v.paused){ var pr=v.play(); if(pr&&pr.catch) pr.catch(function(){}); }
828
- window.setTimeout(function(){
829
- if(v.muted) return; // already reverted (pointer left)
830
- if(v.paused){ v.muted=true; poster.classList.remove('is-sound'); var pr2=v.play(); if(pr2&&pr2.catch) pr2.catch(function(){}); }
831
- else poster.classList.add('is-sound');
832
- },70);
833
- } else {
834
- v.muted=true; poster.classList.remove('is-sound');
835
- }
836
- }
837
- container.addEventListener('mouseover',function(e){
838
- var p=posterOf(e); if(!p) return;
839
- if(e.relatedTarget&&p.contains(e.relatedTarget)) return; // still inside the same poster
840
- setSound(p,true);
841
- });
842
- container.addEventListener('mouseout',function(e){
843
- var p=posterOf(e); if(!p) return;
844
- if(e.relatedTarget&&p.contains(e.relatedTarget)) return;
845
- setSound(p,false);
846
- });
847
- }
848
-
849
- // ── TikTok-style viewer modal — SHARED by the feed grid and the categories
850
- // reveal grid. open(list, clickedIdx, park): list is the template array
851
- // behind the grid the user clicked, clickedIdx the clicked card index in
852
- // that list, park the grid container whose muted autoplay is paused while
853
- // the modal owns audio + CPU. Slides hydrate lazily (active +/-1); the click
854
- // that opens the modal is a real user gesture, so the active video plays
855
- // WITH sound (with a muted-playback fallback if the browser still refuses).
856
- var viewerCtl=(function(){
857
- var viewer=root.querySelector('[data-rk-viewer]');
858
- if(!viewer) return null;
859
- var vTrack=viewer.querySelector('[data-viewer-track]');
860
- var vItems=[]; var vOpen=false; var vActive=-1; var vSound=true; var vIO=null; var parkEl=null;
861
-
862
- function viewerSlideHtml(t,i){
863
- var id=domId(t);
864
- var tid=realTemplateId(t);
865
- var remix=needsDecompose(t);
866
- var url=t.previewUrl||t.preview_url||'';
867
- var media=isImage(url)
868
- ? '<img class="rk-discover-viewer-media" src="'+attr(url)+'" alt="'+attr((t.title||'Template')+' preview')+'">'
869
- : '<video class="rk-discover-viewer-media" data-vsrc="'+attr(url)+'" loop playsinline preload="none"></video>';
870
- var meta=[];
871
- var srcL=sourceLabel(t.sourceType); if(srcL) meta.push(esc(srcL));
872
- var dur=fmtDuration(t.durationSeconds); if(dur) meta.push(esc(dur));
873
- var desc=String(t.viralDna||t.summary||'').trim();
874
- return '<section class="rk-discover-viewer-slide" data-vslide="'+i+'">'
875
- +'<figure class="rk-discover-viewer-stage">'
876
- + media
877
- +'<span class="rk-discover-viewer-pause">'+PLAY_ICON+'</span>'
878
- +'<div class="rk-discover-viewer-shade"></div>'
879
- + bmBtnHtml(id,'rk-discover-viewer-bm')
880
- +'<div class="rk-discover-viewer-info">'
881
- +(meta.length?'<div class="rk-discover-viewer-meta">'+meta.join(' &middot; ')+'</div>':'')
882
- +'<div class="rk-discover-viewer-title">'+esc(t.title||id)+'</div>'
883
- +(desc?'<div class="rk-discover-viewer-desc">'+esc(desc)+'</div>':'')
884
- +(remix
885
- ? '<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>'
886
- : '<a class="rk-btn rk-btn-gold rk-btn-sm rk-discover-viewer-fork" href="'+attr(editorUrl(tid))+'" target="_blank" rel="noopener">'+EDIT_ICON+' Open Editor</a>')
887
- +'</div>'
888
- +'</figure>'
889
- +'</section>';
890
- }
891
- function hydrateSlide(slide){
892
- var v=slide.querySelector('video[data-vsrc]');
893
- if(!v||v.getAttribute('src')) return;
894
- v.preload='auto';
895
- v.setAttribute('src', v.getAttribute('data-vsrc'));
896
- }
897
- function updateMuteUi(){ viewer.classList.toggle('is-muted',!vSound); }
898
- function setViewerActive(idx){
899
- vActive=idx;
900
- [].forEach.call(vTrack.children,function(s,i){
901
- if(Math.abs(i-idx)<=1) hydrateSlide(s);
902
- s.classList.remove('is-paused');
903
- var v=s.querySelector('video');
904
- if(!v) return;
905
- if(i===idx){
906
- v.muted=!vSound;
907
- var pr=v.play();
908
- if(pr&&pr.catch) pr.catch(function(){
909
- // sound-on autoplay blocked → fall back to muted playback
910
- v.muted=true; vSound=false; updateMuteUi();
911
- var pr2=v.play(); if(pr2&&pr2.catch) pr2.catch(function(){});
912
- });
913
- } else { v.pause(); }
914
- });
915
- updateMuteUi();
916
- }
917
- function stepViewer(d){
918
- if(!vOpen) return;
919
- var next=Math.max(0,Math.min(vItems.length-1,vActive+d));
920
- if(next===vActive) return;
921
- vTrack.scrollTo({top:next*vTrack.clientHeight,behavior:'smooth'});
922
- }
923
- function open(list, clickedIdx, park){
924
- vItems=[]; var start=0;
925
- (list||[]).forEach(function(t,i){
926
- var url=t.previewUrl||t.preview_url||'';
927
- if(!url||t.status==='processing'||t.status==='failed') return;
928
- if(i===clickedIdx) start=vItems.length;
929
- vItems.push(t);
930
- });
931
- if(!vItems.length) return;
932
- parkEl=park||null;
933
- vTrack.innerHTML=vItems.map(viewerSlideHtml).join('');
934
- vOpen=true; vSound=true;
935
- viewer.hidden=false;
936
- document.documentElement.classList.add('rk-noscroll');
937
- parkVideos(parkEl);
938
- if(vIO) vIO.disconnect();
939
- vIO=('IntersectionObserver' in window)?new IntersectionObserver(function(entries){
940
- entries.forEach(function(en){
941
- if(!en.isIntersecting) return;
942
- var i=Number(en.target.getAttribute('data-vslide'));
943
- if(i!==vActive) setViewerActive(i);
944
- });
945
- },{root:vTrack,threshold:0.6}):null;
946
- if(vIO) [].forEach.call(vTrack.children,function(s){ vIO.observe(s); });
947
- vTrack.scrollTop=start*vTrack.clientHeight;
948
- setViewerActive(start);
949
- }
950
- function close(){
951
- if(!vOpen) return;
952
- vOpen=false; vActive=-1;
953
- if(vIO){ vIO.disconnect(); vIO=null; }
954
- vTrack.querySelectorAll('video').forEach(function(v){ v.pause(); });
955
- vTrack.innerHTML='';
956
- viewer.hidden=true;
957
- document.documentElement.classList.remove('rk-noscroll');
958
- resumeVideos(parkEl);
959
- parkEl=null;
960
- }
961
-
962
- viewer.querySelectorAll('[data-viewer-close]').forEach(function(b){ b.addEventListener('click',close); });
963
- var muteBtn=viewer.querySelector('[data-viewer-mute]');
964
- if(muteBtn) muteBtn.addEventListener('click',function(){
965
- vSound=!vSound; updateMuteUi();
966
- var s=vTrack.children[vActive]; var v=s&&s.querySelector('video');
967
- if(v){ v.muted=!vSound; if(vSound&&v.paused){ var pr=v.play(); if(pr&&pr.catch) pr.catch(function(){}); s.classList.remove('is-paused'); } }
968
- });
969
- var prevBtn=viewer.querySelector('[data-viewer-prev]');
970
- var nextBtn=viewer.querySelector('[data-viewer-next]');
971
- if(prevBtn) prevBtn.addEventListener('click',function(){ stepViewer(-1); });
972
- if(nextBtn) nextBtn.addEventListener('click',function(){ stepViewer(1); });
973
- // tap the video → pause/resume (TikTok); tap the empty gutter → close;
974
- // bookmarks are handled by the document-level [data-bm] listener
975
- vTrack.addEventListener('click',function(e){
976
- var t=e.target;
977
- if(t&&t.closest&&(t.closest('a')||t.closest('[data-bm]'))) return;
978
- var media=(t&&t.closest)?t.closest('.rk-discover-viewer-media'):null;
979
- if(media&&media.tagName==='VIDEO'){
980
- var slide=media.closest('.rk-discover-viewer-slide');
981
- if(media.paused){ var pr=media.play(); if(pr&&pr.catch) pr.catch(function(){}); if(slide) slide.classList.remove('is-paused'); }
982
- else { media.pause(); if(slide) slide.classList.add('is-paused'); }
983
- return;
984
- }
985
- if(t&&t.classList&&(t.classList.contains('rk-discover-viewer-slide')||t===vTrack)) close();
986
- });
987
- document.addEventListener('keydown',function(e){
988
- if(!vOpen) return;
989
- if(e.key==='Escape'){ e.preventDefault(); close(); }
990
- else if(e.key==='ArrowDown'||e.key==='PageDown'){ e.preventDefault(); stepViewer(1); }
991
- else if(e.key==='ArrowUp'||e.key==='PageUp'){ e.preventDefault(); stepViewer(-1); }
992
- });
993
- window.addEventListener('resize',function(){
994
- if(vOpen&&vActive>=0) vTrack.scrollTop=vActive*vTrack.clientHeight;
995
- });
996
-
997
- return { open: open, close: close, isOpen: function(){ return vOpen; } };
998
- })();
999
-
1000
- // Wire a poster grid's plain-clicks into the shared viewer. getList is
1001
- // called at click time so it always reflects the grid's CURRENT contents
1002
- // (search filter, bookmark filter, revealed group).
1003
- function bindViewerOpens(container, getList){
1004
- if(!container||!viewerCtl) return;
1005
- container.addEventListener('click',function(e){
1006
- if(e.defaultPrevented||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0) return;
1007
- var a=(e.target&&e.target.closest)?e.target.closest('[data-rk-view]'):null;
1008
- if(!a||!container.contains(a)) return;
1009
- e.preventDefault();
1010
- viewerCtl.open(getList()||[], Number(a.getAttribute('data-rk-view')), container);
1011
- });
1012
- }
1013
-
1014
- // ════════════════════════════════════════════════════════════════════════
1015
- // FEED
1016
- // ════════════════════════════════════════════════════════════════════════
1017
- var feedRoot=root.querySelector('[data-rk-feed]');
1018
- if(feedRoot){
1019
- var gridEl=feedRoot.querySelector('[data-rk-grid]');
1020
- var stateEl=feedRoot.querySelector('[data-rk-state]');
1021
- // search lives in the shared toolbar row (outside [data-rk-feed]); the
1022
- // visible-count is folded into the search placeholder rather than shown as
1023
- // a separate label.
1024
- var searchEl=root.querySelector('[data-rk-search]');
1025
- // caption the placeholder with the count, but only while the box is empty —
1026
- // a typed query keeps its own placeholder-less state.
1027
- function setCountHint(n){
1028
- if(!searchEl) return;
1029
- searchEl.placeholder = (n==null)
1030
- ? 'Search templates'
1031
- : 'Search '+n+' template'+(n===1?'':'s');
1032
- }
1033
- var bmChip=root.querySelector('[data-rk-bmfilter]');
1034
- var bmCountEl=root.querySelector('[data-rk-bmcount]');
1035
- var bmFilter=false;
1036
- var feedState={items:[],visible:[],loading:false};
1037
- var feedReq=0;
1038
-
1039
- function feedSetState(msg,isError){
1040
- if(gridEl) gridEl.innerHTML='';
1041
- if(stateEl){ stateEl.hidden=false; stateEl.innerHTML=msg; stateEl.classList.toggle('is-error',!!isError); }
1042
- setCountHint(null);
1043
- }
1044
- function updateBmChip(){
1045
- if(!bmChip) return;
1046
- var n=0;
1047
- feedState.items.forEach(function(t){ if(isBookmarked(t.templateId||t.template_id)) n++; });
1048
- if(bmCountEl) bmCountEl.textContent=n?String(n):'';
1049
- bmChip.classList.toggle('is-active',bmFilter);
1050
- bmChip.setAttribute('aria-pressed',bmFilter?'true':'false');
1051
- }
1052
- function feedRender(){
1053
- var vis=bmFilter
1054
- ? feedState.items.filter(function(t){ return isBookmarked(t.templateId||t.template_id); })
1055
- : feedState.items;
1056
- feedState.visible=vis;
1057
- updateBmChip();
1058
- if(!vis.length){
1059
- feedSetState(bmFilter?'No bookmarks yet — tap the bookmark on any template to save it for later.':'No templates match — try a different search.',false);
1060
- return;
1061
- }
1062
- if(stateEl) stateEl.hidden=true;
1063
- if(gridEl) gridEl.innerHTML=vis.map(function(t,i){ return posterCardHtml(t,i); }).join('');
1064
- setCountHint(vis.length);
1065
- }
1066
- if(bmChip) bmChip.addEventListener('click',function(){ bmFilter=!bmFilter; feedRender(); });
1067
- bmChangeHandlers.push(function(){
1068
- updateBmChip();
1069
- // with the filter on, un-bookmarking drops the card from the grid; the
1070
- // re-render restarts muted autoplay, so re-park it if the viewer is open
1071
- if(bmFilter){ feedRender(); if(viewerCtl&&viewerCtl.isOpen()) parkVideos(gridEl); }
1072
- });
1073
- function applyFeedItems(items){
1074
- feedState.items=Array.isArray(items)?items:[];
1075
- feedState.loading=false;
1076
- feedRender();
1077
- }
1078
- // Direct same-origin fetch — the fallback for when the Zustand bridge isn't
1079
- // on the page yet (its deferred module is still loading) or is disabled.
1080
- function feedFetchDirect(query,my){
1081
- var url=feedEndpoint;
1082
- 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; }
1083
- fetch(url,{credentials:'same-origin',headers:{'accept':'application/json'}})
1084
- .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(); })
1085
- .then(function(j){ if(my!==feedReq) return; applyFeedItems((j&&Array.isArray(j.templates))?j.templates:[]); })
1086
- .catch(function(err){ if(my!==feedReq) return; feedState.loading=false; feedSetState(esc(friendlyError(err)),true); });
1087
- }
1088
- // Feed loads flow through the Zustand store bridge (SSR-seed + SWR cache);
1089
- // it may call onResult twice for the default feed (cached snapshot first,
1090
- // then a background-revalidated fresh copy) — both are honored under the
1091
- // same request token so a newer search still wins.
1092
- function feedLoad(query){
1093
- var my=++feedReq;
1094
- feedState.loading=true;
1095
- // Only flash the loading state when nothing's painted yet — a
1096
- // revalidate must never blank an already-good grid.
1097
- if(!feedState.items.length) feedSetState('Loading templates&hellip;',false);
1098
- var bridge=window.__vidfarmDiscover;
1099
- if(bridge&&bridge.loadFeed){
1100
- bridge.loadFeed(query,{
1101
- onLoading:function(){ if(my===feedReq && !feedState.items.length) feedSetState('Loading templates&hellip;',false); },
1102
- onResult:function(items){ if(my!==feedReq) return; applyFeedItems(items); },
1103
- onError:function(err){ if(my!==feedReq) return; feedState.loading=false; feedSetState(esc(friendlyError(err)),true); }
1104
- });
1105
- } else {
1106
- feedFetchDirect(query,my);
1107
- }
1108
- }
1109
- // Honor a ?q= (e.g. from a category link) in the page URL.
1110
- var initialQuery='';
1111
- try{ initialQuery=(new URL(window.location.href)).searchParams.get('q')||''; }catch(e){ initialQuery=''; }
1112
- if(searchEl){
1113
- searchEl.value=initialQuery;
1114
- var debounce=null;
1115
- searchEl.addEventListener('input',function(){
1116
- if(debounce) window.clearTimeout(debounce);
1117
- var v=searchEl.value.trim();
1118
- debounce=window.setTimeout(function(){ feedLoad(v); },260);
1119
- });
1120
- searchEl.addEventListener('keydown',function(e){ if(e.key==='Enter'){ if(debounce) window.clearTimeout(debounce); feedLoad(searchEl.value.trim()); } });
1121
- }
1122
- bindHoverAudio(gridEl);
1123
- // plain click on a card → shared viewer over the CURRENT visible feed
1124
- // (search + bookmark filter applied)
1125
- bindViewerOpens(gridEl, function(){ return feedState.visible; });
1126
- // SSR-seed: if the server inlined the first page of the default feed AND
1127
- // there's no ?q= search, paint it immediately with NO initial fetch — this
1128
- // is what kills the redundant SSR-render → client-fetch round trip.
1129
- var seeded=false;
1130
- if(!initialQuery && boot.feedInitial && Array.isArray(boot.feedInitial) && boot.feedInitial.length){
1131
- applyFeedItems(boot.feedInitial);
1132
- seeded=true;
1133
- }
1134
- if(!seeded){
1135
- feedLoad(initialQuery.trim());
1136
- }
1137
- // A background revalidation (stale snapshot / BFCache restore) from the
1138
- // store bridge re-paints the grid — but only while not mid-search.
1139
- window.addEventListener('vidfarm:discover-feed-updated',function(ev){
1140
- var q=searchEl?searchEl.value.trim():'';
1141
- if(q) return;
1142
- var items=ev&&ev.detail&&ev.detail.items;
1143
- if(Array.isArray(items)) applyFeedItems(items);
1144
- });
1145
-
1146
- // ── "Add New" modal — paste a social URL or upload a video, add it to the
1147
- // caller's private feed (real POST /discover/templates + upload presign
1148
- // pair), then reload the feed so the new pending card appears. In feed
1149
- // scope so it can reach feedLoad + the current search value.
1150
- var addModal=root.querySelector('[data-rk-add-modal]');
1151
- var addBtn=root.querySelector('[data-rk-add]');
1152
- if(addModal&&addBtn){
1153
- var addForm=addModal.querySelector('[data-rk-add-form]');
1154
- var addUrl=addModal.querySelector('[data-rk-add-url]');
1155
- var addFile=addModal.querySelector('[data-rk-add-file]');
1156
- var addFileLabel=addModal.querySelector('[data-rk-add-filelabel]');
1157
- var addFileText=addModal.querySelector('[data-rk-add-filetext]');
1158
- var addTagline=addModal.querySelector('[data-rk-add-tagline]');
1159
- var addNotes=addModal.querySelector('[data-rk-add-notes]');
1160
- var addErr=addModal.querySelector('[data-rk-add-error]');
1161
- var addSubmit=addModal.querySelector('[data-rk-add-submit]');
1162
- var MAX_UPLOAD=200*1024*1024;
1163
- var defaultFileText=addFileText?addFileText.innerHTML:'';
1164
- var addBusy=false;
1165
-
1166
- function showAddErr(msg){ if(addErr){ addErr.textContent=msg||''; addErr.hidden=!msg; } }
1167
- function openAdd(){
1168
- addModal.hidden=false;
1169
- document.documentElement.classList.add('rk-noscroll');
1170
- if(addUrl){ try{ addUrl.focus(); }catch(e){} }
1171
- }
1172
- function closeAdd(){
1173
- if(addBusy) return;
1174
- addModal.hidden=true;
1175
- document.documentElement.classList.remove('rk-noscroll');
1176
- }
1177
- function resetAdd(){
1178
- if(addForm) addForm.reset();
1179
- if(addFileLabel) addFileLabel.classList.remove('is-picked');
1180
- if(addFileText) addFileText.innerHTML=defaultFileText;
1181
- showAddErr('');
1182
- }
1183
- function setAddBusy(on,label){
1184
- addBusy=on;
1185
- if(addSubmit){ addSubmit.disabled=on; addSubmit.textContent=on?(label||'Adding\\u2026'):'Add to feed'; }
1186
- }
1187
-
1188
- addBtn.addEventListener('click',openAdd);
1189
- addModal.querySelectorAll('[data-rk-add-close]').forEach(function(b){ b.addEventListener('click',closeAdd); });
1190
- document.addEventListener('keydown',function(e){ if(e.key==='Escape'&&!addModal.hidden) closeAdd(); });
1191
-
1192
- if(addFile){
1193
- addFile.addEventListener('change',function(){
1194
- var f=addFile.files&&addFile.files[0];
1195
- if(addFileLabel) addFileLabel.classList.toggle('is-picked',!!f);
1196
- if(addFileText) addFileText.innerHTML=f?esc(f.name):defaultFileText;
1197
- showAddErr('');
1198
- });
1199
- }
1200
-
1201
- function ingest(uploadRef){
1202
- var tagline=addTagline?addTagline.value.trim():'';
1203
- var notes=addNotes?addNotes.value.trim():'';
1204
- var payload=uploadRef
1205
- ? { upload:uploadRef, tagline:tagline||null, notes:notes||null }
1206
- : { source_url:(addUrl?addUrl.value.trim():''), tagline:tagline||null, notes:notes||null };
1207
- return fetch(withAccount('/discover/templates'),{
1208
- method:'POST',credentials:'same-origin',
1209
- headers:{'content-type':'application/json',Accept:'application/json'},
1210
- body:JSON.stringify(payload)
1211
- }).then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,status:r.status,data:d}; }); });
1212
- }
1213
- function uploadFile(file){
1214
- return fetch(withAccount('/discover/templates/upload/presign'),{
1215
- method:'POST',credentials:'same-origin',
1216
- headers:{'content-type':'application/json',Accept:'application/json'},
1217
- body:JSON.stringify({ file_name:file.name, content_type:file.type||null, size_bytes:file.size })
1218
- }).then(function(r){ return r.json().catch(function(){ return {}; }).then(function(d){ return {ok:r.ok,data:d}; }); })
1219
- .then(function(res){
1220
- if(!res.ok) throw new Error((res.data&&res.data.error)||'Unable to start the video upload.');
1221
- var p=res.data||{};
1222
- if(p.transport==='presigned'&&p.upload&&p.upload.url&&p.storage_key){
1223
- return fetch(p.upload.url,{ method:(p.upload.method||'PUT'), headers:(p.upload.headers||{}), body:file })
1224
- .then(function(put){ if(!put.ok) throw new Error('Video upload failed ('+put.status+'). Try again.');
1225
- return { storage_key:p.storage_key, file_name:p.file_name||file.name }; });
1226
- }
1227
- var form=new FormData(); form.append('file',file,p.file_name||file.name);
1228
- return fetch((p.upload&&p.upload.url)||'/discover/templates/upload',{ method:'POST',credentials:'same-origin',body:form })
1229
- .then(function(direct){ return direct.json().catch(function(){ return {}; }).then(function(dd){
1230
- if(!direct.ok||!dd.storage_key) throw new Error(dd.error||'Video upload failed. Try again.');
1231
- return { storage_key:dd.storage_key, file_name:dd.file_name||file.name }; }); });
1232
- });
1233
- }
1234
-
1235
- if(addForm){
1236
- addForm.addEventListener('submit',function(e){
1237
- e.preventDefault();
1238
- if(addBusy) return;
1239
- showAddErr('');
1240
- var file=addFile&&addFile.files?addFile.files[0]:null;
1241
- var url=addUrl?addUrl.value.trim():'';
1242
- if(!file&&!url){ showAddErr('Paste a TikTok, YouTube, Twitter/X, or Instagram video URL \\u2014 or upload a video file.'); return; }
1243
- if(file&&file.size>MAX_UPLOAD){ showAddErr('Video uploads can be at most 200 MB.'); return; }
1244
- setAddBusy(true,file?'Uploading\\u2026':'Adding\\u2026');
1245
- var chain=file?uploadFile(file):Promise.resolve(null);
1246
- chain.then(function(uploadRef){ return ingest(uploadRef); })
1247
- .then(function(res){
1248
- setAddBusy(false);
1249
- if(!res.ok){ showAddErr((res.data&&res.data.error)||'Unable to add that video.'); return; }
1250
- resetAdd(); closeAdd();
1251
- // A new inspiration just landed — drop the cached snapshot so the
1252
- // reload fetches fresh (no stale-cache flash of the old feed).
1253
- if(window.__vidfarmDiscover&&window.__vidfarmDiscover.invalidate) window.__vidfarmDiscover.invalidate();
1254
- feedLoad(searchEl?searchEl.value.trim():'');
1255
- })
1256
- .catch(function(err){ setAddBusy(false); showAddErr((err&&err.message)?err.message:'Unable to add that video.'); });
1257
- });
1258
- }
1259
- }
1260
- }
1261
-
1262
- // ════════════════════════════════════════════════════════════════════════
1263
- // SWIPE
1264
- // ════════════════════════════════════════════════════════════════════════
1265
- var swipeRoot=root.querySelector('[data-rk-swipe]');
1266
- if(swipeRoot){
1267
- var deck=swipeRoot.querySelector('[data-deck]');
1268
- var swStateEl=swipeRoot.querySelector('[data-rk-swipe-state]');
1269
- var actionBtns=[].slice.call(swipeRoot.querySelectorAll('[data-swipe-act]'));
1270
- var hint=swipeRoot.querySelector('[data-actions-hint]');
1271
- var skipBtn=swipeRoot.querySelector('[data-swipe-skip]');
1272
- var forkBtn=swipeRoot.querySelector('[data-swipe-fork]');
1273
- var soundBtn=swipeRoot.querySelector('[data-swipe-sound]');
1274
- var sw={target:5,customizing:0,productSet:false,busy:false,refilling:false,loaded:false,soundOn:false,known:{}};
1275
- var nodes=[]; // live card DOM nodes, top-of-deck first
1276
- var deckExpanded=false; // true while the fullscreen swipe viewer owns the previews
1277
-
1278
- // ── audio: the same-origin preview iframes poll these window flags (see
1279
- // injectSwipePreviewFit). Only the TOP card's id is named audible so the
1280
- // preloaded 2nd iframe stays silent (audio never doubles). Deck previews
1281
- // always start muted (the only autoplay browsers permit); the toggle is the
1282
- // user gesture that unlocks sound.
1283
- function syncSound(){
1284
- // While the fullscreen viewer is open it owns the audio flags (it names the
1285
- // active slide's card audible); the deck must not clobber them.
1286
- if(deckExpanded) return;
1287
- try{
1288
- window.__VF_SWIPE_SOUND__ = sw.soundOn===true;
1289
- window.__VF_SWIPE_SOUND_TOP__ = nodes.length? String(nodes[0]._card.id): null;
1290
- }catch(e){}
1291
- }
1292
- function updateSoundBtn(){
1293
- if(!soundBtn) return;
1294
- var show=nodes.length>0;
1295
- soundBtn.hidden=!show;
1296
- soundBtn.classList.toggle('is-on', sw.soundOn);
1297
- soundBtn.setAttribute('aria-pressed', sw.soundOn?'true':'false');
1298
- soundBtn.setAttribute('aria-label', sw.soundOn?'Mute':'Unmute');
1299
- soundBtn.innerHTML = sw.soundOn? SOUND_ON_ICON : SOUND_OFF_ICON;
1300
- }
1301
- if(soundBtn) soundBtn.addEventListener('click',function(){ sw.soundOn=!sw.soundOn; syncSound(); updateSoundBtn(); });
1302
-
1303
- function swShowState(html,kind){
1304
- if(swStateEl){ swStateEl.hidden=false; swStateEl.innerHTML=html; swStateEl.classList.toggle('is-error',kind==='error'); }
1305
- actionBtns.forEach(function(b){ b.hidden=true; });
1306
- if(hint) hint.hidden=true;
1307
- }
1308
- function swHideState(){ if(swStateEl) swStateEl.hidden=true; }
1309
-
1310
- function makeCard(card){
1311
- if(card&&card.id) sw.known[String(card.id)]=true;
1312
- var el=document.createElement('article');
1313
- el.className='rk-discover-swipe';
1314
- el._card=card; el._iframed=false;
1315
- var title=esc(card.title||card.source_title||card.template_id||'Template');
1316
- var caption=card.caption?('<div class="rk-discover-swipe-caption">'+esc(card.caption)+'</div>'):'';
1317
- el.innerHTML='<div class="rk-discover-swipe-fallback"><span>'+title+'</span></div>'
1318
- +'<div class="rk-discover-swipe-scrim"></div>'
1319
- +'<div class="rk-discover-swipe-info"><div class="rk-discover-swipe-title">'+title+'</div>'+caption+'</div>';
1320
- deck.appendChild(el);
1321
- return el;
1322
- }
1323
- function upgradeToIframe(el){
1324
- if(el._iframed) return;
1325
- var card=el._card; var url=card&&(card.preview_url||card.previewUrl);
1326
- if(!url) return;
1327
- var fb=el.querySelector('.rk-discover-swipe-fallback');
1328
- var frame=document.createElement('iframe');
1329
- frame.className='rk-discover-swipe-frame';
1330
- frame.setAttribute('src', url);
1331
- frame.setAttribute('title', String(card.title||'Preview'));
1332
- frame.setAttribute('loading','lazy');
1333
- frame.setAttribute('scrolling','no');
1334
- frame.setAttribute('allow','autoplay');
1335
- // The fallback is an OPAQUE dark placeholder that sits later in the DOM,
1336
- // so it paints over the iframe. Reveal the live preview once it loads by
1337
- // fading the placeholder out (kept as a load state until then). A timed
1338
- // fallback covers the case where 'load' never fires.
1339
- function revealFrame(){ if(fb){ fb.style.opacity='0'; fb.style.pointerEvents='none'; } }
1340
- frame.addEventListener('load', function(){ window.setTimeout(revealFrame, 120); });
1341
- window.setTimeout(revealFrame, 2600);
1342
- if(fb&&fb.parentNode) fb.parentNode.insertBefore(frame, fb); else el.insertBefore(frame, el.firstChild);
1343
- el._iframed=true;
1344
- }
1345
- function restack(){
1346
- nodes.forEach(function(el,i){
1347
- if(i>2){ el.style.display='none'; return; }
1348
- el.style.display='';
1349
- el.style.zIndex=String(20-i);
1350
- el.style.transform='translateY('+(i*12)+'px) scale('+(1-i*0.045).toFixed(3)+')';
1351
- el.style.opacity=i===0?'1':(i===1?'0.92':'0.82');
1352
- // The viewer owns the previews while expanded — don't build deck iframes
1353
- // (that would double the audio + CPU); resume rebuilds them on close.
1354
- if(i<=1 && !deckExpanded) upgradeToIframe(el);
1355
- });
1356
- var has=nodes.length>0;
1357
- actionBtns.forEach(function(b){ b.hidden=!has; });
1358
- if(hint) hint.hidden=!has;
1359
- syncSound(); updateSoundBtn();
1360
- // Keep the open viewer's slide list in step with refills/removals.
1361
- if(deckExpanded && swViewer) swViewer.sync();
1362
- if(!has) swCaughtUp();
1363
- }
1364
- function swCaughtUp(){
1365
- if(sw.customizing>0||sw.refilling){
1366
- swShowState('<div class="rk-discover-spinner" aria-hidden="true"></div><div class="rk-discover-swipe-state-title">Customizing fresh clips&hellip;</div><div>Recutting viral templates into ads for your product.</div>');
1367
- } else {
1368
- swShowState('<div class="rk-discover-swipe-state-title">You\\u2019re all caught up</div><div>No more templates to customize right now. Check back as new ones are added.</div>');
1369
- }
1370
- }
1371
- // POST approve/reject for a card (shared by the deck buttons + the viewer).
1372
- function postDecision(card,dir){
1373
- var path=swipeBase+'/'+encodeURIComponent(String(card.id))+'/'+(dir==='fork'?'approve':'reject');
1374
- return fetch(withAccount(path),{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json','accept':'application/json'},body:'{}'})
1375
- .then(function(r){ return r.json().catch(function(){ return {}; }); })
1376
- .catch(function(){ return {}; });
1377
- }
1378
- // Drop a card from the live deck (array + its DOM node) by id. The viewer can
1379
- // consume a card that isn't the top one, so removal is id-based, not shift().
1380
- function removeNodeByCard(card){
1381
- var id=card&&card.id!=null?String(card.id):null; if(id==null) return;
1382
- for(var i=0;i<nodes.length;i++){
1383
- if(String(nodes[i]._card.id)===id){
1384
- var el=nodes[i]; nodes.splice(i,1);
1385
- if(el&&el.parentNode) el.parentNode.removeChild(el);
1386
- return;
1387
- }
1388
- }
1389
- }
1390
- function decide(dir){
1391
- if(sw.busy||!nodes.length) return;
1392
- sw.busy=true;
1393
- var el=nodes.shift();
1394
- var card=el._card;
1395
- el.classList.remove('is-front');
1396
- el.style.zIndex='30';
1397
- el.style.transform='translateX('+(dir==='fork'?460:-460)+'px) rotate('+(dir==='fork'?15:-15)+'deg)';
1398
- el.style.opacity='0';
1399
- window.setTimeout(function(){ if(el&&el.parentNode) el.parentNode.removeChild(el); },380);
1400
- postDecision(card,dir).then(function(){ sw.busy=false; restack(); refill(); });
1401
- }
1402
- function refill(){
1403
- if(sw.refilling||!sw.productSet||!sw.loaded) return;
1404
- if(nodes.length>=sw.target) return;
1405
- sw.refilling=true;
1406
- fetch(withAccount(swipeRefillEndpoint),{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json','accept':'application/json'},body:'{}'})
1407
- .then(function(r){ return r.json().catch(function(){ return {}; }); })
1408
- .then(function(j){
1409
- sw.refilling=false;
1410
- if(!j||j.ok===false) return; // e.g. need_provider_key — stop quietly
1411
- if(j.filled&&j.record){ makeCard(j.record); restack(); }
1412
- var more=j.filled&&nodes.length<sw.target&&j.reason!=='no_eligible'&&j.reason!=='at_target';
1413
- if(more) refill();
1414
- // Server said it's still working (no card yet, not "done") — the
1415
- // customization keeps running Lambda-side even if THIS request replies
1416
- // late; poll the stack so a finished card appears without a refresh.
1417
- else if(!j.filled&&j.reason!=='no_eligible'&&j.reason!=='at_target') schedulePoll();
1418
- })
1419
- .catch(function(){
1420
- // Timeout / 504 / network drop: customizeOneMore often runs past the
1421
- // gateway's reply window, so the card lands server-side after we gave
1422
- // up here. Poll the stack to pick it up (this is the "504 on first
1423
- // load, works after a manual refresh" case — now it self-heals).
1424
- sw.refilling=false; schedulePoll();
1425
- });
1426
- }
1427
- // ── stack re-poll: recover cards the refill Lambda produced after its HTTP
1428
- // reply timed out. Backs off after a fixed number of empty tries so we never
1429
- // poll forever.
1430
- var pollTimer=null, pollTries=0;
1431
- function schedulePoll(){
1432
- if(pollTimer||pollTries>=12) return;
1433
- if(nodes.length>=sw.target) return;
1434
- pollTimer=window.setTimeout(function(){ pollTimer=null; pollStack(); }, 5000);
1435
- }
1436
- function pollStack(){
1437
- if(nodes.length>=sw.target) return;
1438
- fetch(withAccount(swipeStackEndpoint),{credentials:'same-origin',headers:{'accept':'application/json'}})
1439
- .then(function(r){ return r.ok? r.json(): null; })
1440
- .then(function(j){
1441
- if(!j){ pollTries++; schedulePoll(); return; }
1442
- sw.customizing=Number(j&&j.customizing)||0;
1443
- var stack=(j&&Array.isArray(j.stack))?j.stack:[];
1444
- var added=0;
1445
- stack.forEach(function(card){ if(card&&card.id&&!sw.known[String(card.id)]){ nodes.push(makeCard(card)); added++; } });
1446
- if(added){ pollTries=0; swHideState(); restack(); }
1447
- else { pollTries++; if(!nodes.length) swCaughtUp(); }
1448
- if((sw.customizing>0||nodes.length<sw.target)&&nodes.length<sw.target) schedulePoll();
1449
- })
1450
- .catch(function(){ pollTries++; schedulePoll(); });
1451
- }
1452
- function loadStack(){
1453
- swShowState('<div class="rk-discover-spinner" aria-hidden="true"></div><div>Loading your deck&hellip;</div>');
1454
- fetch(withAccount(swipeStackEndpoint),{credentials:'same-origin',headers:{'accept':'application/json'}})
1455
- .then(function(r){
1456
- if(r.status===401){ var e=new Error('unauth'); e.code=401; throw e; }
1457
- if(!r.ok){ throw new Error("Couldn't load your deck ("+r.status+")."); }
1458
- return r.json();
1459
- })
1460
- .then(function(j){
1461
- sw.loaded=true;
1462
- sw.target=Number(j&&j.target_depth)||5;
1463
- sw.customizing=Number(j&&j.customizing)||0;
1464
- sw.productSet=Boolean(j&&j.product_description_set);
1465
- var stack=(j&&Array.isArray(j.stack))?j.stack:[];
1466
- if(!sw.productSet){
1467
- 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>');
1468
- return;
1469
- }
1470
- nodes=stack.map(makeCard);
1471
- if(nodes.length){ swHideState(); restack(); }
1472
- else { swCaughtUp(); }
1473
- refill();
1474
- })
1475
- .catch(function(err){
1476
- if(err&&err.code===401){
1477
- swShowState('<div class="rk-discover-swipe-state-title">Swipe is for your product</div><div>Log in to auto-generate ads from viral templates and swipe to approve.</div><a class="rk-btn rk-btn-gold rk-btn-sm" href="/login">Log in</a>');
1478
- return;
1479
- }
1480
- swShowState('<div class="rk-discover-swipe-state-title">Couldn\\u2019t load your deck</div><div>'+esc(friendlyError(err))+'</div>','error');
1481
- });
1482
- }
1483
- // ── fullscreen swipe viewer — a TikTok-style vertical scroll over the CURRENT
1484
- // deck. Its bottom bar reject/approves the ACTIVE slide (same postDecision +
1485
- // refill the deck buttons use). While open it OWNS the previews: the deck's
1486
- // iframes are torn down (parkDeck) so audio/CPU never double, and it drives
1487
- // __VF_SWIPE_SOUND_* for the active slide's card. Closing rebuilds the deck.
1488
- var swViewer=(function(){
1489
- var el=root.querySelector('[data-rk-swipe-viewer]');
1490
- if(!el) return null;
1491
- var track=el.querySelector('[data-sv-track]');
1492
- var actionsEl=el.querySelector('[data-sv-actions]');
1493
- var rejectBtn=el.querySelector('[data-sv-reject]');
1494
- var approveBtn=el.querySelector('[data-sv-approve]');
1495
- var open=false, active=-1, sound=false, io=null, busy=false, activeCardId=null;
1496
-
1497
- function updateMuteUi(){ el.classList.toggle('is-muted',!sound); }
1498
- function syncSVSound(){
1499
- try{ window.__VF_SWIPE_SOUND__ = sound===true; window.__VF_SWIPE_SOUND_TOP__ = activeCardId || null; }catch(e){}
1500
- updateMuteUi();
1501
- }
1502
- function updateActionState(){ if(actionsEl) actionsEl.style.display=track.children.length?'':'none'; }
1503
- // Detach every deck iframe so only the viewer plays audio/video.
1504
- function parkDeck(){
1505
- nodes.forEach(function(n){
1506
- var f=n.querySelector('.rk-discover-swipe-frame');
1507
- if(f&&f.parentNode) f.parentNode.removeChild(f);
1508
- n._iframed=false;
1509
- var fb=n.querySelector('.rk-discover-swipe-fallback');
1510
- if(fb){ fb.style.opacity=''; fb.style.pointerEvents=''; }
1511
- });
1512
- }
1513
- function hydrate(slide){
1514
- if(!slide||slide._iframed) return;
1515
- var url=slide.getAttribute('data-preview-url'); if(!url) return;
1516
- var fb=slide.querySelector('.rk-discover-swipe-fallback');
1517
- var frame=document.createElement('iframe');
1518
- frame.className='rk-discover-swipe-frame';
1519
- frame.setAttribute('src',url);
1520
- frame.setAttribute('title', slide.getAttribute('data-title')||'Preview');
1521
- frame.setAttribute('loading','lazy');
1522
- frame.setAttribute('scrolling','no');
1523
- frame.setAttribute('allow','autoplay');
1524
- function reveal(){ if(fb){ fb.style.opacity='0'; fb.style.pointerEvents='none'; } }
1525
- frame.addEventListener('load',function(){ window.setTimeout(reveal,120); });
1526
- window.setTimeout(reveal,2600);
1527
- var stage=slide.querySelector('.rk-swipe-viewer-stage');
1528
- if(stage) stage.insertBefore(frame, stage.firstChild); else slide.insertBefore(frame, slide.firstChild);
1529
- slide._iframed=true;
1530
- }
1531
- function setActive(idx){
1532
- active=idx;
1533
- var kids=track.children;
1534
- for(var i=0;i<kids.length;i++){ if(Math.abs(i-idx)<=1) hydrate(kids[i]); }
1535
- var s=kids[idx];
1536
- activeCardId=s? s.getAttribute('data-card-id'): null;
1537
- syncSVSound();
1538
- }
1539
- function appendSlide(card){
1540
- var slide=document.createElement('section');
1541
- slide.className='rk-discover-viewer-slide rk-swipe-viewer-slide';
1542
- slide.setAttribute('data-card-id', String(card.id));
1543
- var url=card.preview_url||card.previewUrl||'';
1544
- if(url) slide.setAttribute('data-preview-url', url);
1545
- var titleTxt=String(card.title||card.source_title||card.template_id||'Template');
1546
- slide.setAttribute('data-title', titleTxt);
1547
- var title=esc(titleTxt);
1548
- var caption=card.caption?('<div class="rk-discover-viewer-desc">'+esc(card.caption)+'</div>'):'';
1549
- slide.innerHTML='<figure class="rk-discover-viewer-stage rk-swipe-viewer-stage">'
1550
- +'<div class="rk-discover-swipe-fallback"><span>'+title+'</span></div>'
1551
- +'<div class="rk-discover-viewer-shade"></div>'
1552
- +'<div class="rk-discover-viewer-info"><div class="rk-discover-viewer-title">'+title+'</div>'+caption+'</div>'
1553
- +'</figure>';
1554
- track.appendChild(slide);
1555
- if(open&&io) io.observe(slide);
1556
- return slide;
1557
- }
1558
- // Keep the open track in step with the live deck: append a slide for any
1559
- // node that doesn't have one yet (refills). Removals go through decide().
1560
- function sync(){
1561
- if(!open) return;
1562
- var have={};
1563
- for(var i=0;i<track.children.length;i++){ have[track.children[i].getAttribute('data-card-id')]=true; }
1564
- nodes.forEach(function(n){ if(!have[String(n._card.id)]) appendSlide(n._card); });
1565
- updateActionState();
1566
- }
1567
- function step(d){
1568
- if(!open) return;
1569
- var next=Math.max(0,Math.min(track.children.length-1,active+d));
1570
- if(next===active) return;
1571
- track.scrollTo({top:next*track.clientHeight,behavior:'smooth'});
1572
- }
1573
- function decideActive(dir){
1574
- if(busy||active<0) return;
1575
- var slide=track.children[active]; if(!slide) return;
1576
- var id=slide.getAttribute('data-card-id'); var card=null;
1577
- for(var i=0;i<nodes.length;i++){ if(String(nodes[i]._card.id)===String(id)){ card=nodes[i]._card; break; } }
1578
- if(!card) return;
1579
- busy=true; var wasIdx=active;
1580
- var stage=slide.querySelector('.rk-swipe-viewer-stage');
1581
- if(stage){
1582
- stage.style.transition='transform .34s ease, opacity .34s ease';
1583
- stage.style.transform='translateX('+(dir==='fork'?'62%':'-62%')+') rotate('+(dir==='fork'?'9deg':'-9deg')+')';
1584
- stage.style.opacity='0';
1585
- }
1586
- postDecision(card,dir).then(function(){ refill(); });
1587
- removeNodeByCard(card);
1588
- window.setTimeout(function(){
1589
- if(slide&&slide.parentNode) slide.parentNode.removeChild(slide);
1590
- busy=false; updateActionState();
1591
- if(!track.children.length){ close(); return; }
1592
- var nextIdx=Math.min(wasIdx, track.children.length-1);
1593
- track.scrollTop=nextIdx*track.clientHeight;
1594
- setActive(nextIdx);
1595
- },340);
1596
- }
1597
- function openAt(startId){
1598
- if(!nodes.length||open) return;
1599
- deckExpanded=true; parkDeck();
1600
- track.innerHTML=''; sound=sw.soundOn===true; open=true;
1601
- el.hidden=false; document.documentElement.classList.add('rk-noscroll');
1602
- if(io) io.disconnect();
1603
- io=('IntersectionObserver' in window)?new IntersectionObserver(function(entries){
1604
- entries.forEach(function(en){
1605
- if(!en.isIntersecting) return;
1606
- var i=[].indexOf.call(track.children,en.target);
1607
- if(i>=0&&i!==active) setActive(i);
1608
- });
1609
- },{root:track,threshold:0.6}):null;
1610
- nodes.forEach(function(n){ appendSlide(n._card); });
1611
- updateActionState();
1612
- var startIdx=0;
1613
- for(var i=0;i<nodes.length;i++){ if(String(nodes[i]._card.id)===String(startId)){ startIdx=i; break; } }
1614
- track.scrollTop=startIdx*track.clientHeight;
1615
- setActive(startIdx);
1616
- }
1617
- function close(){
1618
- if(!open) return;
1619
- open=false; active=-1; activeCardId=null; busy=false;
1620
- if(io){ io.disconnect(); io=null; }
1621
- track.innerHTML=''; el.hidden=true;
1622
- document.documentElement.classList.remove('rk-noscroll');
1623
- deckExpanded=false; sw.soundOn=sound; // carry the sound pref back to the deck
1624
- restack(); updateSoundBtn();
1625
- }
1626
-
1627
- el.querySelectorAll('[data-sv-close]').forEach(function(b){ b.addEventListener('click',close); });
1628
- var muteBtn=el.querySelector('[data-sv-mute]');
1629
- if(muteBtn) muteBtn.addEventListener('click',function(){ sound=!sound; syncSVSound(); });
1630
- var prevBtn=el.querySelector('[data-sv-prev]'); if(prevBtn) prevBtn.addEventListener('click',function(){ step(-1); });
1631
- var nextBtn=el.querySelector('[data-sv-next]'); if(nextBtn) nextBtn.addEventListener('click',function(){ step(1); });
1632
- if(rejectBtn) rejectBtn.addEventListener('click',function(){ decideActive('skip'); });
1633
- if(approveBtn) approveBtn.addEventListener('click',function(){ decideActive('fork'); });
1634
- // tap the empty gutter (not the phone frame) closes the viewer
1635
- track.addEventListener('click',function(e){
1636
- var t=e.target;
1637
- if(t&&t.classList&&(t.classList.contains('rk-discover-viewer-slide')||t===track)) close();
1638
- });
1639
- document.addEventListener('keydown',function(e){
1640
- if(!open) return;
1641
- if(e.key==='Escape'){ e.preventDefault(); close(); }
1642
- else if(e.key==='ArrowDown'||e.key==='PageDown'){ e.preventDefault(); step(1); }
1643
- else if(e.key==='ArrowUp'||e.key==='PageUp'){ e.preventDefault(); step(-1); }
1644
- else if(e.key==='ArrowRight'){ e.preventDefault(); decideActive('fork'); }
1645
- else if(e.key==='ArrowLeft'){ e.preventDefault(); decideActive('skip'); }
1646
- });
1647
- window.addEventListener('resize',function(){ if(open&&active>=0) track.scrollTop=active*track.clientHeight; });
1648
-
1649
- return { openAt:openAt, close:close, sync:sync, isOpen:function(){ return open; } };
1650
- })();
1651
-
1652
- // Tap the front card → open the fullscreen viewer at that card.
1653
- if(deck) deck.addEventListener('click',function(e){
1654
- if(sw.busy||(swViewer&&swViewer.isOpen())||!nodes.length) return;
1655
- var el=(e.target&&e.target.closest)?e.target.closest('.rk-discover-swipe'):null;
1656
- if(!el||!el._card||el!==nodes[0]) return; // only the front card is interactive
1657
- if(swViewer) swViewer.openAt(el._card.id);
1658
- });
1659
-
1660
- if(skipBtn) skipBtn.addEventListener('click',function(){ decide('skip'); });
1661
- if(forkBtn) forkBtn.addEventListener('click',function(){ decide('fork'); });
1662
- loadStack();
1663
- }
1664
-
1665
- // ════════════════════════════════════════════════════════════════════════
1666
- // CATEGORIES
1667
- // ════════════════════════════════════════════════════════════════════════
1668
- var catsRoot=root.querySelector('[data-rk-cats]');
1669
- if(catsRoot){
1670
- var catsGrid=catsRoot.querySelector('[data-rk-cats-grid]');
1671
- var reveal=catsRoot.querySelector('[data-rk-cat-reveal]');
1672
- var revealGrid=catsRoot.querySelector('[data-rk-reveal-grid]');
1673
- // reveal header ("← All categories" + group title) lives in the toolbar row
1674
- var revealHead=root.querySelector('[data-rk-reveal-head]');
1675
- var revealTitle=root.querySelector('[data-rk-reveal-title]');
1676
- var backBtn=root.querySelector('[data-rk-cat-back]');
1677
- var catsState=catsRoot.querySelector('[data-rk-cats-state]');
1678
- var groups={}; // label -> [templates]
1679
- var revealItems=[]; // the currently-revealed group's templates (viewer list)
1680
-
1681
- function miniHtml(t){
1682
- var url=t.previewUrl||t.preview_url||'';
1683
- if(!url) return '<span class="rk-discover-mini rk-discover-mini-ph"></span>';
1684
- if(isImage(url)) return '<span class="rk-discover-mini"><img src="'+attr(url)+'" alt="" loading="lazy" decoding="async"></span>';
1685
- // First-frame still, NOT autoplay: 4 tiles x 16 category cards would be
1686
- // ~64 playing videos. preload=metadata + #t=0.1 paints frame one only.
1687
- return '<span class="rk-discover-mini"><video src="'+attr(url+'#t=0.1')+'" muted playsinline preload="metadata" tabindex="-1" aria-hidden="true"></video></span>';
1688
- }
1689
- function showReveal(label){
1690
- var items=groups[label]||[];
1691
- if(!items.length) return false;
1692
- revealItems=items;
1693
- if(revealTitle) revealTitle.textContent=label;
1694
- // cards carry data-rk-view=<i> so a plain click opens the shared viewer
1695
- if(revealGrid) revealGrid.innerHTML=items.map(function(t,i){ return posterCardHtml(t,i); }).join('');
1696
- if(catsGrid) catsGrid.hidden=true;
1697
- if(reveal) reveal.hidden=false;
1698
- if(revealHead) revealHead.hidden=false;
1699
- window.scrollTo(0,0);
1700
- return true;
1701
- }
1702
- function hideReveal(){
1703
- if(viewerCtl&&viewerCtl.isOpen()) viewerCtl.close();
1704
- revealItems=[];
1705
- if(reveal) reveal.hidden=true;
1706
- if(revealHead) revealHead.hidden=true;
1707
- if(catsGrid) catsGrid.hidden=false;
1708
- }
1709
- if(backBtn) backBtn.addEventListener('click',hideReveal);
1710
- bindHoverAudio(revealGrid); // reveal cards share posterCardHtml → same hover audio
1711
- bindViewerOpens(revealGrid, function(){ return revealItems; }); // click → shared TikTok viewer
1712
-
1713
- // Intercept category-card clicks: if we have live templates for that group,
1714
- // reveal them in-page; otherwise let the href fall through to a filtered
1715
- // feed (graceful offline / not-yet-seeded fallback).
1716
- catsRoot.querySelectorAll('[data-rk-cat]').forEach(function(a){
1717
- a.addEventListener('click',function(e){
1718
- var label=a.getAttribute('data-rk-cat');
1719
- if(groups[label]&&groups[label].length){ e.preventDefault(); showReveal(label); }
1720
- });
1721
- });
1722
-
1723
- function enrichCards(){
1724
- catsRoot.querySelectorAll('[data-rk-cat]').forEach(function(a){
1725
- var label=a.getAttribute('data-rk-cat');
1726
- var items=groups[label]||[];
1727
- if(!items.length) return;
1728
- var thumbs=a.querySelector('[data-rk-cat-thumbs]');
1729
- if(thumbs) thumbs.innerHTML=items.slice(0,4).map(miniHtml).join('');
1730
- var count=a.querySelector('[data-rk-cat-count]');
1731
- if(count) count.textContent=items.length+' template'+(items.length===1?'':'s');
1732
- });
1733
- }
1734
- function ingestPopular(list){
1735
- groups={};
1736
- (list||[]).forEach(function(t){ var g=(t.popularGroup||'Popular').trim()||'Popular'; (groups[g]=groups[g]||[]).push(t); });
1737
- enrichCards();
1738
- }
1739
- function loadPopular(){
1740
- var url=popularEndpoint;
1741
- try{ var u=new URL(popularEndpoint, window.location.href); if(accountId) u.searchParams.set('account', accountId); url=u.toString(); }catch(e){ url=popularEndpoint; }
1742
- fetch(url,{credentials:'same-origin',headers:{'accept':'application/json'}})
1743
- .then(function(r){ if(!r.ok){ throw new Error("Couldn't load categories ("+r.status+")."); } return r.json(); })
1744
- .then(function(j){
1745
- var list=(j&&Array.isArray(j.templates))?j.templates:[];
1746
- if(list.length){ ingestPopular(list); return; }
1747
- // Local serve without the popular catalog seeded (and no upstream
1748
- // passthrough configured): fall back to the public prod endpoint —
1749
- // it's CORS-open and unauthenticated — so the sandbox still shows
1750
- // real first-frame previews on a fully-local box.
1751
- loadPopularFallback();
1752
- // The static skeleton already shows every real group; the fetch just
1753
- // enriches it, so a fetch that returns nothing is not an error state.
1754
- })
1755
- .catch(function(){
1756
- // Offline / unreachable: try the public prod catalog before settling
1757
- // for the static skeleton grid (real group labels + counts, clicks
1758
- // fall through to the filtered feed).
1759
- loadPopularFallback();
1760
- });
1761
- }
1762
- function loadPopularFallback(){
1763
- if(window.location.hostname==='vidfarm.cc') return; // already prod
1764
- fetch('https://vidfarm.cc/discover/popular',{headers:{'accept':'application/json'}})
1765
- .then(function(r){ return r.ok?r.json():null; })
1766
- .then(function(j){
1767
- var list=(j&&Array.isArray(j.templates))?j.templates:[];
1768
- if(list.length) ingestPopular(list);
1769
- })
1770
- .catch(function(){ /* keep the static skeleton */ });
1771
- }
1772
- loadPopular();
1773
- }
1774
- })();`;
1775
- return reskinDocument({
1776
- title: `vidfarm reskin — Discover (${mode})`,
1777
- description: "Reskinned vidfarm Discover — real Feed / Swipe / Categories, live template posters, warm sunny SaaS.",
1778
- activeSlug: "discover",
1779
- account: { name: input.name, email: input.email },
1780
- pageCss,
1781
- body,
1782
- script,
1783
- // Feed mode hydrates a Zustand store (SSR-seed + stale-while-revalidate);
1784
- // the other modes don't need it.
1785
- moduleScripts: mode === "feed" ? ["/assets/discover-client-app.js"] : undefined
1786
- });
1787
- }
1788
- //# sourceMappingURL=discover-page.js.map