@mevdragon/vidfarm-devcli 0.15.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.
Files changed (37) hide show
  1. package/.agents/skills/farmville-saas-ux/SKILL.md +156 -0
  2. package/.agents/skills/farmville-saas-ux/assets/starter.html +294 -0
  3. package/.agents/skills/farmville-saas-ux/references/components.md +340 -0
  4. package/.agents/skills/farmville-saas-ux/references/porting-guide.md +121 -0
  5. package/.agents/skills/farmville-saas-ux/references/tokens.md +271 -0
  6. package/.agents/skills/vidfarm-media/SKILL.md +2 -1
  7. package/.agents/skills/vidfarm-media/references/tts.md +20 -1
  8. package/demo/dist/app.css +1 -1
  9. package/demo/dist/app.js +74 -74
  10. package/dist/src/account-pages-legacy.js +2 -2
  11. package/dist/src/app.js +682 -12
  12. package/dist/src/config.js +13 -0
  13. package/dist/src/editor-chat.js +3 -2
  14. package/dist/src/frontend/homepage-client.js +211 -2
  15. package/dist/src/frontend/homepage-shared.js +197 -0
  16. package/dist/src/frontend/homepage-store.js +39 -1
  17. package/dist/src/frontend/homepage-view.js +150 -5
  18. package/dist/src/homepage.js +234 -18
  19. package/dist/src/landing-page.js +367 -0
  20. package/dist/src/primitive-registry.js +278 -2
  21. package/dist/src/reskin/agency-page.js +255 -0
  22. package/dist/src/reskin/calendar-page.js +252 -0
  23. package/dist/src/reskin/chat-page.js +414 -0
  24. package/dist/src/reskin/discover-page.js +380 -0
  25. package/dist/src/reskin/document.js +74 -0
  26. package/dist/src/reskin/help-page.js +318 -0
  27. package/dist/src/reskin/index-page.js +62 -0
  28. package/dist/src/reskin/job-runs-page.js +249 -0
  29. package/dist/src/reskin/library-page.js +515 -0
  30. package/dist/src/reskin/login-page.js +225 -0
  31. package/dist/src/reskin/pricing-page.js +359 -0
  32. package/dist/src/reskin/settings-page.js +353 -0
  33. package/dist/src/reskin/theme.js +265 -0
  34. package/dist/src/services/serverless-records.js +63 -0
  35. package/dist/src/services/swipe-customize.js +434 -0
  36. package/package.json +1 -1
  37. package/public/assets/homepage-client-app.js +23 -23
@@ -73,6 +73,19 @@ const schema = z.object({
73
73
  // long video through GhostCut. 15 min default ceiling (per-30s pricing makes
74
74
  // long-form runs absurd anyway).
75
75
  GHOSTCUT_MAX_DURATION_SEC: z.coerce.number().min(0).default(900),
76
+ // Auto-decompose kicks off GhostCut subtitle removal on the source video, but
77
+ // GhostCut prices per-30s chunk and is meant for short clips. Skip it entirely
78
+ // when the decompose source is longer than this — the composition just keeps
79
+ // the original burned-in captions rather than paying to launder a multi-minute
80
+ // video. 0 = never skip on length. Distinct from GHOSTCUT_MAX_DURATION_SEC
81
+ // (the standalone remove-captions primitive's hard ceiling).
82
+ GHOSTCUT_DECOMPOSE_MAX_DURATION_SEC: z.coerce.number().min(0).default(180),
83
+ // Inspiration ingest + auto-decompose used to hard-cap sources at 120s (a
84
+ // short-form-only assumption). Longer inspiration is now allowed. 0 = unlimited;
85
+ // set a positive ceiling to re-impose a limit. Kept separate from
86
+ // MAX_PROBE_DURATION_SECONDS (the per-primitive probe cost-guard) so raising the
87
+ // inspiration ceiling never loosens the generic probe default.
88
+ INSPIRATION_MAX_DURATION_SEC: z.coerce.number().min(0).default(0),
76
89
  // Cloud passthrough for `vidfarm serve`: the local box keeps editing/render
77
90
  // fully local but lists the upstream (prod) catalog in /discover and
78
91
  // /library, seeds forks on demand, and can hand a render off to the cloud.
@@ -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.",
@@ -72,6 +72,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
72
72
  "The editor_context block includes last_export_url, last_export_title, last_export_status, and last_export_render_id when a render has succeeded in this session. Treat last_export_url as the current 'finished Export MP4' for this composition. If the user asks to approve, package, share, or schedule the exported video without providing an explicit URL, use last_export_url as the primary MP4 media (kind=video, role=primary) for POST /api/v1/approved/posts. If last_export_url is null, tell the user to Export first (or offer to call editor_action with action_type=export_composition on their behalf) instead of guessing a URL.",
73
73
  "Each layer in editor_context includes element_id (data-hf-id), optional slug (data-hf-slug), and optional note (data-hf-note). layer_key on any editor_action may be EITHER the element_id or the slug — prefer the slug when the user (or a viral DNA blurb) refers to a layer by its human name like 'the hero_image'. Use set_layer_identity to add/change a layer's slug or note. Slugs are the stable AI-friendly handle: when planning viral DNA notes, reference the element by its slug (e.g., 'raise the {{hero_image}} to full canvas at 4s') so future turns can resolve it deterministically.",
74
74
  "Treat the most recent <editor_context> block in the user message as the source of truth for current layer_keys, layer indices (called track in HTML: track=layer index, higher = drawn on top), durations, and composition_duration_seconds. Never invent layer_keys for remove/set/duplicate/split actions; only reference keys that appear there.",
75
+ "CRITICAL — verify your edits actually landed. An editor_action tool result only echoes the arguments you sent; it is NOT proof the change was applied or saved. The real outcome shows up in the NEXT <editor_context>. Before telling the user an edit succeeded, check that block: (1) if it contains save_status:\"error\" (or is_read_only:true / a save_error message), the composition is NOT being saved — do NOT claim any edit worked; tell the user exactly what save_error says (e.g. the video is read-only from this link and they should open their own copy to edit) and stop making mutating editor_action calls until it clears; (2) if it contains recent_action_results, each entry with ok:false is an edit from your previous turn that FAILED (read its error) — acknowledge the failure and retry or explain it rather than repeating a false success; (3) otherwise confirm the intended change is reflected in the layers (e.g. the new text on the target layer) before saying it's done. Never report a timeline edit as successful without this check.",
75
76
  "editor_context also carries the canvas shape and free time: composition_width, composition_height, and aspect_ratio (e.g. '9:16') describe the output frame — when generating media to place on this timeline, request the matching aspect_ratio on /videos/generate and /images/generate so it fills the canvas without letterboxing or wrong cropping. timeline_gaps is a precomputed array of {start, end, duration} spans (seconds) where NO visual layer is on screen — i.e. the literal blank/black moments. When the user says something like 'at 00:05:52 there's blank space, add X', map their timestamp (interpret mm:ss or seconds sensibly) to the covering entry in timeline_gaps, set the new layer's start to that gap's start (or the user's exact time if inside the gap) and clamp its duration to fit the gap unless the user asks otherwise. If the requested time is NOT inside any timeline_gaps entry, tell the user it already has content there and offer to overlay on a higher track or replace the existing layer instead of silently colliding.",
76
77
  "For add_layer, always supply a stable layer_key (e.g., vf-caption-hook, vf-hero-image, vf-narration-audio) so later tool calls in the same turn can reference the new layer without waiting for the next <editor_context>. The editor uses that layer_key as the new layer's id. Choose kind from video, image, audio, text, or html. Provide src (exact URL) for video/image/audio, text for text layers, and html for html layers. Pick a track index higher than every existing layer on the requested time range, or set start/duration so the range is collision-free. Default visual layers fill roughly the upper-middle of the canvas; text layers default to a lower-third band. Always include explanation.",
77
78
  "GENERATE-AND-PLACE (preferred for NEW AI footage/images on the timeline): when the user wants to generate a NEW AI video or image and drop it on the timeline — fill a blank gap, replace a scene, or add an overlay — call editor_action with action_type=generate_layer. This ONE action submits the primitive generate job, immediately drops a 'Generating…' placeholder clip into the target slot, and auto-swaps in the finished media when the job settles — you do NOT poll and you do NOT make a separate add_layer call. Set media_type ('video' or 'image'), prompt, and aspect_ratio to the editor_context aspect_ratio so it fills the canvas. Set intent: 'fill_gap' (also set start=the timeline_gaps span start and duration to fit the gap), 'replace_layer' (also set replace_layer_key to the scene's layer_key/slug — its timing and geometry, including full-canvas, are copied automatically), or 'add' (set start/track/geometry for an overlay). For video pass gen_duration (seconds of footage, e.g. 4) and, for character consistency, input_references=[cast reference_url or still_url]; for image pass prompt_attachments=[reference image URL]. Optionally set provider/model/resolution/generate_audio. Give explanation. Because generation is asynchronous, the placeholder appears now and the real media lands in a later turn — do NOT claim the media is finished; tell the user it is generating and will appear on the timeline shortly.",
@@ -468,7 +469,7 @@ export const EDITOR_CHAT_SKILL_PACKS = [
468
469
  { name: "hyperframes-animation", summary: "motion rules catalog, scene blueprints, transition doctrine, runtime adapters" },
469
470
  { name: "hyperframes-keyframes", summary: "seek-safe keyframe/CSS animation patterns" },
470
471
  { name: "hyperframes-creative", summary: "design specs, palettes, typography, house style, narration craft" },
471
- { 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" },
472
473
  { name: "embedded-captions", summary: "caption identity catalog + subject-occlusion caption pipeline" },
473
474
  { name: "talking-head-recut", summary: "timed graphic overlay cards on existing footage" },
474
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);
@@ -65,6 +80,19 @@ function HomepageClientApp({ boot }) {
65
80
  const bookmarks = useStore(store, (state) => state.bookmarks);
66
81
  const loading = useStore(store, (state) => state.loading);
67
82
  const error = useStore(store, (state) => state.error);
83
+ const view = useStore(store, (state) => state.view);
84
+ const popularTemplates = useStore(store, (state) => state.popularTemplates);
85
+ const popularLoading = useStore(store, (state) => state.popularLoading);
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);
68
96
  const [bookmarksReady, setBookmarksReady] = useState(false);
69
97
  const visibleTemplates = useMemo(() => filterTemplates({ templates, searchQuery, bookmarksOnly, bookmarks }), [templates, searchQuery, bookmarksOnly, bookmarks]);
70
98
  useEffect(() => {
@@ -121,6 +149,187 @@ function HomepageClientApp({ boot }) {
121
149
  }, 8000);
122
150
  return () => window.clearInterval(timer);
123
151
  }, [hasProcessingTemplates, refreshFeed]);
152
+ // The curated Popular picks come from a separate endpoint and are the same
153
+ // for everyone, so they're fetched lazily the first time the toggle is opened
154
+ // (and re-fetched, without a spinner, while any pick is still downloading).
155
+ const refreshPopular = useCallback(async (options) => {
156
+ if (options?.showLoading) {
157
+ store.getState().setPopularLoading(true);
158
+ }
159
+ try {
160
+ const response = await fetch("/discover/popular", {
161
+ credentials: "same-origin",
162
+ cache: "no-store",
163
+ headers: { Accept: "application/json" }
164
+ });
165
+ const payload = await response.json().catch(() => ({}));
166
+ if (!response.ok) {
167
+ throw new Error(typeof payload.error === "string" && payload.error ? payload.error : "Unable to load popular formats.");
168
+ }
169
+ store.getState().setPopularTemplates(Array.isArray(payload.templates) ? payload.templates : []);
170
+ }
171
+ catch (fetchError) {
172
+ if (options?.showLoading) {
173
+ store.getState().setPopularError(fetchError instanceof Error ? fetchError.message : "Unable to load popular formats.");
174
+ }
175
+ else {
176
+ debugError("homepage.popular_refresh_failed", { message: fetchError instanceof Error ? fetchError.message : String(fetchError) });
177
+ }
178
+ }
179
+ }, [store]);
180
+ const popularLoaded = useStore(store, (state) => state.popularLoaded);
181
+ useEffect(() => {
182
+ if (view !== "popular" || popularLoaded) {
183
+ return;
184
+ }
185
+ void refreshPopular({ showLoading: true });
186
+ }, [view, popularLoaded, refreshPopular]);
187
+ const hasProcessingPopular = view === "popular" && popularTemplates.some((template) => template.status === "processing");
188
+ useEffect(() => {
189
+ if (!hasProcessingPopular) {
190
+ return;
191
+ }
192
+ const timer = window.setInterval(() => {
193
+ void refreshPopular();
194
+ }, 8000);
195
+ return () => window.clearInterval(timer);
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]);
124
333
  const handleDeleteTemplate = useCallback(async (templateId) => {
125
334
  try {
126
335
  const response = await fetch(`/discover/templates/${encodeURIComponent(templateId)}`, {
@@ -219,7 +428,7 @@ function HomepageClientApp({ boot }) {
219
428
  return { ok: false, error: submitError instanceof Error ? submitError.message : "Unable to add that video." };
220
429
  }
221
430
  }, [refreshFeed]);
222
- return (_jsx(HomepageShell, { account: boot.account, templates: templates, visibleTemplates: visibleTemplates, searchQuery: searchQuery, bookmarksOnly: bookmarksOnly, bookmarks: bookmarks, loading: loading, error: error, skillEndpointPrefix: boot.skillEndpointPrefix, onSearchQueryChange: (value) => store.getState().setSearchQuery(value), onBookmarksOnlyChange: (value) => store.getState().setBookmarksOnly(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 }));
223
432
  }
224
433
  const boot = readBoot();
225
434
  const root = document.getElementById("homepage-root");
@@ -1,4 +1,201 @@
1
1
  export function isVideoPreview(url) {
2
2
  return Boolean(url && /\.(mp4|webm|ogg|mov)(\?|#|$)/i.test(url));
3
3
  }
4
+ export const POPULAR_INSPIRATION_GROUPS = [
5
+ {
6
+ label: "Talking Head & Personal",
7
+ items: [
8
+ { title: "Vlog style", url: "https://vt.tiktok.com/ZSCWTFBSe/" },
9
+ { title: "Talking head monologue", url: "https://vt.tiktok.com/ZSCWw9wqf/" },
10
+ { title: "AI Avatar presenter", url: "https://vt.tiktok.com/ZSC7RVW1o/" },
11
+ { title: "Day in the life", url: "https://www.tiktok.com/@rubygrifiths/video/7657529386131868950" },
12
+ { title: "GRWM", url: "https://vt.tiktok.com/ZSCWKv8Xb/" },
13
+ { title: "Reaction video", url: "https://vt.tiktok.com/ZSCWEF8Rv/" },
14
+ { title: "Q&A", url: "https://vt.tiktok.com/ZSCWExEeV/" },
15
+ { title: "Behind the Scenes", url: "https://vt.tiktok.com/ZSCWTFYew/" },
16
+ { title: "Story time narrated", url: "https://vt.tiktok.com/ZSCW3kAT5/" },
17
+ { title: "VTuber", url: "https://vt.tiktok.com/ZSC7TQyVM/" },
18
+ { title: "Female Thirst Trap", url: "https://vt.tiktok.com/ZSCvRLyMf/" },
19
+ { title: "Male Thirst Trap", url: "https://vt.tiktok.com/ZSCvdUUE2/" },
20
+ { title: "Masked Host", url: "https://vt.tiktok.com/ZSCvdNvxG/" },
21
+ { title: "Hot Take", url: "https://vt.tiktok.com/ZSCvewSbB/" }
22
+ ]
23
+ },
24
+ {
25
+ label: "Text / Story-Driven",
26
+ items: [
27
+ { title: "Reddit story + gameplay", url: "https://vt.tiktok.com/ZSCWpeSRy/" },
28
+ { title: "Fake text message story", url: "https://vt.tiktok.com/ZSCWov56s/" },
29
+ { title: "Twitter/X Thread Video", url: "https://vt.tiktok.com/ZSC7199q6/" },
30
+ { title: "Quote / motivational card", url: "https://vt.tiktok.com/ZSC71D9QD/" },
31
+ { title: "Facts / did you know", url: "https://vt.tiktok.com/ZSCW3QrKA/" },
32
+ { title: "Bible verse / spiritual", url: "https://vt.tiktok.com/ZSCWsP8r7/" },
33
+ { title: "Horoscope / Astrology", url: "https://vt.tiktok.com/ZSC71497u/" },
34
+ { title: "Minecraft Reddit Brainrot", url: "https://youtube.com/shorts/AmdI56dl1z4" }
35
+ ]
36
+ },
37
+ {
38
+ label: "Educational / Explainer",
39
+ items: [
40
+ { title: "Educational explainer", url: "https://vt.tiktok.com/ZSC7e5fbr/" },
41
+ { title: "Whiteboard animation", url: "https://vt.tiktok.com/ZSCWpLa4p/" },
42
+ { title: "Kinetic typography", url: "https://vt.tiktok.com/ZSC7dJnJP/" },
43
+ { title: "Data viz / Infographic", url: "https://vt.tiktok.com/ZSC7RvdWR/" },
44
+ { title: "Science explainer", url: "https://youtu.be/WxmSJbG134k" },
45
+ { title: "Vox News Style", url: "https://www.tiktok.com/@vox/video/7619108001424723231" },
46
+ { title: "Low Poly Animation", url: "https://vt.tiktok.com/ZSC7RwmGR/" },
47
+ { title: "Math tutorial", url: "https://youtu.be/6yYnr8FL1fk" },
48
+ { title: "Historic retelling", url: "https://vt.tiktok.com/ZSC7N6Y68/" },
49
+ { title: "Book Summary", url: "https://vt.tiktok.com/ZSC7Fmemb/" },
50
+ { title: "Documentary Style", url: "https://vt.tiktok.com/ZSC7YBXm9/" },
51
+ { title: "Course lecture", url: "https://youtube.com/shorts/QoQUfVEp-d0" },
52
+ { title: "Scientific Selling", url: "https://www.tiktok.com/@maxcern/video/7604237124036037919" }
53
+ ]
54
+ },
55
+ {
56
+ label: "Product / Marketing",
57
+ items: [
58
+ { title: "Software product demo", url: "https://vt.tiktok.com/ZSC7rbNcV/" },
59
+ { title: "Product commercial", url: "https://vt.tiktok.com/ZSCv6C1Ms/" },
60
+ { title: "UGC Creator Ad", url: "https://vt.tiktok.com/ZSC7hwraf/" },
61
+ { title: "Product Spin Show", url: "https://vt.tiktok.com/ZSCWsuxxp/" },
62
+ { title: "Testimonial", url: "https://vt.tiktok.com/ZSC7kRvxk/" },
63
+ { title: "Explainer commercial", url: "https://vt.tiktok.com/ZSC7SEeHA/" },
64
+ { title: "Case Study", url: "https://www.tiktok.com/@juliejonesdesigns/video/7298542413948243243" },
65
+ { title: "Brand story", url: "https://vt.tiktok.com/ZSC7a7wvb/" },
66
+ { title: "Real Estate tour", url: "https://www.instagram.com/reel/DY19EdHGcDk/" },
67
+ { title: "Sales pitch", url: "https://www.tiktok.com/@mindfullallie/video/7633917841774742815" },
68
+ { title: "Logo reveal / brand intro", url: "https://vt.tiktok.com/ZSC7avGmH/" }
69
+ ]
70
+ },
71
+ {
72
+ label: "News / Journalism",
73
+ items: [
74
+ { title: "News anchor style", url: "https://vt.tiktok.com/ZSC7VtHNS/" },
75
+ { title: "Breaking news", url: "https://vt.tiktok.com/ZSCWWUCqS/" },
76
+ { title: "Weather Report", url: "https://vt.tiktok.com/ZSCWWBEEY/" },
77
+ { title: "Financial / stock update", url: "https://vt.tiktok.com/ZSC7Vt6rP/" },
78
+ { title: "Sports scores/recap", url: "https://vt.tiktok.com/ZSC7V7Pqf/" }
79
+ ]
80
+ },
81
+ {
82
+ label: "Entertainment / Creative",
83
+ items: [
84
+ { title: "Anime / manga", url: "https://vt.tiktok.com/ZSC7qnwWn/" },
85
+ { title: "Cartoon Animation", url: "https://vt.tiktok.com/ZSC7qvEfe/" },
86
+ { title: "Music Video", url: "https://vt.tiktok.com/ZSC7beC8P/" },
87
+ { title: "Lyric Video", url: "https://vt.tiktok.com/ZSC7bP5x7/" },
88
+ { title: "Meme video", url: "https://vt.tiktok.com/ZSC7g8sEn/" },
89
+ { title: "Meme recaptioning", url: "https://vt.tiktok.com/ZSCv6eeKD/" },
90
+ { title: "Hype Edit", url: "https://www.tiktok.com/@goldenhype0/video/7645035734901984543" },
91
+ { title: "Fantasy Hype Edit", url: "https://www.tiktok.com/@hype.edits/video/7538337237453344013" },
92
+ { title: "UGC Comedy", url: "https://www.tiktok.com/@very.manipulative/video/7644074719737646366" },
93
+ { title: "Parody", url: "https://vt.tiktok.com/ZSC7g4aa8/" },
94
+ { title: "ASMR", url: "https://vt.tiktok.com/ZSCvJvseN/" },
95
+ { title: "Cinematic short film", url: "https://vt.tiktok.com/ZSCvJV2mM/" },
96
+ { title: "Oddly satisfying", url: "https://vt.tiktok.com/ZSC7gqn9o/" },
97
+ { title: "POV video", url: "https://vt.tiktok.com/ZSCveMjMp/" },
98
+ { title: "Nostalgia", url: "https://vt.tiktok.com/ZSCv2DALg/" },
99
+ { title: "Musical", url: "https://vt.tiktok.com/ZSCvYwKBQ/" },
100
+ { title: "Gossip", url: "https://vt.tiktok.com/ZSCvYvrh7/" }
101
+ ]
102
+ },
103
+ {
104
+ label: "Data / Analytical",
105
+ items: [
106
+ { title: "Comparison \"X vs Y\"", url: "https://youtube.com/shorts/jFvemXPxayg" },
107
+ { title: "Ranking / Tier List", url: "https://youtube.com/shorts/Ku5xNHs7sAE" },
108
+ { title: "Countdown / top 10", url: "https://vt.tiktok.com/ZSC7psUDB/" },
109
+ { title: "Poll Results Reveal", url: "https://www.tiktok.com/@carterjstockman/video/7477653012874267947" },
110
+ { title: "Stats Comparison", url: "https://www.tiktok.com/@everythingecon/video/7650833928726564118" },
111
+ { title: "Leaderboard Reveal", url: "https://vt.tiktok.com/ZSC7sseno/" },
112
+ { title: "Sports stat breakdown", url: "https://vt.tiktok.com/ZSCvFUTHN/" },
113
+ { title: "Before/after Transformation", url: "https://vt.tiktok.com/ZSC7GRLT2/" }
114
+ ]
115
+ },
116
+ {
117
+ label: "Gaming",
118
+ items: [
119
+ { title: "Gameplay walkthrough", url: "https://vt.tiktok.com/ZSCWWE5X5/" },
120
+ { title: "Game review", url: "https://youtube.com/shorts/nrb0t9cRw3g" },
121
+ { title: "Game trailer", url: "https://vt.tiktok.com/ZSCW7LqKV/" },
122
+ { title: "Esports highlights", url: "https://vt.tiktok.com/ZSC7y4BEF/" }
123
+ ]
124
+ },
125
+ {
126
+ label: "Kids / Educational",
127
+ items: [
128
+ { title: "Nursery Rhyme", url: "https://vt.tiktok.com/ZSC7SWUUr/" },
129
+ { title: "Alphabet / counting", url: "https://vt.tiktok.com/ZSCWvhRNd/" },
130
+ { title: "Cartoon lesson", url: "https://vt.tiktok.com/ZSC7y9wHp/" },
131
+ { title: "Storybook read-along", url: "https://vt.tiktok.com/ZSC7yrnN8/" }
132
+ ]
133
+ },
134
+ {
135
+ label: "Ambient / Background",
136
+ items: [
137
+ { title: "Lo-fi study loop", url: "https://vt.tiktok.com/ZSCWcFgAh/" },
138
+ { title: "Nature + Music", url: "https://vt.tiktok.com/ZSCWc4Egr/" },
139
+ { title: "Fireplace / rain loop", url: "https://vt.tiktok.com/ZSCWcrF7v/" },
140
+ { title: "Aesthetic vibe/mood", url: "https://vt.tiktok.com/ZSCWcK2uQ/" }
141
+ ]
142
+ },
143
+ {
144
+ label: "Fitness / Wellness",
145
+ items: [
146
+ { title: "Workout tutorial", url: "https://vt.tiktok.com/ZSC7fLGUL/" },
147
+ { title: "Yoga / meditation", url: "https://vt.tiktok.com/ZSC7fTkJr/" },
148
+ { title: "Recipe / cooking", url: "https://vt.tiktok.com/ZSC7ffpcX/" },
149
+ { title: "Health tips", url: "https://vt.tiktok.com/ZSC7P6T8y/" }
150
+ ]
151
+ },
152
+ {
153
+ label: "Travel / Location",
154
+ items: [
155
+ { title: "Travel vlog", url: "https://vt.tiktok.com/ZSCWthBcp/" },
156
+ { title: "Destination showcase", url: "https://vt.tiktok.com/ZSC7aVDUv/" },
157
+ { title: "Review Guides", url: "https://vt.tiktok.com/ZSC7PpXUR/" },
158
+ { title: "City tour", url: "https://vt.tiktok.com/ZSC7ajj14/" }
159
+ ]
160
+ },
161
+ {
162
+ label: "Interview / Conversation",
163
+ items: [
164
+ { title: "Interview format", url: "https://vt.tiktok.com/ZSC74xE8A/" },
165
+ { title: "Panel discussion", url: "https://vt.tiktok.com/ZSC74xYML/" },
166
+ { title: "Debate", url: "https://vt.tiktok.com/ZSCvRpCW1/" },
167
+ { title: "Podcast clip", url: "https://www.tiktok.com/@rogantimes04/video/7659533957049961750" },
168
+ { title: "Audiogram", url: "https://vt.tiktok.com/ZSCWs6uMm/" },
169
+ { title: "Diary Confession", url: "https://vt.tiktok.com/ZSCvRcFAn/" }
170
+ ]
171
+ },
172
+ {
173
+ label: "Motion Design",
174
+ items: [
175
+ { title: "Motion graphics", url: "https://vt.tiktok.com/ZSC7u4VD8/" },
176
+ { title: "2D character animation", url: "https://vt.tiktok.com/ZSC7uCayM/" },
177
+ { title: "Stop motion", url: "https://vt.tiktok.com/ZSCWseh2r/" },
178
+ { title: "Static Fast Forward", url: "https://vt.tiktok.com/ZSC7HRUVa/" },
179
+ { title: "Montage Video", url: "https://vt.tiktok.com/ZSCv8QPRY/" },
180
+ { title: "Memoirs", url: "https://vt.tiktok.com/ZSCv89bXC/" }
181
+ ]
182
+ },
183
+ {
184
+ label: "Hype / Trailer",
185
+ items: [
186
+ { title: "Trailer / hype video", url: "https://vt.tiktok.com/ZSC7uHkTc/" },
187
+ { title: "Event promo", url: "https://vt.tiktok.com/ZSC7uMvBA/" },
188
+ { title: "Reveal video", url: "https://vt.tiktok.com/ZSCvNxK7d/" }
189
+ ]
190
+ },
191
+ {
192
+ label: "Pure AI",
193
+ items: [
194
+ { title: "AI Fruit Dramas", url: "https://vt.tiktok.com/ZSCv89Xck/" },
195
+ { title: "Surreal AI Characters", url: "https://vt.tiktok.com/ZSCv8nN2K/" },
196
+ { title: "Claymation Story", url: "https://vt.tiktok.com/ZSCvLJhAc/" },
197
+ { title: "Paper Mache Story", url: "https://www.youtube.com/watch?v=0JKu9TQCLMI" }
198
+ ]
199
+ }
200
+ ];
4
201
  //# sourceMappingURL=homepage-shared.js.map
@@ -7,6 +7,19 @@ export function createHomepageStore(boot) {
7
7
  bookmarks: new Set(),
8
8
  loading: boot.templates.length === 0,
9
9
  error: null,
10
+ view: "feed",
11
+ popularTemplates: [],
12
+ popularLoading: false,
13
+ popularLoaded: false,
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,
10
23
  setTemplates: (templates) => set({ templates, loading: false, error: null }),
11
24
  setSearchQuery: (searchQuery) => set({ searchQuery }),
12
25
  setBookmarksOnly: (bookmarksOnly) => set({ bookmarksOnly }),
@@ -22,7 +35,32 @@ export function createHomepageStore(boot) {
22
35
  return { bookmarks: next };
23
36
  }),
24
37
  setLoading: (loading) => set({ loading }),
25
- setError: (error) => set({ error, loading: false })
38
+ setError: (error) => set({ error, loading: false }),
39
+ setView: (view) => set({ view }),
40
+ setPopularTemplates: (popularTemplates) => set({ popularTemplates, popularLoading: false, popularLoaded: true, popularError: null }),
41
+ setPopularLoading: (popularLoading) => set({ popularLoading }),
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 })
26
64
  }));
27
65
  }
28
66
  //# sourceMappingURL=homepage-store.js.map