Files
Hellion-NewTab/src/js/settings.js
T

525 lines
20 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/'));
}
// ---- 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', () => {
document.getElementById('bgInputRow').classList.toggle('hidden');
});
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;
settings.bgUrl = ev.target.result;
document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`;
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' };
await saveBoards();
await saveTrash();
await saveSettings();
setLanguage('auto');
applySettings();
renderBoards();
closeSettings();
});
}