/
Alex_Ural
/
Open-Generative-AI
Обзор
Документация
Войти
/
Alex_Ural
/
Open-Generative-AI
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
src/components/UploadPicker.js
406 строк
20 KB
Anil Matcha
Seedance 2.0 watermark remover changes added
07 мар 2026, 17:43
07 мар 2026, 17:43
ade8e20
Код
Авторство
О чём код?
import { muapi } from '../lib/muapi.js'; import { AuthModal } from './AuthModal.js'; import { getUploadHistory, saveUpload, removeUpload, generateThumbnail } from '../lib/uploadHistory.js'; /** * Creates a self-contained upload picker: a trigger button + history panel. * Supports single-image (maxImages=1) and multi-image (maxImages>1) modes. * * @param {object} options * @param {HTMLElement} options.anchorContainer - The container element the panel is positioned relative to * @param {function({ url: string, urls: string[], thumbnail: string }): void} options.onSelect * @param {function(): void} [options.onClear] * @param {number} [options.maxImages=1] - Maximum number of images selectable * @returns {{ trigger: HTMLElement, panel: HTMLElement, reset: function, setMaxImages: function }} */ export function createUploadPicker({ anchorContainer, onSelect, onClear, maxImages: initialMaxImages = 1 }) { let panelOpen = false; let maxImages = initialMaxImages; let selectedEntries = []; // [{ url, thumbnail }, ...] // ── Hidden file input ───────────────────────────────────────────────────── const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.className = 'hidden'; // ── Trigger button ──────────────────────────────────────────────────────── const trigger = document.createElement('button'); trigger.type = 'button'; trigger.title = 'Reference image'; trigger.className = 'w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden mt-1.5 bg-white/5 border-white/10 hover:bg-white/10 hover:border-primary/40 group'; // State: icon const iconState = document.createElement('div'); iconState.className = 'flex items-center justify-center w-full h-full'; iconState.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-muted group-hover:text-primary transition-colors"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`; // State: spinner const spinnerState = document.createElement('div'); spinnerState.className = 'hidden items-center justify-center w-full h-full'; spinnerState.innerHTML = `<span class="animate-spin text-primary text-sm">◌</span>`; // State: thumbnail (first selected image + optional count badge) const thumbnailState = document.createElement('div'); thumbnailState.className = 'hidden w-full h-full'; const thumbImg = document.createElement('img'); thumbImg.className = 'w-full h-full object-cover'; const countBadge = document.createElement('div'); countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5'; countBadge.innerHTML = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`; thumbnailState.appendChild(thumbImg); thumbnailState.appendChild(countBadge); trigger.appendChild(fileInput); trigger.appendChild(iconState); trigger.appendChild(spinnerState); trigger.appendChild(thumbnailState); // ── Trigger state helpers ───────────────────────────────────────────────── const showIcon = () => { iconState.classList.replace('hidden', 'flex'); spinnerState.classList.add('hidden'); spinnerState.classList.remove('flex'); thumbnailState.classList.add('hidden'); thumbnailState.classList.remove('flex'); trigger.classList.remove('border-primary/60'); trigger.classList.add('border-white/10'); }; const showSpinner = () => { iconState.classList.add('hidden'); iconState.classList.remove('flex'); spinnerState.classList.replace('hidden', 'flex'); thumbnailState.classList.add('hidden'); thumbnailState.classList.remove('flex'); }; const updateTrigger = () => { if (selectedEntries.length === 0) { showIcon(); trigger.title = maxImages > 1 ? `Add up to ${maxImages} images` : 'Reference image'; return; } // Show first image thumbnail thumbImg.src = selectedEntries[0].thumbnail; iconState.classList.add('hidden'); iconState.classList.remove('flex'); spinnerState.classList.add('hidden'); spinnerState.classList.remove('flex'); thumbnailState.classList.replace('hidden', 'flex'); trigger.classList.remove('border-white/10'); trigger.classList.add('border-primary/60'); const count = selectedEntries.length; const canAddMore = maxImages > 1 && count < maxImages; if (count > 1) { // Multiple selected — show count countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5'; countBadge.innerHTML = `<span class="text-[9px] font-black text-black leading-none">${count}</span>`; trigger.title = `${count} of ${maxImages} images selected — click to manage`; } else if (canAddMore) { // 1 selected, multi-mode active — show "+" to invite adding more countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-white/80 rounded-full flex items-center justify-center px-0.5 border border-primary/60'; countBadge.innerHTML = `<span class="text-[9px] font-black text-black leading-none">+</span>`; trigger.title = `1 image selected — click to add more (up to ${maxImages})`; } else { // Single mode or at max — show checkmark countBadge.className = 'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5'; countBadge.innerHTML = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`; trigger.title = count > 1 ? `${count} images selected` : 'Reference image'; } }; // ── Panel ───────────────────────────────────────────────────────────────── const panel = document.createElement('div'); panel.className = 'absolute z-50 opacity-0 pointer-events-none scale-95 origin-bottom-left glass rounded-3xl p-3 shadow-4xl border border-white/10 w-72 transition-all'; const openPanel = () => { renderPanel(); panel.classList.remove('opacity-0', 'pointer-events-none', 'scale-95'); panel.classList.add('opacity-100', 'pointer-events-auto', 'scale-100'); const btnRect = trigger.getBoundingClientRect(); const containerRect = anchorContainer.getBoundingClientRect(); panel.style.left = `${btnRect.left - containerRect.left}px`; panel.style.bottom = `${containerRect.bottom - btnRect.top + 8}px`; panelOpen = true; }; const closePanel = () => { panel.classList.add('opacity-0', 'pointer-events-none', 'scale-95'); panel.classList.remove('opacity-100', 'pointer-events-auto', 'scale-100'); panelOpen = false; }; const fireOnSelect = () => { if (selectedEntries.length === 0) return; const urls = selectedEntries.map(e => e.url); onSelect({ url: urls[0], // backward-compatible single URL urls, // full array for multi-image models thumbnail: selectedEntries[0].thumbnail }); }; const renderPanel = () => { panel.innerHTML = ''; const history = getUploadHistory(); const isMulti = maxImages > 1; // ── Header ── const header = document.createElement('div'); header.className = 'flex items-center justify-between px-1 pb-3 mb-2 border-b border-white/5'; const headerLeft = document.createElement('div'); headerLeft.className = 'flex flex-col gap-0.5'; headerLeft.innerHTML = `<span class="text-[10px] font-bold text-secondary uppercase tracking-widest">Reference Images</span>`; if (isMulti) { const hint = document.createElement('span'); hint.className = 'text-[9px] text-muted'; hint.textContent = `Select up to ${maxImages} images`; headerLeft.appendChild(hint); } header.appendChild(headerLeft); const headerRight = document.createElement('div'); headerRight.className = 'flex items-center gap-2'; // Done button (multi-select only) if (isMulti && selectedEntries.length > 0) { const doneBtn = document.createElement('button'); doneBtn.type = 'button'; doneBtn.className = 'flex items-center gap-1 px-3 py-1.5 bg-primary text-black rounded-xl text-xs font-black transition-all hover:scale-105'; doneBtn.innerHTML = `✓ Done (${selectedEntries.length})`; doneBtn.onclick = (e) => { e.stopPropagation(); closePanel(); fireOnSelect(); }; headerRight.appendChild(doneBtn); } const uploadNewBtn = document.createElement('button'); uploadNewBtn.type = 'button'; uploadNewBtn.className = 'flex items-center gap-1.5 px-3 py-1.5 bg-primary/10 hover:bg-primary/20 text-primary rounded-xl text-xs font-bold transition-all border border-primary/20'; const uploadLabel = isMulti ? 'Upload files' : 'Upload new'; uploadNewBtn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> ${uploadLabel}`; uploadNewBtn.onclick = (e) => { e.stopPropagation(); closePanel(); fileInput.click(); }; headerRight.appendChild(uploadNewBtn); header.appendChild(headerRight); panel.appendChild(header); if (history.length === 0) { const empty = document.createElement('div'); empty.className = 'py-6 flex flex-col items-center gap-2 opacity-40'; empty.innerHTML = ` <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-secondary"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <span class="text-xs text-secondary">No uploads yet</span> `; panel.appendChild(empty); return; } // ── Grid ── const grid = document.createElement('div'); grid.className = 'grid grid-cols-3 gap-2 max-h-56 overflow-y-auto custom-scrollbar pr-0.5'; history.forEach(entry => { const selIdx = selectedEntries.findIndex(e => e.url === entry.uploadedUrl); const isSelected = selIdx !== -1; const cell = document.createElement('div'); cell.className = `relative rounded-xl overflow-hidden border-2 cursor-pointer group/cell aspect-square transition-all ${isSelected ? 'border-primary shadow-glow' : 'border-white/10 hover:border-white/30'}`; cell.title = entry.name; const img = document.createElement('img'); img.src = entry.thumbnail; img.className = 'w-full h-full object-cover'; // Hover overlay with delete button const overlay = document.createElement('div'); overlay.className = 'absolute inset-0 bg-black/60 opacity-0 group-hover/cell:opacity-100 transition-opacity flex items-end justify-end p-1'; const delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'w-5 h-5 bg-red-500/80 hover:bg-red-500 rounded-md flex items-center justify-center transition-colors'; delBtn.title = 'Remove from history'; delBtn.innerHTML = `<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`; delBtn.onclick = (e) => { e.stopPropagation(); removeUpload(entry.id); const idx = selectedEntries.findIndex(e => e.url === entry.uploadedUrl); if (idx !== -1) { selectedEntries.splice(idx, 1); updateTrigger(); if (selectedEntries.length === 0) onClear?.(); } renderPanel(); }; overlay.appendChild(delBtn); // Selection badge: order number (multi) or checkmark (single) if (isSelected) { const badge = document.createElement('div'); badge.className = 'absolute top-1 left-1 min-w-[20px] h-5 bg-primary rounded-full flex items-center justify-center px-1'; if (isMulti) { badge.innerHTML = `<span class="text-[10px] font-black text-black">${selIdx + 1}</span>`; } else { badge.innerHTML = `<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg>`; } cell.appendChild(badge); } // Not-yet-reachable dim (when at max) const atMax = isMulti && !isSelected && selectedEntries.length >= maxImages; if (atMax) { cell.classList.add('opacity-40'); cell.style.cursor = 'not-allowed'; } cell.appendChild(img); cell.appendChild(overlay); cell.onclick = (e) => { e.stopPropagation(); if (atMax) return; // can't select more if (!isMulti) { // Single-select: select & close immediately selectedEntries = [{ url: entry.uploadedUrl, thumbnail: entry.thumbnail }]; updateTrigger(); fireOnSelect(); closePanel(); } else { // Multi-select: toggle if (isSelected) { selectedEntries.splice(selIdx, 1); if (selectedEntries.length === 0) onClear?.(); } else { selectedEntries.push({ url: entry.uploadedUrl, thumbnail: entry.thumbnail }); } updateTrigger(); renderPanel(); // re-render to update badges / dim state } }; grid.appendChild(cell); }); panel.appendChild(grid); // Bottom "Done" bar for multi-select (always visible when items selected) if (isMulti && selectedEntries.length > 0) { const bottomBar = document.createElement('div'); bottomBar.className = 'mt-3 pt-3 border-t border-white/5 flex items-center justify-between'; bottomBar.innerHTML = `<span class="text-xs text-secondary">${selectedEntries.length} of ${maxImages} selected</span>`; const doneBtn2 = document.createElement('button'); doneBtn2.type = 'button'; doneBtn2.className = 'px-4 py-1.5 bg-primary text-black rounded-xl text-xs font-black transition-all hover:scale-105'; doneBtn2.textContent = 'Use Selected'; doneBtn2.onclick = (e) => { e.stopPropagation(); closePanel(); fireOnSelect(); }; bottomBar.appendChild(doneBtn2); panel.appendChild(bottomBar); } }; // ── Trigger click ───────────────────────────────────────────────────────── trigger.onclick = (e) => { e.stopPropagation(); if (panelOpen) closePanel(); else openPanel(); }; // Close panel on outside click window.addEventListener('click', closePanel); // ── File upload handler ─────────────────────────────────────────────────── fileInput.onchange = async (e) => { const files = Array.from(e.target.files); if (!files.length) return; const apiKey = localStorage.getItem('muapi_key'); if (!apiKey) { AuthModal(() => fileInput.click()); return; } showSpinner(); try { if (maxImages === 1) { // Single mode: upload first file only, replace selection const file = files[0]; const [uploadedUrl, thumbnail] = await Promise.all([ muapi.uploadFile(file), generateThumbnail(file) ]); const entry = { id: Date.now().toString(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() }; saveUpload(entry); selectedEntries = [{ url: uploadedUrl, thumbnail }]; updateTrigger(); fireOnSelect(); } else { // Multi mode: upload all files (up to remaining slots) const slots = maxImages - selectedEntries.length; const toUpload = files.slice(0, Math.max(slots, 1)); // Upload all in parallel const results = await Promise.all(toUpload.map(async (file) => { const [uploadedUrl, thumbnail] = await Promise.all([ muapi.uploadFile(file), generateThumbnail(file) ]); return { id: Date.now().toString() + Math.random(), name: file.name, uploadedUrl, thumbnail, timestamp: new Date().toISOString() }; })); results.forEach(entry => { saveUpload(entry); if (selectedEntries.length < maxImages) { selectedEntries.push({ url: entry.uploadedUrl, thumbnail: entry.thumbnail }); } }); updateTrigger(); // In multi-mode reopen panel so user can continue selecting / see Done button openPanel(); } } catch (err) { console.error('[UploadPicker] Upload failed:', err); updateTrigger(); alert(`Image upload failed: ${err.message}`); } fileInput.value = ''; }; // ── Public API ──────────────────────────────────────────────────────────── const reset = () => { selectedEntries = []; showIcon(); closePanel(); }; const setMaxImages = (n) => { maxImages = n; // Enable multi-file selection in file picker when multi-mode fileInput.multiple = n > 1; // Trim selection if exceeding new limit if (selectedEntries.length > n) { selectedEntries = selectedEntries.slice(0, n); if (selectedEntries.length === 0) onClear?.(); } // Always refresh trigger so badge/tooltip reflects new mode updateTrigger(); }; const getSelectedUrls = () => selectedEntries.map(e => e.url); // Programmatically select an image (e.g. for demo mode) without uploading const setImage = (url, thumbnail) => { selectedEntries = [{ url, thumbnail: thumbnail || url }]; updateTrigger(); fireOnSelect(); }; return { trigger, panel, reset, setMaxImages, getSelectedUrls, setImage }; }