@mevdragon/vidfarm-devcli 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/farmville-saas-ux/SKILL.md +156 -0
- package/.agents/skills/farmville-saas-ux/assets/starter.html +294 -0
- package/.agents/skills/farmville-saas-ux/references/components.md +340 -0
- package/.agents/skills/farmville-saas-ux/references/porting-guide.md +121 -0
- package/.agents/skills/farmville-saas-ux/references/tokens.md +271 -0
- package/.agents/skills/vidfarm-media/SKILL.md +2 -1
- package/.agents/skills/vidfarm-media/references/tts.md +20 -1
- package/dist/src/account-pages-legacy.js +2 -2
- package/dist/src/app.js +441 -77
- package/dist/src/editor-chat.js +2 -2
- package/dist/src/frontend/homepage-client.js +162 -2
- package/dist/src/frontend/homepage-store.js +30 -1
- package/dist/src/frontend/homepage-view.js +111 -4
- package/dist/src/homepage.js +184 -1
- package/dist/src/landing-page.js +367 -0
- package/dist/src/primitive-registry.js +278 -2
- package/dist/src/reskin/agency-page.js +255 -0
- package/dist/src/reskin/calendar-page.js +252 -0
- package/dist/src/reskin/chat-page.js +414 -0
- package/dist/src/reskin/discover-page.js +380 -0
- package/dist/src/reskin/document.js +74 -0
- package/dist/src/reskin/help-page.js +318 -0
- package/dist/src/reskin/index-page.js +62 -0
- package/dist/src/reskin/job-runs-page.js +249 -0
- package/dist/src/reskin/library-page.js +515 -0
- package/dist/src/reskin/login-page.js +225 -0
- package/dist/src/reskin/pricing-page.js +359 -0
- package/dist/src/reskin/settings-page.js +353 -0
- package/dist/src/reskin/theme.js +265 -0
- package/dist/src/services/serverless-records.js +54 -0
- package/dist/src/services/swipe-customize.js +434 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +22 -22
package/dist/src/editor-chat.js
CHANGED
|
@@ -45,7 +45,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
45
45
|
"If the user asks to modify an attached image and the message includes an attachment URL, call POST /api/v1/primitives/images/edit with source_image_url inside payload set to that exact URL and instruction inside payload set to the requested edit.",
|
|
46
46
|
"If the user asks to generate a new AI video from text or reference images, call POST /api/v1/primitives/videos/generate with body { tracer, payload: { prompt, input_references?, duration? } }. AI video duration is seconds; use duration: 4 for a 4-second video and never use duration_ms on /videos/generate. input_references must be direct image asset URLs, not webpage or social post URLs. Use frame_images for exact first/last-frame image-to-video.",
|
|
47
47
|
"If the user asks to render HTML/design into an image, call POST /api/v1/primitives/images/render-html. If the user asks to turn existing media URLs into a slideshow/video, call POST /api/v1/primitives/videos/render-slides. If the user asks to dedupe, camouflage, or apply small zoom/tilt/rotate/filter/tint transforms to an image or video for reuse, call POST /api/v1/primitives/media/dedupe with body { tracer, payload: { source_media_url, media_type?, effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Primitive media utility routes are also allowed directly here, including /videos/trim, /videos/remove-captions, /videos/extract-audio, /videos/probe, /videos/extract-frame, /videos/mute, /videos/replace-audio, /videos/concat, /audio/trim, /audio/probe, /audio/concat, and /audio/normalize. If the user asks to remove burned-in captions, subtitles, or on-screen text from a video, call POST /api/v1/primitives/videos/remove-captions with body { tracer, payload: { source_video_url } } — an async job that returns a durable caption-free MP4 and bills about $0.10 per 30 seconds of source video. Caption removal is capped at ~15 minutes of source and must NEVER be used on long-form video: when the user wants short clips out of a long video (a podcast, stream VOD, webinar, or a YouTube/TikTok/IG/X URL), call POST /clips/scan instead with body { source_url | temp_file_id | s3_key, prompt, ranges?: [\"MM:SS-MM:SS\"], target_duration_sec?, aspect?: \"9:16\"|\"16:9\"|\"4:3\"|\"1:1\", avoid_text?, tracer? } — target_duration_sec is a SOFT range (30 → 20-40s), ranges restrict the hunt to those source windows (big cost saver), aspect crops every clip, and avoid_text prefers text-free scenes via scene SELECTION (never caption removal on the source). The hunt is async (202 + scan_id): poll GET /clips/scan/:scanId, then browse GET /clips/feed?source=<source_video_id> or POST /clips/search. Hunts bill AWS compute only — the AI tagging runs on the user's own saved provider keys. To erase captions from a finished HUNTED clip, pass that short mp4 to /videos/remove-captions afterwards.",
|
|
48
|
-
"SPEECH PRIMITIVES (TTS/STT) are first-class here. VOICEOVER / NARRATION: when the user wants a script, hook, caption, or dub read aloud as AI audio, call POST /api/v1/primitives/audio/speech with body { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? } } — instructions is a free-form voice-STYLE prompt (tone, pacing, accent, emotion, persona; e.g. 'calm warm bedtime narrator' or 'excited sports announcer, fast paced'), and voice picks a provider preset (OpenAI: alloy/ash/coral/...; Gemini: Kore/Puck/Charon/...). When sending a voice you may omit provider — it is inferred from the voice preset; a voice paired with the WRONG provider (e.g. voice 'Kore' with provider 'openai') is rejected with a 400, so never mix families. The finished job returns a durable audio URL — place it on the timeline with editor_action add_layer (kind=audio, src=that URL), typically starting where the narration should begin. TRANSCRIPTION: when the user wants a transcript, subtitles/captions from speech, a translation source, or to know who says what, call POST /api/v1/primitives/audio/transcribe with body { tracer, payload: { source_url, diarize?, language?, prompt? } } — source_url may be a VIDEO or AUDIO URL (video audio is demuxed automatically; pick the fork's source via GET /api/v1/compositions/:forkId/remove-video-captions first when transcribing the current composition). One job returns BOTH formats: a simple subtitle version (plain transcript text + timed SRT cues, stored as transcript.txt/transcript.srt) and an advanced multi-speaker version (speaker-attributed timed segments in output.segments and transcript.json) for multi-narration sources like podcasts and interviews. diarize defaults to true and speaker attribution needs a Gemini key — openai/openrouter keys degrade to a plain single-speaker transcript. For transcript-grounded caption work on THIS fork, the read-only video_context tool may already contain the decompose-time transcript; prefer it before running a new billable-key transcription, and use /audio/transcribe for other URLs, multi-speaker attribution, or SRT output.",
|
|
48
|
+
"SPEECH PRIMITIVES (TTS/STT) are first-class here. VOICEOVER / NARRATION: when the user wants a script, hook, caption, or dub read aloud as AI audio, call POST /api/v1/primitives/audio/speech with body { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? } } — instructions is a free-form voice-STYLE prompt (tone, pacing, accent, emotion, persona; e.g. 'calm warm bedtime narrator' or 'excited sports announcer, fast paced'), and voice picks a provider preset (OpenAI: alloy/ash/coral/...; Gemini: Kore/Puck/Charon/...). When sending a voice you may omit provider — it is inferred from the voice preset; a voice paired with the WRONG provider (e.g. voice 'Kore' with provider 'openai') is rejected with a 400, so never mix families. The finished job returns a durable audio URL — place it on the timeline with editor_action add_layer (kind=audio, src=that URL), typically starting where the narration should begin. TRANSCRIPTION: when the user wants a transcript, subtitles/captions from speech, a translation source, or to know who says what, call POST /api/v1/primitives/audio/transcribe with body { tracer, payload: { source_url, diarize?, language?, prompt? } } — source_url may be a VIDEO or AUDIO URL (video audio is demuxed automatically; pick the fork's source via GET /api/v1/compositions/:forkId/remove-video-captions first when transcribing the current composition). One job returns BOTH formats: a simple subtitle version (plain transcript text + timed SRT cues, stored as transcript.txt/transcript.srt) and an advanced multi-speaker version (speaker-attributed timed segments in output.segments and transcript.json) for multi-narration sources like podcasts and interviews. diarize defaults to true and speaker attribution needs a Gemini key — openai/openrouter keys degrade to a plain single-speaker transcript. For transcript-grounded caption work on THIS fork, the read-only video_context tool may already contain the decompose-time transcript; prefer it before running a new billable-key transcription, and use /audio/transcribe for other URLs, multi-speaker attribution, or SRT output. REGENERATE / REVOICE: when the user wants the SAME speaker to say DIFFERENT words (reword the narration, fix a line, change an offer or CTA, redub in the original voice), call POST /api/v1/primitives/audio/regenerate-speech with body { tracer, payload: { source_url (video or audio; video audio is demuxed), rewrite_instruction (how to reword the original transcript) OR text (the exact new script — one of the two is required), voice?, instructions?, provider?, model?, language?, output_format? } }. One job listens to the source, profiles the speaker's voice, rewords the script, and returns a durable audio URL spoken by the CLOSEST PRESET voice with a style prompt matched to the speaker (result fields: audioUrl, script, original_transcript, voice_profile, voice_match). Voice profiling needs a saved Gemini key — with only openai/openrouter keys the job degrades to transcript-only rewording with a default or explicitly passed voice and voice_match 'none'. IMPORTANT HONESTY RULE: OpenAI and Gemini APIs cannot clone voice identity (preset voices only), so always tell the user the regenerated audio APPROXIMATES the original voice rather than clones it. To swap the regenerated audio into the composition, mute the original span (/videos/mute or volume/muted on the layer) and place the new audio with editor_action add_layer (kind=audio, src=audioUrl).",
|
|
49
49
|
"Brainstorm primitives are also available directly here. If the user explicitly asks for hooks, angles, awareness-stage advice, a cold-start strategy questionnaire, or product-placement opportunities in a video, call the matching brainstorm primitive immediately instead of brainstorming in chat from memory. Use POST /api/v1/primitives/brainstorm/coldstart with body { tracer, payload: { user_message }, webhook_url? } when the user is clueless and needs foundational strategy questions. Use /brainstorm/awareness_stages with body { tracer, payload: { offer_description }, webhook_url? } when they are unsure what type of ads to make. Use /brainstorm/angles with body { tracer, payload: { offer_description, problem_awareness, solution_awareness }, webhook_url? } when they want persuasive ad angles; problem_awareness must be problem_unaware or problem_aware, and solution_awareness must be solution_unaware or solution_aware. Use /brainstorm/hooks with body { tracer, payload: { offer_description }, webhook_url? } when they are stuck on openings and want many hooks.",
|
|
50
50
|
"Product placement is a first-class brainstorm primitive, just like angles and hooks. When the user asks to identify product-placement opportunities in a video, or to repurpose a video into a native ad for their product, prefer POST /api/v1/primitives/brainstorm/product_placement with body { tracer, payload: { source_video_url, offer_description }, webhook_url? }. source_video_url is the exact video to analyze — for the current composition, first call GET /api/v1/compositions/:forkId/remove-video-captions to pick the correct source (the ORIGINAL uploaded video vs the DECOMPOSED, caption-free video), then pass that URL. offer_description is the user's product/offer. This primitive watches the video and returns timestamped, native placement opportunities. It prefers a Gemini key for video understanding.",
|
|
51
51
|
"BEFORE calling /brainstorm/product_placement for the CURRENT composition, first call the product_placement_context tool (or GET /api/v1/compositions/:forkId/product-placement.json). Decompose already scouted product-AGNOSTIC placement surfaces for this fork — real, grounded moments (timestamp, surface_type, surface_note, best_for, why_it_works). Use those surfaces to ground your answer and to seed the primitive: fold the strongest surfaces into offer_description (for example append 'Known native placement surfaces: 0:04 empty desk prop; 0:12 phone screen swap') so the product-specific pass builds on grounded moments instead of re-watching blind. If the user just wants a quick conversational take on where their product could go, the surfaces from product_placement_context are often enough to answer directly without the billable primitive.",
|
|
@@ -469,7 +469,7 @@ export const EDITOR_CHAT_SKILL_PACKS = [
|
|
|
469
469
|
{ name: "hyperframes-animation", summary: "motion rules catalog, scene blueprints, transition doctrine, runtime adapters" },
|
|
470
470
|
{ name: "hyperframes-keyframes", summary: "seek-safe keyframe/CSS animation patterns" },
|
|
471
471
|
{ name: "hyperframes-creative", summary: "design specs, palettes, typography, house style, narration craft" },
|
|
472
|
-
{ name: "vidfarm-media", summary: "narration TTS/STT, BGM/SFX/media resolution through vidfarm primitives" },
|
|
472
|
+
{ name: "vidfarm-media", summary: "narration TTS/STT, voice-matched speech regeneration (same speaker, new words), BGM/SFX/media resolution through vidfarm primitives" },
|
|
473
473
|
{ name: "embedded-captions", summary: "caption identity catalog + subject-occlusion caption pipeline" },
|
|
474
474
|
{ name: "talking-head-recut", summary: "timed graphic overlay cards on existing footage" },
|
|
475
475
|
{ name: "product-launch-video", summary: "product/launch promo workflow (time-coded shot sequences)" },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import "./sentry.js";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { hydrateRoot } from "react-dom/client";
|
|
5
5
|
import * as Sentry from "@sentry/react";
|
|
6
6
|
import { useStore } from "zustand";
|
|
@@ -57,6 +57,21 @@ function filterTemplates(input) {
|
|
|
57
57
|
return matchesSearch && matchesBookmarks;
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
|
+
// The swipe API serializes snake_case; the store/view use camelCase SwipeCard.
|
|
61
|
+
function mapSwipeCard(raw) {
|
|
62
|
+
return {
|
|
63
|
+
id: String(raw?.id ?? ""),
|
|
64
|
+
templateId: String(raw?.template_id ?? ""),
|
|
65
|
+
forkId: raw?.fork_id ?? null,
|
|
66
|
+
sourceTitle: raw?.source_title ?? null,
|
|
67
|
+
title: String(raw?.title ?? raw?.source_title ?? "Untitled"),
|
|
68
|
+
caption: String(raw?.caption ?? ""),
|
|
69
|
+
pinnedComment: raw?.pinned_comment ?? null,
|
|
70
|
+
previewUrl: raw?.preview_url ?? null,
|
|
71
|
+
durationSeconds: typeof raw?.duration_seconds === "number" ? raw.duration_seconds : null,
|
|
72
|
+
status: String(raw?.status ?? "ready")
|
|
73
|
+
};
|
|
74
|
+
}
|
|
60
75
|
function HomepageClientApp({ boot }) {
|
|
61
76
|
const store = useMemo(() => createHomepageStore(boot), [boot]);
|
|
62
77
|
const templates = useStore(store, (state) => state.templates);
|
|
@@ -69,6 +84,15 @@ function HomepageClientApp({ boot }) {
|
|
|
69
84
|
const popularTemplates = useStore(store, (state) => state.popularTemplates);
|
|
70
85
|
const popularLoading = useStore(store, (state) => state.popularLoading);
|
|
71
86
|
const popularError = useStore(store, (state) => state.popularError);
|
|
87
|
+
const swipeStack = useStore(store, (state) => state.swipeStack);
|
|
88
|
+
const swipeLoading = useStore(store, (state) => state.swipeLoading);
|
|
89
|
+
const swipeLoaded = useStore(store, (state) => state.swipeLoaded);
|
|
90
|
+
const swipeError = useStore(store, (state) => state.swipeError);
|
|
91
|
+
const swipeCustomizing = useStore(store, (state) => state.swipeCustomizing);
|
|
92
|
+
const swipeTargetDepth = useStore(store, (state) => state.swipeTargetDepth);
|
|
93
|
+
const swipeProductSet = useStore(store, (state) => state.swipeProductSet);
|
|
94
|
+
const swipeRefilling = useStore(store, (state) => state.swipeRefilling);
|
|
95
|
+
const swipeFillingRef = useRef(false);
|
|
72
96
|
const [bookmarksReady, setBookmarksReady] = useState(false);
|
|
73
97
|
const visibleTemplates = useMemo(() => filterTemplates({ templates, searchQuery, bookmarksOnly, bookmarks }), [templates, searchQuery, bookmarksOnly, bookmarks]);
|
|
74
98
|
useEffect(() => {
|
|
@@ -170,6 +194,142 @@ function HomepageClientApp({ boot }) {
|
|
|
170
194
|
}, 8000);
|
|
171
195
|
return () => window.clearInterval(timer);
|
|
172
196
|
}, [hasProcessingPopular, refreshPopular]);
|
|
197
|
+
// Swipe deck: the customer's own product, auto-recut from decomposed templates.
|
|
198
|
+
const refreshSwipeStack = useCallback(async (options) => {
|
|
199
|
+
if (options?.showLoading) {
|
|
200
|
+
store.getState().setSwipeLoading(true);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const response = await fetch("/discover/swipe/stack", {
|
|
204
|
+
credentials: "same-origin",
|
|
205
|
+
cache: "no-store",
|
|
206
|
+
headers: { Accept: "application/json" }
|
|
207
|
+
});
|
|
208
|
+
const payload = await response.json().catch(() => ({}));
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
throw new Error(typeof payload.error === "string" && payload.error ? payload.error : "Unable to load your swipe deck.");
|
|
211
|
+
}
|
|
212
|
+
const stack = Array.isArray(payload.stack) ? payload.stack.map(mapSwipeCard) : [];
|
|
213
|
+
store.getState().setSwipeStack(stack, {
|
|
214
|
+
customizing: typeof payload.customizing === "number" ? payload.customizing : 0,
|
|
215
|
+
targetDepth: typeof payload.target_depth === "number" ? payload.target_depth : undefined,
|
|
216
|
+
productSet: typeof payload.product_description_set === "boolean" ? payload.product_description_set : undefined
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (fetchError) {
|
|
220
|
+
if (options?.showLoading) {
|
|
221
|
+
store.getState().setSwipeError(fetchError instanceof Error ? fetchError.message : "Unable to load your swipe deck.");
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
debugError("homepage.swipe_refresh_failed", { message: fetchError instanceof Error ? fetchError.message : String(fetchError) });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}, [store]);
|
|
228
|
+
// Progressively customize until the buffer hits target (server-capped), one
|
|
229
|
+
// per request. Each finished variant streams into the deck as it lands.
|
|
230
|
+
const fillSwipeDeck = useCallback(async () => {
|
|
231
|
+
if (swipeFillingRef.current)
|
|
232
|
+
return;
|
|
233
|
+
swipeFillingRef.current = true;
|
|
234
|
+
store.getState().setSwipeRefilling(true);
|
|
235
|
+
try {
|
|
236
|
+
for (let i = 0; i < 8; i += 1) {
|
|
237
|
+
const response = await fetch("/discover/swipe/refill", {
|
|
238
|
+
method: "POST",
|
|
239
|
+
credentials: "same-origin",
|
|
240
|
+
headers: { Accept: "application/json" }
|
|
241
|
+
});
|
|
242
|
+
const payload = await response.json().catch(() => ({}));
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
if (payload?.need_product_description) {
|
|
245
|
+
store.getState().setSwipeMeta({ productSet: false });
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
store.getState().setSwipeError(typeof payload.error === "string" && payload.error ? payload.error : "Customization failed.");
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
if (payload.filled && payload.record) {
|
|
253
|
+
store.getState().appendSwipeCard(mapSwipeCard(payload.record));
|
|
254
|
+
}
|
|
255
|
+
if (!payload.filled) {
|
|
256
|
+
break; // at_target or no_eligible
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch (fillError) {
|
|
261
|
+
debugError("homepage.swipe_refill_failed", { message: fillError instanceof Error ? fillError.message : String(fillError) });
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
swipeFillingRef.current = false;
|
|
265
|
+
store.getState().setSwipeRefilling(false);
|
|
266
|
+
// Re-sync the customizing/buffered counts after filling settles.
|
|
267
|
+
void refreshSwipeStack();
|
|
268
|
+
}
|
|
269
|
+
}, [store, refreshSwipeStack]);
|
|
270
|
+
// First open of Swipe → load the current deck, then top it up.
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (view !== "swipe" || swipeLoaded) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
void (async () => {
|
|
276
|
+
await refreshSwipeStack({ showLoading: true });
|
|
277
|
+
const state = store.getState();
|
|
278
|
+
if (state.swipeProductSet && state.swipeStack.length < state.swipeTargetDepth) {
|
|
279
|
+
void fillSwipeDeck();
|
|
280
|
+
}
|
|
281
|
+
})();
|
|
282
|
+
}, [view, swipeLoaded, refreshSwipeStack, fillSwipeDeck, store]);
|
|
283
|
+
const handleSwipeApprove = useCallback(async (id) => {
|
|
284
|
+
try {
|
|
285
|
+
const response = await fetch(`/discover/swipe/${encodeURIComponent(id)}/approve`, {
|
|
286
|
+
method: "POST",
|
|
287
|
+
credentials: "same-origin",
|
|
288
|
+
headers: { Accept: "application/json" }
|
|
289
|
+
});
|
|
290
|
+
const payload = await response.json().catch(() => ({}));
|
|
291
|
+
if (!response.ok) {
|
|
292
|
+
// The server retired this card (built by an older customize pipeline) —
|
|
293
|
+
// drop it from the deck and pull a fresh one instead of jamming.
|
|
294
|
+
if (payload && payload.superseded) {
|
|
295
|
+
store.getState().removeSwipeCard(id);
|
|
296
|
+
void fillSwipeDeck();
|
|
297
|
+
return { ok: true };
|
|
298
|
+
}
|
|
299
|
+
return { ok: false, error: typeof payload.error === "string" && payload.error ? payload.error : "Unable to approve." };
|
|
300
|
+
}
|
|
301
|
+
store.getState().removeSwipeCard(id);
|
|
302
|
+
void fillSwipeDeck(); // +1 fresh at the tail
|
|
303
|
+
return { ok: true };
|
|
304
|
+
}
|
|
305
|
+
catch (approveError) {
|
|
306
|
+
return { ok: false, error: approveError instanceof Error ? approveError.message : "Unable to approve." };
|
|
307
|
+
}
|
|
308
|
+
}, [store, fillSwipeDeck]);
|
|
309
|
+
const handleSwipeReject = useCallback(async (id) => {
|
|
310
|
+
try {
|
|
311
|
+
const response = await fetch(`/discover/swipe/${encodeURIComponent(id)}/reject`, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
credentials: "same-origin",
|
|
314
|
+
headers: { Accept: "application/json" }
|
|
315
|
+
});
|
|
316
|
+
const payload = await response.json().catch(() => ({}));
|
|
317
|
+
if (!response.ok) {
|
|
318
|
+
if (payload && payload.superseded) {
|
|
319
|
+
store.getState().removeSwipeCard(id);
|
|
320
|
+
void fillSwipeDeck();
|
|
321
|
+
return { ok: true };
|
|
322
|
+
}
|
|
323
|
+
return { ok: false, error: typeof payload.error === "string" && payload.error ? payload.error : "Unable to reject." };
|
|
324
|
+
}
|
|
325
|
+
store.getState().removeSwipeCard(id);
|
|
326
|
+
void fillSwipeDeck(); // +1 fresh at the tail
|
|
327
|
+
return { ok: true };
|
|
328
|
+
}
|
|
329
|
+
catch (rejectError) {
|
|
330
|
+
return { ok: false, error: rejectError instanceof Error ? rejectError.message : "Unable to reject." };
|
|
331
|
+
}
|
|
332
|
+
}, [store, fillSwipeDeck]);
|
|
173
333
|
const handleDeleteTemplate = useCallback(async (templateId) => {
|
|
174
334
|
try {
|
|
175
335
|
const response = await fetch(`/discover/templates/${encodeURIComponent(templateId)}`, {
|
|
@@ -268,7 +428,7 @@ function HomepageClientApp({ boot }) {
|
|
|
268
428
|
return { ok: false, error: submitError instanceof Error ? submitError.message : "Unable to add that video." };
|
|
269
429
|
}
|
|
270
430
|
}, [refreshFeed]);
|
|
271
|
-
return (_jsx(HomepageShell, { account: boot.account, templates: templates, visibleTemplates: visibleTemplates, searchQuery: searchQuery, bookmarksOnly: bookmarksOnly, bookmarks: bookmarks, loading: loading, error: error, view: view, popularTemplates: popularTemplates, popularLoading: popularLoading, popularError: popularError, skillEndpointPrefix: boot.skillEndpointPrefix, onSearchQueryChange: (value) => store.getState().setSearchQuery(value), onBookmarksOnlyChange: (value) => store.getState().setBookmarksOnly(value), onViewChange: (value) => store.getState().setView(value), onToggleBookmark: (templateId) => store.getState().toggleBookmark(templateId), onAddTemplate: handleAddTemplate, onDeleteTemplate: handleDeleteTemplate }));
|
|
431
|
+
return (_jsx(HomepageShell, { account: boot.account, templates: templates, visibleTemplates: visibleTemplates, searchQuery: searchQuery, bookmarksOnly: bookmarksOnly, bookmarks: bookmarks, loading: loading, error: error, view: view, popularTemplates: popularTemplates, popularLoading: popularLoading, popularError: popularError, swipeStack: swipeStack, swipeLoading: swipeLoading, swipeLoaded: swipeLoaded, swipeError: swipeError, swipeCustomizing: swipeCustomizing, swipeTargetDepth: swipeTargetDepth, swipeProductSet: swipeProductSet, swipeRefilling: swipeRefilling, skillEndpointPrefix: boot.skillEndpointPrefix, onSearchQueryChange: (value) => store.getState().setSearchQuery(value), onBookmarksOnlyChange: (value) => store.getState().setBookmarksOnly(value), onViewChange: (value) => store.getState().setView(value), onToggleBookmark: (templateId) => store.getState().toggleBookmark(templateId), onAddTemplate: handleAddTemplate, onDeleteTemplate: handleDeleteTemplate, onSwipeApprove: handleSwipeApprove, onSwipeReject: handleSwipeReject }));
|
|
272
432
|
}
|
|
273
433
|
const boot = readBoot();
|
|
274
434
|
const root = document.getElementById("homepage-root");
|
|
@@ -12,6 +12,14 @@ export function createHomepageStore(boot) {
|
|
|
12
12
|
popularLoading: false,
|
|
13
13
|
popularLoaded: false,
|
|
14
14
|
popularError: null,
|
|
15
|
+
swipeStack: [],
|
|
16
|
+
swipeLoading: false,
|
|
17
|
+
swipeLoaded: false,
|
|
18
|
+
swipeError: null,
|
|
19
|
+
swipeCustomizing: 0,
|
|
20
|
+
swipeTargetDepth: 5,
|
|
21
|
+
swipeProductSet: true,
|
|
22
|
+
swipeRefilling: false,
|
|
15
23
|
setTemplates: (templates) => set({ templates, loading: false, error: null }),
|
|
16
24
|
setSearchQuery: (searchQuery) => set({ searchQuery }),
|
|
17
25
|
setBookmarksOnly: (bookmarksOnly) => set({ bookmarksOnly }),
|
|
@@ -31,7 +39,28 @@ export function createHomepageStore(boot) {
|
|
|
31
39
|
setView: (view) => set({ view }),
|
|
32
40
|
setPopularTemplates: (popularTemplates) => set({ popularTemplates, popularLoading: false, popularLoaded: true, popularError: null }),
|
|
33
41
|
setPopularLoading: (popularLoading) => set({ popularLoading }),
|
|
34
|
-
setPopularError: (popularError) => set({ popularError, popularLoading: false })
|
|
42
|
+
setPopularError: (popularError) => set({ popularError, popularLoading: false }),
|
|
43
|
+
setSwipeLoading: (swipeLoading) => set({ swipeLoading }),
|
|
44
|
+
setSwipeError: (swipeError) => set({ swipeError, swipeLoading: false }),
|
|
45
|
+
setSwipeStack: (swipeStack, meta) => set({
|
|
46
|
+
swipeStack,
|
|
47
|
+
swipeLoading: false,
|
|
48
|
+
swipeLoaded: true,
|
|
49
|
+
swipeError: null,
|
|
50
|
+
...(meta?.customizing !== undefined ? { swipeCustomizing: meta.customizing } : {}),
|
|
51
|
+
...(meta?.targetDepth !== undefined ? { swipeTargetDepth: meta.targetDepth } : {}),
|
|
52
|
+
...(meta?.productSet !== undefined ? { swipeProductSet: meta.productSet } : {})
|
|
53
|
+
}),
|
|
54
|
+
setSwipeMeta: (meta) => set({
|
|
55
|
+
...(meta.customizing !== undefined ? { swipeCustomizing: meta.customizing } : {}),
|
|
56
|
+
...(meta.targetDepth !== undefined ? { swipeTargetDepth: meta.targetDepth } : {}),
|
|
57
|
+
...(meta.productSet !== undefined ? { swipeProductSet: meta.productSet } : {})
|
|
58
|
+
}),
|
|
59
|
+
appendSwipeCard: (card) => set((state) => (state.swipeStack.some((existing) => existing.id === card.id)
|
|
60
|
+
? {}
|
|
61
|
+
: { swipeStack: [...state.swipeStack, card] })),
|
|
62
|
+
removeSwipeCard: (id) => set((state) => ({ swipeStack: state.swipeStack.filter((card) => card.id !== id) })),
|
|
63
|
+
setSwipeRefilling: (swipeRefilling) => set({ swipeRefilling })
|
|
35
64
|
}));
|
|
36
65
|
}
|
|
37
66
|
//# sourceMappingURL=homepage-store.js.map
|
|
@@ -485,7 +485,7 @@ function AddTemplateModal(input) {
|
|
|
485
485
|
// grouped under their editorial category headings and ordered by the server.
|
|
486
486
|
function PopularTemplates(input) {
|
|
487
487
|
if (input.loading) {
|
|
488
|
-
return _jsx("ul", { className: "discover-list", children: _jsx("li", { className: "empty-state discover-loading-card", children: "Loading
|
|
488
|
+
return _jsx("ul", { className: "discover-list", children: _jsx("li", { className: "empty-state discover-loading-card", children: "Loading categories\u2026" }) });
|
|
489
489
|
}
|
|
490
490
|
if (input.error) {
|
|
491
491
|
return _jsx("ul", { className: "discover-list", children: _jsx("li", { className: "empty-state discover-loading-card", children: input.error }) });
|
|
@@ -495,7 +495,7 @@ function PopularTemplates(input) {
|
|
|
495
495
|
? input.templates.filter((template) => `${template.title} ${template.templateId} ${template.popularGroup ?? ""}`.toLowerCase().includes(query))
|
|
496
496
|
: input.templates;
|
|
497
497
|
if (filtered.length === 0) {
|
|
498
|
-
return (_jsx("ul", { className: "discover-list", children: _jsx("li", { className: "empty-state discover-loading-card", children: query ? "No
|
|
498
|
+
return (_jsx("ul", { className: "discover-list", children: _jsx("li", { className: "empty-state discover-loading-card", children: query ? "No categories match your search." : "No categories yet." }) }));
|
|
499
499
|
}
|
|
500
500
|
// Group by popularGroup, preserving the server's rank order (already sorted).
|
|
501
501
|
const groups = [];
|
|
@@ -510,6 +510,112 @@ function PopularTemplates(input) {
|
|
|
510
510
|
}
|
|
511
511
|
return (_jsx("div", { className: "discover-popular", children: groups.map((group) => (_jsxs("section", { className: "discover-popular-group", children: [_jsx("h3", { className: "discover-popular-group-label", children: group.label }), _jsx("ul", { className: "discover-list", children: group.items.map((template) => (_jsx(TemplateCard, { template: template, isBookmarked: input.bookmarks.has(template.templateId), onToggleBookmark: input.onToggleBookmark, onOpenPreview: input.onOpenPreview, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.accountUserId }, template.templateId))) })] }, group.label))) }));
|
|
512
512
|
}
|
|
513
|
+
// Tinder-style deck of the customer's own product, auto-recut from decomposed
|
|
514
|
+
// templates. Previews are LIVE HyperFrames compositions in an iframe (no baked
|
|
515
|
+
// MP4) — only the top two cards mount an iframe (the concurrent-decoder ceiling);
|
|
516
|
+
// the rest are static placeholders. Drag or tap the buttons to approve/reject.
|
|
517
|
+
function SwipeDeck(input) {
|
|
518
|
+
const [drag, setDrag] = useState(null);
|
|
519
|
+
const [flyOut, setFlyOut] = useState(null);
|
|
520
|
+
const [busy, setBusy] = useState(false);
|
|
521
|
+
const [actionError, setActionError] = useState(null);
|
|
522
|
+
// Deck previews start muted (the only autoplay browsers allow); this toggle
|
|
523
|
+
// is the user gesture that unlocks audio. The same-origin preview iframes
|
|
524
|
+
// poll these window flags (see injectSwipePreviewFit) — the top-card id gate
|
|
525
|
+
// keeps the preloaded second iframe silent so audio never doubles.
|
|
526
|
+
const [soundOn, setSoundOn] = useState(false);
|
|
527
|
+
const topCardId = input.stack.length ? input.stack[0].id : null;
|
|
528
|
+
useEffect(() => {
|
|
529
|
+
window.__VF_SWIPE_SOUND__ = soundOn;
|
|
530
|
+
window.__VF_SWIPE_SOUND_TOP__ = topCardId;
|
|
531
|
+
return () => {
|
|
532
|
+
window.__VF_SWIPE_SOUND__ = false;
|
|
533
|
+
window.__VF_SWIPE_SOUND_TOP__ = null;
|
|
534
|
+
};
|
|
535
|
+
}, [soundOn, topCardId]);
|
|
536
|
+
const startRef = useRef(null);
|
|
537
|
+
const THRESHOLD = 110;
|
|
538
|
+
const decide = (card, dir) => {
|
|
539
|
+
if (!card || busy)
|
|
540
|
+
return;
|
|
541
|
+
setBusy(true);
|
|
542
|
+
setActionError(null);
|
|
543
|
+
setFlyOut({ id: card.id, dir });
|
|
544
|
+
// Let the fly-out animation play, then commit (parent removes the card and
|
|
545
|
+
// refills the deck).
|
|
546
|
+
window.setTimeout(async () => {
|
|
547
|
+
const action = dir === "right" ? input.onApprove : input.onReject;
|
|
548
|
+
try {
|
|
549
|
+
const result = await action?.(card.id);
|
|
550
|
+
if (result && !result.ok)
|
|
551
|
+
setActionError(result.error ?? "Action failed.");
|
|
552
|
+
}
|
|
553
|
+
finally {
|
|
554
|
+
setFlyOut(null);
|
|
555
|
+
setDrag(null);
|
|
556
|
+
startRef.current = null;
|
|
557
|
+
setBusy(false);
|
|
558
|
+
}
|
|
559
|
+
}, 260);
|
|
560
|
+
};
|
|
561
|
+
if (!input.isLoggedIn) {
|
|
562
|
+
return (_jsxs("div", { className: "swipe-empty", children: [_jsx("p", { className: "swipe-empty-title", children: "Swipe is for your product" }), _jsx("p", { className: "swipe-empty-sub", children: "Log in to auto-generate ads from viral templates and swipe to approve." }), _jsx("a", { className: "swipe-empty-cta", href: "/login", children: "Log in" })] }));
|
|
563
|
+
}
|
|
564
|
+
if (!input.productSet) {
|
|
565
|
+
return (_jsxs("div", { className: "swipe-empty", children: [_jsx("p", { className: "swipe-empty-title", children: "Tell us about your product" }), _jsx("p", { className: "swipe-empty-sub", children: "Swipe auto-recuts viral templates into ads for your product. Add a product / offer description in Settings to start the deck." }), _jsx("a", { className: "swipe-empty-cta", href: "/settings", children: "Open Settings" })] }));
|
|
566
|
+
}
|
|
567
|
+
if (input.error) {
|
|
568
|
+
return (_jsxs("div", { className: "swipe-empty", children: [_jsx("p", { className: "swipe-empty-title", children: "Couldn't load your deck" }), _jsx("p", { className: "swipe-empty-sub", children: input.error })] }));
|
|
569
|
+
}
|
|
570
|
+
if (input.stack.length === 0) {
|
|
571
|
+
if (input.loading || input.customizing > 0 || input.refilling) {
|
|
572
|
+
return (_jsxs("div", { className: "swipe-empty", children: [_jsx("div", { className: "swipe-spinner", "aria-hidden": "true" }), _jsx("p", { className: "swipe-empty-title", children: "Customizing fresh clips\u2026" }), _jsx("p", { className: "swipe-empty-sub", children: "Recutting viral templates into ads for your product." })] }));
|
|
573
|
+
}
|
|
574
|
+
return (_jsxs("div", { className: "swipe-empty", children: [_jsx("p", { className: "swipe-empty-title", children: "You're all caught up" }), _jsx("p", { className: "swipe-empty-sub", children: "No more templates to customize right now. Check back as new ones are added." })] }));
|
|
575
|
+
}
|
|
576
|
+
const visible = input.stack.slice(0, 4);
|
|
577
|
+
const top = visible[0];
|
|
578
|
+
return (_jsxs("div", { className: "swipe-deck-wrap", children: [_jsxs("div", { className: "swipe-deck-row", children: [_jsx("button", { type: "button", className: "swipe-action swipe-action-reject", disabled: busy, onClick: () => decide(top, "left"), "aria-label": "Reject", children: "\u2715" }), _jsxs("div", { className: "swipe-deck", children: [_jsx("button", { type: "button", className: "swipe-sound", onClick: () => setSoundOn((value) => !value), "aria-label": soundOn ? "Mute preview" : "Play preview with sound", "aria-pressed": soundOn, title: soundOn ? "Mute" : "Sound on", children: soundOn ? "🔊" : "🔇" }), visible.map((card, index) => {
|
|
579
|
+
const isTop = index === 0;
|
|
580
|
+
const isFlying = flyOut?.id === card.id;
|
|
581
|
+
const isDragging = isTop && drag?.id === card.id;
|
|
582
|
+
const useIframe = index <= 1 && Boolean(card.previewUrl);
|
|
583
|
+
const dx = isDragging ? drag.dx : 0;
|
|
584
|
+
const dy = isDragging ? drag.dy : 0;
|
|
585
|
+
let transform;
|
|
586
|
+
let transition;
|
|
587
|
+
if (isFlying) {
|
|
588
|
+
transform = `translate(${flyOut.dir === "right" ? 130 : -130}%, ${dy}px) rotate(${flyOut.dir === "right" ? 22 : -22}deg)`;
|
|
589
|
+
transition = "transform 0.26s ease-in";
|
|
590
|
+
}
|
|
591
|
+
else if (isTop) {
|
|
592
|
+
transform = `translate(${dx}px, ${dy * 0.25}px) rotate(${dx / 22}deg)`;
|
|
593
|
+
transition = isDragging ? "none" : "transform 0.2s ease";
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
transform = `translateY(${index * 12}px) scale(${1 - index * 0.04})`;
|
|
597
|
+
transition = "transform 0.2s ease";
|
|
598
|
+
}
|
|
599
|
+
const hint = isDragging ? (drag.dx > 40 ? "approve" : drag.dx < -40 ? "reject" : null) : null;
|
|
600
|
+
return (_jsxs("div", { className: "swipe-card", style: { transform, transition, zIndex: 100 - index }, onPointerDown: isTop && !busy ? (event) => {
|
|
601
|
+
startRef.current = { x: event.clientX, y: event.clientY };
|
|
602
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
603
|
+
} : undefined, onPointerMove: isTop && !busy ? (event) => {
|
|
604
|
+
if (!startRef.current)
|
|
605
|
+
return;
|
|
606
|
+
setDrag({ id: card.id, dx: event.clientX - startRef.current.x, dy: event.clientY - startRef.current.y });
|
|
607
|
+
} : undefined, onPointerUp: isTop && !busy ? () => {
|
|
608
|
+
const moved = drag?.id === card.id ? drag.dx : 0;
|
|
609
|
+
startRef.current = null;
|
|
610
|
+
if (moved > THRESHOLD)
|
|
611
|
+
decide(card, "right");
|
|
612
|
+
else if (moved < -THRESHOLD)
|
|
613
|
+
decide(card, "left");
|
|
614
|
+
else
|
|
615
|
+
setDrag(null);
|
|
616
|
+
} : undefined, onPointerCancel: isTop ? () => { startRef.current = null; setDrag(null); } : undefined, children: [useIframe ? (_jsx("iframe", { className: "swipe-card-preview", src: card.previewUrl ?? undefined, title: card.title, loading: "lazy", scrolling: "no", allow: "autoplay", style: { pointerEvents: "none" } })) : (_jsx("div", { className: "swipe-card-fallback", children: _jsx("span", { children: card.title }) })), _jsxs("div", { className: "swipe-card-overlay", children: [_jsx("div", { className: "swipe-card-title", children: card.title }), card.caption ? _jsx("div", { className: "swipe-card-caption", children: card.caption }) : null] }), hint === "approve" ? _jsx("div", { className: "swipe-stamp swipe-stamp-yes", children: "Approve" }) : null, hint === "reject" ? _jsx("div", { className: "swipe-stamp swipe-stamp-no", children: "Reject" }) : null] }, card.id));
|
|
617
|
+
})] }), _jsx("button", { type: "button", className: "swipe-action swipe-action-approve", disabled: busy, onClick: () => decide(top, "right"), "aria-label": "Approve", children: "\u2713" })] }), _jsx("div", { className: "swipe-status", children: input.customizing > 0 || input.refilling ? "Customizing more…" : `${input.stack.length} ready` }), actionError ? _jsx("div", { className: "swipe-action-error", children: actionError }) : null] }));
|
|
618
|
+
}
|
|
513
619
|
export function HomepageShell(input) {
|
|
514
620
|
const showEmpty = !input.loading && !input.error && input.templates.length > 0 && input.visibleTemplates.length === 0;
|
|
515
621
|
const [previewTemplate, setPreviewTemplate] = useState(null);
|
|
@@ -518,9 +624,10 @@ export function HomepageShell(input) {
|
|
|
518
624
|
// curated static picks fetched from /discover/popular. View state lives in the
|
|
519
625
|
// store so the client can lazily load the Popular set on first open.
|
|
520
626
|
const isPopular = input.view === "popular";
|
|
627
|
+
const isSwipe = input.view === "swipe";
|
|
521
628
|
const setView = (value) => input.onViewChange?.(value);
|
|
522
629
|
const searchPlaceholder = isPopular
|
|
523
|
-
? "Search
|
|
630
|
+
? "Search categories"
|
|
524
631
|
: input.loading || input.error
|
|
525
632
|
? "Search Templates"
|
|
526
633
|
: `Search ${input.visibleTemplates.length} Template${input.visibleTemplates.length === 1 ? "" : "s"}`;
|
|
@@ -531,7 +638,7 @@ export function HomepageShell(input) {
|
|
|
531
638
|
// disabled textarea and a "Log in to chat" CTA in the same slot.
|
|
532
639
|
const hasChatRail = true;
|
|
533
640
|
const isChatEnabled = input.account.isLoggedIn;
|
|
534
|
-
return (_jsxs("section", { className: `frame discover-frame${hasChatRail ? " has-chat-rail" : ""}`, children: [_jsxs("header", { className: "topbar", children: [_jsx(BrandLockup, { account: input.account }), _jsx("div", { className: "topbar-actions", children: _jsx(PrimaryNav, { account: input.account }) })] }), _jsx("div", { className: "frame-body", children: _jsxs("section", { className: "discover-shell", children: [_jsxs("div", { className: "discover-filter-bar", role: "toolbar", "aria-label": "Composition filters", children: [_jsxs("div", { className: "view-toggle discover-view-toggle", role: "tablist", "aria-label": "Discover view", children: [_jsx("button", { type: "button", className: `view-toggle-tab${!isPopular ? " is-active" : ""}`, role: "tab", "aria-selected": !isPopular, onClick: () => setView("feed"), children: "Feed" }), _jsx("button", { type: "button", className: `view-toggle-tab${isPopular ? " is-active" : ""}`, role: "tab", "aria-selected": isPopular, onClick: () => setView("popular"), children: "
|
|
641
|
+
return (_jsxs("section", { className: `frame discover-frame${hasChatRail ? " has-chat-rail" : ""}`, children: [_jsxs("header", { className: "topbar", children: [_jsx(BrandLockup, { account: input.account }), _jsx("div", { className: "topbar-actions", children: _jsx(PrimaryNav, { account: input.account }) })] }), _jsx("div", { className: "frame-body", children: _jsxs("section", { className: "discover-shell", children: [_jsxs("div", { className: "discover-filter-bar", role: "toolbar", "aria-label": "Composition filters", children: [_jsxs("div", { className: "view-toggle discover-view-toggle", role: "tablist", "aria-label": "Discover view", children: [_jsx("button", { type: "button", className: `view-toggle-tab${!isPopular && !isSwipe ? " is-active" : ""}`, role: "tab", "aria-selected": !isPopular && !isSwipe, onClick: () => setView("feed"), children: "Feed" }), _jsx("button", { type: "button", className: `view-toggle-tab${isSwipe ? " is-active" : ""}`, role: "tab", "aria-selected": isSwipe, onClick: () => setView("swipe"), children: "Swipe" }), _jsx("button", { type: "button", className: `view-toggle-tab${isPopular ? " is-active" : ""}`, role: "tab", "aria-selected": isPopular, onClick: () => setView("popular"), children: "Category" })] }), _jsx(DiscoverMuteToggle, {}), _jsxs("div", { className: "discover-filter-search search-field", children: [_jsx("span", { className: "search-icon", "aria-hidden": "true", children: _jsx(SearchIcon, {}) }), _jsx("input", { id: "search", type: "search", placeholder: searchPlaceholder, autoComplete: "off", value: input.searchQuery, onChange: (event) => input.onSearchQueryChange?.(event.target.value) })] }), !isPopular && !isSwipe ? (_jsxs("label", { className: "discover-filter-toggle", htmlFor: "bookmarks-only", "data-active": input.bookmarksOnly ? "true" : "false", children: [_jsx("span", { className: "discover-filter-toggle-label", children: "Saved" }), _jsxs("span", { className: "switch-shell", "data-active": input.bookmarksOnly ? "true" : "false", children: [_jsx("input", { id: "bookmarks-only", className: "switch-input", type: "checkbox", checked: input.bookmarksOnly, onChange: (event) => input.onBookmarksOnlyChange?.(event.target.checked) }), _jsx("span", { className: "switch-knob" })] })] })) : null, input.account.isLoggedIn ? (_jsx("button", { type: "button", className: "discover-add-template-button", onClick: () => setIsAddTemplateOpen(true), children: "Add Template" })) : (_jsx("a", { className: "discover-add-template-button", href: "/login", children: "Add Template" }))] }), _jsx("section", { className: "discover-results", children: _jsx("div", { className: "discover-results-scroll", children: _jsx("div", { className: "discover-list-scroll", children: isSwipe ? (_jsx(SwipeDeck, { stack: input.swipeStack, loading: input.swipeLoading, loaded: input.swipeLoaded, error: input.swipeError, customizing: input.swipeCustomizing, targetDepth: input.swipeTargetDepth, productSet: input.swipeProductSet, refilling: input.swipeRefilling, isLoggedIn: input.account.isLoggedIn, onApprove: input.onSwipeApprove, onReject: input.onSwipeReject })) : isPopular ? (_jsx(PopularTemplates, { templates: input.popularTemplates, loading: input.popularLoading, error: input.popularError, searchQuery: input.searchQuery, bookmarks: input.bookmarks, onToggleBookmark: input.onToggleBookmark, onOpenPreview: setPreviewTemplate, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.account.userId })) : (_jsxs(_Fragment, { children: [_jsx("ul", { className: "discover-list", id: "template-list", children: input.loading ? (_jsx("li", { className: "empty-state discover-loading-card", children: "Loading compositions..." })) : input.error ? (_jsx("li", { className: "empty-state discover-loading-card", children: "Unable to load compositions." })) : input.templates.length === 0 ? (_jsx("li", { className: "empty-state discover-loading-card", children: "No compositions available." })) : (input.visibleTemplates.map((template) => (_jsx(TemplateCard, { template: template, isBookmarked: input.bookmarks.has(template.templateId), onToggleBookmark: input.onToggleBookmark, onOpenPreview: setPreviewTemplate, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.account.userId }, template.templateId)))) }), _jsx("div", { className: "empty-state empty", id: "empty-state", "data-visible": showEmpty ? "true" : "false", children: "No compositions match the current search and filter combination." })] })) }) }) })] }) }), hasChatRail ? (_jsx("aside", { className: "editor-right-rail", "aria-label": "Discover copilot", children: isChatEnabled ? (_jsx("div", { className: "editor-chat-panel", "data-template-editor-chat-root": true })) : (_jsxs(_Fragment, { children: [_jsx("a", { className: "vf-editor-chat-mobile-fab", href: "/login", "aria-label": "Log in to chat with Vidfarm", children: _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M20 11.5a7.5 7.5 0 0 1-9.8 7.1L4 20l1.5-5.3A7.5 7.5 0 1 1 20 11.5Z" }) }) }), _jsxs("div", { className: "editor-chat-panel editor-chat-panel--logged-out", style: {
|
|
535
642
|
display: "flex",
|
|
536
643
|
flexDirection: "column",
|
|
537
644
|
height: "100%",
|