@mevdragon/vidfarm-devcli 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,38 +22,627 @@ export function rkEscape(value) {
22
22
  .replace(/"/g, """)
23
23
  .replace(/'/g, "'");
24
24
  }
25
+ /** The sidebar shows only the core app pages (not every reskinned page).
26
+ * Other pages stay reachable from the /reskin gallery (brand link). */
27
+ const SIDEBAR_NAV = [
28
+ { slug: "discover", label: "Discover" },
29
+ { slug: "chat", label: "Chat" },
30
+ { slug: "library", label: "Library" },
31
+ { slug: "calendar", label: "Calendar" },
32
+ { slug: "help", label: "Guides" },
33
+ { slug: "settings", label: "Settings" }
34
+ ];
25
35
  function renderSidebar(activeSlug) {
26
- const items = RESKIN_PAGES.map((p) => {
36
+ const items = SIDEBAR_NAV.map((p) => {
27
37
  const active = p.slug === activeSlug ? " is-active" : "";
28
- return `<a class="rk-sidebar-item${active}" href="/reskin/${p.slug}"${p.slug === activeSlug ? ` aria-current="page"` : ""}>${p.label}</a>`;
38
+ return `<a class="rk-sidebar-item${active}" href="/${p.slug}"${p.slug === activeSlug ? ` aria-current="page"` : ""}>${p.label}</a>`;
29
39
  }).join("");
30
- const live = RESKIN_PAGES.find((p) => p.slug === activeSlug)?.live ?? "/";
31
- const homeActive = activeSlug === null ? " is-active" : "";
32
40
  return `<aside class="rk-sidebar">
33
- <a class="rk-sidebar-brand" href="/reskin"><span class="rk-brand-mark">V</span>vidfarm<span class="rk-brand-tag">reskin</span></a>
41
+ <a class="rk-sidebar-brand" href="/discover"><span class="rk-brand-mark">V</span>vidfarm</a>
34
42
  <nav class="rk-sidebar-nav" aria-label="Reskin pages">
35
- <a class="rk-sidebar-item${homeActive}" href="/reskin">Gallery</a>
36
- <div class="rk-sidebar-sep"></div>
37
43
  ${items}
38
44
  </nav>
39
- <div class="rk-sidebar-foot">
40
- <a class="rk-sidebar-foot-link" href="${rkEscape(live)}">↗ Compare with live page</a>
41
- <a class="rk-btn rk-btn-ink rk-btn-sm rk-sidebar-cta" href="/">Live site <span class="rk-arrow">→</span></a>
45
+ </aside>`;
46
+ }
47
+ /** Draggable chat FAB (defaults to the old sidebar-foot spot, bottom-left) +
48
+ * the popup AI chat panel it toggles. Sample chat only — this is the design
49
+ * sandbox, so the panel demos the UI rather than hitting a live agent. The
50
+ * old "compare with live page" link survives as the ↗ tool in the panel head. */
51
+ function renderChatDock(activeSlug) {
52
+ const page = RESKIN_PAGES.find((p) => p.slug === activeSlug);
53
+ const label = rkEscape(page?.label ?? "vidfarm");
54
+ return `<button class="rk-fab" id="rkFab" type="button" aria-label="Open AI chat" aria-expanded="false" aria-controls="rkAichat" title="Chat with vidfarm AI — drag to move">
55
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
56
+ </button>
57
+ <aside class="rk-aichat has-files" id="rkAichat" hidden aria-label="AI chat panel">
58
+ <div class="rk-aichat-files" id="rkAichatFiles" aria-label="Files">
59
+ <div class="rk-aichat-crumbs" id="rkAichatCrumbs" aria-label="Current folder"></div>
60
+ <div class="rk-aichat-files-body" id="rkAichatFilesBody"></div>
61
+ <div class="rk-aichat-drop" id="rkAichatDrop" hidden>
62
+ <input type="file" id="rkAichatUploadInput" multiple hidden>
63
+ <div class="rk-aichat-drop-text" id="rkAichatDropText">Drop files to upload</div>
64
+ <button type="button" class="rk-aichat-drop-btn" id="rkAichatUploadBtn">Upload files</button>
65
+ </div>
66
+ </div>
67
+ <div class="rk-aichat-main">
68
+ <header class="rk-aichat-head">
69
+ <span class="rk-brand-mark">V</span>
70
+ <div class="rk-aichat-head-main">
71
+ <div class="rk-aichat-title">Vidfarm AI</div>
72
+ <div class="rk-aichat-sub">sample chat</div>
73
+ </div>
74
+ <button class="rk-aichat-tool is-on" id="rkFilesToggle" type="button" title="Toggle My Files" aria-label="Toggle My Files" aria-pressed="true">
75
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
76
+ </button>
77
+ <button class="rk-aichat-tool" id="rkAichatMin" type="button" title="Minimize" aria-label="Minimize chat">—</button>
78
+ </header>
79
+ <div class="rk-aichat-log" id="rkAichatLog">
80
+ <div class="rk-aichat-msg is-ai">Hi! I'm the vidfarm assistant. Ask me anything about the ${label} page — or attach a file from the Files panel on the left.</div>
81
+ <div class="rk-aichat-msg is-user">What changed in this reskin?</div>
82
+ <div class="rk-aichat-msg is-ai">This page was ported into the farmville design system — honey-gold accent, soft rounded cards, the shared rk- primitives — while keeping the layout one-to-one with the live page.</div>
83
+ </div>
84
+ <div class="rk-aichat-chips" id="rkAichatChips"></div>
85
+ <form class="rk-aichat-composer" id="rkAichatForm">
86
+ <input class="rk-input" id="rkAichatInput" placeholder="Ask the vidfarm AI…" autocomplete="off">
87
+ <button class="rk-btn rk-btn-gold rk-btn-sm" type="submit">Send</button>
88
+ </form>
42
89
  </div>
43
90
  </aside>`;
44
91
  }
92
+ /** Chrome behavior: FAB drag (click-vs-drag threshold, viewport clamp, position
93
+ * persisted in localStorage) + panel toggle. The panel anchors to whichever
94
+ * half of the viewport the FAB sits in so it is never cut off; when it opens
95
+ * on the left it replaces the sidebar nav (body.rk-chat-left). Open state
96
+ * survives page navigation via sessionStorage. Plain IIFE — no backticks or
97
+ * dollar-brace inside (it is embedded in a template literal). */
98
+ const RESKIN_CHROME_SCRIPT = `(() => {
99
+ var fab = document.getElementById('rkFab');
100
+ var panel = document.getElementById('rkAichat');
101
+ if (!fab || !panel) return;
102
+ var minBtn = document.getElementById('rkAichatMin');
103
+ var form = document.getElementById('rkAichatForm');
104
+ var input = document.getElementById('rkAichatInput');
105
+ var log = document.getElementById('rkAichatLog');
106
+ var MARGIN = 10;
107
+
108
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
109
+
110
+ // offsetWidth/offsetLeft (not getBoundingClientRect) so the hover/drag/open
111
+ // transforms never skew the clamp math
112
+ function placeFab(x, y) {
113
+ x = clamp(x, MARGIN, window.innerWidth - fab.offsetWidth - MARGIN);
114
+ y = clamp(y, MARGIN, window.innerHeight - fab.offsetHeight - MARGIN);
115
+ fab.style.left = x + 'px';
116
+ fab.style.top = y + 'px';
117
+ fab.style.bottom = 'auto';
118
+ return { x: x, y: y };
119
+ }
120
+
121
+ try {
122
+ var saved = JSON.parse(localStorage.getItem('rk-fab-pos') || 'null');
123
+ if (saved && typeof saved.x === 'number') placeFab(saved.x, saved.y);
124
+ } catch (e) {}
125
+
126
+ function anchorPanel() {
127
+ var r = fab.getBoundingClientRect();
128
+ var onRight = r.left + r.width / 2 > window.innerWidth / 2;
129
+ panel.classList.toggle('is-right', onRight);
130
+ document.body.classList.toggle('rk-chat-left', !panel.hidden && !onRight);
131
+ }
132
+
133
+ function openPanel() {
134
+ panel.hidden = false;
135
+ fab.classList.add('is-open');
136
+ fab.setAttribute('aria-expanded', 'true');
137
+ anchorPanel();
138
+ try { sessionStorage.setItem('rk-chat-open', '1'); } catch (e) {}
139
+ refreshFilesOnOpen();
140
+ if (input) input.focus();
141
+ }
142
+
143
+ function closePanel() {
144
+ panel.hidden = true;
145
+ fab.classList.remove('is-open');
146
+ fab.setAttribute('aria-expanded', 'false');
147
+ document.body.classList.remove('rk-chat-left');
148
+ try { sessionStorage.removeItem('rk-chat-open'); } catch (e) {}
149
+ }
150
+
151
+ var drag = null;
152
+ fab.addEventListener('pointerdown', function (e) {
153
+ drag = { sx: e.clientX, sy: e.clientY, ox: fab.offsetLeft, oy: fab.offsetTop, moved: false, last: null };
154
+ fab.setPointerCapture(e.pointerId);
155
+ });
156
+ fab.addEventListener('pointermove', function (e) {
157
+ if (!drag) return;
158
+ var dx = e.clientX - drag.sx;
159
+ var dy = e.clientY - drag.sy;
160
+ if (!drag.moved && Math.hypot(dx, dy) < 6) return;
161
+ drag.moved = true;
162
+ fab.classList.add('is-dragging');
163
+ drag.last = placeFab(drag.ox + dx, drag.oy + dy);
164
+ });
165
+ fab.addEventListener('pointerup', function () {
166
+ if (!drag) return;
167
+ var moved = drag.moved;
168
+ if (drag.last) {
169
+ try { localStorage.setItem('rk-fab-pos', JSON.stringify(drag.last)); } catch (e) {}
170
+ }
171
+ fab.classList.remove('is-dragging');
172
+ drag = null;
173
+ if (!moved) openPanel();
174
+ });
175
+ fab.addEventListener('pointercancel', function () {
176
+ fab.classList.remove('is-dragging');
177
+ drag = null;
178
+ });
179
+ fab.addEventListener('click', function (e) {
180
+ if (e.detail === 0 && panel.hidden) openPanel();
181
+ });
182
+
183
+ if (minBtn) minBtn.addEventListener('click', closePanel);
184
+ document.addEventListener('keydown', function (e) {
185
+ if (e.key === 'Escape' && !panel.hidden) closePanel();
186
+ });
187
+ window.addEventListener('resize', function () {
188
+ if (fab.style.top) {
189
+ var pos = placeFab(fab.offsetLeft, fab.offsetTop);
190
+ try { localStorage.setItem('rk-fab-pos', JSON.stringify(pos)); } catch (e) {}
191
+ }
192
+ if (!panel.hidden) anchorPanel();
193
+ });
194
+
195
+ function bubble(kind, text) {
196
+ var el = document.createElement('div');
197
+ el.className = 'rk-aichat-msg ' + kind;
198
+ el.textContent = text;
199
+ log.appendChild(el);
200
+ log.scrollTop = log.scrollHeight;
201
+ }
202
+ if (form) form.addEventListener('submit', function (e) {
203
+ e.preventDefault();
204
+ var v = (input.value || '').trim();
205
+ var picked = selected.slice();
206
+ if (!v && !picked.length) return;
207
+ var line = v;
208
+ if (picked.length) {
209
+ var names = picked.map(function (f) { return f.name; }).join(', ');
210
+ line = (v ? v + ' ' : '') + '(attached: ' + names + ')';
211
+ }
212
+ bubble('is-user', line);
213
+ input.value = '';
214
+ clearChips();
215
+ setTimeout(function () {
216
+ bubble('is-ai', picked.length
217
+ ? 'Got the ' + picked.length + ' file' + (picked.length > 1 ? 's' : '') + ' from your ' + rootLabel(fstate.root) + ' — on the live app I would use ' + (picked.length > 1 ? 'them' : 'it') + ' in the conversation. This reskin panel is a sample, so replies are canned.'
218
+ : "I'm the sample chat in the reskin sandbox, so replies here are canned — on the live app this panel wires up to the vidfarm agent.");
219
+ }, 450);
220
+ });
221
+
222
+ // ─── My Files drawer: real directory navigation over my_files/ + temp/ ───
223
+ var filesPane = document.getElementById('rkAichatFiles');
224
+ var crumbsEl = document.getElementById('rkAichatCrumbs');
225
+ var filesBody = document.getElementById('rkAichatFilesBody');
226
+ var chipsEl = document.getElementById('rkAichatChips');
227
+ var filesToggle = document.getElementById('rkFilesToggle');
228
+ var dropEl = document.getElementById('rkAichatDrop');
229
+ var dropText = document.getElementById('rkAichatDropText');
230
+ var uploadInput = document.getElementById('rkAichatUploadInput');
231
+ var uploadBtn = document.getElementById('rkAichatUploadBtn');
232
+ var selected = [];
233
+ var uploading = false, uploadMsg = '';
234
+
235
+ var ROOTS = {
236
+ my_files: { url: '/api/v1/user/me/attachments', key: 'attachments', label: 'My Files' },
237
+ temp: { url: '/api/v1/user/me/temporary-files', key: 'files', label: 'Temp' },
238
+ // Raws = the reusable clip/source library (renamed clips→raws). Flat list
239
+ // (no folders); items have a clip shape, so they get their own normalizer.
240
+ raws: { url: '/raws/feed', key: 'clips', label: 'Raws', norm: normRaw }
241
+ };
242
+ // The sources are surfaced as top-level FOLDERS of one virtual root (no source
243
+ // tabs) — navigate into them natively like any folder.
244
+ var VROOTS = [{ key: 'my_files', label: 'My Files' }, { key: 'temp', label: 'Temp' }, { key: 'raws', label: 'Raws' }];
245
+ var fstate = {
246
+ root: '', // '' = virtual root (lists My Files + Temp + Raws); else a ROOTS key
247
+ path: { my_files: '', temp: '', raws: '' },
248
+ data: { my_files: null, temp: null, raws: null },
249
+ status: { my_files: 'idle', temp: 'idle', raws: 'idle' } // idle|loading|ready|auth|error
250
+ };
251
+
252
+ function rootLabel(root) { return ROOTS[root] ? ROOTS[root].label : 'Files'; }
253
+ function normFolder(v) {
254
+ return String(v || '').split('/').map(function (s) { return s.trim(); }).filter(Boolean).join('/');
255
+ }
256
+ function normItem(raw) {
257
+ var id = raw.id || raw.attachment_id || raw.file_id || '';
258
+ var name = raw.fileName || raw.file_name || '';
259
+ if (!id || !name) return null;
260
+ return {
261
+ id: id, name: name,
262
+ contentType: raw.contentType || raw.content_type || '',
263
+ viewUrl: raw.viewUrl || raw.view_url || '',
264
+ sizeBytes: typeof raw.sizeBytes === 'number' ? raw.sizeBytes : raw.size_bytes,
265
+ folderPath: normFolder(raw.folderPath || raw.folder_path || '')
266
+ };
267
+ }
268
+ function fmtBytes(b) {
269
+ if (!b || b <= 0) return '';
270
+ var u = ['B', 'KB', 'MB', 'GB'], v = b, i = 0;
271
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
272
+ return (v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)) + ' ' + u[i];
273
+ }
274
+ function fmtDur(s) {
275
+ if (typeof s !== 'number' || !(s > 0)) return '';
276
+ s = Math.round(s); var m = Math.floor(s / 60), r = s % 60;
277
+ return m + ':' + (r < 10 ? '0' + r : r);
278
+ }
279
+ // Raws feed items are clips (clip_id / source_filename / view_url / duration),
280
+ // a different shape than attachments — map them onto the file-row model.
281
+ function normRaw(raw) {
282
+ var id = raw.clip_id || raw.id || '';
283
+ if (!id) return null;
284
+ return {
285
+ id: id,
286
+ name: raw.source_filename || raw.description || 'clip',
287
+ contentType: 'video/mp4',
288
+ viewUrl: raw.view_url || raw.viewUrl || '',
289
+ thumbUrl: raw.thumbnail_url || raw.thumbnailUrl || '',
290
+ metaText: fmtDur(raw.duration_sec),
291
+ sizeBytes: undefined,
292
+ folderPath: ''
293
+ };
294
+ }
295
+ function immediateFolders(data, current) {
296
+ var cur = normFolder(current);
297
+ var prefix = cur ? cur + '/' : '';
298
+ var names = {};
299
+ function consider(fp) {
300
+ var n = normFolder(fp);
301
+ if (!n || (cur && n === cur) || n.indexOf(prefix) !== 0) return;
302
+ var child = n.slice(prefix.length).split('/')[0];
303
+ if (child) names[child] = prefix + child;
304
+ }
305
+ (data.folders || []).forEach(consider);
306
+ (data.items || []).forEach(function (it) { consider(it.folderPath); });
307
+ return Object.keys(names).sort().map(function (n) { return { name: n, path: names[n] }; });
308
+ }
309
+
310
+ function loadRoot(root, force) {
311
+ if (!ROOTS[root]) return;
312
+ if (!force && fstate.status[root] === 'ready') { renderFiles(); return; }
313
+ fstate.status[root] = 'loading';
314
+ renderFiles();
315
+ fetch(ROOTS[root].url, { credentials: 'same-origin', headers: { accept: 'application/json' } })
316
+ .then(function (r) {
317
+ if (r.status === 401 || r.status === 403) { fstate.status[root] = 'auth'; return null; }
318
+ if (!r.ok) throw new Error('http ' + r.status);
319
+ return r.json();
320
+ })
321
+ .then(function (j) {
322
+ if (!j) { renderFiles(); return; }
323
+ var raw = j[ROOTS[root].key] || [];
324
+ var norm = ROOTS[root].norm || normItem;
325
+ var items = [];
326
+ for (var i = 0; i < raw.length; i++) { var n = norm(raw[i]); if (n) items.push(n); }
327
+ fstate.data[root] = { items: items, folders: j.folders || [] };
328
+ fstate.status[root] = 'ready';
329
+ renderFiles();
330
+ })
331
+ .catch(function () { fstate.status[root] = 'error'; renderFiles(); });
332
+ }
333
+
334
+ function fstateNode(text, opts) {
335
+ var wrap = document.createElement('div');
336
+ wrap.className = 'rk-aichat-fstate';
337
+ var p = document.createElement('div');
338
+ p.textContent = text;
339
+ wrap.appendChild(p);
340
+ if (opts && opts.href) {
341
+ var a = document.createElement('a');
342
+ a.href = opts.href;
343
+ a.textContent = opts.linkText || 'Open';
344
+ wrap.appendChild(a);
345
+ }
346
+ if (opts && opts.retry) {
347
+ var btn = document.createElement('button');
348
+ btn.type = 'button';
349
+ btn.textContent = 'Try again';
350
+ btn.addEventListener('click', function () { loadRoot(fstate.root, true); });
351
+ wrap.appendChild(btn);
352
+ }
353
+ return wrap;
354
+ }
355
+
356
+ // Generic folder row. onOpen is called on click; sub is an optional caption
357
+ // under the name (used for the virtual roots to show their file count / hint).
358
+ function folderRow(label, onOpen, sub) {
359
+ var b = document.createElement('button');
360
+ b.type = 'button';
361
+ b.className = 'rk-aichat-frow';
362
+ var ic = document.createElement('span');
363
+ ic.className = 'rk-aichat-fic rk-aichat-fic-folder';
364
+ ic.textContent = '/';
365
+ var main = document.createElement('span');
366
+ main.className = 'rk-aichat-fmain';
367
+ var name = document.createElement('span');
368
+ name.className = 'rk-aichat-fname';
369
+ name.textContent = label;
370
+ main.appendChild(name);
371
+ if (sub) {
372
+ var meta = document.createElement('span');
373
+ meta.className = 'rk-aichat-fmeta';
374
+ meta.textContent = sub;
375
+ main.appendChild(meta);
376
+ }
377
+ var chev = document.createElement('span');
378
+ chev.className = 'rk-aichat-fadd';
379
+ chev.textContent = 'Open ›';
380
+ b.appendChild(ic); b.appendChild(main); b.appendChild(chev);
381
+ b.addEventListener('click', onOpen);
382
+ return b;
383
+ }
384
+ // A virtual-root entry (My Files / Temp) — shows a live count once loaded.
385
+ function vrootRow(v) {
386
+ var sub = '';
387
+ var st = fstate.status[v.key];
388
+ var d = fstate.data[v.key];
389
+ if (st === 'ready' && d) sub = d.items.length + (d.items.length === 1 ? ' file' : ' files');
390
+ else if (st === 'loading') sub = 'Loading…';
391
+ else if (st === 'auth') sub = 'Sign in to view';
392
+ return folderRow(v.label, function () { enterRoot(v.key); }, sub);
393
+ }
394
+
395
+ function fileRow(it) {
396
+ var b = document.createElement('button');
397
+ b.type = 'button';
398
+ b.className = 'rk-aichat-frow';
399
+ var ic = document.createElement('span');
400
+ ic.className = 'rk-aichat-fic rk-aichat-fic-file';
401
+ if (it.thumbUrl) {
402
+ var thumb = document.createElement('img'); thumb.src = it.thumbUrl; thumb.alt = ''; thumb.loading = 'lazy'; ic.appendChild(thumb);
403
+ } else if (it.viewUrl && it.contentType.indexOf('image/') === 0) {
404
+ var img = document.createElement('img'); img.src = it.viewUrl; img.alt = ''; img.loading = 'lazy'; ic.appendChild(img);
405
+ } else if (it.viewUrl && it.contentType.indexOf('video/') === 0) {
406
+ var vid = document.createElement('video'); vid.src = it.viewUrl; vid.muted = true; vid.playsInline = true; ic.appendChild(vid);
407
+ } else {
408
+ ic.textContent = (it.name.split('.').pop() || it.name.slice(0, 1)).slice(0, 3).toUpperCase();
409
+ }
410
+ var main = document.createElement('span');
411
+ main.className = 'rk-aichat-fmain';
412
+ var name = document.createElement('span');
413
+ name.className = 'rk-aichat-fname';
414
+ name.textContent = it.name;
415
+ var meta = document.createElement('span');
416
+ meta.className = 'rk-aichat-fmeta';
417
+ meta.textContent = it.metaText || fmtBytes(it.sizeBytes) || it.contentType || 'file';
418
+ main.appendChild(name); main.appendChild(meta);
419
+ var add = document.createElement('span');
420
+ add.className = 'rk-aichat-fadd';
421
+ add.textContent = '+ Attach';
422
+ b.appendChild(ic); b.appendChild(main); b.appendChild(add);
423
+ b.addEventListener('click', function () { attachFile(it); });
424
+ return b;
425
+ }
426
+
427
+ function crumb(label, current, onClick) {
428
+ var b = document.createElement('button');
429
+ b.type = 'button';
430
+ b.className = 'rk-aichat-crumb' + (current ? ' is-current' : '');
431
+ b.textContent = label;
432
+ b.addEventListener('click', onClick);
433
+ return b;
434
+ }
435
+ function crumbSep() {
436
+ var s = document.createElement('span');
437
+ s.className = 'rk-aichat-crumb-sep';
438
+ s.textContent = '/';
439
+ return s;
440
+ }
441
+ function renderCrumbs() {
442
+ crumbsEl.innerHTML = '';
443
+ // "Files" (virtual root) is always the first crumb
444
+ var atVirtual = !fstate.root;
445
+ crumbsEl.appendChild(crumb('Files', atVirtual, goVirtualRoot));
446
+ if (atVirtual) return;
447
+ if (fstate.status[fstate.root] !== 'ready') return;
448
+ var cur = normFolder(fstate.path[fstate.root]);
449
+ crumbsEl.appendChild(crumbSep());
450
+ crumbsEl.appendChild(crumb(ROOTS[fstate.root].label, !cur, function () { goPath(''); }));
451
+ var parts = cur.split('/').filter(Boolean);
452
+ var acc = '';
453
+ parts.forEach(function (part, idx) {
454
+ crumbsEl.appendChild(crumbSep());
455
+ acc = acc ? acc + '/' + part : part;
456
+ var pth = acc;
457
+ crumbsEl.appendChild(crumb(part, idx === parts.length - 1, function () { goPath(pth); }));
458
+ });
459
+ }
460
+
461
+ var rendering = false;
462
+ function renderFiles() {
463
+ if (!filesPane) return;
464
+ // loadRoot() calls renderFiles() synchronously to show a loading state; a
465
+ // guard stops that re-entrant call from duplicating rows mid-render.
466
+ if (rendering) return;
467
+ rendering = true;
468
+ try { renderFilesInner(); } finally { rendering = false; }
469
+ }
470
+ function renderFilesInner() {
471
+ renderCrumbs();
472
+ updateDropzone();
473
+ filesBody.innerHTML = '';
474
+ // Virtual root: the two sources appear as folders. Kick off a background
475
+ // load of each so the counts fill in, but render the folders immediately.
476
+ if (!fstate.root) {
477
+ VROOTS.forEach(function (v) { if (fstate.status[v.key] === 'idle') loadRoot(v.key, false); });
478
+ VROOTS.forEach(function (v) { filesBody.appendChild(vrootRow(v)); });
479
+ return;
480
+ }
481
+ var st = fstate.status[fstate.root];
482
+ if (st === 'idle') { loadRoot(fstate.root, false); return; }
483
+ if (st === 'loading') { filesBody.appendChild(fstateNode('Loading your files…')); return; }
484
+ if (st === 'auth') { filesBody.appendChild(fstateNode('Sign in to browse your files.', { href: '/login', linkText: 'Sign in' })); return; }
485
+ if (st === 'error') { filesBody.appendChild(fstateNode('Couldn’t reach your files.', { retry: true })); return; }
486
+ var data = fstate.data[fstate.root] || { items: [], folders: [] };
487
+ var cur = normFolder(fstate.path[fstate.root]);
488
+ var folders = immediateFolders(data, cur);
489
+ var here = data.items.filter(function (it) { return normFolder(it.folderPath) === cur; });
490
+ if (!folders.length && !here.length) {
491
+ filesBody.appendChild(fstateNode(cur ? 'This folder is empty.' : 'No files in ' + ROOTS[fstate.root].label + ' yet.'));
492
+ return;
493
+ }
494
+ folders.forEach(function (f) { filesBody.appendChild(folderRow(f.name, function () { goPath(f.path); })); });
495
+ here.forEach(function (it) { filesBody.appendChild(fileRow(it)); });
496
+ }
497
+
498
+ function goPath(p) { uploadMsg = ''; fstate.path[fstate.root] = normFolder(p); renderFiles(); }
499
+ function goVirtualRoot() { uploadMsg = ''; fstate.root = ''; renderFiles(); }
500
+ function enterRoot(root) {
501
+ if (!ROOTS[root]) return;
502
+ uploadMsg = '';
503
+ fstate.root = root;
504
+ fstate.path[root] = '';
505
+ loadRoot(root, false);
506
+ renderFiles();
507
+ }
508
+
509
+ // ── upload: drop files or click the button; posts to the current source +
510
+ // folder's real upload endpoint (attachments/upload or temporary-files/upload).
511
+ function dropCaption() {
512
+ if (!fstate.root) return '';
513
+ var f = normFolder(fstate.path[fstate.root]);
514
+ return 'Drop files into ' + ROOTS[fstate.root].label + (f ? ' / ' + f : '');
515
+ }
516
+ function updateDropzone() {
517
+ if (!dropEl) return;
518
+ // Raws are imported/scanned, not plain-uploaded — no dropzone in that root.
519
+ var show = !!fstate.root && fstate.root !== 'raws' && fstate.status[fstate.root] !== 'auth';
520
+ dropEl.hidden = !show;
521
+ if (show && dropText && !uploading) { dropText.textContent = uploadMsg || dropCaption(); }
522
+ }
523
+ function uploadFiles(fileList) {
524
+ if (!fstate.root || fstate.root === 'raws' || uploading) return;
525
+ var files = [];
526
+ for (var i = 0; i < fileList.length; i++) files.push(fileList[i]);
527
+ if (!files.length) return;
528
+ uploading = true; uploadMsg = '';
529
+ var root = fstate.root;
530
+ var folder = normFolder(fstate.path[root]);
531
+ var url = ROOTS[root].url + '/upload';
532
+ if (dropEl) dropEl.classList.remove('is-drag');
533
+ if (uploadBtn) uploadBtn.disabled = true;
534
+ var total = files.length, failed = 0;
535
+ if (dropText) dropText.textContent = 'Uploading ' + total + ' file' + (total > 1 ? 's' : '') + '…';
536
+ function next(idx) {
537
+ if (idx >= files.length) {
538
+ uploading = false;
539
+ if (uploadBtn) uploadBtn.disabled = false;
540
+ uploadMsg = failed ? (failed + ' of ' + total + ' failed to upload') : '';
541
+ loadRoot(root, true); // refresh listing; its re-render resets the caption
542
+ return;
543
+ }
544
+ var fd = new FormData();
545
+ fd.append('file', files[idx]);
546
+ fd.append('folder_path', folder);
547
+ fetch(url, { method: 'POST', credentials: 'same-origin', body: fd })
548
+ .then(function (r) { if (!r.ok) throw new Error('http ' + r.status); })
549
+ .catch(function () { failed++; })
550
+ .then(function () { next(idx + 1); });
551
+ }
552
+ next(0);
553
+ }
554
+ if (uploadBtn) uploadBtn.addEventListener('click', function () { if (uploadInput) uploadInput.click(); });
555
+ if (uploadInput) uploadInput.addEventListener('change', function () { uploadFiles(uploadInput.files); uploadInput.value = ''; });
556
+ if (dropEl) {
557
+ dropEl.addEventListener('dragover', function (e) { e.preventDefault(); dropEl.classList.add('is-drag'); });
558
+ dropEl.addEventListener('dragleave', function (e) { if (e.target === dropEl) dropEl.classList.remove('is-drag'); });
559
+ dropEl.addEventListener('drop', function (e) {
560
+ e.preventDefault(); dropEl.classList.remove('is-drag');
561
+ if (e.dataTransfer && e.dataTransfer.files) uploadFiles(e.dataTransfer.files);
562
+ });
563
+ }
564
+
565
+ function attachFile(it) {
566
+ if (selected.some(function (f) { return f.id === it.id; })) { flashChip(it.id); return; }
567
+ selected.push(it);
568
+ renderChips();
569
+ if (input) input.focus();
570
+ }
571
+ function renderChips() {
572
+ if (!chipsEl) return;
573
+ chipsEl.innerHTML = '';
574
+ selected.forEach(function (f) {
575
+ var chip = document.createElement('span');
576
+ chip.className = 'rk-aichat-chip';
577
+ chip.setAttribute('data-id', f.id);
578
+ var label = document.createElement('span');
579
+ label.textContent = f.name;
580
+ var x = document.createElement('button');
581
+ x.type = 'button';
582
+ x.setAttribute('aria-label', 'Remove ' + f.name);
583
+ x.textContent = '×';
584
+ x.addEventListener('click', function () {
585
+ selected = selected.filter(function (s) { return s.id !== f.id; });
586
+ renderChips();
587
+ });
588
+ chip.appendChild(label); chip.appendChild(x);
589
+ chipsEl.appendChild(chip);
590
+ });
591
+ }
592
+ function flashChip(id) {
593
+ var chip = chipsEl && chipsEl.querySelector('[data-id="' + id + '"]');
594
+ if (!chip) return;
595
+ chip.style.transition = 'none';
596
+ chip.style.transform = 'scale(1.08)';
597
+ setTimeout(function () { chip.style.transition = 'transform .18s'; chip.style.transform = 'none'; }, 20);
598
+ }
599
+ function clearChips() { selected = []; renderChips(); }
600
+
601
+ function filesOpen() { return panel.classList.contains('has-files'); }
602
+ function setFilesOpen(open) {
603
+ panel.classList.toggle('has-files', open);
604
+ if (filesToggle) {
605
+ filesToggle.classList.toggle('is-on', open);
606
+ filesToggle.setAttribute('aria-pressed', open ? 'true' : 'false');
607
+ }
608
+ try { localStorage.setItem('rk-files-open', open ? '1' : '0'); } catch (e) {}
609
+ if (open) renderFiles();
610
+ if (!panel.hidden) anchorPanel();
611
+ }
612
+ function refreshFilesOnOpen() {
613
+ if (filesOpen()) {
614
+ // re-fetch whichever source(s) are in view so counts/listings are fresh
615
+ if (fstate.root) loadRoot(fstate.root, true);
616
+ else VROOTS.forEach(function (v) { loadRoot(v.key, true); });
617
+ renderFiles();
618
+ }
619
+ }
620
+
621
+ if (filesToggle) {
622
+ filesToggle.addEventListener('click', function () { setFilesOpen(!filesOpen()); });
623
+ }
624
+ // Honor the saved visibility preference (default: open).
625
+ try {
626
+ setFilesOpen(localStorage.getItem('rk-files-open') !== '0');
627
+ } catch (e) { setFilesOpen(true); }
628
+ renderFiles();
629
+
630
+ try { if (sessionStorage.getItem('rk-chat-open') === '1') openPanel(); } catch (e) {}
631
+ })();`;
45
632
  /** Wrap page content in a complete standalone reskin HTML document. */
46
633
  export function reskinDocument(input) {
47
634
  const chrome = input.chrome ?? "full";
48
635
  const active = input.activeSlug ?? null;
49
636
  const pageCss = input.pageCss ? `\n/* page-scoped */\n${input.pageCss}\n` : "";
50
- const script = input.script ? `\n<script>${input.script}</script>` : "";
637
+ const chromeScript = chrome === "full" ? `\n<script>${RESKIN_CHROME_SCRIPT}</script>` : "";
638
+ const script = (input.script ? `\n<script>${input.script}</script>` : "") + chromeScript;
51
639
  const shell = chrome === "full"
52
640
  ? `<div class="rk-app">
53
641
  ${renderSidebar(active)}
54
642
  <div class="rk-content">
55
643
  ${input.body}
56
644
  </div>
645
+ ${renderChatDock(active)}
57
646
  </div>`
58
647
  : input.body;
59
648
  return `<!doctype html>
@@ -61,8 +650,8 @@ ${input.body}
61
650
  <head>
62
651
  <meta charset="utf-8">
63
652
  <meta name="viewport" content="width=device-width, initial-scale=1">
64
- <title>${rkEscape(input.title)}</title>
65
- <meta name="description" content="${rkEscape(input.description ?? input.title)}">
653
+ <title>${rkEscape(input.title.replace("vidfarm reskin", "vidfarm"))}</title>
654
+ <meta name="description" content="${rkEscape((input.description ?? input.title).replace("vidfarm reskin", "vidfarm"))}">
66
655
  ${RESKIN_FONT_LINKS}
67
656
  <style>${RESKIN_CSS}${pageCss}</style>
68
657
  </head>