/* ============================================= HELLION NEWTAB — settings.js Settings Panel, Theme-Modal, Accordion, Toggles ============================================= */ // ---- A11Y: Fokus-Management fuer Modals ---- // Merkt sich das vor dem Oeffnen fokussierte Element, damit wir es beim // Schliessen restaurieren koennen. Pro offenem Modal eine Closure-Variable. const _focusReturn = { settings: null, theme: null }; /** Liefert die fokussierbaren Elemente innerhalb eines Containers. */ function _focusable(container) { return Array.from(container.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' )).filter(el => el.offsetParent !== null); } /** Tab/Shift+Tab im Container einfangen + Escape schliesst. */ function _makeTrap(container, closeFn) { return function trap(e) { // Ein offener HellionDialog (z.B. Reset-All-Confirm oder BG-URL-Alert aus // dem Panel) hat Vorrang: sein eigener keydown-Handler uebernimmt Escape/Tab. // Sonst schloessen beide Listener gleichzeitig und die Dialog-Fokusfalle wird loechrig. if (document.querySelector('.dialog-overlay')) return; if (e.key === 'Escape') { e.preventDefault(); closeFn(); return; } if (e.key !== 'Tab') return; const items = _focusable(container); if (items.length === 0) return; const first = items[0]; const last = items[items.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; } // ---- SETTINGS PANEL ---- // Hinweis: withViewTransition (Phase 4) bleibt fuer das Fade erhalten; das // Fokus-Management (merken, Falle, Rueckgabe) liegt bewusst ausserhalb des // Transition-Callbacks. activeElement wird vor der Mutation gelesen. let _settingsTrap = null; function openSettings() { const panel = document.getElementById('settingsPanel'); _focusReturn.settings = document.activeElement; withViewTransition(() => { panel.classList.add('open'); document.getElementById('settingsOverlay').classList.add('active'); }); panel.setAttribute('aria-hidden', 'false'); renderTrash(); _settingsTrap = _makeTrap(panel, closeSettings); document.addEventListener('keydown', _settingsTrap); const first = _focusable(panel)[0]; if (first) first.focus(); } function closeSettings() { const panel = document.getElementById('settingsPanel'); withViewTransition(() => { panel.classList.remove('open'); document.getElementById('settingsOverlay').classList.remove('active'); }); panel.setAttribute('aria-hidden', 'true'); if (_settingsTrap) { document.removeEventListener('keydown', _settingsTrap); _settingsTrap = null; } if (_focusReturn.settings) { _focusReturn.settings.focus(); _focusReturn.settings = null; } } // ---- THEME MODAL ---- let _themeTrap = null; function openThemeModal() { const overlay = document.getElementById('themeOverlay'); const modal = document.getElementById('themeModal'); _focusReturn.theme = document.activeElement; withViewTransition(() => { overlay.classList.add('active'); }); modal.setAttribute('aria-hidden', 'false'); _themeTrap = _makeTrap(modal, closeThemeModal); document.addEventListener('keydown', _themeTrap); const first = _focusable(modal)[0]; if (first) first.focus(); syncCustomPickers(); document.getElementById('themeBuilderPanel').classList.toggle('hidden', settings.theme !== 'custom'); } function closeThemeModal() { const overlay = document.getElementById('themeOverlay'); const modal = document.getElementById('themeModal'); withViewTransition(() => { overlay.classList.remove('active'); }); modal.setAttribute('aria-hidden', 'true'); if (_themeTrap) { document.removeEventListener('keydown', _themeTrap); _themeTrap = null; } if (_focusReturn.theme) { _focusReturn.theme.focus(); _focusReturn.theme = null; } } /** * Wechselt das Theme mit nativem Cross-Fade (View Transitions API). * Wrap sitzt bewusst hier am User-Ausloeser, NICHT in applyTheme(), * sonst fadet jeder neue Tab beim Initial-Load (settings.js:101). * Feature-Detection-Fallback: aeltere Browser (z.B. Firefox < 144) * schalten instant um, ohne Bruch. * @param {string} name - Theme-Name */ function switchTheme(name) { const swap = () => applyTheme(name, false); // false: Theme-BG anwenden (kein User-bgUrl-Schutz hier noetig, bgUrl wurde geleert) withViewTransition(swap); } /** * Prueft ob eine Background-URL sicher fuer CSS-Einbettung ist. * Erlaubt nur blob: und data:image/ Protokolle (aus File Upload). * @param {string} url * @returns {boolean} */ function isValidBgUrl(url) { return typeof url === 'string' && url.length > 0 && (url.startsWith('blob:') || url.startsWith('data:image/') || url.startsWith('https://')); } // ---- THEME-BUILDER: Konstanten + reine Helfer ---- const CUSTOM_DEFAULTS = { accent: '#6c8cff', bgPrimary: '#0b0d12', bgBoard: '#141821', textPrimary: '#e6e8ef', textSecondary: '#9aa3b8', textMuted: '#5b6478', }; const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; function isValidHexColor(v) { return typeof v === 'string' && HEX_RE.test(v); } function safeHex(v, fallback) { return isValidHexColor(v) ? v : fallback; } function hexToRgba(hex, alpha) { let h = hex.replace('#', ''); if (h.length === 3) h = h.split('').map(c => c + c).join(''); const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } // WCAG 2.x Relativluminanz + Kontrastverhaeltnis function relLuminance(hex) { let h = hex.replace('#', ''); if (h.length === 3) h = h.split('').map(c => c + c).join(''); const lin = [0, 2, 4].map(i => { const c = parseInt(h.slice(i, i + 2), 16) / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; } function contrastRatio(hexA, hexB) { const a = relLuminance(hexA), b = relLuminance(hexB); return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); } function updateContrastIndicator(textHex, bgHex) { const el = document.getElementById('tbContrast'); if (!el) return; const ratio = contrastRatio(textHex, bgHex); let cls, key; if (ratio >= 4.5) { cls = 'good'; key = 'theme.builder.contrast_good'; } else if (ratio >= 3) { cls = 'ok'; key = 'theme.builder.contrast_ok'; } else { cls = 'bad'; key = 'theme.builder.contrast_bad'; } el.classList.remove('good', 'ok', 'bad'); el.classList.add(cls); const txt = document.getElementById('tbContrastText'); if (txt) txt.textContent = `${t(key)} (${ratio.toFixed(1)}:1)`; } // Setzt data-theme='custom' + 6 validierte Inline-Vars (Gate vor jedem setProperty). function applyCustomTheme(ct) { const root = document.documentElement; const c = ct || {}; const accent = safeHex(c.accent, CUSTOM_DEFAULTS.accent); const bgPrimary = safeHex(c.bgPrimary, CUSTOM_DEFAULTS.bgPrimary); const bgBoard = safeHex(c.bgBoard, CUSTOM_DEFAULTS.bgBoard); const textPrimary = safeHex(c.textPrimary, CUSTOM_DEFAULTS.textPrimary); const textSecondary = safeHex(c.textSecondary, CUSTOM_DEFAULTS.textSecondary); const textMuted = safeHex(c.textMuted, CUSTOM_DEFAULTS.textMuted); root.setAttribute('data-theme', 'custom'); root.style.setProperty('--accent', accent); root.style.setProperty('--bg-primary', bgPrimary); root.style.setProperty('--bg-board', hexToRgba(bgBoard, 0.55)); root.style.setProperty('--text-primary', textPrimary); root.style.setProperty('--text-secondary', textSecondary); root.style.setProperty('--text-muted', textMuted); document.querySelectorAll('.theme-card').forEach(card => { const on = card.dataset.value === 'custom'; card.classList.toggle('active', on); card.setAttribute('aria-pressed', on ? 'true' : 'false'); }); updateContrastIndicator(textPrimary, bgPrimary); } // Entfernt die 6 Inline-Vars (Rueckwechsel auf Preset / Reset). function clearCustomTheme() { const root = document.documentElement; ['--accent', '--bg-primary', '--bg-board', '--text-primary', '--text-secondary', '--text-muted'] .forEach(v => root.style.removeProperty(v)); } // Schreibt die gespeicherten (oder Default-) Farben in die 6 Picker-Inputs. function syncCustomPickers() { const ct = settings.customTheme || {}; const set = (id, key) => { const el = document.getElementById(id); if (el) el.value = safeHex(ct[key], CUSTOM_DEFAULTS[key]); }; set('tbAccent', 'accent'); set('tbBg', 'bgPrimary'); set('tbBoard', 'bgBoard'); set('tbText', 'textPrimary'); set('tbTextSec', 'textSecondary'); set('tbTextMuted', 'textMuted'); } // Eigenes Upload-Bild Quota-schonend verkleinern: auf die laengste Bildschirmkante // (× devicePixelRatio, gedeckelt) herunterrechnen und als WebP neu kodieren. Das spart // gegenueber dem rohen Base64-Upload locker den Grossteil der chrome.storage.local-Quota. // Greift nur beim lokalen Upload (data:-URL ist same-origin, Canvas wird nicht getainted); // https-Hintergruende liegen remote und kosten keine Quota. function downscaleBgImage(dataUrl) { const MAX_DIM = Math.min(2560, Math.round(Math.max(screen.width, screen.height) * (window.devicePixelRatio || 1))); const QUALITY = 0.82; return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { const scale = Math.min(1, MAX_DIM / Math.max(img.naturalWidth, img.naturalHeight)); const w = Math.max(1, Math.round(img.naturalWidth * scale)); const h = Math.max(1, Math.round(img.naturalHeight * scale)); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); if (!ctx) { resolve(dataUrl); return; } // kein 2D-Context -> Original behalten ctx.drawImage(img, 0, 0, w, h); // WebP wo verfuegbar (Chrome/Opera/FF142+); sonst faellt toDataURL auf PNG zurueck -> dann JPEG let out = canvas.toDataURL('image/webp', QUALITY); if (!out.startsWith('data:image/webp')) out = canvas.toDataURL('image/jpeg', QUALITY); resolve(out); }; img.onerror = () => reject(new Error('image decode failed')); img.src = dataUrl; }); } // ---- ACCORDION ---- function initAccordion() { const defaultOpen = new Set(['widgets']); const sections = document.querySelectorAll('.settings-section[data-section]'); sections.forEach(section => { const name = section.dataset.section; const title = section.querySelector('.settings-section-title'); if (defaultOpen.has(name)) { section.classList.add('open'); } title.addEventListener('click', () => { section.classList.toggle('open'); }); title.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); section.classList.toggle('open'); } }); }); } // ---- PAPIERKORB ---- /** * Formatiert einen deletedAt-Timestamp lokalisiert (folgt der aktiven UI-Sprache). * @param {number} ts - Millisekunden-Timestamp * @returns {string} */ function formatTrashDate(ts) { const locale = I18n.currentLang === 'de' ? 'de-DE' : 'en-US'; return new Date(ts).toLocaleDateString(locale, { year: 'numeric', month: '2-digit', day: '2-digit' }); } /** * Rendert den Papierkorb in die Settings-Section. Wird bei jedem openSettings() * sowie nach jeder Trash-Mutation aufgerufen. Baut DOM ohne innerHTML (XSS-frei, * Titel kommen aus User-/Importdaten). */ function renderTrash() { const listEl = document.getElementById('trashList'); const actionsRow = document.getElementById('trashActionsRow'); if (!listEl) return; listEl.replaceChildren(); if (trash.length === 0) { const empty = document.createElement('div'); empty.className = 'trash-empty'; empty.textContent = t('trash.empty'); listEl.appendChild(empty); if (actionsRow) actionsRow.classList.add('hidden'); return; } if (actionsRow) actionsRow.classList.remove('hidden'); // Neueste zuerst. const sorted = [...trash].sort((a, b) => b.deletedAt - a.deletedAt); sorted.forEach(entry => listEl.appendChild(createTrashItemEl(entry))); } /** * Baut eine einzelne Papierkorb-Zeile. * @param {Object} entry - trash-Eintrag { item, type, originBoardId, deletedAt } * @returns {HTMLElement} */ function createTrashItemEl(entry) { const row = document.createElement('div'); row.className = 'trash-item'; const info = document.createElement('div'); info.className = 'trash-item-info'; const titleLine = document.createElement('span'); titleLine.className = 'trash-item-title'; const badge = document.createElement('span'); badge.className = 'trash-item-badge'; badge.textContent = entry.type === 'board' ? t('trash.type.board') : t('trash.type.bookmark'); const titleText = document.createTextNode(entry.item && entry.item.title ? entry.item.title : ''); titleLine.append(badge, titleText); const meta = document.createElement('span'); meta.className = 'trash-item-meta'; let metaText = t('trash.deleted_at', { date: formatTrashDate(entry.deletedAt) }); if (entry.type === 'bookmark') { const origin = entry.originBoardId ? boards.find(b => b.id === entry.originBoardId) : null; metaText += origin ? ' · ' + t('trash.from_board', { board: origin.title }) : ' · ' + t('trash.from_board_unknown'); } meta.textContent = metaText; info.append(titleLine, meta); const actions = document.createElement('div'); actions.className = 'trash-item-actions'; const btnRestore = document.createElement('button'); btnRestore.className = 'btn-small'; btnRestore.textContent = t('trash.restore'); btnRestore.title = t('trash.restore_title'); btnRestore.addEventListener('click', () => { btnRestore.disabled = true; restoreTrashEntry(entry); }); const btnForever = document.createElement('button'); btnForever.className = 'btn-danger'; btnForever.textContent = t('trash.delete_forever'); btnForever.title = t('trash.delete_forever_title'); btnForever.addEventListener('click', () => deleteTrashEntryForever(entry)); actions.append(btnRestore, btnForever); row.append(info, actions); return row; } /** * Stellt einen Papierkorb-Eintrag wieder her. * Bookmark -> in originBoardId (falls noch vorhanden), sonst in die Inbox (ensureInboxBoard). * Board -> zurueck in boards[]. * @param {Object} entry */ async function restoreTrashEntry(entry) { // Re-Entry-Guard: ein zweiter Klick (z.B. waehrend der Inbox-Alert offen ist) wuerde sonst // das Item ein zweites Mal einfuegen (Duplikat). Nach der ersten Ausfuehrung ist entry // nicht mehr in trash[]; btnRestore wird zusaetzlich beim ersten Klick disabled. if (!trash.includes(entry)) return; if (entry.type === 'board') { // Ganzes Board zurueck (inkl. blurred). Falls die id schon existiert (Edge-Case), // neue uid vergeben, damit nichts ueberschrieben wird. const restored = structuredClone(entry.item); if (boards.some(b => b.id === restored.id)) restored.id = uid(); boards.push(restored); await saveBoards(); } else { const restored = structuredClone(entry.item); let target = entry.originBoardId ? boards.find(b => b.id === entry.originBoardId) : null; let toInbox = false; if (!target) { // Ursprungs-Board weg -> in die Inbox (Page-Wrapper ensureInboxBoard aus Phase 1). target = await ensureInboxBoard(); toInbox = true; } target.bookmarks.push(restored); await saveBoards(); if (toInbox) { await HellionDialog.alert(t('trash.restored_to_inbox'), { type: 'info', title: t('trash.restored_to_inbox.title') }); } } trash = trash.filter(e => e !== entry); await saveTrash(); renderTrash(); renderBoards(); } /** * Loescht einen einzelnen Papierkorb-Eintrag endgueltig (mit Confirm). * @param {Object} entry */ async function deleteTrashEntryForever(entry) { const ok = await HellionDialog.confirm( t('trash.delete_forever_confirm'), { type: 'danger', title: t('trash.delete_forever_confirm.title'), confirmText: t('trash.delete_forever') } ); if (!ok) return; trash = trash.filter(e => e !== entry); await saveTrash(); renderTrash(); } /** * Leert den gesamten Papierkorb (mit Confirm). */ async function emptyTrash() { if (trash.length === 0) return; const ok = await HellionDialog.confirm( t('trash.empty_confirm', { count: trash.length }), { type: 'danger', title: t('trash.empty_confirm.title'), confirmText: t('trash.empty_btn') } ); if (!ok) return; trash = []; await saveTrash(); renderTrash(); } // ---- APPLY SETTINGS ---- function applySettings() { const body = document.body; body.classList.toggle('compact', settings.compact); body.classList.toggle('shorten-titles', settings.shortenTitles); body.classList.toggle('show-desc', settings.showDesc); document.getElementById('settingCompact').checked = settings.compact; document.getElementById('settingShorten').checked = settings.shortenTitles; document.getElementById('settingNewTab').checked = settings.newTab; document.getElementById('settingShowDesc').checked = settings.showDesc; document.getElementById('settingHideExtra').checked = settings.hideExtra; document.getElementById('settingVisibleCount').value = String(settings.visibleCount); document.getElementById('visibleCountRow').classList.toggle('dim', !settings.hideExtra); // showSearch: undefined (alter Save) → true if (settings.showSearch === undefined) settings.showSearch = true; const searchWrapper = document.getElementById('searchBarWrapper'); if (searchWrapper) searchWrapper.classList.toggle('hidden', !settings.showSearch); const showSearchEl = document.getElementById('settingShowSearch'); if (showSearchEl) showSearchEl.checked = settings.showSearch; // Image-Ref Toggle if (settings.imageRefEnabled === undefined) settings.imageRefEnabled = false; const imgRefCheckbox = document.getElementById('settingImageRef'); if (imgRefCheckbox) imgRefCheckbox.checked = settings.imageRefEnabled; const imgRefBtn = document.querySelector('[data-action="image-ref"]'); if (imgRefBtn) imgRefBtn.classList.toggle('hidden', !settings.imageRefEnabled); // A11y: aria-checked aller role=switch-Toggles an den realen checked-State angleichen document.querySelectorAll('.toggle input[role="switch"]').forEach(cb => { cb.setAttribute('aria-checked', cb.checked ? 'true' : 'false'); }); // Toolbar-Position document.body.classList.toggle('toolbar-left', settings.toolbarPos === 'left'); const toolbarPosEl = document.getElementById('settingToolbarPos'); if (toolbarPosEl) toolbarPosEl.value = settings.toolbarPos || 'right'; // Sprache (Dropdown-Wert setzen — I18n.init() übernimmt die eigentliche Anwendung) const langEl = document.getElementById('settingLanguage'); if (langEl) langEl.value = settings.language || 'auto'; if (settings.theme === 'custom') { applyCustomTheme(settings.customTheme); } else { applyTheme(settings.theme || 'nebula', !!settings.bgUrl); } if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) { document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`; } else if (settings.bgUrl) { settings.bgUrl = ''; } } // ---- BIND EVENTS ---- function bindSettingsEvents() { // Settings Panel document.getElementById('settingsOverlay').addEventListener('click', closeSettings); document.getElementById('btnCloseSettings').addEventListener('click', closeSettings); document.getElementById('btnSettings').addEventListener('click', openSettings); // Theme Modal document.getElementById('btnTheme').addEventListener('click', openThemeModal); document.getElementById('btnCloseTheme').addEventListener('click', closeThemeModal); document.getElementById('themeOverlay').addEventListener('click', e => { if (e.target === document.getElementById('themeOverlay')) closeThemeModal(); }); // Theme-Picker (Cards im Theme-Modal) const themeCards = document.querySelectorAll('.theme-card'); function selectThemeCard(card) { const name = card.dataset.value; if (!name) return Promise.resolve(); // Custom: VOR dem name===settings.theme-Guard, damit ein Re-Klick das Panel wieder oeffnet. if (name === 'custom') { settings.theme = 'custom'; if (!settings.customTheme) settings.customTheme = { ...CUSTOM_DEFAULTS }; themeCards.forEach(c => c.setAttribute('aria-pressed', c === card ? 'true' : 'false')); applyCustomTheme(settings.customTheme); // setzt data-theme + Inline-Vars; bgUrl UNANGETASTET (Koexistenz) syncCustomPickers(); document.getElementById('themeBuilderPanel').classList.remove('hidden'); return saveSettings(); } if (name === settings.theme) return Promise.resolve(); settings.theme = name; settings.bgUrl = ''; document.getElementById('bgUrlInput').value = ''; clearCustomTheme(); // Inline-Vars weg beim Rueckwechsel auf ein Preset document.getElementById('themeBuilderPanel').classList.add('hidden'); // aria-pressed synchron halten — applyTheme/switchTheme pflegt nur die .active-Klasse, nicht ARIA themeCards.forEach(c => c.setAttribute('aria-pressed', c === card ? 'true' : 'false')); switchTheme(name); // WICHTIG: switchTheme aus Phase 4 (View-Transition-Wrapper), NICHT applyTheme direkt — sonst geht der Theme-Fade verloren return saveSettings(); } themeCards.forEach(card => { card.addEventListener('click', () => selectThemeCard(card)); card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectThemeCard(card); } }); }); // Theme-Builder Picker const TB_PICKERS = [['tbAccent', 'accent'], ['tbBg', 'bgPrimary'], ['tbBoard', 'bgBoard'], ['tbText', 'textPrimary'], ['tbTextSec', 'textSecondary'], ['tbTextMuted', 'textMuted']]; TB_PICKERS.forEach(([id, key]) => { const el = document.getElementById(id); if (!el) return; el.addEventListener('input', () => { // live waehrend des Ziehens if (!settings.customTheme) settings.customTheme = { ...CUSTOM_DEFAULTS }; settings.customTheme[key] = el.value; settings.theme = 'custom'; applyCustomTheme(settings.customTheme); }); el.addEventListener('change', () => saveSettings()); // persistiert beim Loslassen/Schliessen }); const tbReset = document.getElementById('tbReset'); if (tbReset) { tbReset.addEventListener('click', async () => { settings.customTheme = { ...CUSTOM_DEFAULTS }; applyCustomTheme(settings.customTheme); syncCustomPickers(); await saveSettings(); }); } // Accordion initialisieren initAccordion(); // Toggles const toggleMap = { settingCompact: v => { settings.compact = v; document.body.classList.toggle('compact', v); }, settingShorten: v => { settings.shortenTitles = v; document.body.classList.toggle('shorten-titles', v); }, settingNewTab: v => { settings.newTab = v; }, settingShowDesc: v => { settings.showDesc = v; document.body.classList.toggle('show-desc', v); }, settingHideExtra: v => { settings.hideExtra = v; document.getElementById('visibleCountRow').classList.toggle('dim', !v); renderBoards(); }, settingShowSearch: v => { settings.showSearch = v; document.getElementById('searchBarWrapper').classList.toggle('hidden', !v); }, settingImageRef: v => { settings.imageRefEnabled = v; const imgBtn = document.querySelector('[data-action="image-ref"]'); if (imgBtn) imgBtn.classList.toggle('hidden', !v); } }; Object.entries(toggleMap).forEach(([id, fn]) => { const el = document.getElementById(id); if (el) { el.addEventListener('change', async e => { e.target.setAttribute('aria-checked', e.target.checked ? 'true' : 'false'); fn(e.target.checked); await saveSettings(); }); } }); document.getElementById('settingVisibleCount').addEventListener('change', async e => { settings.visibleCount = parseInt(e.target.value, 10); await saveSettings(); renderBoards(); }); // Background URL (im Theme-Modal) document.getElementById('btnChangeBg').addEventListener('click', () => { // toggle() liefert true, wenn 'hidden' jetzt gesetzt ist -> Hinweis exakt parallel schalten const isNowHidden = document.getElementById('bgInputRow').classList.toggle('hidden'); document.getElementById('bgUrlHint').classList.toggle('hidden', isNowHidden); }); document.getElementById('btnApplyBg').addEventListener('click', async () => { const url = document.getElementById('bgUrlInput').value.trim(); if (url && !isValidBgUrl(url)) { await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') }); return; } settings.bgUrl = url; document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : ''; await saveSettings(); document.getElementById('bgInputRow').classList.add('hidden'); }); // Background File Upload (im Theme-Modal) document.getElementById('btnBgFile').addEventListener('click', () => { document.getElementById('bgFileInput').click(); }); document.getElementById('bgFileInput').addEventListener('change', e => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async ev => { if (!isValidBgUrl(ev.target.result)) return; let bg = ev.target.result; try { bg = await downscaleBgImage(bg); // Quota-Schutz: verkleinern + WebP } catch { // Downscale fehlgeschlagen -> Original-Upload nutzen (besser als gar kein Bild) } settings.bgUrl = bg; document.getElementById('bgLayer').style.backgroundImage = `url('${bg}')`; await saveSettings(); }; reader.onerror = () => { HellionDialog.alert(t('settings.file_read_error'), { type: 'danger', title: t('settings.file_read_error.title') }); }; reader.readAsDataURL(file); }); // Sprach-Einstellung const languageEl = document.getElementById('settingLanguage'); if (languageEl) { languageEl.value = settings.language || 'auto'; languageEl.addEventListener('change', async (e) => { settings.language = e.target.value; setLanguage(e.target.value); await saveSettings(); }); } // Toolbar-Position Setting const toolbarPosEl = document.getElementById('settingToolbarPos'); if (toolbarPosEl) { toolbarPosEl.value = settings.toolbarPos || 'right'; toolbarPosEl.addEventListener('change', async (e) => { settings.toolbarPos = e.target.value; document.body.classList.toggle('toolbar-left', e.target.value === 'left'); await saveSettings(); }); } // Onboarding wiederholen document.getElementById('btnRestartOnboarding').addEventListener('click', () => { closeSettings(); Onboarding.start(); }); // Papierkorb leeren const btnEmptyTrash = document.getElementById('btnEmptyTrash'); if (btnEmptyTrash) btnEmptyTrash.addEventListener('click', emptyTrash); // Reset All document.getElementById('btnResetAll').addEventListener('click', async () => { const ok = await HellionDialog.confirm( t('settings.reset_confirm'), { type: 'danger', title: t('settings.reset_confirm.title'), confirmText: t('settings.reset_confirm.button') } ); if (!ok) return; boards = []; trash = []; settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false, hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula', showSearch: true, searchEngine: 'google', toolbarPos: 'right', imageRefEnabled: false, language: 'auto', customTheme: null }; clearCustomTheme(); await saveBoards(); await saveTrash(); await saveSettings(); setLanguage('auto'); applySettings(); renderBoards(); closeSettings(); }); }