mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-27 18:42:20 +02:00
Squash Odysseus development history
This commit is contained in:
+256
-15
@@ -4,7 +4,7 @@
|
||||
* File attachment and upload handling
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import spinnerModule from './spinner.js';
|
||||
|
||||
let pendingFiles = [];
|
||||
@@ -15,8 +15,168 @@ let uploaded = [];
|
||||
let _lastUploadedMeta = [];
|
||||
let API_BASE = '';
|
||||
let _uploadSpinners = [];
|
||||
let _uploadAbortCtrl = null;
|
||||
let _uploading = false;
|
||||
let _lastUploadCancelled = false;
|
||||
const _previewUrls = new WeakMap();
|
||||
|
||||
const MAX_FILES = 10;
|
||||
const MAX_VISIBLE = 3;
|
||||
let _expanded = false;
|
||||
|
||||
function _isMobileViewport() {
|
||||
return window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
||||
}
|
||||
|
||||
function _isCroppableImage(f) {
|
||||
const mime = (f?.type || '').toLowerCase();
|
||||
const name = (f?.name || '').toLowerCase();
|
||||
if (!(mime.startsWith('image/') || /\.(png|jpe?g|webp|bmp)$/i.test(name))) return false;
|
||||
return !mime.includes('svg') && !mime.includes('gif') && !/\.svg|\.gif$/i.test(name);
|
||||
}
|
||||
|
||||
function _loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function _canvasToBlob(canvas, type, quality) {
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, type || 'image/png', quality));
|
||||
}
|
||||
|
||||
async function _openMobileCropper(file) {
|
||||
const url = _getPreviewUrl(file);
|
||||
let imgProbe = await _loadImage(url);
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'attach-crop-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="attach-crop-panel" role="dialog" aria-modal="true" aria-label="Crop image">
|
||||
<div class="attach-crop-stage">
|
||||
<img class="attach-crop-img" alt="">
|
||||
<div class="attach-crop-box"><span class="attach-crop-handle"></span></div>
|
||||
</div>
|
||||
<div class="attach-crop-actions">
|
||||
<button type="button" class="attach-crop-btn" data-action="cancel">Cancel</button>
|
||||
<button type="button" class="attach-crop-btn" data-action="original">Original</button>
|
||||
<button type="button" class="attach-crop-btn" data-action="rotate" title="Rotate image clockwise">Rotate</button>
|
||||
<button type="button" class="attach-crop-btn attach-crop-primary" data-action="crop">Use crop</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
const img = overlay.querySelector('.attach-crop-img');
|
||||
const box = overlay.querySelector('.attach-crop-box');
|
||||
let workingFile = file;
|
||||
let workingUrl = url;
|
||||
let rotatedUrl = null;
|
||||
img.src = workingUrl;
|
||||
img.alt = file.name || 'image';
|
||||
|
||||
let crop = { x: 0.08, y: 0.08, w: 0.84, h: 0.84 };
|
||||
let drag = null;
|
||||
|
||||
function applyCrop() {
|
||||
const r = img.getBoundingClientRect();
|
||||
const pr = overlay.querySelector('.attach-crop-stage').getBoundingClientRect();
|
||||
box.style.left = (r.left - pr.left + crop.x * r.width) + 'px';
|
||||
box.style.top = (r.top - pr.top + crop.y * r.height) + 'px';
|
||||
box.style.width = (crop.w * r.width) + 'px';
|
||||
box.style.height = (crop.h * r.height) + 'px';
|
||||
}
|
||||
function clampCrop() {
|
||||
crop.w = Math.max(0.12, Math.min(1, crop.w));
|
||||
crop.h = Math.max(0.12, Math.min(1, crop.h));
|
||||
crop.x = Math.max(0, Math.min(1 - crop.w, crop.x));
|
||||
crop.y = Math.max(0, Math.min(1 - crop.h, crop.y));
|
||||
}
|
||||
function finish(value) {
|
||||
overlay.remove();
|
||||
window.removeEventListener('resize', applyCrop);
|
||||
if (rotatedUrl) URL.revokeObjectURL(rotatedUrl);
|
||||
resolve(value);
|
||||
}
|
||||
requestAnimationFrame(applyCrop);
|
||||
img.addEventListener('load', applyCrop);
|
||||
window.addEventListener('resize', applyCrop);
|
||||
|
||||
box.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
box.setPointerCapture(e.pointerId);
|
||||
drag = {
|
||||
mode: e.target.classList.contains('attach-crop-handle') ? 'resize' : 'move',
|
||||
sx: e.clientX,
|
||||
sy: e.clientY,
|
||||
start: { ...crop },
|
||||
};
|
||||
});
|
||||
box.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
const r = img.getBoundingClientRect();
|
||||
const dx = (e.clientX - drag.sx) / Math.max(1, r.width);
|
||||
const dy = (e.clientY - drag.sy) / Math.max(1, r.height);
|
||||
if (drag.mode === 'resize') {
|
||||
crop.w = drag.start.w + dx;
|
||||
crop.h = drag.start.h + dy;
|
||||
} else {
|
||||
crop.x = drag.start.x + dx;
|
||||
crop.y = drag.start.y + dy;
|
||||
}
|
||||
clampCrop();
|
||||
applyCrop();
|
||||
});
|
||||
box.addEventListener('pointerup', () => { drag = null; });
|
||||
box.addEventListener('pointercancel', () => { drag = null; });
|
||||
|
||||
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => finish(null));
|
||||
overlay.querySelector('[data-action="original"]').addEventListener('click', () => finish(file));
|
||||
overlay.querySelector('[data-action="rotate"]').addEventListener('click', async () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imgProbe.naturalHeight;
|
||||
canvas.height = imgProbe.naturalWidth;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.translate(canvas.width, 0);
|
||||
ctx.rotate(Math.PI / 2);
|
||||
ctx.drawImage(imgProbe, 0, 0);
|
||||
const type = workingFile.type && workingFile.type !== 'image/bmp' ? workingFile.type : 'image/png';
|
||||
const blob = await _canvasToBlob(canvas, type, 0.92);
|
||||
if (!blob) return;
|
||||
const ext = type.includes('jpeg') ? 'jpg' : (type.split('/')[1] || 'png');
|
||||
const base = (file.name || 'image').replace(/\.[^.]+$/, '');
|
||||
workingFile = new File([blob], `${base}-rotated.${ext}`, { type, lastModified: Date.now() });
|
||||
const nextUrl = URL.createObjectURL(workingFile);
|
||||
if (rotatedUrl) URL.revokeObjectURL(rotatedUrl);
|
||||
rotatedUrl = nextUrl;
|
||||
workingUrl = nextUrl;
|
||||
imgProbe = await _loadImage(workingUrl);
|
||||
crop = { x: 0.08, y: 0.08, w: 0.84, h: 0.84 };
|
||||
img.src = workingUrl;
|
||||
requestAnimationFrame(applyCrop);
|
||||
});
|
||||
overlay.querySelector('[data-action="crop"]').addEventListener('click', async () => {
|
||||
clampCrop();
|
||||
const canvas = document.createElement('canvas');
|
||||
const sx = Math.round(crop.x * imgProbe.naturalWidth);
|
||||
const sy = Math.round(crop.y * imgProbe.naturalHeight);
|
||||
const sw = Math.max(1, Math.round(crop.w * imgProbe.naturalWidth));
|
||||
const sh = Math.max(1, Math.round(crop.h * imgProbe.naturalHeight));
|
||||
canvas.width = sw;
|
||||
canvas.height = sh;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(imgProbe, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||
const type = workingFile.type && workingFile.type !== 'image/bmp' ? workingFile.type : 'image/png';
|
||||
const blob = await _canvasToBlob(canvas, type, 0.92);
|
||||
if (!blob) { finish(file); return; }
|
||||
const ext = type.includes('jpeg') ? 'jpg' : (type.split('/')[1] || 'png');
|
||||
const base = (file.name || 'image').replace(/\.[^.]+$/, '');
|
||||
finish(new File([blob], `${base}-cropped.${ext}`, { type, lastModified: Date.now() }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _getPreviewUrl(f) {
|
||||
if (!f) return '';
|
||||
let url = _previewUrls.get(f);
|
||||
@@ -49,10 +209,6 @@ export function openPicker() {
|
||||
document.getElementById('file-input').click();
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
const MAX_EXPAND = 6; // beyond this, the badge stays collapsed (too many chips to preview)
|
||||
let _expanded = false;
|
||||
|
||||
/**
|
||||
* Render the attachment strip with pending files.
|
||||
* 1-3 files: show individual chips.
|
||||
@@ -80,11 +236,9 @@ export function renderAttachStrip() {
|
||||
label.className = 'thumb-collapsed-label';
|
||||
badge.appendChild(label);
|
||||
badge.title = pendingFiles.map(f => f.name || 'pasted-image').join('\n');
|
||||
const canExpand = total <= MAX_EXPAND;
|
||||
badge.style.cursor = canExpand ? 'pointer' : 'default';
|
||||
badge.style.cursor = 'pointer';
|
||||
badge.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.thumb-collapsed-x')) return;
|
||||
if (!canExpand) return; // too many files — don't expand into chips
|
||||
_expanded = true;
|
||||
renderAttachStrip();
|
||||
});
|
||||
@@ -112,8 +266,22 @@ function _createChip(f, idx) {
|
||||
chip.classList.add('thumb-image'); // lets CSS overlay the remove-X on the corner (mobile)
|
||||
const img = document.createElement('img');
|
||||
img.className = 'thumb-img';
|
||||
img.src = URL.createObjectURL(f);
|
||||
img.src = _getPreviewUrl(f);
|
||||
img.alt = f.name || 'image';
|
||||
img.title = _isMobileViewport() && _isCroppableImage(f) ? 'Crop or rotate image' : '';
|
||||
img.addEventListener('click', async (e) => {
|
||||
if (!_isMobileViewport() || !_isCroppableImage(f) || _uploading) return;
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const nextFile = await _openMobileCropper(f);
|
||||
if (!nextFile || nextFile === f || pendingFiles[idx] !== f) return;
|
||||
_revokePreviewUrl(f);
|
||||
pendingFiles[idx] = nextFile;
|
||||
renderAttachStrip();
|
||||
} catch (_) {
|
||||
_showToast('Could not edit image');
|
||||
}
|
||||
});
|
||||
chip.appendChild(img);
|
||||
} else {
|
||||
const span = document.createElement('span');
|
||||
@@ -132,6 +300,7 @@ function _createChip(f, idx) {
|
||||
* Remove a pending file by index
|
||||
*/
|
||||
export function removePending(idx) {
|
||||
if (_uploading) cancelUpload();
|
||||
_revokePreviewUrl(pendingFiles[idx]);
|
||||
pendingFiles.splice(idx, 1);
|
||||
renderAttachStrip();
|
||||
@@ -140,8 +309,9 @@ export function removePending(idx) {
|
||||
/**
|
||||
* Upload all pending files to server
|
||||
*/
|
||||
export async function uploadPending() {
|
||||
export async function uploadPending(opts = {}) {
|
||||
if (pendingFiles.length === 0) return [];
|
||||
_lastUploadCancelled = false;
|
||||
|
||||
// The message bubble is shown immediately, but the upload can take a moment —
|
||||
// dim the chips and overlay a whirlpool so it's clear the files are still
|
||||
@@ -166,21 +336,56 @@ export async function uploadPending() {
|
||||
|
||||
const fd = new FormData();
|
||||
pendingFiles.forEach(f => fd.append('files', f, f.name || 'paste.png'));
|
||||
if (opts.sessionId) fd.append('session_id', opts.sessionId);
|
||||
_uploadAbortCtrl = new AbortController();
|
||||
_uploading = true;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (_uploadAbortCtrl && !_uploadAbortCtrl.signal.aborted) {
|
||||
try { _uploadAbortCtrl.abort(); } catch (_) {}
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/upload`, {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
body: fd,
|
||||
signal: _uploadAbortCtrl.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Surface the failure instead of swallowing it. Previously a non-OK
|
||||
// response (e.g. 429 rate limit, 413 too large) was ignored: the files
|
||||
// silently vanished and the chat sent with no attachments, so the model
|
||||
// "didn't even see them" (issue #1346). Show the server's reason and keep
|
||||
// pendingFiles so the strip re-renders for a retry (see finally below).
|
||||
let detail = '';
|
||||
try { const e = await res.json(); detail = e.detail || e.error || ''; } catch (_) {}
|
||||
_showToast('Upload failed' + (detail ? ': ' + detail : ` (HTTP ${res.status})`));
|
||||
return [];
|
||||
}
|
||||
const data = await res.json();
|
||||
uploaded = (data.files || []);
|
||||
if (uploaded.some(x => x && x.gallery_id)) {
|
||||
try { localStorage.setItem('gallery-fresh-chat-upload', String(Date.now())); } catch (_) {}
|
||||
window.dispatchEvent(new CustomEvent('gallery-refresh', { detail: { source: 'chat-upload' } }));
|
||||
}
|
||||
pendingFiles = []; // clear only on success
|
||||
// Stash the full meta (incl. width/height for images) on the module so
|
||||
// callers that want it can grab it via getLastUploadedMeta(). Keep the
|
||||
// returned shape as `ids` for backward-compatibility with existing call sites.
|
||||
_lastUploadedMeta = uploaded;
|
||||
return uploaded.map(x => x.id);
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') {
|
||||
_lastUploadCancelled = true;
|
||||
_showToast('Upload cancelled');
|
||||
return [];
|
||||
}
|
||||
_showToast('Upload failed: ' + (e?.message || 'network error'));
|
||||
return [];
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
_uploading = false;
|
||||
_uploadAbortCtrl = null;
|
||||
_uploadSpinners.forEach(sp => { try { sp.stop && sp.stop(); } catch (_) {} });
|
||||
_uploadSpinners = [];
|
||||
if (strip) strip.classList.remove('attach-uploading');
|
||||
@@ -190,22 +395,38 @@ export async function uploadPending() {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_FILES = 10;
|
||||
|
||||
/**
|
||||
* Add files to pending list (capped at MAX_FILES)
|
||||
*/
|
||||
export function addFiles(files) {
|
||||
export async function addFiles(files, opts = {}) {
|
||||
for (const f of files) {
|
||||
if (pendingFiles.length >= MAX_FILES) {
|
||||
_showToast(`Max ${MAX_FILES} files allowed`);
|
||||
break;
|
||||
}
|
||||
pendingFiles.push(f);
|
||||
let nextFile = f;
|
||||
if (!opts.skipCrop && _isMobileViewport() && _isCroppableImage(f)) {
|
||||
try {
|
||||
nextFile = await _openMobileCropper(f);
|
||||
} catch (_) {
|
||||
nextFile = f;
|
||||
}
|
||||
if (!nextFile) continue;
|
||||
}
|
||||
pendingFiles.push(nextFile);
|
||||
}
|
||||
renderAttachStrip();
|
||||
}
|
||||
|
||||
export async function cropForMobileUpload(file) {
|
||||
if (!_isMobileViewport() || !_isCroppableImage(file)) return file;
|
||||
try {
|
||||
return await _openMobileCropper(file);
|
||||
} catch (_) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
function _showToast(msg) {
|
||||
if (window.showToast) { window.showToast(msg); return; }
|
||||
// Fallback inline toast
|
||||
@@ -255,6 +476,7 @@ export function getPendingInfo() {
|
||||
* Clear all pending files
|
||||
*/
|
||||
export function clearPending() {
|
||||
if (_uploading) cancelUpload();
|
||||
pendingFiles.forEach(_revokePreviewUrl);
|
||||
pendingFiles = [];
|
||||
renderAttachStrip();
|
||||
@@ -265,6 +487,21 @@ export function getLastUploadedMeta() {
|
||||
return _lastUploadedMeta;
|
||||
}
|
||||
|
||||
export function isUploading() {
|
||||
return _uploading;
|
||||
}
|
||||
|
||||
export function wasLastUploadCancelled() {
|
||||
return _lastUploadCancelled;
|
||||
}
|
||||
|
||||
export function cancelUpload() {
|
||||
_lastUploadCancelled = true;
|
||||
if (_uploadAbortCtrl && !_uploadAbortCtrl.signal.aborted) {
|
||||
try { _uploadAbortCtrl.abort(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
const fileHandlerModule = {
|
||||
@@ -274,11 +511,15 @@ const fileHandlerModule = {
|
||||
removePending,
|
||||
uploadPending,
|
||||
addFiles,
|
||||
cropForMobileUpload,
|
||||
getPendingCount,
|
||||
getPendingInfo,
|
||||
getPendingRaw,
|
||||
clearPending,
|
||||
getLastUploadedMeta,
|
||||
isUploading,
|
||||
wasLastUploadCancelled,
|
||||
cancelUpload,
|
||||
};
|
||||
|
||||
export default fileHandlerModule;
|
||||
|
||||
Reference in New Issue
Block a user