651 lines
26 KiB
JavaScript
651 lines
26 KiB
JavaScript
/* =============================================
|
||
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();
|
||
}
|
||
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';
|
||
|
||
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 || name === settings.theme) return Promise.resolve();
|
||
settings.theme = name;
|
||
settings.bgUrl = '';
|
||
document.getElementById('bgUrlInput').value = '';
|
||
// 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);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 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 };
|
||
await saveBoards();
|
||
await saveTrash();
|
||
await saveSettings();
|
||
setLanguage('auto');
|
||
applySettings();
|
||
renderBoards();
|
||
closeSettings();
|
||
});
|
||
}
|