@mevdragon/vidfarm-devcli 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.agents/skills/vidfarm-media/SKILL.md +43 -6
  2. package/SKILL.director.md +37 -23
  3. package/SKILL.platform.md +6 -6
  4. package/demo/dist/app.js +69 -69
  5. package/demo/dist/favicon.ico +0 -0
  6. package/dist/src/app.js +1832 -213
  7. package/dist/src/cli.js +238 -17
  8. package/dist/src/devcli/clip-store.js +29 -2
  9. package/dist/src/devcli/clips.js +116 -73
  10. package/dist/src/devcli/composition-edit.js +262 -0
  11. package/dist/src/devcli/timeline-edit.js +283 -0
  12. package/dist/src/editor-chat.js +29 -6
  13. package/dist/src/frontend/discover-client.js +130 -0
  14. package/dist/src/frontend/discover-store.js +23 -0
  15. package/dist/src/frontend/file-directory.js +744 -0
  16. package/dist/src/frontend/template-editor-chat.js +22 -19
  17. package/dist/src/landing-page.js +24 -7
  18. package/dist/src/page-shell.js +25 -1
  19. package/dist/src/reskin/agency-page.js +214 -170
  20. package/dist/src/reskin/calendar-page.js +503 -187
  21. package/dist/src/reskin/chat-page.js +739 -299
  22. package/dist/src/reskin/discover-page.js +1450 -279
  23. package/dist/src/reskin/document.js +1392 -16
  24. package/dist/src/reskin/help-page.js +139 -100
  25. package/dist/src/reskin/index-page.js +1 -1
  26. package/dist/src/reskin/inpaint-page.js +547 -0
  27. package/dist/src/reskin/job-runs-page.js +355 -127
  28. package/dist/src/reskin/library-page.js +1188 -317
  29. package/dist/src/reskin/login-page.js +124 -87
  30. package/dist/src/reskin/pricing-page.js +197 -166
  31. package/dist/src/reskin/settings-page.js +566 -187
  32. package/dist/src/reskin/theme.js +434 -17
  33. package/dist/src/services/clip-curation/gemini.js +5 -0
  34. package/dist/src/services/clip-curation/hunt.js +79 -1
  35. package/dist/src/services/clip-curation/index.js +2 -1
  36. package/dist/src/services/clip-curation/local-agent.js +4 -3
  37. package/dist/src/services/clip-curation/media-select.js +85 -0
  38. package/dist/src/services/clip-curation/query.js +5 -1
  39. package/dist/src/services/clip-curation/refine.js +50 -20
  40. package/dist/src/services/clip-curation/scan.js +10 -3
  41. package/dist/src/services/clip-curation/taxonomy.js +3 -1
  42. package/dist/src/services/clip-curation/taxonomy.v1.json +13 -1
  43. package/dist/src/services/clip-records.js +14 -1
  44. package/dist/src/services/clip-search.js +43 -13
  45. package/dist/src/services/file-directory.js +114 -0
  46. package/dist/src/services/storage.js +24 -1
  47. package/dist/src/services/upstream.js +5 -5
  48. package/dist/src/template-editor-shell.js +16 -2
  49. package/package.json +1 -1
  50. package/public/assets/discover-client-app.js +1 -0
  51. package/public/assets/file-directory-app.js +2 -0
  52. package/public/assets/homepage-client-app.js +12 -12
  53. package/public/assets/page-runtime-client-app.js +24 -24
  54. package/src/assets/favicon.ico +0 -0
  55. package/src/assets/logo-vidfarm.png +0 -0
@@ -1,217 +1,307 @@
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
+ // Compact per-card action icons + viewer-modal chrome (ported from the /discover
41
+ // reskin TikTok viewer so the library modal reads identical).
42
+ const ICON_EDIT = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
43
+ const ICON_SHARE = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>`;
44
+ const ICON_DL = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg>`;
45
+ const CROSS_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>`;
46
+ const SOUND_ON_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H2v6h4l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/></svg>`;
47
+ const SOUND_OFF_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H2v6h4l5 4V5Z"/><path d="m23 9-6 6"/><path d="m17 9 6 6"/></svg>`;
48
+ const CHEV_UP_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 15 6-6 6 6"/></svg>`;
49
+ const CHEV_DOWN_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>`;
50
+ const ICON_MORE = `<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="12" cy="19" r="1.9"/></svg>`;
51
+ const ICON_COPY = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>`;
52
+ const ICON_TRASH = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6M14 11v6"/></svg>`;
53
+ const ICON_FOLDER = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z"/></svg>`;
54
+ const ICON_CHEVLEFT = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>`;
55
+ const ICON_LOGS = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></svg>`;
56
+ // Logs is no longer a primary tab — it's a right-floated ghost button that rides
57
+ // the topbar (to the right of the tab's quick-search). Shared by the Approved,
58
+ // Raws and Files topbars so the row reads identically everywhere.
59
+ const LOGS_BTN = `<a class="rk-btn rk-btn-ghost rk-btn-sm rk-library-logs-btn" href="/library/logs">${ICON_LOGS}<span>Logs</span></a>`;
60
+ /* ──────────────────────────── Approved (projects) tab ─────────────────────── */
33
61
  const STATUS = {
34
- draft: { short: "Draft", pill: "rk-pill" },
35
- rendering: { short: "Rendering", pill: "rk-pill-sky" },
36
62
  ready: { short: "Ready", pill: "rk-pill-green" },
37
- scheduled: { short: "Scheduled", pill: "rk-pill-violet" },
38
- posted: { short: "Posted", pill: "rk-pill-gold" }
63
+ scheduled: { short: "Scheduled", pill: "rk-pill-violet" }
39
64
  };
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;
65
+ function statusInfo(status) {
66
+ return STATUS[status] || { short: status ? status.charAt(0).toUpperCase() + status.slice(1) : "Ready", pill: "rk-pill" };
67
+ }
68
+ function fmtDate(iso) {
69
+ const t = Date.parse(iso);
70
+ if (Number.isNaN(t))
71
+ return iso || "";
72
+ return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
51
73
  }
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>`;
74
+ function isVideoAsset(a) {
75
+ return a.kind === "video" || String(a.content_type || "").startsWith("video");
76
+ }
77
+ function isImageAsset(a) {
78
+ return a.kind === "image" || String(a.content_type || "").startsWith("image");
79
+ }
80
+ // primaryAsset mirrors the live library: role="primary" → first video → media[0].
81
+ function primaryAsset(post) {
82
+ return post.media.find((a) => a.role === "primary")
83
+ || post.media.find((a) => isVideoAsset(a))
84
+ || post.media[0]
85
+ || null;
86
+ }
87
+ function downloadAsset(post) {
88
+ const v = post.media.find((a) => isVideoAsset(a));
89
+ return v || primaryAsset(post);
90
+ }
91
+ // "View post" now always opens the account-scoped public post page in a new
92
+ // tab: /u/:accountId/approved/posts/:postId (falls back to the un-scoped path or
93
+ // the stored share_url when the account id is unknown).
94
+ function shareHref(post, accountId) {
95
+ if (accountId) {
96
+ return `/u/${encodeURIComponent(accountId)}/approved/posts/${encodeURIComponent(post.post_id)}`;
64
97
  }
98
+ return post.share_url && post.share_url.length
99
+ ? post.share_url
100
+ : `/approved/posts/${encodeURIComponent(post.post_id)}`;
65
101
  }
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>`;
102
+ function editorHref(post, accountId) {
103
+ if (!post.template_id)
104
+ return null;
105
+ const q = accountId ? `?account=${encodeURIComponent(accountId)}` : "";
106
+ return `/editor/${encodeURIComponent(post.template_id)}${q}`;
107
+ }
108
+ function posterMedia(post) {
109
+ const asset = post.media.find((a) => isVideoAsset(a)) || post.media.find((a) => isImageAsset(a)) || primaryAsset(post);
110
+ if (asset && isVideoAsset(asset)) {
111
+ return `<video class="rk-library-video" muted loop playsinline preload="metadata" src="${rkEscape(asset.url)}#t=0.1"></video>`;
71
112
  }
72
- return `<div class="rk-library-origin">
73
- <span class="rk-library-origin-txt">${rkEscape(p.originLabel)} · Your project</span>
74
- </div>`;
113
+ if (asset && isImageAsset(asset)) {
114
+ return `<img class="rk-library-video" loading="lazy" src="${rkEscape(asset.url)}" alt="${rkEscape(post.title || "Published render")}" />`;
115
+ }
116
+ return `<span class="rk-library-noprev">No preview</span>`;
75
117
  }
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>`
118
+ function projectCard(post, accountId, index) {
119
+ const s = statusInfo(post.status);
120
+ const title = post.title || "Published render";
121
+ const subLine = post.tracer
122
+ ? post.tracer
123
+ : post.template_id
124
+ ? `Composition ${post.template_id}`
125
+ : "Ready-to-post cut";
126
+ const searchHay = [title, post.caption, post.tracer, post.template_id].filter(Boolean).join(" ").toLowerCase();
127
+ const editHref = editorHref(post, accountId);
128
+ // Compact one-row action bar: "Share Post" is the labelled primary (opens the
129
+ // approved-post preview page in a new tab); "Edit" is the labelled secondary
130
+ // (opens the composition in the editor in a new tab). A right-floated "⋯"
131
+ // overflow menu carries Copy ID + Delete (with a popconfirm).
132
+ const actions = [
133
+ `<a class="rk-btn rk-btn-gold rk-library-act" href="${rkEscape(shareHref(post, accountId))}" target="_blank" rel="noreferrer">Share Post</a>`,
134
+ editHref ? `<a class="rk-btn rk-btn-ghost rk-library-act" href="${rkEscape(editHref)}" target="_blank" rel="noreferrer" title="Open in editor">Edit</a>` : ""
135
+ ].filter(Boolean).join("");
136
+ // Delete targets the real account-scoped DELETE route; only wired when we know
137
+ // the owning account (always true for the authed library).
138
+ const delUrl = accountId
139
+ ? `/u/${encodeURIComponent(accountId)}/approved/posts/${encodeURIComponent(post.post_id)}`
81
140
  : "";
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>
141
+ const menu = `<div class="rk-library-menu" data-rk-menu>
142
+ <button type="button" class="rk-library-kebab" data-rk-menu-btn aria-label="More actions" aria-haspopup="true" aria-expanded="false">${ICON_MORE}</button>
143
+ <div class="rk-library-menu-pop" data-rk-menu-pop hidden role="menu">
144
+ <button type="button" class="rk-library-menu-item" data-rk-copy-id="${rkEscape(post.post_id)}" role="menuitem">${ICON_COPY}<span>Copy ID</span></button>
145
+ <button type="button" class="rk-library-menu-item is-danger" data-rk-del="${rkEscape(delUrl)}" role="menuitem">${ICON_TRASH}<span>Delete</span></button>
146
+ <div class="rk-library-menu-confirm" data-rk-confirm hidden>
147
+ <p class="rk-library-menu-confirm-q">Delete this post?</p>
148
+ <div class="rk-library-menu-confirm-row">
149
+ <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm" data-rk-confirm-no>Cancel</button>
150
+ <button type="button" class="rk-btn rk-btn-danger rk-btn-sm" data-rk-confirm-yes>Delete</button>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </div>`;
155
+ return `<article class="rk-library-card rk-card-hover" data-status="${rkEscape(post.status)}" data-title="${rkEscape(searchHay)}" data-post-id="${rkEscape(post.post_id)}"
156
+ <button type="button" class="rk-library-poster" data-rk-view="${index}" aria-label="Play ${rkEscape(title)}">
157
+ ${posterMedia(post)}
158
+ <span class="rk-pill ${s.pill} rk-library-pstatus"><span class="rk-library-dot"></span>${rkEscape(s.short)}</span>
159
+ <span class="rk-library-dur">9:16</span>
87
160
  <span class="rk-library-play">${PLAY}</span>
88
- ${progress}
89
- </a>
161
+ </button>
90
162
  <div class="rk-library-meta">
91
- <h3 class="rk-library-title">${rkEscape(p.title)}</h3>
92
- ${originRow(p)}
93
- <div class="rk-spread rk-library-foot">
94
- <span class="rk-library-time">${rkEscape(p.when)}</span>
95
- ${cardAction(p)}
96
- </div>
163
+ <h3 class="rk-library-title">${rkEscape(title)}</h3>
164
+ <div class="rk-library-origin"><span class="rk-library-time">${rkEscape(fmtDate(post.created_at))}</span><span class="rk-library-origin-sep">·</span><span class="rk-library-origin-txt">${rkEscape(subLine)}</span></div>
165
+ <div class="rk-library-actions">${actions}${menu}</div>
97
166
  </div>
98
167
  </article>`;
99
168
  }
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>
169
+ function approvedPanel(input, tabs) {
170
+ const posts = input.posts;
171
+ const accountId = input.currentAccountId;
172
+ // The quickfilter searchbar mirrors the Clips tab's pill search — always
173
+ // rendered so the topbar reads the same on both tabs (disabled when empty).
174
+ const qCount = posts.length;
175
+ const searchBar = `<label class="rk-clips-search rk-library-qsearch">
176
+ <span class="rk-clips-search-ic">${SEARCH_ICON}</span>
177
+ <input class="rk-input rk-clips-search-input" data-rk-search type="search"
178
+ placeholder="Search ${qCount} approved post${qCount === 1 ? "" : "s"}…" autocomplete="off" aria-label="Search approved posts"${posts.length ? "" : " disabled"} />
179
+ </label>`;
180
+ if (posts.length === 0) {
181
+ return `
182
+ <div class="rk-library-topbar">
183
+ ${tabs}
184
+ ${searchBar}
185
+ ${LOGS_BTN}
186
+ </div>
187
+
188
+ <div class="rk-library-empty is-visible">
189
+ <h3>No published cuts yet</h3>
190
+ <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>
191
+ <a class="rk-btn rk-btn-gold" href="/editor/original/fork/new">Start a project <span class="rk-arrow">→</span></a>
107
192
  </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("");
111
- 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>
113
- <div>
114
- <div class="rk-library-new-title">New project</div>
115
- <div class="rk-library-new-sub">Fork a template or start from a prompt</div>
116
- </div>
117
- </a>`;
118
- const cards = PROJECTS.map(projectCard).join("");
193
+ }
194
+ const cards = posts.map((p, i) => projectCard(p, accountId, i)).join("");
195
+ // Data the client viewer needs to build the vertical scroll deck (order MUST
196
+ // match the cards' data-rk-view index). Videos play in the modal; image-only
197
+ // posts show their poster still.
198
+ const viewerData = posts.map((p) => {
199
+ const asset = primaryAsset(p);
200
+ const isVideo = Boolean(asset && isVideoAsset(asset));
201
+ const poster = p.media.find((a) => isImageAsset(a));
202
+ return {
203
+ title: p.title || "Published render",
204
+ sub: p.tracer || (p.template_id ? `Composition ${p.template_id}` : "Ready-to-post cut"),
205
+ url: asset ? asset.url : "",
206
+ is_video: isVideo,
207
+ poster: poster ? poster.url : "",
208
+ post_href: shareHref(p, accountId),
209
+ editor_href: editorHref(p, accountId)
210
+ };
211
+ });
119
212
  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>
121
-
122
- <div class="rk-library-toolbar">
123
- <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">
213
+ <div class="rk-library-topbar">
214
+ ${tabs}
215
+ ${searchBar}
216
+ ${LOGS_BTN}
125
217
  </div>
126
218
 
127
219
  <div class="rk-library-grid" data-rk-grid>
128
- ${newTile}
129
220
  ${cards}
130
221
  </div>
131
222
 
132
223
  <div class="rk-library-empty" data-rk-empty>
133
224
  <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>
135
- <a class="rk-btn rk-btn-gold" href="/discover">Start a project <span class="rk-arrow">→</span></a>
136
- </div>`;
137
- }
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}`;
225
+ <p class="rk-muted">No cuts match your search. Try a different term or spin up a fresh render.</p>
226
+ <a class="rk-btn rk-btn-gold" href="/editor/original/fork/new">Start a project <span class="rk-arrow">→</span></a>
227
+ </div>
228
+
229
+ <script type="application/json" data-rk-library-data>${JSON.stringify(viewerData).replace(/</g, "\\u003c")}</script>
230
+ ${LIBRARY_VIEWER_HTML}`;
169
231
  }
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>
232
+ // Fullscreen TikTok-style viewer shell — ported from the /discover reskin so the
233
+ // library modal reads identically. The client fills [data-viewer-track] on open.
234
+ const LIBRARY_VIEWER_HTML = `
235
+ <div class="rk-library-viewer" data-rk-viewer hidden role="dialog" aria-modal="true" aria-label="Published cut viewer">
236
+ <div class="rk-library-viewer-scrim" data-viewer-close></div>
237
+ <div class="rk-library-viewer-track" data-viewer-track></div>
238
+ <div class="rk-library-viewer-ui">
239
+ <button type="button" class="rk-library-viewer-btn" data-viewer-mute aria-label="Toggle sound"><span class="rk-lvm-on">${SOUND_ON_ICON}</span><span class="rk-lvm-off">${SOUND_OFF_ICON}</span></button>
240
+ <button type="button" class="rk-library-viewer-btn" data-viewer-close aria-label="Close viewer">${CROSS_ICON}</button>
178
241
  </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>
242
+ <div class="rk-library-viewer-nav">
243
+ <button type="button" class="rk-library-viewer-btn" data-viewer-prev aria-label="Previous cut">${CHEV_UP_ICON}</button>
244
+ <button type="button" class="rk-library-viewer-btn" data-viewer-next aria-label="Next cut">${CHEV_DOWN_ICON}</button>
186
245
  </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("");
246
+ </div>`;
247
+ /* ──────────────────────────────── Clips tab ───────────────────────────────── */
248
+ // The clip library is NOT part of the library route's input — the client fetches
249
+ // the REAL endpoints on load: GET /clips/feed (library), POST /clips/search
250
+ // (hybrid semantic search), POST /clips/scan + GET /clips/scan/:id (mine a
251
+ // source), download via /clips/:clipId/download.
252
+ function clipsPanel(tabs) {
253
+ // Raws render as a file-directory explorer: raws are foldered by their source
254
+ // video (source_video_id / source_filename). The root shows folders; opening a
255
+ // folder shows its raws as big scannable thumbnails; typing a query flips to a
256
+ // flat semantic-search results view. The clip count lives in the search
257
+ // placeholder (kept in sync by the client) — no separate summary/count row.
193
258
  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>
195
-
196
- <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>
205
- </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>
259
+ <div class="rk-library-topbar">
260
+ ${tabs}
261
+ <form class="rk-clips-search rk-library-qsearch" id="rkClipsSearch">
262
+ <span class="rk-clips-search-ic">${SEARCH_ICON}</span>
263
+ <input class="rk-input rk-clips-search-input" id="rkClipsQuery" type="search"
264
+ placeholder="Search raw clips…" autocomplete="off" aria-label="Search raw clips" />
265
+ </form>
266
+ ${LOGS_BTN}
267
+ </div>
268
+
269
+ <div class="rk-raws-bar">
270
+ <nav class="rk-raws-crumbs" id="rkRawsCrumbs" aria-label="Breadcrumb"></nav>
271
+ <div class="rk-clips-status" id="rkClipsStatus"></div>
208
272
  </div>
209
273
 
210
- <div class="rk-clips-grid" id="rkClipsGrid">${cards}</div>
274
+ <div class="rk-raws-explorer" id="rkClipsGrid"></div>
211
275
 
212
276
  <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>
277
+ <h3>No raws yet</h3>
278
+ <p class="rk-muted">Click <b>Import source</b> to mine a long video into tagged, searchable short clips.</p>
279
+ </div>
280
+
281
+ <div class="rk-raws-hover" id="rkRawsHover" hidden aria-hidden="true">
282
+ <video class="rk-raws-hover-vid" muted loop playsinline preload="none"></video>
283
+ <div class="rk-raws-hover-cap" id="rkRawsHoverCap"></div>
284
+ </div>
285
+ ${LIBRARY_VIEWER_HTML}`;
286
+ }
287
+ /* ──────────────────────────────── Files tab ───────────────────────────────── */
288
+ // The Files tab is the full-page mount of the reusable file-directory explorer
289
+ // (src/frontend/file-directory.ts). It opens at the virtual root so the three
290
+ // backends read as folders — Files / Temp / Raws — with the component's own
291
+ // unified search (semantic vector + substring + absolute-path, mode=auto),
292
+ // breadcrumb navigation, multi-select, and drag-drop upload. The bundle
293
+ // (/assets/file-directory-app.js) already ships on every full-chrome reskin
294
+ // page and auto-mounts any `[data-rk-directory]`, so no page script is needed;
295
+ // preview routes through the chrome's `window.__rkDirectoryHost.onPreview`.
296
+ function filesPanel(tabs) {
297
+ return `
298
+ <div class="rk-library-topbar">
299
+ ${tabs}
300
+ ${LOGS_BTN}
301
+ </div>
302
+
303
+ <div class="rk-files-explorer">
304
+ <div data-rk-directory data-mode="standalone" data-show-upload="true" data-show-multiselect="true" data-primary-click="preview"></div>
215
305
  </div>`;
216
306
  }
217
307
  const CLIPS_DIALOG = `
@@ -219,19 +309,27 @@ const CLIPS_DIALOG = `
219
309
  <form class="rk-clips-dialog-form" id="rkClipsScanForm" method="dialog">
220
310
  <div class="rk-card-head">
221
311
  <span class="rk-eyebrow">Import source</span>
222
- <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>
312
+ <h2>Import a video</h2>
313
+ <p class="rk-muted">Paste a video URL — TikTok, YouTube, Instagram, X, or a direct link. We'll add it to your raws library.</p>
224
314
  </div>
225
315
  <div class="rk-field">
226
316
  <label class="rk-label" for="rkScanUrl">Video URL</label>
227
317
  <input class="rk-input" id="rkScanUrl" name="url" type="url" placeholder="https://youtube.com/watch?v=… or https://…/video.mp4" autocomplete="off" />
228
318
  </div>
229
319
  <div class="rk-field">
230
- <label class="rk-label" for="rkScanPrompt">What to clip for</label>
231
- <textarea class="rk-textarea" id="rkScanPrompt" name="prompt" placeholder="Clip scenes of people reacting, no text on screen, vertical, between 5–10 secs long"></textarea>
232
- <span class="rk-hint">Guides the AI tagger toward the moments you actually want.</span>
320
+ <label class="rk-label" for="rkScanTracer">Tracer <span class="rk-hint">optional</span></label>
321
+ <input class="rk-input" id="rkScanTracer" name="tracer" type="text" placeholder="my-campaign-001" autocomplete="off" />
233
322
  </div>
234
- <div class="rk-grid rk-grid-2 rk-clips-dialog-grid">
323
+ <label class="rk-clips-toggle" for="rkScanClip">
324
+ <input type="checkbox" id="rkScanClip" name="clip" />
325
+ <span><b>Clip raws</b> — mine this video into matching moments with AI, instead of importing the whole thing.</span>
326
+ </label>
327
+ <div class="rk-clips-advanced" id="rkClipsAdvanced" hidden>
328
+ <div class="rk-field">
329
+ <label class="rk-label" for="rkScanPrompt">What to clip for</label>
330
+ <textarea class="rk-textarea" id="rkScanPrompt" name="prompt" placeholder="Clip scenes of people reacting, no text on screen, vertical, between 5–10 secs long"></textarea>
331
+ <span class="rk-hint">Guides the AI tagger toward the moments you actually want.</span>
332
+ </div>
235
333
  <div class="rk-field">
236
334
  <label class="rk-label" for="rkScanProvider">AI provider</label>
237
335
  <select class="rk-select" id="rkScanProvider" name="provider">
@@ -241,15 +339,11 @@ const CLIPS_DIALOG = `
241
339
  <option value="openrouter">OpenRouter</option>
242
340
  </select>
243
341
  </div>
244
- <div class="rk-field">
245
- <label class="rk-label" for="rkScanTracer">Tracer <span class="rk-hint">optional</span></label>
246
- <input class="rk-input" id="rkScanTracer" name="tracer" type="text" placeholder="my-campaign-001" autocomplete="off" />
247
- </div>
248
342
  </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>
343
+ <p class="rk-hint rk-clips-dialog-note" id="rkScanNote">We'll pull the whole video into your raws library.</p>
250
344
  <div class="rk-clips-dialog-actions">
251
345
  <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>
346
+ <button type="submit" class="rk-btn rk-btn-gold" id="rkClipsScanSubmit">Import video <span class="rk-arrow">→</span></button>
253
347
  </div>
254
348
  </form>
255
349
  </dialog>`;
@@ -257,93 +351,653 @@ const CLIPS_DIALOG = `
257
351
  const APPROVED_SCRIPT = `
258
352
  (function(){
259
353
  var root=document;
260
- var grid=root.querySelector('[data-rk-grid]');
261
354
  var empty=root.querySelector('[data-rk-empty]');
262
- var chips=Array.prototype.slice.call(root.querySelectorAll('[data-rk-filter]'));
263
- var newTile=root.querySelector('[data-rk-newtile]');
264
355
  var search=root.querySelector('[data-rk-search]');
356
+ var grid=root.querySelector('[data-rk-grid]');
265
357
  var cards=Array.prototype.slice.call(root.querySelectorAll('.rk-library-card'));
266
- var activeFilter='';
358
+ var total=cards.length;
359
+ function syncPlaceholder(){
360
+ if(!search)return;
361
+ search.setAttribute('placeholder','Search '+total+' approved post'+(total===1?'':'s')+String.fromCharCode(8230));
362
+ }
267
363
  function apply(){
268
364
  var q=(search&&search.value||'').trim().toLowerCase();
269
365
  var shown=0;
270
366
  cards.forEach(function(card){
271
- var okStatus=activeFilter===''||card.getAttribute('data-status')===activeFilter;
272
- var okSearch=!q||(card.getAttribute('data-title')||'').indexOf(q)!==-1;
273
- var visible=okStatus&&okSearch;
367
+ if(card.getAttribute('data-removed'))return;
368
+ var visible=!q||(card.getAttribute('data-title')||'').indexOf(q)!==-1;
274
369
  card.hidden=!visible;
275
370
  if(visible)shown++;
276
371
  });
277
- if(newTile)newTile.hidden=!(activeFilter===''&&!q);
278
372
  if(empty)empty.classList.toggle('is-visible',shown===0);
279
373
  }
280
- chips.forEach(function(btn){
281
- btn.addEventListener('click',function(){
282
- activeFilter=btn.getAttribute('data-rk-filter')||'';
283
- chips.forEach(function(b){b.classList.toggle('is-active',b===btn)});
284
- apply();
285
- });
286
- });
287
374
  if(search)search.addEventListener('input',apply);
375
+ // hover-to-play the real MP4 previews on the grid cards
376
+ root.querySelectorAll('video.rk-library-video').forEach(function(v){
377
+ var card=v.closest('.rk-library-card');
378
+ if(!card)return;
379
+ card.addEventListener('mouseenter',function(){ if(v.play){var p=v.play(); if(p&&p.catch)p.catch(function(){});} });
380
+ card.addEventListener('mouseleave',function(){ if(v.pause)v.pause(); });
381
+ });
288
382
  apply();
383
+
384
+ // ── per-card "⋯" overflow menu: Copy ID + Delete (popconfirm) ────────────
385
+ function closeAllMenus(except){
386
+ root.querySelectorAll('[data-rk-menu].is-open').forEach(function(m){
387
+ if(m===except)return;
388
+ m.classList.remove('is-open');
389
+ var pop=m.querySelector('[data-rk-menu-pop]'); if(pop)pop.hidden=true;
390
+ var cf=m.querySelector('[data-rk-confirm]'); if(cf)cf.hidden=true;
391
+ var btn=m.querySelector('[data-rk-menu-btn]'); if(btn)btn.setAttribute('aria-expanded','false');
392
+ });
393
+ }
394
+ function flashKebab(menu,text){
395
+ var btn=menu&&menu.querySelector('[data-rk-menu-btn]'); if(!btn)return;
396
+ btn.classList.add('is-ok'); var prev=btn.getAttribute('title')||'';
397
+ btn.setAttribute('title',text||'Done');
398
+ setTimeout(function(){ btn.classList.remove('is-ok'); btn.setAttribute('title',prev); },1100);
399
+ }
400
+ document.addEventListener('click',function(e){
401
+ var t=e.target;
402
+ var btn=t&&t.closest?t.closest('[data-rk-menu-btn]'):null;
403
+ if(btn){
404
+ e.preventDefault(); e.stopPropagation();
405
+ var menu=btn.closest('[data-rk-menu]');
406
+ var open=menu.classList.contains('is-open');
407
+ closeAllMenus(open?null:menu);
408
+ if(open){ menu.classList.remove('is-open'); btn.setAttribute('aria-expanded','false'); return; }
409
+ menu.classList.add('is-open'); btn.setAttribute('aria-expanded','true');
410
+ var pop=menu.querySelector('[data-rk-menu-pop]'); if(pop)pop.hidden=false;
411
+ var cf=menu.querySelector('[data-rk-confirm]'); if(cf)cf.hidden=true;
412
+ return;
413
+ }
414
+ // Copy ID
415
+ var copy=t&&t.closest?t.closest('[data-rk-copy-id]'):null;
416
+ if(copy){
417
+ e.preventDefault();
418
+ var id=copy.getAttribute('data-rk-copy-id')||'';
419
+ var menuC=copy.closest('[data-rk-menu]');
420
+ var done=function(){ flashKebab(menuC,'Copied ID'); closeAllMenus(null); };
421
+ if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(id).then(done,done); }
422
+ else { try{ var ta=document.createElement('textarea'); ta.value=id; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }catch(err){} done(); }
423
+ return;
424
+ }
425
+ // Delete → reveal popconfirm
426
+ var del=t&&t.closest?t.closest('[data-rk-del]'):null;
427
+ if(del){
428
+ e.preventDefault();
429
+ var m2=del.closest('[data-rk-menu]');
430
+ var cf2=m2.querySelector('[data-rk-confirm]'); if(cf2)cf2.hidden=false;
431
+ return;
432
+ }
433
+ // Cancel popconfirm
434
+ var no=t&&t.closest?t.closest('[data-rk-confirm-no]'):null;
435
+ if(no){ e.preventDefault(); var mN=no.closest('[data-rk-menu]'); closeAllMenus(null); void mN; return; }
436
+ // Confirm delete → real DELETE
437
+ var yes=t&&t.closest?t.closest('[data-rk-confirm-yes]'):null;
438
+ if(yes){
439
+ e.preventDefault();
440
+ var m3=yes.closest('[data-rk-menu]');
441
+ var card=m3.closest('.rk-library-card');
442
+ var delBtn=m3.querySelector('[data-rk-del]');
443
+ var url=delBtn&&delBtn.getAttribute('data-rk-del')||'';
444
+ if(!url){ window.alert('Cannot delete this post.'); return; }
445
+ yes.disabled=true; yes.classList.add('is-loading');
446
+ fetch(url,{method:'DELETE',credentials:'same-origin',headers:{Accept:'application/json'}})
447
+ .then(function(r){ if(!r.ok)throw new Error('delete failed'); return r.json().catch(function(){return {};}); })
448
+ .then(function(){
449
+ if(card){ card.setAttribute('data-removed','1'); card.hidden=true; card.parentNode&&card.parentNode.removeChild(card); }
450
+ total=Math.max(0,total-1); syncPlaceholder(); apply();
451
+ })
452
+ .catch(function(){ yes.disabled=false; yes.classList.remove('is-loading'); window.alert('Could not delete that post. Please try again.'); });
453
+ return;
454
+ }
455
+ // click elsewhere closes any open menu
456
+ if(!(t&&t.closest&&t.closest('[data-rk-menu]')))closeAllMenus(null);
457
+ });
458
+ document.addEventListener('keydown',function(e){ if(e.key==='Escape')closeAllMenus(null); });
459
+
460
+ // ── TikTok-style viewer modal (mirrors the /discover reskin viewer) ──────
461
+ var DATA=[]; try{ var el=root.querySelector('[data-rk-library-data]'); if(el) DATA=JSON.parse(el.textContent||'[]'); }catch(e){ DATA=[]; }
462
+ function esc(t){ var d=document.createElement('div'); d.textContent=(t==null?'':String(t)); return d.innerHTML; }
463
+ function attr(t){ return esc(t).replace(/"/g,'&quot;'); }
464
+ var viewer=root.querySelector('[data-rk-viewer]');
465
+ var vTrack=viewer&&viewer.querySelector('[data-viewer-track]');
466
+ if(viewer&&vTrack){
467
+ var vItems=[]; var vOpen=false; var vActive=-1; var vSound=true; var vIO=null;
468
+ function slideHtml(t,i){
469
+ var media=t.is_video
470
+ ? '<video class="rk-library-viewer-media" data-vsrc="'+attr(t.url)+'"'+(t.poster?' poster="'+attr(t.poster)+'"':'')+' loop playsinline preload="none"></video>'
471
+ : '<img class="rk-library-viewer-media" src="'+attr(t.url||t.poster)+'" alt="'+attr(t.title||'Published cut')+'">';
472
+ var cta=t.editor_href
473
+ ? '<a class="rk-btn rk-btn-gold rk-btn-sm rk-library-viewer-cta" href="'+attr(t.editor_href)+'">Open in editor</a>'
474
+ : '';
475
+ var view=t.post_href
476
+ ? '<a class="rk-btn rk-btn-ghost-light rk-btn-sm rk-library-viewer-cta" href="'+attr(t.post_href)+'" target="_blank" rel="noreferrer">View post</a>'
477
+ : '';
478
+ return '<section class="rk-library-viewer-slide" data-vslide="'+i+'">'
479
+ +'<figure class="rk-library-viewer-stage">'
480
+ + media
481
+ +'<span class="rk-library-viewer-pause">'+${JSON.stringify(PLAY)}+'</span>'
482
+ +'<div class="rk-library-viewer-shade"></div>'
483
+ +'<div class="rk-library-viewer-info">'
484
+ +'<div class="rk-library-viewer-title">'+esc(t.title)+'</div>'
485
+ +(t.sub?'<div class="rk-library-viewer-desc">'+esc(t.sub)+'</div>':'')
486
+ +'<div class="rk-library-viewer-actions">'+cta+view+'</div>'
487
+ +'</div>'
488
+ +'</figure>'
489
+ +'</section>';
490
+ }
491
+ function hydrate(slide){ var v=slide.querySelector('video[data-vsrc]'); if(!v||v.getAttribute('src'))return; v.preload='auto'; v.setAttribute('src',v.getAttribute('data-vsrc')); }
492
+ function updateMuteUi(){ viewer.classList.toggle('is-muted',!vSound); }
493
+ function setActive(idx){
494
+ vActive=idx;
495
+ [].forEach.call(vTrack.children,function(s,i){
496
+ if(Math.abs(i-idx)<=1) hydrate(s);
497
+ s.classList.remove('is-paused');
498
+ var v=s.querySelector('video'); if(!v)return;
499
+ if(i===idx){ v.muted=!vSound; var pr=v.play(); if(pr&&pr.catch) pr.catch(function(){ v.muted=true; vSound=false; updateMuteUi(); var p2=v.play(); if(p2&&p2.catch)p2.catch(function(){}); }); }
500
+ else { v.pause(); }
501
+ });
502
+ updateMuteUi();
503
+ }
504
+ function step(d){ if(!vOpen)return; var n=Math.max(0,Math.min(vItems.length-1,vActive+d)); if(n===vActive)return; vTrack.scrollTo({top:n*vTrack.clientHeight,behavior:'smooth'}); }
505
+ function open(clickedIdx){
506
+ vItems=[]; var start=0;
507
+ DATA.forEach(function(t,i){ if(!t.url&&!t.poster)return; if(i===clickedIdx)start=vItems.length; vItems.push(t); });
508
+ if(!vItems.length)return;
509
+ vTrack.innerHTML=vItems.map(slideHtml).join('');
510
+ vOpen=true; vSound=true; viewer.hidden=false;
511
+ document.documentElement.classList.add('rk-noscroll');
512
+ if(vIO)vIO.disconnect();
513
+ vIO=('IntersectionObserver' in window)?new IntersectionObserver(function(entries){ entries.forEach(function(en){ if(!en.isIntersecting)return; var i=Number(en.target.getAttribute('data-vslide')); if(i!==vActive)setActive(i); }); },{root:vTrack,threshold:0.6}):null;
514
+ if(vIO)[].forEach.call(vTrack.children,function(s){ vIO.observe(s); });
515
+ vTrack.scrollTop=start*vTrack.clientHeight;
516
+ setActive(start);
517
+ }
518
+ function close(){
519
+ if(!vOpen)return; vOpen=false; vActive=-1;
520
+ if(vIO){ vIO.disconnect(); vIO=null; }
521
+ vTrack.querySelectorAll('video').forEach(function(v){ v.pause(); });
522
+ vTrack.innerHTML=''; viewer.hidden=true;
523
+ document.documentElement.classList.remove('rk-noscroll');
524
+ }
525
+ // open from a card poster
526
+ document.addEventListener('click',function(e){
527
+ var b=(e.target&&e.target.closest)?e.target.closest('[data-rk-view]'):null;
528
+ if(!b)return; e.preventDefault(); open(Number(b.getAttribute('data-rk-view')));
529
+ });
530
+ viewer.querySelectorAll('[data-viewer-close]').forEach(function(b){ b.addEventListener('click',close); });
531
+ var muteBtn=viewer.querySelector('[data-viewer-mute]');
532
+ if(muteBtn)muteBtn.addEventListener('click',function(){ vSound=!vSound; updateMuteUi(); var s=vTrack.children[vActive]; var v=s&&s.querySelector('video'); if(v){ v.muted=!vSound; if(vSound&&v.paused){ var pr=v.play(); if(pr&&pr.catch)pr.catch(function(){}); s.classList.remove('is-paused'); } } });
533
+ var prevBtn=viewer.querySelector('[data-viewer-prev]'); if(prevBtn)prevBtn.addEventListener('click',function(){ step(-1); });
534
+ var nextBtn=viewer.querySelector('[data-viewer-next]'); if(nextBtn)nextBtn.addEventListener('click',function(){ step(1); });
535
+ vTrack.addEventListener('click',function(e){
536
+ var t=e.target;
537
+ if(t&&t.closest&&t.closest('a'))return;
538
+ var media=(t&&t.closest)?t.closest('.rk-library-viewer-media'):null;
539
+ if(media&&media.tagName==='VIDEO'){ var slide=media.closest('.rk-library-viewer-slide'); if(media.paused){ var pr=media.play(); if(pr&&pr.catch)pr.catch(function(){}); if(slide)slide.classList.remove('is-paused'); } else { media.pause(); if(slide)slide.classList.add('is-paused'); } return; }
540
+ if(t&&t.classList&&(t.classList.contains('rk-library-viewer-slide')||t===vTrack))close();
541
+ });
542
+ document.addEventListener('keydown',function(e){ if(!vOpen)return; if(e.key==='Escape'){ e.preventDefault(); close(); } else if(e.key==='ArrowDown'||e.key==='PageDown'){ e.preventDefault(); step(1); } else if(e.key==='ArrowUp'||e.key==='PageUp'){ e.preventDefault(); step(-1); } });
543
+ window.addEventListener('resize',function(){ if(vOpen&&vActive>=0)vTrack.scrollTop=vActive*vTrack.clientHeight; });
544
+ }
289
545
  })();`;
290
546
  const CLIPS_SCRIPT = `
291
547
  (function(){
292
548
  var root=document;
293
- var chips=root.getElementById('rkClipsChips');
294
549
  var grid=root.getElementById('rkClipsGrid');
295
550
  var statusEl=root.getElementById('rkClipsStatus');
551
+ var crumbsEl=root.getElementById('rkRawsCrumbs');
296
552
  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():'';
553
+ var form=root.getElementById('rkClipsSearch');
554
+ var query=root.getElementById('rkClipsQuery');
555
+ var hoverEl=root.getElementById('rkRawsHover');
556
+ var hoverVid=hoverEl&&hoverEl.querySelector('.rk-raws-hover-vid');
557
+ var hoverCap=root.getElementById('rkRawsHoverCap');
558
+ var canHover=!(window.matchMedia&&window.matchMedia('(hover:none)').matches);
559
+
560
+ // ── file-directory state ────────────────────────────────────────────────
561
+ var ALL=[]; // every raw in the library
562
+ var FOLDERS=[]; // [{id,name,clips:[...]}] grouped by source video
563
+ var VIEW=[]; // clips currently on screen (drives viewer + hover, by index)
564
+ var MODE='root'; // 'root' (folders) | 'folder' | 'search'
565
+ var CUR_FOLDER=''; // active folder id when MODE==='folder'
566
+ var LAST_Q=''; // active query when MODE==='search'
567
+ var PENDING=[]; // optimistic import cards: {id,url,tracer,clip,label,error}
568
+
569
+ function esc(t){ var d=document.createElement('div'); d.textContent=(t==null?'':String(t)); return d.innerHTML; }
570
+ function attr(t){ return esc(t).replace(/"/g,'&quot;'); }
571
+ function cssUrl(u){ return "'"+String(u||'').replace(/['")\\\\]/g,'').replace(/\\s/g,'%20')+"'"; }
572
+ function fmtClock(sec){ sec=Number(sec)||0; var m=Math.floor(sec/60), s=Math.floor(sec%60); return m+':'+(s<10?'0':'')+s; }
573
+ function topTags(tags){
574
+ if(!tags||typeof tags!=='object') return [];
575
+ var out=[];
576
+ // content_type leads — it's the shot-kind filter the chips key off.
577
+ (tags.content_type||[]).slice(0,2).forEach(function(v){ out.push(v.replace(/_/g,' ')); });
578
+ ['subject','action','emotion'].forEach(function(k){ (tags[k]||[]).slice(0,1).forEach(function(v){ out.push(v); }); });
579
+ if(tags.energy) out.push('energy:'+tags.energy);
580
+ return out.slice(0,5);
302
581
  }
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++;
582
+ function setGridEmpty(show){ if(empty)empty.hidden=!show; if(grid)grid.style.display=show?'none':''; }
583
+ function syncPlaceholder(){
584
+ if(!query)return;
585
+ var n=ALL.length;
586
+ query.setAttribute('placeholder','Search '+n+' raw clip'+(n===1?'':'s')+String.fromCharCode(8230));
587
+ }
588
+
589
+ // Group raws into folders by their source video (source_video_id, falling back
590
+ // to source_filename). First-seen order is preserved.
591
+ function buildFolders(){
592
+ var map={}; FOLDERS=[];
593
+ ALL.forEach(function(c){
594
+ var id=String(c.source_video_id||c.source_filename||'ungrouped');
595
+ var f=map[id];
596
+ if(!f){ f={id:id,name:c.source_filename||'Untitled source',clips:[]}; map[id]=f; FOLDERS.push(f); }
597
+ f.clips.push(c);
309
598
  });
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
- }
314
- if(chips){
315
- chips.addEventListener('click',function(e){
316
- var btn=e.target.closest('.rk-clips-chip');
317
- if(!btn)return;
318
- chips.querySelectorAll('.rk-clips-chip').forEach(function(b){b.classList.toggle('is-active',b===btn);});
319
- applyFilter(btn.getAttribute('data-filter'));
599
+ }
600
+
601
+ // Optimistic "importing…" placeholder card (spinner, or an error state with a
602
+ // Dismiss button). Kept in PENDING and re-prepended on every root repaint so a
603
+ // feed reload never wipes a still-running import.
604
+ function importCardHtml(p){
605
+ var label=p.error?'Import failed':(p.label||'Importing…');
606
+ var sub=p.error?p.error:(p.url||'');
607
+ var badge=p.error
608
+ ? '<button type="button" class="rk-clips-dl" data-import-dismiss="'+attr(p.id)+'">Dismiss</button>'
609
+ : '<span class="rk-clips-dl rk-clips-importing-tag">'+(p.clip?'Mining':'Importing')+'</span>';
610
+ return '<article class="rk-clips-card rk-clips-importing'+(p.error?' is-error':'')+'" data-import-id="'+attr(p.id)+'">'
611
+ + '<div class="rk-clips-thumb">'
612
+ + (p.error?'<span class="rk-clips-importing-x">'+${JSON.stringify(CROSS_ICON)}+'</span>':'<span class="rk-clips-importing-spin" aria-hidden="true"></span>')
613
+ + '</div>'
614
+ + '<div class="rk-clips-body">'
615
+ + '<h3 class="rk-clips-title">'+esc(label)+'</h3>'
616
+ + '<div class="rk-clips-foot"><span class="rk-clips-src" title="'+attr(sub)+'">'+esc(sub)+'</span>'+badge+'</div>'
617
+ + '</div></article>';
618
+ }
619
+
620
+ // Folder tile — a 2×2 thumbnail mosaic + name + clip count.
621
+ function folderCardHtml(folder){
622
+ var thumbs=folder.clips.filter(function(c){ return c.thumbnail_url; }).slice(0,4);
623
+ var mosaic;
624
+ if(thumbs.length){
625
+ mosaic='<span class="rk-raws-folder-mosaic'+(thumbs.length===1?' is-single':'')+'">'
626
+ + thumbs.map(function(c){ return '<span class="rk-raws-folder-cell" style="background-image:url('+cssUrl(c.thumbnail_url)+')"></span>'; }).join('')
627
+ + '</span>';
628
+ } else {
629
+ mosaic='<span class="rk-raws-folder-mosaic is-empty">'+${JSON.stringify(ICON_FOLDER)}+'</span>';
630
+ }
631
+ var n=folder.clips.length;
632
+ return '<button type="button" class="rk-raws-folder rk-card-hover" data-folder-id="'+attr(folder.id)+'">'
633
+ + mosaic
634
+ + '<span class="rk-raws-folder-meta">'
635
+ + '<span class="rk-raws-folder-ic">'+${JSON.stringify(ICON_FOLDER)}+'</span>'
636
+ + '<span class="rk-raws-folder-txt"><span class="rk-raws-folder-name" title="'+attr(folder.name)+'">'+esc(folder.name)+'</span>'
637
+ + '<span class="rk-raws-folder-count">'+n+' clip'+(n===1?'':'s')+'</span></span>'
638
+ + '</span></button>';
639
+ }
640
+
641
+ function card(clip,i){
642
+ var range=fmtClock(clip.start_time_sec)+'–'+fmtClock(clip.end_time_sec);
643
+ var dur=(clip.duration_sec!=null)?(Number(clip.duration_sec).toFixed(1)+'s'):'';
644
+ var media=clip.thumbnail_url
645
+ ? '<img class="rk-clips-thumb-img" loading="lazy" src="'+esc(clip.thumbnail_url)+'" alt="" />'
646
+ : '<span class="rk-clips-noimg">No preview</span>';
647
+ var tags=topTags(clip.tags).map(function(t){ return '<span class="rk-clips-tag">'+esc(t)+'</span>'; }).join('');
648
+ var desc=clip.description || (clip.tags&&clip.tags.transcript) || '(untagged clip)';
649
+ var dl='/raws/'+encodeURIComponent(clip.clip_id)+'/download';
650
+ return '<article class="rk-clips-card rk-card-hover">'
651
+ + '<button type="button" class="rk-clips-thumb" data-rk-clip-view="'+i+'" aria-label="Open raw">'+media
652
+ + '<span class="rk-clips-dur">'+esc(range+(dur?' · '+dur:''))+'</span>'
653
+ + '<span class="rk-clips-play" aria-hidden="true">'+${JSON.stringify(PLAY)}+'</span>'
654
+ + '</button>'
655
+ + '<div class="rk-clips-body">'
656
+ + '<h3 class="rk-clips-title">'+esc(desc)+'</h3>'
657
+ + (tags?'<div class="rk-clips-tags">'+tags+'</div>':'')
658
+ + '<div class="rk-clips-foot"><span class="rk-clips-src" title="'+attr(clip.source_filename)+'">'+esc(clip.source_filename||'')+'</span>'
659
+ + '<a class="rk-clips-dl" href="'+dl+'" target="_blank" rel="noopener">Download</a></div>'
660
+ + '</div></article>';
661
+ }
662
+
663
+ /* ── mouseover preview popover ────────────────────────────────────────────── */
664
+ var hoverTimer=null;
665
+ function hideHover(){
666
+ if(hoverTimer){ clearTimeout(hoverTimer); hoverTimer=null; }
667
+ if(!hoverEl)return;
668
+ hoverEl.hidden=true; hoverEl.classList.remove('is-visible');
669
+ if(hoverVid){ try{ hoverVid.pause(); }catch(e){} hoverVid.removeAttribute('src'); hoverVid.load&&hoverVid.load(); }
670
+ }
671
+ function positionHover(rect){
672
+ if(!hoverEl)return;
673
+ var gap=12, bw=hoverEl.offsetWidth||250, bh=hoverEl.offsetHeight||330;
674
+ var x=rect.right+gap;
675
+ if(x+bw>window.innerWidth-8) x=rect.left-gap-bw;
676
+ if(x<8) x=Math.max(8,Math.min(rect.left,window.innerWidth-bw-8));
677
+ var y=rect.top+rect.height/2-bh/2;
678
+ y=Math.max(8,Math.min(y,window.innerHeight-bh-8));
679
+ hoverEl.style.left=Math.round(x)+'px';
680
+ hoverEl.style.top=Math.round(y)+'px';
681
+ }
682
+ function showHover(clip,rect){
683
+ if(!hoverEl||!clip||(!clip.view_url&&!clip.thumbnail_url))return;
684
+ if(hoverCap){ hoverCap.textContent=clip.description||(clip.tags&&clip.tags.transcript)||clip.source_filename||'Raw clip'; }
685
+ if(hoverVid){
686
+ hoverVid.poster=clip.thumbnail_url||'';
687
+ if(clip.view_url){ hoverVid.setAttribute('src',clip.view_url); hoverVid.load&&hoverVid.load(); var p=hoverVid.play&&hoverVid.play(); if(p&&p.catch)p.catch(function(){}); }
688
+ else { hoverVid.removeAttribute('src'); }
689
+ }
690
+ hoverEl.hidden=false; hoverEl.classList.add('is-visible');
691
+ positionHover(rect);
692
+ }
693
+ function bindHover(){
694
+ if(!grid||!canHover)return;
695
+ Array.prototype.slice.call(grid.querySelectorAll('.rk-clips-thumb[data-rk-clip-view]')).forEach(function(thumb){
696
+ var idx=Number(thumb.getAttribute('data-rk-clip-view'));
697
+ thumb.addEventListener('mouseenter',function(){
698
+ var clip=VIEW[idx]; if(!clip)return;
699
+ var rect=thumb.getBoundingClientRect();
700
+ if(hoverTimer)clearTimeout(hoverTimer);
701
+ hoverTimer=setTimeout(function(){ showHover(clip,rect); },90);
702
+ });
703
+ thumb.addEventListener('mouseleave',hideHover);
320
704
  });
321
705
  }
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++;
706
+ if(hoverEl){
707
+ window.addEventListener('scroll',hideHover,true);
708
+ window.addEventListener('resize',hideHover);
709
+ }
710
+
711
+ /* ── breadcrumb + view rendering ──────────────────────────────────────────── */
712
+ function renderCrumbs(){
713
+ if(!crumbsEl)return;
714
+ var html='';
715
+ if(MODE==='root'){
716
+ html='<span class="rk-raws-crumb is-current">All raws</span>';
717
+ } else {
718
+ html='<button type="button" class="rk-raws-crumb rk-raws-crumb-link" data-crumb-root>'+${JSON.stringify(ICON_CHEVLEFT)}+'<span>All raws</span></button>';
719
+ html+='<span class="rk-raws-crumb-sep" aria-hidden="true">/</span>';
720
+ if(MODE==='folder'){
721
+ var f=folderById(CUR_FOLDER);
722
+ html+='<span class="rk-raws-crumb is-current">'+esc(f?f.name:'Folder')+' <span class="rk-raws-crumb-n">'+(f?f.clips.length:0)+'</span></span>';
723
+ } else {
724
+ html+='<span class="rk-raws-crumb is-current">Search '+String.fromCharCode(8220)+esc(LAST_Q)+String.fromCharCode(8221)+' <span class="rk-raws-crumb-n">'+VIEW.length+'</span></span>';
725
+ }
726
+ }
727
+ crumbsEl.innerHTML=html;
728
+ }
729
+ function folderById(id){ for(var i=0;i<FOLDERS.length;i++){ if(FOLDERS[i].id===id)return FOLDERS[i]; } return null; }
730
+
731
+ function paintRoot(){
732
+ MODE='root'; CUR_FOLDER=''; VIEW=[];
733
+ if(!grid)return;
734
+ buildFolders();
735
+ grid.classList.add('is-folders');
736
+ if(FOLDERS.length===0 && PENDING.length===0){ grid.innerHTML=''; setGridEmpty(true); renderCrumbs(); if(statusEl)statusEl.textContent=''; return; }
737
+ grid.innerHTML=PENDING.map(importCardHtml).join('')+FOLDERS.map(folderCardHtml).join('');
738
+ setGridEmpty(false);
739
+ renderCrumbs();
740
+ if(statusEl)statusEl.textContent=FOLDERS.length+' folder'+(FOLDERS.length===1?'':'s');
741
+ }
742
+ function paintFolder(id){
743
+ var f=folderById(id); if(!f){ paintRoot(); return; }
744
+ MODE='folder'; CUR_FOLDER=id; VIEW=f.clips;
745
+ if(!grid)return;
746
+ grid.classList.remove('is-folders');
747
+ grid.innerHTML=VIEW.map(card).join('');
748
+ setGridEmpty(VIEW.length===0);
749
+ renderCrumbs(); bindHover();
750
+ if(statusEl)statusEl.textContent='';
751
+ }
752
+ function paintSearch(results,q){
753
+ MODE='search'; LAST_Q=q; VIEW=results||[];
754
+ if(!grid)return;
755
+ grid.classList.remove('is-folders');
756
+ if(VIEW.length===0){ grid.innerHTML=''; setGridEmpty(true); renderCrumbs(); if(statusEl)statusEl.textContent='No raws match '+String.fromCharCode(8220)+q+String.fromCharCode(8221)+'.'; return; }
757
+ grid.innerHTML=VIEW.map(card).join('');
758
+ setGridEmpty(false);
759
+ renderCrumbs(); bindHover();
760
+ if(statusEl)statusEl.textContent='';
761
+ }
762
+ // Repaint whatever view is active (used after an optimistic import lands).
763
+ function repaint(){
764
+ if(MODE==='folder')paintFolder(CUR_FOLDER);
765
+ else if(MODE==='search')paintSearch(VIEW,LAST_Q);
766
+ else paintRoot();
767
+ }
768
+
769
+ /* ── optimistic import lifecycle ─────────────────────────────────────────── */
770
+ function getPending(id){ for(var i=0;i<PENDING.length;i++){ if(PENDING[i].id===id)return PENDING[i]; } return null; }
771
+ function removeImport(id){ PENDING=PENDING.filter(function(p){ return p.id!==id; }); }
772
+ function updateImportCard(id){
773
+ var p=getPending(id); if(!p)return;
774
+ var el=grid&&grid.querySelector('[data-import-id="'+id+'"]');
775
+ if(el){ el.outerHTML=importCardHtml(p); } else { repaint(); }
776
+ }
777
+ function finishImport(id){ removeImport(id); loadFeed(); }
778
+ function failImport(id,msg){ var p=getPending(id); if(p){ p.error=msg; } updateImportCard(id); if(statusEl)statusEl.textContent=msg; }
779
+ function onScanResult(id,clip,res){
780
+ if(!res.ok){ failImport(id,(res.data&&res.data.error)||(clip?'Could not start the scan.':'Could not import that video.')); return; }
781
+ var d=res.data||{};
782
+ if(d.scan_id && d.status!=='complete'){
783
+ var p=getPending(id); if(p){ p.label=clip?'Mining clips…':'Importing…'; updateImportCard(id); }
784
+ pollImport(id,d.scan_id);
785
+ } else {
786
+ finishImport(id);
787
+ }
788
+ }
789
+ function pollImport(id,scanId){
790
+ var tries=0;
791
+ var t=setInterval(function(){
792
+ tries++;
793
+ if(tries>200){ clearInterval(t); var p=getPending(id); if(p){ p.error='Still working in the background — refresh shortly.'; updateImportCard(id); } return; }
794
+ fetch('/raws/scan/'+encodeURIComponent(scanId),{credentials:'same-origin',headers:{Accept:'application/json'}})
795
+ .then(function(r){ return r.ok?r.json():null; })
796
+ .then(function(d){
797
+ if(!d)return;
798
+ var src=d.source||{}; var status=src.status||(d.scan&&d.scan.status)||'running';
799
+ if(status==='complete'){ clearInterval(t); finishImport(id); }
800
+ else if(status==='failed'||status==='error'){ clearInterval(t); failImport(id,src.error||(d.scan&&d.scan.error)||'The source could not be processed.'); }
801
+ }).catch(function(){});
802
+ },4000);
803
+ }
804
+ if(grid){
805
+ grid.addEventListener('click',function(e){
806
+ var d=e.target&&e.target.closest?e.target.closest('[data-import-dismiss]'):null;
807
+ if(!d)return; e.preventDefault(); removeImport(d.getAttribute('data-import-dismiss')); repaint();
808
+ });
809
+ }
810
+
811
+ /* ── fullscreen scroll-snap viewer (click a raw → vertical deck) ──────────── */
812
+ var viewer=root.querySelector('[data-rk-viewer]');
813
+ var vTrack=viewer&&viewer.querySelector('[data-viewer-track]');
814
+ if(viewer&&vTrack){
815
+ var vItems=[]; var vOpen=false; var vActive=-1; var vSound=true; var vIO=null;
816
+ function slideHtml(t,i){
817
+ var media=t.view_url
818
+ ? '<video class="rk-library-viewer-media" data-vsrc="'+attr(t.view_url)+'"'+(t.thumbnail_url?' poster="'+attr(t.thumbnail_url)+'"':'')+' loop playsinline preload="none"></video>'
819
+ : '<img class="rk-library-viewer-media" src="'+attr(t.thumbnail_url||'')+'" alt="'+attr(t.description||'Raw clip')+'">';
820
+ var desc=t.description || (t.tags&&t.tags.transcript) || t.source_filename || 'Raw clip';
821
+ var range=fmtClock(t.start_time_sec)+'–'+fmtClock(t.end_time_sec);
822
+ var meta=(t.source_filename?esc(t.source_filename)+' · ':'')+esc(range);
823
+ var dl='/raws/'+encodeURIComponent(t.clip_id)+'/download';
824
+ return '<section class="rk-library-viewer-slide" data-vslide="'+i+'">'
825
+ +'<figure class="rk-library-viewer-stage">'
826
+ + media
827
+ +'<span class="rk-library-viewer-pause">'+${JSON.stringify(PLAY)}+'</span>'
828
+ +'<div class="rk-library-viewer-shade"></div>'
829
+ +'<div class="rk-library-viewer-info">'
830
+ +'<div class="rk-library-viewer-title">'+esc(desc)+'</div>'
831
+ +'<div class="rk-library-viewer-desc">'+meta+'</div>'
832
+ +'<div class="rk-library-viewer-actions"><a class="rk-btn rk-btn-gold rk-btn-sm rk-library-viewer-cta" href="'+dl+'" target="_blank" rel="noopener">Download</a></div>'
833
+ +'</div>'
834
+ +'</figure>'
835
+ +'</section>';
836
+ }
837
+ function hydrate(slide){ var v=slide.querySelector('video[data-vsrc]'); if(!v||v.getAttribute('src'))return; v.preload='auto'; v.setAttribute('src',v.getAttribute('data-vsrc')); }
838
+ function updateMuteUi(){ viewer.classList.toggle('is-muted',!vSound); }
839
+ function setActive(idx){
840
+ vActive=idx;
841
+ [].forEach.call(vTrack.children,function(s,i){
842
+ if(Math.abs(i-idx)<=1) hydrate(s);
843
+ s.classList.remove('is-paused');
844
+ var v=s.querySelector('video'); if(!v)return;
845
+ if(i===idx){ v.muted=!vSound; var pr=v.play(); if(pr&&pr.catch) pr.catch(function(){ v.muted=true; vSound=false; updateMuteUi(); var p2=v.play(); if(p2&&p2.catch)p2.catch(function(){}); }); }
846
+ else { v.pause(); }
847
+ });
848
+ updateMuteUi();
849
+ }
850
+ function step(d){ if(!vOpen)return; var n=Math.max(0,Math.min(vItems.length-1,vActive+d)); if(n===vActive)return; vTrack.scrollTo({top:n*vTrack.clientHeight,behavior:'smooth'}); }
851
+ function open(clickedIdx){
852
+ hideHover();
853
+ vItems=[]; var start=0;
854
+ VIEW.forEach(function(t,i){ if(!t.view_url&&!t.thumbnail_url)return; if(i===clickedIdx)start=vItems.length; vItems.push(t); });
855
+ if(!vItems.length)return;
856
+ vTrack.innerHTML=vItems.map(slideHtml).join('');
857
+ vOpen=true; vSound=true; viewer.hidden=false;
858
+ document.documentElement.classList.add('rk-noscroll');
859
+ if(vIO)vIO.disconnect();
860
+ vIO=('IntersectionObserver' in window)?new IntersectionObserver(function(entries){ entries.forEach(function(en){ if(!en.isIntersecting)return; var i=Number(en.target.getAttribute('data-vslide')); if(i!==vActive)setActive(i); }); },{root:vTrack,threshold:0.6}):null;
861
+ if(vIO)[].forEach.call(vTrack.children,function(s){ vIO.observe(s); });
862
+ vTrack.scrollTop=start*vTrack.clientHeight;
863
+ setActive(start);
864
+ }
865
+ function close(){
866
+ if(!vOpen)return; vOpen=false; vActive=-1;
867
+ if(vIO){ vIO.disconnect(); vIO=null; }
868
+ vTrack.querySelectorAll('video').forEach(function(v){ v.pause(); });
869
+ vTrack.innerHTML=''; viewer.hidden=true;
870
+ document.documentElement.classList.remove('rk-noscroll');
871
+ }
872
+ if(grid){
873
+ grid.addEventListener('click',function(e){
874
+ var b=e.target&&e.target.closest?e.target.closest('[data-rk-clip-view]'):null;
875
+ if(!b)return; e.preventDefault(); open(Number(b.getAttribute('data-rk-clip-view')));
336
876
  });
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');
877
+ }
878
+ viewer.querySelectorAll('[data-viewer-close]').forEach(function(b){ b.addEventListener('click',close); });
879
+ var muteBtn=viewer.querySelector('[data-viewer-mute]');
880
+ if(muteBtn)muteBtn.addEventListener('click',function(){ vSound=!vSound; updateMuteUi(); var s=vTrack.children[vActive]; var v=s&&s.querySelector('video'); if(v){ v.muted=!vSound; if(vSound&&v.paused){ var pr=v.play(); if(pr&&pr.catch)pr.catch(function(){}); s.classList.remove('is-paused'); } } });
881
+ var prevBtn=viewer.querySelector('[data-viewer-prev]'); if(prevBtn)prevBtn.addEventListener('click',function(){ step(-1); });
882
+ var nextBtn=viewer.querySelector('[data-viewer-next]'); if(nextBtn)nextBtn.addEventListener('click',function(){ step(1); });
883
+ vTrack.addEventListener('click',function(e){
884
+ var t=e.target;
885
+ if(t&&t.closest&&t.closest('a'))return;
886
+ var media=(t&&t.closest)?t.closest('.rk-library-viewer-media'):null;
887
+ if(media&&media.tagName==='VIDEO'){ var slide=media.closest('.rk-library-viewer-slide'); if(media.paused){ var pr=media.play(); if(pr&&pr.catch)pr.catch(function(){}); if(slide)slide.classList.remove('is-paused'); } else { media.pause(); if(slide)slide.classList.add('is-paused'); } return; }
888
+ if(t&&t.classList&&(t.classList.contains('rk-library-viewer-slide')||t===vTrack))close();
889
+ });
890
+ document.addEventListener('keydown',function(e){ if(!vOpen)return; if(e.key==='Escape'){ e.preventDefault(); close(); } else if(e.key==='ArrowDown'||e.key==='PageDown'){ e.preventDefault(); step(1); } else if(e.key==='ArrowUp'||e.key==='PageUp'){ e.preventDefault(); step(-1); } });
891
+ window.addEventListener('resize',function(){ if(vOpen&&vActive>=0)vTrack.scrollTop=vActive*vTrack.clientHeight; });
892
+ }
893
+
894
+ // Folder navigation (grid tile) + breadcrumb "All raws" back link.
895
+ if(grid){
896
+ grid.addEventListener('click',function(e){
897
+ var f=e.target&&e.target.closest?e.target.closest('[data-folder-id]'):null;
898
+ if(!f)return; e.preventDefault(); hideHover(); if(query)query.value=''; paintFolder(f.getAttribute('data-folder-id'));
899
+ });
900
+ }
901
+ if(crumbsEl){
902
+ crumbsEl.addEventListener('click',function(e){
903
+ var r=e.target&&e.target.closest?e.target.closest('[data-crumb-root]'):null;
904
+ if(!r)return; e.preventDefault(); hideHover(); if(query)query.value=''; paintRoot();
340
905
  });
341
906
  }
907
+
908
+ function handleAuth(res){
909
+ if(res.status===401||res.status===403){
910
+ if(grid)grid.innerHTML='';
911
+ setGridEmpty(false);
912
+ 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>.';
913
+ throw new Error('unauthorized');
914
+ }
915
+ return res.json();
916
+ }
917
+
918
+ function loadFeed(){
919
+ if(statusEl)statusEl.textContent='Loading your raws…';
920
+ fetch('/raws/feed?limit=250',{credentials:'same-origin',headers:{Accept:'application/json'}})
921
+ .then(handleAuth).then(function(d){
922
+ ALL=d.clips||[]; buildFolders(); syncPlaceholder();
923
+ // stay inside the current folder if it survived the refresh, else root
924
+ if(MODE==='folder' && folderById(CUR_FOLDER)) paintFolder(CUR_FOLDER);
925
+ else paintRoot();
926
+ }).catch(function(){});
927
+ }
928
+
929
+ function search(q){
930
+ if(statusEl)statusEl.textContent='Searching…';
931
+ fetch('/raws/search',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json',Accept:'application/json'},body:JSON.stringify({query:q,limit:60})})
932
+ .then(handleAuth).then(function(d){ paintSearch(d.results||[],q); }).catch(function(){});
933
+ }
934
+
935
+ if(form){
936
+ form.addEventListener('submit',function(e){
937
+ e.preventDefault();
938
+ var q=(query&&query.value||'').trim();
939
+ if(q)search(q); else paintRoot();
940
+ });
941
+ }
942
+ if(query){
943
+ // clearing the box returns to the folder root without a round-trip
944
+ query.addEventListener('input',function(){ if(!(query.value||'').trim() && MODE==='search'){ hideHover(); paintRoot(); } });
945
+ }
946
+
947
+ // ── Scan a source (real clip hunt) ─────────────────────────────────────
342
948
  var dialog=root.getElementById('rkClipsDialog');
343
- var openBtn=root.getElementById('rkClipsScanBtn');
949
+ var openBtns=Array.prototype.slice.call(root.querySelectorAll('[data-rk-open-scan]'));
344
950
  var cancelBtn=root.getElementById('rkClipsScanCancel');
345
951
  var scanForm=root.getElementById('rkClipsScanForm');
346
- if(openBtn&&dialog){ openBtn.addEventListener('click',function(){ if(typeof dialog.showModal==='function'&&!dialog.open)dialog.showModal(); }); }
952
+ var scanUrl=root.getElementById('rkScanUrl');
953
+ var scanPrompt=root.getElementById('rkScanPrompt');
954
+ var scanProvider=root.getElementById('rkScanProvider');
955
+ var scanTracer=root.getElementById('rkScanTracer');
956
+ var scanNote=root.getElementById('rkScanNote');
957
+ var scanSubmit=root.getElementById('rkClipsScanSubmit');
958
+ var scanClip=root.getElementById('rkScanClip');
959
+ var scanAdvanced=root.getElementById('rkClipsAdvanced');
960
+ var IMPORT_NOTE="We'll pull the whole video into your raws library.";
961
+ var CLIP_NOTE='AI tagging runs on your own key (BYOK) — vidfarm only bills the clip-scan compute.';
962
+ var SCAN_OPTS=null; // GET /raws/scan-options: which providers/keys can run a hunt
963
+ function setNote(msg,tone){ if(!scanNote)return; scanNote.textContent=msg; if(tone)scanNote.setAttribute('data-tone',tone); else scanNote.removeAttribute('data-tone'); }
964
+ function providerLabel(p){ return p==='gemini'?'Gemini':p==='openai'?'OpenAI':p==='openrouter'?'OpenRouter':p; }
965
+ // Can a clip hunt actually run? A saved provider key, a local agent CLI, or a
966
+ // linked cloud account all qualify. Unknown (not yet loaded) → don't block.
967
+ function clipReady(){
968
+ if(!SCAN_OPTS)return true;
969
+ if(SCAN_OPTS.default_provider)return true;
970
+ if(SCAN_OPTS.pipeline==='upstream'||SCAN_OPTS.upstream_backup)return true;
971
+ return (SCAN_OPTS.providers||[]).some(function(p){ return p.has_key; });
972
+ }
973
+ function applyScanOpts(){
974
+ if(!SCAN_OPTS||!scanProvider)return;
975
+ Array.prototype.slice.call(scanProvider.options).forEach(function(opt){
976
+ if(!opt.value)return; // leave "Auto" alone
977
+ var pr=null; (SCAN_OPTS.providers||[]).forEach(function(p){ if(p.provider===opt.value)pr=p; });
978
+ if(!pr)return;
979
+ opt.disabled=!pr.has_key;
980
+ opt.textContent=providerLabel(opt.value)+(pr.has_key?' — key saved':' — no key saved');
981
+ });
982
+ if(scanClip&&scanClip.checked)syncClipMode();
983
+ }
984
+ function loadScanOpts(){
985
+ fetch('/raws/scan-options',{credentials:'same-origin',headers:{Accept:'application/json'}})
986
+ .then(function(r){ return r.ok?r.json():null; })
987
+ .then(function(d){ if(d){ SCAN_OPTS=d; applyScanOpts(); } }).catch(function(){});
988
+ }
989
+ function syncClipMode(){
990
+ var on=!!(scanClip&&scanClip.checked);
991
+ if(scanAdvanced)scanAdvanced.hidden=!on;
992
+ if(scanSubmit)scanSubmit.innerHTML=(on?'Start scan':'Import video')+' <span class="rk-arrow">→</span>';
993
+ if(on&&!clipReady()){
994
+ setNote('No AI provider available — add a Gemini, OpenAI, or OpenRouter key in Settings to clip raws (or uncheck to import the whole video).','error');
995
+ } else {
996
+ setNote(on?CLIP_NOTE:IMPORT_NOTE);
997
+ }
998
+ }
999
+ if(scanClip)scanClip.addEventListener('change',syncClipMode);
1000
+ if(dialog){ openBtns.forEach(function(openBtn){ openBtn.addEventListener('click',function(){ if(!SCAN_OPTS)loadScanOpts(); syncClipMode(); if(typeof dialog.showModal==='function'&&!dialog.open)dialog.showModal(); }); }); }
347
1001
  if(cancelBtn&&dialog){ cancelBtn.addEventListener('click',function(){ if(dialog.open)dialog.close(); }); }
348
1002
  if(dialog){
349
1003
  dialog.addEventListener('click',function(e){
@@ -351,87 +1005,186 @@ const CLIPS_SCRIPT = `
351
1005
  if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)dialog.close();
352
1006
  });
353
1007
  }
354
- if(scanForm){ scanForm.addEventListener('submit',function(){ if(statusEl)statusEl.textContent='Scanning source… this can take a few minutes.'; }); }
1008
+ if(scanForm){
1009
+ scanForm.addEventListener('submit',function(e){
1010
+ e.preventDefault();
1011
+ var url=(scanUrl&&scanUrl.value||'').trim();
1012
+ var tracer=(scanTracer&&scanTracer.value||'').trim();
1013
+ var clip=!!(scanClip&&scanClip.checked);
1014
+ var prompt=clip?(scanPrompt&&scanPrompt.value||'').trim():'';
1015
+ var provider=clip?(scanProvider&&scanProvider.value||'').trim():'';
1016
+ if(!url){ setNote('Enter a video URL.','error'); return; }
1017
+ setNote(clip?'Starting scan…':'Importing video…');
1018
+ if(scanSubmit){ scanSubmit.disabled=true; scanSubmit.classList.add('is-loading'); }
1019
+
1020
+ var payload={source_url:url};
1021
+ if(clip){ payload.prompt=prompt; if(provider)payload.provider=provider; } else { payload.import_only=true; }
1022
+ if(tracer)payload.tracer=tracer;
1023
+
1024
+ // Fire the request now; hold it and only wire the result AFTER the modal
1025
+ // closes and the optimistic card lands (so a fast reply can't race ahead
1026
+ // of its own placeholder).
1027
+ var id='imp'+Date.now()+'-'+Math.floor(Math.random()*1e6);
1028
+ var req=fetch('/raws/scan',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json',Accept:'application/json'},body:JSON.stringify(payload)})
1029
+ .then(function(r){ return r.json().then(function(d){ return {ok:r.ok,data:d}; }); });
1030
+
1031
+ // brief spinner beat, then close and optimistically drop an "importing…" card
1032
+ setTimeout(function(){
1033
+ if(scanSubmit){ scanSubmit.disabled=false; scanSubmit.classList.remove('is-loading'); }
1034
+ if(dialog&&dialog.open)dialog.close();
1035
+ if(scanForm.reset)scanForm.reset();
1036
+ syncClipMode();
1037
+ PENDING.unshift({id:id,url:url,tracer:tracer,clip:clip,label:clip?'Starting scan…':'Importing…'});
1038
+ if(query)query.value='';
1039
+ paintRoot(); // surface the optimistic "importing…" card at the directory root
1040
+ if(statusEl)statusEl.textContent=(clip?'Mining a source…':'Importing a video…')+(tracer?' (tracer '+tracer+')':'');
1041
+ req.then(function(res){ onScanResult(id,clip,res); }).catch(function(){ failImport(id,'Network error — please try again.'); });
1042
+ },650);
1043
+ });
1044
+ }
1045
+
1046
+ loadFeed();
355
1047
  })();`;
356
- export function renderReskinLibrary(tab = "approved") {
357
- const isClips = tab === "clips";
1048
+ export function renderReskinLibrary(tab = "approved", input = EMPTY_LIBRARY_INPUT) {
1049
+ const isRaws = tab === "raws";
1050
+ const isLogs = tab === "logs";
1051
+ const isFiles = tab === "files";
1052
+ const tabDef = [
1053
+ { key: "approved", label: "Approved", href: "/library/approved" },
1054
+ { key: "raws", label: "Raws", href: "/library/raws" },
1055
+ { key: "files", label: "Files", href: "/library/files" }
1056
+ ];
358
1057
  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>
1058
+ ${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
1059
  </div>`;
1060
+ // Logs tab reuses the real /job-runs history endpoint (client-fetched by the
1061
+ // folded-in job-runs script); the seed is empty so it loads live.
1062
+ const logsBoot = {
1063
+ loadEndpoint: "/job-runs/history?limit=250",
1064
+ accountId: input.currentAccountId || "",
1065
+ storageDriver: "s3",
1066
+ entries: []
1067
+ };
1068
+ const headSub = isLogs
1069
+ ? "Every render, clip scan, decompose and primitive job — with status, timing, and the compute it cost."
1070
+ : isFiles
1071
+ ? "Your whole workspace as one navigable directory — files, temp scratch, and mined raws. Search by meaning, name, or path."
1072
+ : isRaws
1073
+ ? "Long-form videos mined into tagged, searchable short clips — every reaction, hook, and money shot, ready to drop into an edit."
1074
+ : "Every published cut you own — ready-to-post renders and scheduled drops. Open in the editor, share, download, or schedule.";
1075
+ const headCta = isRaws
1076
+ ? `<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>`
1077
+ : isLogs || isFiles
1078
+ ? ""
1079
+ : `<a class="rk-btn rk-btn-gold rk-btn-sm rk-library-new-btn" href="/editor/original/fork/new">New project <span class="rk-arrow">→</span></a>`;
1080
+ const panel = isLogs
1081
+ ? `<div class="rk-library-topbar">${tabs}</div>\n ${jobRunsPanel(logsBoot)}`
1082
+ : isFiles
1083
+ ? filesPanel(tabs)
1084
+ : isRaws
1085
+ ? clipsPanel(tabs)
1086
+ : approvedPanel(input, tabs);
362
1087
  const body = `
363
1088
  <main class="rk-container-wide rk-page rk-library-page">
364
- <div class="rk-spread rk-wrap rk-page-head rk-library-head">
1089
+ <div class="rk-spread rk-page-head rk-library-head">
365
1090
  <div class="rk-stack rk-library-head-copy">
366
- <span class="rk-badge">Your studio</span>
367
1091
  <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>
1092
+ <p class="rk-sub">${headSub}</p>
374
1093
  </div>
1094
+ ${headCta}
375
1095
  </div>
376
1096
 
377
- ${tabs}
378
-
379
- ${isClips ? clipsPanel() : approvedPanel()}
1097
+ ${panel}
380
1098
  </main>
381
- ${isClips ? CLIPS_DIALOG : ""}`;
1099
+ ${isRaws ? CLIPS_DIALOG : ""}`;
382
1100
  const pageCss = `
383
1101
  /* .rk-page zeroes horizontal padding; the wide container == viewport at 1280 so
384
1102
  margin:auto adds no gutter. Restore side gutters (longhand beats the shorthand). */
385
1103
  .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}}
1104
+ /* compact head: title + sub left, small New project pinned top-right on the same row */
1105
+ .rk-library-head{align-items:flex-start}
1106
+ .rk-library-head-copy{gap:4px;margin:0;flex:1 1 auto;min-width:0}
1107
+ .rk-library-new-btn{flex:none;margin-top:2px}
1108
+
1109
+ /* topbar row: primary tab toggle + the tab's quick search share one line */
1110
+ .rk-library-topbar{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin-bottom:22px}
1111
+ /* "Logs" — pulled out of the tab toggle; floats to the far right of the row.
1112
+ When a search bar is present it already grabs margin-left:auto, so Logs just
1113
+ trails it; when there's no search (Files tab) Logs floats right on its own. */
1114
+ .rk-library-logs-btn{flex:none;margin-left:auto;gap:7px}
1115
+ .rk-library-qsearch ~ .rk-library-logs-btn,#rkClipsSearch ~ .rk-library-logs-btn{margin-left:0}
1116
+ .rk-library-logs-btn svg{color:var(--rk-n-500)}
1117
+ @media(max-width:720px){.rk-library-logs-btn{margin-left:auto}}
391
1118
 
392
1119
  /* 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}
1120
+ .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
1121
  .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
1122
  .rk-library-tab span{font-size:15px}
396
1123
  .rk-library-tab:hover{color:var(--rk-ink)}
397
1124
  .rk-library-tab.is-active{background:var(--rk-ink);color:#fff;box-shadow:var(--rk-shadow-sm)}
398
1125
 
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}
1126
+ /* stats — sits right-aligned in the chips row */
1127
+ .rk-library-summary{font-size:13.5px;color:var(--rk-text-muted);font-weight:500;line-height:1.7}
401
1128
  .rk-library-summary b{color:var(--rk-ink);font-weight:800;font-family:var(--rk-font-display);font-size:15px;letter-spacing:-.01em}
1129
+ .rk-library-toolbar .rk-library-summary{margin-left:auto}
402
1130
 
403
- /* toolbar */
1131
+ /* toolbar (filter chips + count) */
404
1132
  .rk-library-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin-bottom:24px}
405
1133
  .rk-library-chips{display:flex;flex-wrap:wrap;gap:4px}
406
1134
  .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
1135
  .rk-library-chip:hover{background:var(--rk-n-100);color:var(--rk-ink)}
408
1136
  .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}}
1137
+ /* approved quickfilter — same pill search as the Clips tab, right-aligned */
1138
+ .rk-library-qsearch{flex:1 1 220px;max-width:360px;margin-left:auto}
1139
+ .rk-library-qsearch .rk-clips-search-input:disabled{background:var(--rk-n-100);cursor:not-allowed}
1140
+ @media(max-width:720px){.rk-library-qsearch{max-width:none;margin-left:0;width:100%}}
411
1141
 
412
1142
  /* grid + cards */
413
1143
  .rk-library-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:20px}
414
1144
  .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
1145
  .rk-library-card[hidden]{display:none}
416
- .rk-library-poster{position:relative;display:block;aspect-ratio:9/16;overflow:hidden}
1146
+ .rk-library-poster{position:relative;display:block;width:100%;aspect-ratio:9/16;overflow:hidden;background:linear-gradient(158deg,#2c2c2c,#171717);border:0;padding:0;margin:0;cursor:pointer;text-align:left;font:inherit;color:inherit}
417
1147
  .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))}
1148
+ .rk-library-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#050505}
1149
+ .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
1150
  .rk-library-pstatus{position:absolute;top:12px;left:12px;z-index:2;box-shadow:var(--rk-shadow-sm)}
420
1151
  .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
1152
  .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
1153
  .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
1154
  .rk-library-card:hover .rk-library-play{opacity:1;transform:translate(-50%,-50%) scale(1)}
1155
+ @media(hover:none){.rk-library-play{opacity:1;transform:translate(-50%,-50%) scale(1)}}
425
1156
  .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
- .rk-library-meta{display:grid;gap:10px;padding:16px 16px 18px}
1157
+ .rk-library-meta{display:grid;gap:9px;padding:14px 14px 15px}
429
1158
  .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
- .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)}
1159
+ .rk-library-origin{display:flex;align-items:center;gap:6px;min-width:0;font-size:12px;font-weight:500;color:var(--rk-text-muted)}
1160
+ .rk-library-origin-sep{color:var(--rk-text-faint);flex:none}
431
1161
  .rk-library-origin-txt{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
432
- .rk-library-foot{margin-top:2px;gap:10px}
433
- .rk-library-foot .rk-btn{flex:none}
434
- .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}
1162
+ .rk-library-time{font-size:12px;font-weight:600;color:var(--rk-text-faint);white-space:nowrap;flex:none}
1163
+ /* compact action row: labelled Schedule + icon-only Open/View/Download */
1164
+ .rk-library-actions{display:flex;align-items:center;gap:6px;margin-top:1px}
1165
+ .rk-library-act{flex:none;min-height:32px;padding:0 12px;font-size:12.5px;border-radius:var(--rk-r-full)}
1166
+ .rk-library-act-ic{width:32px;min-width:32px;padding:0;display:inline-flex;align-items:center;justify-content:center}
1167
+ .rk-library-act-ic svg{display:block}
1168
+
1169
+ /* right-floated "⋯" overflow menu (Copy ID / Delete-with-popconfirm) */
1170
+ .rk-library-menu{position:relative;margin-left:auto;flex:none}
1171
+ .rk-library-kebab{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--rk-border);border-radius:var(--rk-r-full);background:var(--rk-surface);color:var(--rk-n-600);cursor:pointer;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
1172
+ .rk-library-kebab:hover{background:var(--rk-n-100);color:var(--rk-ink)}
1173
+ .rk-library-menu.is-open .rk-library-kebab{background:var(--rk-n-100);color:var(--rk-ink);border-color:var(--rk-border-strong)}
1174
+ .rk-library-kebab.is-ok{color:#1f7a44;border-color:#bfe6cd;background:#e8f7ee}
1175
+ .rk-library-menu-pop{position:absolute;right:0;bottom:calc(100% + 8px);z-index:30;min-width:172px;padding:6px;background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-xl);box-shadow:var(--rk-shadow-lg,var(--rk-shadow-xl))}
1176
+ .rk-library-menu-pop[hidden]{display:none}
1177
+ .rk-library-menu-item{display:flex;align-items:center;gap:9px;width:100%;padding:9px 11px;border:0;border-radius:var(--rk-r-lg,12px);background:none;color:var(--rk-ink);font:inherit;font-size:13px;font-weight:600;text-align:left;cursor:pointer;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
1178
+ .rk-library-menu-item svg{flex:none;color:var(--rk-n-500)}
1179
+ .rk-library-menu-item:hover{background:var(--rk-n-100)}
1180
+ .rk-library-menu-item.is-danger{color:var(--rk-red)}
1181
+ .rk-library-menu-item.is-danger svg{color:var(--rk-red)}
1182
+ .rk-library-menu-item.is-danger:hover{background:var(--rk-red-tint)}
1183
+ .rk-library-menu-confirm{padding:10px 11px 4px}
1184
+ .rk-library-menu-confirm[hidden]{display:none}
1185
+ .rk-library-menu-confirm-q{font-size:12.5px;font-weight:600;color:var(--rk-ink);margin:0 0 9px}
1186
+ .rk-library-menu-confirm-row{display:flex;justify-content:flex-end;gap:7px}
1187
+ .rk-library-menu-confirm-row .rk-btn{min-height:30px}
435
1188
 
436
1189
  /* new-project tile */
437
1190
  .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,69 +1200,187 @@ ${isClips ? CLIPS_DIALOG : ""}`;
447
1200
  .rk-library-empty h3{font-size:var(--rk-text-xl)}
448
1201
  .rk-library-empty p{max-width:42ch}
449
1202
 
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}}
1203
+ @media(prefers-reduced-motion:reduce){.rk-library-video{}}
1204
+
1205
+ /* ── fullscreen TikTok-style viewer (click a card → vertical scroll deck) ── */
1206
+ html.rk-noscroll,html.rk-noscroll body{overflow:hidden}
1207
+ .rk-library-viewer{position:fixed;inset:0;z-index:120}
1208
+ .rk-library-viewer[hidden]{display:none}
1209
+ .rk-library-viewer-scrim{position:absolute;inset:0;background:rgba(15,12,5,.88);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
1210
+ .rk-library-viewer-track{position:absolute;inset:0;overflow-y:auto;scroll-snap-type:y mandatory;overscroll-behavior:contain;scrollbar-width:none}
1211
+ .rk-library-viewer-track::-webkit-scrollbar{display:none}
1212
+ .rk-library-viewer-slide{height:100%;display:grid;place-items:center;scroll-snap-align:start;scroll-snap-stop:always;padding:20px 0}
1213
+ .rk-library-viewer-stage{position:relative;width:min(92vw,calc(93vh * 9 / 16));aspect-ratio:9/16;max-height:100%;margin:0;border-radius:var(--rk-r-3xl);overflow:hidden;background:#000;box-shadow:var(--rk-shadow-xl)}
1214
+ .rk-library-viewer-media{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block}
1215
+ video.rk-library-viewer-media{cursor:pointer}
1216
+ .rk-library-viewer-pause{position:absolute;inset:0;z-index:2;display:none;place-items:center;color:rgba(255,255,255,.92);pointer-events:none;filter:drop-shadow(0 4px 14px rgba(0,0,0,.55))}
1217
+ .rk-library-viewer-pause svg{width:64px;height:64px}
1218
+ .rk-library-viewer-slide.is-paused .rk-library-viewer-pause{display:grid}
1219
+ .rk-library-viewer-shade{position:absolute;left:0;right:0;bottom:0;height:46%;z-index:1;pointer-events:none;background:linear-gradient(to top,rgba(0,0,0,.74) 0%,rgba(0,0,0,.22) 62%,transparent 100%)}
1220
+ .rk-library-viewer-info{position:absolute;left:0;right:0;bottom:0;z-index:3;padding:20px;display:grid;gap:8px;justify-items:start;pointer-events:none}
1221
+ .rk-library-viewer-title{font-family:var(--rk-font-display);font-weight:700;font-size:1.12rem;line-height:1.2;letter-spacing:-.01em;color:#fff}
1222
+ .rk-library-viewer-desc{font-size:12.5px;font-weight:500;color:rgba(255,255,255,.82);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
1223
+ .rk-library-viewer-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:4px;pointer-events:auto}
1224
+ .rk-library-viewer-cta{flex:none}
1225
+ .rk-library-viewer .rk-btn-ghost-light{background:rgba(255,255,255,.14);color:#fff;border-color:rgba(255,255,255,.24);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);box-shadow:none}
1226
+ .rk-library-viewer .rk-btn-ghost-light:hover{background:rgba(255,255,255,.26);border-color:rgba(255,255,255,.4)}
1227
+ .rk-library-viewer-ui{position:absolute;top:max(16px,env(safe-area-inset-top,0px));right:16px;z-index:5;display:flex;gap:10px}
1228
+ .rk-library-viewer-btn{width:44px;height:44px;border-radius:var(--rk-r-full);border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.12);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;display:grid;place-items:center;cursor:pointer;transition:background var(--rk-dur) var(--rk-ease),transform var(--rk-dur) var(--rk-ease)}
1229
+ .rk-library-viewer-btn:hover{background:rgba(255,255,255,.24);transform:scale(1.06)}
1230
+ .rk-library-viewer-btn span{display:grid;place-items:center;line-height:0}
1231
+ .rk-library-viewer .rk-lvm-off{display:none}
1232
+ .rk-library-viewer.is-muted .rk-lvm-on{display:none}
1233
+ .rk-library-viewer.is-muted .rk-lvm-off{display:grid}
1234
+ .rk-library-viewer-nav{position:absolute;right:18px;top:50%;transform:translateY(-50%);z-index:5;display:grid;gap:12px}
1235
+ @media(hover:none){.rk-library-viewer-nav{display:none}}
1236
+ @media(max-width:640px){
1237
+ .rk-library-viewer-nav{display:none}
1238
+ .rk-library-viewer-slide{padding:0}
1239
+ .rk-library-viewer-stage{width:100%;height:100%;max-height:none;aspect-ratio:auto;border-radius:0;box-shadow:none}
1240
+ }
452
1241
 
453
1242
  /* ── clips tab ── */
454
1243
  .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}
1244
+ .rk-clips-search{position:relative;display:flex;align-items:center;gap:8px;flex:1 1 300px;min-width:240px;margin:0}
457
1245
  .rk-clips-search-ic{position:absolute;left:14px;top:50%;transform:translateY(-50%);color:var(--rk-n-400);pointer-events:none;display:flex}
458
1246
  .rk-clips-search-input{flex:1;padding-left:40px;border-radius:var(--rk-r-full)}
459
1247
  .rk-clips-search-btn{flex:none}
460
1248
  .rk-clips-scan{flex:none}
461
- .rk-clips-chips{display:flex;flex-wrap:wrap;gap:4px}
462
- .rk-clips-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 13px;border-radius:var(--rk-r-full);border:1px solid transparent;background:transparent;font-family:var(--rk-font-body);font-size:13px;font-weight:600;color:var(--rk-text-muted);cursor:pointer;white-space:nowrap;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
463
- .rk-clips-chip:hover{background:var(--rk-n-100);color:var(--rk-ink)}
464
- .rk-clips-chip.is-active{background:var(--rk-ink);color:#fff}
465
1249
  .rk-clips-status{font-size:13px;color:var(--rk-text-muted);font-weight:500}
466
- .rk-clips-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:22px}
1250
+ .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}
1251
+
1252
+ /* ── raws file-directory explorer ── */
1253
+ /* breadcrumb bar (replaces the old count/summary double-row) */
1254
+ .rk-raws-bar{display:flex;flex-wrap:wrap;align-items:center;gap:12px;min-height:30px;margin-bottom:18px}
1255
+ .rk-raws-crumbs{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0}
1256
+ .rk-raws-crumb{display:inline-flex;align-items:center;gap:5px;font-size:14px;font-weight:600;color:var(--rk-text-muted);max-width:52ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1257
+ .rk-raws-crumb.is-current{color:var(--rk-ink);font-family:var(--rk-font-display);font-weight:800;letter-spacing:-.01em}
1258
+ .rk-raws-crumb-link{border:0;background:none;padding:4px 8px 4px 6px;margin-left:-6px;border-radius:var(--rk-r-full);cursor:pointer;font-family:inherit;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease)}
1259
+ .rk-raws-crumb-link:hover{background:var(--rk-n-100);color:var(--rk-ink)}
1260
+ .rk-raws-crumb-link svg{flex:none}
1261
+ .rk-raws-crumb-sep{color:var(--rk-text-faint);font-weight:500}
1262
+ .rk-raws-crumb-n{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:19px;padding:0 6px;border-radius:var(--rk-r-full);background:var(--rk-n-100);color:var(--rk-text-muted);font-family:var(--rk-font-mono,monospace);font-size:11px;font-weight:700}
1263
+ .rk-raws-bar .rk-clips-status{margin-left:auto;text-align:right}
1264
+
1265
+ /* the explorer grid switches between clip cards (default) and folder tiles */
1266
+ .rk-raws-explorer{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:20px}
1267
+ .rk-raws-explorer.is-folders{grid-template-columns:repeat(auto-fill,minmax(228px,1fr))}
1268
+
1269
+ /* folder tile — 2×2 thumbnail mosaic + name + count */
1270
+ .rk-raws-folder{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);cursor:pointer;text-align:left;font:inherit;color:inherit;padding:0;transition:transform var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
1271
+ .rk-raws-folder:hover{transform:translateY(-3px);box-shadow:var(--rk-shadow-md);border-color:var(--rk-gold-600)}
1272
+ .rk-raws-folder-mosaic{position:relative;display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:1fr;gap:2px;aspect-ratio:4/3;background:var(--rk-n-100);overflow:hidden}
1273
+ .rk-raws-folder-mosaic.is-single{grid-template-columns:1fr}
1274
+ .rk-raws-folder-mosaic.is-empty{display:grid;grid-template-columns:1fr;place-items:center;color:var(--rk-n-400);background:linear-gradient(150deg,var(--rk-n-100),var(--rk-n-50))}
1275
+ .rk-raws-folder-mosaic.is-empty svg{width:38px;height:38px}
1276
+ .rk-raws-folder-cell{display:block;background-size:cover;background-position:center;background-repeat:no-repeat;background-color:#171717}
1277
+ .rk-raws-folder-meta{display:flex;align-items:center;gap:10px;padding:13px 14px}
1278
+ .rk-raws-folder-ic{flex:none;color:var(--rk-gold-700);display:flex}
1279
+ .rk-raws-folder-txt{display:flex;flex-direction:column;gap:2px;min-width:0}
1280
+ .rk-raws-folder-name{font-family:var(--rk-font-display);font-size:14px;font-weight:700;color:var(--rk-ink);letter-spacing:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1281
+ .rk-raws-folder-count{font-size:12px;font-weight:600;color:var(--rk-text-muted)}
1282
+
1283
+ /* mouseover preview popover (floating enlarged media) */
1284
+ .rk-raws-hover{position:fixed;z-index:90;width:250px;pointer-events:none;border-radius:var(--rk-r-2xl);overflow:hidden;background:#0d0d0d;border:1px solid rgba(255,255,255,.12);box-shadow:var(--rk-shadow-xl);opacity:0;transform:scale(.96);transition:opacity .12s var(--rk-ease),transform .12s var(--rk-ease)}
1285
+ .rk-raws-hover.is-visible{opacity:1;transform:scale(1)}
1286
+ .rk-raws-hover[hidden]{display:none}
1287
+ .rk-raws-hover-vid{display:block;width:100%;aspect-ratio:3/4;object-fit:cover;background:#0d0d0d}
1288
+ .rk-raws-hover-cap{padding:9px 12px 11px;font-size:12px;font-weight:600;line-height:1.35;color:rgba(255,255,255,.92);background:linear-gradient(180deg,#171717,#0d0d0d);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
1289
+ @media(hover:none){.rk-raws-hover{display:none!important}}
1290
+
1291
+ .rk-clips-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:20px}
467
1292
  .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)}
1293
+ .rk-clips-thumb{position:relative;aspect-ratio:3/4;overflow:hidden;display:grid;place-items:center;background:linear-gradient(150deg,#2c2c2c,#171717)}
1294
+ .rk-clips-thumb-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
1295
+ .rk-clips-thumb-vid{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:none}
1296
+ .rk-clips-noimg{color:rgba(255,255,255,.68);font-size:12px;font-weight:600;letter-spacing:.01em}
1297
+ .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}
1298
+ .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
1299
  .rk-clips-card:hover .rk-clips-play{opacity:1;transform:scale(1)}
1300
+ @media(hover:none){.rk-clips-play{opacity:1;transform:scale(1)}}
474
1301
  .rk-clips-play:hover{background:var(--rk-gold-500)}
475
1302
  .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}
1303
+ .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}
1304
+ .rk-clips-tags{display:flex;flex-wrap:wrap;gap:5px}
1305
+ .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
1306
  .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
1307
  .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
- .rk-clips-dl{flex:none;color:var(--rk-gold-700);font-size:12px;font-weight:700}
1308
+ .rk-clips-dl{flex:none;color:var(--rk-gold-700);font-size:12px;font-weight:700;background:none;border:0;padding:0;font-family:inherit;cursor:pointer}
481
1309
  .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))}
1310
+ /* the whole thumb is a button that opens the scroll-snap viewer */
1311
+ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer}
1312
+ /* optimistic "importing…" placeholder card */
1313
+ .rk-clips-importing .rk-clips-thumb{aspect-ratio:3/4}
1314
+ .rk-clips-importing-spin{width:36px;height:36px;border-radius:var(--rk-r-full);border:3px solid rgba(255,255,255,.26);border-top-color:#fff;animation:rk-clips-spin .8s linear infinite}
1315
+ @keyframes rk-clips-spin{to{transform:rotate(360deg)}}
1316
+ .rk-clips-importing.is-error .rk-clips-thumb{background:linear-gradient(150deg,#5b2b23,#3a1a15)}
1317
+ .rk-clips-importing-x{color:rgba(255,255,255,.9);display:grid;place-items:center}
1318
+ .rk-clips-importing.is-error .rk-clips-title{color:#8d3b2f}
1319
+ .rk-clips-importing-tag{color:var(--rk-text-faint)!important;cursor:default}
1320
+ /* submit spinner while the request is in flight, before the modal closes */
1321
+ .rk-clips-dialog .rk-btn.is-loading{position:relative;color:transparent!important;pointer-events:none}
1322
+ .rk-clips-dialog .rk-btn.is-loading::after{content:"";position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:var(--rk-r-full);border:2px solid rgba(0,0,0,.32);border-top-color:var(--rk-ink);animation:rk-clips-spin .7s linear infinite}
489
1323
  .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
1324
  .rk-clips-empty[hidden]{display:none}
491
1325
  .rk-clips-empty h3{font-size:var(--rk-text-xl)}
492
- .rk-clips-dialog{width:min(34rem,calc(100vw - 2rem));padding:0;border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);background:var(--rk-surface);box-shadow:var(--rk-shadow-xl)}
1326
+ .rk-clips-dialog{width:min(34rem,calc(100vw - 2rem));max-height:calc(100dvh - 2rem);padding:0;border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);background:var(--rk-surface);box-shadow:var(--rk-shadow-xl);overflow:hidden}
493
1327
  .rk-clips-dialog::backdrop{background:rgba(23,23,23,.34);backdrop-filter:blur(6px)}
494
- .rk-clips-dialog-form{display:grid;gap:16px;margin:0;padding:28px}
1328
+ /* scroll the form (not the page) so Cancel / Import stay reachable when the
1329
+ "Clip raws" advanced block expands on short / landscape-phone viewports */
1330
+ .rk-clips-dialog-form{display:grid;gap:16px;margin:0;padding:28px;max-height:calc(100dvh - 2rem);overflow-y:auto}
495
1331
  .rk-clips-dialog-grid{gap:14px}
1332
+ .rk-clips-toggle{display:flex;gap:10px;align-items:flex-start;cursor:pointer;font-size:.92rem;line-height:1.4;color:var(--rk-n-700);background:var(--rk-n-50);border:1px solid var(--rk-border);border-radius:var(--rk-r-xl);padding:12px 14px}
1333
+ .rk-clips-toggle input{margin-top:2px;flex:0 0 auto;accent-color:var(--rk-gold);width:16px;height:16px;cursor:pointer}
1334
+ .rk-clips-toggle b{color:var(--rk-ink)}
1335
+ .rk-clips-advanced{display:grid;gap:16px}
1336
+ .rk-clips-advanced[hidden]{display:none}
496
1337
  .rk-clips-dialog-note{background:var(--rk-gold-tint);border-radius:var(--rk-r-xl);padding:10px 13px;color:var(--rk-n-700)}
1338
+ .rk-clips-dialog-note[data-tone="error"]{background:#fdecea;color:#8d3b2f}
1339
+ .rk-clips-dialog-note[data-tone="success"]{background:#e8f7ee;color:#1f7a44}
497
1340
  .rk-clips-dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}
1341
+ /* ── files tab: full-page mount of the shared directory-explorer component ──
1342
+ The component (.rk-dir + .rk-aichat-* rows) is tuned for the narrow chat
1343
+ drawer; on a full page we wrap it in a card and scale the search, crumbs and
1344
+ rows up so it reads as a first-class file explorer. Scoped to
1345
+ .rk-files-explorer so the drawer instance is untouched. */
1346
+ .rk-files-explorer{background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-card);overflow:hidden;min-height:60vh;display:flex;flex-direction:column}
1347
+ .rk-files-explorer .rk-dir{flex:1;min-height:60vh}
1348
+ .rk-files-explorer .rk-dir-search{padding:18px 20px 12px}
1349
+ .rk-files-explorer .rk-dir-search-input{font-size:15px;padding:12px 16px;border-radius:var(--rk-r-full)}
1350
+ .rk-files-explorer .rk-aichat-crumbs,.rk-files-explorer .rk-dir-crumbs{padding:4px 22px 12px;gap:2px;border-bottom:1px solid var(--rk-border)}
1351
+ .rk-files-explorer .rk-aichat-crumb{font-size:14px;padding:4px 9px}
1352
+ .rk-files-explorer .rk-aichat-crumb-sep{font-size:14px}
1353
+ .rk-files-explorer .rk-dir-newfolder{font-size:12.5px;padding:5px 11px}
1354
+ .rk-files-explorer .rk-dir-selbar{margin:12px 18px 0;padding:9px 14px}
1355
+ .rk-files-explorer .rk-aichat-files-body,.rk-files-explorer .rk-dir-body{padding:12px 14px 20px;gap:3px}
1356
+ .rk-files-explorer .rk-aichat-frow{padding:11px 12px;border-radius:var(--rk-r-xl)}
1357
+ .rk-files-explorer .rk-aichat-fic{width:44px;height:44px;border-radius:var(--rk-r-lg)}
1358
+ .rk-files-explorer .rk-aichat-fname{font-size:14.5px}
1359
+ .rk-files-explorer .rk-aichat-fmeta{font-size:12.5px}
1360
+ .rk-files-explorer .rk-dir-open{font-size:12.5px}
1361
+ .rk-files-explorer .rk-dir-kebab{width:30px;height:30px}
1362
+ .rk-files-explorer .rk-aichat-drop{margin:0 18px 18px}
1363
+ .rk-files-explorer .rk-aichat-fstate{padding:48px 24px;text-align:center}
498
1364
  @media(max-width:620px){
499
1365
  .rk-clips-search{flex:1 1 100%}
500
1366
  .rk-clips-scan{flex:1 1 100%}
501
1367
  .rk-clips-search-input{font-size:16px}
502
- .rk-clips-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
503
- .rk-clips-glyph{font-size:42px}
1368
+ .rk-clips-grid,.rk-raws-explorer{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
1369
+ .rk-raws-explorer.is-folders{grid-template-columns:repeat(2,minmax(0,1fr))}
1370
+ .rk-files-explorer .rk-dir-search-input{font-size:16px}
504
1371
  }
505
1372
  `;
1373
+ const tabTitle = isRaws ? " · Raws" : isLogs ? " · Logs" : isFiles ? " · Files" : "";
506
1374
  return reskinDocument({
507
- title: `vidfarm reskin — Library${isClips ? " · Clips" : ""}`,
508
- description: "Reskinned vidfarm library: your projects, renders & ready-to-post cuts, plus mined clips.",
1375
+ title: `vidfarm reskin — Library${tabTitle}`,
1376
+ description: "Reskinned vidfarm library: your published cuts, mined raws library, unified file directory, and job run history.",
509
1377
  activeSlug: "library",
510
- pageCss,
1378
+ account: { name: input.name, email: input.email },
1379
+ pageCss: pageCss + (isLogs ? "\n" + JOBRUNS_PAGE_CSS : ""),
511
1380
  body,
512
- script: isClips ? CLIPS_SCRIPT : APPROVED_SCRIPT
1381
+ // Files tab needs no page script the auto-mounted directory-explorer
1382
+ // component (base module on every full-chrome page) owns all its behaviour.
1383
+ script: isFiles ? undefined : isLogs ? JOBRUNS_SCRIPT : isRaws ? CLIPS_SCRIPT : APPROVED_SCRIPT
513
1384
  });
514
1385
  }
515
1386
  //# sourceMappingURL=library-page.js.map