PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/fileHandler.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:08 UTC 8,840 bytes, 291 lines download raw bytes

1// static/js/fileHandler.js
2
3/**
4 * File attachment and upload handling
5 */
6
7import uiModule from './ui.js';
8import spinnerModule from './spinner.js';
9
10let pendingFiles = [];
11let uploaded = [];
12// Holds the full meta (id/name/mime/size/width/height/…) from the most recent
13// uploadPending() so callers can stamp width/height onto their attachment
14// objects without changing uploadPending()'s return signature.
15let _lastUploadedMeta = [];
16let API_BASE = '';
17let _uploadSpinners = [];
18const _previewUrls = new WeakMap();
19
20const MAX_FILES = 10;
21const MAX_VISIBLE = 3;
22let _expanded = false;
23
24function _getPreviewUrl(f) {
25  if (!f) return '';
26  let url = _previewUrls.get(f);
27  if (!url) {
28    url = URL.createObjectURL(f);
29    _previewUrls.set(f, url);
30  }
31  return url;
32}
33
34function _revokePreviewUrl(f) {
35  const url = _previewUrls.get(f);
36  if (url) {
37    try { URL.revokeObjectURL(url); } catch (_) {}
38    _previewUrls.delete(f);
39  }
40}
41
42/**
43 * Initialize with dependencies
44 */
45export function init(apiBase) {
46  API_BASE = apiBase;
47}
48
49/**
50 * Open file picker dialog
51 */
52export function openPicker() {
53  document.getElementById('file-input').click();
54}
55
56/**
57 * Render the attachment strip with pending files.
58 * 1-3 files: show individual chips.
59 * 4+  files: collapse into a single "N files" badge (click to expand).
60 */
61export function renderAttachStrip() {
62  const strip = document.getElementById('attach-strip');
63
64  while (strip.firstChild) strip.removeChild(strip.firstChild);
65  if (pendingFiles.length === 0) {
66    _expanded = false;
67    if (window._updateSendBtnIcon) window._updateSendBtnIcon();
68    return;
69  }
70
71  const total = pendingFiles.length;
72  const collapsed = total > MAX_VISIBLE && !_expanded;
73
74  if (collapsed) {
75    // Single compact badge: "5 files ×"
76    const badge = document.createElement('div');
77    badge.className = 'thumb thumb-collapsed';
78    const label = document.createElement('span');
79    label.textContent = total + ' file' + (total > 1 ? 's' : '');
80    label.className = 'thumb-collapsed-label';
81    badge.appendChild(label);
82    badge.title = pendingFiles.map(f => f.name || 'pasted-image').join('\n');
83    badge.style.cursor = 'pointer';
84    badge.addEventListener('click', (e) => {
85      if (e.target.closest('.thumb-collapsed-x')) return;
86      _expanded = true;
87      renderAttachStrip();
88    });
89    const x = document.createElement('button');
90    x.className = 'thumb-collapsed-x';
91    x.textContent = '\u00d7';
92    x.title = 'Remove all';
93    x.addEventListener('click', (e) => { e.stopPropagation(); clearPending(); });
94    badge.appendChild(x);
95    strip.appendChild(badge);
96  } else {
97    // Show individual chips
98    for (let idx = 0; idx < total; idx++) {
99      strip.appendChild(_createChip(pendingFiles[idx], idx));
100    }
101  }
102  if (window._updateSendBtnIcon) window._updateSendBtnIcon();
103}
104
105function _createChip(f, idx) {
106  const chip = document.createElement('div');
107  chip.className = 'thumb';
108  const isImage = f.type?.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f.name || '');
109  if (isImage) {
110    chip.classList.add('thumb-image');  // lets CSS overlay the remove-X on the corner (mobile)
111    const img = document.createElement('img');
112    img.className = 'thumb-img';
113    img.src = _getPreviewUrl(f);
114    img.alt = f.name || 'image';
115    chip.appendChild(img);
116  } else {
117    const span = document.createElement('span');
118    span.textContent = f.name || 'pasted-image';
119    chip.appendChild(span);
120  }
121  const x = document.createElement('button');
122  x.textContent = '\u00d7';
123  x.setAttribute('aria-label', 'Remove attachment');
124  x.addEventListener('click', (e) => { e.stopPropagation(); removePending(idx); });
125  chip.appendChild(x);
126  return chip;
127}
128
129/**
130 * Remove a pending file by index
131 */
132export function removePending(idx) {
133  _revokePreviewUrl(pendingFiles[idx]);
134  pendingFiles.splice(idx, 1);
135  renderAttachStrip();
136}
137
138/**
139 * Upload all pending files to server
140 */
141export async function uploadPending() {
142  if (pendingFiles.length === 0) return [];
143
144  // The message bubble is shown immediately, but the upload can take a moment —
145  // dim the chips and overlay a whirlpool so it's clear the files are still
146  // being sent (and aren't stuck). Cleared in the finally below.
147  const strip = document.getElementById('attach-strip');
148  if (strip) {
149    strip.classList.add('attach-uploading');
150    // Put a whirlpool ON each attachment chip (image/doc) so the spinner sits on
151    // the thing being uploaded, not floating over the whole strip.
152    strip.querySelectorAll('.thumb').forEach(chip => {
153      try {
154        const sp = spinnerModule.create('', 'clean', 'whirlpool');
155        const ov = document.createElement('span');
156        ov.className = 'thumb-upload-spinner';
157        ov.appendChild(sp.createElement());
158        chip.appendChild(ov);
159        sp.start();
160        _uploadSpinners.push(sp);
161      } catch (_) { /* spinner is best-effort */ }
162    });
163  }
164
165  const fd = new FormData();
166  pendingFiles.forEach(f => fd.append('files', f, f.name || 'paste.png'));
167
168  try {
169    const res = await fetch(`${API_BASE}/api/upload`, {
170      method: 'POST',
171      body: fd
172    });
173    if (!res.ok) {
174      // Surface the failure instead of swallowing it. Previously a non-OK
175      // response (e.g. 429 rate limit, 413 too large) was ignored: the files
176      // silently vanished and the chat sent with no attachments, so the model
177      // "didn't even see them" (issue #1346). Show the server's reason and keep
178      // pendingFiles so the strip re-renders for a retry (see finally below).
179      let detail = '';
180      try { const e = await res.json(); detail = e.detail || e.error || ''; } catch (_) {}
181      _showToast('Upload failed' + (detail ? ': ' + detail : ` (HTTP ${res.status})`));
182      return [];
183    }
184    const data = await res.json();
185    uploaded = (data.files || []);
186    pendingFiles = [];          // clear only on success
187    // Stash the full meta (incl. width/height for images) on the module so
188    // callers that want it can grab it via getLastUploadedMeta(). Keep the
189    // returned shape as `ids` for backward-compatibility with existing call sites.
190    _lastUploadedMeta = uploaded;
191    return uploaded.map(x => x.id);
192  } finally {
193    _uploadSpinners.forEach(sp => { try { sp.stop && sp.stop(); } catch (_) {} });
194    _uploadSpinners = [];
195    if (strip) strip.classList.remove('attach-uploading');
196    // Re-render: empty on success (chips gone), or restored on error so the
197    // user can retry — and either way the spinners are removed.
198    renderAttachStrip();
199  }
200}
201
202/**
203 * Add files to pending list (capped at MAX_FILES)
204 */
205export function addFiles(files) {
206  for (const f of files) {
207    if (pendingFiles.length >= MAX_FILES) {
208      _showToast(`Max ${MAX_FILES} files allowed`);
209      break;
210    }
211    pendingFiles.push(f);
212  }
213  renderAttachStrip();
214}
215
216function _showToast(msg) {
217  if (window.showToast) { window.showToast(msg); return; }
218  // Fallback inline toast
219  let t = document.getElementById('_attach-toast');
220  if (!t) {
221    t = document.createElement('div');
222    t.id = '_attach-toast';
223    t.style.cssText = 'position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--red);color:var(--red);padding:6px 14px;border-radius:6px;font-size:13px;z-index:9999;opacity:0;transition:opacity .3s';
224    document.body.appendChild(t);
225  }
226  t.textContent = msg;
227  t.style.opacity = '1';
228  clearTimeout(t._timer);
229  t._timer = setTimeout(() => { t.style.opacity = '0'; }, 2500);
230}
231
232/**
233 * Get pending files count
234 */
235export function getPendingCount() {
236  return pendingFiles.length;
237}
238
239/**
240 * Get raw pending File objects (for reading content before upload clears them)
241 */
242export function getPendingRaw() {
243  return [...pendingFiles];
244}
245
246/**
247 * Get pending file metadata (name, size, type) for display
248 */
249export function getPendingInfo() {
250  return pendingFiles.map(f => {
251    const isImage = f.type?.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f.name || '');
252    return {
253      name: f.name || 'pasted-image',
254      size: f.size || 0,
255      mime: f.type || '',
256      previewUrl: isImage ? _getPreviewUrl(f) : '',
257    };
258  });
259}
260
261/**
262 * Clear all pending files
263 */
264export function clearPending() {
265  pendingFiles.forEach(_revokePreviewUrl);
266  pendingFiles = [];
267  renderAttachStrip();
268}
269
270/** Full meta (incl. width/height for images) from the most recent uploadPending(). */
271export function getLastUploadedMeta() {
272  return _lastUploadedMeta;
273}
274
275var escapeHtml = uiModule.esc;
276
277const fileHandlerModule = {
278  init,
279  openPicker,
280  renderAttachStrip,
281  removePending,
282  uploadPending,
283  addFiles,
284  getPendingCount,
285  getPendingInfo,
286  getPendingRaw,
287  clearPending,
288  getLastUploadedMeta,
289};
290
291export default fileHandlerModule;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.