Spaces:
Running
Running
Download src/app.js from Luca448/APP-Backend: direct link, hf CLI and curl.
- Browser
- Download file 70.5 kB
-
https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/src/app.js
- Command line
-
hf download hf://spaces/Luca448/APP-Backend/src/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/src/app.js
70.5 kB
| import { ChatView } from './views/chat/chat-view.js?v=13'; | |
| import { db } from './db/database.js'; | |
| import { Clipboard } from '@capacitor/clipboard'; | |
| import { authService } from './services/auth-service.js'; | |
| import { syncManager } from './services/sync-service.js'; | |
| import { ttsService } from './services/tts-service.js'; | |
| import { appShortcutsService } from './services/app-shortcuts-service.js'; | |
| import { showVocabularyModal } from './views/vocabulary-modal.js'; | |
| import { TextToSpeech } from '@capacitor-community/text-to-speech'; | |
| // Handle PWA Service Worker | |
| if ('serviceWorker' in navigator) { | |
| if (window.Capacitor) { | |
| // IMPORTANT: If we are in the native Capacitor app, we MUST NOT use a Service Worker. | |
| // A Service Worker here would aggressively cache local files and prevent app updates! | |
| navigator.serviceWorker.getRegistrations().then(function(registrations) { | |
| for(let registration of registrations) { | |
| registration.unregister(); | |
| console.log('Successfully unregistered old PWA Service Worker in Capacitor'); | |
| } | |
| }); | |
| } else { | |
| // We are in a normal browser (e.g. HuggingFace). Register the PWA Service Worker. | |
| import('virtual:pwa-register').then(({ registerSW }) => { | |
| registerSW({ immediate: true }); | |
| }).catch(err => console.warn('PWA Registration failed', err)); | |
| } | |
| } | |
| // Handle App Shortcuts and Intents globally | |
| window.addEventListener('appShortcutAction', (e) => { | |
| if (e.detail && e.detail.shortcutId === 'add_vocabulary') { | |
| showVocabularyModal(); | |
| } | |
| }); | |
| window.ttsService = ttsService; | |
| window.TextToSpeech = TextToSpeech; | |
| window.appPrompt = function(message) { | |
| return new Promise(resolve => { | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'custom-modal-overlay'; | |
| overlay.innerHTML = ` | |
| <div class="custom-modal"> | |
| <h3>Eingabe</h3> | |
| <p>${message}</p> | |
| <input type="text" style="width:100%; padding:10px; border-radius:8px; border:1px solid rgba(255,255,255,0.2); background:rgba(0,0,0,0.5); color:#fff; margin-top:8px; outline:none;" id="app-prompt-input" autocomplete="off" /> | |
| <div class="modal-actions"> | |
| <button class="modal-btn cancel">Abbrechen</button> | |
| <button class="modal-btn confirm">Speichern</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(overlay); | |
| const input = overlay.querySelector('#app-prompt-input'); | |
| const cancelBtn = overlay.querySelector('.cancel'); | |
| const confirmBtn = overlay.querySelector('.confirm'); | |
| input.focus(); | |
| const close = (val) => { | |
| input.blur(); | |
| overlay.classList.add('fade-out'); | |
| setTimeout(() => { overlay.remove(); resolve(val); }, 300); | |
| }; | |
| cancelBtn.onclick = () => close(null); | |
| confirmBtn.onclick = () => close(input.value.trim() ? input.value.trim() : null); | |
| input.onkeydown = (e) => { | |
| if (e.key === 'Enter') close(input.value.trim() ? input.value.trim() : null); | |
| if (e.key === 'Escape') close(null); | |
| }; | |
| }); | |
| }; | |
| window.appConfirm = function(message, confirmText = "Löschen", cancelText = "Abbrechen") { | |
| return new Promise(resolve => { | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'custom-modal-overlay'; | |
| overlay.innerHTML = ` | |
| <div class="custom-modal"> | |
| <h3>Bestätigung</h3> | |
| <p>${message}</p> | |
| <div class="modal-actions" style="margin-top: 16px;"> | |
| <button class="modal-btn cancel">${cancelText}</button> | |
| <button class="modal-btn confirm" style="background: var(--destructive); border-color: var(--destructive); color: white;">${confirmText}</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(overlay); | |
| const cancelBtn = overlay.querySelector('.cancel'); | |
| const confirmBtn = overlay.querySelector('.confirm'); | |
| const close = (val) => { | |
| overlay.classList.add('fade-out'); | |
| setTimeout(() => { overlay.remove(); resolve(val); }, 300); | |
| }; | |
| cancelBtn.onclick = () => close(false); | |
| confirmBtn.onclick = () => close(true); | |
| }); | |
| }; | |
| window.copyToClipboard = async (text, btnElement) => { | |
| const showCheckmark = () => { | |
| if (!btnElement) return; | |
| const svg = btnElement.querySelector('svg'); | |
| if (!svg) return; | |
| const oldHtml = svg.outerHTML; | |
| svg.outerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'; | |
| setTimeout(() => { | |
| btnElement.innerHTML = oldHtml; | |
| }, 2000); | |
| }; | |
| const formatForNotes = (raw) => { | |
| if (!raw) return raw; | |
| // 1. Strip inline formatting and block markers | |
| let text = raw | |
| .replace(/\*\*(.*?)\*\*/g, '$1') | |
| .replace(/__(.*?)__/g, '$1') | |
| .replace(/\*(.*?)\*/g, '$1') | |
| .replace(/_(.*?)_/g, '$1') | |
| .replace(/~~(.*?)~~/g, '$1') | |
| .replace(/`(.*?)`/g, '$1') | |
| .replace(/\[(.*?)\]\(.*?\)/g, '$1') | |
| .replace(/^```[a-z]*\n/gm, '') | |
| .replace(/^```\s*$/gm, '') | |
| .replace(/^>\s+/gm, ''); | |
| const lines = text.split('\n'); | |
| const processedLines = []; | |
| let previousRealLineWasList = false; | |
| let emptyLinesSeen = 0; | |
| for (let i = 0; i < lines.length; i++) { | |
| let line = lines[i].trim(); | |
| if (line === '') { | |
| emptyLinesSeen++; | |
| continue; | |
| } | |
| const headerMatch = line.match(/^#+\s+(.*)$/); | |
| const listMatch = line.match(/^([\*\-]|\d+\.)\s+(.*)$/); | |
| if (headerMatch) { | |
| // Empty line before header | |
| if (processedLines.length > 0 && processedLines[processedLines.length - 1] !== '') { | |
| processedLines.push(''); | |
| } | |
| processedLines.push(headerMatch[1]); | |
| // Empty line after header | |
| processedLines.push(''); | |
| previousRealLineWasList = false; | |
| emptyLinesSeen = 0; | |
| } else if (listMatch) { | |
| if (!previousRealLineWasList) { | |
| // Empty line before first list item | |
| if (processedLines.length > 0 && processedLines[processedLines.length - 1] !== '') { | |
| processedLines.push(''); | |
| } | |
| } | |
| const bullet = (listMatch[1] === '*' || listMatch[1] === '-') ? '• ' : `${listMatch[1]} `; | |
| processedLines.push(`${bullet}${listMatch[2]}`); | |
| previousRealLineWasList = true; | |
| emptyLinesSeen = 0; | |
| } else { | |
| // Normal text | |
| if (previousRealLineWasList) { | |
| // Ensure empty line after list | |
| if (processedLines.length > 0 && processedLines[processedLines.length - 1] !== '') { | |
| processedLines.push(''); | |
| } | |
| } else if (emptyLinesSeen > 0 && processedLines.length > 0) { | |
| // Maintain paragraph breaks for text | |
| if (processedLines[processedLines.length - 1] !== '') { | |
| processedLines.push(''); | |
| } | |
| } | |
| processedLines.push(line); | |
| previousRealLineWasList = false; | |
| emptyLinesSeen = 0; | |
| } | |
| } | |
| return processedLines.join('\n').trim(); | |
| }; | |
| const formattedText = formatForNotes(text); | |
| try { | |
| await Clipboard.write({ string: formattedText }); | |
| showCheckmark(); | |
| } catch (e) { | |
| console.log('Capacitor Clipboard fallback:', e); | |
| // Legacy fallback for non-secure HTTP contexts | |
| try { | |
| if (navigator.clipboard && navigator.clipboard.writeText) { | |
| await navigator.clipboard.writeText(formattedText); | |
| } else { | |
| const textArea = document.createElement("textarea"); | |
| textArea.value = formattedText; | |
| textArea.style.position = "fixed"; | |
| textArea.style.left = "-999999px"; | |
| textArea.style.top = "-999999px"; | |
| document.body.appendChild(textArea); | |
| textArea.focus(); | |
| textArea.select(); | |
| document.execCommand('copy'); | |
| textArea.remove(); | |
| } | |
| showCheckmark(); | |
| } catch (err) { | |
| console.error('Copy failed:', err); | |
| } | |
| } | |
| }; | |
| const initApp = async () => { | |
| // FAST PATH: Check for overlay mode IMMEDIATELY, before any heavy initialization. | |
| // This cuts ~1-2 seconds off overlay startup by skipping auth, sync, etc. | |
| const isNative = window.Capacitor && typeof window.Capacitor.isNativePlatform === 'function' && window.Capacitor.isNativePlatform(); | |
| if (isNative) { | |
| const { QuickSettings } = window.Capacitor.Plugins; | |
| if (QuickSettings) { | |
| const intentResult = await QuickSettings.getPendingIntentAction(); | |
| if (intentResult && intentResult.overlay_mode === true) { | |
| // Force transparent on EVERY layer via cssText (highest inline priority) | |
| document.documentElement.style.cssText = 'background: transparent !important; background-color: transparent !important;'; | |
| document.body.style.cssText = 'background: transparent !important; background-color: transparent !important; margin: 0; padding: 0; visibility: visible !important;'; | |
| // Hide ALL non-modal elements | |
| const hideSelectors = ['#app', '.aurora-background', '#ambient-light', '#live-version-badge', 'svg[style*="pointer-events: none"]']; | |
| hideSelectors.forEach(sel => { | |
| const el = document.querySelector(sel); | |
| if (el) el.style.display = 'none'; | |
| }); | |
| window.isOverlayMode = true; | |
| await db.initPromise; | |
| showVocabularyModal(); | |
| // Signal native side to fade in the window (prevents black flash) | |
| QuickSettings.overlayReady(); | |
| return; // Early return — skip ALL heavy initialization | |
| } | |
| } | |
| } | |
| // NORMAL APP PATH: Full initialization | |
| await db.initPromise; | |
| await authService.initialize(); | |
| await syncManager.initialize(); | |
| await appShortcutsService.initialize(); | |
| // Load background style | |
| const bgStyle = db.getSetting('background_style') || 'standard'; | |
| document.body.setAttribute('data-bg-style', bgStyle); | |
| // Make body visible for normal app | |
| document.body.style.visibility = 'visible'; | |
| // Process any pending vocabulary from the Quick Settings native overlay | |
| const processOfflineVocabularyQueue = async () => { | |
| if (!isNative) return; | |
| try { | |
| const { QuickSettings } = window.Capacitor.Plugins; | |
| if (QuickSettings) { | |
| const { queue } = await QuickSettings.getPendingVocab(); | |
| if (queue && queue !== '[]') { | |
| const words = JSON.parse(queue); | |
| if (words.length > 0) { | |
| console.log("Processing offline vocabulary queue:", words); | |
| // Start background translation process | |
| setTimeout(async () => { | |
| const { aiRouter } = await import('./ai/router.js'); | |
| let addedCount = 0; | |
| for (const word of words) { | |
| try { | |
| const prompt = `Übersetze das folgende Wort oder den Satz. Wenn es Deutsch ist, übersetze es ins Englische. Wenn es Englisch ist, übersetze es ins Deutsche. | |
| Falls das Wort extrem falsch geschrieben ist (z.B. phonetisch nach Gehör geschrieben), erkenne das korrekte Wort automatisch und übersetze es. | |
| Das korrekte Wort soll in "front" stehen und die Übersetzung in "back". | |
| Zusätzlich generiere: | |
| - "explanation": 1-2 kurze Sätze in der Sprache der Übersetzung ('back'), die genau dieses Wort erklären. WICHTIG: Markiere das Lernwort (und seine Abwandlungen) IMMER fett mit HTML-Tags (z.B. <b>Wort</b>). | |
| - "example": Ein prägnanter Beispielsatz mit der Übersetzung ('back'). Das Lernwort MUSS auch hier mit <b>...</b> fett markiert sein. | |
| - "imageQuery": Ein präziser englischer Suchbegriff für die Bing-Bildersuche. Das Bild muss EINDEUTIG zur Übersetzung passen. Füge IMMER 'single photo, clear focus, high quality' am Ende hinzu. Keine abstrakten Cartoons. | |
| Antworte AUSSCHLIESSLICH mit einem validen JSON-Objekt. Keine Markdown-Formatierung. | |
| Beispiel für "abanden": | |
| {"front": "abandon", "back": "verlassen", "explanation": "Jemanden oder etwas <b>verlassen</b> oder aufgeben.", "example": "Er musste das alte Auto <b>verlassen</b>.", "imageQuery": "abandoned old car single photo clear focus"} | |
| Zu übersetzen: "${word}"`; | |
| const response = await aiRouter.sendMessage(prompt, { | |
| model: db.getSetting('model') || 'gemini-1.5-flash' | |
| }); | |
| let jsonStr = response.trim(); | |
| if (jsonStr.startsWith('```json')) jsonStr = jsonStr.replace(/```json/g, '').replace(/```/g, '').trim(); | |
| else if (jsonStr.startsWith('```')) jsonStr = jsonStr.replace(/```/g, '').trim(); | |
| let parsed = null; | |
| try { | |
| parsed = JSON.parse(jsonStr); | |
| } catch (e) { | |
| console.warn("JSON parse failed, trying fallback...", jsonStr); | |
| parsed = { front: word, back: response.replace(/\{|\}/g, '').replace(/"/g, '').replace(/back:|front:|\\n/gi, '').trim(), explanation: "", example: "", imageQuery: encodeURIComponent(word) }; | |
| } | |
| if (parsed && parsed.back) { | |
| let decks = db.getDecks(); | |
| let schnelldeck = decks.find(d => d.title === 'Schnelleingabe' || d.title === 'Inbox'); | |
| if (!schnelldeck) schnelldeck = db.createDeck('Schnelleingabe', 'Automatisch gespeicherte Vokabeln aus der Schnelleingabe', '#8a53e6', 'Englisch', 'Deutsch'); | |
| let finalBack = parsed.back; | |
| if (parsed.explanation && parsed.example) { | |
| finalBack = `${parsed.back}\n\n**Erklärung:** ${parsed.explanation}\n\n**Beispiel:** ${parsed.example}`; | |
| } | |
| const searchTerms = encodeURIComponent(parsed.imageQuery || parsed.front); | |
| const imageUrl = `https://tse1.mm.bing.net/th?q=${searchTerms}&w=800&h=1000&c=7&rs=1`; | |
| db.addFlashcard(schnelldeck.id, parsed.front, finalBack, imageUrl); | |
| addedCount++; | |
| } | |
| } catch (e) { | |
| console.error("Failed to translate offline word:", word, e); | |
| } | |
| } | |
| await QuickSettings.clearPendingVocab(); | |
| if (addedCount > 0) { | |
| // Dispatch event so UI can refresh if user is on flashcards page | |
| window.dispatchEvent(new CustomEvent('flashcards-updated')); | |
| // Show a small toast notification | |
| const toast = document.createElement('div'); | |
| toast.style = "position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: rgba(52, 199, 89, 0.95); color: white; padding: 12px 24px; border-radius: 30px; font-weight: 600; font-size: 15px; z-index: 99999; box-shadow: 0 8px 24px rgba(0,0,0,0.3); animation: slide-down 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);"; | |
| toast.innerHTML = `✨ ${addedCount} Vokabel(n) hinzugefügt!`; | |
| document.body.appendChild(toast); | |
| setTimeout(() => { | |
| toast.style.opacity = '0'; | |
| toast.style.transition = 'opacity 0.4s'; | |
| setTimeout(() => toast.remove(), 400); | |
| }, 4000); | |
| } | |
| }, 1000); // Wait 1s instead of 2s | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| console.warn("Error reading pending vocab queue:", e); | |
| } | |
| }; | |
| const processEnrichmentQueue = async () => { | |
| // Only run if we're not in the tiny transparent overlay mode | |
| if (window.isOverlayMode) return; | |
| let pendingCards = db.getPendingCards(); | |
| if (!pendingCards || pendingCards.length === 0) return; | |
| if (!window.isEnriching) { | |
| window.isEnriching = true; | |
| try { | |
| const { aiRouter } = await import('./ai/router.js'); | |
| // Process sequentially | |
| for (const card of pendingCards) { | |
| const word = card.front; | |
| const pureTranslation = card.back.replace('<!-- PENDING_CORRECTION -->', '').replace('<!-- NEEDS_ENRICHMENT -->', '').trim(); | |
| const prompt = `Erstelle eine kurze Erklärung und ein passendes Beispiel für diese Vokabelkarte, sowie einen Suchbegriff für ein Bild. | |
| Wort: "${word}" | |
| Übersetzung: "${pureTranslation}" | |
| WICHTIG: Prüfe, ob das "Wort" offensichtlich falsch geschrieben ist (z.B. phonetisch nach Gehör geschrieben). | |
| Falls es einen Tippfehler enthält, gib das richtig geschriebene Wort im Feld "correctedWord" zurück. Wenn es korrekt geschrieben ist, lass das Feld weg oder setze es auf null. | |
| Generiere: | |
| - "explanation": 1-2 kurze Sätze in der Sprache der Übersetzung, die genau dieses Wort erklären. WICHTIG: Markiere das Lernwort (und seine Abwandlungen) IMMER fett mit HTML-Tags (z.B. <b>Wort</b>). | |
| - "example": Ein prägnanter Beispielsatz mit der Übersetzung. Das Lernwort MUSS auch hier mit <b>...</b> fett markiert sein. | |
| - "imageQuery": Ein präziser englischer Suchbegriff für die Bing-Bildersuche. Das Bild muss EINDEUTIG zur Übersetzung passen. Füge IMMER 'single photo, clear focus, high quality' am Ende hinzu. Keine abstrakten Cartoons. | |
| Antworte AUSSCHLIESSLICH mit einem validen JSON-Objekt. Keine Markdown-Formatierung. | |
| Beispiel für Tippfehler bei "abanden": | |
| {"correctedWord": "abandon", "explanation": "Jemanden oder etwas <b>verlassen</b> oder aufgeben.", "example": "Er musste das alte Auto <b>verlassen</b>.", "imageQuery": "abandoned old car single photo clear focus"}`; | |
| const response = await aiRouter.sendMessage(prompt, { | |
| model: db.getSetting('model') || 'gemini-1.5-flash', // Use standard model to ensure it doesn't fail | |
| disableTools: true // VERY IMPORTANT: Prevent AI from calling create_flashcards and duplicating the deck | |
| }); | |
| let jsonStr = response.trim(); | |
| if (jsonStr.startsWith('```json')) jsonStr = jsonStr.replace(/```json/g, '').replace(/```/g, '').trim(); | |
| else if (jsonStr.startsWith('```')) jsonStr = jsonStr.replace(/```/g, '').trim(); | |
| try { | |
| const parsed = JSON.parse(jsonStr); | |
| if (parsed.explanation && parsed.example) { | |
| const finalBack = `${pureTranslation}\n\n**Erklärung:** ${parsed.explanation}\n\n**Beispiel:** ${parsed.example}`; | |
| const searchTerms = encodeURIComponent(parsed.imageQuery || word); | |
| const imageUrl = `https://tse1.mm.bing.net/th?q=${searchTerms}&w=800&h=1000&c=7&rs=1`; | |
| // If the AI corrected a typo, use it. Otherwise use the original front. | |
| const finalFront = parsed.correctedWord ? parsed.correctedWord : card.front; | |
| db.editFlashcard(card.id, finalFront, finalBack, imageUrl); | |
| console.log(`Enriched card: ${word}`); | |
| } | |
| } catch (e) { | |
| console.warn("JSON parse failed for enrichment", jsonStr); | |
| } | |
| // Small pause between requests to avoid rate limits | |
| await new Promise(r => setTimeout(r, 500)); | |
| } | |
| // Dispatch event ONCE after all retroactive enrichment is done, to prevent UI scrolling spam | |
| window.dispatchEvent(new CustomEvent('flashcards-updated')); | |
| if (document.visibilityState === 'visible') { | |
| const toast = document.createElement('div'); | |
| toast.style = "position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: rgba(52, 199, 89, 0.95); color: white; padding: 12px 24px; border-radius: 30px; font-weight: 600; font-size: 15px; z-index: 99999; box-shadow: 0 8px 24px rgba(0,0,0,0.3); animation: slide-down 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);"; | |
| toast.innerHTML = `✨ Vokabel im Hintergrund geprüft und vervollständigt!`; | |
| document.body.appendChild(toast); | |
| setTimeout(() => { | |
| toast.style.opacity = '0'; | |
| toast.style.transition = 'opacity 0.4s ease'; | |
| setTimeout(() => toast.remove(), 400); | |
| }, 3000); | |
| } | |
| } catch (e) { | |
| console.error("Enrichment queue failed", e); | |
| } finally { | |
| window.isEnriching = false; | |
| } | |
| } | |
| }; | |
| // Run on startup | |
| processOfflineVocabularyQueue(); | |
| processEnrichmentQueue(); | |
| // Check if we were launched with a native Intent (non-overlay) | |
| if (isNative) { | |
| const { QuickSettings } = window.Capacitor.Plugins; | |
| if (QuickSettings) { | |
| const intentResult = await QuickSettings.getPendingIntentAction(); | |
| if (intentResult && intentResult.action === 'add_vocabulary') { | |
| showVocabularyModal(); | |
| } | |
| } | |
| } | |
| // Run whenever the app resumes (comes to foreground) | |
| document.addEventListener("visibilitychange", async () => { | |
| if (document.visibilityState === 'visible') { | |
| processOfflineVocabularyQueue(); | |
| processEnrichmentQueue(); | |
| if (isNative) { | |
| const { QuickSettings } = window.Capacitor.Plugins; | |
| if (QuickSettings) { | |
| const intentResult = await QuickSettings.getPendingIntentAction(); | |
| if (intentResult && intentResult.overlay_mode === true) { | |
| document.body.style.background = 'transparent'; | |
| document.body.style.backgroundColor = 'transparent'; | |
| const appElement = document.getElementById('app'); | |
| if (appElement) appElement.style.display = 'none'; | |
| window.isOverlayMode = true; | |
| showVocabularyModal(); | |
| } else if (intentResult && intentResult.action === 'add_vocabulary') { | |
| showVocabularyModal(); | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| // Scale up UI on web (HuggingFace) to match Capacitor native scale | |
| const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); | |
| if (!isNative && isMobile) { | |
| document.documentElement.style.zoom = '1.15'; | |
| } | |
| console.log('SchoolMind AI initialized'); | |
| const appContainer = document.getElementById('app'); | |
| // Basic skeleton for the layout | |
| appContainer.innerHTML = ` | |
| <!-- Sidebar Drawer --> | |
| <div id="sidebar-overlay" class="sidebar-overlay"></div> | |
| <div id="history-sidebar" class="sidebar-drawer"> | |
| <div class="sidebar-inner"> | |
| <div class="sidebar-header"> | |
| <h2>Verlauf</h2> | |
| <div style="display: flex; gap: 8px;"> | |
| <button id="fullscreen-sidebar-btn" class="icon-btn" style="font-size: 16px; width: 32px; height: 32px;">⛶</button> | |
| <button id="close-sidebar-btn" class="icon-btn" style="font-size: 24px; width: 32px; height: 32px;">×</button> | |
| </div> | |
| </div> | |
| <div style="padding: 0 16px 12px 16px;"> | |
| <input type="text" id="history-search-input" placeholder="Chats durchsuchen..." style="width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.3); color: white; outline: none; font-size: 14px; box-shadow: inset 0 2px 4px rgba(0,0,0,0.2);"> | |
| </div> | |
| <div id="history-list" class="history-list"> | |
| <!-- History items will be injected here --> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="view-container" id="router-view"> | |
| <!-- Active view will be mounted here --> | |
| </div> | |
| <!-- TTS Floating Player (Apple Music Style) --> | |
| <div id="tts-player-container" class="tts-player-container collapsed"> | |
| <div class="tts-player-inner liquid-glass-heavy" style="padding: 10px 16px;"> | |
| <div class="tts-player-drag-handle" style="margin-bottom: 8px;"></div> | |
| <div class="tts-player-content" style="justify-content: space-between; width: 100%; display: flex; align-items: center;"> | |
| <div style="flex: 1; display: flex; align-items: center;"> | |
| <button id="tts-cc-btn" class="icon-btn tts-btn active" title="Live-Untertitel" style="margin-left: 0; color: var(--primary);"> | |
| <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="10" rx="2" ry="2"></rect><path d="M7 11h2"></path><path d="M11 11h6"></path><path d="M7 13h6"></path><path d="M15 13h2"></path></svg> | |
| </button> | |
| </div> | |
| <div class="tts-player-controls" style="gap: 12px; flex: 0 0 auto; display: flex; justify-content: center; align-items: center;"> | |
| <button id="tts-prev-btn" class="icon-btn tts-btn"> | |
| <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><text x="12" y="16" font-size="8" text-anchor="middle" font-weight="bold" fill="currentColor" stroke="none">5</text></svg> | |
| </button> | |
| <button id="tts-play-btn" class="icon-btn tts-btn tts-play-btn" style="width: 36px; height: 36px;"> | |
| <svg id="tts-play-icon" viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><polygon points="7 3 21 12 7 21 7 3"></polygon></svg> | |
| </button> | |
| <button id="tts-next-btn" class="icon-btn tts-btn"> | |
| <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><text x="12" y="16" font-size="8" text-anchor="middle" font-weight="bold" fill="currentColor" stroke="none">5</text></svg> | |
| </button> | |
| </div> | |
| <div style="flex: 1; display: flex; justify-content: flex-end;"> | |
| <button id="tts-rate-btn" class="tts-rate-btn" style="margin-left: 0;">1x</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Input Capsule (Apple Style) --> | |
| <div class="input-capsule-container"> | |
| <!-- Scroll to bottom button --> | |
| <button id="scroll-to-bottom-btn" class="liquid-glass" style="display: none; position: absolute; top: -46px; left: 50%; transform: translateX(-50%); background: rgba(30, 30, 35, 0.85); color: white; border: 1px solid rgba(255,255,255,0.15); border-radius: 50%; width: 36px; height: 36px; padding: 0; justify-content: center; align-items: center; z-index: 100; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.3); transition: opacity 0.2s, transform 0.2s, top 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);"> | |
| <svg id="scroll-arrow-icon" viewBox="0 0 24 24" width="18" height="18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="margin-top: 2px;"><line x1="12" y1="4" x2="12" y2="20"></line><polyline points="18 14 12 20 6 14"></polyline></svg> | |
| <div id="scroll-typing-indicator" style="display: none; gap: 4px; align-items: center;"> | |
| <div style="width: 5px; height: 5px; background: white; border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both;"></div> | |
| <div style="width: 5px; height: 5px; background: white; border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both; animation-delay: 0.16s;"></div> | |
| <div style="width: 5px; height: 5px; background: white; border-radius: 50%; animation: bounce 1.4s infinite ease-in-out both; animation-delay: 0.32s;"></div> | |
| </div> | |
| </button> | |
| <!-- File Permission Popup --> | |
| <div id="file-permission-popup" style="display: none; position: absolute; top: -100px; left: 16px; right: 16px; background: rgba(30, 30, 35, 0.95); border-radius: 16px; border: 1px solid rgba(182, 120, 242, 0.3); padding: 12px 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.4); z-index: 101; backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); align-items: center; justify-content: space-between; gap: 12px; animation: slide-up 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);"> | |
| <div style="display: flex; flex-direction: column; flex: 1; overflow: hidden;"> | |
| <div style="font-size: 12px; font-weight: 600; color: #b678f2; margin-bottom: 2px;">KI Dateizugriff</div> | |
| <div id="file-permission-text" style="font-size: 13px; color: white; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">Gemini möchte Datei X lesen.</div> | |
| </div> | |
| <div style="display: flex; flex-direction: column; gap: 6px;"> | |
| <button id="file-permission-allow" style="background: #b678f2; color: white; border: none; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer;">Erlauben</button> | |
| <button id="file-permission-deny" style="background: rgba(255,255,255,0.1); color: white; border: none; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer;">Ablehnen</button> | |
| </div> | |
| </div> | |
| <div id="image-preview-container" style="display: none; padding: 12px 16px 16px 16px; z-index: 0; position: relative; gap: 12px; overflow-x: auto; white-space: nowrap; align-items: center;"> | |
| </div> | |
| <div class="input-capsule siri-capsule" style="z-index: 1; position: relative;"> | |
| <input type="file" id="image-upload-input" accept="image/*,application/pdf" multiple style="display: none;" /> | |
| <button class="icon-btn" id="plus-menu-btn" title="Menü"> | |
| <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.7;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> | |
| </button> | |
| <textarea placeholder="Nachricht an Gemini..." rows="1" id="chat-input"></textarea> | |
| <button class="icon-btn primary" id="send-btn"> | |
| <svg viewBox="0 0 24 24" width="26" height="26" stroke="currentColor" stroke-width="2" fill="none" style="margin-left: -2px; margin-top: 1px;"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg> | |
| </div> | |
| <!-- Plus Popover Menu --> | |
| <div id="plus-popover-menu" class="liquid-glass-heavy" style="display: none; position: absolute; bottom: calc(100% + 16px); left: 16px; border-radius: 16px; padding: 12px; min-width: 260px; z-index: 10; box-shadow: 0 16px 40px rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.08); flex-direction: column; gap: 4px; transform-origin: bottom left; animation: popover-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);"> | |
| <div style="font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; padding: 4px 8px; margin-bottom: 4px;">Aktionen</div> | |
| <button id="menu-attach-image-btn" style="display: flex; align-items: center; gap: 12px; padding: 10px 12px; background: transparent; border: none; color: white; border-radius: 10px; cursor: pointer; text-align: left; font-size: 14px; transition: background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.background='transparent'"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.7;"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg> | |
| Bild/Dokument anhängen | |
| </button> | |
| <div style="height: 1px; background: rgba(255,255,255,0.1); margin: 6px 0;"></div> | |
| <div style="font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; padding: 4px 8px; margin-bottom: 4px;">Features</div> | |
| <div style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 10px; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.background='transparent'" onclick="document.getElementById('deep-research-toggle').click()"> | |
| <div style="display: flex; align-items: center; gap: 12px; font-size: 14px;"> | |
| <span style="font-size: 16px;">✨</span> Vertiefte Recherche | |
| </div> | |
| <input type="checkbox" id="deep-research-toggle" class="ios-toggle" style="transform: scale(0.7); margin: 0; pointer-events: none;"> | |
| </div> | |
| <div id="detective-board-container" style="display: none; align-items: center; justify-content: space-between; padding: 10px 12px; padding-left: 38px; border-radius: 10px; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.background='transparent'" onclick="document.getElementById('detective-board-toggle').click()"> | |
| <div style="display: flex; align-items: center; gap: 12px; font-size: 14px; color: rgba(255,255,255,0.8);"> | |
| <span style="font-size: 16px;">🕵️</span> Detective Board | |
| </div> | |
| <input type="checkbox" id="detective-board-toggle" class="ios-toggle" style="transform: scale(0.7); margin: 0; pointer-events: none;" checked> | |
| </div> | |
| <!-- NEUER SLIDER / SELECT --> | |
| <div id="deep-research-time-container" style="display: none; padding: 0 12px 10px 38px; font-size: 12px; color: rgba(255,255,255,0.6);"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; gap: 8px;"> | |
| <span>Dauer (Tiefe):</span> | |
| <select id="deep-research-time-select" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 2px 6px; border-radius: 4px; outline: none; font-size: 12px;" onclick="event.stopPropagation()"> | |
| <option value="1">1 Minute (Schnell)</option> | |
| <option value="3" selected>3 Minuten (Standard)</option> | |
| <option value="5">5 Minuten (Tief)</option> | |
| <option value="10">10 Minuten (Extrem)</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 10px; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.background='transparent'" onclick="document.getElementById('extended-thinking-toggle').click()"> | |
| <div style="display: flex; align-items: center; gap: 12px; font-size: 14px;"> | |
| <span style="font-size: 16px;">🧠</span> Erweitertes Denken | |
| </div> | |
| <input type="checkbox" id="extended-thinking-toggle" class="ios-toggle" style="transform: scale(0.7); margin: 0; pointer-events: none;"> | |
| </div> | |
| <div style="height: 1px; background: rgba(255,255,255,0.1); margin: 6px 0;"></div> | |
| <div style="font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; padding: 4px 8px; margin-bottom: 4px;">Lokales Wissen</div> | |
| <div style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 10px; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.background='transparent'" onclick="document.getElementById('vault-search-toggle').click()"> | |
| <div style="display: flex; align-items: center; gap: 12px; font-size: 14px;"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.7;"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/></svg> | |
| Vault durchsuchen | |
| </div> | |
| <input type="checkbox" id="vault-search-toggle" class="ios-toggle" style="transform: scale(0.7); margin: 0; pointer-events: none;"> | |
| </div> | |
| <div style="height: 1px; background: rgba(255,255,255,0.1); margin: 6px 0;"></div> | |
| <div style="font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px; padding: 4px 8px; margin-bottom: 4px;">Stimme & Stil</div> | |
| <div style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 10px; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.background='transparent'" onclick="document.getElementById('plus-profile-toggle').click()"> | |
| <div style="display: flex; align-items: center; gap: 12px; font-size: 14px;"> | |
| <span style="font-size: 16px;">✍️</span> Persönlicher Style | |
| </div> | |
| <input type="checkbox" id="plus-profile-toggle" class="chat-profile-toggle ios-toggle" style="transform: scale(0.7); margin: 0; pointer-events: none;"> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Bottom Tab Bar --> | |
| <nav class="bottom-tab-bar"> | |
| <svg width="0" height="0" style="position: absolute;"> | |
| <defs> | |
| <linearGradient id="active-icon-gradient" x1="0%" y1="0%" x2="100%" y2="100%"> | |
| <stop offset="0%" stop-color="#c183f0" /> | |
| <stop offset="100%" stop-color="#8a53e6" /> | |
| </linearGradient> | |
| </defs> | |
| </svg> | |
| <div class="tab-item active" data-tab="Chat"> | |
| <svg viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.03 2 11c0 2.84 1.503 5.37 3.856 7.02L4 22l4.28-1.564A10.61 10.61 0 0012 20c5.523 0 10-4.03 10-9s-4.477-9-10-9z"/></svg> | |
| </div> | |
| <div class="tab-item" data-tab="News"> | |
| <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg> | |
| </div> | |
| <div class="tab-item" data-tab="Hub"> | |
| <svg viewBox="0 0 24 24"><path d="M4 4h6v6H4zm0 10h6v6H4zm10 0h6v6h-6zm0-10h6v6h-6z"/></svg> | |
| </div> | |
| <div class="tab-item" data-tab="Vault"> | |
| <!-- Changed to a solid folder icon for consistency with others --> | |
| <svg viewBox="0 0 24 24"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg> | |
| </div> | |
| <div class="tab-item" data-tab="Settings"> | |
| <svg viewBox="0 0 24 24"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.73,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.43-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.49-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg> | |
| </div> | |
| </nav> | |
| `; | |
| // View Instances | |
| const chatView = new ChatView('router-view'); | |
| try { | |
| await chatView.init(); | |
| } catch (err) { | |
| console.error('Failed to initialize ChatView:', err); | |
| // Continue execution so tabs still work even if chat fails | |
| } | |
| window.chatView = chatView; // Export globally for IServ View to trigger PDF modal | |
| window.openPdfModal = chatView.openPdfModal ? chatView.openPdfModal.bind(chatView) : null; | |
| window.openArticleModal = chatView.openArticleModal ? chatView.openArticleModal.bind(chatView) : null; | |
| // We lazily import or instantiate SettingsView when needed, or just import it at top | |
| // To keep it simple, we'll instantiate it directly here but handle rendering in tab clicks. | |
| // --- TAB NAVIGATION LOGIC --- | |
| const tabItems = document.querySelectorAll('.tab-item'); | |
| const inputCapsule = document.querySelector('.input-capsule-container'); | |
| const routerView = document.getElementById('router-view'); | |
| let activeView = null; // Track the currently active view to destroy it when switching | |
| tabItems.forEach(tab => { | |
| tab.addEventListener('click', async () => { | |
| const tabName = tab.getAttribute('data-tab'); | |
| // Remove active class from all | |
| tabItems.forEach(t => t.classList.remove('active')); | |
| // Add active class to clicked | |
| tab.classList.add('active'); | |
| await window.openView(tabName); | |
| }); | |
| }); | |
| const handleImportError = (e) => { | |
| if (e.message && e.message.includes('Failed to fetch dynamically imported module')) { | |
| // The app was updated and the old chunks are gone from the server. | |
| // Force a reload to fetch the new index.html and its new chunk map. | |
| routerView.innerHTML = `<div style="padding: 20px; color: white; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; text-align: center;"> | |
| <div style="font-size: 40px; margin-bottom: 20px;">🔄</div> | |
| <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px;">App wurde aktualisiert</div> | |
| <div style="font-size: 14px; opacity: 0.7;">Die neuste Version wird geladen...</div> | |
| </div>`; | |
| setTimeout(() => { | |
| window.location.reload(true); | |
| }, 1000); | |
| } else { | |
| routerView.innerHTML = `<div style="padding: 20px; color: red;">Error: ${e.message}</div>`; | |
| } | |
| }; | |
| let currentOpenViewCounter = 0; | |
| window.openView = async (viewName, isSubView = false) => { | |
| currentOpenViewCounter++; | |
| const myCounter = currentOpenViewCounter; | |
| // Cleanup the previous view | |
| if (activeView && typeof activeView.destroy === 'function') { | |
| activeView.destroy(); | |
| } | |
| // Safety fallback: always ensure chat is destroyed if we navigate away | |
| if (viewName !== 'Chat' && window.chatView) { | |
| window.chatView.destroy(); | |
| } | |
| activeView = null; | |
| document.body.classList.toggle('in-chat-view', viewName === 'Chat'); | |
| document.body.classList.toggle('in-hub-view', viewName === 'Hub'); | |
| document.body.classList.toggle('in-news-view', viewName === 'News'); | |
| document.body.classList.toggle('in-vault-view', viewName === 'Vault'); | |
| routerView.classList.remove('view-enter-anim'); | |
| routerView.classList.add('view-exit-anim'); | |
| await new Promise(resolve => setTimeout(resolve, 150)); | |
| // Abort if another view was opened while we were waiting | |
| if (currentOpenViewCounter !== myCounter) return; | |
| routerView.innerHTML = ''; | |
| // Reset scroll position when switching main tabs | |
| if (!isSubView) { | |
| routerView.scrollTop = 0; | |
| } | |
| if (viewName === 'Chat') { | |
| inputCapsule.style.display = 'block'; | |
| try { | |
| chatView.render(); | |
| chatView.loadMessages(); | |
| } catch(e) { | |
| handleImportError(e); | |
| } | |
| activeView = chatView; | |
| } | |
| else if (viewName === 'Settings') { | |
| inputCapsule.style.display = 'none'; | |
| try { | |
| const { SettingsView } = await import('./views/settings/settings-view.js'); | |
| const settingsView = new SettingsView('router-view'); | |
| settingsView.init(); | |
| activeView = settingsView; | |
| } catch (e) { | |
| handleImportError(e); | |
| } | |
| } | |
| else if (viewName === 'Vault') { | |
| inputCapsule.style.display = 'none'; | |
| try { | |
| const { DocumentsView } = await import('./views/documents/documents-view.js'); | |
| const docsView = new DocumentsView('router-view'); | |
| await docsView.init(); | |
| activeView = docsView; | |
| } catch (e) { | |
| handleImportError(e); | |
| } | |
| } | |
| else if (viewName === 'Hub') { | |
| inputCapsule.style.display = 'none'; | |
| try { | |
| const { HubView } = await import('./views/hub/hub-view.js'); | |
| const hubView = new HubView('router-view'); | |
| await hubView.init(); | |
| activeView = hubView; | |
| } catch (e) { | |
| handleImportError(e); | |
| } | |
| } | |
| else if (viewName === 'News') { | |
| inputCapsule.style.display = 'none'; | |
| try { | |
| const { NewsView } = await import('./views/news/news-view.js'); | |
| const newsView = new NewsView('router-view'); | |
| await newsView.init(); | |
| activeView = newsView; | |
| } catch (e) { | |
| handleImportError(e); | |
| } | |
| } | |
| // Sub-views that can be opened from the Hub | |
| else if (viewName === 'Lernen') { | |
| inputCapsule.style.display = 'none'; | |
| try { | |
| const { LearnView } = await import('./views/learn/learn-view.js'); | |
| const learnView = new LearnView('router-view'); | |
| await learnView.init(); | |
| learnView.render(); | |
| window.learnView = learnView; | |
| activeView = learnView; | |
| } catch (e) { | |
| handleImportError(e); | |
| } | |
| } | |
| else if (viewName === 'Scan') { | |
| inputCapsule.style.display = 'none'; | |
| try { | |
| const { ScanView } = await import('./views/scan/scan-view.js'); | |
| const scanView = new ScanView('router-view'); | |
| await scanView.init(); | |
| scanView.render(); | |
| activeView = scanView; | |
| } catch (e) { | |
| handleImportError(e); | |
| } | |
| } | |
| else if (viewName === 'Kalender') { | |
| inputCapsule.style.display = 'none'; | |
| try { | |
| const { CalendarView } = await import('./views/calendar/calendar-view.js'); | |
| const calView = new CalendarView('router-view'); | |
| await calView.init(); | |
| activeView = calView; | |
| } catch (e) { | |
| handleImportError(e); | |
| } | |
| } | |
| else if (viewName === 'IServ') { | |
| inputCapsule.style.display = 'none'; | |
| try { | |
| const { IServView } = await import('./views/iserv/iserv-view.js'); | |
| const iservView = new IServView('router-view'); | |
| await iservView.init(); | |
| activeView = iservView; | |
| } catch (e) { | |
| handleImportError(e); | |
| } | |
| } | |
| routerView.classList.remove('view-exit-anim'); | |
| routerView.classList.add('view-enter-anim'); | |
| }; | |
| // Helper for Hub to open subviews without changing the active tab highlight | |
| window.openHubSubView = (viewName) => { | |
| window.openView(viewName, true); | |
| }; | |
| // --- CHAT LOGIC --- | |
| const textarea = document.getElementById('chat-input'); | |
| textarea.addEventListener('input', function() { | |
| this.style.height = 'auto'; | |
| this.style.height = (this.scrollHeight < 120 ? this.scrollHeight : 120) + 'px'; | |
| }); | |
| // --- PLUS MENU & IMAGE UPLOAD LOGIC --- | |
| const plusMenuBtn = document.getElementById('plus-menu-btn'); | |
| const plusPopoverMenu = document.getElementById('plus-popover-menu'); | |
| const menuAttachBtn = document.getElementById('menu-attach-image-btn'); | |
| const fileInput = document.getElementById('image-upload-input'); | |
| const previewContainer = document.getElementById('image-preview-container'); | |
| window.currentAttachments = []; // Max 20 | |
| function renderAttachments() { | |
| previewContainer.innerHTML = ''; | |
| if (window.currentAttachments.length === 0) { | |
| previewContainer.style.display = 'none'; | |
| return; | |
| } | |
| previewContainer.style.display = 'flex'; | |
| window.currentAttachments.forEach((att, index) => { | |
| const itemWrapper = document.createElement('div'); | |
| itemWrapper.className = 'gpu-accelerated'; | |
| itemWrapper.style = 'position: relative; display: inline-flex; align-items: center; justify-content: center; padding: 6px; background: rgba(255,255,255,0.03); border-radius: 14px; border: 1px solid rgba(255,255,255,0.08); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); box-shadow: 0 8px 32px rgba(0,0,0,0.2); width: 80px; height: 80px; flex-shrink: 0;'; | |
| if (att.mimeType === 'application/pdf') { | |
| itemWrapper.innerHTML = ` | |
| <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; background: #eb4d4b; border-radius: 8px;"> | |
| <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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 style="font-size: 9px; color: white; margin-top: 4px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90%;">${att.name}</div> | |
| </div> | |
| `; | |
| } else { | |
| itemWrapper.innerHTML = ` | |
| <img src="${att.data}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 8px; display: block;" /> | |
| `; | |
| } | |
| const delBtn = document.createElement('button'); | |
| delBtn.style = 'position: absolute; top: -6px; right: -6px; background: rgba(40,40,45,0.9); color: #fff; border: 1px solid rgba(255,255,255,0.15); border-radius: 50%; width: 22px; height: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 10px; padding: 0; box-shadow: 0 4px 12px rgba(0,0,0,0.4); z-index: 2;'; | |
| delBtn.innerHTML = '✕'; | |
| delBtn.onclick = () => { | |
| window.currentAttachments.splice(index, 1); | |
| renderAttachments(); | |
| }; | |
| itemWrapper.appendChild(delBtn); | |
| previewContainer.appendChild(itemWrapper); | |
| }); | |
| } | |
| // Toggle plus menu | |
| if (plusMenuBtn && plusPopoverMenu) { | |
| plusMenuBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const isVisible = plusPopoverMenu.style.display === 'flex'; | |
| plusPopoverMenu.style.display = isVisible ? 'none' : 'flex'; | |
| }); | |
| // Close when clicking outside | |
| document.addEventListener('click', (e) => { | |
| if (!plusPopoverMenu.contains(e.target) && !plusMenuBtn.contains(e.target)) { | |
| plusPopoverMenu.style.display = 'none'; | |
| } | |
| }); | |
| document.body.addEventListener('change', (e) => { | |
| if (e.target.id === 'deep-research-toggle') { | |
| const container = document.getElementById('deep-research-time-container'); | |
| if(container) container.style.display = e.target.checked ? 'block' : 'none'; | |
| const detectiveContainer = document.getElementById('detective-board-container'); | |
| if(detectiveContainer) detectiveContainer.style.display = e.target.checked ? 'flex' : 'none'; | |
| } | |
| }); | |
| } | |
| if (menuAttachBtn) { | |
| menuAttachBtn.addEventListener('click', () => { | |
| if (plusPopoverMenu) plusPopoverMenu.style.display = 'none'; | |
| fileInput.click(); | |
| }); | |
| } | |
| function handleImageFile(file) { | |
| if (!file || !file.type.startsWith('image/')) return; | |
| if (window.currentAttachments.length >= 20) { | |
| alert("Maximal 20 Anhänge erlaubt."); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| const canvas = document.createElement('canvas'); | |
| const MAX_DIM = 800; | |
| let width = img.width; | |
| let height = img.height; | |
| if (width > height && width > MAX_DIM) { | |
| height *= MAX_DIM / width; | |
| width = MAX_DIM; | |
| } else if (height > MAX_DIM) { | |
| width *= MAX_DIM / height; | |
| height = MAX_DIM; | |
| } | |
| canvas.width = width; | |
| canvas.height = height; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(img, 0, 0, width, height); | |
| const dataUrl = canvas.toDataURL('image/jpeg', 0.7); | |
| window.currentAttachments.push({ mimeType: 'image/jpeg', data: dataUrl, name: file.name }); | |
| renderAttachments(); | |
| }; | |
| img.src = event.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| async function handlePdfFile(file) { | |
| if (window.currentAttachments.length >= 20) { | |
| alert("Maximal 20 Anhänge erlaubt."); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| window.currentAttachments.push({ mimeType: 'application/pdf', data: event.target.result, name: file.name }); | |
| renderAttachments(); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function clearImage() { | |
| window.currentAttachments = []; | |
| renderAttachments(); | |
| fileInput.value = ''; | |
| window.updateChatPlaceholder(); | |
| } | |
| // Handle global errors gracefully | |
| window.addEventListener('unhandledrejection', event => { | |
| console.error('Unhandled promise rejection:', event.reason); | |
| }); | |
| // --- File Permission UI Logic --- | |
| let activePermissionResolve = null; | |
| const permPopup = document.getElementById('file-permission-popup'); | |
| const permText = document.getElementById('file-permission-text'); | |
| const permAllow = document.getElementById('file-permission-allow'); | |
| const permDeny = document.getElementById('file-permission-deny'); | |
| window.addEventListener('request-file-permission', (e) => { | |
| const detail = e.detail; | |
| activePermissionResolve = detail.resolve; | |
| let actionText = 'lesen'; | |
| if (detail.action === 'write') actionText = 'erstellen/überschreiben'; | |
| if (detail.action === 'delete') actionText = 'löschen'; | |
| permText.innerText = `Die KI möchte die Datei "${detail.path}" ${actionText}.`; | |
| permPopup.style.display = 'flex'; | |
| // Haptic feedback to alert user | |
| if (window.navigator && window.navigator.vibrate) { | |
| window.navigator.vibrate(50); | |
| } | |
| }); | |
| const closePermissionPopup = (allowed) => { | |
| permPopup.style.display = 'none'; | |
| if (activePermissionResolve) { | |
| activePermissionResolve(allowed); | |
| activePermissionResolve = null; | |
| } | |
| }; | |
| permAllow.addEventListener('click', () => closePermissionPopup(true)); | |
| permDeny.addEventListener('click', () => closePermissionPopup(false)); | |
| fileInput.addEventListener('change', (e) => { | |
| const files = Array.from(e.target.files); | |
| if (!files.length) return; | |
| files.forEach(file => { | |
| if (file.type === 'application/pdf') { | |
| handlePdfFile(file); | |
| } else { | |
| handleImageFile(file); | |
| } | |
| }); | |
| // Clear the input so selecting the same files again works | |
| fileInput.value = ''; | |
| }); | |
| // Global Paste Event Listener for Images | |
| document.addEventListener('paste', (e) => { | |
| const items = (e.clipboardData || e.originalEvent.clipboardData).items; | |
| for (let index in items) { | |
| const item = items[index]; | |
| if (item.kind === 'file') { | |
| const file = item.getAsFile(); | |
| if (file) { | |
| if (file.type === 'application/pdf') { | |
| e.preventDefault(); | |
| handlePdfFile(file); | |
| break; | |
| } else if (file.type.includes('image')) { | |
| e.preventDefault(); | |
| handleImageFile(file); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| // Handle Sending | |
| const sendBtn = document.getElementById('send-btn'); | |
| const handleSend = async () => { | |
| if (chatView && chatView.isGenerating) { | |
| if (chatView.currentAbortController) { | |
| chatView.currentAbortController.abort(); | |
| } | |
| return; | |
| } | |
| const text = textarea.value; | |
| const hasAttachments = window.currentAttachments && window.currentAttachments.length > 0; | |
| if (text.trim() || hasAttachments) { | |
| // Send button spin animation | |
| sendBtn.classList.add('sending'); | |
| setTimeout(() => sendBtn.classList.remove('sending'), 600); | |
| textarea.value = ''; | |
| textarea.style.height = 'auto'; | |
| let finalMessage = text.trim(); | |
| if (hasAttachments) { | |
| let attachStr = ''; | |
| window.currentAttachments.forEach(att => { | |
| attachStr += `\n`; | |
| }); | |
| finalMessage = `${attachStr}\n${finalMessage}`; | |
| clearImage(); | |
| } | |
| // Check for API Key if using cloud models | |
| const currentModel = db.getSetting('model') || 'gemini-1.5-flash'; | |
| if (currentModel.startsWith('gemini') && !db.getSetting('gemini_api_key')) { | |
| const key = await window.appPrompt("Bitte gib deinen Gemini API Key ein (wird lokal gespeichert):"); | |
| if (key) db.setSetting('gemini_api_key', key.trim()); else return; | |
| } else if (currentModel.startsWith('groq') && !db.getSetting('groq_api_key')) { | |
| const key = await window.appPrompt("Bitte gib deinen Groq API Key ein (wird lokal gespeichert):"); | |
| if (key) db.setSetting('groq_api_key', key.trim()); else return; | |
| } else if (currentModel.startsWith('openrouter') && !db.getSetting('openrouter_api_key')) { | |
| const key = await window.appPrompt("Bitte gib deinen OpenRouter API Key ein (wird lokal gespeichert):"); | |
| if (key) db.setSetting('openrouter_api_key', key.trim()); else return; | |
| } else if (currentModel.startsWith('deepseek') && !db.getSetting('deepseek_api_key')) { | |
| const key = await window.appPrompt("Bitte gib deinen DeepSeek API Key ein (wird lokal gespeichert):"); | |
| if (key) db.setSetting('deepseek_api_key', key.trim()); else return; | |
| } | |
| chatView.handleSend(finalMessage); | |
| // Auto-collapse in PDF mode after sending | |
| if (document.body.classList.contains('pdf-reading-mode')) { | |
| document.body.classList.add('pdf-input-collapsed'); | |
| } | |
| } | |
| }; | |
| sendBtn.addEventListener('click', handleSend); | |
| textarea.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }); | |
| window.updateChatPlaceholder = function() { | |
| try { | |
| const model = db.getSetting('model') || 'gemini-1.5-flash'; | |
| let aiName = "SchoolMind AI"; | |
| let isLocal = false; | |
| if (model.includes('gemini') || model.includes('Gemini')) aiName = "Gemini"; | |
| else if (model.includes('claude') || model.includes('Claude')) aiName = "Claude"; | |
| else if (model.includes('qwen') || model.includes('Qwen')) aiName = "Qwen"; | |
| else if (model.includes('llama') || model.includes('Llama')) aiName = "Llama"; | |
| else if (model.includes('lyria') || model.includes('Lyria')) aiName = "Lyria"; | |
| else if (model.includes('gemma') || model.includes('Gemma')) aiName = "Gemma"; | |
| else if (model.includes('deepseek') || model.includes('DeepSeek')) aiName = "DeepSeek"; | |
| else if (model.includes('mistral') || model.includes('codestral')) aiName = "Mistral"; | |
| if (model.startsWith('ollama-') || model.includes('nano-')) isLocal = true; | |
| let userName = ""; | |
| const useProfileStr = db.getSetting('use_profile'); | |
| const useProfile = useProfileStr === null ? true : useProfileStr === 'true'; | |
| if (useProfile) { | |
| try { | |
| const activeProfileRes = db.getActiveProfile(); | |
| if (activeProfileRes && activeProfileRes.length > 0 && activeProfileRes[0].values.length > 0) { | |
| const row = activeProfileRes[0].values[0]; | |
| const nameIndex = activeProfileRes[0].columns.indexOf('name'); | |
| if (nameIndex !== -1 && row[nameIndex]) { | |
| userName = row[nameIndex]; | |
| } | |
| } | |
| } catch (profileErr) { | |
| console.warn("Profile fetch error:", profileErr); | |
| } | |
| } | |
| let placeholder = `Nachricht an ${aiName}...`; | |
| if (userName) { | |
| placeholder = `Als ${userName} an ${aiName}...`; | |
| } | |
| if (isLocal) { | |
| placeholder = `(Lokal) ` + placeholder; | |
| } | |
| const chatInput = document.getElementById('chat-input'); | |
| if (chatInput) { | |
| chatInput.placeholder = placeholder; | |
| } | |
| } catch (e) { | |
| const chatInput = document.getElementById('chat-input'); | |
| if (chatInput) { | |
| if (e.message.includes('not ready')) { | |
| chatInput.placeholder = "Warte auf Datenbank..."; | |
| } else { | |
| chatInput.placeholder = "ERROR: " + e.message; | |
| } | |
| } | |
| } | |
| }; | |
| // Wait for DB to be ready before first update | |
| db.initPromise.then(() => window.updateChatPlaceholder()); | |
| // --- Scroll to Bottom Button Logic --- | |
| const routerViewEl = document.getElementById('router-view'); | |
| const scrollBtn = document.getElementById('scroll-to-bottom-btn'); | |
| routerViewEl.addEventListener('scroll', (e) => { | |
| // Only show in chat view | |
| if (document.body.classList.contains('in-chat-view')) { | |
| const scrollContainer = document.getElementById('chat-scroll-area') || routerViewEl; | |
| if (e.target !== scrollContainer && e.target !== routerViewEl) return; | |
| const scrollHeight = scrollContainer.scrollHeight; | |
| const scrollTop = scrollContainer.scrollTop; | |
| const clientHeight = scrollContainer.clientHeight; | |
| // If user scrolled up by more than 80 lines (ca. 1000px) | |
| if (scrollHeight - scrollTop - clientHeight > 1000) { | |
| scrollBtn.style.display = 'flex'; | |
| setTimeout(() => scrollBtn.style.opacity = '1', 10); | |
| } else { | |
| scrollBtn.style.opacity = '0'; | |
| setTimeout(() => { | |
| if (scrollBtn.style.opacity === '0') scrollBtn.style.display = 'none'; | |
| }, 200); | |
| } | |
| } else { | |
| scrollBtn.style.display = 'none'; | |
| } | |
| }, true); | |
| scrollBtn.addEventListener('click', () => { | |
| const scrollContainer = document.getElementById('chat-scroll-area') || routerViewEl; | |
| scrollContainer.scrollTo({ | |
| top: scrollContainer.scrollHeight, | |
| behavior: 'smooth' | |
| }); | |
| }); | |
| window.setGeneratingState = (isGen) => { | |
| const icon = document.getElementById('scroll-arrow-icon'); | |
| const ind = document.getElementById('scroll-typing-indicator'); | |
| if (icon && ind) { | |
| if (isGen) { | |
| icon.style.display = 'none'; | |
| ind.style.display = 'flex'; | |
| } else { | |
| icon.style.display = 'block'; | |
| ind.style.display = 'none'; | |
| } | |
| } | |
| }; | |
| // Extract and Wrap Words Helper for TTS | |
| window.extractAndWrapText = (element) => { | |
| // If already wrapped, just return the text | |
| if (element.hasAttribute('data-words-wrapped')) { | |
| return element.getAttribute('data-words-wrapped-text'); | |
| } | |
| const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false); | |
| const nodes = []; | |
| while (walker.nextNode()) nodes.push(walker.currentNode); | |
| let wordIndex = 0; | |
| let fullTextParts = []; | |
| nodes.forEach(node => { | |
| if (node.nodeValue.trim() === '') { | |
| fullTextParts.push(node.nodeValue); | |
| return; | |
| } | |
| const parts = node.nodeValue.split(/(\s+)/); | |
| const fragment = document.createDocumentFragment(); | |
| parts.forEach(part => { | |
| if (part.trim().length > 0) { | |
| const span = document.createElement('span'); | |
| span.className = 'tts-word'; | |
| span.dataset.ttsIndex = wordIndex++; | |
| span.textContent = part; | |
| fragment.appendChild(span); | |
| fullTextParts.push(part); | |
| } else { | |
| fragment.appendChild(document.createTextNode(part)); | |
| fullTextParts.push(part); | |
| } | |
| }); | |
| node.parentNode.replaceChild(fragment, node); | |
| }); | |
| const finalString = fullTextParts.join(''); | |
| element.setAttribute('data-words-wrapped', wordIndex); | |
| element.setAttribute('data-words-wrapped-text', finalString); | |
| return finalString; | |
| }; | |
| // Play TTS Message from a DOM Element | |
| window.playTTSMessage = (btn, rawText) => { | |
| // The button is inside .ai-msg-actions, which is a sibling of .chat-bubble-ai | |
| const actionsDiv = btn.closest('.ai-msg-actions'); | |
| const wrapper = actionsDiv ? actionsDiv.parentElement : null; | |
| const container = wrapper ? wrapper.querySelector('.chat-bubble-ai') : null; | |
| if (container) { | |
| const textToPlay = window.extractAndWrapText(container); | |
| window.ttsService.play(textToPlay, true); | |
| } else { | |
| window.ttsService.play(rawText, false); | |
| } | |
| }; | |
| // --- TTS Player Logic --- | |
| const ttsPlayer = document.getElementById('tts-player-container'); | |
| const ttsPlayBtn = document.getElementById('tts-play-btn'); | |
| const ttsPlayIcon = document.getElementById('tts-play-icon'); | |
| const ttsPrevBtn = document.getElementById('tts-prev-btn'); | |
| const ttsNextBtn = document.getElementById('tts-next-btn'); | |
| const ttsRateBtn = document.getElementById('tts-rate-btn'); | |
| const ttsCcBtn = document.getElementById('tts-cc-btn'); | |
| let ttsCcEnabled = true; | |
| if (ttsPlayer) { | |
| // CC Button logic | |
| ttsCcBtn.addEventListener('click', () => { | |
| ttsCcEnabled = !ttsCcEnabled; | |
| if (ttsCcEnabled) { | |
| ttsCcBtn.classList.add('active'); | |
| ttsCcBtn.style.color = 'var(--primary)'; | |
| } else { | |
| ttsCcBtn.classList.remove('active'); | |
| ttsCcBtn.style.color = 'var(--text-secondary)'; | |
| // Remove active highlights | |
| document.querySelectorAll('.tts-word.active').forEach(el => { | |
| el.classList.remove('active'); | |
| el.style.background = 'transparent'; | |
| el.style.color = 'inherit'; | |
| }); | |
| } | |
| }); | |
| ttsService.onStateChange = (state) => { | |
| const scrollBtn = document.getElementById('scroll-to-bottom-btn'); | |
| // Update word highlights based on progress | |
| if (ttsCcEnabled && state.currentWordIndex !== undefined) { | |
| document.querySelectorAll('.tts-word.active').forEach(el => el.classList.remove('active')); | |
| const activeWord = document.querySelector(`.tts-word[data-tts-index="${state.currentWordIndex}"]`); | |
| if (activeWord) { | |
| activeWord.classList.add('active'); | |
| if (!window.ttsScrollTimeout) { | |
| window.ttsScrollTimeout = setTimeout(() => { | |
| const scrollContainer = activeWord.closest('#chat-scroll-area') || activeWord.closest('.view-container'); | |
| if (scrollContainer) { | |
| const containerRect = scrollContainer.getBoundingClientRect(); | |
| const wordRect = activeWord.getBoundingClientRect(); | |
| const isOutsideMiddle = wordRect.top < containerRect.top + containerRect.height * 0.3 || | |
| wordRect.bottom > containerRect.bottom - containerRect.height * 0.3; | |
| if (isOutsideMiddle) { | |
| const scrollOffset = wordRect.top - containerRect.top + scrollContainer.scrollTop - (containerRect.height / 2); | |
| scrollContainer.scrollTo({ top: scrollOffset, behavior: 'smooth' }); | |
| } | |
| } | |
| window.ttsScrollTimeout = null; | |
| }, 300); // Small debounce to prevent jitter | |
| } | |
| } | |
| } | |
| if (state.hasActiveSession) { | |
| ttsPlayer.classList.remove('collapsed'); | |
| if (scrollBtn) scrollBtn.style.top = '-130px'; | |
| } else { | |
| ttsPlayer.classList.add('collapsed'); | |
| if (scrollBtn) scrollBtn.style.top = '-46px'; | |
| } | |
| if (state.isLoading) { | |
| ttsPlayer.classList.add('playing'); | |
| ttsPlayIcon.innerHTML = '<circle cx="12" cy="12" r="8" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="25 25" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/></circle>'; | |
| ttsPlayBtn.style.opacity = '0.5'; | |
| } else if (state.isPlaying) { | |
| ttsPlayer.classList.add('playing'); | |
| ttsPlayIcon.innerHTML = '<rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect>'; | |
| ttsPlayBtn.style.opacity = '1'; | |
| } else { | |
| ttsPlayer.classList.remove('playing'); | |
| ttsPlayIcon.innerHTML = '<polygon points="7 3 21 12 7 21 7 3"></polygon>'; | |
| ttsPlayBtn.style.opacity = '1'; | |
| } | |
| ttsRateBtn.innerText = state.rate + 'x'; | |
| }; | |
| ttsPlayBtn.addEventListener('click', () => ttsService.togglePlayPause()); | |
| ttsPrevBtn.addEventListener('click', () => ttsService.prev()); | |
| ttsNextBtn.addEventListener('click', () => ttsService.next()); | |
| ttsRateBtn.addEventListener('click', () => ttsService.cycleRate()); | |
| // Swipe down to close | |
| let touchStartY = 0; | |
| let touchEndY = 0; | |
| ttsPlayer.addEventListener('touchstart', (e) => { | |
| touchStartY = e.touches[0].clientY; | |
| touchEndY = touchStartY; | |
| ttsPlayer.style.transition = 'none'; // Instant drag | |
| }); | |
| ttsPlayer.addEventListener('touchmove', (e) => { | |
| touchEndY = e.touches[0].clientY; | |
| const deltaY = touchEndY - touchStartY; | |
| if (deltaY > 0) { | |
| ttsPlayer.style.transform = `translateY(${deltaY}px)`; | |
| // Smoothly fade out as it reaches the 70px threshold | |
| const dragOpacity = Math.max(0, 1 - (deltaY / 70)); | |
| ttsPlayer.style.opacity = dragOpacity; | |
| } | |
| }); | |
| ttsPlayer.addEventListener('touchend', () => { | |
| ttsPlayer.style.transition = ''; // Restore CSS transition | |
| ttsPlayer.style.transform = ''; // Restore CSS transform | |
| ttsPlayer.style.opacity = ''; // Restore CSS opacity | |
| if (touchEndY > touchStartY + 70) { | |
| // Swiped down deliberately | |
| ttsService.stop(); | |
| } | |
| }); | |
| } | |
| }; | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initApp().catch(err => { | |
| console.error("Critical app initialization error:", err); | |
| if (err && err.message && err.message.includes('no such table')) { | |
| alert("Die lokale Datenbank ist beschädigt. Sie wird nun zurückgesetzt, um die App wiederherzustellen."); | |
| localStorage.removeItem('schoolmind_db'); | |
| window.location.reload(); | |
| } | |
| }); | |
| // Also catch if it was launched directly and stored in window | |
| if (window.pendingAppShortcut === 'add_vocabulary') { | |
| showVocabularyModal(); | |
| window.pendingAppShortcut = null; | |
| } | |
| }); | |