* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
329 lines
16 KiB
JavaScript
329 lines
16 KiB
JavaScript
import { apiRequest } from '../../core/api-client.js';
|
||
|
||
const root = document.getElementById('theme-manager-root');
|
||
if (!root) throw new Error('Theme Manager Root fehlt.');
|
||
|
||
const state = {
|
||
catalog: null,
|
||
me: null,
|
||
selectedThemeId: 'inherit',
|
||
editingId: null,
|
||
draftId: null,
|
||
editorValues: {},
|
||
};
|
||
|
||
const $ = selector => root.querySelector(selector);
|
||
const status = (message, tone = 'info') => {
|
||
const node = $('#theme-manager-status');
|
||
node.textContent = message;
|
||
node.dataset.tone = tone;
|
||
};
|
||
|
||
function cssValues(values = {}) {
|
||
const tokens = state.catalog?.contract?.tokens || {};
|
||
return Object.fromEntries(Object.entries(values).flatMap(([name, value]) => tokens[name]?.css ? [[tokens[name].css, value]] : []));
|
||
}
|
||
|
||
function profileById(id) {
|
||
return [...(state.catalog?.builtin || []), ...(state.catalog?.custom || [])].find(theme => theme.id === id) || null;
|
||
}
|
||
|
||
function runtimeState(preference, profile = null) {
|
||
return {
|
||
theme_id: preference.theme_id,
|
||
density: preference.density,
|
||
reduced_motion: preference.reduced_motion,
|
||
custom_theme: profile && !profile.builtin ? {
|
||
id: profile.id,
|
||
base_theme: profile.base_theme,
|
||
css_values: cssValues(profile.resolved_values || profile.values),
|
||
} : null,
|
||
};
|
||
}
|
||
|
||
function applyPreference(preference, { persist = true } = {}) {
|
||
const effective = preference.theme_id === 'inherit' ? state.catalog.system_default : preference;
|
||
const profile = profileById(effective.theme_id);
|
||
window.VendooTheme?.applyState(runtimeState(effective, profile), { persist });
|
||
}
|
||
|
||
|
||
function renderChoices() {
|
||
const host = $('#theme-choice-grid');
|
||
const choices = [
|
||
{ id: 'inherit', name: 'Systemstandard', base_theme: state.catalog.system_default.theme_id, resolved_values: profileById(state.catalog.system_default.theme_id)?.resolved_values || profileById('light').resolved_values, builtin: true },
|
||
{ id: 'system', name: 'Betriebssystem', base_theme: 'system', resolved_values: profileById(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light').resolved_values, builtin: true },
|
||
...state.catalog.builtin.filter(item => item.id !== 'system'),
|
||
...state.catalog.custom,
|
||
];
|
||
host.replaceChildren(...choices.map(profile => {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'theme-choice';
|
||
button.dataset.themeId = profile.id;
|
||
button.setAttribute('aria-pressed', String(profile.id === state.selectedThemeId));
|
||
const swatchWrap = document.createElement('span');
|
||
swatchWrap.className = 'theme-choice-swatches';
|
||
swatchWrap.setAttribute('aria-hidden', 'true');
|
||
for (const token of ['color.canvas', 'color.surface', 'color.brand', 'color.ink']) {
|
||
const swatch = document.createElement('i');
|
||
swatch.style.background = profile.resolved_values?.[token] || '#888888';
|
||
swatchWrap.append(swatch);
|
||
}
|
||
const copy = document.createElement('span');
|
||
const title = document.createElement('strong'); title.textContent = profile.name;
|
||
const meta = document.createElement('small'); meta.textContent = profile.builtin ? 'Vendoo Preset' : 'Eigenes Theme';
|
||
copy.append(title, meta); button.append(swatchWrap, copy);
|
||
button.addEventListener('click', () => selectTheme(profile.id));
|
||
return button;
|
||
}));
|
||
}
|
||
|
||
function selectTheme(themeId) {
|
||
state.selectedThemeId = themeId;
|
||
root.querySelectorAll('.theme-choice').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.themeId === themeId)));
|
||
const preference = {
|
||
theme_id: themeId,
|
||
density: $('#theme-density').value,
|
||
reduced_motion: $('#theme-reduced-motion').value,
|
||
};
|
||
applyPreference(preference, { persist: false });
|
||
status('Vorschau aktiv. Mit „Meine Darstellung speichern“ dauerhaft übernehmen.');
|
||
}
|
||
|
||
|
||
function renderSystemOptions() {
|
||
const select = $('#theme-system-default');
|
||
const selected = state.catalog.system_default.theme_id;
|
||
[...select.querySelectorAll('option[data-custom="true"]')].forEach(option => option.remove());
|
||
for (const profile of state.catalog.custom) {
|
||
const option = document.createElement('option');
|
||
option.value = profile.id;
|
||
option.textContent = profile.name;
|
||
option.dataset.custom = 'true';
|
||
select.append(option);
|
||
}
|
||
select.value = selected;
|
||
}
|
||
|
||
function renderProfiles() {
|
||
const list = $('#theme-profile-list');
|
||
list.replaceChildren(...state.catalog.custom.map(profile => {
|
||
const row = document.createElement('div');
|
||
row.className = 'theme-profile-row';
|
||
const copy = document.createElement('div');
|
||
const title = document.createElement('strong');
|
||
title.textContent = profile.name;
|
||
const meta = document.createElement('small');
|
||
meta.textContent = `Basis: ${profile.base_theme} · ${Object.keys(profile.values || {}).length} Anpassungen`;
|
||
copy.append(title, document.createElement('br'), meta);
|
||
const actions = document.createElement('div');
|
||
const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'vd-button compact'; edit.textContent = 'Bearbeiten'; edit.addEventListener('click', () => openEditor(profile));
|
||
const exp = document.createElement('button'); exp.type = 'button'; exp.className = 'vd-button compact'; exp.textContent = 'Export'; exp.addEventListener('click', () => exportProfile(profile.id));
|
||
actions.append(edit, exp); row.append(copy, actions); return row;
|
||
}));
|
||
if (!state.catalog.custom.length) {
|
||
const empty = document.createElement('p'); empty.className = 'theme-manager-status'; empty.textContent = 'Noch keine eigenen Themes angelegt.'; list.append(empty);
|
||
}
|
||
}
|
||
|
||
function baseValues(baseTheme) {
|
||
return profileById(baseTheme)?.resolved_values || profileById('light')?.resolved_values || {};
|
||
}
|
||
|
||
function currentEditorDefinition() {
|
||
return {
|
||
id: state.editingId || state.draftId || 'custom-draft',
|
||
name: $('#theme-editor-name').value.trim(),
|
||
base_theme: $('#theme-editor-base').value,
|
||
values: { ...state.editorValues },
|
||
};
|
||
}
|
||
|
||
function renderTokenFields(profile = null) {
|
||
const baseTheme = $('#theme-editor-base').value;
|
||
const defaults = baseValues(baseTheme);
|
||
const contract = state.catalog.contract.tokens;
|
||
const groups = new Map();
|
||
for (const [name, token] of Object.entries(contract)) {
|
||
if (!token.customizable) continue;
|
||
if (!groups.has(token.group)) groups.set(token.group, []);
|
||
groups.get(token.group).push([name, token]);
|
||
}
|
||
const host = $('#theme-token-groups');
|
||
host.replaceChildren(...[...groups.entries()].map(([groupName, entries]) => {
|
||
const section = document.createElement('section'); section.className = 'theme-token-group';
|
||
const heading = document.createElement('h4'); heading.textContent = groupName; section.append(heading);
|
||
const list = document.createElement('div'); list.className = 'theme-token-list';
|
||
for (const [name, token] of entries) {
|
||
const field = document.createElement('div'); field.className = 'theme-token-field';
|
||
const label = document.createElement('label'); label.htmlFor = `theme-token-${name.replace(/[^a-z0-9]/gi, '-')}`; label.textContent = token.label;
|
||
const value = state.editorValues[name] ?? defaults[name] ?? '';
|
||
if (token.type === 'color') {
|
||
const wrap = document.createElement('div'); wrap.className = 'theme-color-control';
|
||
const color = document.createElement('input'); color.type = 'color'; color.value = /^#[0-9a-f]{6}$/i.test(value) ? value : '#000000'; color.tabIndex = -1; color.setAttribute('aria-hidden', 'true');
|
||
const text = document.createElement('input'); text.id = label.htmlFor; text.className = 'vd-input'; text.value = value; text.dataset.token = name;
|
||
color.addEventListener('input', () => { text.value = color.value; updateEditorValue(name, text.value); });
|
||
text.addEventListener('input', () => { if (/^#[0-9a-f]{6}$/i.test(text.value)) color.value = text.value; updateEditorValue(name, text.value); });
|
||
wrap.append(color, text); field.append(label, wrap);
|
||
} else {
|
||
const input = document.createElement('input'); input.id = label.htmlFor; input.className = 'vd-input'; input.value = value; input.dataset.token = name; input.inputMode = 'decimal';
|
||
input.addEventListener('input', () => updateEditorValue(name, input.value)); field.append(label, input);
|
||
}
|
||
list.append(field);
|
||
}
|
||
section.append(list); return section;
|
||
}));
|
||
previewEditor();
|
||
}
|
||
|
||
function updateEditorValue(name, value) {
|
||
const base = baseValues($('#theme-editor-base').value)[name];
|
||
if (String(value).trim() === String(base).trim()) delete state.editorValues[name];
|
||
else state.editorValues[name] = String(value).trim();
|
||
previewEditor();
|
||
}
|
||
|
||
function previewEditor() {
|
||
const definition = currentEditorDefinition();
|
||
const merged = { ...baseValues(definition.base_theme), ...definition.values };
|
||
window.VendooTheme?.previewTheme({ base_theme: definition.base_theme, css_values: cssValues(merged), density: $('#theme-density').value, reduced_motion: $('#theme-reduced-motion').value });
|
||
$('#theme-preview-name').textContent = definition.name || 'Theme-Vorschau';
|
||
}
|
||
|
||
|
||
async function validateEditor(showMessage = true) {
|
||
try {
|
||
const result = await apiRequest('/api/themes/validate', { method: 'POST', body: currentEditorDefinition() });
|
||
renderContrast(result.accessibility);
|
||
if (showMessage) status(result.accessibility.ok ? 'Theme ist gültig und erfüllt die geprüften Kontrastziele.' : 'Theme ist gültig, hat aber Kontrastwarnungen.', result.accessibility.ok ? 'success' : 'warning');
|
||
return result;
|
||
} catch (error) {
|
||
renderContrast({ checks: [], ok: false });
|
||
if (showMessage) status(error.message, 'danger');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function renderContrast(accessibility) {
|
||
const host = $('#theme-contrast-list');
|
||
host.replaceChildren(...(accessibility?.checks || []).map(check => {
|
||
const row = document.createElement('div'); row.className = `theme-contrast-row ${check.ok ? 'is-ok' : 'is-fail'}`;
|
||
const label = document.createElement('span'); label.textContent = check.id.replaceAll('-', ' ');
|
||
const result = document.createElement('strong'); result.textContent = `${check.ratio ?? '–'} : 1`;
|
||
row.append(label, result); return row;
|
||
}));
|
||
}
|
||
|
||
function openEditor(profile = null) {
|
||
state.editingId = profile?.id || null;
|
||
state.draftId = profile?.id || `custom-${Date.now().toString(36)}`;
|
||
state.editorValues = { ...(profile?.values || {}) };
|
||
$('#theme-editor-name').value = profile?.name || '';
|
||
$('#theme-editor-base').value = profile?.base_theme || 'light';
|
||
$('#theme-delete-btn').hidden = !profile;
|
||
$('#theme-editor-title').textContent = profile ? 'Theme bearbeiten' : 'Neues Theme';
|
||
renderTokenFields(profile);
|
||
$('#theme-editor-name').focus();
|
||
}
|
||
|
||
async function savePreference() {
|
||
const payload = { theme_id: state.selectedThemeId, density: $('#theme-density').value, reduced_motion: $('#theme-reduced-motion').value };
|
||
try {
|
||
const saved = await apiRequest('/api/themes/preference', { method: 'PUT', body: payload });
|
||
state.catalog.preference = saved;
|
||
applyPreference(saved, { persist: true });
|
||
status('Deine Darstellung wurde sicher gespeichert.', 'success');
|
||
} catch (error) { status(error.message, 'danger'); }
|
||
}
|
||
|
||
async function saveProfile() {
|
||
const valid = await validateEditor(true); if (!valid) return;
|
||
const definition = currentEditorDefinition();
|
||
try {
|
||
const profile = state.editingId
|
||
? await apiRequest(`/api/themes/${encodeURIComponent(state.editingId)}`, { method: 'PUT', body: definition })
|
||
: await apiRequest('/api/themes', { method: 'POST', body: definition });
|
||
await reloadCatalog();
|
||
renderSystemOptions();
|
||
state.editingId = profile.id;
|
||
state.selectedThemeId = profile.id;
|
||
renderChoices(); renderProfiles(); openEditor(profile);
|
||
status('Theme gespeichert.', 'success');
|
||
} catch (error) { status(error.message, 'danger'); }
|
||
}
|
||
|
||
async function deleteProfile() {
|
||
if (!state.editingId || !confirm('Dieses Theme wirklich löschen? Benutzer mit diesem Theme fallen auf den Systemstandard zurück.')) return;
|
||
try {
|
||
await apiRequest(`/api/themes/${encodeURIComponent(state.editingId)}`, { method: 'DELETE' });
|
||
await reloadCatalog(); renderSystemOptions(); state.selectedThemeId = state.catalog.preference.theme_id; renderChoices(); renderProfiles(); openEditor();
|
||
status('Theme gelöscht.', 'success');
|
||
} catch (error) { status(error.message, 'danger'); }
|
||
}
|
||
|
||
async function saveSystemDefault() {
|
||
const payload = { theme_id: $('#theme-system-default').value, density: $('#theme-system-density').value, reduced_motion: $('#theme-system-motion').value };
|
||
try { state.catalog.system_default = await apiRequest('/api/themes/system-default', { method: 'PUT', body: payload }); status('Systemstandard gespeichert.', 'success'); renderChoices(); }
|
||
catch (error) { status(error.message, 'danger'); }
|
||
}
|
||
|
||
async function exportProfile(id) {
|
||
try {
|
||
const payload = await apiRequest(`/api/themes/${encodeURIComponent(id)}/export`);
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `${id}.vendoo-theme.json`; link.click(); URL.revokeObjectURL(link.href);
|
||
} catch (error) { status(error.message, 'danger'); }
|
||
}
|
||
|
||
function importProfile(file) {
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
const payload = JSON.parse(String(reader.result));
|
||
const profile = payload.theme || payload;
|
||
state.editingId = null; state.draftId = `custom-${Date.now().toString(36)}`; state.editorValues = { ...(profile.values || {}) };
|
||
$('#theme-editor-name').value = `${profile.name || 'Importiertes Theme'} Kopie`;
|
||
$('#theme-editor-base').value = profile.base_theme || 'light'; renderTokenFields(); status('Import geladen. Prüfen und anschließend speichern.');
|
||
} catch { status('Die Theme-Datei ist ungültig.', 'danger'); }
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
async function reloadCatalog() {
|
||
state.catalog = await apiRequest('/api/themes');
|
||
}
|
||
|
||
async function init() {
|
||
try {
|
||
[state.catalog, state.me] = await Promise.all([apiRequest('/api/themes'), apiRequest('/api/me')]);
|
||
state.selectedThemeId = state.catalog.preference.theme_id;
|
||
$('#theme-density').value = state.catalog.preference.density;
|
||
$('#theme-reduced-motion').value = state.catalog.preference.reduced_motion;
|
||
$('#theme-system-default').value = state.catalog.system_default.theme_id;
|
||
$('#theme-system-density').value = state.catalog.system_default.density;
|
||
$('#theme-system-motion').value = state.catalog.system_default.reduced_motion;
|
||
renderSystemOptions();
|
||
const canManage = state.me.permissions?.includes('settings.manage');
|
||
root.querySelectorAll('.theme-admin-only').forEach(node => node.classList.toggle('hidden', !canManage));
|
||
renderChoices(); renderProfiles(); openEditor(); applyPreference(state.catalog.effective_preference, { persist: true });
|
||
status('Theme Manager bereit.');
|
||
} catch (error) { status(`Theme Manager konnte nicht geladen werden: ${error.message}`, 'danger'); }
|
||
}
|
||
|
||
$('#theme-preference-save').addEventListener('click', savePreference);
|
||
$('#theme-density').addEventListener('change', () => selectTheme(state.selectedThemeId));
|
||
$('#theme-reduced-motion').addEventListener('change', () => selectTheme(state.selectedThemeId));
|
||
$('#theme-new-btn').addEventListener('click', () => openEditor());
|
||
$('#theme-editor-base').addEventListener('change', () => { state.editorValues = {}; renderTokenFields(); });
|
||
$('#theme-editor-name').addEventListener('input', previewEditor);
|
||
$('#theme-validate-btn').addEventListener('click', () => validateEditor(true));
|
||
$('#theme-save-btn').addEventListener('click', saveProfile);
|
||
$('#theme-delete-btn').addEventListener('click', deleteProfile);
|
||
$('#theme-preview-reset').addEventListener('click', () => applyPreference(state.catalog.preference, { persist: false }));
|
||
$('#theme-system-save').addEventListener('click', saveSystemDefault);
|
||
$('#theme-import-input').addEventListener('change', event => importProfile(event.target.files?.[0]));
|
||
|
||
init();
|