Spaces:
Running
Running
Download src/views/documents/documents-view.js from Luca448/APP-Backend: direct link, hf CLI and curl.
- Browser
- Download file 73.1 kB
-
https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/src/views/documents/documents-view.js
- Command line
-
hf download hf://spaces/Luca448/APP-Backend/src/views/documents/documents-view.js
-
curl -L -o documents-view.js https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/src/views/documents/documents-view.js
73.1 kB
| import { fsService } from '../../services/file-system.js'; | |
| import { db } from '../../db/database.js'; | |
| import { getIconSvg, ICON_NAMES } from '../../utils/icons.js?v=2'; | |
| export class DocumentsView { | |
| constructor(containerId) { | |
| this.container = document.getElementById(containerId); | |
| this.currentPath = ''; // Root is empty string | |
| this.viewState = 'grid'; // 'grid' or 'detail' | |
| this._longPressTimer = null; | |
| this._longPressTarget = null; | |
| this._isLongPress = false; | |
| this._cachedFiles = []; // cached file list for current path | |
| this._filePreviewCache = new Map(); // path → preview HTML | |
| } | |
| async init() { | |
| await db.initPromise; | |
| await fsService.init(); | |
| await this.render(); | |
| } | |
| // ─── Ambient title text ───────────────────────────────── | |
| _getAmbientTitle() { | |
| if (this.currentPath) { | |
| return this.currentPath.split('/').pop(); | |
| } | |
| const greetings = [ | |
| 'Was möchtest du\nlernen?', | |
| 'Dein Wissen,\norganisiert.', | |
| 'Bereit zum\nEntdecken?', | |
| 'Deine Dateien,\nimmer dabei.' | |
| ]; | |
| return greetings[Math.floor(Math.random() * greetings.length)]; | |
| } | |
| // ─── Main Render ───────────────────────────────── | |
| async render() { | |
| this.viewState = 'grid'; | |
| this.container.innerHTML = ` | |
| <div id="vault-glass-wrapper"> | |
| <div class="vault-header vault-header-sticky" style="margin-bottom: 4px; align-items: center;"> | |
| <div class="vault-header-left" style="display: flex; align-items: center;"> | |
| <h2 class="vault-header-title" style="display: flex; align-items: center; margin: 0;"> | |
| ${this.currentPath ? ` | |
| <button id="doc-back-btn" class="icon-btn" style="width: 32px; height: 32px; background: rgba(255,255,255,0.05); flex-shrink: 0; margin-right: 12px;"> | |
| <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"></polyline></svg> | |
| </button> | |
| ` : ''} | |
| Mein Vault | |
| </h2> | |
| </div> | |
| <div class="vault-header-actions" style="display: flex; gap: 12px;"> | |
| <input type="file" id="vault-file-upload" style="display: none;" multiple> | |
| <button id="upload-btn" class="icon-btn" style="width: 40px; height: 40px; background: rgba(255,255,255,0.05); border-radius: 12px; pointer-events: auto;" title="Datei hochladen"> | |
| <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg> | |
| </button> | |
| <button id="new-folder-btn" class="icon-btn" style="width: 40px; height: 40px; background: rgba(255,255,255,0.05); border-radius: 12px; pointer-events: auto;" title="Neuer Ordner"> | |
| <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> | |
| </button> | |
| <button id="trash-btn" class="icon-btn" style="width: 40px; height: 40px; background: rgba(255,255,255,0.05); border-radius: 12px; pointer-events: auto;" title="Papierkorb"> | |
| <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg> | |
| </button> | |
| </div> | |
| </div> | |
| ${this.currentPath ? (() => { | |
| const meta = db.getFolderMetadata(this.currentPath) || { emoji: 'folder', color: '#4285f4' }; | |
| const iconName = ICON_NAMES.includes(meta.emoji) ? meta.emoji : 'folder'; | |
| const color = meta.color || '#4285f4'; | |
| const svgIconHtml = getIconSvg(iconName, { | |
| class: 'folder-icon-engraved', | |
| width: '100%', | |
| height: '100%' | |
| }); | |
| return ` | |
| <style> | |
| .vault-folder-hero-container .vault-folder-icon svg { | |
| width: 22px !important; | |
| height: 22px !important; | |
| } | |
| </style> | |
| <div class="vault-folder-hero" style="display: flex; flex-direction: column; align-items: center; justify-content: center; margin-top: 16px; margin-bottom: 24px; animation: scaleIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);"> | |
| <!-- Native width without transform/zoom to avoid iOS Safari backdrop-filter clipping bugs --> | |
| <div class="vault-folder-hero-container" style="position: relative; width: 100px; margin-bottom: 8px; display: flex; justify-content: center; align-items: center;"> | |
| <div class="vault-folder-body" style="--folder-color: ${color}; width: 100%; position: relative;"> | |
| <div class="vault-folder-back"> | |
| <div class="vault-folder-tab"></div> | |
| <div class="vault-folder-back-panel"></div> | |
| </div> | |
| <div class="vault-folder-papers"> | |
| <div class="vault-paper" style="transform: rotate(-3deg) translateX(-5px);"></div> | |
| <div class="vault-paper" style="transform: rotate(2deg) translateX(5px) translateY(2px);"></div> | |
| </div> | |
| <div class="vault-folder-front"> | |
| <div class="vault-folder-icon">${svgIconHtml}</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="vault-ambient-title" style="margin-top: 4px; margin-bottom: 6px; font-size: 26px;">${this.currentPath.split('/').pop()}</div> | |
| <div id="vault-folder-item-count" style="font-size: 13px; color: rgba(255,255,255,0.5); font-weight: 500;">...</div> | |
| </div> | |
| `; | |
| })() : ` | |
| <div class="vault-ambient-title" style="margin-top: 0; margin-bottom: 28px;">${this._getAmbientTitle()}</div> | |
| `} | |
| <div id="vault-grid"> | |
| ${Array(6).fill(0).map(() => `<div class="vault-skeleton-item"></div>`).join('')} | |
| </div> | |
| </div> | |
| `; | |
| await this.loadFiles(); | |
| this.attachEvents(); | |
| } | |
| // ─── Load & Render Files ───────────────────────────────── | |
| async loadFiles() { | |
| const files = await fsService.readDir(this.currentPath); | |
| const gridEl = document.getElementById('vault-grid'); | |
| if (!gridEl) return; | |
| const countEl = document.getElementById('vault-folder-item-count'); | |
| if (countEl) { | |
| countEl.textContent = `${files.length} Dokument${files.length !== 1 ? 'e' : ''}`; | |
| } | |
| if (files.length === 0) { | |
| gridEl.innerHTML = ` | |
| <div class="vault-empty"> | |
| <div class="vault-empty-icon">📂</div> | |
| <div class="vault-empty-title">Ordner ist leer</div> | |
| <div class="vault-empty-sub">Die KI kann hier automatisch Berichte ablegen.</div> | |
| </div> | |
| `; | |
| return; | |
| } | |
| // Sort: Folders first, then alphabetically | |
| files.sort((a, b) => { | |
| if (a.type === 'directory' && b.type !== 'directory') return -1; | |
| if (a.type !== 'directory' && b.type === 'directory') return 1; | |
| return a.name.localeCompare(b.name); | |
| }); | |
| this._cachedFiles = files; | |
| // Separate folders and files | |
| const folders = files.filter(f => f.type === 'directory'); | |
| const documents = files.filter(f => f.type !== 'directory'); | |
| // Group documents by date (simulated with random dates for demo if mtime isn't easily formatted, but we'll try to use a static nice date for now like the reference) | |
| // To match the reference closely, let's group them by a realistic date string. | |
| // In a real app we'd use: new Intl.DateTimeFormat('de-DE', { weekday: 'long', month: 'long', day: 'numeric' }).format(new Date(f.mtime || Date.now())) | |
| const groups = {}; | |
| documents.forEach((f, i) => { | |
| // Create a nice date string. | |
| const date = new Date(f.mtime || Date.now()); | |
| // Formatting like "Montag, 23. September" (German format) | |
| const dateString = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long' }).format(date); | |
| if (!groups[dateString]) groups[dateString] = []; | |
| groups[dateString].push(f); | |
| }); | |
| let html = ''; | |
| if (folders.length > 0) { | |
| html += `<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 32px;">`; | |
| html += folders.map((f, idx) => { | |
| const path = this.currentPath ? `${this.currentPath}/${f.name}` : f.name; | |
| return this._renderFolder(f, path, idx * 40); | |
| }).join(''); | |
| html += `</div>`; | |
| } | |
| if (documents.length > 0) { | |
| let docIdx = 0; | |
| for (const [dateTitle, groupFiles] of Object.entries(groups)) { | |
| html += ` | |
| <div class="vault-timeline-group"> | |
| <div class="vault-timeline-header"> | |
| <div class="vault-timeline-line"></div> | |
| <div class="vault-timeline-title">${dateTitle}</div> | |
| <div class="vault-timeline-subtitle">${groupFiles.length} Dokument${groupFiles.length !== 1 ? 'e' : ''}</div> | |
| </div> | |
| <div class="vault-timeline-content"> | |
| <div class="vault-timeline-line-content"></div> | |
| <div class="vault-timeline-items"> | |
| ${groupFiles.map(f => { | |
| const path = this.currentPath ? `${this.currentPath}/${f.name}` : f.name; | |
| const itemHtml = this._renderFile(f, path, docIdx * 40); | |
| docIdx++; | |
| return itemHtml; | |
| }).join('')} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| } | |
| // Override the #vault-grid CSS to be a block container so our inner grid/timeline works | |
| gridEl.style.display = 'block'; | |
| gridEl.innerHTML = html; | |
| // Load async file previews (text/image) | |
| this._loadFilePreviews(files); | |
| // Hover shows delete btn | |
| gridEl.querySelectorAll('.vault-folder, .vault-file').forEach(item => { | |
| item.addEventListener('mouseenter', () => { | |
| const btn = item.querySelector('.vault-item-delete'); | |
| if (btn) btn.style.opacity = '1'; | |
| }); | |
| item.addEventListener('mouseleave', () => { | |
| const btn = item.querySelector('.vault-item-delete'); | |
| if (btn) btn.style.opacity = '0'; | |
| }); | |
| }); | |
| } | |
| // ─── Render a Glass Folder ───────────────────────────────── | |
| _renderFolder(f, path, delay) { | |
| const meta = db.getFolderMetadata(path) || { emoji: 'folder', color: '#4285f4' }; | |
| const iconName = ICON_NAMES.includes(meta.emoji) ? meta.emoji : 'folder'; | |
| const color = meta.color || '#4285f4'; | |
| const svgIconHtml = getIconSvg(iconName, { | |
| class: 'folder-icon-engraved', | |
| width: '36', | |
| height: '36' | |
| }); | |
| return ` | |
| <div class="vault-folder vault-item-enter" style="--delay: ${delay}ms; --folder-color: ${color};" data-name="${f.name}" data-isdir="true" data-path="${path}" data-emoji="${iconName}" data-color="${color}"> | |
| <div class="vault-folder-body"> | |
| <!-- Back cover with the tab --> | |
| <div class="vault-folder-back"> | |
| <div class="vault-folder-tab"></div> | |
| <div class="vault-folder-back-panel"></div> | |
| </div> | |
| <!-- Papers sticking out in the middle --> | |
| <div class="vault-folder-papers"> | |
| <div class="vault-paper" style="transform: rotate(-3deg) translateX(-5px);"></div> | |
| <div class="vault-paper" style="transform: rotate(2deg) translateX(5px) translateY(2px);"></div> | |
| <div class="vault-paper" style="transform: rotate(-1deg) translateY(4px);"></div> | |
| </div> | |
| <!-- Front glassy cover with icon --> | |
| <div class="vault-folder-front"> | |
| <div class="vault-folder-icon">${svgIconHtml}</div> | |
| </div> | |
| </div> | |
| <button class="folder-edit-btn" title="Bearbeiten" data-path="${path}"> | |
| <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg> | |
| </button> | |
| <button class="vault-item-delete" title="Löschen" data-name="${f.name}" data-isdir="true"> | |
| <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg> | |
| </button> | |
| </div> | |
| `; | |
| } | |
| // ─── Render a Glass File Card ───────────────────────────────── | |
| _renderFile(f, path, delay) { | |
| const ext = f.name.split('.').pop().toLowerCase(); | |
| return ` | |
| <div class="vault-file vault-item-enter" style="--delay: ${delay}ms" data-name="${f.name}" data-isdir="false" data-path="${path}" title="${f.name}"> | |
| <div class="vault-file-body type-${ext}"> | |
| <!-- Background layer to preserve border-radius under clip-path --> | |
| <div class="vault-file-bg"></div> | |
| <!-- Colored top bar based on file type --> | |
| <div class="vault-file-top-bar"></div> | |
| <!-- Async content slot --> | |
| <div id="preview-${path.replace(/[^a-zA-Z0-9]/g, '_')}" class="vault-file-content-slot"> | |
| <div class="vault-file-preview-generic"> | |
| <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" style="opacity: 0.3"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg> | |
| </div> | |
| </div> | |
| <!-- File info at bottom --> | |
| <div class="vault-file-bottom-info"> | |
| <div class="vault-file-ext-label">.${ext}</div> | |
| <div class="vault-file-name-label">${f.name}</div> | |
| </div> | |
| </div> | |
| <!-- The folded flap at top right (outside body so it isn't clipped) --> | |
| <div class="vault-file-fold"></div> | |
| <button class="vault-item-delete" title="Löschen" data-name="${f.name}" data-isdir="false"> | |
| <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg> | |
| </button> | |
| </div> | |
| `; | |
| } | |
| // ─── Async preview loading ───────────────────────────────── | |
| async _loadFilePreviews(files) { | |
| for (const f of files) { | |
| if (f.type === 'directory') continue; | |
| const path = this.currentPath ? `${this.currentPath}/${f.name}` : f.name; | |
| const slotId = `preview-${path.replace(/[^a-zA-Z0-9]/g, '_')}`; | |
| const slot = document.getElementById(slotId); | |
| if (slot) await this._fillPreviewSlot(slot, f.name.split('.').pop().toLowerCase(), path); | |
| } | |
| } | |
| async _fillPreviewSlot(slot, ext, path) { | |
| if (!slot.isConnected) return; | |
| const isImage = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico', 'tiff'].includes(ext); | |
| const isCodeOrText = ['txt', 'md', 'csv', 'tsv', 'json', 'js', 'html', 'css', 'xml', 'yaml', 'yml'].includes(ext); | |
| const isAudio = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext); | |
| const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'wmv'].includes(ext); | |
| const isArchive = ['zip', 'rar', '7z', 'tar', 'gz'].includes(ext); | |
| try { | |
| if (isImage) { | |
| const base64 = await fsService.readBinaryFile(path); | |
| if (base64 && slot.isConnected) { | |
| slot.innerHTML = `<div class="vault-file-preview-img" style="background-image: url('data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${base64}')"></div>`; | |
| } | |
| } else if (isCodeOrText) { | |
| const content = await fsService.readFile(path); | |
| if (content && slot.isConnected) { | |
| const preview = content.substring(0, 300).replace(/</g, '<'); | |
| slot.innerHTML = `<div class="vault-file-preview-text">${preview}</div>`; | |
| } | |
| } else if (ext === 'pdf') { | |
| slot.innerHTML = `<div class="vault-file-preview-generic"> | |
| <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="#ff0000" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg> | |
| </div>`; | |
| } else if (ext === 'docx') { | |
| slot.innerHTML = `<div class="vault-file-preview-generic"> | |
| <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="#2b579a" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><polyline points="9 13 10.5 17 12 13 13.5 17 15 13"></polyline></svg> | |
| </div>`; | |
| } else if (['xls', 'xlsx'].includes(ext)) { | |
| slot.innerHTML = `<div class="vault-file-preview-generic"> | |
| <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="#217346" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><rect x="8" y="13" width="8" height="4"></rect><line x1="12" y1="13" x2="12" y2="17"></line></svg> | |
| </div>`; | |
| } else if (ext === 'pptx') { | |
| slot.innerHTML = `<div class="vault-file-preview-generic"> | |
| <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="#d24726" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><rect x="8" y="13" width="8" height="5"></rect></svg> | |
| </div>`; | |
| } else if (isAudio) { | |
| slot.innerHTML = `<div class="vault-file-preview-generic"> | |
| <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="#8a2be2" stroke-width="1.5"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg> | |
| </div>`; | |
| } else if (isVideo) { | |
| slot.innerHTML = `<div class="vault-file-preview-generic"> | |
| <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="#1e90ff" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect><line x1="7" y1="2" x2="7" y2="22"></line><line x1="17" y1="2" x2="17" y2="22"></line><line x1="2" y1="12" x2="22" y2="12"></line><line x1="2" y1="7" x2="7" y2="7"></line><line x1="2" y1="17" x2="7" y2="17"></line><line x1="17" y1="17" x2="22" y2="17"></line><line x1="17" y1="7" x2="22" y2="7"></line></svg> | |
| </div>`; | |
| } else if (isArchive) { | |
| slot.innerHTML = `<div class="vault-file-preview-generic"> | |
| <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="#ff8c00" stroke-width="1.5"><path d="M21 8v13H3V8"></path><path d="M10 12h4"></path><polyline points="12 12 12 15 10 15"></polyline><rect x="3" y="3" width="18" height="5"></rect></svg> | |
| </div>`; | |
| } else if (['exe', 'dmg'].includes(ext)) { | |
| slot.innerHTML = `<div class="vault-file-preview-generic"> | |
| <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="#888888" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><polyline points="11 3 11 11 14 11 14 14 11 14 11 21"></polyline><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg> | |
| </div>`; | |
| } | |
| } catch (e) { | |
| // Silently fail for previews | |
| } | |
| } | |
| // ─── Events ───────────────────────────────── | |
| attachEvents() { | |
| const wrapper = this.container.querySelector('#vault-glass-wrapper'); | |
| if (!wrapper) return; | |
| // Long-press handling for folders | |
| this._setupLongPress(wrapper); | |
| const fileInput = this.container.querySelector('#vault-file-upload'); | |
| if (fileInput) { | |
| fileInput.addEventListener('change', async (e) => { | |
| const files = Array.from(e.target.files); | |
| if (!files.length) return; | |
| let targetPath = this.currentPath; | |
| if (this.viewState === 'detail') { | |
| const openFolder = document.querySelector('.vault-folder-detail'); | |
| if (openFolder) targetPath = openFolder.dataset.path; | |
| } | |
| await Promise.all(files.map(file => { | |
| return new Promise((resolve) => { | |
| const reader = new FileReader(); | |
| reader.onload = async (event) => { | |
| const base64Data = event.target.result.split(',')[1]; | |
| if (base64Data) { | |
| const savePath = targetPath ? `${targetPath}/${file.name}` : file.name; | |
| await fsService.writeBinaryFile(savePath, base64Data); | |
| } | |
| resolve(); | |
| }; | |
| reader.readAsDataURL(file); | |
| }); | |
| })); | |
| this.loadFiles(); | |
| if (this.viewState === 'detail') { | |
| const openFolder = document.querySelector('.vault-folder-detail'); | |
| const allItems = document.querySelectorAll('.vault-folder, .vault-file'); | |
| if (openFolder) this._cinematicClose(openFolder, allItems); | |
| } | |
| }); | |
| } | |
| wrapper.addEventListener('click', async (e) => { | |
| // If the long press just ended, ignore the click | |
| if (this._isLongPress) { | |
| this._isLongPress = false; | |
| return; | |
| } | |
| const folder = e.target.closest('.vault-folder'); | |
| const file = e.target.closest('.vault-file'); | |
| const deleteBtn = e.target.closest('.vault-item-delete'); | |
| const editBtn = e.target.closest('.folder-edit-btn'); | |
| const backBtn = e.target.closest('#doc-back-btn'); | |
| const newFolderBtn = e.target.closest('#new-folder-btn'); | |
| const trashBtn = e.target.closest('#trash-btn'); | |
| const uploadBtn = e.target.closest('#upload-btn'); | |
| if (uploadBtn) { | |
| e.stopPropagation(); | |
| const fileIn = this.container.querySelector('#vault-file-upload'); | |
| if (fileIn) fileIn.click(); | |
| return; | |
| } | |
| if (trashBtn) { | |
| e.stopPropagation(); | |
| this.showTrashModal(); | |
| return; | |
| } | |
| if (deleteBtn) { | |
| e.stopPropagation(); | |
| const name = deleteBtn.dataset.name; | |
| const isDir = deleteBtn.dataset.isdir === 'true'; | |
| this.showCustomConfirm( | |
| isDir ? 'In den Papierkorb verschieben' : 'Dokument in den Papierkorb', | |
| `Möchtest du "${name}" wirklich in den Papierkorb verschieben?`, | |
| async () => { | |
| const path = this.currentPath ? `${this.currentPath}/${name}` : name; | |
| await fsService.moveToTrash(path, isDir); | |
| this.loadFiles(); | |
| }, | |
| 'Verschieben' | |
| ); | |
| return; | |
| } | |
| if (editBtn) { | |
| e.stopPropagation(); | |
| this.showFolderPopover(editBtn); | |
| return; | |
| } | |
| if (folder) { | |
| e.stopPropagation(); | |
| const name = folder.dataset.name; | |
| const path = this.currentPath ? `${this.currentPath}/${name}` : name; | |
| this._navigateToFolder(path); | |
| return; | |
| } | |
| if (file) { | |
| e.stopPropagation(); | |
| const name = file.dataset.name; | |
| const path = this.currentPath ? `${this.currentPath}/${name}` : name; | |
| this._openFile(name, path); | |
| return; | |
| } | |
| if (backBtn) { | |
| const parts = this.currentPath.split('/'); | |
| parts.pop(); | |
| this.currentPath = parts.join('/'); | |
| this.render(); | |
| return; | |
| } | |
| if (newFolderBtn) { | |
| this.showCustomPrompt('Name des neuen Ordners', async (folderName) => { | |
| if (folderName && folderName.trim()) { | |
| const cleanName = folderName.trim(); | |
| const path = this.currentPath ? `${this.currentPath}/${cleanName}` : cleanName; | |
| await fsService.createFolder(path); | |
| this.loadFiles(); | |
| try { | |
| const { folderAgent } = await import('../../ai/folder-agent.js?v=2'); | |
| const visuals = await folderAgent.generateVisualsForFolder(cleanName); | |
| db.saveFolderMetadata(path, visuals.emoji, visuals.color); | |
| this.loadFiles(); | |
| } catch (err) { | |
| console.error('Background emoji assignment failed:', err); | |
| } | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| // ─── Long-press Setup ───────────────────────────────── | |
| _setupLongPress(wrapper) { | |
| let pressTimer = null; | |
| let pressTarget = null; | |
| let pressed = false; | |
| const onStart = (e) => { | |
| pressed = false; | |
| const folder = e.target.closest('.vault-folder'); | |
| if (!folder || e.target.closest('.folder-edit-btn') || e.target.closest('.vault-item-delete')) return; | |
| pressTarget = folder; | |
| pressTimer = setTimeout(() => { | |
| pressed = true; | |
| this._isLongPress = true; | |
| this._cinematicOpen(folder); | |
| }, 500); | |
| }; | |
| const onEnd = () => { | |
| if (pressTimer) { | |
| clearTimeout(pressTimer); | |
| pressTimer = null; | |
| } | |
| if (pressed) { | |
| // Prevent the click that follows touchend | |
| this._isLongPress = true; | |
| setTimeout(() => { this._isLongPress = false; }, 500); | |
| } | |
| pressed = false; | |
| pressTarget = null; | |
| }; | |
| const onMove = (e) => { | |
| // Cancel long-press if finger moves | |
| if (pressTimer) { | |
| clearTimeout(pressTimer); | |
| pressTimer = null; | |
| } | |
| }; | |
| wrapper.addEventListener('touchstart', onStart, { passive: true }); | |
| wrapper.addEventListener('touchend', onEnd, { passive: true }); | |
| wrapper.addEventListener('touchmove', onMove, { passive: true }); | |
| wrapper.addEventListener('touchcancel', onEnd, { passive: true }); | |
| // Mouse fallback | |
| wrapper.addEventListener('mousedown', onStart); | |
| wrapper.addEventListener('mouseup', onEnd); | |
| wrapper.addEventListener('mouseleave', onEnd); | |
| } | |
| // ─── Tap Navigation (quick folder open) ───────────────── | |
| _navigateToFolder(path) { | |
| const grid = document.getElementById('vault-grid'); | |
| if (!grid) { | |
| this.currentPath = path; | |
| this.render(); | |
| return; | |
| } | |
| // Fade out all items | |
| const items = grid.querySelectorAll('.vault-folder, .vault-file'); | |
| items.forEach(item => item.classList.add('vault-item-exit')); | |
| // After fade-out, navigate | |
| setTimeout(() => { | |
| this.currentPath = path; | |
| this.render(); | |
| }, 200); | |
| } | |
| // ─── Cinematic Long-Press Open ───────────────────────────── | |
| async _cinematicOpen(folderEl) { | |
| // Haptic feedback | |
| if (navigator.vibrate) navigator.vibrate(30); | |
| const folderName = folderEl.dataset.name; | |
| const path = folderEl.dataset.path || (this.currentPath ? `${this.currentPath}/${folderName}` : folderName); | |
| const grid = document.getElementById('vault-grid'); | |
| if (!grid) return; | |
| // 1. Scale up pressed folder | |
| folderEl.classList.add('vault-folder-pressing'); | |
| folderEl.style.willChange = 'transform, opacity'; | |
| // 2. Hide all other items | |
| const allItems = grid.querySelectorAll('.vault-folder, .vault-file'); | |
| allItems.forEach(item => { | |
| if (item !== folderEl) { | |
| item.classList.add('vault-item-hide'); | |
| item.style.willChange = 'transform, opacity'; | |
| } | |
| }); | |
| // Wait for hide animation | |
| await this._wait(300); | |
| // 3. Move folder to center and scale | |
| const rect = folderEl.getBoundingClientRect(); | |
| const targetWidth = window.innerWidth * 0.5; | |
| // Calculate top so that the folder sits 90px from the bottom during animation. | |
| // .vault-folder-body has aspect-ratio: 1.3, so height is width / 1.3. Plus ~26px for the label. | |
| const targetHeight = (targetWidth / 1.3) + 26; | |
| const targetTop = window.innerHeight - targetHeight - 90; | |
| const targetLeft = window.innerWidth * 0.05; | |
| const scale = targetWidth / rect.width; | |
| const translateX = targetLeft - rect.left; | |
| const translateY = targetTop - rect.top; | |
| folderEl._originalRect = rect; | |
| // Switch to fixed positioning at current spot | |
| folderEl.style.position = 'fixed'; | |
| folderEl.style.top = rect.top + 'px'; | |
| folderEl.style.left = rect.left + 'px'; | |
| folderEl.style.width = rect.width + 'px'; | |
| folderEl.style.zIndex = '100'; | |
| // Set random rotation | |
| const randomRot = (Math.random() > 0.5 ? 1 : -1) * (8 + Math.random() * 8); | |
| folderEl.dataset.rot = randomRot; | |
| // Combine everything into a single transform for rotation ONLY | |
| folderEl.style.setProperty('--folder-rot', `${randomRot}deg`); | |
| if (randomRot < 0) folderEl.classList.add('tilt-left'); | |
| else folderEl.classList.add('tilt-right'); | |
| folderEl.classList.remove('vault-folder-pressing'); | |
| folderEl.classList.add('vault-folder-detail'); | |
| // Force reflow | |
| folderEl.offsetHeight; | |
| // Animate to detail position using top, left, width | |
| folderEl.style.transition = 'top 0.45s cubic-bezier(.22,.68,0,1.1), left 0.45s cubic-bezier(.22,.68,0,1.1), width 0.45s cubic-bezier(.22,.68,0,1.1), transform 0.45s cubic-bezier(.22,.68,0,1.1)'; | |
| folderEl.style.top = targetTop + 'px'; | |
| folderEl.style.left = targetLeft + 'px'; | |
| folderEl.style.width = targetWidth + 'px'; | |
| // After animation, pin to bottom so it resists viewport resizing (e.g. keyboard/address bar) | |
| setTimeout(() => { | |
| if (this.viewState === 'detail' && folderEl.classList.contains('vault-folder-detail')) { | |
| folderEl.style.transition = 'none'; | |
| folderEl.style.top = 'auto'; | |
| folderEl.style.bottom = '90px'; | |
| } | |
| }, 450); | |
| // 4. Update ambient title | |
| const titleEl = document.querySelector('.vault-ambient-title'); | |
| if (titleEl) titleEl.textContent = folderName; | |
| // 5. Add detail overlay | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'vault-detail-overlay'; | |
| overlay.addEventListener('click', () => this._cinematicClose(folderEl, allItems)); | |
| const wrapper = this.container.querySelector('#vault-glass-wrapper'); | |
| if (wrapper) { | |
| wrapper.appendChild(overlay); | |
| } else { | |
| document.body.appendChild(overlay); | |
| } | |
| this.viewState = 'detail'; | |
| // 6. Load child items and display them flying out from the folder | |
| await this._wait(350); | |
| try { | |
| const childFiles = await fsService.readDir(path); | |
| childFiles.sort((a, b) => { | |
| const timeA = a.mtime || a.ctime || 0; | |
| const timeB = b.mtime || b.ctime || 0; | |
| if (timeA !== timeB) return timeB - timeA; | |
| if (a.type === 'directory' && b.type !== 'directory') return -1; | |
| if (a.type !== 'directory' && b.type === 'directory') return 1; | |
| return a.name.localeCompare(b.name); | |
| }); | |
| const filesToShow = childFiles.slice(0, 3); | |
| if (filesToShow.length > 0) { | |
| // Create a container for child items inside the folder body (between front and back) | |
| const childGrid = document.createElement('div'); | |
| childGrid.id = 'vault-detail-children'; | |
| childGrid.style.cssText = ` | |
| position: absolute; | |
| bottom: 10%; | |
| left: 0; | |
| width: 100%; | |
| height: 0; | |
| z-index: 2; | |
| overflow: visible; | |
| pointer-events: none; | |
| `; | |
| // Also ensure all parent containers allow overflow | |
| folderEl.style.overflow = 'visible'; | |
| const fb = folderEl.querySelector('.vault-folder-body'); | |
| if (fb) fb.style.overflow = 'visible'; | |
| childGrid.innerHTML = filesToShow.map((cf, idx) => { | |
| const childPath = `${path}/${cf.name}`; | |
| const delay = idx * 60; | |
| // Zig-zag math relative to folder | |
| const itemWidth = window.innerWidth * 0.4; | |
| const isLeft = idx % 2 === 0; | |
| // left offset relative to folder body left | |
| const leftPx = isLeft ? 10 : (window.innerWidth * 0.4); | |
| // bottom offset relative to childGrid bottom | |
| const bottomPx = 40 + idx * (itemWidth * 0.6); | |
| // Start position for fly-out animation (originating from folder bottom-center) | |
| const startX = (targetWidth / 2) - (leftPx + itemWidth / 2); | |
| const startY = -bottomPx; | |
| // Rotate alternating left/right (e.g. -8deg, 5deg, -4deg) | |
| const rotDeg = isLeft ? - (6 + Math.random() * 4) : (4 + Math.random() * 4); | |
| const styleStr = `position: absolute; width: ${itemWidth}px; bottom: ${bottomPx}px; left: ${leftPx}px; pointer-events: auto; --start-x: ${startX}px; --start-y: ${startY}px; --rot: ${rotDeg}deg; transform: rotate(${rotDeg}deg);`; | |
| let html = cf.type === 'directory' ? this._renderFolder(cf, childPath, 0) : this._renderFile(cf, childPath, 0); | |
| // Inject style and classes | |
| html = html.replace('class="vault-', `style="${styleStr}" class="vault-`); | |
| html = html.replace('vault-item-enter', `vault-child-enter vault-child-item`); | |
| html = html.replace('--delay: 0ms', `--delay: ${delay}ms`); | |
| return html; | |
| }).join(''); | |
| const folderBody = folderEl.querySelector('.vault-folder-body'); | |
| if (folderBody) { | |
| folderBody.appendChild(childGrid); | |
| } | |
| // Load previews for child files | |
| this._loadFilePreviewsInContainer(filesToShow, path, childGrid); | |
| // Attach events to child items | |
| this._attachDetailChildEvents(childGrid, path, folderEl, allItems); | |
| } | |
| } catch (err) { | |
| console.error('Error loading children in detail view:', err); | |
| } | |
| } | |
| // ─── Cinematic Close (back to grid) ───────────────────────── | |
| async _cinematicClose(folderEl, allItems) { | |
| // 1. Fly child items back | |
| const childGrid = document.getElementById('vault-detail-children'); | |
| if (childGrid) { | |
| const children = childGrid.querySelectorAll('.vault-folder, .vault-file'); | |
| children.forEach((child, idx) => { | |
| child.style.setProperty('--delay', `${(children.length - 1 - idx) * 40}ms`); | |
| child.classList.remove('vault-child-enter'); | |
| child.classList.add('vault-child-exit'); | |
| child.style.willChange = 'transform, opacity'; | |
| }); | |
| await this._wait(200); | |
| childGrid.remove(); | |
| } | |
| // 2. Remove overlay | |
| document.querySelectorAll('.vault-detail-overlay').forEach(o => o.remove()); | |
| // Record the EXACT current fixed position before doing anything | |
| const currentFixedRect = folderEl.getBoundingClientRect(); | |
| // 3. Get fresh layout position in case of scroll | |
| folderEl.classList.remove('vault-folder-detail'); | |
| folderEl.classList.remove('tilt-left', 'tilt-right'); | |
| folderEl.style.position = ''; | |
| folderEl.style.top = ''; | |
| folderEl.style.bottom = ''; // Clear bottom too! | |
| folderEl.style.left = ''; | |
| folderEl.style.width = ''; | |
| folderEl.style.transition = 'none'; | |
| const freshRect = folderEl.getBoundingClientRect(); | |
| // Put back to fixed detail state instantly | |
| folderEl.style.position = 'fixed'; | |
| // Restore exact visual position before animating | |
| folderEl.style.top = currentFixedRect.top + 'px'; | |
| folderEl.style.left = currentFixedRect.left + 'px'; | |
| folderEl.style.width = currentFixedRect.width + 'px'; | |
| folderEl.classList.add('vault-folder-detail'); | |
| const rot = folderEl.dataset.rot || -12; | |
| if (rot < 0) folderEl.classList.add('tilt-left'); | |
| else folderEl.classList.add('tilt-right'); | |
| // Force reflow | |
| folderEl.offsetHeight; | |
| // Now animate smoothly back to grid rect | |
| folderEl.style.transition = 'top 0.45s cubic-bezier(.22,.68,0,1.1), left 0.45s cubic-bezier(.22,.68,0,1.1), width 0.45s cubic-bezier(.22,.68,0,1.1), transform 0.45s cubic-bezier(.22,.68,0,1.1)'; | |
| folderEl.style.top = freshRect.top + 'px'; | |
| folderEl.style.left = freshRect.left + 'px'; | |
| folderEl.style.width = freshRect.width + 'px'; | |
| folderEl.classList.remove('vault-folder-detail'); | |
| folderEl.classList.remove('tilt-left', 'tilt-right'); | |
| await this._wait(450); | |
| // 5. Reset folder styles | |
| folderEl.style.position = ''; | |
| folderEl.style.top = ''; | |
| folderEl.style.left = ''; | |
| folderEl.style.width = ''; | |
| folderEl.style.zIndex = ''; | |
| folderEl.style.transition = ''; | |
| folderEl.style.willChange = ''; | |
| // 5. Bring back other items | |
| allItems.forEach((item, idx) => { | |
| if (item !== folderEl) { | |
| item.classList.remove('vault-item-hide'); | |
| item.style.setProperty('--delay', `${idx * 30}ms`); | |
| item.classList.add('vault-item-return'); | |
| item.style.willChange = 'transform, opacity'; | |
| } | |
| }); | |
| // 6. Restore title | |
| const titleEl = document.querySelector('.vault-ambient-title'); | |
| if (titleEl) titleEl.textContent = this._getAmbientTitle(); | |
| this.viewState = 'grid'; | |
| // Cleanup will-change and animation classes after ALL animations finish | |
| const maxDelay = allItems.length * 30; | |
| setTimeout(() => { | |
| allItems.forEach(item => { | |
| item.classList.remove('vault-item-return', 'vault-item-enter', 'vault-item-hide'); | |
| item.style.willChange = ''; | |
| }); | |
| }, 550 + maxDelay); | |
| } | |
| // ─── Attach events to detail-view children ─────────────── | |
| _attachDetailChildEvents(childGrid, parentPath, folderEl, allItems) { | |
| childGrid.addEventListener('click', async (e) => { | |
| const folder = e.target.closest('.vault-folder'); | |
| const file = e.target.closest('.vault-file'); | |
| const deleteBtn = e.target.closest('.vault-item-delete'); | |
| const editBtn = e.target.closest('.folder-edit-btn'); | |
| if (deleteBtn) { | |
| e.stopPropagation(); | |
| const name = deleteBtn.dataset.name; | |
| const isDir = deleteBtn.dataset.isdir === 'true'; | |
| this.showCustomConfirm( | |
| isDir ? 'In den Papierkorb verschieben' : 'Dokument in den Papierkorb', | |
| `Möchtest du "${name}" wirklich in den Papierkorb verschieben?`, | |
| async () => { | |
| const path = `${parentPath}/${name}`; | |
| await fsService.moveToTrash(path, isDir); | |
| // Refresh: close and reopen | |
| await this._cinematicClose(folderEl, allItems); | |
| }, | |
| 'Verschieben' | |
| ); | |
| return; | |
| } | |
| if (editBtn) { | |
| e.stopPropagation(); | |
| this.showFolderPopover(editBtn); | |
| return; | |
| } | |
| if (folder) { | |
| e.stopPropagation(); | |
| const name = folder.dataset.name; | |
| // Close detail, navigate to sub-folder | |
| await this._cinematicClose(folderEl, allItems); | |
| this.currentPath = `${parentPath}/${name}`; | |
| this.render(); | |
| return; | |
| } | |
| if (file) { | |
| e.stopPropagation(); | |
| const name = file.dataset.name; | |
| const path = `${parentPath}/${name}`; | |
| this._openFile(name, path); | |
| return; | |
| } | |
| }); | |
| // Hover shows delete btn | |
| childGrid.querySelectorAll('.vault-folder, .vault-file').forEach(item => { | |
| item.addEventListener('mouseenter', () => { | |
| const btn = item.querySelector('.vault-item-delete'); | |
| if (btn) btn.style.opacity = '1'; | |
| }); | |
| item.addEventListener('mouseleave', () => { | |
| const btn = item.querySelector('.vault-item-delete'); | |
| if (btn) btn.style.opacity = '0'; | |
| }); | |
| }); | |
| } | |
| // ─── Load previews for child items in container ───────── | |
| async _loadFilePreviewsInContainer(files, parentPath, container) { | |
| for (const f of files) { | |
| if (f.type === 'directory') continue; | |
| const path = `${parentPath}/${f.name}`; | |
| const slotId = `preview-${path.replace(/[^a-zA-Z0-9]/g, '_')}`; | |
| const slot = container.querySelector(`#${slotId}`); | |
| if (slot) await this._fillPreviewSlot(slot, f.name.split('.').pop().toLowerCase(), path); | |
| } | |
| } | |
| // ─── Open File ───────────────────────────────── | |
| async _openFile(name, path) { | |
| if (name.endsWith('.md') || name.endsWith('.txt') || name.endsWith('.csv')) { | |
| const content = await fsService.readFile(path); | |
| if (content && window.openPdfModal) { | |
| window.openPdfModal(content); | |
| } | |
| } else if (name.toLowerCase().endsWith('.pdf')) { | |
| const base64 = await fsService.readBinaryFile(path); | |
| if (base64) { | |
| this._openPdfViewer(base64); | |
| } | |
| } | |
| } | |
| // ─── PDF Viewer (extracted, unchanged logic) ───────────── | |
| _openPdfViewer(base64) { | |
| const viewerWrapper = document.createElement('div'); | |
| viewerWrapper.id = 'pdf-viewer-wrapper'; | |
| viewerWrapper.style.position = 'fixed'; | |
| viewerWrapper.style.top = '0'; viewerWrapper.style.left = '0'; viewerWrapper.style.width = '100vw'; viewerWrapper.style.height = '100vh'; | |
| viewerWrapper.style.zIndex = '999999'; | |
| viewerWrapper.style.backgroundColor = '#1a1a1a'; | |
| viewerWrapper.style.overflow = 'auto'; | |
| viewerWrapper.style.boxSizing = 'border-box'; | |
| viewerWrapper.style.padding = '80px 20px'; | |
| const textSelectStyle = document.createElement('style'); | |
| textSelectStyle.textContent = ` | |
| #pdf-viewer-wrapper, #pdf-viewer-wrapper * { | |
| -webkit-user-select: text !important; | |
| user-select: text !important; | |
| -webkit-touch-callout: default !important; | |
| } | |
| #pdf-viewer-wrapper .textLayer span { | |
| color: rgba(0,0,0,0.01) !important; | |
| pointer-events: all !important; | |
| cursor: text !important; | |
| } | |
| #pdf-viewer-wrapper ::selection { | |
| background: rgba(0, 120, 255, 0.3) !important; | |
| } | |
| `; | |
| viewerWrapper.appendChild(textSelectStyle); | |
| const pdfContainer = document.createElement('div'); | |
| pdfContainer.style.display = 'block'; | |
| pdfContainer.style.zoom = '1.0'; | |
| pdfContainer.style.transformOrigin = '0 0'; | |
| viewerWrapper.appendChild(pdfContainer); | |
| // Close Button | |
| const closeBtn = document.createElement('button'); | |
| closeBtn.innerHTML = 'Zurück'; | |
| closeBtn.style.position = 'fixed'; | |
| closeBtn.style.top = 'env(safe-area-inset-top, 20px)'; | |
| closeBtn.style.right = '20px'; | |
| closeBtn.style.padding = '8px 16px'; | |
| closeBtn.style.background = 'rgba(0,0,0,0.5)'; | |
| closeBtn.style.color = '#fff'; | |
| closeBtn.style.border = '1px solid rgba(255,255,255,0.2)'; | |
| closeBtn.style.borderRadius = '12px'; | |
| closeBtn.style.cursor = 'pointer'; | |
| closeBtn.style.backdropFilter = 'blur(10px)'; | |
| closeBtn.style.fontWeight = '600'; | |
| closeBtn.style.boxShadow = '0 4px 12px rgba(0,0,0,0.5)'; | |
| closeBtn.style.zIndex = '1000000'; | |
| // Zoom Controls | |
| const zoomControls = document.createElement('div'); | |
| zoomControls.style.position = 'fixed'; | |
| zoomControls.style.bottom = '20px'; | |
| zoomControls.style.left = '50%'; | |
| zoomControls.style.transform = 'translateX(-50%)'; | |
| zoomControls.style.display = 'flex'; | |
| zoomControls.style.gap = '12px'; | |
| zoomControls.style.background = 'rgba(30, 30, 35, 0.9)'; | |
| zoomControls.style.padding = '8px'; | |
| zoomControls.style.borderRadius = '16px'; | |
| zoomControls.style.border = '1px solid rgba(255,255,255,0.1)'; | |
| zoomControls.style.backdropFilter = 'blur(10px)'; | |
| zoomControls.style.zIndex = '1000000'; | |
| zoomControls.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)'; | |
| let currentZoom = 1.0; | |
| const updateZoom = (newZoom) => { | |
| if (newZoom < 1.0) newZoom = 1.0; | |
| if (newZoom > 4.0) newZoom = 4.0; | |
| currentZoom = newZoom; | |
| pdfContainer.style.zoom = currentZoom; | |
| }; | |
| const zoomToPoint = (delta, centerX, centerY) => { | |
| const pointX = (centerX + viewerWrapper.scrollLeft) / currentZoom; | |
| const pointY = (centerY + viewerWrapper.scrollTop) / currentZoom; | |
| updateZoom(currentZoom + delta); | |
| viewerWrapper.scrollLeft = (pointX * currentZoom) - centerX; | |
| viewerWrapper.scrollTop = (pointY * currentZoom) - centerY; | |
| }; | |
| const zoomOutBtn = document.createElement('button'); | |
| zoomOutBtn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none"><line x1="5" y1="12" x2="19" y2="12"></line></svg>'; | |
| zoomOutBtn.style.background = 'transparent'; zoomOutBtn.style.border = 'none'; zoomOutBtn.style.color = 'white'; zoomOutBtn.style.cursor = 'pointer'; zoomOutBtn.style.padding = '8px'; | |
| zoomOutBtn.onclick = () => zoomToPoint(-0.5, viewerWrapper.clientWidth / 2, viewerWrapper.clientHeight / 2); | |
| const zoomInBtn = document.createElement('button'); | |
| zoomInBtn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>'; | |
| zoomInBtn.style.background = 'transparent'; zoomInBtn.style.border = 'none'; zoomInBtn.style.color = 'white'; zoomInBtn.style.cursor = 'pointer'; zoomInBtn.style.padding = '8px'; | |
| zoomInBtn.onclick = () => zoomToPoint(0.5, viewerWrapper.clientWidth / 2, viewerWrapper.clientHeight / 2); | |
| zoomControls.appendChild(zoomOutBtn); | |
| zoomControls.appendChild(zoomInBtn); | |
| // Pinch-To-Zoom | |
| let initialDistance = 0; | |
| let initialZoom = 1.0; | |
| let pinchPoint = { x: 0, y: 0 }; | |
| viewerWrapper.addEventListener('touchstart', (e) => { | |
| if (e.touches.length === 2) { | |
| const t1 = e.touches[0]; const t2 = e.touches[1]; | |
| const dx = t1.clientX - t2.clientX; const dy = t1.clientY - t2.clientY; | |
| initialDistance = Math.sqrt(dx * dx + dy * dy); | |
| initialZoom = currentZoom; | |
| const centerX = (t1.clientX + t2.clientX) / 2; | |
| const centerY = (t1.clientY + t2.clientY) / 2; | |
| pinchPoint.x = (centerX + viewerWrapper.scrollLeft) / currentZoom; | |
| pinchPoint.y = (centerY + viewerWrapper.scrollTop) / currentZoom; | |
| } | |
| }); | |
| viewerWrapper.addEventListener('touchmove', (e) => { | |
| if (e.touches.length === 2) { | |
| e.preventDefault(); | |
| const t1 = e.touches[0]; const t2 = e.touches[1]; | |
| const dx = t1.clientX - t2.clientX; const dy = t1.clientY - t2.clientY; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| const newZoom = initialZoom * (dist / initialDistance); | |
| updateZoom(newZoom); | |
| const centerX = (t1.clientX + t2.clientX) / 2; | |
| const centerY = (t1.clientY + t2.clientY) / 2; | |
| viewerWrapper.scrollLeft = (pinchPoint.x * currentZoom) - centerX; | |
| viewerWrapper.scrollTop = (pinchPoint.y * currentZoom) - centerY; | |
| } | |
| }, { passive: false }); | |
| closeBtn.onclick = () => { | |
| if (viewerWrapper.parentNode) document.body.removeChild(viewerWrapper); | |
| }; | |
| viewerWrapper.appendChild(closeBtn); | |
| viewerWrapper.appendChild(zoomControls); | |
| document.body.appendChild(viewerWrapper); | |
| const loadingText = document.createElement('div'); | |
| loadingText.innerHTML = 'Lade PDF...'; | |
| loadingText.style.color = '#fff'; | |
| loadingText.style.fontFamily = 'sans-serif'; | |
| loadingText.style.textAlign = 'center'; | |
| pdfContainer.appendChild(loadingText); | |
| const loadPdfJs = () => new Promise((resolve, reject) => { | |
| const link = document.createElement('link'); | |
| link.rel = 'stylesheet'; | |
| link.href = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf_viewer.min.css'; | |
| document.head.appendChild(link); | |
| if (window['pdfjs-dist/build/pdf']) return resolve(window['pdfjs-dist/build/pdf']); | |
| const script = document.createElement('script'); | |
| script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'; | |
| script.onload = () => resolve(window['pdfjs-dist/build/pdf']); | |
| script.onerror = reject; | |
| document.head.appendChild(script); | |
| }); | |
| (async () => { | |
| try { | |
| const pdfjsLib = await loadPdfJs(); | |
| pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; | |
| const pdfData = atob(base64); | |
| const uint8Array = new Uint8Array(pdfData.length); | |
| for (let i = 0; i < pdfData.length; i++) { | |
| uint8Array[i] = pdfData.charCodeAt(i); | |
| } | |
| const loadingTask = pdfjsLib.getDocument({ data: uint8Array }); | |
| const pdf = await loadingTask.promise; | |
| if (loadingText && loadingText.parentNode) { | |
| loadingText.parentNode.removeChild(loadingText); | |
| } | |
| for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { | |
| const page = await pdf.getPage(pageNum); | |
| const unscaledViewport = page.getViewport({ scale: 1.0 }); | |
| const screenWidth = window.innerWidth - 40; | |
| const baseScale = screenWidth / unscaledViewport.width; | |
| const renderScale = baseScale * 4; | |
| const cssViewport = page.getViewport({ scale: baseScale }); | |
| const renderViewport = page.getViewport({ scale: renderScale }); | |
| const pageContainerDiv = document.createElement('div'); | |
| pageContainerDiv.style.position = 'relative'; | |
| pageContainerDiv.style.width = cssViewport.width + 'px'; | |
| pageContainerDiv.style.height = cssViewport.height + 'px'; | |
| pageContainerDiv.style.marginBottom = '24px'; | |
| pageContainerDiv.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)'; | |
| pageContainerDiv.style.borderRadius = '8px'; | |
| pageContainerDiv.style.backgroundColor = 'white'; | |
| const canvas = document.createElement('canvas'); | |
| const context = canvas.getContext('2d'); | |
| canvas.width = renderViewport.width; | |
| canvas.height = renderViewport.height; | |
| canvas.style.width = cssViewport.width + 'px'; | |
| canvas.style.height = cssViewport.height + 'px'; | |
| canvas.style.display = 'block'; | |
| canvas.style.borderRadius = '8px'; | |
| pageContainerDiv.appendChild(canvas); | |
| const textLayerDiv = document.createElement('div'); | |
| textLayerDiv.setAttribute('class', 'textLayer selectable-text'); | |
| textLayerDiv.style.width = cssViewport.width + 'px'; | |
| textLayerDiv.style.height = cssViewport.height + 'px'; | |
| textLayerDiv.style.position = 'absolute'; | |
| textLayerDiv.style.top = '0'; | |
| textLayerDiv.style.left = '0'; | |
| textLayerDiv.style.setProperty('--scale-factor', cssViewport.scale); | |
| pageContainerDiv.appendChild(textLayerDiv); | |
| pdfContainer.appendChild(pageContainerDiv); | |
| const renderTask = page.render({ canvasContext: context, viewport: renderViewport }); | |
| await renderTask.promise; | |
| const textContent = await page.getTextContent(); | |
| pdfjsLib.renderTextLayer({ textContentSource: textContent, container: textLayerDiv, viewport: cssViewport, textDivs: [] }); | |
| } | |
| } catch (err) { | |
| if (loadingText) loadingText.innerHTML = 'Fehler beim Laden der PDF.'; | |
| console.error(err); | |
| } | |
| })(); | |
| } | |
| // ─── Utility ───────────────────────────────── | |
| _wait(ms) { | |
| return new Promise(resolve => setTimeout(resolve, ms)); | |
| } | |
| // ─── Folder Popover (unchanged) ───────────────────────── | |
| showFolderPopover(btnEl) { | |
| document.querySelectorAll('.folder-popover').forEach(p => p.remove()); | |
| const path = btnEl.dataset.path; | |
| const currentEmoji = btnEl.dataset.emoji; | |
| const currentColor = btnEl.dataset.color || '#6c8ef5'; | |
| const popover = document.createElement('div'); | |
| popover.className = 'folder-popover'; | |
| const rect = btnEl.getBoundingClientRect(); | |
| const previewSvg = getIconSvg(currentEmoji, { class: 'folder-icon-engraved', width: '32', height: '32' }); | |
| const iconGridHtml = ICON_NAMES.map(name => ` | |
| <div class="icon-grid-item ${name === currentEmoji ? 'selected' : ''}" data-name="${name}"> | |
| ${getIconSvg(name, { width: '20', height: '20' })} | |
| </div> | |
| `).join(''); | |
| popover.innerHTML = ` | |
| <h3>Ordner Anpassen</h3> | |
| <div style="display: flex; gap: 12px; align-items: center; justify-content: space-between;"> | |
| <div class="folder-emoji-wrapper" style="position: static; font-size: 32px; transform: none; margin-left: 8px; filter: drop-shadow(0 1px 1px rgba(255,255,255,0.4));"> | |
| <div id="popover-emoji-preview" style="display: flex; align-items: center; justify-content: center;"> | |
| ${previewSvg} | |
| </div> | |
| </div> | |
| <div id="popover-color-preview" style="width: 40px; height: 40px; border-radius: 50%; background: ${currentColor}; border: 2px solid rgba(255,255,255,0.3); flex-shrink: 0;"></div> | |
| </div> | |
| <!-- HSV Square Color Picker --> | |
| <div style="margin-top: 14px;"> | |
| <canvas id="color-square" style="width:100%; height:160px; border-radius:8px; cursor:crosshair; touch-action:none; display:block;"></canvas> | |
| <div style="margin-top: 8px; display:flex; align-items:center; gap:8px;"> | |
| <span style="font-size:10px;color:rgba(255,255,255,0.4);flex-shrink:0;">Farbton</span> | |
| <input id="hue-slider" type="range" min="0" max="359" value="210" style="flex:1; -webkit-appearance:none; height:14px; border-radius:7px; outline:none; cursor:pointer; background: linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00); border: 1px solid rgba(255,255,255,0.1);"> | |
| </div> | |
| <div style="display:flex; align-items:center; gap:8px; margin-top:8px;"> | |
| <span style="font-size:12px; color:rgba(255,255,255,0.5);">#</span> | |
| <input id="input-hex" type="text" maxlength="6" placeholder="rrggbb" value="${currentColor.replace('#','')}" style="flex:1; background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.15); border-radius:6px; color:white; font-size:13px; padding:5px 8px; font-family: monospace; text-transform: uppercase;"> | |
| <button id="btn-apply-hex" style="padding: 5px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: white; font-size: 12px; cursor: pointer;">OK</button> | |
| </div> | |
| </div> | |
| <button id="popover-save" class="liquid-glass" style="padding: 10px; border-radius: 8px; border: none; cursor: pointer; color: white; font-weight: 600; width: 100%; margin-top: 16px;">Speichern</button> | |
| <div class="icon-picker-grid" style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; max-height: 180px; overflow-y: auto; margin-top: 16px; padding-right: 4px;"> | |
| ${iconGridHtml} | |
| </div> | |
| `; | |
| document.body.appendChild(popover); | |
| let selectedEmoji = currentEmoji; | |
| let selectedColor = currentColor; | |
| popover.querySelectorAll('.icon-grid-item').forEach(item => { | |
| item.addEventListener('click', () => { | |
| popover.querySelectorAll('.icon-grid-item').forEach(i => i.classList.remove('selected')); | |
| item.classList.add('selected'); | |
| selectedEmoji = item.dataset.name; | |
| popover.querySelector('#popover-emoji-preview').innerHTML = getIconSvg(selectedEmoji, { class: 'folder-icon-engraved', width: '32', height: '32' }); | |
| }); | |
| }); | |
| // --- HSV Color Picker Logic --- | |
| const _hexToRgbArr = hex => [parseInt(hex.slice(1,3),16), parseInt(hex.slice(3,5),16), parseInt(hex.slice(5,7),16)]; | |
| const _rgbToHex = (r,g,b) => '#' + [r,g,b].map(v => Math.max(0,Math.min(255,Math.round(v))).toString(16).padStart(2,'0')).join(''); | |
| const _hsvToRgb = (h,s,v) => { | |
| s/=100; v/=100; | |
| const i=Math.floor(h/60)%6, f=h/60-Math.floor(h/60); | |
| const p=v*(1-s), q=v*(1-f*s), t=v*(1-(1-f)*s); | |
| const m=[[v,t,p],[q,v,p],[p,v,t],[p,q,v],[t,p,v],[v,p,q]][i]; | |
| return m.map(c=>Math.round(c*255)); | |
| }; | |
| const _rgbToHsv = (r,g,b) => { | |
| r/=255; g/=255; b/=255; | |
| const max=Math.max(r,g,b), min=Math.min(r,g,b), d=max-min; | |
| let h=0, s=max===0?0:d/max, v=max; | |
| if(d!==0){ | |
| if(max===r) h=((g-b)/d+6)%6; | |
| else if(max===g) h=(b-r)/d+2; | |
| else h=(r-g)/d+4; | |
| h*=60; | |
| } | |
| return [Math.round(h), Math.round(s*100), Math.round(v*100)]; | |
| }; | |
| const [initR2, initG2, initB2] = _hexToRgbArr(currentColor); | |
| let [curH, curS, curV] = _rgbToHsv(initR2, initG2, initB2); | |
| const canvas = popover.querySelector('#color-square'); | |
| const hueSlider = popover.querySelector('#hue-slider'); | |
| const inputHex = popover.querySelector('#input-hex'); | |
| const btnApplyHex = popover.querySelector('#btn-apply-hex'); | |
| const preview = popover.querySelector('#popover-color-preview'); | |
| hueSlider.value = curH; | |
| const _deselectSwatches = () => { | |
| popover.querySelectorAll('.color-swatch').forEach(s => { s.style.border='2px solid rgba(255,255,255,0.15)'; s.style.transform=''; }); | |
| }; | |
| const _drawSquare = () => { | |
| const ctx = canvas.getContext('2d'); | |
| const w = canvas.width, h = canvas.height; | |
| const hGrad = ctx.createLinearGradient(0,0,w,0); | |
| hGrad.addColorStop(0, 'white'); | |
| hGrad.addColorStop(1, `hsl(${curH},100%,50%)`); | |
| ctx.fillStyle = hGrad; ctx.fillRect(0,0,w,h); | |
| const dGrad = ctx.createLinearGradient(0,0,0,h); | |
| dGrad.addColorStop(0,'transparent'); dGrad.addColorStop(1,'black'); | |
| ctx.fillStyle = dGrad; ctx.fillRect(0,0,w,h); | |
| const cx=(curS/100)*w, cy=(1-curV/100)*h; | |
| ctx.beginPath(); ctx.arc(cx,cy,9,0,Math.PI*2); | |
| ctx.strokeStyle='white'; ctx.lineWidth=2.5; ctx.stroke(); | |
| ctx.beginPath(); ctx.arc(cx,cy,9,0,Math.PI*2); | |
| ctx.strokeStyle='rgba(0,0,0,0.5)'; ctx.lineWidth=1; ctx.stroke(); | |
| }; | |
| const _updateFromHsv = () => { | |
| const [r,g,b] = _hsvToRgb(curH,curS,curV); | |
| const hex = _rgbToHex(r,g,b); | |
| selectedColor = hex; | |
| preview.style.background = hex; | |
| inputHex.value = hex.replace('#','').toUpperCase(); | |
| _drawSquare(); | |
| _deselectSwatches(); | |
| }; | |
| const _initCanvas = () => { | |
| const rect = canvas.getBoundingClientRect(); | |
| canvas.width = rect.width || 248; | |
| canvas.height = rect.height || 160; | |
| _drawSquare(); | |
| }; | |
| setTimeout(_initCanvas, 60); | |
| let _pickingSquare = false; | |
| const _pickSquare = (e) => { | |
| e.preventDefault(); | |
| const rect = canvas.getBoundingClientRect(); | |
| const cx = e.touches ? e.touches[0].clientX : e.clientX; | |
| const cy = e.touches ? e.touches[0].clientY : e.clientY; | |
| curS = Math.round(Math.max(0,Math.min(1,(cx-rect.left)/rect.width))*100); | |
| curV = Math.round(Math.max(0,Math.min(1,1-(cy-rect.top)/rect.height))*100); | |
| _updateFromHsv(); | |
| }; | |
| canvas.addEventListener('mousedown', e => { _pickingSquare=true; _pickSquare(e); }); | |
| canvas.addEventListener('touchstart', e => { _pickingSquare=true; _pickSquare(e); }, {passive:false}); | |
| document.addEventListener('mousemove', e => { if(_pickingSquare) _pickSquare(e); }); | |
| document.addEventListener('touchmove', e => { if(_pickingSquare) _pickSquare(e); }, {passive:false}); | |
| document.addEventListener('mouseup', () => { _pickingSquare=false; }); | |
| document.addEventListener('touchend', () => { _pickingSquare=false; }); | |
| hueSlider.addEventListener('input', () => { curH=parseInt(hueSlider.value); _updateFromHsv(); }); | |
| btnApplyHex.addEventListener('click', () => { | |
| const raw = inputHex.value.trim().replace('#',''); | |
| if(/^[0-9a-fA-F]{6}$/.test(raw)) { | |
| const hex = '#'+raw.toLowerCase(); | |
| const [r,g,b] = _hexToRgbArr(hex); | |
| [curH,curS,curV] = _rgbToHsv(r,g,b); | |
| hueSlider.value = curH; | |
| selectedColor = hex; | |
| preview.style.background = hex; | |
| _deselectSwatches(); | |
| _drawSquare(); | |
| } else { | |
| inputHex.style.border='1px solid #ff4444'; | |
| setTimeout(()=>inputHex.style.border='1px solid rgba(255,255,255,0.15)',1000); | |
| } | |
| }); | |
| inputHex.addEventListener('keydown', e => { if(e.key==='Enter') btnApplyHex.click(); }); | |
| popover.querySelectorAll('.color-swatch').forEach(swatch => { | |
| swatch.addEventListener('click', () => { | |
| _deselectSwatches(); | |
| swatch.style.border = '2px solid rgba(255,255,255,0.9)'; | |
| swatch.style.transform = 'scale(1.2)'; | |
| const hex = swatch.dataset.color; | |
| const [r,g,b] = _hexToRgbArr(hex); | |
| [curH,curS,curV] = _rgbToHsv(r,g,b); | |
| hueSlider.value = curH; | |
| selectedColor = hex; | |
| preview.style.background = hex; | |
| inputHex.value = hex.replace('#','').toUpperCase(); | |
| _drawSquare(); | |
| }); | |
| }); | |
| popover.style.position = 'fixed'; | |
| popover.style.zIndex = '999999'; // ensure it's above everything | |
| let top = rect.bottom + 10; | |
| // Use hardcoded popover width (280 + 32 padding) to prevent offsetWidth returning 0 during animation | |
| const pWidth = 312; | |
| let left = rect.left - (pWidth / 2) + 14; | |
| const cw = document.documentElement.clientWidth; | |
| const ch = document.documentElement.clientHeight; | |
| if (left + pWidth > cw) left = cw - pWidth - 20; | |
| if (left < 20) left = 20; | |
| // Prevent bottom overflow | |
| const pHeight = 250; // approximate height | |
| if (top + pHeight > ch) { | |
| top = rect.top - pHeight - 10; | |
| } | |
| popover.style.top = `${top}px`; | |
| popover.style.left = `${left}px`; | |
| popover.style.touchAction = 'none'; // Prevent scrolling while dragging | |
| // --- Drag Support --- | |
| let isDragging = false; | |
| let dragStartX = 0; | |
| let dragStartY = 0; | |
| let popoverStartLeft = 0; | |
| let popoverStartTop = 0; | |
| const dragStart = (e) => { | |
| // Don't drag if clicking inside input/button/grid/canvas | |
| if (e.target.closest('input, button, .icon-grid-item, canvas')) return; | |
| isDragging = true; | |
| const clientX = e.touches ? e.touches[0].clientX : e.clientX; | |
| const clientY = e.touches ? e.touches[0].clientY : e.clientY; | |
| dragStartX = clientX; | |
| dragStartY = clientY; | |
| popoverStartLeft = parseFloat(popover.style.left); | |
| popoverStartTop = parseFloat(popover.style.top); | |
| }; | |
| const dragMove = (e) => { | |
| if (!isDragging) return; | |
| e.preventDefault(); // Stop scrolling | |
| const clientX = e.touches ? e.touches[0].clientX : e.clientX; | |
| const clientY = e.touches ? e.touches[0].clientY : e.clientY; | |
| const dx = clientX - dragStartX; | |
| const dy = clientY - dragStartY; | |
| popover.style.left = `${popoverStartLeft + dx}px`; | |
| popover.style.top = `${popoverStartTop + dy}px`; | |
| }; | |
| const dragEnd = () => { | |
| isDragging = false; | |
| }; | |
| popover.addEventListener('mousedown', dragStart); | |
| document.addEventListener('mousemove', dragMove); | |
| document.addEventListener('mouseup', dragEnd); | |
| popover.addEventListener('touchstart', dragStart, { passive: false }); | |
| document.addEventListener('touchmove', dragMove, { passive: false }); | |
| document.addEventListener('touchend', dragEnd); | |
| // -------------------- | |
| popover.querySelector('#popover-save').addEventListener('click', () => { | |
| const newColor = selectedColor; | |
| db.saveFolderMetadata(path, selectedEmoji, newColor); | |
| popover.remove(); | |
| // Update inline immediately without rebuilding entire grid | |
| const folderBtn = document.querySelector(`.folder-edit-btn[data-path="${path}"]`); | |
| if (folderBtn) { | |
| const folderEl = folderBtn.closest('.vault-folder'); | |
| if (folderEl) { | |
| folderEl.style.setProperty('--folder-color', newColor); | |
| folderEl.dataset.color = newColor; | |
| folderEl.dataset.emoji = selectedEmoji; | |
| folderBtn.dataset.color = newColor; | |
| folderBtn.dataset.emoji = selectedEmoji; | |
| // Update icon if in grid or detail | |
| const iconEl = folderEl.querySelector('.vault-folder-icon'); | |
| if (iconEl) { | |
| import('../../utils/icons.js?v=2').then(({ getIconSvg }) => { | |
| iconEl.innerHTML = getIconSvg(selectedEmoji, { class: 'folder-icon-engraved', width: '36', height: '36' }); | |
| }).catch(() => {}); | |
| } | |
| } | |
| } | |
| // Only rebuild if not in detail view to prevent destroying the animating elements | |
| if (this.viewState !== 'detail') { | |
| this.loadFiles(); | |
| } | |
| }); | |
| const closePopover = (e) => { | |
| // Don't close if we were just dragging | |
| if (isDragging) return; | |
| if (!popover.contains(e.target) && !btnEl.contains(e.target)) { | |
| popover.remove(); | |
| document.removeEventListener('click', closePopover); | |
| document.removeEventListener('mousemove', dragMove); | |
| document.removeEventListener('mouseup', dragEnd); | |
| document.removeEventListener('touchmove', dragMove); | |
| document.removeEventListener('touchend', dragEnd); | |
| } | |
| }; | |
| setTimeout(() => { | |
| document.addEventListener('click', closePopover); | |
| }, 10); | |
| } | |
| // ─── Modals (unchanged) ───────────────────────────────── | |
| showCustomConfirm(title, text, onConfirm, confirmText = 'Löschen') { | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'custom-modal-overlay'; | |
| overlay.innerHTML = ` | |
| <div class="custom-modal"> | |
| <h3>${title}</h3> | |
| <p>${text}</p> | |
| <div class="modal-actions"> | |
| <button class="modal-btn cancel">Abbrechen</button> | |
| <button class="modal-btn confirm danger">${confirmText}</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(overlay); | |
| const close = () => { | |
| overlay.classList.add('fade-out'); | |
| setTimeout(() => overlay.remove(), 200); | |
| }; | |
| overlay.querySelector('.cancel').onclick = close; | |
| overlay.querySelector('.confirm').onclick = () => { | |
| onConfirm(); | |
| close(); | |
| }; | |
| } | |
| showCustomPrompt(title, onConfirm) { | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'custom-modal-overlay'; | |
| overlay.innerHTML = ` | |
| <div class="custom-modal"> | |
| <h3>${title}</h3> | |
| <input type="text" id="custom-prompt-input" style="width: 100%; padding: 12px; border-radius: 12px; background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.1); color: white; margin-bottom: 20px; outline: none; font-size: 16px;" placeholder="Ordnername..." autocomplete="off"> | |
| <div class="modal-actions"> | |
| <button class="modal-btn cancel">Abbrechen</button> | |
| <button class="modal-btn confirm primary">Erstellen</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(overlay); | |
| const input = overlay.querySelector('#custom-prompt-input'); | |
| input.focus(); | |
| const close = () => { | |
| overlay.classList.add('fade-out'); | |
| setTimeout(() => overlay.remove(), 200); | |
| }; | |
| overlay.querySelector('.cancel').onclick = close; | |
| overlay.querySelector('.confirm').onclick = () => { | |
| onConfirm(input.value); | |
| close(); | |
| }; | |
| input.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { onConfirm(input.value); close(); } | |
| if (e.key === 'Escape') close(); | |
| }); | |
| } | |
| showTrashModal() { | |
| let items = []; | |
| try { | |
| items = db.getTrashItems(); | |
| } catch (err) { | |
| console.error('Error fetching trash items:', err); | |
| alert('Error fetching trash items: ' + err.message); | |
| return; | |
| } | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'custom-modal-overlay'; | |
| let listHtml = ''; | |
| if (items.length === 0) { | |
| listHtml = '<div style="text-align: center; color: var(--text-secondary); padding: 20px;">Der Papierkorb ist leer.</div>'; | |
| } else { | |
| listHtml = ` | |
| <div style="max-height: 300px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px;"> | |
| ${items.map(item => { | |
| const dateStr = new Date(item.deleted_at).toLocaleDateString(); | |
| const icon = item.is_dir ? '📁' : '📄'; | |
| return ` | |
| <div class="liquid-glass" style="display: flex; align-items: center; justify-content: space-between; padding: 12px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.05);"> | |
| <div style="display: flex; align-items: center; gap: 12px; overflow: hidden;"> | |
| <span style="font-size: 20px;">${icon}</span> | |
| <div style="display: flex; flex-direction: column; overflow: hidden;"> | |
| <span style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${item.original_path.split('/').pop()}</span> | |
| <span style="font-size: 12px; color: var(--text-secondary);">Gelöscht am: ${dateStr}</span> | |
| </div> | |
| </div> | |
| <div style="display: flex; gap: 8px;"> | |
| <button class="icon-btn restore-btn" data-id="${item.id}" title="Wiederherstellen" style="width: 32px; height: 32px; background: rgba(255,255,255,0.1); border-radius: 8px; cursor: pointer;">↩️</button> | |
| <button class="icon-btn perm-delete-btn" data-id="${item.id}" data-isdir="${item.is_dir}" title="Endgültig löschen" style="width: 32px; height: 32px; background: rgba(255,59,48,0.2); color: #ff3b30; border-radius: 8px; cursor: pointer;">❌</button> | |
| </div> | |
| </div> | |
| `; | |
| }).join('')} | |
| </div> | |
| `; | |
| } | |
| overlay.innerHTML = ` | |
| <div class="custom-modal" style="width: 90%; max-width: 500px;"> | |
| <h3 style="display: flex; align-items: center; justify-content: space-between;"> | |
| <span>🗑️ Papierkorb (30 Tage)</span> | |
| ${items.length > 0 ? `<button id="empty-trash-btn" style="background: transparent; border: none; color: #ff3b30; cursor: pointer; font-size: 14px; font-weight: 600;">Alle leeren</button>` : ''} | |
| </h3> | |
| <p style="margin-bottom: 16px; color: var(--text-secondary); font-size: 14px;">Gelöschte Elemente werden nach 30 Tagen endgültig gelöscht.</p> | |
| ${listHtml} | |
| <div class="modal-actions" style="margin-top: 24px;"> | |
| <button class="modal-btn close-trash-btn" style="width: 100%; background: rgba(255,255,255,0.1);">Schließen</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(overlay); | |
| const close = () => { | |
| overlay.classList.add('fade-out'); | |
| setTimeout(() => overlay.remove(), 200); | |
| this.loadFiles(); | |
| }; | |
| overlay.querySelector('.close-trash-btn').onclick = close; | |
| const emptyBtn = overlay.querySelector('#empty-trash-btn'); | |
| if (emptyBtn) { | |
| emptyBtn.onclick = async () => { | |
| this.showCustomConfirm('Papierkorb leeren', 'Wirklich den gesamten Papierkorb endgültig leeren?', async () => { | |
| for (const item of items) { | |
| await fsService.deletePath(`.trash/${item.id}`, item.is_dir === 1); | |
| db.removeTrashItem(item.id); | |
| if (item.is_dir === 1) db.deleteFolderMetadata(item.original_path); | |
| } | |
| close(); | |
| this.showTrashModal(); | |
| }); | |
| }; | |
| } | |
| overlay.querySelectorAll('.restore-btn').forEach(btn => { | |
| btn.onclick = async () => { | |
| const id = btn.dataset.id; | |
| btn.style.opacity = '0.5'; | |
| await fsService.restoreFromTrash(id); | |
| close(); | |
| this.showTrashModal(); | |
| }; | |
| }); | |
| overlay.querySelectorAll('.perm-delete-btn').forEach(btn => { | |
| btn.onclick = async () => { | |
| const id = btn.dataset.id; | |
| const isDir = btn.dataset.isdir === '1'; | |
| const item = items.find(i => i.id === id); | |
| this.showCustomConfirm('Endgültig löschen', 'Dieses Element unwiderruflich löschen?', async () => { | |
| btn.style.opacity = '0.5'; | |
| await fsService.deletePath(`.trash/${id}`, isDir); | |
| db.removeTrashItem(id); | |
| if (isDir && item) db.deleteFolderMetadata(item.original_path); | |
| close(); | |
| this.showTrashModal(); | |
| }); | |
| }; | |
| }); | |
| } | |
| } | |