chore: complete update

This commit is contained in:
2025-07-17 16:24:20 +02:00
parent 899227b0a4
commit 64a7051137
1300 changed files with 85570 additions and 2756 deletions

View File

@@ -0,0 +1,136 @@
// modules/core/click-manager.js
import { Logger } from './logger.js';
import { useEvent } from './useEvent.js';
import {navigateTo} from "./navigateTo";
import {SimpleCache} from "../utils/cache";
let callback = null;
let unsubscribes = [];
let cleanupInterval = null;
const prefetchCache = new SimpleCache(20, 60000); //new Map();
const maxCacheSize = 20; // max. Anzahl gecachter Seiten
const cacheTTL = 60000; // Lebensdauer in ms (60s)
function isInternal(link) {
return link.origin === location.origin;
}
function handleClick(e) {
const link = e.target.closest('a');
if (!link || e.defaultPrevented) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#')) return;
// Skip conditions
if (
link.target === '_blank' ||
link.hasAttribute('download') ||
link.getAttribute('rel')?.includes('external') ||
link.hasAttribute('data-skip')
) {
Logger.info(`[click-manager] skipped: ${href}`);
return;
}
if (isInternal(link)) {
e.preventDefault();
const cached = prefetchCache.get(href);
const valid = cached && Date.now() - cached.timestamp < cacheTTL;
const options = {
viewTransition: link.hasAttribute('data-view-transition'),
replace: link.hasAttribute('data-replace'),
modal: link.hasAttribute('data-modal'),
prefetched: valid,
data: valid ? cached.data : null,
};
Logger.info(`[click-manager] internal: ${href}`, options);
if(options.modal) {
callback?.(href, link, options);
} else {
navigateTo(href, options);
}
}
}
let prefetchTimeout;
function handleMouseOver(e) {
clearTimeout(prefetchTimeout);
const link = e.target.closest('a[href]');
if (!link || !isInternal(link)) return;
const href = link.getAttribute('href');
if (!href || prefetchCache.has(href)) return;
// optional: Wait 150ms to reduce noise
prefetchTimeout = setTimeout(() => prefetch(href), 150);
}
function prefetch(href) {
Logger.info(`[click-manager] prefetching: ${href}`);
fetch(href)
.then(res => res.text())
.then(html => {
if (prefetchCache.cache.size >= maxCacheSize) {
const oldestKey = [...prefetchCache.cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0][0];
prefetchCache.cache.delete(oldestKey);
}
prefetchCache.set(href, {
data: html,
timestamp: Date.now(),
});
})
.catch(err => {
Logger.warn(`[click-manager] prefetch failed for ${href}`, err);
});
}
function handlePopState() {
const href = location.pathname;
Logger.info(`[click-manager] popstate: ${href}`);
navigateTo(href, { replace: true });
}
export function getPrefetched(href) {
const cached = prefetchCache.get(href);
const valid = cached && Date.now() - cached.timestamp < cacheTTL;
return valid ? cached.data : null;
}
export function prefetchHref(href) {
if (!href || prefetchCache.has(href)) return;
prefetch(href);
}
export function init(onNavigate) {
callback = onNavigate;
unsubscribes = [
useEvent(document, 'click', handleClick),
useEvent(document, 'mouseover', handleMouseOver),
useEvent(window, 'popstate', handlePopState),
]
cleanupInterval = setInterval(() => {
for (const [key, val] of prefetchCache.cache.entries()) {
if (Date.now() - val.timestamp > prefetchCache.ttl) {
prefetchCache.cache.delete(key);
}
}
}, 120000);
Logger.info('[click-manager] ready');
}
export function destroy() {
callback = null;
prefetchCache.clear();
unsubscribes.forEach(unsub => unsub());
unsubscribes = [];
Logger.info('[click-manager] destroyed');
}

View File

@@ -0,0 +1,49 @@
// modules/core/EventManager.js
const registry = new Map();
export const EventManager = {
/**
* Fügt einen EventListener hinzu und speichert ihn im Modul-Kontext
*/
add(target, type, handler, { module = 'global', options = false } = {}) {
target.addEventListener(type, handler, options);
if (!registry.has(module)) registry.set(module, []);
registry.get(module).push([target, type, handler, options]);
},
/**
* Entfernt alle Listener, die für ein bestimmtes Modul registriert wurden
*/
removeModule(module) {
const entries = registry.get(module);
if (!entries) return;
entries.forEach(([target, type, handler, options]) => {
target.removeEventListener(type, handler, options);
});
registry.delete(module);
},
/**
* Entfernt alle Event-Listener aus allen Modulen
*/
clearAll() {
for (const [module, entries] of registry.entries()) {
entries.forEach(([target, type, handler, options]) => {
target.removeEventListener(type, handler, options);
});
}
registry.clear();
},
/**
* Debug: Gibt die aktuelle Registry in der Konsole aus
*/
debug() {
console.table([...registry.entries()].map(([module, events]) => ({
module,
listeners: events.length
})));
}
};

View File

@@ -0,0 +1,73 @@
// modules/core/PerformanceMonitor.js
export class PerformanceMonitor {
constructor({ fps = true } = {}) {
this.fpsEnabled = fps;
this.fps = 0;
this.frameCount = 0;
this.lastTime = performance.now();
this.visible = false;
this.taskTimings = new Map();
this.logs = [];
this.container = document.createElement('div');
this.container.style.position = 'fixed';
this.container.style.bottom = '0';
this.container.style.left = '0';
this.container.style.font = '12px Consolas, monospace';
this.container.style.color = '#0f0';
this.container.style.background = 'rgba(0,0,0,0.75)';
this.container.style.padding = '0.5rem';
this.container.style.zIndex = '9999';
this.container.style.pointerEvents = 'none';
this.container.style.lineHeight = '1.4';
this.container.style.whiteSpace = 'pre';
this.container.style.display = 'none';
document.body.appendChild(this.container);
window.addEventListener('keydown', (e) => {
if (e.key === '§') {
this.visible = !this.visible;
this.container.style.display = this.visible ? 'block' : 'none';
}
});
}
log(message) {
const timestamp = new Date().toLocaleTimeString();
this.logs.push(`[${timestamp}] ${message}`);
if (this.logs.length > 5) this.logs.shift();
}
update(taskMap = new Map()) {
this.frameCount++;
const now = performance.now();
if (now - this.lastTime >= 1000) {
this.fps = this.frameCount;
this.frameCount = 0;
this.lastTime = now;
const timings = [];
for (const [id, duration] of this.taskTimings.entries()) {
timings.push(`${id}: ${duration.toFixed(2)}ms`);
}
const barWidth = Math.min(this.fps * 2, 100);
const logOutput = this.logs.slice().reverse().join('\n');
this.container.innerHTML = `
FPS: ${this.fps} | Tasks: ${taskMap.size}
${timings.join('\n')}
<div style="width:${barWidth}%;height:4px;background:#0f0;margin-top:4px;"></div>
${logOutput ? '\nLogs:\n' + logOutput : ''}
`;
}
}
trackTask(id, callback) {
const start = performance.now();
callback();
const duration = performance.now() - start;
this.taskTimings.set(id, duration);
}
}

View File

@@ -0,0 +1,46 @@
// src/core/events.js
// --- 1. Globaler EventBus ---
const listeners = new Map();
/**
* Abonniert ein benanntes Event
*/
export function on(eventName, callback) {
if (!listeners.has(eventName)) listeners.set(eventName, []);
listeners.get(eventName).push(callback);
// Unsubscribe
return () => {
const arr = listeners.get(eventName);
if (arr) listeners.set(eventName, arr.filter(fn => fn !== callback));
};
}
/**
* Sendet ein benanntes Event mit Payload
*/
export function emit(eventName, payload) {
const arr = listeners.get(eventName);
if (arr) arr.forEach(fn => fn(payload));
}
// --- 2. ActionDispatcher ---
const actionListeners = new Set();
/**
* Action registrieren (globaler Listener für alle Aktionen)
*/
export function registerActionListener(callback) {
actionListeners.add(callback);
return () => actionListeners.delete(callback);
}
/**
* Aktion ausführen
*/
export function dispatchAction(type, payload = {}) {
actionListeners.forEach(fn => fn({ type, payload }));
}

View File

@@ -0,0 +1,83 @@
// modules/core/frameloop.js
import {Logger} from "./logger";
const tasks = new Map();
let running = false;
let showDebug = false;
let lastTime = performance.now();
let frameCount = 0;
let fps = 0;
const debugOverlay = document.createElement('div');
debugOverlay.style.position = 'fixed';
debugOverlay.style.bottom = '0';
debugOverlay.style.left = '0';
debugOverlay.style.font = '12px monospace';
debugOverlay.style.color = '#0f0';
debugOverlay.style.background = 'rgba(0,0,0,0.75)';
debugOverlay.style.padding = '0.25rem 0.5rem';
debugOverlay.style.zIndex = '9999';
debugOverlay.style.pointerEvents = 'none';
debugOverlay.style.display = 'none';
const barWidth = Math.min(fps * 2, 100);
const bar = `<div style="width:${barWidth}%;height:4px;background:#0f0;margin-top:4px;"></div>`;
debugOverlay.innerHTML += bar;
debugOverlay.style.lineHeight = '1.4';
document.body.appendChild(debugOverlay);
import { PerformanceMonitor } from './PerformanceMonitor.js';
export const monitor = new PerformanceMonitor();
window.addEventListener('keydown', (e) => {
if (e.key === '§') {
showDebug = !showDebug;
debugOverlay.style.display = showDebug ? 'block' : 'none';
}
});
export function registerFrameTask(id, callback, options = {}) {
tasks.set(id, callback);
if (options.autoStart && !running) startFrameLoop();
}
export function unregisterFrameTask(id) {
tasks.delete(id);
}
export function clearFrameTasks() {
tasks.clear();
}
export function startFrameLoop() {
if (running) return;
running = true;
function loop() {
for (const [id, task] of tasks) {
try {
if (showDebug) {
monitor.trackTask(id, task);
} else {
task();
}
} catch (err) {
Logger.warn(`[Frameloop] Fehler in Task:`, err);
}
}
if (showDebug) {
monitor.update(tasks);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
}
export function stopFrameLoop() {
running = false;
// Achtung: Loop läuft weiter, solange nicht aktiv gestoppt
}

View File

@@ -0,0 +1,2 @@
export * from './logger.js';
export * from './useEvent';

125
resources/js/core/init.js Normal file
View File

@@ -0,0 +1,125 @@
/*
const menu = document.getElementById("sidebar-menu");
const closeBtn = menu.querySelector(".close-btn");
closeBtn.addEventListener("click", () => {
menu.hidePopover();
});
*/
import {registerModules} from "../modules";
import {useEvent} from "./useEvent";
/*import { createTrigger, destroyTrigger, destroyAllTriggers } from './scrollfx/index.js';
createTrigger({
element: 'section',
target: '.fade',
start: 'top 80%',
end: 'bottom 30%',
scrub: true,
onUpdate: (() => {
const progressMap = new WeakMap();
return (el, progress) => {
if (!el) return;
let current = progressMap.get(el) || 0;
current += (progress - current) * 0.1;
progressMap.set(el, current);
el.style.opacity = current;
el.style.transform = `translateY(${30 - 30 * current}px)`;
};
})(),
onEnter: el => el.classList.add('entered'),
onLeave: el => {
el.classList.remove('entered');
el.style.opacity = 0;
el.style.transform = 'translateY(30px)';
}
});*/
/*
let lastScrollY = window.scrollY;
const fadeElements = document.querySelectorAll('.fade-in-on-scroll');
// Observer 1: Einblenden beim Runterscrollen
const fadeInObserver = new IntersectionObserver((entries) => {
const scrollingDown = window.scrollY > lastScrollY;
lastScrollY = window.scrollY;
entries.forEach(entry => {
if (entry.isIntersecting && scrollingDown) {
entry.target.classList.add('visible');
}
});
}, {
threshold: 0.4,
rootMargin: '0px 0px -10% 0px'
});
// Observer 2: Ausblenden beim Hochscrollen
const fadeOutObserver = new IntersectionObserver((entries) => {
const scrollingUp = window.scrollY < lastScrollY;
lastScrollY = window.scrollY;
entries.forEach(entry => {
if (!entry.isIntersecting && scrollingUp) {
entry.target.classList.remove('visible');
}
});
}, {
threshold: 0.5,
rootMargin: '0% 0px 50% 0px' // früher triggern beim Hochscrollen
});
// Alle Elemente mit beiden Observern beobachten
fadeElements.forEach(el => {
fadeInObserver.observe(el);
fadeOutObserver.observe(el);
});
*/
/*
const newContent = '<h1>Neue Seite</h1>'; // dein AJAX-Inhalt z.B.
const container = document.querySelector('main');
document.startViewTransition(() => {
container.innerHTML = newContent;
});*/
import { fadeScrollTrigger, zoomScrollTrigger, fixedZoomScrollTrigger } from '../modules/scrollfx/Tween.js';
import {autoLoadResponsiveVideos} from "../utils/autoLoadResponsiveVideo";
export async function initApp() {
await registerModules();
autoLoadResponsiveVideos();
/*initNoiseToggle({
selector: '.noise-overlay',
toggleKey: 'g', // Taste zum Umschalten
className: 'grainy', // Klasse auf <body>
enableTransition: true // Smooth fade
});
fadeScrollTrigger('.fade');
zoomScrollTrigger('.zoomed');
fixedZoomScrollTrigger('h1');*/
}

View File

@@ -0,0 +1,277 @@
/**
* Erweiterter Logger mit Processor-Architektur, Request-ID-Unterstützung und Server-Kommunikation.
*/
export class Logger {
/**
* Konfiguration des Loggers
*/
static config = {
enabled: true,
apiEndpoint: '/api/log',
consoleEnabled: true,
serverEnabled: true,
minLevel: 'debug',
};
/**
* Liste der registrierten Processors
*/
static processors = [];
/**
* Registrierte Handler
*/
static handlers = [];
/**
* Aktive RequestID
*/
static requestId = null;
/**
* Logger initialisieren
*/
static initialize(config = {}) {
// Konfiguration überschreiben
this.config = { ...this.config, ...config };
// Standard-Processors registrieren
this.registerProcessor(this.requestIdProcessor);
this.registerProcessor(this.timestampProcessor);
// Standard-Handler registrieren
if (this.config.consoleEnabled) {
this.registerHandler(this.consoleHandler);
}
if (this.config.serverEnabled) {
this.registerHandler(this.serverHandler);
}
// Request-ID aus dem Document laden, wenn vorhanden
if (typeof document !== 'undefined') {
this.initFromDocument();
}
// Unhandled Errors abfangen
this.setupErrorHandling();
}
/**
* Debug-Nachricht loggen
*/
static debug(...args) {
this.log('debug', ...args);
}
/**
* Info-Nachricht loggen
*/
static info(...args) {
this.log('info', ...args);
}
/**
* Warnungs-Nachricht loggen
*/
static warn(...args) {
this.log('warn', ...args);
}
/**
* Fehler-Nachricht loggen
*/
static error(...args) {
this.log('error', ...args);
}
/**
* Log-Nachricht mit beliebigem Level erstellen
*/
static log(level, ...args) {
if (!this.config.enabled) return;
// Level-Validierung
const validLevels = ['debug', 'info', 'warn', 'error'];
if (!validLevels.includes(level)) {
level = 'info';
}
// Nachricht und Kontext extrahieren
const message = this.formatMessage(args);
const context = args.find(arg => typeof arg === 'object' && arg !== null && !(arg instanceof Error)) || {};
// Exception extrahieren, falls vorhanden
const error = args.find(arg => arg instanceof Error);
if (error) {
context.exception = error;
}
// Log-Record erstellen
let record = {
level,
message,
context,
timestamp: new Date(),
extra: {},
};
// Alle Processors durchlaufen
this.processors.forEach(processor => {
record = processor(record);
});
// Log-Level-Prüfung (nach Processors, da sie das Level ändern könnten)
const levelPriority = {
debug: 100,
info: 200,
warn: 300,
error: 400,
};
if (levelPriority[record.level] < levelPriority[this.config.minLevel]) {
return;
}
// Alle Handler durchlaufen
this.handlers.forEach(handler => {
handler(record);
});
}
/**
* Nachricht aus verschiedenen Argumenten formatieren
*/
static formatMessage(args) {
return args
.filter(arg => !(arg instanceof Error) && (typeof arg !== 'object' || arg === null))
.map(arg => String(arg))
.join(' ');
}
/**
* Processor registrieren
*/
static registerProcessor(processor) {
if (typeof processor !== 'function') return;
this.processors.push(processor);
}
/**
* Handler registrieren
*/
static registerHandler(handler) {
if (typeof handler !== 'function') return;
this.handlers.push(handler);
}
/**
* Error-Handling-Setup
*/
static setupErrorHandling() {
if (typeof window !== 'undefined') {
// Unbehandelte Fehler abfangen
window.addEventListener('error', (event) => {
this.error('Unbehandelter Fehler:', event.error || event.message);
});
// Unbehandelte Promise-Rejects abfangen
window.addEventListener('unhandledrejection', (event) => {
this.error('Unbehandelte Promise-Ablehnung:', event.reason);
});
}
}
/**
* Request-ID aus dem Document laden
*/
static initFromDocument() {
const meta = document.querySelector('meta[name="request-id"]');
if (meta) {
const fullRequestId = meta.getAttribute('content');
// Nur den ID-Teil ohne Signatur verwenden
this.requestId = fullRequestId.split('.')[0] || null;
}
}
/*** STANDARD-PROCESSORS ***/
/**
* Processor für Request-ID
*/
static requestIdProcessor(record) {
if (Logger.requestId) {
record.extra.request_id = Logger.requestId;
}
return record;
}
/**
* Processor für Timestamp-Formatierung
*/
static timestampProcessor(record) {
record.formattedTimestamp = record.timestamp.toLocaleTimeString('de-DE');
return record;
}
/*** STANDARD-HANDLERS ***/
/**
* Handler für Console-Ausgabe
*/
static consoleHandler(record) {
const levelColors = {
debug: 'color: gray',
info: 'color: green',
warn: 'color: orange',
error: 'color: red',
};
const color = levelColors[record.level] || 'color: black';
const requestIdStr = record.extra.request_id ? `[${record.extra.request_id}] ` : '';
const formattedMessage = `[${record.formattedTimestamp}] [${record.level.toUpperCase()}] ${requestIdStr}${record.message}`;
// Farbige Ausgabe in der Konsole
console[record.level](
`%c${formattedMessage}`,
color,
...(record.context ? [record.context] : [])
);
}
/**
* Handler für Server-Kommunikation
*/
static serverHandler(record) {
fetch(Logger.config.apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-ID': Logger.requestId || ''
},
body: JSON.stringify({
level: record.level,
message: record.message,
context: record.context || {}
})
})
.then(response => {
// Request-ID aus dem Header extrahieren
const requestId = response.headers.get('X-Request-ID');
if (requestId) {
// Nur den ID-Teil ohne Signatur speichern
const idPart = requestId.split('.')[0];
if (idPart) {
Logger.requestId = idPart;
}
}
return response.json();
})
.catch(() => {
// Fehler beim Senden des Logs ignorieren (keine rekursive Fehlerbehandlung)
});
}
}
// Standard-Initialisierung
Logger.initialize();

View File

@@ -0,0 +1,37 @@
import {monitor} from "./frameloop";
export class Logger {
static enabled = true //import.meta.env.MODE !== 'production';
static log(...args) {
this._write('log', '[LOG]', args);
}
static warn(...args) {
this._write('warn', '[WARN]', args)
}
static info(...args) {
this._write('info', '[INFO]', args);
}
static error(...args) {
this._write('error', '[ERROR]', args);
}
static _write(consoleMethod, prefix, args) {
if(!this.enabled) return;
const date = new Date();
const timestamp = date.toLocaleTimeString('de-DE');
const msg = `${prefix} [${timestamp}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ')}`;
if(typeof console[consoleMethod] === 'function') {
console[consoleMethod](msg);
}
monitor?.log(msg)
}
}

View File

@@ -0,0 +1,51 @@
// modules/core/navigateTo.js
import { getPrefetched } from './ClickManager.js';
/**
* Funktion für SPA-Navigation mit optionaler ViewTransition
* Diese Funktion kann als generische Utility überall eingebunden werden!
*
* @param {string} href - Die Ziel-URL
* @param {object} options - Steueroptionen
* @param {boolean} [options.replace=false] - history.replaceState statt push
* @param {boolean} [options.viewTransition=false] - ViewTransition API verwenden
* @param {Function} [options.onUpdate] Wird nach Laden des HTML aufgerufen (html)
* @param {Function} [options.getPrefetched] Funktion zum Abrufen gecachter Daten (optional)
*/
export async function navigateTo(href, options = {}) {
const {
replace = false,
viewTransition = false,
onUpdate = html => {},
getPrefetched = null
/*onUpdate = (html) => {
const container = document.querySelector('main');
if (container) container.innerHTML = html;
}*/
} = options;
const fetchHtml = async () => {
let html = '';
if(getPrefetched) {
html = getPrefetched(href) || '';
}
if(!html) {
html = await fetch(href).then(r => r.text());
}
onUpdate(html);
if(replace) {
history.replaceState(null, '', href);
} else {
history.pushState(null, '', href);
}
};
if (viewTransition && document.startViewTransition) {
document.startViewTransition(fetchHtml);
} else {
await fetchHtml();
}
}

View File

@@ -0,0 +1,14 @@
// modules/core/removeModules.js
import { EventManager } from './EventManager.js';
/**
* Entfernt alle bekannten Events zu einem Array von Modulen
* Optional: Logging oder Cleanup-Hooks ergänzbar
*/
export function removeModules(moduleList) {
if (!Array.isArray(moduleList)) return;
moduleList.forEach((module) => {
EventManager.removeModule(module);
});
}

138
resources/js/core/router.js Normal file
View File

@@ -0,0 +1,138 @@
// modules/core/router.js
import { init as initClickManager } from './ClickManager.js';
import { navigateTo } from './navigateTo.js';
const routes = new Map();
const wildcards = [];
const guards = new Map();
let currentRoute = null;
let layoutCallback = null;
let metaCallback = null;
/**
* Registriert eine neue Route mit optionalem Handler
* @param {string} path - Pfad der Route
* @param {Function} handler - Callback bei Treffer
*/
export function defineRoute(path, handler) {
if (path.includes('*')) {
wildcards.push({ pattern: new RegExp('^' + path.replace('*', '.*') + '$'), handler });
} else {
routes.set(path, handler);
}
}
/**
* Definiert einen Guard für eine Route
* @param {string} path - Pfad
* @param {Function} guard - Guard-Funktion (return false = block)
*/
export function guardRoute(path, guard) {
guards.set(path, guard);
}
/**
* Gibt die aktuell aktive Route zurück
*/
export function getRouteContext() {
return currentRoute;
}
/**
* Setzt eine Callback-Funktion für dynamische Layout-Switches
*/
export function onLayoutSwitch(fn) {
layoutCallback = fn;
}
/**
* Setzt eine Callback-Funktion für Meta-Daten (z.B. title, theme)
*/
export function onMetaUpdate(fn) {
metaCallback = fn;
}
function matchRoute(href) {
if (routes.has(href)) return routes.get(href);
for (const entry of wildcards) {
if (entry.pattern.test(href)) return entry.handler;
}
return null;
}
function runGuard(href) {
const guard = guards.get(href);
return guard ? guard(href) !== false : true;
}
function extractMetaFromHTML(html) {
const temp = document.createElement('div');
temp.innerHTML = html;
const metaTags = {};
temp.querySelectorAll('[data-meta]').forEach(el => {
for (const attr of el.attributes) {
if (attr.name.startsWith('data-meta-')) {
const key = attr.name.replace('data-meta-', '');
metaTags[key] = attr.value;
}
}
})
//const title = temp.querySelector('[data-meta-title]')?.getAttribute('data-meta-title');
//const theme = temp.querySelector('[data-meta-theme]')?.getAttribute('data-meta-theme');
return { metaTags };
}
function animateLayoutSwitch(type) {
document.body.dataset.layout = type;
document.body.classList.add('layout-transition');
setTimeout(() => document.body.classList.remove('layout-transition'), 300);
}
/**
* Startet den Router
*/
export function startRouter() {
initClickManager((href, link, options) => {
if (!runGuard(href)) return;
if (options.modal) {
const handler = matchRoute(href);
currentRoute = { href, modal: true, link, options };
handler?.(currentRoute);
layoutCallback?.(currentRoute);
metaCallback?.(currentRoute);
} else {
navigateTo(href, {
...options,
onUpdate: (html) => {
const container = document.querySelector('main');
if (container) container.innerHTML = html;
const routeHandler = matchRoute(href);
currentRoute = { href, html, modal: false };
const meta = extractMetaFromHTML(html);
if (meta.title) document.title = meta.title;
if (meta.theme) document.documentElement.style.setProperty('--theme-color', meta.theme);
routeHandler?.(currentRoute);
layoutCallback?.(currentRoute);
metaCallback?.(currentRoute);
}
});
}
});
// Bei Seitenstart erste Route prüfen
window.addEventListener('DOMContentLoaded', () => {
const href = location.pathname;
const routeHandler = matchRoute(href);
currentRoute = { href, modal: false };
routeHandler?.(currentRoute);
layoutCallback?.(currentRoute);
metaCallback?.(currentRoute);
});
}
export { animateLayoutSwitch, extractMetaFromHTML };

View File

@@ -0,0 +1,99 @@
/**
* Erweiterter Logger mit Request-ID-Unterstützung und Server-Kommunikation.
*/
export class SecureLogger {
static enabled = true;
static apiEndpoint = '/api/log';
static requestId = null;
static log(...args) {
this._write('log', args);
}
static warn(...args) {
this._write('warn', args)
}
static info(...args) {
this._write('info', args);
}
static error(...args) {
this._write('error', args);
}
static _write(level, args) {
if(!this.enabled) return;
const date = new Date();
const timestamp = date.toLocaleTimeString('de-DE');
const requestIdStr = this.requestId ? `[${this.requestId}] ` : '';
const message = args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ');
const formattedMessage = `[${timestamp}] ${requestIdStr}[${level.toUpperCase()}] ${message}`;
// Lokales Logging in der Konsole
console[level](formattedMessage);
// An den Server senden (wenn nicht in Produktion)
this._sendToServer(level, message, args.find(a => typeof a === 'object'));
}
static _sendToServer(level, message, context) {
fetch(this.apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-ID': this.requestId || ''
},
body: JSON.stringify({
level,
message,
context
})
})
.then(response => {
// Request-ID aus dem Header extrahieren
const requestId = response.headers.get('X-Request-ID');
if (requestId) {
// Nur den ID-Teil ohne Signatur speichern
const idPart = requestId.split('.')[0];
if (idPart) {
this.requestId = idPart;
}
}
return response.json();
})
.catch(err => {
console.error('Fehler beim Senden des Logs:', err);
});
}
/**
* Request-ID aus dem Document laden
*/
static initFromDocument() {
// Versuche die Request-ID aus einem Meta-Tag zu lesen
const meta = document.querySelector('meta[name="request-id"]');
if (meta) {
const fullRequestId = meta.getAttribute('content');
// Nur den ID-Teil ohne Signatur verwenden
this.requestId = fullRequestId.split('.')[0] || null;
}
}
}
// Request-ID initialisieren, wenn das DOM geladen ist
document.addEventListener('DOMContentLoaded', () => {
SecureLogger.initFromDocument();
// Abfangen aller unbehandelten Fehler und Logging
window.addEventListener('error', (event) => {
SecureLogger.error('Unbehandelter Fehler:', event.error || event.message);
});
// Abfangen aller unbehandelten Promise-Rejects
window.addEventListener('unhandledrejection', (event) => {
SecureLogger.error('Unbehandelte Promise-Ablehnung:', event.reason);
});
});

213
resources/js/core/state.js Normal file
View File

@@ -0,0 +1,213 @@
// src/core/state.js
const globalState = new Map();
/**
* Normale State-Erstellung (nicht persistent)
*/
export function createState(key, initialValue) {
if (!globalState.has(key)) globalState.set(key, initialValue);
return {
get() {
return globalState.get(key);
},
set(value) {
globalState.set(key, value);
dispatchEvent(new CustomEvent('statechange', {
detail: { key, value }
}));
},
subscribe(callback) {
const handler = (event) => {
if (event.detail.key === key) {
callback(event.detail.value);
}
};
addEventListener('statechange', handler);
return () => removeEventListener('statechange', handler);
}
};
}
/**
* Persistent State via localStorage
*/
export function createPersistentState(key, defaultValue) {
const stored = localStorage.getItem(`state:${key}`);
const initial = stored !== null ? JSON.parse(stored) : defaultValue;
const state = createState(key, initial);
state.subscribe((val) => {
localStorage.setItem(`state:${key}`, JSON.stringify(val));
});
return state;
}
/**
* Zugriff auf alle States (intern)
*/
export function getRawState() {
return globalState;
}
/**
* Elementbindung: Text, Attribut, Klasse oder Custom-Callback
*/
export function bindStateToElement({ state, element, property = 'text', attributeName = null }) {
const apply = (value) => {
if (property === 'text') {
element.textContent = value;
} else if (property === 'attr' && attributeName) {
element.setAttribute(attributeName, value);
} else if (property === 'class') {
element.className = value;
} else if (typeof property === 'function') {
property(element, value);
}
};
apply(state.get());
return state.subscribe(apply);
}
/**
* Mehrere Elemente gleichzeitig binden
*/
export function bindStateToElements({ state, elements, ...rest }) {
return elements.map(el => bindStateToElement({ state, element: el, ...rest }));
}
/**
* Declarative Auto-Bindings via data-bind Attribute
*/
export function initStateBindings() {
document.querySelectorAll('[data-bind]').forEach(el => {
const key = el.getAttribute('data-bind');
const state = createState(key, '');
bindStateToElement({ state, element: el, property: 'text' });
});
document.querySelectorAll('[data-bind-attr]').forEach(el => {
const key = el.getAttribute('data-bind-attr');
const attr = el.getAttribute('data-bind-attr-name') || 'value';
const state = createState(key, '');
bindStateToElement({ state, element: el, property: 'attr', attributeName: attr });
});
document.querySelectorAll('[data-bind-class]').forEach(el => {
const key = el.getAttribute('data-bind-class');
const state = createState(key, '');
bindStateToElement({ state, element: el, property: 'class' });
});
document.querySelectorAll('[data-bind-input]').forEach(el => {
const key = el.getAttribute('data-bind-input');
const persistent = el.hasAttribute('data-persistent');
const state = persistent ? createPersistentState(key, '') : createState(key, '');
bindInputToState(el, state);
});
}
/**
* Zwei-Wege-Bindung für Formulareingaben
*/
export function bindInputToState(inputEl, state) {
// Initialwert setzen
if (inputEl.type === 'checkbox') {
inputEl.checked = !!state.get();
} else {
inputEl.value = state.get();
}
// DOM → State
inputEl.addEventListener('input', () => {
if (inputEl.type === 'checkbox') {
state.set(inputEl.checked);
} else {
state.set(inputEl.value);
}
});
// State → DOM
return state.subscribe((val) => {
if (inputEl.type === 'checkbox') {
inputEl.checked = !!val;
} else {
inputEl.value = val;
}
});
}
/**
* Berechneter Zustand auf Basis anderer States
*/
export function createComputedState(dependencies, computeFn) {
let value = computeFn(...dependencies.map(d => d.get()));
const key = `computed:${Math.random().toString(36).slice(2)}`;
const state = createState(key, value);
dependencies.forEach(dep => {
dep.subscribe(() => {
const newValue = computeFn(...dependencies.map(d => d.get()));
state.set(newValue);
});
});
return state;
}
/**
* Aktiviert Undo/Redo für einen State
*/
export function enableUndoRedoForState(state) {
const history = [];
let index = -1;
let lock = false;
const record = (val) => {
if (lock) return;
history.splice(index + 1); // zukünftige States verwerfen
history.push(val);
index++;
};
record(state.get());
const unsubscribe = state.subscribe((val) => {
if (!lock) record(val);
});
return {
undo() {
if (index > 0) {
lock = true;
index--;
state.set(history[index]);
lock = false;
}
},
redo() {
if (index < history.length - 1) {
lock = true;
index++;
state.set(history[index]);
lock = false;
}
},
destroy: unsubscribe
};
}
/**
* Dev-Konsole zur Nachverfolgung von State-Änderungen
*/
export function enableStateLogging(state, label = 'state') {
state.subscribe((val) => {
console.debug(`[State Change: ${label}]`, val);
});
}

View File

@@ -0,0 +1,23 @@
// modules/core/useEvent.js
import { EventManager } from './EventManager.js';
/**
* Vereinfachte Kurzform zur Registrierung eines EventListeners
*
* @param {EventTarget} target - Das Element, auf das der Event gehört
* @param {string} type - Der Eventtyp (z.B. 'click')
* @param {Function} handler - Die Callback-Funktion
* @param {object} meta - Optionen oder automatisch aus `import.meta` (optional)
* @param {Object|boolean} options - EventListener-Optionen
*/
export function useEvent(target, type, handler, meta = import.meta, options = false) {
const module = typeof meta === 'string'
? meta
: (meta.url?.split('/').slice(-2, -1)[0] || 'unknown');
EventManager.add(target, type, handler, { module, options });
return () => {
EventManager.removeModule(module);
}
}

View File

@@ -0,0 +1,115 @@
# 📦 Scroll-Module Anwendung und Integration
Dieses Projekt enthält mehrere leichtgewichtige JavaScript-Module zur Gestaltung interaktiver Scroll-Erlebnisse.
## 🔧 Modulübersicht
| Modul | Funktion |
|------------------|-------------------------------------|
| `scroll-timeline` | Zustandswechsel bei Scrollschritten |
| `parallax` | Parallax-Scrolling-Effekte |
| `sticky-steps` | Scrollbasierte Sticky-Kapitel |
---
## 1⃣ scroll-timeline
### 📄 Verwendung
```html
<section data-scroll-step="1">Intro</section>
<section data-scroll-step="2">Chart</section>
<section data-scroll-step="3">Zitat</section>
```
### ⚙️ Konfiguration
```js
init({
attribute: 'data-scroll-step', // Attributname
triggerPoint: 0.4, // Auslösehöhe (in % der Viewporthöhe)
once: false // true = nur einmal triggern
});
```
### 🎯 Callbacks (in steps.js)
```js
export const scrollSteps = {
onEnter(index, el) {
// Element wird aktiv
},
onLeave(index, el) {
// Element verlässt Fokus (wenn once = false)
}
};
```
---
## 2⃣ parallax
### 📄 Verwendung
```html
<img src="..." data-parallax data-parallax-speed="0.2">
```
### ⚙️ Konfiguration
```js
init({
selector: '[data-parallax]', // Ziel-Selektor
speedAttr: 'data-parallax-speed', // Attribut für Geschwindigkeit
defaultSpeed: 0.5 // Fallback-Geschwindigkeit
});
```
### 💡 Hinweis
Je niedriger die `speed`, desto "langsamer" scrollt das Element.
---
## 3⃣ sticky-steps
### 📄 Verwendung
```html
<div data-sticky-container>
<div data-sticky-step>Kapitel 1</div>
<div data-sticky-step>Kapitel 2</div>
</div>
```
### ⚙️ Konfiguration
```js
init({
containerSelector: '[data-sticky-container]',
stepSelector: '[data-sticky-step]',
activeClass: 'is-sticky-active'
});
```
### 🎨 CSS-Vorschlag
```css
[data-sticky-step] {
position: sticky;
top: 20vh;
opacity: 0.3;
transition: opacity 0.3s ease;
}
.is-sticky-active {
opacity: 1;
}
```
---
## 🚀 Integration in dein Framework
- Jedes Modul exportiert eine `init()`-Funktion
- Wird automatisch über `modules/index.js` geladen
- Konfiguration erfolgt über `modules/config.js`
```js
export const moduleConfig = {
'scroll-timeline': { once: false },
'parallax': { defaultSpeed: 0.3 },
'sticky-steps': { activeClass: 'active' }
};
```

View File

@@ -0,0 +1,9 @@
| Attribut | Typ | Standardwert | Beschreibung |
| ------------------- | ---------------------------------------------------------- | ------------- | ------------------------------------------------------------------------- |
| `data-scroll-loop` | — | — | Aktiviert das Scroll-Loop-Modul auf dem Element |
| `data-scroll-speed` | `float` | `0.2` | Faktor für Scrollgeschwindigkeit positiv oder negativ |
| `data-scroll-axis` | `"x"` \| `"y"` | `"y"` | Achse der Bewegung |
| `data-scroll-type` | `"translate"` \| `"rotate"` \| `"background"` \| `"scale"` | `"translate"` | Art der Scrollanimation |
| `data-loop-offset` | `float` | `0` | Start-Offset in Pixeln (nützlich für Desynchronisation mehrerer Elemente) |
| `data-loop-limit` | `number` (Pixelwert) | — | Obergrenze für Scrollbereich ab dieser Position stoppt die Animation |
| `data-loop-pause` | `"true"` \| `"false"` | — | Bei `"true"` wird die Animation bei Hover oder aktivem Element pausiert |

View File

@@ -0,0 +1,29 @@
---
## 🔧 Neue Funktionen:
### `getPrefetched(href: string): string | null`
* Gibt den HTML-Text zurück, **falls gültig gecached**
* Sonst `null`
### `prefetchHref(href: string): void`
* Manuelles Prefetching von URLs
* Wird nur ausgeführt, wenn nicht bereits gecached
---
## 🧪 Verwendung:
```js
import { getPrefetched, prefetchHref } from './core/click-manager.js';
prefetchHref('/about.html'); // manuelles Prefetching
const html = getPrefetched('/about.html');
if (html) {
render(html);
}
```

View File

@@ -0,0 +1,34 @@
✅ Dein Router-Modul wurde erweitert um:
---
## 🎭 Layout-Animation
```js
import { animateLayoutSwitch } from './router.js';
onLayoutSwitch(ctx => {
const type = ctx.href.startsWith('/studio') ? 'studio' : 'default';
animateLayoutSwitch(type);
});
```
→ Fügt `data-layout="…"`, animiert via `.layout-transition` (z.B. Fade)
---
## 📝 Meta-Daten aus HTML
```html
<div data-meta-title="Über Uns" data-meta-theme="#111"></div>
```
→ Beim Laden des HTML werden automatisch:
* `document.title` gesetzt
* CSS-Variable `--theme-color` aktualisiert
---
Wenn du möchtest, kann ich dir nun einen `<meta name="theme-color">`-Updater bauen oder eine ViewTransition speziell für Layoutwechsel. Sag einfach:
**„Ja, bitte meta\[name=theme-color]“** oder **„ViewTransition für Layout“**.

197
resources/js/docs/state.md Normal file
View File

@@ -0,0 +1,197 @@
# 📦 Framework-Module: `state.js` & `events.js`
Diese Dokumentation beschreibt die Verwendung der globalen **State-Verwaltung** und des **Event-/Action-Dispatching** in deinem Framework.
---
## 📁 `core/state.js` Globale Zustandsverwaltung
### 🔹 `createState(key, initialValue)`
Erzeugt einen flüchtigen (nicht-persistenten) globalen Zustand.
```js
const username = createState('username', 'Gast');
console.log(username.get()); // → "Gast"
username.set('Michael');
```
---
### 🔹 `createPersistentState(key, defaultValue)`
Wie `createState`, aber mit `localStorage`-Speicherung.
```js
const theme = createPersistentState('theme', 'light');
theme.set('dark'); // Wird gespeichert
```
---
### 🔹 `state.subscribe(callback)`
Reagiert auf Änderungen des Zustands.
```js
username.subscribe((val) => {
console.log('Neuer Benutzername:', val);
});
```
---
### 🔹 `bindStateToElement({ state, element, property, attributeName })`
Bindet einen State an ein DOM-Element.
```js
bindStateToElement({
state: username,
element: document.querySelector('#userDisplay'),
property: 'text'
});
```
```html
<span id="userDisplay"></span>
```
---
### 🔹 `bindInputToState(inputElement, state)`
Erzeugt eine **Zwei-Wege-Bindung** zwischen Eingabefeld und State.
```js
bindInputToState(document.querySelector('#nameInput'), username);
```
```html
<input id="nameInput" type="text" />
```
---
### 🔹 `initStateBindings()`
Initialisiert alle Elemente mit `data-bind-*` automatisch.
```html
<!-- Einfache Textbindung -->
<span data-bind="username"></span>
<!-- Input mit persistenter Speicherung -->
<input data-bind-input="username" data-persistent />
<!-- Attributbindung -->
<img data-bind-attr="profilePic" data-bind-attr-name="src" />
<!-- Klassenbindung -->
<div data-bind-class="themeClass"></div>
```
```js
initStateBindings(); // Einmalig in initApp() aufrufen
```
---
### 🔹 `createComputedState([dependencies], computeFn)`
Leitet einen State aus anderen ab.
```js
const first = createState('first', 'Max');
const last = createState('last', 'Mustermann');
const fullName = createComputedState([first, last], (f, l) => \`\${f} \${l}\`);
```
---
### 🔹 `enableUndoRedoForState(state)`
Fügt Undo/Redo-Funktionalität hinzu.
```js
const message = createState('message', '');
const history = enableUndoRedoForState(message);
history.undo();
history.redo();
```
---
### 🔹 `enableStateLogging(state, label?)`
Gibt alle Änderungen in der Konsole aus.
```js
enableStateLogging(username, 'Benutzername');
```
---
## 📁 `core/events.js` EventBus & ActionDispatcher
### 🔹 `on(eventName, callback)`
Abonniert benutzerdefinierte Events.
```js
on('user:login', user => {
console.log('Login:', user.name);
});
```
---
### 🔹 `emit(eventName, payload)`
Sendet ein Event global.
```js
emit('user:login', { name: 'Max', id: 123 });
```
---
### 🔹 `registerActionListener(callback)`
Reagiert auf alle ausgelösten Aktionen.
```js
registerActionListener(({ type, payload }) => {
if (type === 'counter/increment') {
payload.state.set(payload.state.get() + 1);
}
});
```
---
### 🔹 `dispatchAction(type, payload?)`
Löst eine benannte Aktion aus.
```js
dispatchAction('counter/increment', { state: counter });
```
---
## ✅ Zusammenfassung
| Funktion | Zweck |
|----------|-------|
| `createState` / `createPersistentState` | Reaktiver globaler Zustand |
| `bindStateToElement` / `bindInputToState` | DOM-Bindings |
| `createComputedState` | Abhängiger Zustand |
| `enableUndoRedoForState` | History / Undo |
| `enableStateLogging` | Debugging |
| `on` / `emit` | Lose gekoppelte Event-Kommunikation |
| `registerActionListener` / `dispatchAction` | Zentrale Aktionssteuerung |

View File

@@ -1 +1,48 @@
import '../css/styles.css';
import { initApp } from './core/init.js';
// resources/js/app.js (dein Einstiegspunkt)
import { registerSW } from 'virtual:pwa-register';
const updateSW = registerSW({
onNeedRefresh() {
const reload = confirm('🔄 Neue Version verfügbar. Seite neu laden?');
if (reload) updateSW(true);
},
onOfflineReady() {
console.log('📦 Offline-Inhalte sind bereit.');
}
});
registerSW({
onRegistered(reg) {
console.log('Service Worker registriert:', reg);
},
onRegisterError(error) {
console.error('Service Worker Fehler:', error);
}
});
document.addEventListener("DOMContentLoaded", () => {
initApp();
});
function isHtmlAttributeSupported(elementName, attribute) {
const element = document.createElement(elementName);
return attribute in element;
}
let closedAttr = document.getElementById('my-dialog');
if(! 'closedby' in closedAttr) {
alert('oh no');
}
/*
if (isHtmlAttributeSupported('dialog', 'closedby')) {
alert('Attribut wird unterstützt!');
} else {
alert('Nicht unterstützt!');
}
*/

View File

@@ -0,0 +1,25 @@
export const moduleConfig = {
'noise': {
selector: '.noise-overlay',
toggleKey: 'g',
className: 'grainy',
enableTransition: true
},
'shortcut-handler.js': {
debug: false
},
'scrollfx': {
selector: '.fade-in-on-scroll, .zoom-in',
offset: 0.8,
baseDelay: 0.075,
once: true
},
'scroll-timeline': {
attribute: 'data-scroll-step',
triggerPoint: 0.4,
once: false
},
'smooth-scroll': {
'speed': 0.2,
}
};

View File

@@ -0,0 +1,37 @@
// modules/example-module/index.js
import { registerFrameTask, unregisterFrameTask } from '../../core/frameloop.js';
let frameId = 'example-module';
let resizeHandler = null;
export function init(config = {}) {
console.log('[example-module] init');
// z.B. Event-Listener hinzufügen
resizeHandler = () => {
console.log('Fenstergröße geändert');
};
window.addEventListener('resize', resizeHandler);
// Scroll- oder Frame-Logik
registerFrameTask(frameId, () => {
// wiederkehrende Aufgabe
const scrollY = window.scrollY;
// ggf. transformieren oder Werte speichern
}, { autoStart: true });
}
export function destroy() {
console.log('[example-module] destroy');
// EventListener entfernen
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler);
resizeHandler = null;
}
// FrameTask entfernen
unregisterFrameTask(frameId);
// weitere Aufräumarbeiten, z.B. Observer disconnect
}

View File

@@ -0,0 +1,47 @@
import { moduleConfig } from './config.js';
import { Logger } from '../core/logger.js';
export const activeModules = new Map(); // key: modulename → { mod, config }
export async function registerModules() {
const modules = import.meta.glob('./*/index.js', { eager: true });
const domModules = new Set(
Array.from(document.querySelectorAll('[data-module]')).map(el => el.dataset.module).filter(Boolean)
);
const usedModules = new Set(domModules);
const fallbackMode = usedModules.size === 0;
Object.entries(modules).forEach(([path, mod]) => {
const name = path.split('/').slice(-2, -1)[0]; // z.B. "noise-toggle.js"
const config = moduleConfig[name] || {};
if(!fallbackMode && !usedModules.has(name)) {
Logger.info(`⏭️ [Module] Skipped (not used in DOM): ${name}`);
return;
}
if (typeof mod.init === 'function') {
mod.init(config);
activeModules.set(name, { mod, config });
Logger.info(`✅ [Module] Initialized: ${name}`);
} else {
Logger.warn(`⛔ [Module] No init() in ${name}`);
}
});
if (fallbackMode) {
Logger.info('⚠️ [Module] No data-module usage detected, fallback to full init mode');
}
}
export function destroyModules() {
for (const [name, { mod }] of activeModules.entries()) {
if (typeof mod.destroy === 'function') {
mod.destroy();
Logger.info(`🧹 [Module] Destroyed: ${name}`);
}
}
activeModules.clear();
}

View File

@@ -0,0 +1,63 @@
// modules/inertia-scroll/index.js
import { registerFrameTask, unregisterFrameTask } from '../../core/frameloop.js';
let taskId = 'inertia-scroll';
let velocity = 0;
let lastY = window.scrollY;
let active = false;
let scrollEndTimer;
let damping = 0.9;
let minVelocity = 0.2;
export function init(config = {}) {
damping = typeof config.damping === 'number' ? config.damping : 0.9;
minVelocity = typeof config.minVelocity === 'number' ? contig.minVelocity : 0.1;
window.addEventListener('scroll', onScroll, { passive: true });
registerFrameTask(taskId, () => {
const root = document.documentElement;
const scrollY = window.scrollY;
const delta = scrollY - lastY;
const direction = delta > 0 ? 'down' : delta < 0 ? 'up' : 'none';
const speed = Math.abs(delta);
if (!active && Math.abs(velocity) > minVelocity) {
window.scrollTo(0, scrollY + velocity);
velocity *= damping; // Trägheit / Dämpfung
root.dataset.scrollState = 'inertia';
} else if (active) {
velocity = delta;
lastY = scrollY;
root.dataset.scrollState = 'active';
} else {
delete root.dataset.scrollState;
}
root.dataset.scrollDirection = direction;
root.dataset.scrollSpeed = speed.toFixed(2);
}, { autoStart: true });
}
function onScroll() {
active = true;
clearTimeout(scrollEndTimer);
scrollEndTimer = setTimeout(() => {
active = false;
}, 50);
}
export function destroy() {
window.removeEventListener('scroll', onScroll);
unregisterFrameTask(taskId);
velocity = 0;
lastY = window.scrollY;
active = false;
clearTimeout(scrollEndTimer);
const root = document.documentElement;
delete root.dataset.scrollState;
delete root.dataset.scrollDirection;
delete root.dataset.scrollSpeed;
}

View File

@@ -0,0 +1,26 @@
// modules/lightbox-trigger/index.js
import { UIManager } from '../ui/UIManager.js';
import { Logger } from '../../core/logger.js';
import { useEvent } from '../../core/useEvent.js';
function onClick(e) {
const img = e.target.closest('[data-lightbox]');
if (!img || img.tagName !== 'IMG') return;
e.preventDefault();
UIManager.open('lightbox', {
content: `<img src="${img.src}" alt="${img.alt || ''}" />`
});
}
export function init() {
Logger.info('[lightbox-trigger] init');
useEvent(document, 'click', onClick);
}
export function destroy() {
Logger.info('[lightbox-trigger] destroy');
// Automatische Entfernung über EventManager erfolgt über Modulkennung
// Kein direkter Aufruf nötig, solange removeModules() global verwendet wird
}

View File

@@ -0,0 +1,42 @@
// js/noise-toggle.js
import { Logger } from "../../core/logger.js";
export function init(config = {}) {
Logger.log('Noise Toggle Init', config);
const {
selector = ".noise-overlay",
toggleKey = "g",
className = "grainy",
enableTransition = true,
} = config;
const body = document.body;
const noiseElement = document.querySelector(selector);
if (!noiseElement) return;
const isInput = noiseElement => ["input", "textarea"].includes(noiseElement.tagName.toLowerCase());
function update() {
if (enableTransition) {
noiseElement.classList.toggle("hidden", !body.classList.contains(className));
} else {
noiseElement.style.display = body.classList.contains(className) ? "block" : "none";
}
}
update();
document.addEventListener("keydown", (e) => {
if (
e.key.toLowerCase() === toggleKey &&
!e.ctrlKey && !e.metaKey && !e.altKey &&
!isInput(e.target)
) {
body.classList.toggle(className);
update();
}
})
}

View File

@@ -0,0 +1,27 @@
// modules/parallax/index.js
import { Logger } from '../../core/logger.js';
import { registerFrameTask } from '../../core/frameloop.js';
export function init(config = {}) {
Logger.info('Parallax init');
const defaultConfig = {
selector: '[data-parallax]',
speedAttr: 'data-parallax-speed',
defaultSpeed: 0.5
};
const settings = { ...defaultConfig, ...config };
const elements = document.querySelectorAll(settings.selector);
function updateParallax() {
const scrollY = window.scrollY;
elements.forEach(el => {
const speed = parseFloat(el.getAttribute(settings.speedAttr)) || settings.defaultSpeed;
const offset = scrollY * speed;
el.style.transform = `translateY(${offset}px)`;
});
}
registerFrameTask('parallax', updateParallax, { autoStart: true });
}

View File

@@ -0,0 +1,66 @@
// modules/scroll-loop/index.js
import { registerFrameTask } from '../../core/frameloop.js';
export function init(config = {}) {
const elements = document.querySelectorAll('[data-scroll-loop]');
elements.forEach(el => {
const type = el.dataset.scrollType || 'translate';
if (type === 'translate' && el.children.length === 1) {
const clone = el.firstElementChild.cloneNode(true);
clone.setAttribute('aria-hidden', 'true');
el.appendChild(clone);
}
});
registerFrameTask('scroll-loop', () => {
const scrollY = window.scrollY;
const scrollX = window.scrollX;
elements.forEach(el => {
const factor = parseFloat(el.dataset.scrollSpeed || config.speed || 0.2);
const axis = el.dataset.scrollAxis || 'y';
const type = el.dataset.scrollType || 'translate';
const pause = el.dataset.loopPause === 'true';
const offsetStart = parseFloat(el.dataset.loopOffset || 0);
const limit = parseFloat(el.dataset.loopLimit || 0);
const input = axis === 'x' ? scrollX : scrollY;
if (limit && input > limit) return;
if (pause && (el.matches(':hover') || el.matches(':active'))) return;
const offset = (input + offsetStart) * factor;
switch (type) {
case 'translate': {
const base = axis === 'x' ? el.offsetWidth : el.offsetHeight;
const value = -(offset % base);
const transform = axis === 'x' ? `translateX(${value}px)` : `translateY(${value}px)`;
el.style.transform = transform;
break;
}
case 'rotate': {
const deg = offset % 360;
el.style.transform = `rotate(${deg}deg)`;
break;
}
case 'background': {
const pos = offset % 100;
if (axis === 'x') {
el.style.backgroundPosition = `${pos}% center`;
} else {
el.style.backgroundPosition = `center ${pos}%`;
}
break;
}
case 'scale': {
const scale = 1 + Math.sin(offset * 0.01) * 0.1;
el.style.transform = `scale(${scale.toFixed(3)})`;
break;
}
default:
break;
}
});
}, { autoStart: true });
}

View File

@@ -0,0 +1,53 @@
// modules/scroll-timeline/index.js
import { Logger } from '../../core/logger.js';
import { scrollSteps } from './steps.js';
import {registerFrameTask, unregisterFrameTask} from '../../core/frameloop.js';
export function init(userConfig = {}) {
Logger.info('ScrollTimeline init');
const defaultConfig = {
attribute: 'data-scroll-step',
triggerPoint: 0.4,
once: true
};
const config = { ...defaultConfig, ...userConfig };
const steps = Array.from(document.querySelectorAll(`[${config.attribute}]`)).map(el => ({
el,
index: parseInt(el.getAttribute(config.attribute), 10),
active: false
}));
function update() {
const triggerY = window.innerHeight * config.triggerPoint;
steps.forEach(step => {
const rect = step.el.getBoundingClientRect();
const isVisible = rect.top < triggerY && rect.bottom > 0;
if (isVisible && !step.active) {
step.active = true;
step.el.classList.add('active');
Logger.log(`➡️ ENTER step ${step.index}`);
scrollSteps.onEnter?.(step.index, step.el);
}
if (!isVisible && step.active) {
step.active = false;
step.el.classList.remove('active');
Logger.log(`⬅️ LEAVE step ${step.index}`);
if (!config.once) {
scrollSteps.onLeave?.(step.index, step.el);
}
}
});
}
registerFrameTask('scroll-timeline', update, { autoStart: true });
}
export function destroy () {
unregisterFrameTask('scroll-timeline');
}

View File

@@ -0,0 +1,33 @@
export const scrollSteps = {
onEnter(index, el) {
el.classList.add('active');
document.body.dataset.activeScrollStep = index;
console.log(`[ScrollStep] Enter: ${index}`);
// Beispielaktionen
if (index === 1) showIntro();
if (index === 2) activateChart();
if (index === 3) revealQuote();
},
onLeave(index, el) {
el.classList.remove('active');
el.style.transitionDelay = '';
console.log(`[ScrollStep] Leave: ${index}`);
if (document.body.dataset.activeScrollStep === String(index)) {
delete document.body.dataset.activeScrollStep;
}
if (index === 1) hideIntro();
if (index === 2) deactivateChart();
if (index === 3) hideQuote();
}
};
function showIntro() { console.log('Intro sichtbar'); }
function hideIntro() { console.log('Intro ausgeblendet'); }
function activateChart() { console.log('Chart aktiviert'); }
function deactivateChart() { console.log('Chart deaktiviert'); }
function revealQuote() { console.log('Zitat eingeblendet'); }
function hideQuote() { console.log('Zitat ausgeblendet'); }

View File

@@ -0,0 +1,36 @@
// src/resources/js/scrollfx/ScrollEngine.js
class ScrollEngine {
constructor() {
this.triggers = new Set();
this.viewportHeight = window.innerHeight;
this._loop = this._loop.bind(this);
window.addEventListener('resize', () => {
this.viewportHeight = window.innerHeight;
});
requestAnimationFrame(this._loop);
}
register(trigger) {
this.triggers.add(trigger);
}
unregister(trigger) {
this.triggers.delete(trigger);
}
clear() {
this.triggers.clear();
}
_loop() {
this.triggers.forEach(trigger => {
trigger.update(this.viewportHeight);
});
requestAnimationFrame(this._loop);
}
}
export default new ScrollEngine();

View File

@@ -0,0 +1,69 @@
// src/resources/js/scrollfx/ScrollTrigger.js
export default class ScrollTrigger {
constructor(config) {
this.element = this.resolveElement(config.element);
this.target = config.target
? this.element.querySelector(config.target)
: this.element;
if (config.target && !this.target) {
throw new Error(`Target selector '${config.target}' not found inside element '${config.element}'.`);
}
this.start = config.start || 'top 80%';
this.end = config.end || 'bottom 20%';
this.scrub = config.scrub || false;
this.onEnter = config.onEnter || null;
this.onLeave = config.onLeave || null;
this.onUpdate = config.onUpdate || null;
this._wasVisible = false;
this._progress = 0;
}
resolveElement(input) {
if (typeof input === 'string') {
const els = document.querySelectorAll(input);
if (els.length === 1) return els[0];
throw new Error(`Selector '${input}' matched ${els.length} elements, expected exactly 1.`);
}
return input;
}
getScrollProgress(viewportHeight) {
const rect = this.element.getBoundingClientRect();
const startPx = this.parsePosition(this.start, viewportHeight);
const endPx = this.parsePosition(this.end, viewportHeight);
const scrollRange = endPx - startPx;
const current = rect.top - startPx;
return 1 - Math.min(Math.max(current / scrollRange, 0), 1);
}
parsePosition(pos, viewportHeight) {
const [edge, value] = pos.split(' ');
const edgeOffset = edge === 'top' ? 0 : viewportHeight;
const percentage = parseFloat(value) / 100;
return edgeOffset - viewportHeight * percentage;
}
update(viewportHeight) {
const rect = this.element.getBoundingClientRect();
const inViewport = rect.bottom > 0 && rect.top < viewportHeight;
if (inViewport && !this._wasVisible) {
this._wasVisible = true;
if (this.onEnter) this.onEnter(this.target);
}
if (!inViewport && this._wasVisible) {
this._wasVisible = false;
if (this.onLeave) this.onLeave(this.target);
}
if (this.scrub && inViewport) {
const progress = this.getScrollProgress(viewportHeight);
if (this.onUpdate) this.onUpdate(this.target, progress);
}
}
}

View File

@@ -0,0 +1,177 @@
// resources/js/scrollfx/Tween.js
export const Easing = {
linear: t => t,
easeInQuad: t => t * t,
easeOutQuad: t => t * (2 - t),
easeInOutQuad: t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
};
function interpolate(start, end, t) {
return start + (end - start) * t;
}
function parseTransform(transform) {
const result = {
translateY: 0,
scale: 1,
rotate: 0
};
if (!transform || transform === 'none') return result;
const translateMatch = transform.match(/translateY\((-?\d+(?:\.\d+)?)px\)/);
const scaleMatch = transform.match(/scale\((-?\d+(?:\.\d+)?)\)/);
const rotateMatch = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
if (translateMatch) result.translateY = parseFloat(translateMatch[1]);
if (scaleMatch) result.scale = parseFloat(scaleMatch[1]);
if (rotateMatch) result.rotate = parseFloat(rotateMatch[1]);
return result;
}
export function tweenTo(el, props = {}, duration = 300, easing = Easing.linear) {
const start = performance.now();
const initial = {};
const currentTransform = parseTransform(getComputedStyle(el).transform);
for (const key in props) {
if (['translateY', 'scale', 'rotate'].includes(key)) {
initial[key] = currentTransform[key];
} else {
const current = parseFloat(getComputedStyle(el)[key]) || 0;
initial[key] = current;
}
}
function animate(now) {
const t = Math.min((now - start) / duration, 1);
const eased = easing(t);
let transformParts = [];
for (const key in props) {
const startValue = initial[key];
const endValue = parseFloat(props[key]);
const current = interpolate(startValue, endValue, eased);
if (key === 'translateY') transformParts.push(`translateY(${current}px)`);
else if (key === 'scale') transformParts.push(`scale(${current})`);
else if (key === 'rotate') transformParts.push(`rotate(${current}deg)`);
else el.style[key] = current + (key === 'opacity' ? '' : 'px');
}
if (transformParts.length > 0) {
el.style.transform = transformParts.join(' ');
}
if (t < 1) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
}
export function timeline(steps = []) {
let index = 0;
function runNext() {
if (index >= steps.length) return;
const { el, props, duration, easing = Easing.linear, delay = 0 } = steps[index++];
setTimeout(() => {
tweenTo(el, props, duration, easing);
setTimeout(runNext, duration);
}, delay);
}
runNext();
}
export function tweenFromTo(el, from = {}, to = {}, duration = 300, easing = Easing.linear) {
for (const key in from) {
if (['translateY', 'scale', 'rotate'].includes(key)) {
el.style.transform = `${key}(${from[key]}${key === 'rotate' ? 'deg' : key === 'translateY' ? 'px' : ''})`;
} else {
el.style[key] = from[key] + (key === 'opacity' ? '' : 'px');
}
}
tweenTo(el, to, duration, easing);
}
// === Utility Animations ===
export function fadeIn(el, duration = 400, easing = Easing.easeOutQuad) {
el.classList.remove('fade-out');
el.classList.add('fade-in');
tweenTo(el, { opacity: 1 }, duration, easing);
}
export function fadeOut(el, duration = 400, easing = Easing.easeInQuad) {
el.classList.remove('fade-in');
el.classList.add('fade-out');
tweenTo(el, { opacity: 0 }, duration, easing);
}
export function zoomIn(el, duration = 500, easing = Easing.easeOutQuad) {
el.classList.add('zoom-in');
tweenTo(el, { opacity: 1, scale: 1 }, duration, easing);
}
export function zoomOut(el, duration = 500, easing = Easing.easeInQuad) {
el.classList.remove('zoom-in');
tweenTo(el, { opacity: 0, scale: 0.8 }, duration, easing);
}
export function triggerCssAnimation(el, className, duration = 1000) {
el.classList.add(className);
setTimeout(() => {
el.classList.remove(className);
}, duration);
}
// === ScrollTrigger Presets ===
import { createTrigger } from './index.js';
export function fadeScrollTrigger(selector, options = {}) {
const elements = document.querySelectorAll(selector);
elements.forEach(el => {
createTrigger({
element: el,
start: options.start || 'top 80%',
end: options.end || 'bottom 20%',
onEnter: () => fadeIn(el),
onLeave: () => fadeOut(el)
});
});
}
export function zoomScrollTrigger(selector, options = {}) {
const elements = document.querySelectorAll(selector);
elements.forEach(el => {
createTrigger({
element: el,
start: options.start || 'top 80%',
end: options.end || 'bottom 20%',
onEnter: () => zoomIn(el),
onLeave: () => zoomOut(el)
});
});
}
export function fixedZoomScrollTrigger(selector, options = {}) {
const elements = document.querySelectorAll(selector);
elements.forEach(el => {
el.style.willChange = 'transform, opacity';
el.style.opacity = 0;
el.style.transform = 'scale(0.8)';
createTrigger({
element: el,
start: options.start || 'top 100%',
end: options.end || 'top 40%',
onEnter: () => zoomIn(el, 600),
onLeave: () => zoomOut(el, 400)
});
});
}

View File

@@ -0,0 +1,57 @@
// src/resources/js/scrollfx/index.js
import ScrollEngine from './ScrollEngine.js';
import ScrollTrigger from './ScrollTrigger.js';
import {fadeScrollTrigger, fixedZoomScrollTrigger, zoomScrollTrigger} from "./Tween";
export function createTrigger(config) {
const elements = typeof config.element === 'string'
? document.querySelectorAll(config.element)
: [config.element];
const triggers = [];
elements.forEach(el => {
const trigger = new ScrollTrigger({ ...config, element: el });
ScrollEngine.register(trigger);
triggers.push(trigger);
});
return triggers.length === 1 ? triggers[0] : triggers;
}
export function init(config = {}) {
const {
selector = '.fade-in-on-scroll, .zoom-in, .fade-out, .fade',
offset = 0.85, // z.B. 85 % Viewport-Höhe
baseDelay = 0.05, // delay pro Element (stagger)
once = true // nur 1× triggern?
} = config;
const elements = Array.from(document.querySelectorAll(selector))
.map(el => ({ el, triggered: false }));
function update() {
const triggerY = window.innerHeight * offset;
elements.forEach((obj, index) => {
if (obj.triggered && once) return;
const rect = obj.el.getBoundingClientRect();
if (rect.top < triggerY) {
obj.el.style.transitionDelay = `${index * baseDelay}s`;
obj.el.classList.add('visible', 'entered');
obj.el.classList.remove('fade-out');
obj.triggered = true;
} else if (!once) {
obj.el.classList.remove('visible', 'entered');
obj.triggered = false;
}
});
requestAnimationFrame(update);
}
requestAnimationFrame(update);
}

View File

@@ -0,0 +1,62 @@
import {useEvent} from "../../core";
import {removeModules} from "../../core/removeModules";
let keydownHandler = null;
let clickHandler = null;
export function init() {
const el = document.getElementById("sidebar-menu");
const button = document.getElementById('menu-toggle');
const aside = document.getElementById('sidebar');
const backdrop = document.querySelector('.backdrop');
const footer = document.querySelector('footer');
const headerLink = document.querySelector('header a');
useEvent(button, 'click', (e) => {
aside.classList.toggle('show');
//aside.toggleAttribute('inert')
let isVisible = aside.classList.contains('show');
if (isVisible) {
backdrop.classList.add('visible');
footer.setAttribute('inert', 'true');
headerLink.setAttribute('inert', 'true');
} else {
backdrop.classList.remove('visible');
footer.removeAttribute('inert');
headerLink.removeAttribute('inert');
}
})
keydownHandler = (e) => {
if(e.key === 'Escape'){
if(aside.classList.contains('show')){
aside.classList.remove('show');
backdrop.classList.remove('visible');
}
}
}
useEvent(document, 'keydown', keydownHandler)
clickHandler = (e) => {
aside.classList.remove('show');
backdrop.classList.remove('visible');
footer.removeAttribute('inert');
headerLink.removeAttribute('inert');
}
useEvent(backdrop, 'click' , clickHandler)
}
export function destroy() {
/*document.removeEventListener('keydown', keydownHandler);
document.removeEventListener('click', clickHandler);*/
removeModules('sidebar');
}

View File

@@ -0,0 +1,118 @@
// modules/smooth-scroll/index.js
import { Logger } from '../../core/logger.js';
import { registerFrameTask } from '../../core/frameloop.js';
/*
export function init(config = {}) {
Logger.info('SmoothScroll init');
const defaultConfig = {
speed: 0.12,
scrollTarget: window,
interceptWheel: true,
interceptTouch: true,
interceptKeys: true
};
const settings = { ...defaultConfig, ...config };
const scrollElement = settings.scrollTarget === window ? document.scrollingElement : settings.scrollTarget;
let current = scrollElement.scrollTop;
let target = current;
function clampTarget() {
const maxScroll = scrollElement.scrollHeight - window.innerHeight;
target = Math.max(0, Math.min(target, maxScroll));
}
function update() {
const delta = target - current;
current += delta * settings.speed;
const maxScroll = scrollElement.scrollHeight - window.innerHeight;
current = Math.max(0, Math.min(current, maxScroll));
scrollElement.scrollTop = current;
}
registerFrameTask('smooth-scroll', update, { autoStart: true });
function triggerScroll(immediate = false) {
clampTarget();
if (immediate) scrollElement.scrollTop = target;
}
function onWheel(e) {
if (!settings.interceptWheel) return;
e.preventDefault();
target += e.deltaY;
triggerScroll(true);
}
let lastTouchY = 0;
function onTouchStart(e) {
if (!settings.interceptTouch) return;
lastTouchY = e.touches[0].clientY;
}
function onTouchMove(e) {
if (!settings.interceptTouch) return;
e.preventDefault();
const touch = e.changedTouches[0];
const dy = lastTouchY - touch.clientY;
target += dy;
lastTouchY = touch.clientY;
triggerScroll(true);
}
function onKeyDown(e) {
if (!settings.interceptKeys) return;
const keyScrollAmount = 60;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
target += keyScrollAmount;
break;
case 'ArrowUp':
e.preventDefault();
target -= keyScrollAmount;
break;
case 'PageDown':
e.preventDefault();
target += window.innerHeight * 0.9;
break;
case 'PageUp':
e.preventDefault();
target -= window.innerHeight * 0.9;
break;
case 'Home':
e.preventDefault();
target = 0;
break;
case 'End':
e.preventDefault();
target = scrollElement.scrollHeight;
break;
}
triggerScroll(true);
}
if (settings.interceptWheel) {
window.addEventListener('wheel', onWheel, { passive: false });
}
if (settings.interceptTouch) {
window.addEventListener('touchstart', onTouchStart, { passive: false });
window.addEventListener('touchmove', onTouchMove, { passive: false });
}
if (settings.interceptKeys) {
window.addEventListener('keydown', onKeyDown);
}
// initial scroll alignment
target = scrollElement.scrollTop;
current = target;
}
*/

View File

@@ -0,0 +1,67 @@
// modules/sticky-fade/index.js
import { registerFrameTask, unregisterFrameTask } from '../../core/frameloop.js';
let taskId = 'sticky-fade';
let elements = [];
let lastScrollY = window.scrollY;
let activeMap = new WeakMap();
let configCache = {
direction: false,
reset: false,
};
export function init(config = {}) {
elements = Array.from(document.querySelectorAll('[data-sticky-fade]'));
if (elements.length === 0) return;
configCache.direction = config.direction ?? false;
configCache.reset = config.reset ?? false;
registerFrameTask(taskId, () => {
const scrollY = window.scrollY;
const direction = scrollY > lastScrollY ? 'down' : scrollY < lastScrollY ? 'up' : 'none';
lastScrollY = scrollY;
const viewportHeight = window.innerHeight;
elements.forEach(el => {
const rect = el.getBoundingClientRect();
const progress = 1 - Math.min(Math.max(rect.top / viewportHeight, 0), 1);
el.style.opacity = progress.toFixed(3);
el.style.transform = `translateY(${(1 - progress) * 20}px)`;
if(configCache.direction) {
el.dataset.scrollDir = direction;
}
if (configCache.reset) {
const isVisible = progress >= 1;
const wasActive = activeMap.get(el) || false;
if(isVisible && !wasActive) {
el.classList.add('visible');
activeMap.set(el, true);
} else if(!isVisible && wasActive) {
el.classList.remove('visible');
activeMap.set(el, false);
}
}
});
}, { autoStart: true });
}
export function destroy() {
unregisterFrameTask(taskId);
elements.forEach(el => {
el.style.opacity = '';
el.style.transform = '';
el.classList.remove('visible');
delete el.dataset.scrollDir;
});
elements = [];
activeMap = new WeakMap();
}

View File

@@ -0,0 +1,45 @@
// modules/sticky-steps/index.js
import { Logger } from '../../core/logger.js';
import {registerFrameTask, unregisterFrameTask} from '../../core/frameloop.js';
export function init(config = {}) {
Logger.info('StickySteps init');
const defaultConfig = {
containerSelector: '[data-sticky-container]',
stepSelector: '[data-sticky-step]',
activeClass: 'is-sticky-active',
datasetKey: 'activeStickyStep'
};
const settings = { ...defaultConfig, ...config };
const containers = document.querySelectorAll(settings.containerSelector);
containers.forEach(container => {
const steps = container.querySelectorAll(settings.stepSelector);
const containerOffsetTop = container.offsetTop;
function update() {
const scrollY = window.scrollY;
const containerHeight = container.offsetHeight;
steps.forEach((step, index) => {
const stepOffset = containerOffsetTop + index * (containerHeight / steps.length);
const nextStepOffset = containerOffsetTop + (index + 1) * (containerHeight / steps.length);
const isActive = scrollY >= stepOffset && scrollY < nextStepOffset;
step.classList.toggle(settings.activeClass, isActive);
if (isActive) {
container.dataset[settings.datasetKey] = index;
}
});
}
registerFrameTask(`sticky-steps-${container.dataset.moduleId || Math.random()}`, update, { autoStart: true });
});
}
export function destroy() {
unregisterFrameTask('sticky-steps');
}

View File

@@ -0,0 +1,23 @@
// modules/ui/UIManager.js
import { Modal } from './components/Modal.js';
const components = {
modal: Modal,
};
export const UIManager = {
open(type, props = {}) {
const Component = components[type];
if (!Component) {
console.warn(`[UIManager] Unknown type: ${type}`);
return null;
}
const instance = new Component(props);
instance.open();
return instance;
},
close(instance) {
if (instance?.close) instance.close();
}
};

View File

@@ -0,0 +1,41 @@
import {useEvent} from "../../../core/useEvent";
export class Dialog {
constructor({content = '', className = '', onClose = null} = {}) {
this.onClose = onClose;
this.dialog = document.createElement('dialog');
this.dialog.className = className;
this.dialog.innerHTML = `
<form method="dialog" class="${className}-content">
${content}
<button class="${className}-close" value="close">×</button>
</form>
`;
useEvent(this.dialog, 'click', (e) => {
const isOutside = !e.target.closest(className+'-content');
if (isOutside) this.close();
})
useEvent(this.dialog, 'cancel', (e) => {
e.preventDefault();
this.close();
})
}
open()
{
document.body.appendChild(this.dialog);
this.dialog.showModal?.() || this.dialog.setAttribute('open', '');
document.documentElement.dataset[`${this.dialog.className}Open`] = 'true';
}
close()
{
this.dialog.close?.() || this.dialog.removeAttribute('open');
this.dialog.remove();
delete document.documentElement.dataset[`${this.dialog.className}Open`];
this.onClose?.();
}
}

View File

@@ -0,0 +1,6 @@
import { Dialog } from './Dialog.js';
export class Lightbox extends Dialog {
constructor(props) {
super({ ...props, className: 'lightbox' });
}
}

View File

@@ -0,0 +1,6 @@
import { Dialog } from './Dialog.js';
export class Modal extends Dialog {
constructor(props) {
super({ ...props, className: 'modal' });
}
}

View File

@@ -0,0 +1,9 @@
import { UIManager } from './UIManager.js';
export function init() {
/*UIManager.open('modal', {
content: '<p>Hallo!</p><button class="modal-close">OK</button>',
onClose: () => console.log('Modal wurde geschlossen')
});*/
}

View File

@@ -0,0 +1,40 @@
// modules/wheel-boost/index.js
import { registerFrameTask, unregisterFrameTask } from '../../core/frameloop.js';
/*
let velocity = 0;
let taskId = 'wheel-boost';
let damping = 0.85;
let boost = 1.2;
let enabled = true;
export function init(config = {}) {
damping = typeof config.damping === 'number' ? config.damping : 0.85;
boost = typeof config.boost === 'number' ? config.boost : 1.2;
enabled = config.enabled !== false;
if (!enabled) return;
window.addEventListener('wheel', onWheel, { passive: false });
registerFrameTask(taskId, () => {
if (Math.abs(velocity) > 0.1) {
window.scrollBy(0, velocity);
velocity *= damping;
} else {
velocity = 0;
}
}, { autoStart: true });
}
function onWheel(e) {
velocity += e.deltaY * boost;
e.preventDefault(); // verhindert das native Scrollen
}
export function destroy() {
window.removeEventListener('wheel', onWheel);
unregisterFrameTask(taskId);
velocity = 0;
}
*/

View File

@@ -0,0 +1,23 @@
export function registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => {
console.log('✅ Service Worker registriert:', reg.scope);
// Update found?
reg.onupdatefound = () => {
const installing = reg.installing;
installing.onstatechange = () => {
if (installing.state === 'installed') {
if (navigator.serviceWorker.controller) {
console.log('🔄 Neue Version verfügbar Seite neu laden?');
} else {
console.log('✅ Inhalte jetzt offline verfügbar');
}
}
};
};
})
.catch(err => console.error('❌ SW-Fehler:', err));
}
}

View File

@@ -0,0 +1,50 @@
// 📦 Automatischer Video-Loader (Bandbreiten- & Auflösungs-bewusst)
// Erkennt automatisch <video data-src="basename"> Elemente
// Lädt passende Variante: basename-480.webm, -720.webm, -1080.webm etc.
export function autoLoadResponsiveVideos() {
const videos = document.querySelectorAll('video[data-src]');
const width = window.innerWidth;
const connection = navigator.connection || {};
const net = connection.effectiveType || '4g';
videos.forEach(video => {
const base = video.dataset.src;
let suffix = '480';
if (net === '2g' || net === 'slow-2g') {
suffix = '480';
} else if (net === '3g') {
suffix = width >= 1200 ? '720' : '480';
} else {
suffix = width >= 1200 ? '1080' : width >= 800 ? '720' : '480';
}
const src = `${base}-${suffix}.webm`;
const newVideo = document.createElement('video');
newVideo.autoplay = true;
newVideo.loop = true;
newVideo.muted = true;
newVideo.playsInline = true;
newVideo.poster = video.getAttribute('poster') || '';
newVideo.setAttribute('width', video.getAttribute('width') || '100%');
const source = document.createElement('source');
source.src = src;
source.type = 'video/webm';
newVideo.appendChild(source);
video.replaceWith(newVideo);
});
}
/*
<video data-src="/media/video" poster="/media/preview.jpg" width="100%">
<!-- Optionaler Fallback -->
<source src="/media/video-480.mp4" type="video/mp4"/>
</video>
*/

View File

@@ -0,0 +1,34 @@
export class SimpleCache {
constructor(maxSize = 20, ttl = 60000) {
this.cache = new Map();
this.maxSize = maxSize;
this.ttl = ttl;
}
get(key) {
const cached = this.cache.get(key);
if (!cached) return null;
if (Date.now() - cached.timestamp > this.ttl) {
this.cache.delete(key);
return null;
}
return cached.data;
}
set(key, data) {
if (this.cache.size >= this.maxSize) {
// Entferne das älteste Element
const oldest = [...this.cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0][0];
this.cache.delete(oldest);
}
this.cache.set(key, { data, timestamp: Date.now() });
}
has(key) {
return this.get(key) !== null;
}
clear() {
this.cache.clear();
}
}

View File

@@ -0,0 +1 @@
export * from './cache'