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
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/brand/favicons/mstile-150x150.png"/>
|
||||
<TileColor>#faf6f2</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
After Width: | Height: | Size: 698 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,9 @@
|
||||
<!-- Vendoo favicon package -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicons/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicons/favicon-48x48.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/favicons/site.webmanifest">
|
||||
<meta name="msapplication-config" content="/favicons/browserconfig.xml">
|
||||
<meta name="theme-color" content="#FAF6F2">
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Vendoo",
|
||||
"short_name": "Vendoo",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/brand/favicons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/brand/favicons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#FAF6F2",
|
||||
"background_color": "#FAF6F2",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 698 B |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 838 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 154 KiB |
@@ -0,0 +1,40 @@
|
||||
export class VendooApiError extends Error {
|
||||
constructor(message, { status = 0, code = 'API_ERROR', requestId = null, details = null } = {}) {
|
||||
super(message);
|
||||
this.name = 'VendooApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.requestId = requestId;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(name) {
|
||||
const prefix = `${encodeURIComponent(name)}=`;
|
||||
return document.cookie.split(';').map(value => value.trim()).find(value => value.startsWith(prefix))?.slice(prefix.length) || '';
|
||||
}
|
||||
|
||||
export async function apiRequest(path, { method = 'GET', body, headers = {}, signal } = {}) {
|
||||
const verb = String(method).toUpperCase();
|
||||
const requestHeaders = new Headers(headers);
|
||||
requestHeaders.set('Accept', 'application/json');
|
||||
if (body !== undefined) requestHeaders.set('Content-Type', 'application/json');
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(verb)) requestHeaders.set('X-CSRF-Token', decodeURIComponent(readCookie('csrf_token')));
|
||||
const response = await fetch(path, {
|
||||
method: verb,
|
||||
credentials: 'same-origin',
|
||||
headers: requestHeaders,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
const requestId = response.headers.get('x-vendoo-request-id');
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new VendooApiError(payload.error || `HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
code: payload.code || 'API_ERROR',
|
||||
requestId: payload.request_id || requestId,
|
||||
details: payload.details || null,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const MODULE_ID = /^vendoo\.[a-z][a-z0-9-]*$/;
|
||||
|
||||
export class FrontendModuleRegistry {
|
||||
#modules = new Map();
|
||||
#active = null;
|
||||
|
||||
register(definition) {
|
||||
const id = String(definition?.id || '');
|
||||
if (!MODULE_ID.test(id)) throw new Error(`Ungültige Frontend-Modul-ID: ${id}`);
|
||||
if (this.#modules.has(id)) throw new Error(`Frontend-Modul bereits registriert: ${id}`);
|
||||
if (typeof definition.mount !== 'function') throw new Error(`Frontend-Modul benötigt mount(): ${id}`);
|
||||
this.#modules.set(id, Object.freeze({
|
||||
id,
|
||||
version: String(definition.version || '1.0.0'),
|
||||
status: String(definition.status || 'native'),
|
||||
mount: definition.mount,
|
||||
unmount: typeof definition.unmount === 'function' ? definition.unmount : async () => {},
|
||||
}));
|
||||
}
|
||||
|
||||
async mount(id, root, services = {}) {
|
||||
if (!(root instanceof Element)) throw new Error('Frontend-Modul benötigt ein gültiges Root-Element.');
|
||||
const module = this.#modules.get(id);
|
||||
if (!module) throw new Error(`Frontend-Modul ist nicht registriert: ${id}`);
|
||||
await this.unmount();
|
||||
const controller = new AbortController();
|
||||
const context = Object.freeze({
|
||||
root,
|
||||
services: Object.freeze({ ...services }),
|
||||
signal: controller.signal,
|
||||
listen(target, event, handler, options = {}) {
|
||||
target.addEventListener(event, handler, { ...options, signal: controller.signal });
|
||||
},
|
||||
});
|
||||
await module.mount(context);
|
||||
this.#active = { module, context, controller };
|
||||
return module;
|
||||
}
|
||||
|
||||
async unmount() {
|
||||
if (!this.#active) return;
|
||||
const active = this.#active;
|
||||
this.#active = null;
|
||||
active.controller.abort();
|
||||
await active.module.unmount(active.context);
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.#modules.values()].map(({ mount: _mount, unmount: _unmount, ...module }) => ({ ...module }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function setText(node, value) {
|
||||
if (!(node instanceof Node)) throw new TypeError('DOM-Ziel fehlt.');
|
||||
node.textContent = value == null ? '' : String(value);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createElement(tagName, { className = '', text = '', attributes = {}, children = [] } = {}) {
|
||||
if (!/^[a-z][a-z0-9-]*$/i.test(String(tagName))) throw new TypeError('Ungültiger Elementname.');
|
||||
const element = document.createElement(tagName);
|
||||
if (className) element.className = String(className);
|
||||
if (text !== '') setText(element, text);
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
if (!/^(aria-[a-z-]+|data-[a-z-]+|id|role|title|type|name|value|href|src|alt|tabindex)$/i.test(name)) {
|
||||
throw new TypeError(`Attribut ist nicht freigegeben: ${name}`);
|
||||
}
|
||||
if (['href', 'src'].includes(name.toLowerCase())) {
|
||||
const url = new URL(String(value), window.location.origin);
|
||||
if (!['http:', 'https:', 'blob:'].includes(url.protocol) || (['http:', 'https:'].includes(url.protocol) && url.origin !== window.location.origin)) {
|
||||
throw new TypeError(`Externe oder unsichere URL ist nicht erlaubt: ${name}`);
|
||||
}
|
||||
}
|
||||
element.setAttribute(name, String(value));
|
||||
}
|
||||
for (const child of children) element.append(child);
|
||||
return element;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contractVersion": "1.1.0",
|
||||
"themes": [
|
||||
"light",
|
||||
"dark",
|
||||
"contrast"
|
||||
],
|
||||
"tokens": {
|
||||
"color.canvas": {
|
||||
"css": "--vd-canvas",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Seitenhintergrund",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.surface": {
|
||||
"css": "--vd-surface",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Fläche",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.surfaceRaised": {
|
||||
"css": "--vd-surface-raised",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Erhöhte Fläche",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.surfaceMuted": {
|
||||
"css": "--vd-surface-muted",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Gedämpfte Fläche",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.surfaceQuiet": {
|
||||
"css": "--vd-surface-quiet",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Ruhige Fläche",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.line": {
|
||||
"css": "--vd-line",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Trennlinie",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.lineStrong": {
|
||||
"css": "--vd-line-strong",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Starke Trennlinie",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.ink": {
|
||||
"css": "--vd-ink",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Primärtext",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.inkSecondary": {
|
||||
"css": "--vd-ink-secondary",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Sekundärtext",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.inkMuted": {
|
||||
"css": "--vd-ink-muted",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Hinweistext",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.brand": {
|
||||
"css": "--vd-brand",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Akzentfarbe",
|
||||
"group": "Marke"
|
||||
},
|
||||
"color.brandStrong": {
|
||||
"css": "--vd-brand-strong",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Akzentfarbe kräftig",
|
||||
"group": "Marke"
|
||||
},
|
||||
"color.brandSoft": {
|
||||
"css": "--vd-brand-soft",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"label": "color.brandSoft",
|
||||
"group": "Color"
|
||||
},
|
||||
"color.success": {
|
||||
"css": "--vd-success",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Erfolg",
|
||||
"group": "Status"
|
||||
},
|
||||
"color.successSoft": {
|
||||
"css": "--vd-success-soft",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"label": "color.successSoft",
|
||||
"group": "Color"
|
||||
},
|
||||
"color.warning": {
|
||||
"css": "--vd-warning",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Warnung",
|
||||
"group": "Status"
|
||||
},
|
||||
"color.warningSoft": {
|
||||
"css": "--vd-warning-soft",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"label": "color.warningSoft",
|
||||
"group": "Color"
|
||||
},
|
||||
"color.danger": {
|
||||
"css": "--vd-danger",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Gefahr",
|
||||
"group": "Status"
|
||||
},
|
||||
"color.dangerSoft": {
|
||||
"css": "--vd-danger-soft",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"label": "color.dangerSoft",
|
||||
"group": "Color"
|
||||
},
|
||||
"color.info": {
|
||||
"css": "--vd-info",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"label": "Information",
|
||||
"group": "Status"
|
||||
},
|
||||
"color.infoSoft": {
|
||||
"css": "--vd-info-soft",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"label": "color.infoSoft",
|
||||
"group": "Color"
|
||||
},
|
||||
"color.focus": {
|
||||
"css": "--vd-focus",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"label": "color.focus",
|
||||
"group": "Color"
|
||||
},
|
||||
"shadow.float": {
|
||||
"css": "--vd-shadow-float",
|
||||
"type": "shadow",
|
||||
"customizable": false,
|
||||
"label": "shadow.float",
|
||||
"group": "Shadow"
|
||||
},
|
||||
"shadow.soft": {
|
||||
"css": "--vd-shadow-soft",
|
||||
"type": "shadow",
|
||||
"customizable": false,
|
||||
"label": "shadow.soft",
|
||||
"group": "Shadow"
|
||||
},
|
||||
"radius.xs": {
|
||||
"css": "--vd-radius-xs",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Rundung klein",
|
||||
"group": "Form",
|
||||
"min": 0,
|
||||
"max": 16
|
||||
},
|
||||
"radius.sm": {
|
||||
"css": "--vd-radius-sm",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Rundung mittel",
|
||||
"group": "Form",
|
||||
"min": 0,
|
||||
"max": 20
|
||||
},
|
||||
"radius.md": {
|
||||
"css": "--vd-radius-md",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Rundung groß",
|
||||
"group": "Form",
|
||||
"min": 0,
|
||||
"max": 28
|
||||
},
|
||||
"layout.sidebarWidth": {
|
||||
"css": "--vd-sidebar-width",
|
||||
"type": "dimension",
|
||||
"customizable": false,
|
||||
"label": "layout.sidebarWidth",
|
||||
"group": "Layout"
|
||||
},
|
||||
"layout.topbarHeight": {
|
||||
"css": "--vd-topbar-height",
|
||||
"type": "dimension",
|
||||
"customizable": false,
|
||||
"label": "layout.topbarHeight",
|
||||
"group": "Layout"
|
||||
},
|
||||
"density.controlHeight": {
|
||||
"css": "--vd-control-height",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Bedienelementhöhe",
|
||||
"group": "Dichte",
|
||||
"min": 32,
|
||||
"max": 52
|
||||
},
|
||||
"density.contentGap": {
|
||||
"css": "--vd-content-gap",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Inhaltsabstand",
|
||||
"group": "Dichte",
|
||||
"min": 8,
|
||||
"max": 32
|
||||
},
|
||||
"motion.fast": {
|
||||
"css": "--vd-motion-fast",
|
||||
"type": "duration",
|
||||
"customizable": false,
|
||||
"label": "motion.fast",
|
||||
"group": "Motion"
|
||||
},
|
||||
"motion.normal": {
|
||||
"css": "--vd-motion-normal",
|
||||
"type": "duration",
|
||||
"customizable": false,
|
||||
"label": "motion.normal",
|
||||
"group": "Motion"
|
||||
},
|
||||
"component.buttonHeight": {
|
||||
"css": "--vd-component-button-height",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Button-Höhe",
|
||||
"group": "Komponenten",
|
||||
"min": 32,
|
||||
"max": 56
|
||||
},
|
||||
"component.buttonRadius": {
|
||||
"css": "--vd-component-button-radius",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Button-Rundung",
|
||||
"group": "Komponenten",
|
||||
"min": 0,
|
||||
"max": 24
|
||||
},
|
||||
"component.inputHeight": {
|
||||
"css": "--vd-component-input-height",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Eingabefeld-Höhe",
|
||||
"group": "Komponenten",
|
||||
"min": 34,
|
||||
"max": 58
|
||||
},
|
||||
"component.inputRadius": {
|
||||
"css": "--vd-component-input-radius",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Eingabefeld-Rundung",
|
||||
"group": "Komponenten",
|
||||
"min": 0,
|
||||
"max": 24
|
||||
},
|
||||
"component.cardRadius": {
|
||||
"css": "--vd-component-card-radius",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Karten-Rundung",
|
||||
"group": "Komponenten",
|
||||
"min": 0,
|
||||
"max": 32
|
||||
},
|
||||
"component.cardPadding": {
|
||||
"css": "--vd-component-card-padding",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Karten-Innenabstand",
|
||||
"group": "Komponenten",
|
||||
"min": 8,
|
||||
"max": 32
|
||||
},
|
||||
"component.dialogRadius": {
|
||||
"css": "--vd-component-dialog-radius",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Dialog-Rundung",
|
||||
"group": "Komponenten",
|
||||
"min": 0,
|
||||
"max": 36
|
||||
},
|
||||
"component.focusWidth": {
|
||||
"css": "--vd-component-focus-width",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Fokus-Ring Stärke",
|
||||
"group": "Barrierefreiheit",
|
||||
"min": 2,
|
||||
"max": 6
|
||||
},
|
||||
"component.touchTarget": {
|
||||
"css": "--vd-component-touch-target",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"label": "Mindest-Zielgröße",
|
||||
"group": "Barrierefreiheit",
|
||||
"min": 40,
|
||||
"max": 56
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
const STORAGE_KEY = 'vendoo-theme-state-v2';
|
||||
const LEGACY_KEY = 'vendoo-theme';
|
||||
const BUILTIN = new Set(['light', 'dark', 'contrast', 'system']);
|
||||
let activeCustomProperties = [];
|
||||
|
||||
function mediaDark() {
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
function mediaReducedMotion() {
|
||||
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
function clearCustomProperties() {
|
||||
for (const property of activeCustomProperties) document.documentElement.style.removeProperty(property);
|
||||
activeCustomProperties = [];
|
||||
}
|
||||
|
||||
function effectiveBuiltin(themeId) {
|
||||
return themeId === 'system' ? (mediaDark() ? 'dark' : 'light') : themeId;
|
||||
}
|
||||
|
||||
function normalizeState(input = {}) {
|
||||
const themeId = String(input.theme_id || input.themeId || 'system');
|
||||
return {
|
||||
theme_id: themeId,
|
||||
density: ['compact', 'comfortable', 'spacious'].includes(input.density) ? input.density : 'comfortable',
|
||||
reduced_motion: ['system', 'reduce', 'allow'].includes(input.reduced_motion || input.reducedMotion) ? (input.reduced_motion || input.reducedMotion) : 'system',
|
||||
custom_theme: input.custom_theme || input.customTheme || null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyState(input, { persist = true, dispatch = true } = {}) {
|
||||
const state = normalizeState(input);
|
||||
clearCustomProperties();
|
||||
const custom = state.custom_theme && state.custom_theme.id === state.theme_id ? state.custom_theme : null;
|
||||
const baseTheme = custom ? custom.base_theme : state.theme_id;
|
||||
const resolved = effectiveBuiltin(BUILTIN.has(baseTheme) ? baseTheme : 'light');
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
document.documentElement.dataset.themePreference = state.theme_id;
|
||||
document.documentElement.dataset.density = state.density;
|
||||
const reduce = state.reduced_motion === 'reduce' || (state.reduced_motion === 'system' && mediaReducedMotion());
|
||||
document.documentElement.dataset.reducedMotion = reduce ? 'true' : 'false';
|
||||
if (custom?.css_values) {
|
||||
for (const [property, value] of Object.entries(custom.css_values)) {
|
||||
if (!/^--vd-[a-z0-9-]+$/.test(property)) continue;
|
||||
document.documentElement.style.setProperty(property, String(value));
|
||||
activeCustomProperties.push(property);
|
||||
}
|
||||
}
|
||||
if (persist) localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
if (dispatch) window.dispatchEvent(new CustomEvent('vendoo:theme-changed', { detail: { ...state, resolved } }));
|
||||
return { ...state, resolved };
|
||||
}
|
||||
|
||||
function readStoredState() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||||
if (parsed) return normalizeState(parsed);
|
||||
} catch {}
|
||||
const legacy = localStorage.getItem(LEGACY_KEY);
|
||||
return normalizeState({ theme_id: BUILTIN.has(legacy) ? legacy : 'system' });
|
||||
}
|
||||
|
||||
function previewTheme({ base_theme = 'light', css_values = {}, density = 'comfortable', reduced_motion = 'system' } = {}) {
|
||||
return applyState({ theme_id: '__preview__', density, reduced_motion, custom_theme: { id: '__preview__', base_theme, css_values } }, { persist: false });
|
||||
}
|
||||
|
||||
function restoreStored() {
|
||||
return applyState(readStoredState(), { persist: false });
|
||||
}
|
||||
|
||||
applyState(readStoredState(), { persist: false, dispatch: false });
|
||||
window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener?.('change', () => {
|
||||
if (document.documentElement.dataset.themePreference === 'system') restoreStored();
|
||||
});
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').addEventListener?.('change', () => restoreStored());
|
||||
|
||||
window.VendooTheme = Object.freeze({
|
||||
get preference() { return document.documentElement.dataset.themePreference || 'system'; },
|
||||
get resolved() { return document.documentElement.dataset.theme || 'light'; },
|
||||
applyState,
|
||||
previewTheme,
|
||||
restoreStored,
|
||||
setTheme(themeId, { persist = true } = {}) {
|
||||
const current = readStoredState();
|
||||
return applyState({ ...current, theme_id: BUILTIN.has(themeId) ? themeId : 'system', custom_theme: null }, { persist });
|
||||
},
|
||||
supported: Object.freeze([...BUILTIN]),
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/* Automatisch generiert aus design-system/tokens/source.json. Nicht direkt bearbeiten. */
|
||||
/* Contract 1.1.0 */
|
||||
:root {
|
||||
--vd-canvas: #f7f5f1;
|
||||
--vd-surface: #fffdf9;
|
||||
--vd-surface-raised: #ffffff;
|
||||
--vd-surface-muted: #f1efea;
|
||||
--vd-surface-quiet: #ebe8e1;
|
||||
--vd-line: #e2ded6;
|
||||
--vd-line-strong: #cbc5ba;
|
||||
--vd-ink: #171714;
|
||||
--vd-ink-secondary: #5f5b54;
|
||||
--vd-ink-muted: #8c877e;
|
||||
--vd-brand: #e33a16;
|
||||
--vd-brand-strong: #c92f10;
|
||||
--vd-brand-soft: rgba(227, 58, 22, 0.08);
|
||||
--vd-success: #2f7d50;
|
||||
--vd-success-soft: #e7f2ea;
|
||||
--vd-warning: #b96a23;
|
||||
--vd-warning-soft: #fbefe3;
|
||||
--vd-danger: #c44234;
|
||||
--vd-danger-soft: #f9e8e5;
|
||||
--vd-info: #3975ba;
|
||||
--vd-info-soft: #e8f0f9;
|
||||
--vd-focus: rgba(227, 58, 22, 0.18);
|
||||
--vd-shadow-float: 0 18px 48px rgba(35, 30, 24, 0.12);
|
||||
--vd-shadow-soft: 0 8px 24px rgba(35, 30, 24, 0.06);
|
||||
--vd-radius-xs: 5px;
|
||||
--vd-radius-sm: 8px;
|
||||
--vd-radius-md: 12px;
|
||||
--vd-sidebar-width: 148px;
|
||||
--vd-topbar-height: 60px;
|
||||
--vd-control-height: 40px;
|
||||
--vd-content-gap: 16px;
|
||||
--vd-motion-fast: 120ms;
|
||||
--vd-motion-normal: 180ms;
|
||||
--vd-component-button-height: 40px;
|
||||
--vd-component-button-radius: 8px;
|
||||
--vd-component-input-height: 42px;
|
||||
--vd-component-input-radius: 8px;
|
||||
--vd-component-card-radius: 12px;
|
||||
--vd-component-card-padding: 16px;
|
||||
--vd-component-dialog-radius: 16px;
|
||||
--vd-component-focus-width: 3px;
|
||||
--vd-component-touch-target: 44px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--vd-canvas: #171714;
|
||||
--vd-surface: #1d1d19;
|
||||
--vd-surface-raised: #22221e;
|
||||
--vd-surface-muted: #292923;
|
||||
--vd-surface-quiet: #303029;
|
||||
--vd-line: #37372f;
|
||||
--vd-line-strong: #4a4940;
|
||||
--vd-ink: #f2efe8;
|
||||
--vd-ink-secondary: #c3beb4;
|
||||
--vd-ink-muted: #8f8a80;
|
||||
--vd-brand: #ff5a32;
|
||||
--vd-brand-strong: #ff744f;
|
||||
--vd-brand-soft: rgba(255, 90, 50, 0.12);
|
||||
--vd-success: #75bd8c;
|
||||
--vd-success-soft: #203629;
|
||||
--vd-warning: #e2a35f;
|
||||
--vd-warning-soft: #3b2e20;
|
||||
--vd-danger: #ed7568;
|
||||
--vd-danger-soft: #3b2523;
|
||||
--vd-info: #78a9df;
|
||||
--vd-info-soft: #202f40;
|
||||
--vd-focus: rgba(255, 90, 50, 0.26);
|
||||
--vd-shadow-float: 0 20px 56px rgba(0, 0, 0, 0.38);
|
||||
--vd-shadow-soft: 0 8px 26px rgba(0, 0, 0, 0.20);
|
||||
--vd-radius-xs: 5px;
|
||||
--vd-radius-sm: 8px;
|
||||
--vd-radius-md: 12px;
|
||||
--vd-sidebar-width: 148px;
|
||||
--vd-topbar-height: 60px;
|
||||
--vd-control-height: 40px;
|
||||
--vd-content-gap: 16px;
|
||||
--vd-motion-fast: 120ms;
|
||||
--vd-motion-normal: 180ms;
|
||||
--vd-component-button-height: 40px;
|
||||
--vd-component-button-radius: 8px;
|
||||
--vd-component-input-height: 42px;
|
||||
--vd-component-input-radius: 8px;
|
||||
--vd-component-card-radius: 12px;
|
||||
--vd-component-card-padding: 16px;
|
||||
--vd-component-dialog-radius: 16px;
|
||||
--vd-component-focus-width: 3px;
|
||||
--vd-component-touch-target: 44px;
|
||||
}
|
||||
|
||||
[data-theme="contrast"] {
|
||||
--vd-canvas: #000000;
|
||||
--vd-surface: #090909;
|
||||
--vd-surface-raised: #111111;
|
||||
--vd-surface-muted: #1b1b1b;
|
||||
--vd-surface-quiet: #242424;
|
||||
--vd-line: #ffffff;
|
||||
--vd-line-strong: #ffffff;
|
||||
--vd-ink: #ffffff;
|
||||
--vd-ink-secondary: #ffffff;
|
||||
--vd-ink-muted: #f2f2f2;
|
||||
--vd-brand: #ffe600;
|
||||
--vd-brand-strong: #ffffff;
|
||||
--vd-brand-soft: rgba(255, 230, 0, 0.20);
|
||||
--vd-success: #66ff99;
|
||||
--vd-success-soft: #002d12;
|
||||
--vd-warning: #ffd000;
|
||||
--vd-warning-soft: #332a00;
|
||||
--vd-danger: #ff6b6b;
|
||||
--vd-danger-soft: #3a0000;
|
||||
--vd-info: #72c7ff;
|
||||
--vd-info-soft: #00253d;
|
||||
--vd-focus: rgba(255, 230, 0, 0.55);
|
||||
--vd-shadow-float: 0 0 0 2px #ffffff;
|
||||
--vd-shadow-soft: 0 0 0 1px #ffffff;
|
||||
--vd-radius-xs: 0px;
|
||||
--vd-radius-sm: 2px;
|
||||
--vd-radius-md: 4px;
|
||||
--vd-sidebar-width: 168px;
|
||||
--vd-topbar-height: 64px;
|
||||
--vd-control-height: 44px;
|
||||
--vd-content-gap: 18px;
|
||||
--vd-motion-fast: 0ms;
|
||||
--vd-motion-normal: 0ms;
|
||||
--vd-component-button-height: 46px;
|
||||
--vd-component-button-radius: 2px;
|
||||
--vd-component-input-height: 48px;
|
||||
--vd-component-input-radius: 2px;
|
||||
--vd-component-card-radius: 4px;
|
||||
--vd-component-card-padding: 18px;
|
||||
--vd-component-dialog-radius: 4px;
|
||||
--vd-component-focus-width: 4px;
|
||||
--vd-component-touch-target: 48px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root { --vd-motion-fast: 0ms; --vd-motion-normal: 0ms; }
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contractVersion": "1.1.0",
|
||||
"themes": [
|
||||
"light",
|
||||
"dark",
|
||||
"contrast"
|
||||
],
|
||||
"tokens": {
|
||||
"color.canvas": {
|
||||
"css": "--vd-canvas",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#f7f5f1",
|
||||
"dark": "#171714",
|
||||
"contrast": "#000000",
|
||||
"label": "Seitenhintergrund",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.surface": {
|
||||
"css": "--vd-surface",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#fffdf9",
|
||||
"dark": "#1d1d19",
|
||||
"contrast": "#090909",
|
||||
"label": "Fläche",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.surfaceRaised": {
|
||||
"css": "--vd-surface-raised",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#ffffff",
|
||||
"dark": "#22221e",
|
||||
"contrast": "#111111",
|
||||
"label": "Erhöhte Fläche",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.surfaceMuted": {
|
||||
"css": "--vd-surface-muted",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#f1efea",
|
||||
"dark": "#292923",
|
||||
"contrast": "#1b1b1b",
|
||||
"label": "Gedämpfte Fläche",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.surfaceQuiet": {
|
||||
"css": "--vd-surface-quiet",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#ebe8e1",
|
||||
"dark": "#303029",
|
||||
"contrast": "#242424",
|
||||
"label": "Ruhige Fläche",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.line": {
|
||||
"css": "--vd-line",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#e2ded6",
|
||||
"dark": "#37372f",
|
||||
"contrast": "#ffffff",
|
||||
"label": "Trennlinie",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.lineStrong": {
|
||||
"css": "--vd-line-strong",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#cbc5ba",
|
||||
"dark": "#4a4940",
|
||||
"contrast": "#ffffff",
|
||||
"label": "Starke Trennlinie",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.ink": {
|
||||
"css": "--vd-ink",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#171714",
|
||||
"dark": "#f2efe8",
|
||||
"contrast": "#ffffff",
|
||||
"label": "Primärtext",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.inkSecondary": {
|
||||
"css": "--vd-ink-secondary",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#5f5b54",
|
||||
"dark": "#c3beb4",
|
||||
"contrast": "#ffffff",
|
||||
"label": "Sekundärtext",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.inkMuted": {
|
||||
"css": "--vd-ink-muted",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#8c877e",
|
||||
"dark": "#8f8a80",
|
||||
"contrast": "#f2f2f2",
|
||||
"label": "Hinweistext",
|
||||
"group": "Farben"
|
||||
},
|
||||
"color.brand": {
|
||||
"css": "--vd-brand",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#e33a16",
|
||||
"dark": "#ff5a32",
|
||||
"contrast": "#ffe600",
|
||||
"label": "Akzentfarbe",
|
||||
"group": "Marke"
|
||||
},
|
||||
"color.brandStrong": {
|
||||
"css": "--vd-brand-strong",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#c92f10",
|
||||
"dark": "#ff744f",
|
||||
"contrast": "#ffffff",
|
||||
"label": "Akzentfarbe kräftig",
|
||||
"group": "Marke"
|
||||
},
|
||||
"color.brandSoft": {
|
||||
"css": "--vd-brand-soft",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"light": "rgba(227, 58, 22, 0.08)",
|
||||
"dark": "rgba(255, 90, 50, 0.12)",
|
||||
"contrast": "rgba(255, 230, 0, 0.20)",
|
||||
"label": "color.brandSoft",
|
||||
"group": "Color"
|
||||
},
|
||||
"color.success": {
|
||||
"css": "--vd-success",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#2f7d50",
|
||||
"dark": "#75bd8c",
|
||||
"contrast": "#66ff99",
|
||||
"label": "Erfolg",
|
||||
"group": "Status"
|
||||
},
|
||||
"color.successSoft": {
|
||||
"css": "--vd-success-soft",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"light": "#e7f2ea",
|
||||
"dark": "#203629",
|
||||
"contrast": "#002d12",
|
||||
"label": "color.successSoft",
|
||||
"group": "Color"
|
||||
},
|
||||
"color.warning": {
|
||||
"css": "--vd-warning",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#b96a23",
|
||||
"dark": "#e2a35f",
|
||||
"contrast": "#ffd000",
|
||||
"label": "Warnung",
|
||||
"group": "Status"
|
||||
},
|
||||
"color.warningSoft": {
|
||||
"css": "--vd-warning-soft",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"light": "#fbefe3",
|
||||
"dark": "#3b2e20",
|
||||
"contrast": "#332a00",
|
||||
"label": "color.warningSoft",
|
||||
"group": "Color"
|
||||
},
|
||||
"color.danger": {
|
||||
"css": "--vd-danger",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#c44234",
|
||||
"dark": "#ed7568",
|
||||
"contrast": "#ff6b6b",
|
||||
"label": "Gefahr",
|
||||
"group": "Status"
|
||||
},
|
||||
"color.dangerSoft": {
|
||||
"css": "--vd-danger-soft",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"light": "#f9e8e5",
|
||||
"dark": "#3b2523",
|
||||
"contrast": "#3a0000",
|
||||
"label": "color.dangerSoft",
|
||||
"group": "Color"
|
||||
},
|
||||
"color.info": {
|
||||
"css": "--vd-info",
|
||||
"type": "color",
|
||||
"customizable": true,
|
||||
"light": "#3975ba",
|
||||
"dark": "#78a9df",
|
||||
"contrast": "#72c7ff",
|
||||
"label": "Information",
|
||||
"group": "Status"
|
||||
},
|
||||
"color.infoSoft": {
|
||||
"css": "--vd-info-soft",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"light": "#e8f0f9",
|
||||
"dark": "#202f40",
|
||||
"contrast": "#00253d",
|
||||
"label": "color.infoSoft",
|
||||
"group": "Color"
|
||||
},
|
||||
"color.focus": {
|
||||
"css": "--vd-focus",
|
||||
"type": "color",
|
||||
"customizable": false,
|
||||
"light": "rgba(227, 58, 22, 0.18)",
|
||||
"dark": "rgba(255, 90, 50, 0.26)",
|
||||
"contrast": "rgba(255, 230, 0, 0.55)",
|
||||
"label": "color.focus",
|
||||
"group": "Color"
|
||||
},
|
||||
"shadow.float": {
|
||||
"css": "--vd-shadow-float",
|
||||
"type": "shadow",
|
||||
"customizable": false,
|
||||
"light": "0 18px 48px rgba(35, 30, 24, 0.12)",
|
||||
"dark": "0 20px 56px rgba(0, 0, 0, 0.38)",
|
||||
"contrast": "0 0 0 2px #ffffff",
|
||||
"label": "shadow.float",
|
||||
"group": "Shadow"
|
||||
},
|
||||
"shadow.soft": {
|
||||
"css": "--vd-shadow-soft",
|
||||
"type": "shadow",
|
||||
"customizable": false,
|
||||
"light": "0 8px 24px rgba(35, 30, 24, 0.06)",
|
||||
"dark": "0 8px 26px rgba(0, 0, 0, 0.20)",
|
||||
"contrast": "0 0 0 1px #ffffff",
|
||||
"label": "shadow.soft",
|
||||
"group": "Shadow"
|
||||
},
|
||||
"radius.xs": {
|
||||
"css": "--vd-radius-xs",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 0,
|
||||
"max": 16,
|
||||
"light": "5px",
|
||||
"dark": "5px",
|
||||
"contrast": "0px",
|
||||
"label": "Rundung klein",
|
||||
"group": "Form"
|
||||
},
|
||||
"radius.sm": {
|
||||
"css": "--vd-radius-sm",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 0,
|
||||
"max": 20,
|
||||
"light": "8px",
|
||||
"dark": "8px",
|
||||
"contrast": "2px",
|
||||
"label": "Rundung mittel",
|
||||
"group": "Form"
|
||||
},
|
||||
"radius.md": {
|
||||
"css": "--vd-radius-md",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 0,
|
||||
"max": 28,
|
||||
"light": "12px",
|
||||
"dark": "12px",
|
||||
"contrast": "4px",
|
||||
"label": "Rundung groß",
|
||||
"group": "Form"
|
||||
},
|
||||
"layout.sidebarWidth": {
|
||||
"css": "--vd-sidebar-width",
|
||||
"type": "dimension",
|
||||
"customizable": false,
|
||||
"light": "148px",
|
||||
"dark": "148px",
|
||||
"contrast": "168px",
|
||||
"label": "layout.sidebarWidth",
|
||||
"group": "Layout"
|
||||
},
|
||||
"layout.topbarHeight": {
|
||||
"css": "--vd-topbar-height",
|
||||
"type": "dimension",
|
||||
"customizable": false,
|
||||
"light": "60px",
|
||||
"dark": "60px",
|
||||
"contrast": "64px",
|
||||
"label": "layout.topbarHeight",
|
||||
"group": "Layout"
|
||||
},
|
||||
"density.controlHeight": {
|
||||
"css": "--vd-control-height",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 32,
|
||||
"max": 52,
|
||||
"light": "40px",
|
||||
"dark": "40px",
|
||||
"contrast": "44px",
|
||||
"label": "Bedienelementhöhe",
|
||||
"group": "Dichte"
|
||||
},
|
||||
"density.contentGap": {
|
||||
"css": "--vd-content-gap",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 8,
|
||||
"max": 32,
|
||||
"light": "16px",
|
||||
"dark": "16px",
|
||||
"contrast": "18px",
|
||||
"label": "Inhaltsabstand",
|
||||
"group": "Dichte"
|
||||
},
|
||||
"motion.fast": {
|
||||
"css": "--vd-motion-fast",
|
||||
"type": "duration",
|
||||
"customizable": false,
|
||||
"light": "120ms",
|
||||
"dark": "120ms",
|
||||
"contrast": "0ms",
|
||||
"label": "motion.fast",
|
||||
"group": "Motion"
|
||||
},
|
||||
"motion.normal": {
|
||||
"css": "--vd-motion-normal",
|
||||
"type": "duration",
|
||||
"customizable": false,
|
||||
"light": "180ms",
|
||||
"dark": "180ms",
|
||||
"contrast": "0ms",
|
||||
"label": "motion.normal",
|
||||
"group": "Motion"
|
||||
},
|
||||
"component.buttonHeight": {
|
||||
"css": "--vd-component-button-height",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 32,
|
||||
"max": 56,
|
||||
"light": "40px",
|
||||
"dark": "40px",
|
||||
"contrast": "46px",
|
||||
"label": "Button-Höhe",
|
||||
"group": "Komponenten"
|
||||
},
|
||||
"component.buttonRadius": {
|
||||
"css": "--vd-component-button-radius",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 0,
|
||||
"max": 24,
|
||||
"light": "8px",
|
||||
"dark": "8px",
|
||||
"contrast": "2px",
|
||||
"label": "Button-Rundung",
|
||||
"group": "Komponenten"
|
||||
},
|
||||
"component.inputHeight": {
|
||||
"css": "--vd-component-input-height",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 34,
|
||||
"max": 58,
|
||||
"light": "42px",
|
||||
"dark": "42px",
|
||||
"contrast": "48px",
|
||||
"label": "Eingabefeld-Höhe",
|
||||
"group": "Komponenten"
|
||||
},
|
||||
"component.inputRadius": {
|
||||
"css": "--vd-component-input-radius",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 0,
|
||||
"max": 24,
|
||||
"light": "8px",
|
||||
"dark": "8px",
|
||||
"contrast": "2px",
|
||||
"label": "Eingabefeld-Rundung",
|
||||
"group": "Komponenten"
|
||||
},
|
||||
"component.cardRadius": {
|
||||
"css": "--vd-component-card-radius",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 0,
|
||||
"max": 32,
|
||||
"light": "12px",
|
||||
"dark": "12px",
|
||||
"contrast": "4px",
|
||||
"label": "Karten-Rundung",
|
||||
"group": "Komponenten"
|
||||
},
|
||||
"component.cardPadding": {
|
||||
"css": "--vd-component-card-padding",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 8,
|
||||
"max": 32,
|
||||
"light": "16px",
|
||||
"dark": "16px",
|
||||
"contrast": "18px",
|
||||
"label": "Karten-Innenabstand",
|
||||
"group": "Komponenten"
|
||||
},
|
||||
"component.dialogRadius": {
|
||||
"css": "--vd-component-dialog-radius",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 0,
|
||||
"max": 36,
|
||||
"light": "16px",
|
||||
"dark": "16px",
|
||||
"contrast": "4px",
|
||||
"label": "Dialog-Rundung",
|
||||
"group": "Komponenten"
|
||||
},
|
||||
"component.focusWidth": {
|
||||
"css": "--vd-component-focus-width",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 2,
|
||||
"max": 6,
|
||||
"light": "3px",
|
||||
"dark": "3px",
|
||||
"contrast": "4px",
|
||||
"label": "Fokus-Ring Stärke",
|
||||
"group": "Barrierefreiheit"
|
||||
},
|
||||
"component.touchTarget": {
|
||||
"css": "--vd-component-touch-target",
|
||||
"type": "dimension",
|
||||
"customizable": true,
|
||||
"min": 40,
|
||||
"max": 56,
|
||||
"light": "44px",
|
||||
"dark": "44px",
|
||||
"contrast": "48px",
|
||||
"label": "Mindest-Zielgröße",
|
||||
"group": "Barrierefreiheit"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vendoo — Login</title>
|
||||
<link rel="icon" href="/brand/favicons/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/brand/favicons/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/brand/favicons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/brand/favicons/favicon-48x48.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/brand/favicons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/brand/favicons/site.webmanifest">
|
||||
<meta name="msapplication-config" content="/brand/favicons/browserconfig.xml">
|
||||
<meta name="theme-color" content="#FAF6F2">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--accent: #e63b17; --accent-hover: #c92f0d;
|
||||
--bg: #f5f5f0; --card: #fff; --text: #1a1a1a; --text-muted: #888;
|
||||
--border: #e5e5e0; --shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
--radius: 12px;
|
||||
}
|
||||
body { background: var(--bg); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.login-wrap { width: 100%; max-width: 420px; }
|
||||
.login-card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.login-header { background: #fffdf9; padding: 28px 32px 24px; text-align: center; border-bottom: 1px solid var(--border); }
|
||||
.login-brand-logo { display:block; width:min(230px,78%); height:auto; margin:0 auto 8px; }
|
||||
.login-header h1 { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }
|
||||
.login-header p { color: var(--text-muted); font-size: 13px; margin-top: 6px; }
|
||||
.login-body { padding: 32px; }
|
||||
.login-body h2 { font-size: 18px; color: var(--text); margin-bottom: 8px; }
|
||||
.login-body p.subtitle { font-size: 13px; color: var(--text-muted); margin-bottom: 24px; line-height: 1.5; }
|
||||
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-group label { display: block; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||
.form-group input { width: 100%; padding: 12px 14px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; transition: border-color 0.2s; outline: none; background: var(--bg); }
|
||||
.form-group input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(74,124,89,0.1); }
|
||||
|
||||
.login-btn { width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.2s, transform 0.1s; }
|
||||
.login-btn:hover { background: var(--accent-hover); }
|
||||
.login-btn:active { transform: scale(0.98); }
|
||||
.login-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.status { margin-top: 16px; padding: 12px 14px; border-radius: 8px; font-size: 13px; display: none; line-height: 1.5; }
|
||||
.status.success { display: block; background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
|
||||
.status.error { display: block; background: #ffebee; color: #c62828; border: 1px solid #ffcdd2; }
|
||||
.status.info { display: block; background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.login-footer { padding: 20px 32px; background: #fafaf8; border-top: 1px solid var(--border); text-align: center; }
|
||||
.login-footer p { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; vertical-align: middle; margin-right: 6px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.pw-strength { height: 3px; border-radius: 2px; margin-top: 6px; transition: all 0.3s; }
|
||||
.pw-strength.weak { background: #e53935; width: 33%; }
|
||||
.pw-strength.medium { background: #fb8c00; width: 66%; }
|
||||
.pw-strength.strong { background: #43a047; width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<img class="login-brand-logo" src="/brand/logo/vendoo-logo-transparent.png" alt="Vendoo">
|
||||
<h1>Vendoo</h1>
|
||||
<p>Marketplace Listing Generator</p>
|
||||
</div>
|
||||
<div class="login-body">
|
||||
<!-- LOGIN VIEW -->
|
||||
<div id="login-view">
|
||||
<h2 id="view-title">Anmelden</h2>
|
||||
<p class="subtitle" id="view-subtitle">Melde dich mit deiner E-Mail und deinem Passwort an.</p>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" id="email" placeholder="deine@email.de" required autocomplete="email" autofocus>
|
||||
</div>
|
||||
<div class="form-group hidden" id="name-group">
|
||||
<label>Name</label>
|
||||
<input type="text" id="setup-name" placeholder="Dein Name" autocomplete="name">
|
||||
</div>
|
||||
<div class="form-group hidden" id="setup-token-group">
|
||||
<label>Einrichtungs-Code</label>
|
||||
<input type="password" id="setup-token" placeholder="Code aus der Docker-/NAS-Konfiguration" autocomplete="one-time-code">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Passwort</label>
|
||||
<input type="password" id="password" placeholder="••••••••" required autocomplete="current-password" minlength="6">
|
||||
<div class="pw-strength hidden" id="pw-strength"></div>
|
||||
</div>
|
||||
<div class="form-group hidden" id="pw-confirm-group">
|
||||
<label>Passwort bestätigen</label>
|
||||
<input type="password" id="password-confirm" placeholder="••••••••" autocomplete="new-password" minlength="6">
|
||||
</div>
|
||||
<button type="submit" class="login-btn" id="login-btn">Anmelden</button>
|
||||
</form>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-footer">
|
||||
<p id="footer-text">Zugang nur per Einladung — kontaktiere den Admin</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/login.js?v=1.41.2" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,151 @@
|
||||
let mode = 'login'; // 'login', 'setup', 'invite'
|
||||
let inviteToken = null;
|
||||
let setupTokenRequired = false;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// Show errors/success from URL params
|
||||
if (params.get('error')) {
|
||||
showStatus('error', decodeURIComponent(params.get('error')));
|
||||
}
|
||||
if (params.get('success')) {
|
||||
showStatus('success', decodeURIComponent(params.get('success')));
|
||||
}
|
||||
|
||||
// Check for invite token
|
||||
if (params.get('invite')) {
|
||||
inviteToken = params.get('invite');
|
||||
setMode('invite');
|
||||
} else {
|
||||
checkSetup();
|
||||
}
|
||||
|
||||
async function checkSetup() {
|
||||
try {
|
||||
const r = await fetch('/auth/setup-check');
|
||||
const data = await r.json();
|
||||
setupTokenRequired = Boolean(data.setupTokenRequired);
|
||||
if (data.setupMode) setMode('setup');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function setMode(m) {
|
||||
mode = m;
|
||||
const nameGroup = document.getElementById('name-group');
|
||||
const pwConfirm = document.getElementById('pw-confirm-group');
|
||||
const pwStrength = document.getElementById('pw-strength');
|
||||
const pwInput = document.getElementById('password');
|
||||
const setupTokenGroup = document.getElementById('setup-token-group');
|
||||
const setupTokenInput = document.getElementById('setup-token');
|
||||
|
||||
if (mode === 'setup') {
|
||||
document.getElementById('view-title').textContent = 'Ersten Admin anlegen';
|
||||
document.getElementById('view-subtitle').textContent = 'Noch kein User vorhanden. Erstelle den ersten Admin-Account.';
|
||||
document.getElementById('login-btn').textContent = 'Admin-Account erstellen';
|
||||
document.getElementById('footer-text').textContent = 'Erster Start — Admin-Setup';
|
||||
nameGroup.classList.remove('hidden');
|
||||
pwConfirm.classList.remove('hidden');
|
||||
pwStrength.classList.remove('hidden');
|
||||
pwInput.autocomplete = 'new-password';
|
||||
pwInput.minLength = 10;
|
||||
document.getElementById('password-confirm').minLength = 10;
|
||||
setupTokenGroup.classList.toggle('hidden', !setupTokenRequired);
|
||||
setupTokenInput.required = setupTokenRequired;
|
||||
} else if (mode === 'invite') {
|
||||
document.getElementById('view-title').textContent = 'Einladung annehmen';
|
||||
document.getElementById('view-subtitle').textContent = 'Setze deinen Namen und dein Passwort, um deinen Account zu aktivieren.';
|
||||
document.getElementById('login-btn').textContent = 'Account aktivieren';
|
||||
document.getElementById('footer-text').textContent = 'Einladung vom Admin';
|
||||
nameGroup.classList.remove('hidden');
|
||||
pwConfirm.classList.remove('hidden');
|
||||
pwStrength.classList.remove('hidden');
|
||||
pwInput.autocomplete = 'new-password';
|
||||
pwInput.minLength = 10;
|
||||
document.getElementById('password-confirm').minLength = 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Password strength indicator
|
||||
document.getElementById('password').addEventListener('input', (e) => {
|
||||
if (mode === 'login') return;
|
||||
const pw = e.target.value;
|
||||
const bar = document.getElementById('pw-strength');
|
||||
bar.className = 'pw-strength';
|
||||
if (pw.length === 0) { bar.classList.add('hidden'); return; }
|
||||
bar.classList.remove('hidden');
|
||||
const strong = pw.length >= 10 && /[a-z]/.test(pw) && /[A-Z]/.test(pw) && /[0-9]/.test(pw) && /[^A-Za-z0-9]/.test(pw);
|
||||
if (strong) bar.classList.add('strong');
|
||||
else if (pw.length >= 8) bar.classList.add('medium');
|
||||
else bar.classList.add('weak');
|
||||
});
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('login-btn');
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
if (!email || !password) return;
|
||||
|
||||
// Validate password confirm for setup/invite
|
||||
if (mode === 'setup' || mode === 'invite') {
|
||||
const confirm = document.getElementById('password-confirm').value;
|
||||
if (password !== confirm) {
|
||||
showStatus('error', 'Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
const strong = password.length >= 10 && /[a-z]/.test(password) && /[A-Z]/.test(password) && /[0-9]/.test(password) && /[^A-Za-z0-9]/.test(password);
|
||||
if (!strong) {
|
||||
showStatus('error', 'Passwort: mindestens 10 Zeichen sowie Kleinbuchstabe, Großbuchstabe, Zahl und Sonderzeichen');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span>Sende...';
|
||||
clearStatus();
|
||||
|
||||
try {
|
||||
let url, body;
|
||||
if (mode === 'setup') {
|
||||
url = '/auth/setup';
|
||||
body = { email, password, name: document.getElementById('setup-name').value.trim() || email.split('@')[0], setup_token: document.getElementById('setup-token').value };
|
||||
} else if (mode === 'invite') {
|
||||
url = '/auth/accept-invite';
|
||||
body = { token: inviteToken, password, name: document.getElementById('setup-name').value.trim() || '' };
|
||||
} else {
|
||||
url = '/auth/login';
|
||||
body = { email, password };
|
||||
}
|
||||
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await r.json();
|
||||
|
||||
if (r.ok && data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
return;
|
||||
} else if (!r.ok) {
|
||||
showStatus('error', data.error || 'Fehler aufgetreten');
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus('error', 'Verbindung zum Server fehlgeschlagen');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = mode === 'setup' ? 'Admin-Account erstellen' : mode === 'invite' ? 'Account aktivieren' : 'Anmelden';
|
||||
});
|
||||
|
||||
function showStatus(type, message) {
|
||||
const s = document.getElementById('status');
|
||||
s.className = `status ${type}`;
|
||||
s.textContent = message;
|
||||
}
|
||||
function clearStatus() {
|
||||
const s = document.getElementById('status');
|
||||
s.className = 'status';
|
||||
s.removeAttribute('style');
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#f7f4ee"><title>Upload-Link abgelaufen</title>
|
||||
<link rel="icon" href="/brand/favicons/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/brand/favicons/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/brand/favicons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/brand/favicons/favicon-48x48.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/brand/favicons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/brand/favicons/site.webmanifest">
|
||||
<meta name="msapplication-config" content="/brand/favicons/browserconfig.xml">
|
||||
<meta name="theme-color" content="#FAF6F2"><link rel="stylesheet" href="/mobile-upload.css"></head><body><main class="mobile-upload-shell"><header class="mobile-upload-brand"><img class="mobile-upload-brand-icon" src="/brand/icons/vendoo-icon-64x64.png" alt="" aria-hidden="true"><div><strong>Vendoo</strong><span>Mobiler Foto-Upload</span></div></header><section class="mobile-upload-card error"><div class="error-mark">!</div><h1>Upload-Link abgelaufen</h1><p>Erzeuge in Vendoo einen neuen QR-Code und scanne ihn erneut.</p></section></main></body></html>
|
||||
@@ -0,0 +1,77 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#f7f4ee">
|
||||
<title>Vendoo Foto-Upload</title>
|
||||
<link rel="icon" href="/brand/favicons/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/brand/favicons/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/brand/favicons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/brand/favicons/favicon-48x48.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/brand/favicons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/brand/favicons/site.webmanifest">
|
||||
<meta name="msapplication-config" content="/brand/favicons/browserconfig.xml">
|
||||
<meta name="theme-color" content="#FAF6F2">
|
||||
<link rel="stylesheet" href="/mobile-upload.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="mobile-upload-shell">
|
||||
<header class="mobile-upload-brand">
|
||||
<img class="mobile-upload-brand-icon" src="/brand/icons/vendoo-icon-64x64.png" alt="" aria-hidden="true">
|
||||
<div><strong>Vendoo</strong><span>Mobiler Foto-Upload</span></div>
|
||||
</header>
|
||||
|
||||
<section class="mobile-upload-card hero">
|
||||
<span class="eyebrow">Direkt vom Smartphone</span>
|
||||
<h1>Produktfotos aufnehmen</h1>
|
||||
<p>Fotografiere den Artikel oder wähle vorhandene Bilder. Sie erscheinen automatisch im geöffneten Vendoo-Studio.</p>
|
||||
<div id="session-status" class="session-status"><span></span>Upload-Link wird geprüft …</div>
|
||||
</section>
|
||||
|
||||
<section id="upload-panel" class="mobile-upload-card hidden">
|
||||
<div class="mobile-source-actions" role="group" aria-label="Fotoquelle wählen">
|
||||
<button id="open-camera-btn" class="camera-action" type="button">
|
||||
<span class="camera-icon">◉</span>
|
||||
<strong>Kamera öffnen</strong>
|
||||
<small>Neues Produktfoto aufnehmen</small>
|
||||
</button>
|
||||
<button id="open-library-btn" class="camera-action secondary-source" type="button">
|
||||
<span class="camera-icon">▦</span>
|
||||
<strong>Mediathek auswählen</strong>
|
||||
<small>Vorhandene Bilder vom Smartphone</small>
|
||||
</button>
|
||||
</div>
|
||||
<input id="camera-input" type="file" accept="image/*,.heic,.heif" capture="environment" hidden>
|
||||
<input id="library-input" type="file" accept="image/*,.heic,.heif" multiple hidden>
|
||||
<div class="upload-meta"><span id="photo-count">0 von 10 Bildern</span><span id="expires-copy"></span></div>
|
||||
<div id="mobile-preview" class="mobile-preview"></div>
|
||||
<button id="upload-btn" class="mobile-primary" type="button" disabled>Fotos zu Vendoo senden</button>
|
||||
<div class="mobile-more-actions">
|
||||
<button id="add-camera-btn" class="mobile-secondary" type="button">Weiteres Foto aufnehmen</button>
|
||||
<button id="add-library-btn" class="mobile-secondary" type="button">Weitere aus Mediathek</button>
|
||||
</div>
|
||||
<div id="upload-progress" class="upload-progress hidden"><span></span></div>
|
||||
</section>
|
||||
|
||||
<section id="success-panel" class="mobile-upload-card success hidden">
|
||||
<div class="success-mark">✓</div>
|
||||
<h2>Bilder wurden übertragen</h2>
|
||||
<p>Du kannst am Computer weiterarbeiten. Diese Seite darf geschlossen werden.</p>
|
||||
<div class="mobile-more-actions">
|
||||
<button id="success-camera-btn" class="mobile-secondary" type="button">Weiteres Foto aufnehmen</button>
|
||||
<button id="success-library-btn" class="mobile-secondary" type="button">Weitere aus Mediathek</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="error-panel" class="mobile-upload-card error hidden">
|
||||
<div class="error-mark">!</div>
|
||||
<h2>Upload nicht verfügbar</h2>
|
||||
<p id="error-copy">Der Link ist abgelaufen oder wurde geschlossen.</p>
|
||||
</section>
|
||||
|
||||
<footer>Die Bilder werden ausschließlich an deine Vendoo-Instanz übertragen.</footer>
|
||||
</main>
|
||||
<script src="/mobile-upload.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,169 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const token = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || '');
|
||||
const cameraInput = document.getElementById('camera-input');
|
||||
const libraryInput = document.getElementById('library-input');
|
||||
const preview = document.getElementById('mobile-preview');
|
||||
const uploadButton = document.getElementById('upload-btn');
|
||||
const uploadPanel = document.getElementById('upload-panel');
|
||||
const successPanel = document.getElementById('success-panel');
|
||||
const errorPanel = document.getElementById('error-panel');
|
||||
const status = document.getElementById('session-status');
|
||||
const count = document.getElementById('photo-count');
|
||||
const expiresCopy = document.getElementById('expires-copy');
|
||||
const progress = document.getElementById('upload-progress');
|
||||
const sourceButtons = [
|
||||
document.getElementById('open-camera-btn'),
|
||||
document.getElementById('open-library-btn'),
|
||||
document.getElementById('add-camera-btn'),
|
||||
document.getElementById('add-library-btn'),
|
||||
document.getElementById('success-camera-btn'),
|
||||
document.getElementById('success-library-btn'),
|
||||
].filter(Boolean);
|
||||
|
||||
let selected = [];
|
||||
let serverFiles = [];
|
||||
let remaining = 10;
|
||||
|
||||
function showError(message) {
|
||||
uploadPanel.classList.add('hidden');
|
||||
successPanel.classList.add('hidden');
|
||||
errorPanel.classList.remove('hidden');
|
||||
document.getElementById('error-copy').textContent = message;
|
||||
status.className = 'session-status';
|
||||
status.innerHTML = '<span></span>Upload nicht verfügbar';
|
||||
}
|
||||
|
||||
function openCamera() {
|
||||
if (remaining - selected.length <= 0) return;
|
||||
cameraInput.click();
|
||||
}
|
||||
|
||||
function openLibrary() {
|
||||
if (remaining - selected.length <= 0) return;
|
||||
libraryInput.click();
|
||||
}
|
||||
|
||||
function render() {
|
||||
preview.innerHTML = '';
|
||||
selected.forEach((file, index) => {
|
||||
const figure = document.createElement('figure');
|
||||
const image = document.createElement('img');
|
||||
image.alt = file.name || `Foto ${index + 1}`;
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
image.src = objectUrl;
|
||||
image.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true });
|
||||
image.addEventListener('error', () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
image.remove();
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'mobile-preview-fallback';
|
||||
fallback.innerHTML = `<strong>Bild ${index + 1}</strong><span>${file.name || 'Foto ausgewählt'}</span>`;
|
||||
figure.prepend(fallback);
|
||||
}, { once: true });
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = '×';
|
||||
remove.setAttribute('aria-label', `${file.name || 'Bild'} entfernen`);
|
||||
remove.addEventListener('click', () => {
|
||||
selected.splice(index, 1);
|
||||
render();
|
||||
});
|
||||
figure.append(image, remove);
|
||||
preview.appendChild(figure);
|
||||
});
|
||||
|
||||
const total = serverFiles.length + selected.length;
|
||||
count.textContent = `${total} von 10 Bildern`;
|
||||
uploadButton.disabled = selected.length === 0;
|
||||
uploadButton.textContent = selected.length
|
||||
? `${selected.length} ${selected.length === 1 ? 'Foto' : 'Fotos'} zu Vendoo senden`
|
||||
: 'Fotos zu Vendoo senden';
|
||||
|
||||
const noSlots = remaining - selected.length <= 0;
|
||||
sourceButtons.forEach(button => { button.disabled = noSlots; });
|
||||
}
|
||||
|
||||
function addFiles(files) {
|
||||
const candidates = [...files].filter(file => file.type.startsWith('image/') || /\.(heic|heif)$/i.test(file.name || ''));
|
||||
const slots = Math.max(0, remaining - selected.length);
|
||||
selected.push(...candidates.slice(0, slots));
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
try {
|
||||
const response = await fetch(`/mobile-upload-api/${encodeURIComponent(token)}`, { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Upload-Link konnte nicht geladen werden.');
|
||||
serverFiles = data.files || [];
|
||||
remaining = data.remaining ?? Math.max(0, 10 - serverFiles.length);
|
||||
status.className = 'session-status ok';
|
||||
status.innerHTML = '<span></span>Mit Vendoo verbunden';
|
||||
uploadPanel.classList.remove('hidden');
|
||||
const expires = new Date(String(data.expires_at).replace(' ', 'T') + (String(data.expires_at).includes('Z') ? '' : 'Z'));
|
||||
expiresCopy.textContent = Number.isNaN(expires.getTime())
|
||||
? ''
|
||||
: `gültig bis ${expires.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
render();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (!selected.length) return;
|
||||
uploadButton.disabled = true;
|
||||
sourceButtons.forEach(button => { button.disabled = true; });
|
||||
progress.classList.remove('hidden');
|
||||
try {
|
||||
const form = new FormData();
|
||||
selected.forEach(file => form.append('photos', file, file.name || `mobile-${Date.now()}.jpg`));
|
||||
const response = await fetch(`/mobile-upload-api/${encodeURIComponent(token)}/photos?platform=mobile`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Upload fehlgeschlagen.');
|
||||
serverFiles = data.files || serverFiles;
|
||||
remaining = data.remaining ?? Math.max(0, 10 - serverFiles.length);
|
||||
selected = [];
|
||||
render();
|
||||
uploadPanel.classList.add('hidden');
|
||||
successPanel.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
progress.classList.add('hidden');
|
||||
uploadButton.disabled = false;
|
||||
sourceButtons.forEach(button => { button.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
function returnToUploadAndOpen(opener) {
|
||||
successPanel.classList.add('hidden');
|
||||
uploadPanel.classList.remove('hidden');
|
||||
window.setTimeout(opener, 50);
|
||||
}
|
||||
|
||||
cameraInput.addEventListener('change', () => {
|
||||
addFiles(cameraInput.files || []);
|
||||
cameraInput.value = '';
|
||||
});
|
||||
libraryInput.addEventListener('change', () => {
|
||||
addFiles(libraryInput.files || []);
|
||||
libraryInput.value = '';
|
||||
});
|
||||
|
||||
document.getElementById('open-camera-btn').addEventListener('click', openCamera);
|
||||
document.getElementById('open-library-btn').addEventListener('click', openLibrary);
|
||||
document.getElementById('add-camera-btn').addEventListener('click', openCamera);
|
||||
document.getElementById('add-library-btn').addEventListener('click', openLibrary);
|
||||
document.getElementById('success-camera-btn').addEventListener('click', () => returnToUploadAndOpen(openCamera));
|
||||
document.getElementById('success-library-btn').addEventListener('click', () => returnToUploadAndOpen(openLibrary));
|
||||
uploadButton.addEventListener('click', upload);
|
||||
|
||||
loadSession();
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
# Frontend-Module
|
||||
|
||||
Neue Frontend-Module werden künftig als ES-Module unter diesem Verzeichnis angelegt und über `FrontendModuleRegistry` mit einem klaren `mount`-/`unmount`-Lebenszyklus betrieben.
|
||||
|
||||
Die Bestandsoberfläche aus `public/app.js` bleibt in 1.35.0 unverändert aktiv. Sie wird in späteren Releases tabweise migriert. Direkte neue globale Event-Listener und ungeprüfte `innerHTML`-Zuweisungen sind für migrierte Module nicht zulässig.
|
||||
|
||||
## Theme Manager 1.36.0
|
||||
|
||||
`theme-manager/` ist das erste sichtbare native Frontend-Fachmodul. Es nutzt den zentralen API-Client, sichere DOM-Erzeugung, Token-Verträge und einen klaren Vorschau-/Speicher-Lebenszyklus.
|
||||
@@ -0,0 +1 @@
|
||||
.module-manager-head{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.module-manager-head h3{margin:2px 0 6px}.module-manager-actions{display:flex;gap:8px;flex-wrap:wrap}.module-manager-summary{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-bottom:18px}.module-summary-card,.module-safety-card,.module-card{border:1px solid var(--vd-border-default,var(--border));background:var(--vd-surface-card,var(--card));border-radius:var(--vd-card-radius,16px);box-shadow:var(--vd-card-shadow,var(--shadow-sm))}.module-summary-card{padding:14px}.module-summary-card span{display:block;color:var(--vd-text-muted,var(--muted));font-size:.78rem}.module-summary-card strong{display:block;margin-top:4px;font-size:1.45rem}.module-safety-card{padding:15px 16px;margin-bottom:18px;display:flex;gap:12px;align-items:flex-start}.module-safety-card.is-warning{border-color:var(--vd-status-warning,var(--warning))}.module-install-card{border:1px dashed var(--vd-border-default,var(--border));border-radius:var(--vd-card-radius,16px);padding:16px;margin-bottom:18px;background:var(--vd-surface-subtle,var(--bg-soft))}.module-install-row{display:flex;gap:10px;align-items:end;flex-wrap:wrap}.module-install-row label{flex:1;min-width:250px}.module-install-row input[type=file]{display:block;width:100%;margin-top:6px}.module-filter-row{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}.module-filter-row input{min-width:260px;flex:1}.module-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.module-card{padding:16px;display:flex;flex-direction:column;gap:12px}.module-card.is-disabled{opacity:.78}.module-card.is-invalid{border-color:var(--vd-status-danger,var(--danger))}.module-card-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.module-card-title{display:flex;gap:10px;align-items:flex-start}.module-card-title h4{margin:0 0 3px}.module-card-title code{font-size:.72rem;color:var(--vd-text-muted,var(--muted))}.module-state-dot{width:10px;height:10px;border-radius:999px;margin-top:6px;background:var(--vd-text-muted,var(--muted));box-shadow:0 0 0 4px color-mix(in srgb,currentColor 12%,transparent)}.module-state-dot.is-running{background:var(--vd-status-success,var(--success))}.module-state-dot.is-disabled{background:var(--vd-status-warning,var(--warning))}.module-state-dot.is-failed,.module-state-dot.is-invalid{background:var(--vd-status-danger,var(--danger))}.module-badges{display:flex;gap:6px;flex-wrap:wrap}.module-badge{font-size:.7rem;padding:4px 7px;border-radius:999px;background:var(--vd-surface-subtle,var(--bg-soft));border:1px solid var(--vd-border-default,var(--border))}.module-description{margin:0;color:var(--vd-text-muted,var(--muted));font-size:.88rem;line-height:1.45}.module-dependencies{font-size:.78rem;color:var(--vd-text-muted,var(--muted))}.module-dependencies strong{color:var(--vd-text-primary,var(--text))}.module-card-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:auto}.module-card-actions button,.module-card-actions a{font-size:.78rem}.module-empty{grid-column:1/-1;padding:30px;text-align:center;color:var(--vd-text-muted,var(--muted))}.module-error{padding:10px;border-radius:10px;background:color-mix(in srgb,var(--vd-status-danger,var(--danger)) 10%,transparent);color:var(--vd-status-danger,var(--danger));font-size:.8rem}@media(max-width:1000px){.module-manager-summary{grid-template-columns:repeat(3,minmax(0,1fr))}.module-list{grid-template-columns:1fr}}@media(max-width:650px){.module-manager-head{flex-direction:column}.module-manager-summary{grid-template-columns:repeat(2,minmax(0,1fr))}.module-filter-row input{min-width:100%}.module-install-row{align-items:stretch}.module-install-row label{min-width:100%}}
|
||||
@@ -0,0 +1,189 @@
|
||||
const state = { data: null, filter: '', busy: false };
|
||||
|
||||
function csrfToken() {
|
||||
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
}
|
||||
|
||||
async function request(method, url, body, { multipart = false } = {}) {
|
||||
const headers = { 'X-Vendoo-Request-ID': globalThis.crypto?.randomUUID?.() || `modules-${Date.now()}` };
|
||||
const options = { method, credentials: 'same-origin', headers };
|
||||
if (!['GET', 'HEAD'].includes(method)) headers['X-CSRF-Token'] = csrfToken();
|
||||
if (body !== undefined) {
|
||||
if (multipart) options.body = body;
|
||||
else { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); }
|
||||
}
|
||||
const response = await fetch(url, options);
|
||||
if (response.status === 401) { location.href = '/login.html'; return null; }
|
||||
const type = response.headers.get('content-type') || '';
|
||||
const payload = type.includes('json') ? await response.json().catch(() => ({})) : await response.text();
|
||||
if (!response.ok) throw new Error(payload?.error || payload?.message || payload || `HTTP ${response.status}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function button(label, action, id, { danger = false, disabled = false } = {}) {
|
||||
const node = el('button', danger ? 'secondary-btn danger' : 'secondary-btn', label);
|
||||
node.type = 'button';
|
||||
node.dataset.moduleAction = action;
|
||||
node.dataset.moduleId = id;
|
||||
node.disabled = disabled;
|
||||
return node;
|
||||
}
|
||||
|
||||
function renderSummary(data) {
|
||||
const root = document.getElementById('module-manager-summary');
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
const items = [
|
||||
['Module gesamt', data.summary.total], ['Aktiv', data.summary.enabled], ['Deaktiviert', data.summary.disabled],
|
||||
['Nativ', data.summary.native], ['Installiert', data.summary.external],
|
||||
];
|
||||
for (const [label, value] of items) {
|
||||
const card = el('article', 'module-summary-card');
|
||||
card.append(el('span', '', label), el('strong', '', String(value)));
|
||||
root.append(card);
|
||||
}
|
||||
const safety = document.getElementById('module-manager-safety');
|
||||
if (safety) {
|
||||
safety.className = `module-safety-card${data.safety.isolatedHostAvailable ? '' : ' is-warning'}`;
|
||||
safety.replaceChildren();
|
||||
const text = el('div');
|
||||
text.append(el('strong', '', 'Sicherheitsmodus'), el('p', '', data.safety.isolatedHostAvailable
|
||||
? 'Signierte Fremdmodule laufen ausschließlich im isolierten Extension-Host.'
|
||||
: 'Deklarative Module sind aktivierbar. Ausführbarer Fremdcode bleibt bis zum isolierten Extension-Host quarantänisiert.'));
|
||||
safety.append(text);
|
||||
}
|
||||
}
|
||||
|
||||
function renderModules() {
|
||||
const root = document.getElementById('module-manager-list');
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
const query = state.filter.trim().toLowerCase();
|
||||
const modules = (state.data?.modules || []).filter(item => !query || [item.id, item.name, item.description, item.status, item.installSource].join(' ').toLowerCase().includes(query));
|
||||
if (!modules.length) { root.append(el('div', 'module-empty', 'Keine passenden Module gefunden.')); return; }
|
||||
|
||||
for (const item of modules) {
|
||||
const card = el('article', `module-card ${item.enabled ? '' : 'is-disabled'} ${item.state === 'invalid' ? 'is-invalid' : ''}`.trim());
|
||||
const head = el('header', 'module-card-head');
|
||||
const titleWrap = el('div', 'module-card-title');
|
||||
titleWrap.append(el('span', `module-state-dot is-${item.state || (item.enabled ? 'running' : 'disabled')}`));
|
||||
const title = el('div');
|
||||
title.append(el('h4', '', item.name || item.id), el('code', '', item.id));
|
||||
titleWrap.append(title);
|
||||
const version = el('strong', '', item.version ? `v${item.version}` : '—');
|
||||
head.append(titleWrap, version);
|
||||
card.append(head);
|
||||
|
||||
const badges = el('div', 'module-badges');
|
||||
badges.append(el('span', 'module-badge', item.enabled ? 'Aktiv' : 'Deaktiviert'));
|
||||
badges.append(el('span', 'module-badge', item.status === 'legacy-bridge' ? 'Legacy Bridge' : item.status === 'native' ? 'Nativ' : item.status));
|
||||
badges.append(el('span', 'module-badge', item.installSource === 'builtin' ? 'Vendoo' : 'Installiert'));
|
||||
if (item.protected) badges.append(el('span', 'module-badge', 'Geschützt'));
|
||||
if (item.package?.trust?.signed) badges.append(el('span', 'module-badge', item.package.trust.trusted ? 'Signatur vertraut' : 'Signatur unbekannt'));
|
||||
card.append(badges);
|
||||
card.append(el('p', 'module-description', item.description || 'Keine Beschreibung vorhanden.'));
|
||||
if (item.requires?.length) {
|
||||
const deps = el('div', 'module-dependencies');
|
||||
deps.append(el('strong', '', 'Benötigt: '), document.createTextNode(item.requires.join(', ')));
|
||||
card.append(deps);
|
||||
}
|
||||
if (item.dependents?.length) {
|
||||
const deps = el('div', 'module-dependencies');
|
||||
deps.append(el('strong', '', 'Wird benötigt von: '), document.createTextNode(item.dependents.join(', ')));
|
||||
card.append(deps);
|
||||
}
|
||||
if (item.package?.error || item.error) card.append(el('div', 'module-error', item.package?.error || item.error));
|
||||
|
||||
const actions = el('div', 'module-card-actions');
|
||||
if (item.actions?.enable) actions.append(button('Aktivieren', 'enable', item.id));
|
||||
if (item.actions?.disable) actions.append(button('Deaktivieren', 'disable', item.id, { danger: true }));
|
||||
if (item.actions?.export) {
|
||||
const link = el('a', 'secondary-btn', 'Exportieren');
|
||||
link.href = `/api/admin/modules/${encodeURIComponent(item.id)}/export`;
|
||||
link.download = `${item.id}.vmod`;
|
||||
actions.append(link);
|
||||
}
|
||||
if (item.actions?.remove) actions.append(button('Löschen', 'remove', item.id, { danger: true }));
|
||||
if (item.protected && !actions.childNodes.length) actions.append(el('span', 'module-dependencies', 'Kernmodul – Änderungen gesperrt'));
|
||||
card.append(actions);
|
||||
root.append(card);
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const list = document.getElementById('module-manager-list');
|
||||
if (!list) return;
|
||||
list.replaceChildren(el('div', 'module-empty', 'Module werden geladen …'));
|
||||
try {
|
||||
state.data = await request('GET', '/api/admin/modules');
|
||||
renderSummary(state.data);
|
||||
renderModules();
|
||||
} catch (error) {
|
||||
list.replaceChildren(el('div', 'module-error', error.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function perform(action, id) {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
if (action === 'enable') await request('POST', `/api/admin/modules/${encodeURIComponent(id)}/enable`, {});
|
||||
if (action === 'disable') {
|
||||
const item = state.data?.modules?.find(module => module.id === id);
|
||||
const hasDependents = Boolean(item?.dependents?.length);
|
||||
const message = hasDependents
|
||||
? `Dieses Modul wird von ${item.dependents.join(', ')} benötigt. Abhängige optionale Module ebenfalls deaktivieren?`
|
||||
: 'Modul wirklich deaktivieren? Die zugehörigen API-Funktionen stehen danach bis zur Reaktivierung nicht zur Verfügung.';
|
||||
if (!confirm(message)) return;
|
||||
await request('POST', `/api/admin/modules/${encodeURIComponent(id)}/disable`, { cascade: hasDependents });
|
||||
}
|
||||
if (action === 'remove') {
|
||||
if (!confirm('Installiertes Modul dauerhaft löschen? Die exportierte .vmod-Datei sollte vorher gesichert werden.')) return;
|
||||
await request('DELETE', `/api/admin/modules/${encodeURIComponent(id)}`);
|
||||
}
|
||||
await load();
|
||||
globalThis.toast?.(action === 'enable' ? 'Modul aktiviert.' : action === 'disable' ? 'Modul deaktiviert.' : 'Modul gelöscht.');
|
||||
} catch (error) {
|
||||
globalThis.toast?.(error.message);
|
||||
if (!globalThis.toast) alert(error.message);
|
||||
} finally { state.busy = false; }
|
||||
}
|
||||
|
||||
async function install() {
|
||||
const input = document.getElementById('module-manager-file');
|
||||
const file = input?.files?.[0];
|
||||
if (!file) { globalThis.toast?.('Bitte zuerst eine .vmod-Datei auswählen.'); return; }
|
||||
if (!file.name.toLowerCase().endsWith('.vmod')) { globalThis.toast?.('Nur .vmod-Pakete werden akzeptiert.'); return; }
|
||||
const form = new FormData();
|
||||
form.append('module', file, file.name);
|
||||
try {
|
||||
await request('POST', '/api/admin/modules/install', form, { multipart: true });
|
||||
input.value = '';
|
||||
await load();
|
||||
globalThis.toast?.('Modulpaket geprüft und installiert.');
|
||||
} catch (error) {
|
||||
globalThis.toast?.(error.message);
|
||||
if (!globalThis.toast) alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
document.getElementById('module-manager-refresh')?.addEventListener('click', load);
|
||||
document.getElementById('module-manager-install')?.addEventListener('click', install);
|
||||
document.getElementById('module-manager-search')?.addEventListener('input', event => { state.filter = event.target.value; renderModules(); });
|
||||
document.getElementById('module-manager-list')?.addEventListener('click', event => {
|
||||
const target = event.target.closest('[data-module-action]');
|
||||
if (target) perform(target.dataset.moduleAction, target.dataset.moduleId);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', setup, { once: true });
|
||||
globalThis.VendooModuleManager = Object.freeze({ load });
|
||||
@@ -0,0 +1,49 @@
|
||||
:root {
|
||||
--vd-theme-manager-preview-height: 210px;
|
||||
}
|
||||
[data-reduced-motion="true"] *, [data-reduced-motion="true"] *::before, [data-reduced-motion="true"] *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; }
|
||||
[data-density="compact"] { --vd-control-height: 34px; --vd-content-gap: 10px; --vd-component-button-height: 34px; --vd-component-input-height: 36px; --vd-component-card-padding: 12px; }
|
||||
[data-density="spacious"] { --vd-control-height: 46px; --vd-content-gap: 22px; --vd-component-button-height: 46px; --vd-component-input-height: 48px; --vd-component-card-padding: 22px; }
|
||||
.vd-card { background: var(--vd-surface-raised); border: 1px solid var(--vd-line); border-radius: var(--vd-component-card-radius); padding: var(--vd-component-card-padding); box-shadow: var(--vd-shadow-soft); }
|
||||
.vd-button { min-height: var(--vd-component-button-height); border-radius: var(--vd-component-button-radius); }
|
||||
.setting-group input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), .setting-group select, .vd-input { min-height: var(--vd-component-input-height); border-radius: var(--vd-component-input-radius); }
|
||||
:where(button, a, input, select, textarea, [tabindex]):focus-visible { outline: var(--vd-component-focus-width) solid var(--vd-brand); outline-offset: 2px; }
|
||||
.theme-manager-grid { display:grid; gap:16px; }
|
||||
.theme-choice-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(145px,1fr)); gap:10px; }
|
||||
.theme-choice { min-height:104px; display:grid; gap:8px; align-content:space-between; padding:12px; border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); background:var(--vd-surface-raised); color:var(--vd-ink); text-align:left; cursor:pointer; }
|
||||
.theme-choice:hover { border-color:var(--vd-brand); }
|
||||
.theme-choice[aria-pressed="true"] { border-color:var(--vd-brand); box-shadow:0 0 0 3px var(--vd-focus); }
|
||||
.theme-choice-swatches { display:flex; gap:5px; }
|
||||
.theme-choice-swatches i { width:22px; height:22px; border-radius:50%; border:1px solid color-mix(in srgb, currentColor 18%, transparent); }
|
||||
.theme-manager-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:end; }
|
||||
.theme-manager-toolbar .setting-group { min-width:170px; flex:1 1 170px; }
|
||||
.theme-editor-layout { display:grid; grid-template-columns:minmax(0,1.3fr) minmax(260px,.7fr); gap:14px; }
|
||||
.theme-token-groups { display:grid; gap:12px; max-height:620px; overflow:auto; padding-right:4px; }
|
||||
.theme-token-group { border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); overflow:hidden; }
|
||||
.theme-token-group > h4 { margin:0; padding:10px 12px; background:var(--vd-surface-muted); font-size:12px; }
|
||||
.theme-token-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:10px; padding:12px; }
|
||||
.theme-token-field { display:grid; gap:5px; }
|
||||
.theme-token-field label { font-size:11px; font-weight:700; }
|
||||
.theme-color-control { display:grid; grid-template-columns:42px minmax(0,1fr); gap:7px; }
|
||||
.theme-color-control input[type="color"] { width:42px; height:var(--vd-component-input-height); padding:3px; border:1px solid var(--vd-line); border-radius:var(--vd-component-input-radius); background:var(--vd-surface); }
|
||||
.theme-preview { position:sticky; top:10px; display:grid; gap:12px; align-content:start; }
|
||||
.theme-preview-stage { min-height:var(--vd-theme-manager-preview-height); display:grid; gap:10px; padding:16px; border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); background:var(--vd-canvas); color:var(--vd-ink); }
|
||||
.theme-preview-card { padding:12px; border:1px solid var(--vd-line); border-radius:var(--vd-component-card-radius); background:var(--vd-surface-raised); }
|
||||
.theme-preview-actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.theme-contrast-list { display:grid; gap:6px; }
|
||||
.theme-contrast-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; padding:7px 9px; border-radius:var(--vd-radius-sm); background:var(--vd-surface-muted); font-size:11px; }
|
||||
.theme-contrast-row.is-ok strong { color:var(--vd-success); }
|
||||
.theme-contrast-row.is-fail strong { color:var(--vd-danger); }
|
||||
.theme-manager-status { min-height:22px; padding:8px 10px; border-radius:var(--vd-radius-sm); background:var(--vd-surface-muted); color:var(--vd-ink-secondary); font-size:11px; }
|
||||
.theme-profile-list { display:grid; gap:8px; }
|
||||
.theme-profile-row { display:flex; gap:8px; align-items:center; justify-content:space-between; padding:10px; border:1px solid var(--vd-line); border-radius:var(--vd-radius-sm); }
|
||||
.theme-profile-row > div { min-width:0; }
|
||||
.theme-profile-row small { color:var(--vd-ink-muted); }
|
||||
.theme-admin-only.hidden { display:none !important; }
|
||||
@media(max-width:900px){ .theme-editor-layout{grid-template-columns:1fr}.theme-preview{position:static}.theme-token-groups{max-height:none} }
|
||||
@media(min-width:901px){ .settings-nav { grid-template-columns:repeat(5,minmax(0,1fr)); } }
|
||||
.theme-manager-status[data-tone="success"] { color:var(--vd-success); background:var(--vd-success-soft); }
|
||||
.theme-manager-status[data-tone="warning"] { color:var(--vd-warning); background:var(--vd-warning-soft); }
|
||||
.theme-manager-status[data-tone="danger"] { color:var(--vd-danger); background:var(--vd-danger-soft); }
|
||||
.theme-choice > span:last-child { display:grid; gap:2px; }
|
||||
.theme-choice small { color:var(--vd-ink-muted); }
|
||||
@@ -0,0 +1,328 @@
|
||||
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();
|
||||
@@ -0,0 +1,67 @@
|
||||
/* Vendoo Icons 1.0 — compact local SVG icon set based on Lucide geometry (ISC). */
|
||||
(() => {
|
||||
const ICONS = {
|
||||
check: '<path d="m5 12 4 4L19 6"/>',
|
||||
heart: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z"/>',
|
||||
'heart-filled': '<path fill="currentColor" stroke="none" d="M12 21 4.2 13.5 3.1 12.4a5.5 5.5 0 0 1 7.8-7.8L12 5.7l1-1.1a5.5 5.5 0 0 1 7.8 7.8l-1 1.1Z"/>',
|
||||
pencil: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"/>',
|
||||
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
|
||||
'trash-2': '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="m19 6-1 15H6L5 6"/><path d="M10 11v6M14 11v6"/>',
|
||||
'zoom-in': '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M11 8v6M8 11h6"/>',
|
||||
'zoom-out': '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M8 11h6"/>',
|
||||
maximize: '<path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"/>',
|
||||
x: '<path d="m6 6 12 12M18 6 6 18"/>',
|
||||
'triangle-alert': '<path d="m21.7 18-8-14a2 2 0 0 0-3.4 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z"/><path d="M12 9v4M12 17h.01"/>',
|
||||
sparkles: '<path d="m12 3-1.4 3.6L7 8l3.6 1.4L12 13l1.4-3.6L17 8l-3.6-1.4Z"/><path d="m19 14-.8 2.2L16 17l2.2.8L19 20l.8-2.2L22 17l-2.2-.8Z"/><path d="m5 14-.8 2.2L2 17l2.2.8L5 20l.8-2.2L8 17l-2.2-.8Z"/>',
|
||||
image: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="m4 17 5-5 4 4 2-2 5 5"/>',
|
||||
list: '<path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/>',
|
||||
folder: '<path d="M3 6a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
|
||||
'folder-open': '<path d="M3 7V6a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v2"/><path d="M3 10h18l-2 10H5Z"/>',
|
||||
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
'external-link': '<path d="M15 3h6v6M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
puzzle: '<path d="M8.5 3H3v5.5a2.5 2.5 0 1 1 0 5V21h5.5a2.5 2.5 0 1 1 5 0H21v-6.5a2.5 2.5 0 1 0 0-5V3h-6.5a2.5 2.5 0 1 1-5 0Z"/>',
|
||||
'shield-check': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/>',
|
||||
'shield-alert': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="M12 8v5M12 17h.01"/>',
|
||||
'alert-triangle': '<path d="m21.7 18-8-14a2 2 0 0 0-3.4 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z"/><path d="M12 9v4M12 17h.01"/>',
|
||||
'database-backup': '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3 1.2 0 2.4-.1 3.4-.3"/><path d="M4 11v6c0 1.7 3.6 3 8 3"/><path d="m17 17 2 2 3-4"/>',
|
||||
'calendar-clock': '<path d="M8 2v4M16 2v4M3 10h18"/><rect x="3" y="4" width="18" height="17" rx="2"/><circle cx="16" cy="16" r="3"/><path d="M16 14.5V16l1 1"/>',
|
||||
'scan-check': '<path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><path d="m8 12 2.5 2.5L16 9"/>',
|
||||
'package-check': '<path d="m16.5 9.4-9-5.2M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l3-1.7"/><path d="M3.3 7 12 12l8.7-5M12 22V12"/><path d="m17 18 2 2 3-4"/>',
|
||||
'package-x': '<path d="m16.5 9.4-9-5.2M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l3-1.7"/><path d="M3.3 7 12 12l8.7-5M12 22V12M18 17l4 4M22 17l-4 4"/>',
|
||||
'plug-zap': '<path d="m13 2-2 5h4l-2 5"/><path d="M12 12v3M8 15h8M9 19v3M15 19v3"/><path d="M7 15v2a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-2"/>',
|
||||
'file-archive': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6M10 12h4M10 16h4M11 8h2"/>',
|
||||
'package-open': '<path d="M12 22v-9M3.3 7 12 12l8.7-5M5 4l7 4 7-4M3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4a2 2 0 0 0 1-1.7V8"/>',
|
||||
'radio-tower': '<path d="M4.9 19.1a10 10 0 0 1 0-14.2M7.8 16.2a6 6 0 0 1 0-8.4M16.2 7.8a6 6 0 0 1 0 8.4M19.1 4.9a10 10 0 0 1 0 14.2"/><circle cx="12" cy="12" r="2"/>',
|
||||
'badge-check': '<path d="M12 3l2 2 3-.5 1.5 2.7L21 9l-.5 3 1.5 2.5-2.5 1.8-.5 3-3 .2-2 2.5-2-2.5-3-.2-.5-3L5 14.5 6.5 12 6 9l2.5-1.8L10 4.5Z"/><path d="m9 12 2 2 4-4"/>',
|
||||
'upload-cloud': '<path d="M16 16l-4-4-4 4M12 12v9"/><path d="M20.4 17.5A5 5 0 0 0 18 8.2 7 7 0 0 0 4.3 10.8 4.5 4.5 0 0 0 5.5 19H8"/>',
|
||||
'check-circle-2': '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
|
||||
'archive': '<rect x="3" y="4" width="18" height="5" rx="1"/><path d="M5 9v11h14V9M10 13h4"/>',
|
||||
'refresh-cw': '<path d="M20 11a8 8 0 1 0 2 5M20 4v7h-7"/>',
|
||||
'rotate-ccw': '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>',
|
||||
'circle-help': '<circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 1 1 5.8 1c0 2-3 2-3 4"/><path d="M12 18h.01"/>',
|
||||
chrome: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5"/><path d="M3.8 8h8.2M8.2 20l4-8M20.2 8H12"/>',
|
||||
firefox: '<path d="M5 19c-3-3-3-9 0-13 1.3-1.7 3-2.7 5-3-1 1-1.5 2.2-1.5 3.5 1.2-.7 2.5-1 3.9-.8 4.5.5 7.8 4.6 7.2 9.1-.7 4.5-4.9 7.6-9.4 6.8A9.5 9.5 0 0 1 5 19Z"/><path d="M7 16c1.2 2 4 3 6.4 2.1 2.5-.9 3.8-3.6 3-6.1-.6-1.7-2-2.8-3.6-3.2 1.2 1 .9 2.9-.4 3.6-1 .5-2.1.2-2.7-.6-1.4 1-2.4 2.5-2.7 4.2Z"/>',
|
||||
compass: '<circle cx="12" cy="12" r="9"/><path d="m16 8-2.5 5.5L8 16l2.5-5.5Z"/>',
|
||||
edge: '<path d="M4 15c0-6 4-11 10-11 4 0 7 2 8 5-2-1-5-1-7 0 3 0 6 2 6 6 0 4-4 7-9 7-5 0-9-3-9-7 0-3 2-5 5-6-2 2-3 4-2 6 1 3 5 4 8 2-4 1-8 0-10-2Z"/>',
|
||||
};
|
||||
|
||||
function svg(name, options = {}) {
|
||||
const size = Number(options.size || 18);
|
||||
const label = options.label ? ` role="img" aria-label="${String(options.label).replace(/"/g, '"')}"` : ' aria-hidden="true"';
|
||||
return `<svg class="vd-icon${options.className ? ` ${options.className}` : ''}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false"${label}>${ICONS[name] || ICONS.image}</svg>`;
|
||||
}
|
||||
|
||||
function render(root = document) {
|
||||
root.querySelectorAll('[data-vd-icon]').forEach(node => {
|
||||
const name = node.dataset.vdIcon;
|
||||
const size = node.dataset.vdIconSize || 18;
|
||||
node.innerHTML = svg(name, { size });
|
||||
node.classList.add('vd-icon-host');
|
||||
});
|
||||
}
|
||||
|
||||
window.VendooIcons = Object.freeze({ svg, render, names: Object.keys(ICONS) });
|
||||
document.addEventListener('DOMContentLoaded', () => render());
|
||||
})();
|
||||