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
This commit was merged in pull request #18.
This commit is contained in:
Masterluke77
2026-07-09 17:09:00 +02:00
committed by GitHub
parent f4923437ca
commit 6f48827f4d
556 changed files with 77265 additions and 210 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

+9760
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+9
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+9
View File
@@ -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">
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+19
View File
@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+40
View File
@@ -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;
}
+51
View File
@@ -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 }));
}
}
+26
View File
@@ -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;
}
+333
View File
@@ -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
}
}
}
+90
View File
@@ -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]),
});
+140
View File
@@ -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; }
}
+459
View File
@@ -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"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+2373
View File
File diff suppressed because it is too large Load Diff
+111
View File
@@ -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>
+151
View File
@@ -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');
}
+9
View File
@@ -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>
File diff suppressed because one or more lines are too long
+77
View File
@@ -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>
+169
View File
@@ -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();
})();
+9
View File
@@ -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();
+7381
View File
File diff suppressed because one or more lines are too long
+67
View File
@@ -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, '&quot;')}"` : ' 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());
})();