@mevdragon/vidfarm-devcli 0.17.0 → 0.18.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.
@@ -1,127 +1,183 @@
1
- // /reskin/library — the director's own studio, ported into the farmville design
2
- // system. Mirrors src/account-pages-legacy.ts renderLibraryPage + the clip
3
- // gallery (renderClipsPage). This is now ONE page with two server-routed tabs:
4
- // /reskin/library/approved → your projects, renders & ready-to-post cuts
5
- // /reskin/library/clips → long-form videos mined into tagged short clips
1
+ // /reskin/library — the director's own studio, now MIGRATED to real production
2
+ // data (mirrors src/reskin/settings-page.ts). This is ONE page with two
3
+ // server-routed tabs at distinct URLs:
4
+ // /reskin/library/approved → your REAL published cuts (ready + scheduled)
5
+ // /reskin/library/clips → your REAL mined clip library (client-fetched)
6
6
  //
7
- // Renders with representative SAMPLE data so it can be viewed without auth. The
8
- // tab toggle is real <a> links (each tab is its own URL); card actions point at
9
- // the real routes (/editor, /job-runs, /calendar, /discover, /approved/posts/…).
10
- // Everything visual is built on the shared rk-* primitives + a page-scoped
11
- // rk-library-* / rk-clips-* CSS block.
7
+ // The renderer accepts the SAME input object the live `/library` route already
8
+ // builds `Parameters<typeof renderLibraryPage>[0]` from ../account-pages.js
9
+ // so wiring the route is just "fetch real data + call this". Every action points
10
+ // at a real endpoint:
11
+ // Open → /editor/:templateId (the real editor, account-scoped)
12
+ // • View post → post.share_url (the real share/preview page)
13
+ // • Download → the primary video asset's real MP4 url
14
+ // • Schedule → /reskin/calendar (the real scheduling surface)
15
+ // • Clips tab → GET /clips/feed, POST /clips/search, POST /clips/scan (real)
16
+ //
17
+ // Follow-up: the live library ships an editorChat / chat-dock runtime
18
+ // (buildLibraryEditorChatBoot → editorChat). That is intentionally OMITTED here
19
+ // for now — this pass focuses on real projects + real clip search. `editorChat`
20
+ // is accepted in the input but not yet rendered.
21
+ //
22
+ // Design stays farmville (shared rk-* primitives + a page-scoped rk-library-* /
23
+ // rk-clips-* CSS block); only the data + actions became real.
12
24
  import { reskinDocument, rkEscape } from "./document.js";
13
- const PROJECTS = [
14
- { id: "proj_9c41f0", title: "Swipe-mode ad DTC skincare", emoji: "🧴", grad: "coral", status: "ready", originKind: "inspiration", originLabel: "Punchy Hook 15s", duration: "0:15", when: "Rendered 2h ago" },
15
- { id: "proj_7a12bd", title: "Founder VO “ship a week in an afternoon”", emoji: "🎙️", grad: "honey", status: "rendering", originKind: "raw", originLabel: "Raw upload", duration: "0:42", when: "Started 4m ago", progress: 64 },
16
- { id: "proj_3fd80a", title: "UGC unboxing recut #3", emoji: "📦", grad: "sky", status: "draft", originKind: "inspiration", originLabel: "Unboxing Energy", duration: "0:22", when: "Edited yesterday" },
17
- { id: "proj_c19e44", title: "Honey-gold pricing explainer", emoji: "💳", grad: "violet", status: "scheduled", originKind: "inspiration", originLabel: "Explainer Grid", duration: "0:38", when: "Posts Jul 9, 9:00 AM" },
18
- { id: "proj_66b2c1", title: "TikTok hook A/B — captions pop", emoji: "💬", grad: "green", status: "ready", originKind: "inspiration", originLabel: "Caption Punch", duration: "0:12", when: "Rendered Jul 6" },
19
- { id: "proj_15aa09", title: "Clip mine → 15s teaser", emoji: "✂️", grad: "sky", status: "draft", originKind: "raw", originLabel: "Raw clips", duration: "0:15", when: "Edited 3d ago" },
20
- { id: "proj_ken042", title: "Ken Burns product hero", emoji: "🖼️", grad: "honey", status: "posted", originKind: "inspiration", originLabel: "Still-to-Motion", duration: "0:18", when: "Posted Jul 4 · @vidfarm", href: "/approved/posts/proj_ken042" },
21
- { id: "proj_news77", title: "Newsletter drop cut", emoji: "📮", grad: "coral", status: "posted", originKind: "raw", originLabel: "Raw upload", duration: "0:30", when: "Posted Jul 1 · Email", href: "/approved/posts/proj_news77" },
22
- { id: "proj_sizzle", title: "Sunny SaaS sizzle reel", emoji: "☀️", grad: "ink", status: "rendering", originKind: "inspiration", originLabel: "Brand Sizzle", duration: "1:04", when: "Started 12m ago", progress: 31 },
23
- { id: "proj_prompt5", title: "Prompt-gen: “costco for business apps”", emoji: "", grad: "violet", status: "draft", originKind: "raw", originLabel: "Prompt-gen", duration: "0:20", when: "Created 5d ago" }
24
- ];
25
- const GRAD = {
26
- honey: "radial-gradient(120% 78% at 22% 4%,rgba(255,255,255,.6),transparent 55%),linear-gradient(158deg,#ffe9cc,#ffc738 96%)",
27
- coral: "radial-gradient(120% 78% at 22% 4%,rgba(255,255,255,.5),transparent 55%),linear-gradient(158deg,#ffe1c2,#f97316 98%)",
28
- sky: "radial-gradient(120% 78% at 22% 4%,rgba(255,255,255,.58),transparent 55%),linear-gradient(158deg,#e2effd,#3f9fe0 98%)",
29
- violet: "radial-gradient(120% 78% at 22% 4%,rgba(255,255,255,.5),transparent 55%),linear-gradient(158deg,#ece4fd,#9f5bde 98%)",
30
- green: "radial-gradient(120% 78% at 22% 4%,rgba(255,255,255,.5),transparent 55%),linear-gradient(158deg,#dcfce7,#22c55e 98%)",
31
- ink: "radial-gradient(120% 78% at 22% 4%,rgba(255,255,255,.18),transparent 55%),linear-gradient(158deg,#3f3f46,#171717 98%)"
25
+ import { jobRunsPanel, JOBRUNS_PAGE_CSS, JOBRUNS_SCRIPT } from "./job-runs-page.js";
26
+ // A safe default so the current route (`renderReskinLibrary("approved")`, no
27
+ // input) keeps working until app.ts is updated to pass real data (mirror the
28
+ // live `/library` route). Once wired, the parent passes the real object.
29
+ const EMPTY_LIBRARY_INPUT = {
30
+ userId: "",
31
+ currentAccountId: "",
32
+ name: null,
33
+ email: "",
34
+ posts: [],
35
+ schedule: { channels: [], connectHref: "/settings?tab=channels&connect=flockposter" },
36
+ editorChat: null
32
37
  };
38
+ const PLAY = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13l11-6.5-11-6.5Z"/></svg>`;
39
+ const SEARCH_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>`;
40
+ /* ──────────────────────────── Approved (projects) tab ─────────────────────── */
33
41
  const STATUS = {
34
- draft: { short: "Draft", pill: "rk-pill" },
35
- rendering: { short: "Rendering", pill: "rk-pill-sky" },
36
42
  ready: { short: "Ready", pill: "rk-pill-green" },
37
- scheduled: { short: "Scheduled", pill: "rk-pill-violet" },
38
- posted: { short: "Posted", pill: "rk-pill-gold" }
43
+ scheduled: { short: "Scheduled", pill: "rk-pill-violet" }
39
44
  };
40
- const CHIPS = [
41
- ["", "All", ""],
42
- ["draft", "Drafts", "draft"],
43
- ["rendering", "Rendering", "rendering"],
44
- ["ready", "Ready to post", "ready"],
45
- ["scheduled", "Scheduled", "scheduled"],
46
- ["posted", "Posted", "posted"]
47
- ];
48
- const PLAY = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13l11-6.5-11-6.5Z"/></svg>`;
49
- function count(status) {
50
- return status === "" ? PROJECTS.length : PROJECTS.filter((p) => p.status === status).length;
45
+ function statusInfo(status) {
46
+ return STATUS[status] || { short: status ? status.charAt(0).toUpperCase() + status.slice(1) : "Ready", pill: "rk-pill" };
51
47
  }
52
- function cardAction(p) {
53
- switch (p.status) {
54
- case "draft":
55
- return `<a class="rk-btn rk-btn-ink rk-btn-sm" href="/editor">Open <span class="rk-arrow">→</span></a>`;
56
- case "rendering":
57
- return `<a class="rk-btn rk-btn-ghost rk-btn-sm" href="/job-runs">Track render</a>`;
58
- case "ready":
59
- return `<a class="rk-btn rk-btn-gold rk-btn-sm" href="/calendar">Schedule <span class="rk-arrow">→</span></a>`;
60
- case "scheduled":
61
- return `<a class="rk-btn rk-btn-ghost rk-btn-sm" href="/calendar">Reschedule</a>`;
62
- case "posted":
63
- return `<a class="rk-btn rk-btn-ghost rk-btn-sm" href="${rkEscape(p.href || "#")}" target="_blank" rel="noreferrer">View <span class="rk-arrow">→</span></a>`;
64
- }
48
+ function fmtDate(iso) {
49
+ const t = Date.parse(iso);
50
+ if (Number.isNaN(t))
51
+ return iso || "";
52
+ return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
53
+ }
54
+ function isVideoAsset(a) {
55
+ return a.kind === "video" || String(a.content_type || "").startsWith("video");
65
56
  }
66
- function originRow(p) {
67
- if (p.originKind === "inspiration") {
68
- return `<div class="rk-library-origin">
69
- <span class="rk-library-origin-txt">From “${rkEscape(p.originLabel)}”</span>
70
- </div>`;
57
+ function isImageAsset(a) {
58
+ return a.kind === "image" || String(a.content_type || "").startsWith("image");
59
+ }
60
+ // primaryAsset mirrors the live library: role="primary" → first video → media[0].
61
+ function primaryAsset(post) {
62
+ return post.media.find((a) => a.role === "primary")
63
+ || post.media.find((a) => isVideoAsset(a))
64
+ || post.media[0]
65
+ || null;
66
+ }
67
+ function downloadAsset(post) {
68
+ const v = post.media.find((a) => isVideoAsset(a));
69
+ return v || primaryAsset(post);
70
+ }
71
+ function shareHref(post) {
72
+ return post.share_url && post.share_url.length
73
+ ? post.share_url
74
+ : `/approved/posts/${encodeURIComponent(post.post_id)}`;
75
+ }
76
+ function editorHref(post, accountId) {
77
+ if (!post.template_id)
78
+ return null;
79
+ const q = accountId ? `?account=${encodeURIComponent(accountId)}` : "";
80
+ return `/editor/${encodeURIComponent(post.template_id)}${q}`;
81
+ }
82
+ function posterMedia(post) {
83
+ const asset = post.media.find((a) => isVideoAsset(a)) || post.media.find((a) => isImageAsset(a)) || primaryAsset(post);
84
+ if (asset && isVideoAsset(asset)) {
85
+ return `<video class="rk-library-video" muted loop playsinline preload="metadata" src="${rkEscape(asset.url)}#t=0.1"></video>`;
71
86
  }
72
- return `<div class="rk-library-origin">
73
- <span class="rk-library-origin-txt">${rkEscape(p.originLabel)} · Your project</span>
74
- </div>`;
87
+ if (asset && isImageAsset(asset)) {
88
+ return `<img class="rk-library-video" loading="lazy" src="${rkEscape(asset.url)}" alt="${rkEscape(post.title || "Published render")}" />`;
89
+ }
90
+ return `<span class="rk-library-noprev">No preview</span>`;
75
91
  }
76
- function projectCard(p) {
77
- const s = STATUS[p.status];
78
- const dotCls = p.status === "rendering" ? " is-pulse" : "";
79
- const progress = p.status === "rendering"
80
- ? `<span class="rk-library-prog"><span class="rk-library-prog-fill" style="width:${Math.max(6, Math.min(100, p.progress ?? 40))}%"></span></span>`
81
- : "";
82
- return `<article class="rk-library-card rk-card-hover" data-status="${p.status}" data-title="${rkEscape(p.title.toLowerCase())}">
83
- <a class="rk-library-poster" style="background:${GRAD[p.grad]}" href="/editor" aria-label="${rkEscape(p.title)}">
84
- <span class="rk-library-emoji">${p.emoji}</span>
85
- <span class="rk-pill ${s.pill} rk-library-pstatus"><span class="rk-library-dot${dotCls}"></span>${rkEscape(s.short)}</span>
86
- <span class="rk-library-dur">${rkEscape(p.duration)} · 9:16</span>
92
+ function projectCard(post, accountId) {
93
+ const s = statusInfo(post.status);
94
+ const href = editorHref(post, accountId) || shareHref(post);
95
+ const dl = downloadAsset(post);
96
+ const canDownload = dl && isVideoAsset(dl);
97
+ const title = post.title || "Published render";
98
+ const subLine = post.tracer
99
+ ? post.tracer
100
+ : post.template_id
101
+ ? `Composition ${post.template_id}`
102
+ : "Ready-to-post cut";
103
+ const searchHay = [title, post.caption, post.tracer, post.template_id].filter(Boolean).join(" ").toLowerCase();
104
+ const editHref = editorHref(post, accountId);
105
+ const primaryAction = post.status === "scheduled"
106
+ ? `<a class="rk-btn rk-btn-ghost rk-btn-sm" href="/calendar">Reschedule</a>`
107
+ : `<a class="rk-btn rk-btn-gold rk-btn-sm" href="/calendar">Schedule <span class="rk-arrow">→</span></a>`;
108
+ const actions = [
109
+ editHref ? `<a class="rk-btn rk-btn-ghost rk-btn-sm" href="${rkEscape(editHref)}">Open in editor</a>` : "",
110
+ `<a class="rk-btn rk-btn-ghost rk-btn-sm" href="${rkEscape(shareHref(post))}" target="_blank" rel="noreferrer">View post</a>`,
111
+ canDownload ? `<a class="rk-btn rk-btn-ghost rk-btn-sm" href="${rkEscape(dl.url)}" download="${rkEscape(dl.file_name || "render.mp4")}">Download</a>` : ""
112
+ ].filter(Boolean).join("");
113
+ return `<article class="rk-library-card rk-card-hover" data-status="${rkEscape(post.status)}" data-title="${rkEscape(searchHay)}">
114
+ <a class="rk-library-poster" href="${rkEscape(href)}" aria-label="${rkEscape(title)}">
115
+ ${posterMedia(post)}
116
+ <span class="rk-pill ${s.pill} rk-library-pstatus"><span class="rk-library-dot"></span>${rkEscape(s.short)}</span>
117
+ <span class="rk-library-dur">9:16</span>
87
118
  <span class="rk-library-play">${PLAY}</span>
88
- ${progress}
89
119
  </a>
90
120
  <div class="rk-library-meta">
91
- <h3 class="rk-library-title">${rkEscape(p.title)}</h3>
92
- ${originRow(p)}
121
+ <h3 class="rk-library-title">${rkEscape(title)}</h3>
122
+ <div class="rk-library-origin"><span class="rk-library-origin-txt">${rkEscape(subLine)}</span></div>
93
123
  <div class="rk-spread rk-library-foot">
94
- <span class="rk-library-time">${rkEscape(p.when)}</span>
95
- ${cardAction(p)}
124
+ <span class="rk-library-time">${rkEscape(fmtDate(post.created_at))}</span>
125
+ ${primaryAction}
96
126
  </div>
127
+ <div class="rk-library-actions">${actions}</div>
97
128
  </div>
98
129
  </article>`;
99
130
  }
100
- function statTile(emoji, num, label) {
101
- return `<div class="rk-library-stat">
102
- <span class="rk-tile rk-tile-lg rk-tile-gold">${emoji}</span>
103
- <div class="rk-stat">
104
- <div class="rk-library-stat-num">${rkEscape(num)}</div>
105
- <div class="rk-library-stat-label">${rkEscape(label)}</div>
106
- </div>
131
+ function approvedPanel(input, tabs) {
132
+ const posts = input.posts;
133
+ const accountId = input.currentAccountId;
134
+ // The quickfilter searchbar mirrors the Clips tab's pill search — always
135
+ // rendered so the topbar reads the same on both tabs (disabled when empty).
136
+ const searchBar = `<label class="rk-clips-search rk-library-qsearch">
137
+ <span class="rk-clips-search-ic">${SEARCH_ICON}</span>
138
+ <input class="rk-input rk-clips-search-input" data-rk-search type="search"
139
+ placeholder="Search cuts — title, caption, or tracer…" autocomplete="off" aria-label="Search cuts"${posts.length ? "" : " disabled"} />
140
+ </label>`;
141
+ if (posts.length === 0) {
142
+ return `
143
+ <div class="rk-library-topbar">
144
+ ${tabs}
145
+ ${searchBar}
146
+ </div>
147
+
148
+ <div class="rk-library-empty is-visible">
149
+ <h3>No published cuts yet</h3>
150
+ <p class="rk-muted">Approve a finished render and it lands here — ready to schedule, share, or download. Start from a template or a prompt.</p>
151
+ <a class="rk-btn rk-btn-gold" href="/discover">Start a project <span class="rk-arrow">→</span></a>
107
152
  </div>`;
108
- }
109
- function approvedPanel() {
110
- const chips = CHIPS.map(([value, label], i) => `<button type="button" class="rk-library-chip${i === 0 ? " is-active" : ""}" data-rk-filter="${value}">${rkEscape(label)}</button>`).join("");
153
+ }
154
+ const readyCount = posts.filter((p) => p.status === "ready").length;
155
+ const scheduledCount = posts.filter((p) => p.status === "scheduled").length;
156
+ const chipDefs = [
157
+ ["", "All"],
158
+ ["ready", "Ready to post"],
159
+ ["scheduled", "Scheduled"]
160
+ ];
161
+ const chips = chipDefs
162
+ .map(([value, label], i) => `<button type="button" class="rk-library-chip${i === 0 ? " is-active" : ""}" data-rk-filter="${value}">${rkEscape(label)}</button>`)
163
+ .join("");
111
164
  const newTile = `<a class="rk-library-new" data-rk-newtile href="/discover">
112
- <span class="rk-tile rk-tile-lg rk-tile-gold rk-library-new-plus">+</span>
165
+ <span class="rk-tile rk-tile-lg rk-tile-gold rk-library-new-plus">+</span>
113
166
  <div>
114
167
  <div class="rk-library-new-title">New project</div>
115
168
  <div class="rk-library-new-sub">Fork a template or start from a prompt</div>
116
169
  </div>
117
170
  </a>`;
118
- const cards = PROJECTS.map(projectCard).join("");
171
+ const cards = posts.map((p) => projectCard(p, accountId)).join("");
119
172
  return `
120
- <div class="rk-library-summary"><b>${PROJECTS.length}</b> projects&nbsp;·&nbsp;<b>${count("rendering")}</b> rendering&nbsp;·&nbsp;<b>${count("ready")}</b> ready to post&nbsp;·&nbsp;<b>${count("posted")}</b> posted this week</div>
173
+ <div class="rk-library-topbar">
174
+ ${tabs}
175
+ ${searchBar}
176
+ </div>
121
177
 
122
178
  <div class="rk-library-toolbar">
123
179
  <div class="rk-library-chips" role="tablist" aria-label="Filter by status">${chips}</div>
124
- <input class="rk-input rk-library-search" data-rk-search type="search" placeholder="Search projects…" autocomplete="off" aria-label="Search projects">
180
+ <div class="rk-library-summary"><b>${posts.length}</b> published cut${posts.length === 1 ? "" : "s"}&nbsp;·&nbsp;<b>${readyCount}</b> ready to post&nbsp;·&nbsp;<b>${scheduledCount}</b> scheduled</div>
125
181
  </div>
126
182
 
127
183
  <div class="rk-library-grid" data-rk-grid>
@@ -131,87 +187,54 @@ function approvedPanel() {
131
187
 
132
188
  <div class="rk-library-empty" data-rk-empty>
133
189
  <h3>Nothing here yet</h3>
134
- <p class="rk-muted">No projects match this filter. Try another status or spin up a fresh cut.</p>
190
+ <p class="rk-muted">No cuts match this filter. Try another status or spin up a fresh render.</p>
135
191
  <a class="rk-btn rk-btn-gold" href="/discover">Start a project <span class="rk-arrow">→</span></a>
136
192
  </div>`;
137
193
  }
138
- const CLIP_DATA = {
139
- stats: { clips: 128, sources: 14, minutes: 42, taggedPct: 100 },
140
- filters: [
141
- { slug: "all", label: "All clips" },
142
- { slug: "reactions", label: "Reactions" },
143
- { slug: "hooks", label: "Hooks & hot takes" },
144
- { slug: "money", label: "Money shots" },
145
- { slug: "talking", label: "Talking head" },
146
- { slug: "broll", label: "B-roll" },
147
- { slug: "establishing", label: "Establishing" }
148
- ],
149
- clips: [
150
- { title: "Guest freezes mid-sentence after the host's cold-read stat", durationSec: 18, aspect: "9:16", score: 94, source: "The Founder Hour — Ep. 212", tags: ["reaction", "close-up", "high energy"], glyph: "😮", tone: "gold", cat: "reactions" },
151
- { title: "“That number can't be real” — investor's slow double-take", durationSec: 19, aspect: "9:16", score: 93, source: "Contrarian Capital — Ep. 11", tags: ["reaction", "surprise", "close-up"], glyph: "😳", tone: "coral", cat: "reactions" },
152
- { title: "The “nobody talks about this” hook that opened the episode", durationSec: 22, aspect: "9:16", score: 96, source: "Contrarian Capital Ep. 09", tags: ["hook", "hot take", "talking"], glyph: "🎤", tone: "ink", cat: "hooks" },
153
- { title: "Hands counting a stack of hundreds on the desk", durationSec: 15, aspect: "9:16", score: 88, source: "Cashflow Diaries — Ep. 17", tags: ["money", "hands", "macro"], glyph: "💵", tone: "green", cat: "money" },
154
- { title: "Founder tears up recalling the very first sale", durationSec: 31, aspect: "9:16", score: 90, source: "The Founder Hour — Ep. 205", tags: ["emotion", "close-up", "calm"], glyph: "🥹", tone: "violet", cat: "talking" },
155
- { title: "Slow push-in on the neon “SOLD OUT” window sign", durationSec: 12, aspect: "16:9", score: 78, source: "Retail Nerds — Ep. 40", tags: ["b-roll", "establishing", "calm"], glyph: "🪧", tone: "sky", cat: "broll" },
156
- { title: "City skyline timelapse at golden hour", durationSec: 14, aspect: "16:9", score: 74, source: "Built Different Ep. 51", tags: ["establishing", "b-roll", "wide"], glyph: "🌇", tone: "coral", cat: "establishing" },
157
- { title: "Whiteboard reveal: the four-step funnel, drawn live", durationSec: 27, aspect: "16:9", score: 82, source: "Growth Loops — Ep. 63", tags: ["explainer", "talking", "medium"], glyph: "📊", tone: "sky", cat: "talking" },
158
- { title: "Barista latte-art pour in extreme slow-mo", durationSec: 16, aspect: "9:16", score: 80, source: "Small Shop Big Dreams — Ep. 30", tags: ["b-roll", "macro", "calm"], glyph: "☕", tone: "gold", cat: "broll" },
159
- { title: "Coins dropping into a glass jar, tight macro", durationSec: 13, aspect: "9:16", score: 85, source: "Cashflow Diaries — Ep. 21", tags: ["money", "macro", "sfx"], glyph: "🪙", tone: "green", cat: "money" },
160
- { title: "He didn't realize the camera was still rolling", durationSec: 24, aspect: "9:16", score: 91, source: "Lightbulb Pod — Ep. 88", tags: ["reaction", "candid", "high energy"], glyph: "😅", tone: "violet", cat: "reactions" },
161
- { title: "The mic-drop closing line, right before the outro", durationSec: 21, aspect: "16:9", score: 89, source: "Lightbulb Pod — Ep. 90", tags: ["hook", "hot take", "talking"], glyph: "🎙", tone: "ink", cat: "hooks" }
162
- ]
163
- };
164
- const SEARCH_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>`;
165
- function fmtClock(sec) {
166
- const m = Math.floor(sec / 60);
167
- const s = Math.floor(sec % 60);
168
- return `${m}:${s < 10 ? "0" : ""}${s}`;
169
- }
170
- function clipCard(clip) {
171
- const tagLine = clip.tags.slice(0, 3).map((t) => rkEscape(t)).join(" · ");
172
- return `
173
- <article class="rk-clips-card rk-card-hover" data-cat="${rkEscape(clip.cat)}">
174
- <div class="rk-clips-thumb rk-clips-g-${clip.tone}">
175
- <span class="rk-clips-glyph" aria-hidden="true">${clip.glyph}</span>
176
- <span class="rk-clips-dur">${fmtClock(clip.durationSec)}</span>
177
- <button type="button" class="rk-clips-play" aria-label="Preview clip">▶</button>
178
- </div>
179
- <div class="rk-clips-body">
180
- <h3 class="rk-clips-title">${rkEscape(clip.title)}</h3>
181
- <div class="rk-clips-tagline">${tagLine}</div>
182
- <div class="rk-clips-foot">
183
- <span class="rk-clips-src" title="${rkEscape(clip.source)}">${rkEscape(clip.source)}</span>
184
- <a class="rk-clips-dl" href="#">Download</a>
185
- </div>
186
- </div>
187
- </article>`;
188
- }
189
- function clipsPanel() {
190
- const s = CLIP_DATA;
191
- const filterChips = s.filters.map((f, i) => `<button type="button" class="rk-clips-chip${i === 0 ? " is-active" : ""}" data-filter="${rkEscape(f.slug)}">${rkEscape(f.label)}</button>`).join("");
192
- const cards = s.clips.map(clipCard).join("");
194
+ /* ──────────────────────────────── Clips tab ───────────────────────────────── */
195
+ // The clip library is NOT part of the library route's input — the client fetches
196
+ // the REAL endpoints on load: GET /clips/feed (library), POST /clips/search
197
+ // (hybrid semantic search), POST /clips/scan + GET /clips/scan/:id (mine a
198
+ // source), download via /clips/:clipId/download.
199
+ // Filter chips double as real one-tap semantic searches (each hits /clips/search
200
+ // with its phrase); "All clips" reloads the feed.
201
+ const CLIP_PRESETS = [
202
+ { slug: "all", label: "All raws", q: "" },
203
+ { slug: "reactions", label: "Reactions", q: "someone reacting, a surprised or emotional reaction close-up" },
204
+ { slug: "hooks", label: "Hooks & hot takes", q: "a strong opening hook or hot take, talking to camera" },
205
+ { slug: "money", label: "Money shots", q: "money, cash, coins or payment in close-up" },
206
+ { slug: "talking", label: "Talking head", q: "a person talking to the camera, medium shot" },
207
+ { slug: "broll", label: "B-roll", q: "b-roll cutaway footage with no one talking" },
208
+ { slug: "establishing", label: "Establishing", q: "a wide establishing shot of a place" }
209
+ ];
210
+ function clipsPanel(tabs) {
211
+ const filterChips = CLIP_PRESETS
212
+ .map((f, i) => `<button type="button" class="rk-clips-chip${i === 0 ? " is-active" : ""}" data-filter="${rkEscape(f.slug)}" data-q="${rkEscape(f.q)}">${rkEscape(f.label)}</button>`)
213
+ .join("");
193
214
  return `
194
- <div class="rk-library-summary"><b>${s.stats.clips}</b> clips&nbsp;·&nbsp;<b>${s.stats.sources}</b> sources&nbsp;·&nbsp;<b>${s.stats.minutes}</b> min mined&nbsp;·&nbsp;<b>${s.stats.taggedPct}%</b> tagged</div>
215
+ <div class="rk-library-topbar">
216
+ ${tabs}
217
+ <form class="rk-clips-search" id="rkClipsSearch">
218
+ <span class="rk-clips-search-ic">${SEARCH_ICON}</span>
219
+ <input class="rk-input rk-clips-search-input" id="rkClipsQuery" type="search"
220
+ placeholder="Search raws — e.g. &quot;someone looks confused after reading a message&quot;" autocomplete="off" />
221
+ <button class="rk-btn rk-btn-ink rk-btn-sm rk-clips-search-btn" type="submit">Search</button>
222
+ </form>
223
+ </div>
195
224
 
196
225
  <div class="rk-clips-toolbar">
197
- <div class="rk-clips-toolbar-top">
198
- <form class="rk-clips-search" id="rkClipsSearch">
199
- <span class="rk-clips-search-ic">${SEARCH_ICON}</span>
200
- <input class="rk-input rk-clips-search-input" id="rkClipsQuery" type="search"
201
- placeholder="Search clips — e.g. &quot;someone looks confused after reading a message&quot;" autocomplete="off" />
202
- <button class="rk-btn rk-btn-ink rk-btn-sm rk-clips-search-btn" type="submit">Search</button>
203
- </form>
204
- <button type="button" class="rk-btn rk-btn-gold rk-clips-scan" id="rkClipsScanBtn">Scan a source</button>
226
+ <div class="rk-clips-chipsrow">
227
+ <div class="rk-clips-chips" id="rkClipsChips">${filterChips}</div>
228
+ <div class="rk-library-summary" id="rkClipsSummary">Loading your raws library…</div>
205
229
  </div>
206
- <div class="rk-clips-chips" id="rkClipsChips">${filterChips}</div>
207
- <div class="rk-clips-status" id="rkClipsStatus">Showing all ${s.clips.length} clips from ${s.stats.sources} sources</div>
230
+ <div class="rk-clips-status" id="rkClipsStatus">Loading your raws…</div>
208
231
  </div>
209
232
 
210
- <div class="rk-clips-grid" id="rkClipsGrid">${cards}</div>
233
+ <div class="rk-clips-grid" id="rkClipsGrid"></div>
211
234
 
212
235
  <div class="rk-clips-empty" id="rkClipsEmpty" hidden>
213
- <h3>No clips match that filter yet</h3>
214
- <p class="rk-muted">Try another tag, or scan a new source to grow your library.</p>
236
+ <h3>No clips yet</h3>
237
+ <p class="rk-muted">Click <b>Import source</b> to mine a long video into tagged, searchable short clips.</p>
215
238
  </div>`;
216
239
  }
217
240
  const CLIPS_DIALOG = `
@@ -220,7 +243,7 @@ const CLIPS_DIALOG = `
220
243
  <div class="rk-card-head">
221
244
  <span class="rk-eyebrow">Import source</span>
222
245
  <h2>Mine a video into clips</h2>
223
- <p class="rk-muted">Paste a video URL — TikTok, YouTube, Instagram, X, or a direct link. We'll scan it and add the matching moments to your clip library.</p>
246
+ <p class="rk-muted">Paste a video URL — TikTok, YouTube, Instagram, X, or a direct link. We'll scan it and add the matching moments to your raws library.</p>
224
247
  </div>
225
248
  <div class="rk-field">
226
249
  <label class="rk-label" for="rkScanUrl">Video URL</label>
@@ -246,10 +269,10 @@ const CLIPS_DIALOG = `
246
269
  <input class="rk-input" id="rkScanTracer" name="tracer" type="text" placeholder="my-campaign-001" autocomplete="off" />
247
270
  </div>
248
271
  </div>
249
- <p class="rk-hint rk-clips-dialog-note">AI tagging runs on your own key (BYOK) — vidfarm only bills the clip-scan compute.</p>
272
+ <p class="rk-hint rk-clips-dialog-note" id="rkScanNote">AI tagging runs on your own key (BYOK) — vidfarm only bills the clip-scan compute.</p>
250
273
  <div class="rk-clips-dialog-actions">
251
274
  <button type="button" class="rk-btn rk-btn-ghost" id="rkClipsScanCancel">Cancel</button>
252
- <button type="submit" class="rk-btn rk-btn-gold">Start scan <span class="rk-arrow">→</span></button>
275
+ <button type="submit" class="rk-btn rk-btn-gold" id="rkClipsScanSubmit">Start scan <span class="rk-arrow">→</span></button>
253
276
  </div>
254
277
  </form>
255
278
  </dialog>`;
@@ -285,65 +308,146 @@ const APPROVED_SCRIPT = `
285
308
  });
286
309
  });
287
310
  if(search)search.addEventListener('input',apply);
311
+ // hover-to-play the real MP4 previews
312
+ root.querySelectorAll('video.rk-library-video').forEach(function(v){
313
+ var card=v.closest('.rk-library-card');
314
+ if(!card)return;
315
+ card.addEventListener('mouseenter',function(){ if(v.play){var p=v.play(); if(p&&p.catch)p.catch(function(){});} });
316
+ card.addEventListener('mouseleave',function(){ if(v.pause)v.pause(); });
317
+ });
288
318
  apply();
289
319
  })();`;
290
320
  const CLIPS_SCRIPT = `
291
321
  (function(){
292
322
  var root=document;
293
- var chips=root.getElementById('rkClipsChips');
294
323
  var grid=root.getElementById('rkClipsGrid');
295
324
  var statusEl=root.getElementById('rkClipsStatus');
325
+ var summaryEl=root.getElementById('rkClipsSummary');
296
326
  var empty=root.getElementById('rkClipsEmpty');
297
- var cards=grid?Array.prototype.slice.call(grid.querySelectorAll('.rk-clips-card')):[];
298
- var total=cards.length;
299
- function labelFor(slug){
300
- var btn=chips&&chips.querySelector('[data-filter="'+slug+'"]');
301
- return btn?btn.textContent.replace(/^[^A-Za-z0-9]+/,'').trim():'';
327
+ var chips=root.getElementById('rkClipsChips');
328
+ var form=root.getElementById('rkClipsSearch');
329
+ var query=root.getElementById('rkClipsQuery');
330
+
331
+ function esc(t){ var d=document.createElement('div'); d.textContent=(t==null?'':String(t)); return d.innerHTML; }
332
+ function fmtClock(sec){ sec=Number(sec)||0; var m=Math.floor(sec/60), s=Math.floor(sec%60); return m+':'+(s<10?'0':'')+s; }
333
+ function topTags(tags){
334
+ if(!tags||typeof tags!=='object') return [];
335
+ var out=[];
336
+ ['subject','action','emotion','composition'].forEach(function(k){ (tags[k]||[]).slice(0,1).forEach(function(v){ out.push(v); }); });
337
+ if(tags.energy) out.push('energy:'+tags.energy);
338
+ return out.slice(0,4);
302
339
  }
303
- function applyFilter(slug){
304
- var shown=0;
305
- cards.forEach(function(card){
306
- var on=slug==='all'||card.getAttribute('data-cat')===slug;
307
- card.style.display=on?'':'none';
308
- if(on)shown++;
340
+ function setGridEmpty(show){ if(empty)empty.hidden=!show; if(grid)grid.style.display=show?'none':''; }
341
+
342
+ function card(clip){
343
+ var range=fmtClock(clip.start_time_sec)+''+fmtClock(clip.end_time_sec);
344
+ var dur=(clip.duration_sec!=null)?(Number(clip.duration_sec).toFixed(1)+'s'):'';
345
+ var media=clip.thumbnail_url
346
+ ? '<img class="rk-clips-thumb-img" loading="lazy" src="'+esc(clip.thumbnail_url)+'" alt="" />'
347
+ : '<span class="rk-clips-noimg">No preview</span>';
348
+ var vid=clip.view_url ? '<video class="rk-clips-thumb-vid" muted loop playsinline preload="none" src="'+esc(clip.view_url)+'"></video>' : '';
349
+ var tags=topTags(clip.tags).map(function(t){ return '<span class="rk-clips-tag">'+esc(t)+'</span>'; }).join('');
350
+ var desc=clip.description || (clip.tags&&clip.tags.transcript) || '(untagged clip)';
351
+ var dl='/raws/'+encodeURIComponent(clip.clip_id)+'/download';
352
+ return '<article class="rk-clips-card rk-card-hover">'
353
+ + '<div class="rk-clips-thumb">'+media+vid
354
+ + '<span class="rk-clips-dur">'+esc(range+(dur?' · '+dur:''))+'</span>'
355
+ + '<button type="button" class="rk-clips-play" aria-label="Preview clip">'+String.fromCharCode(9654)+'</button>'
356
+ + '</div>'
357
+ + '<div class="rk-clips-body">'
358
+ + '<h3 class="rk-clips-title">'+esc(desc)+'</h3>'
359
+ + (tags?'<div class="rk-clips-tags">'+tags+'</div>':'')
360
+ + '<div class="rk-clips-foot"><span class="rk-clips-src" title="'+esc(clip.source_filename)+'">'+esc(clip.source_filename||'')+'</span>'
361
+ + '<a class="rk-clips-dl" href="'+dl+'">Download</a></div>'
362
+ + '</div></article>';
363
+ }
364
+
365
+ function bindHover(){
366
+ if(!grid)return;
367
+ Array.prototype.slice.call(grid.querySelectorAll('.rk-clips-thumb')).forEach(function(thumb){
368
+ var v=thumb.querySelector('.rk-clips-thumb-vid');
369
+ if(!v)return;
370
+ thumb.addEventListener('mouseenter',function(){ v.style.display='block'; var p=v.play&&v.play(); if(p&&p.catch)p.catch(function(){}); });
371
+ thumb.addEventListener('mouseleave',function(){ if(v.pause)v.pause(); v.style.display='none'; });
372
+ });
373
+ }
374
+
375
+ function render(clips,label){
376
+ clips=clips||[];
377
+ if(clips.length===0){
378
+ if(statusEl)statusEl.textContent=label||'';
379
+ if(summaryEl)summaryEl.innerHTML='<b>0</b> clips in your library';
380
+ if(grid)grid.innerHTML='';
381
+ setGridEmpty(true);
382
+ return;
383
+ }
384
+ setGridEmpty(false);
385
+ if(statusEl)statusEl.textContent=(label||'')+clips.length+' clip'+(clips.length===1?'':'s');
386
+ if(summaryEl)summaryEl.innerHTML='<b>'+clips.length+'</b> clip'+(clips.length===1?'':'s')+' shown';
387
+ if(grid)grid.innerHTML=clips.map(card).join('');
388
+ bindHover();
389
+ }
390
+
391
+ function handleAuth(res){
392
+ if(res.status===401||res.status===403){
393
+ if(grid)grid.innerHTML='';
394
+ setGridEmpty(false);
395
+ if(statusEl)statusEl.innerHTML='Sign in to vidfarm to see your raws, then build a library with the Import source button or <code>vidfarm raws scan</code>.';
396
+ if(summaryEl)summaryEl.textContent='Sign in to view your raws library';
397
+ throw new Error('unauthorized');
398
+ }
399
+ return res.json();
400
+ }
401
+
402
+ function loadFeed(){
403
+ if(statusEl)statusEl.textContent='Loading your raws…';
404
+ fetch('/raws/feed?limit=120',{credentials:'same-origin',headers:{Accept:'application/json'}})
405
+ .then(handleAuth).then(function(d){ render(d.clips,''); }).catch(function(){});
406
+ }
407
+
408
+ function search(q,label){
409
+ if(statusEl)statusEl.textContent='Searching…';
410
+ fetch('/raws/search',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json',Accept:'application/json'},body:JSON.stringify({query:q,limit:60})})
411
+ .then(handleAuth).then(function(d){
412
+ var lbl=(d.semantic?'semantic ':'')+'results for '+String.fromCharCode(8220)+(label||q)+String.fromCharCode(8221)+' — ';
413
+ render(d.results,lbl);
414
+ }).catch(function(){});
415
+ }
416
+
417
+ if(form){
418
+ form.addEventListener('submit',function(e){
419
+ e.preventDefault();
420
+ var q=(query&&query.value||'').trim();
421
+ if(chips)chips.querySelectorAll('.rk-clips-chip').forEach(function(b){b.classList.remove('is-active');});
422
+ if(q)search(q,null); else { var all=chips&&chips.querySelector('[data-filter="all"]'); if(all)all.classList.add('is-active'); loadFeed(); }
309
423
  });
310
- if(empty)empty.hidden=shown!==0;
311
- if(grid)grid.style.display=shown===0?'none':'';
312
- if(statusEl){ statusEl.textContent=slug==='all'?'Showing all '+total+' clips':'Showing '+shown+' '+labelFor(slug)+' clip'+(shown===1?'':'s'); }
313
424
  }
314
425
  if(chips){
315
426
  chips.addEventListener('click',function(e){
316
427
  var btn=e.target.closest('.rk-clips-chip');
317
428
  if(!btn)return;
318
429
  chips.querySelectorAll('.rk-clips-chip').forEach(function(b){b.classList.toggle('is-active',b===btn);});
319
- applyFilter(btn.getAttribute('data-filter'));
320
- });
321
- }
322
- var searchForm=root.getElementById('rkClipsSearch');
323
- var query=root.getElementById('rkClipsQuery');
324
- if(searchForm){
325
- searchForm.addEventListener('submit',function(e){
326
- e.preventDefault();
327
- var q=(query&&query.value||'').trim().toLowerCase();
328
- if(!q){ if(chips){var all=chips.querySelector('[data-filter="all"]'); if(all)all.click();} return; }
329
- chips&&chips.querySelectorAll('.rk-clips-chip').forEach(function(b){b.classList.remove('is-active');});
330
- var shown=0;
331
- cards.forEach(function(card){
332
- var hay=(card.textContent||'').toLowerCase();
333
- var on=q.split(/\\s+/).every(function(t){return hay.indexOf(t)>=0;});
334
- card.style.display=on?'':'none';
335
- if(on)shown++;
336
- });
337
- if(empty)empty.hidden=shown!==0;
338
- if(grid)grid.style.display=shown===0?'none':'';
339
- if(statusEl)statusEl.textContent='semantic results for \\u201c'+q+'\\u201d — '+shown+' clip'+(shown===1?'':'s');
430
+ var slug=btn.getAttribute('data-filter');
431
+ var q=btn.getAttribute('data-q')||'';
432
+ if(query)query.value='';
433
+ if(slug==='all'||!q)loadFeed(); else search(q,btn.textContent.trim());
340
434
  });
341
435
  }
436
+
437
+ // ── Scan a source (real clip hunt) ─────────────────────────────────────
342
438
  var dialog=root.getElementById('rkClipsDialog');
343
- var openBtn=root.getElementById('rkClipsScanBtn');
439
+ var openBtns=Array.prototype.slice.call(root.querySelectorAll('[data-rk-open-scan]'));
344
440
  var cancelBtn=root.getElementById('rkClipsScanCancel');
345
441
  var scanForm=root.getElementById('rkClipsScanForm');
346
- if(openBtn&&dialog){ openBtn.addEventListener('click',function(){ if(typeof dialog.showModal==='function'&&!dialog.open)dialog.showModal(); }); }
442
+ var scanUrl=root.getElementById('rkScanUrl');
443
+ var scanPrompt=root.getElementById('rkScanPrompt');
444
+ var scanProvider=root.getElementById('rkScanProvider');
445
+ var scanTracer=root.getElementById('rkScanTracer');
446
+ var scanNote=root.getElementById('rkScanNote');
447
+ var scanSubmit=root.getElementById('rkClipsScanSubmit');
448
+ var scanPoll=null;
449
+ function setNote(msg,tone){ if(!scanNote)return; scanNote.textContent=msg; if(tone)scanNote.setAttribute('data-tone',tone); else scanNote.removeAttribute('data-tone'); }
450
+ if(dialog){ openBtns.forEach(function(openBtn){ openBtn.addEventListener('click',function(){ setNote('AI tagging runs on your own key (BYOK) — vidfarm only bills the clip-scan compute.'); if(typeof dialog.showModal==='function'&&!dialog.open)dialog.showModal(); }); }); }
347
451
  if(cancelBtn&&dialog){ cancelBtn.addEventListener('click',function(){ if(dialog.open)dialog.close(); }); }
348
452
  if(dialog){
349
453
  dialog.addEventListener('click',function(e){
@@ -351,80 +455,150 @@ const CLIPS_SCRIPT = `
351
455
  if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)dialog.close();
352
456
  });
353
457
  }
354
- if(scanForm){ scanForm.addEventListener('submit',function(){ if(statusEl)statusEl.textContent='Scanning source… this can take a few minutes.'; }); }
458
+ function pollScan(scanId){
459
+ if(scanPoll)clearInterval(scanPoll);
460
+ var tries=0;
461
+ scanPoll=setInterval(function(){
462
+ tries++;
463
+ if(tries>200){ clearInterval(scanPoll); scanPoll=null; if(statusEl)statusEl.textContent='Still scanning in the background — check back shortly.'; return; }
464
+ fetch('/raws/scan/'+encodeURIComponent(scanId),{credentials:'same-origin',headers:{Accept:'application/json'}})
465
+ .then(function(r){ return r.ok?r.json():null; })
466
+ .then(function(d){
467
+ if(!d)return;
468
+ var src=d.source||{};
469
+ var status=src.status||(d.scan&&d.scan.status)||'running';
470
+ if(status==='complete'){ clearInterval(scanPoll); scanPoll=null; if(statusEl)statusEl.textContent='Import complete — refreshing your raws…'; loadFeed(); }
471
+ else if(status==='failed'||status==='error'){ clearInterval(scanPoll); scanPoll=null; if(statusEl)statusEl.textContent='Import failed: '+(src.error||(d.scan&&d.scan.error)||'the source could not be scanned.'); }
472
+ else if(statusEl){ statusEl.textContent='Scanning source… ('+status+')'; }
473
+ }).catch(function(){});
474
+ },4000);
475
+ }
476
+ if(scanForm){
477
+ scanForm.addEventListener('submit',function(e){
478
+ e.preventDefault();
479
+ var url=(scanUrl&&scanUrl.value||'').trim();
480
+ var prompt=(scanPrompt&&scanPrompt.value||'').trim();
481
+ var tracer=(scanTracer&&scanTracer.value||'').trim();
482
+ var provider=(scanProvider&&scanProvider.value||'').trim();
483
+ if(!url){ setNote('Enter a video URL.','error'); return; }
484
+ setNote('Starting scan…');
485
+ if(scanSubmit)scanSubmit.disabled=true;
486
+ var payload={source_url:url,prompt:prompt};
487
+ if(tracer)payload.tracer=tracer;
488
+ if(provider)payload.provider=provider;
489
+ fetch('/raws/scan',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json',Accept:'application/json'},body:JSON.stringify(payload)})
490
+ .then(function(r){ return r.json().then(function(d){ return {ok:r.ok,data:d}; }); })
491
+ .then(function(res){
492
+ if(scanSubmit)scanSubmit.disabled=false;
493
+ if(!res.ok){ setNote((res.data&&res.data.error)||'Could not start the scan.','error'); return; }
494
+ setNote('Scan started — this can take a few minutes.','success');
495
+ if(statusEl)statusEl.textContent='Scanning source…'+(res.data&&res.data.tracer?' (tracer '+res.data.tracer+')':'');
496
+ if(dialog&&dialog.open)dialog.close();
497
+ if(scanForm.reset)scanForm.reset();
498
+ if(res.data&&res.data.scan_id)pollScan(res.data.scan_id);
499
+ })
500
+ .catch(function(){ if(scanSubmit)scanSubmit.disabled=false; setNote('Network error — please try again.','error'); });
501
+ });
502
+ }
503
+
504
+ loadFeed();
355
505
  })();`;
356
- export function renderReskinLibrary(tab = "approved") {
357
- const isClips = tab === "clips";
506
+ export function renderReskinLibrary(tab = "approved", input = EMPTY_LIBRARY_INPUT) {
507
+ const isRaws = tab === "raws";
508
+ const isLogs = tab === "logs";
509
+ const tabDef = [
510
+ { key: "approved", label: "Approved", href: "/library/approved" },
511
+ { key: "raws", label: "Raws", href: "/library/raws" },
512
+ { key: "logs", label: "Logs", href: "/library/logs" }
513
+ ];
358
514
  const tabs = `<div class="rk-library-tabs" role="tablist" aria-label="Library view">
359
- <a class="rk-library-tab${isClips ? "" : " is-active"}" role="tab" ${isClips ? "" : `aria-current="page"`} href="/reskin/library/approved">Approved</a>
360
- <a class="rk-library-tab${isClips ? " is-active" : ""}" role="tab" ${isClips ? `aria-current="page"` : ""} href="/reskin/library/clips">Clips</a>
515
+ ${tabDef.map((t) => `<a class="rk-library-tab${t.key === tab ? " is-active" : ""}" role="tab"${t.key === tab ? ` aria-current="page"` : ""} href="${t.href}">${t.label}</a>`).join("\n ")}
361
516
  </div>`;
517
+ // Logs tab reuses the real /job-runs history endpoint (client-fetched by the
518
+ // folded-in job-runs script); the seed is empty so it loads live.
519
+ const logsBoot = {
520
+ loadEndpoint: "/job-runs/history?limit=250",
521
+ accountId: input.currentAccountId || "",
522
+ storageDriver: "s3",
523
+ entries: []
524
+ };
525
+ const headSub = isLogs
526
+ ? "Every render, clip scan, decompose and primitive job — with status, timing, and the compute it cost."
527
+ : isRaws
528
+ ? "Long-form videos mined into tagged, searchable short clips — every reaction, hook, and money shot, ready to drop into an edit."
529
+ : "Every published cut you own — ready-to-post renders and scheduled drops. Open in the editor, share, download, or schedule.";
530
+ const headCta = isRaws
531
+ ? `<button type="button" class="rk-btn rk-btn-gold rk-btn-sm rk-library-new-btn" data-rk-open-scan>Import source <span class="rk-arrow">→</span></button>`
532
+ : isLogs
533
+ ? ""
534
+ : `<a class="rk-btn rk-btn-gold rk-btn-sm rk-library-new-btn" href="/discover">New project <span class="rk-arrow">→</span></a>`;
535
+ const panel = isLogs
536
+ ? `<div class="rk-library-topbar">${tabs}</div>\n ${jobRunsPanel(logsBoot)}`
537
+ : isRaws
538
+ ? clipsPanel(tabs)
539
+ : approvedPanel(input, tabs);
362
540
  const body = `
363
541
  <main class="rk-container-wide rk-page rk-library-page">
364
- <div class="rk-spread rk-wrap rk-page-head rk-library-head">
542
+ <div class="rk-spread rk-page-head rk-library-head">
365
543
  <div class="rk-stack rk-library-head-copy">
366
- <span class="rk-badge">Your studio</span>
367
544
  <h1 class="rk-h1">Library</h1>
368
- <p class="rk-sub">${isClips
369
- ? "Long-form videos mined into tagged, searchable short clips — every reaction, hook, and money shot, ready to drop into an edit."
370
- : "Every project you own — forked templates, in-progress edits, active renders, and ready-to-post cuts."}</p>
371
- </div>
372
- <div class="rk-row rk-wrap rk-library-head-cta">
373
- <a class="rk-btn rk-btn-gold rk-btn-lg" href="/discover">New project <span class="rk-arrow">→</span></a>
545
+ <p class="rk-sub">${headSub}</p>
374
546
  </div>
547
+ ${headCta}
375
548
  </div>
376
549
 
377
- ${tabs}
378
-
379
- ${isClips ? clipsPanel() : approvedPanel()}
550
+ ${panel}
380
551
  </main>
381
- ${isClips ? CLIPS_DIALOG : ""}`;
552
+ ${isRaws ? CLIPS_DIALOG : ""}`;
382
553
  const pageCss = `
383
554
  /* .rk-page zeroes horizontal padding; the wide container == viewport at 1280 so
384
555
  margin:auto adds no gutter. Restore side gutters (longhand beats the shorthand). */
385
556
  .rk-library-page{padding-left:var(--rk-gutter);padding-right:var(--rk-gutter)}
386
- .rk-library-head{align-items:flex-end}
387
- .rk-library-head-copy{gap:8px;margin:0}
388
- .rk-library-head-copy .rk-badge{justify-self:start}
389
- .rk-library-head-cta{margin-bottom:4px}
390
- @media(max-width:620px){.rk-library-head-cta .rk-btn-lg{flex:1}}
557
+ /* compact head: title + sub left, small New project pinned top-right on the same row */
558
+ .rk-library-head{align-items:flex-start}
559
+ .rk-library-head-copy{gap:4px;margin:0;flex:1 1 auto;min-width:0}
560
+ .rk-library-new-btn{flex:none;margin-top:2px}
561
+
562
+ /* topbar row: primary tab toggle + the tab's quick search share one line */
563
+ .rk-library-topbar{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin-bottom:22px}
391
564
 
392
565
  /* primary tab toggle (Approved | Clips) */
393
- .rk-library-tabs{display:inline-flex;padding:5px;gap:3px;background:var(--rk-n-100);border:1px solid var(--rk-border);border-radius:var(--rk-r-full);margin-bottom:26px}
566
+ .rk-library-tabs{display:inline-flex;flex:none;padding:5px;gap:3px;background:var(--rk-n-100);border:1px solid var(--rk-border);border-radius:var(--rk-r-full)}
394
567
  .rk-library-tab{display:inline-flex;align-items:center;gap:7px;padding:9px 20px;border-radius:var(--rk-r-full);font-size:14px;font-weight:600;color:var(--rk-n-600);transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
395
568
  .rk-library-tab span{font-size:15px}
396
569
  .rk-library-tab:hover{color:var(--rk-ink)}
397
570
  .rk-library-tab.is-active{background:var(--rk-ink);color:#fff;box-shadow:var(--rk-shadow-sm)}
398
571
 
399
- /* stats */
400
- .rk-library-summary{font-size:13.5px;color:var(--rk-text-muted);font-weight:500;margin-bottom:22px;line-height:1.7}
572
+ /* stats — sits right-aligned in the chips row */
573
+ .rk-library-summary{font-size:13.5px;color:var(--rk-text-muted);font-weight:500;line-height:1.7}
401
574
  .rk-library-summary b{color:var(--rk-ink);font-weight:800;font-family:var(--rk-font-display);font-size:15px;letter-spacing:-.01em}
575
+ .rk-library-toolbar .rk-library-summary,.rk-clips-chipsrow .rk-library-summary{margin-left:auto}
402
576
 
403
- /* toolbar */
577
+ /* toolbar (filter chips + count) */
404
578
  .rk-library-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin-bottom:24px}
405
579
  .rk-library-chips{display:flex;flex-wrap:wrap;gap:4px}
406
580
  .rk-library-chip{display:inline-flex;align-items:center;padding:6px 13px;border-radius:var(--rk-r-full);border:1px solid transparent;background:transparent;color:var(--rk-text-muted);font-size:13px;font-weight:600;cursor:pointer;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
407
581
  .rk-library-chip:hover{background:var(--rk-n-100);color:var(--rk-ink)}
408
582
  .rk-library-chip.is-active{background:var(--rk-ink);color:#fff}
409
- .rk-library-search{max-width:230px;margin-left:auto}
410
- @media(max-width:720px){.rk-library-search{max-width:none;margin-left:0;width:100%;order:3}}
583
+ /* approved quickfilter — same pill search as the Clips tab, right-aligned */
584
+ .rk-library-qsearch{flex:1 1 220px;max-width:360px;margin-left:auto}
585
+ .rk-library-qsearch .rk-clips-search-input:disabled{background:var(--rk-n-100);cursor:not-allowed}
586
+ @media(max-width:720px){.rk-library-qsearch{max-width:none;margin-left:0;width:100%}}
411
587
 
412
588
  /* grid + cards */
413
589
  .rk-library-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:20px}
414
590
  .rk-library-card{display:flex;flex-direction:column;background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);overflow:hidden;box-shadow:var(--rk-shadow-card)}
415
591
  .rk-library-card[hidden]{display:none}
416
- .rk-library-poster{position:relative;display:block;aspect-ratio:9/16;overflow:hidden}
592
+ .rk-library-poster{position:relative;display:block;aspect-ratio:9/16;overflow:hidden;background:linear-gradient(158deg,#2c2c2c,#171717)}
417
593
  .rk-library-poster::after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 58%,rgba(0,0,0,.22));pointer-events:none}
418
- .rk-library-emoji{position:absolute;inset:0;display:grid;place-items:center;font-size:60px;filter:drop-shadow(0 8px 14px rgba(0,0,0,.2))}
594
+ .rk-library-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#050505}
595
+ .rk-library-noprev{position:absolute;inset:0;display:grid;place-items:center;color:rgba(255,255,255,.72);font-size:12px;font-weight:600;letter-spacing:.01em}
419
596
  .rk-library-pstatus{position:absolute;top:12px;left:12px;z-index:2;box-shadow:var(--rk-shadow-sm)}
420
597
  .rk-library-dot{width:6px;height:6px;border-radius:var(--rk-r-full);background:currentColor;opacity:.9}
421
- .rk-library-dot.is-pulse{animation:rk-library-pulse 1.2s var(--rk-ease) infinite}
422
598
  .rk-library-dur{position:absolute;bottom:12px;right:12px;z-index:2;padding:4px 9px;border-radius:var(--rk-r-full);background:rgba(23,23,23,.72);color:#fff;font-size:11px;font-weight:600;letter-spacing:.01em;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}
423
599
  .rk-library-play{position:absolute;top:50%;left:50%;z-index:2;width:52px;height:52px;border-radius:var(--rk-r-full);background:rgba(255,255,255,.94);display:grid;place-items:center;color:var(--rk-ink);box-shadow:var(--rk-shadow-md);opacity:0;transform:translate(-50%,-50%) scale(.82);transition:opacity var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
424
600
  .rk-library-card:hover .rk-library-play{opacity:1;transform:translate(-50%,-50%) scale(1)}
425
601
  .rk-library-play svg{margin-left:2px}
426
- .rk-library-prog{position:absolute;left:0;right:0;bottom:0;z-index:2;height:5px;background:rgba(255,255,255,.4)}
427
- .rk-library-prog-fill{display:block;height:100%;background:var(--rk-gold-500);border-radius:0 var(--rk-r-full) var(--rk-r-full) 0;box-shadow:0 0 8px rgba(252,185,0,.6);animation:rk-library-pulse 1.6s var(--rk-ease) infinite}
428
602
  .rk-library-meta{display:grid;gap:10px;padding:16px 16px 18px}
429
603
  .rk-library-title{font-family:var(--rk-font-display);font-size:15px;font-weight:700;color:var(--rk-ink);line-height:1.24;letter-spacing:-.01em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:2.48em}
430
604
  .rk-library-origin{display:flex;align-items:center;gap:8px;min-width:0;font-size:12.5px;font-weight:500;color:var(--rk-text-muted)}
@@ -432,6 +606,8 @@ ${isClips ? CLIPS_DIALOG : ""}`;
432
606
  .rk-library-foot{margin-top:2px;gap:10px}
433
607
  .rk-library-foot .rk-btn{flex:none}
434
608
  .rk-library-time{font-size:12px;font-weight:500;color:var(--rk-text-faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
609
+ .rk-library-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
610
+ .rk-library-actions .rk-btn{flex:none}
435
611
 
436
612
  /* new-project tile */
437
613
  .rk-library-new{display:grid;grid-auto-flow:row;place-content:center;justify-items:center;gap:14px;text-align:center;padding:28px 22px;border:1.5px dashed var(--rk-border-strong);border-radius:var(--rk-r-3xl);background:var(--rk-n-50);color:var(--rk-text-muted);transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
@@ -447,13 +623,12 @@ ${isClips ? CLIPS_DIALOG : ""}`;
447
623
  .rk-library-empty h3{font-size:var(--rk-text-xl)}
448
624
  .rk-library-empty p{max-width:42ch}
449
625
 
450
- @keyframes rk-library-pulse{0%,100%{opacity:.5}50%{opacity:1}}
451
- @media(prefers-reduced-motion:reduce){.rk-library-dot.is-pulse,.rk-library-prog-fill{animation:none}}
626
+ @media(prefers-reduced-motion:reduce){.rk-library-video{}}
452
627
 
453
628
  /* ── clips tab ── */
454
629
  .rk-clips-toolbar{display:grid;gap:16px;margin-bottom:24px}
455
- .rk-clips-toolbar-top{display:flex;flex-wrap:wrap;align-items:center;gap:12px}
456
- .rk-clips-search{position:relative;display:flex;align-items:center;gap:8px;flex:1 1 340px;min-width:240px;margin:0}
630
+ .rk-clips-chipsrow{display:flex;flex-wrap:wrap;align-items:center;gap:12px}
631
+ .rk-clips-search{position:relative;display:flex;align-items:center;gap:8px;flex:1 1 300px;min-width:240px;margin:0}
457
632
  .rk-clips-search-ic{position:absolute;left:14px;top:50%;transform:translateY(-50%);color:var(--rk-n-400);pointer-events:none;display:flex}
458
633
  .rk-clips-search-input{flex:1;padding-left:40px;border-radius:var(--rk-r-full)}
459
634
  .rk-clips-search-btn{flex:none}
@@ -463,29 +638,25 @@ ${isClips ? CLIPS_DIALOG : ""}`;
463
638
  .rk-clips-chip:hover{background:var(--rk-n-100);color:var(--rk-ink)}
464
639
  .rk-clips-chip.is-active{background:var(--rk-ink);color:#fff}
465
640
  .rk-clips-status{font-size:13px;color:var(--rk-text-muted);font-weight:500}
641
+ .rk-clips-status code{font-family:var(--rk-font-mono,monospace);font-size:12px;background:var(--rk-n-100);padding:1px 5px;border-radius:6px}
466
642
  .rk-clips-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:22px}
467
643
  .rk-clips-card{display:flex;flex-direction:column;overflow:hidden;background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-2xl);box-shadow:var(--rk-shadow-card)}
468
- .rk-clips-thumb{position:relative;aspect-ratio:3/4;overflow:hidden;display:grid;place-items:center}
469
- .rk-clips-glyph{font-size:56px;filter:saturate(.9) drop-shadow(0 6px 14px rgba(0,0,0,.12));opacity:.92;transition:transform var(--rk-dur) var(--rk-ease)}
470
- .rk-clips-card:hover .rk-clips-glyph{transform:scale(1.06)}
471
- .rk-clips-dur{position:absolute;left:10px;bottom:10px;padding:3px 9px;border-radius:var(--rk-r-full);background:rgba(255,255,255,.92);color:var(--rk-ink);font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;box-shadow:var(--rk-shadow-xs)}
472
- .rk-clips-play{position:absolute;right:10px;top:10px;width:34px;height:34px;display:grid;place-items:center;padding:0 0 0 2px;border:0;border-radius:var(--rk-r-full);background:rgba(255,255,255,.92);color:var(--rk-ink);font-size:13px;cursor:pointer;opacity:0;transform:scale(.85);box-shadow:var(--rk-shadow-sm);transition:opacity var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
644
+ .rk-clips-thumb{position:relative;aspect-ratio:3/4;overflow:hidden;display:grid;place-items:center;background:linear-gradient(150deg,#2c2c2c,#171717)}
645
+ .rk-clips-thumb-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
646
+ .rk-clips-thumb-vid{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:none}
647
+ .rk-clips-noimg{color:rgba(255,255,255,.68);font-size:12px;font-weight:600;letter-spacing:.01em}
648
+ .rk-clips-dur{position:absolute;left:10px;bottom:10px;padding:3px 9px;border-radius:var(--rk-r-full);background:rgba(255,255,255,.92);color:var(--rk-ink);font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;box-shadow:var(--rk-shadow-xs);z-index:2}
649
+ .rk-clips-play{position:absolute;right:10px;top:10px;width:34px;height:34px;display:grid;place-items:center;padding:0 0 0 2px;border:0;border-radius:var(--rk-r-full);background:rgba(255,255,255,.92);color:var(--rk-ink);font-size:13px;cursor:pointer;opacity:0;transform:scale(.85);box-shadow:var(--rk-shadow-sm);z-index:2;transition:opacity var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
473
650
  .rk-clips-card:hover .rk-clips-play{opacity:1;transform:scale(1)}
474
651
  .rk-clips-play:hover{background:var(--rk-gold-500)}
475
652
  .rk-clips-body{display:flex;flex-direction:column;gap:10px;flex:1;padding:16px}
476
- .rk-clips-title{font-family:var(--rk-font-display);font-size:15px;font-weight:700;line-height:1.32;color:var(--rk-ink);letter-spacing:-.01em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
477
- .rk-clips-tagline{font-size:12px;font-weight:500;color:var(--rk-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
653
+ .rk-clips-title{font-family:var(--rk-font-display);font-size:14px;font-weight:700;line-height:1.34;color:var(--rk-ink);letter-spacing:-.01em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
654
+ .rk-clips-tags{display:flex;flex-wrap:wrap;gap:5px}
655
+ .rk-clips-tag{display:inline-flex;align-items:center;padding:2px 8px;border-radius:var(--rk-r-full);background:var(--rk-gold-tint);color:var(--rk-n-700);font-size:11px;font-weight:600;white-space:nowrap}
478
656
  .rk-clips-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:auto;border-top:1px solid var(--rk-border);padding-top:12px}
479
657
  .rk-clips-src{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--rk-text-muted);font-size:12px;font-weight:500}
480
658
  .rk-clips-dl{flex:none;color:var(--rk-gold-700);font-size:12px;font-weight:700}
481
659
  .rk-clips-dl:hover{color:var(--rk-gold-500)}
482
- .rk-clips-g-gold{background:radial-gradient(120% 90% at 25% 15%,#fff5e4,transparent 60%),linear-gradient(150deg,#ffe9cc,#ffd98a)}
483
- .rk-clips-g-sky{background:radial-gradient(120% 90% at 25% 15%,#f2f8ff,transparent 60%),linear-gradient(150deg,#e2effd,#b6d8f7)}
484
- .rk-clips-g-violet{background:radial-gradient(120% 90% at 25% 15%,#f6f1ff,transparent 60%),linear-gradient(150deg,#e4ddfd,#cbbdf5)}
485
- .rk-clips-g-green{background:radial-gradient(120% 90% at 25% 15%,#f0fdf4,transparent 60%),linear-gradient(150deg,#dcfce7,#b3ebc7)}
486
- .rk-clips-g-coral{background:radial-gradient(120% 90% at 25% 15%,#fff3ea,transparent 60%),linear-gradient(150deg,#ffe0d0,#ffc4a3)}
487
- .rk-clips-g-ink{background:radial-gradient(130% 100% at 78% 12%,rgba(255,199,56,.28),transparent 55%),linear-gradient(150deg,#2c2c2c,#171717)}
488
- .rk-clips-g-ink .rk-clips-glyph{filter:drop-shadow(0 6px 14px rgba(0,0,0,.4))}
489
660
  .rk-clips-empty{display:grid;place-items:center;gap:10px;text-align:center;padding:64px 20px;background:#fff;border:1.5px dashed var(--rk-border-strong);border-radius:var(--rk-r-3xl)}
490
661
  .rk-clips-empty[hidden]{display:none}
491
662
  .rk-clips-empty h3{font-size:var(--rk-text-xl)}
@@ -494,22 +665,24 @@ ${isClips ? CLIPS_DIALOG : ""}`;
494
665
  .rk-clips-dialog-form{display:grid;gap:16px;margin:0;padding:28px}
495
666
  .rk-clips-dialog-grid{gap:14px}
496
667
  .rk-clips-dialog-note{background:var(--rk-gold-tint);border-radius:var(--rk-r-xl);padding:10px 13px;color:var(--rk-n-700)}
668
+ .rk-clips-dialog-note[data-tone="error"]{background:#fdecea;color:#8d3b2f}
669
+ .rk-clips-dialog-note[data-tone="success"]{background:#e8f7ee;color:#1f7a44}
497
670
  .rk-clips-dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}
498
671
  @media(max-width:620px){
499
672
  .rk-clips-search{flex:1 1 100%}
500
673
  .rk-clips-scan{flex:1 1 100%}
501
674
  .rk-clips-search-input{font-size:16px}
502
675
  .rk-clips-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
503
- .rk-clips-glyph{font-size:42px}
504
676
  }
505
677
  `;
678
+ const tabTitle = isRaws ? " · Raws" : isLogs ? " · Logs" : "";
506
679
  return reskinDocument({
507
- title: `vidfarm reskin — Library${isClips ? " · Clips" : ""}`,
508
- description: "Reskinned vidfarm library: your projects, renders & ready-to-post cuts, plus mined clips.",
680
+ title: `vidfarm reskin — Library${tabTitle}`,
681
+ description: "Reskinned vidfarm library: your published cuts, mined raws library, and job run history.",
509
682
  activeSlug: "library",
510
- pageCss,
683
+ pageCss: pageCss + (isLogs ? "\n" + JOBRUNS_PAGE_CSS : ""),
511
684
  body,
512
- script: isClips ? CLIPS_SCRIPT : APPROVED_SCRIPT
685
+ script: isLogs ? JOBRUNS_SCRIPT : isRaws ? CLIPS_SCRIPT : APPROVED_SCRIPT
513
686
  });
514
687
  }
515
688
  //# sourceMappingURL=library-page.js.map