Files
Masterluke77andGitHub 6f48827f4d Vendoo 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix (#18)
* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix

* fix: track native source modules with root-anchored runtime ignores
2026-07-09 17:09:00 +02:00

329 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();