chore: complete update
This commit is contained in:
42
resources/css/STRUCTURE.md
Normal file
42
resources/css/STRUCTURE.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# CSS-Framework Struktur
|
||||
|
||||
## Layer-Hierarchie
|
||||
1. reset
|
||||
2. base
|
||||
3. layout
|
||||
4. components
|
||||
5. utilities
|
||||
6. overrides
|
||||
|
||||
## Benennungskonventionen
|
||||
- Dateinamen: kebab-case
|
||||
- Klassen: BEM oder Utility-First? (optional notieren)
|
||||
|
||||
|
||||
css/
|
||||
├── styles.css # Haupt-Importdatei
|
||||
├── settings/
|
||||
│ ├── colors.css # Farbdefinitionen
|
||||
│ ├── typography.css # Schriftgrößen, Fonts
|
||||
│ ├── spacing.css # Abstände (margin, padding)
|
||||
│ └── variables.css # Dauer, Easing, Radius, Z-Index etc.
|
||||
├── base/
|
||||
│ ├── reset.css # Reset/Normalize
|
||||
│ ├── global.css # globale Stile für html, body, etc.
|
||||
│ └── typography.css # h1, p, etc.
|
||||
├── components/
|
||||
│ ├── header.css
|
||||
│ ├── nav.css
|
||||
│ ├── footer.css
|
||||
│ └── buttons.css
|
||||
├── layout/
|
||||
│ ├── container.css # .page-container, max-widths, etc.
|
||||
│ └── grid.css # evtl. eigenes Grid-System
|
||||
├── utilities/
|
||||
│ ├── animations.css # .fade-in, .shake, usw.
|
||||
│ ├── helpers.css # .skip-link, .hidden, .visually-hidden
|
||||
│ └── scroll.css # scroll-behavior, scrollbar-style
|
||||
├── forms/
|
||||
│ └── inputs.css
|
||||
└── themes/
|
||||
└── dark.css # Farbanpassungen für Dark-Mode (optional)
|
||||
31
resources/css/base/focus.css
Normal file
31
resources/css/base/focus.css
Normal file
@@ -0,0 +1,31 @@
|
||||
:focus-visible {
|
||||
--outline-size: max(2px, 0.1em);
|
||||
|
||||
outline:
|
||||
var(--outline-width, var(--outline-size))
|
||||
var(--outline-style, solid)
|
||||
var(--outline-color, currentColor);
|
||||
|
||||
outline-offset: var(--outline-offset, var(--outline-size));
|
||||
|
||||
border-radius: 0.25em;
|
||||
}
|
||||
|
||||
:where(:not(:active):focus-visible) {
|
||||
outline-offset: 5px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:where(:focus-visible) {
|
||||
transition: outline-offset .2s ease;
|
||||
}
|
||||
:where(:not(:active):focus-visible) {
|
||||
transition-duration: .25s;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll margin allowance below focused elements
|
||||
to ensure they are clearly in view */
|
||||
:focus {
|
||||
scroll-padding-block-end: 8vh;
|
||||
}
|
||||
147
resources/css/base/global.css
Normal file
147
resources/css/base/global.css
Normal file
@@ -0,0 +1,147 @@
|
||||
:root {
|
||||
--container-width: min(90vw, 2000px);
|
||||
--content-padding: clamp(1rem, 4vw, 3rem);
|
||||
--header-height: 80px;
|
||||
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
--outline-color: var(--accent);
|
||||
}
|
||||
|
||||
body>header {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-block-end: 1px solid var(--accent);
|
||||
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
|
||||
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&>div{
|
||||
width: var(--container-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--content-padding);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
body>main {
|
||||
flex: 1;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
body>footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
main>section {
|
||||
width: var(--container-width);
|
||||
margin: 0 auto;
|
||||
padding: 4rem var(--content-padding);
|
||||
}
|
||||
|
||||
section>.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin: 0 auto;
|
||||
/*margin-top: 3rem;*/
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media(prefers-reduced-motion: no-preference) {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* somehow does not work! */
|
||||
@media(prefers-reduced-motion: no-preference) {
|
||||
:has(:target) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
--selection-text-decoration: underline;
|
||||
--link-color: var(--accent);
|
||||
|
||||
--_color: var(--link-color, rgba(255, 255, 255, 0.5));
|
||||
--_underline-color: var(--underline-color, currentColor);
|
||||
--_thickness: 2px;
|
||||
--_offset: 0.05em;
|
||||
--_hover-color: oklch(from var(--link-color) 80% c h);
|
||||
|
||||
color: var(--_color);
|
||||
|
||||
text-decoration-line: var(--selection-text-decoration);
|
||||
text-decoration-color: var(--_underline-color);
|
||||
text-decoration-thickness: var(--_thickness);
|
||||
text-underline-offset: var(--_offset);
|
||||
|
||||
padding: max(0.25rem, 0.1em) 0;
|
||||
|
||||
display: inline-block;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
transition:
|
||||
color 0.15s ease-in-out,
|
||||
text-decoration 0.15s,
|
||||
transform 0.1s ease-in-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
forced-color-adjust: none;
|
||||
color: LinkText;
|
||||
text-decoration-color: LinkText;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
--_color: var(--_hover-color);
|
||||
|
||||
--_underline-color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
/*text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
|
||||
transform: translateY(-1px);*/
|
||||
}
|
||||
|
||||
&:active {
|
||||
--_color: oklch(from var(--link-color) 75% c h);
|
||||
|
||||
/*transform: translateY(1px);*/
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline Link */
|
||||
p > a {
|
||||
text-decoration: underline;
|
||||
border:none;
|
||||
|
||||
/*&::after {
|
||||
content: ' ↗';
|
||||
}*/
|
||||
}
|
||||
4
resources/css/base/index.css
Normal file
4
resources/css/base/index.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "focus.css";
|
||||
@import "global.css";
|
||||
@import "media.css";
|
||||
@import "typography.css";
|
||||
5
resources/css/base/media.css
Normal file
5
resources/css/base/media.css
Normal file
@@ -0,0 +1,5 @@
|
||||
:where(img, video) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
34
resources/css/base/reset.css
Normal file
34
resources/css/base/reset.css
Normal file
@@ -0,0 +1,34 @@
|
||||
/* alle Elemente inklusive Pseudoelemente mit border-box rechnen lassen */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:where(html, body, h1, h2, h3, h4, h5, h6, p, blockquote, figure, dl, dd, ul, ol) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:where(table) {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
:where(article, aside, footer, header, nav, section, main) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[popover] {
|
||||
/* CSSWG Issue #10258 */
|
||||
inset: auto;
|
||||
}
|
||||
|
||||
/* @link: https://moderncss.dev/12-modern-css-one-line-upgrades/#scroll-margin-topbottom */
|
||||
:where([id]) {
|
||||
scroll-margin-block-start: 2rem;
|
||||
}
|
||||
|
||||
/* Vererbung für SVG-Icons */
|
||||
svg {
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
}
|
||||
108
resources/css/base/typography.css
Normal file
108
resources/css/base/typography.css
Normal file
@@ -0,0 +1,108 @@
|
||||
html {
|
||||
font-size: 100%;
|
||||
|
||||
font-family: 'Roboto', sans-serif;
|
||||
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
font-size: clamp(1.125rem, 4cqi, 1.5rem);
|
||||
}
|
||||
|
||||
section {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* trims of empty pixels above and below fonts */
|
||||
:is(h1, h2, h3, h4, h5, h6, p, button) {
|
||||
text-box: trim-both cap alphabetic;
|
||||
}
|
||||
|
||||
p,
|
||||
li,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
padding-block: 1.5rem;
|
||||
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 6cqi, 6rem);
|
||||
font-weight: 700;
|
||||
|
||||
line-height: 1.125;
|
||||
|
||||
max-inline-size: 25ch;
|
||||
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-transform: uppercase;
|
||||
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.65;
|
||||
max-inline-size: 30ch;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 2rem;
|
||||
--selection-bg-color: rgba(0, 0, 255, 0.3);
|
||||
--selection-text-color: #d8cc48;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: clamp(3rem, 4vw + 0.5rem, 4rem);
|
||||
font-weight: 700;
|
||||
|
||||
max-inline-size: 25ch;
|
||||
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.75rem;
|
||||
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: clamp(2rem, 3vw + 0.5rem, 3rem);
|
||||
}
|
||||
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
|
||||
line-height: 1.2;
|
||||
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.03em;
|
||||
|
||||
/* max 75ch */
|
||||
max-inline-size: 65ch;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
content: '\201C';
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '\201D';
|
||||
}
|
||||
}
|
||||
99
resources/css/components/buttons.css
Normal file
99
resources/css/components/buttons.css
Normal file
@@ -0,0 +1,99 @@
|
||||
:where(button, input):where(:not(:active)):focus-visible {
|
||||
outline-offset: 5px;
|
||||
}
|
||||
|
||||
/* All buttons */
|
||||
:where(
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"],
|
||||
input[type="file"]
|
||||
),
|
||||
/* ::file-selector-button does not work inside of :where() */
|
||||
:where(input[type="file"])::file-selector-button {
|
||||
--_color: var(--color, var(--accent));
|
||||
|
||||
background-color: var(--_color);
|
||||
color: black;
|
||||
|
||||
border: 2px solid var(--_color);
|
||||
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: 1.5;
|
||||
|
||||
border-radius: 1rem;
|
||||
padding: 0.5em;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
object-fit: contain;
|
||||
|
||||
/*display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 1ch;*/
|
||||
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-out;
|
||||
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
|
||||
&:is(:hover, :focus-visible) {
|
||||
--_hoverColor: oklch(from var(--color, var(--accent)) 40% c h);
|
||||
--_color: var(--hoverColor, var(--_hoverColor));
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
--color: lightgreen;
|
||||
/*--hoverColor: lawngreen;*/
|
||||
}
|
||||
|
||||
&.outline {
|
||||
background: transparent;
|
||||
color: var(--_color);
|
||||
}
|
||||
}
|
||||
|
||||
/* on hover but not active */
|
||||
:where(
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"]):where(:not(:active):hover
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
:where([type="reset"]) {
|
||||
color: red;
|
||||
}
|
||||
|
||||
:where([type="reset"]:focus-visible) {
|
||||
outline-color: currentColor;
|
||||
}
|
||||
|
||||
|
||||
:where(button,
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"]
|
||||
)[disabled] {
|
||||
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:where(input[type="file"]) {
|
||||
inline-size: 100%;
|
||||
max-inline-size: max-content;
|
||||
}
|
||||
|
||||
:where(input[type="button"]),
|
||||
:where(input[type="file"])::file-selector-button {
|
||||
appearance: none;
|
||||
}
|
||||
202
resources/css/components/card.css
Normal file
202
resources/css/components/card.css
Normal file
@@ -0,0 +1,202 @@
|
||||
.card {
|
||||
--_bg: var(--card-bg, var(--bg-alt));
|
||||
--_border: var(--card-border, var(--border));
|
||||
--_accent: var(--card-accent, var(--accent));
|
||||
|
||||
background-color: var(--_bg);
|
||||
border: 1px solid var(--_border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
transition: all 0.2s ease;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--_accent);
|
||||
}
|
||||
|
||||
/* Strukturelle Elemente mit Selektoren */
|
||||
.card > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.card > main {
|
||||
flex: 1;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.card > footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-sm);
|
||||
border-top: 1px solid var(--_border);
|
||||
}
|
||||
|
||||
/* Typografie mit Selektoren */
|
||||
.card h1,
|
||||
.card h2,
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
color: var(--_accent);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card small {
|
||||
color: oklch(70% 0.01 var(--h-bg));
|
||||
}
|
||||
|
||||
/* Button-Gruppen mit Selektoren */
|
||||
.card footer > div {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.card button {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--_accent);
|
||||
background: transparent;
|
||||
color: var(--_accent);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card button:first-child {
|
||||
background-color: var(--_accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.card button:hover {
|
||||
background-color: var(--_accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
======================
|
||||
VARIANTEN MIT KLASSEN
|
||||
======================
|
||||
*/
|
||||
|
||||
/* Status-Varianten */
|
||||
.card--success {
|
||||
--card-bg: var(--success-subtle);
|
||||
--card-border: var(--success-border);
|
||||
--card-accent: var(--success);
|
||||
}
|
||||
|
||||
.card--error {
|
||||
--card-bg: var(--error-subtle);
|
||||
--card-border: var(--error-border);
|
||||
--card-accent: var(--error);
|
||||
}
|
||||
|
||||
/* Größen-Varianten */
|
||||
.card--compact {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.card--compact > * {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.card--spacious {
|
||||
padding: calc(var(--space-lg) * 1.5);
|
||||
}
|
||||
|
||||
/* Layout-Varianten */
|
||||
.card--horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card--horizontal > header,
|
||||
.card--horizontal > main {
|
||||
margin-bottom: 0;
|
||||
margin-right: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Media-Variante */
|
||||
.card--media img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
======================
|
||||
RESPONSIVE MIT CONTAINER QUERIES
|
||||
======================
|
||||
*/
|
||||
|
||||
/* Automatische Anpassung basierend auf Card-Größe */
|
||||
@container (max-width: 300px) {
|
||||
.card > header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card > footer {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Demo-Styles */
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.demo-grid--wide {
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
}
|
||||
|
||||
h1 {
|
||||
/*text-align: center;*/
|
||||
background: linear-gradient(45deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: var(--accent);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Badge mit semantischem Selector */
|
||||
.card [role="status"] {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background-color: var(--_accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
36
resources/css/components/footer.css
Normal file
36
resources/css/components/footer.css
Normal file
@@ -0,0 +1,36 @@
|
||||
footer {
|
||||
text-align: center;
|
||||
padding-block: 1.5rem;
|
||||
|
||||
a {
|
||||
--link-color: var(--muted);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 400;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
footer > p {
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 1rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.footer-nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem; /* Abstand zwischen Links */
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0 0; /* leichter Abstand nach dem Text */
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.footer-nav {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
50
resources/css/components/header.css
Normal file
50
resources/css/components/header.css
Normal file
@@ -0,0 +1,50 @@
|
||||
main > .header {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 4000;
|
||||
}
|
||||
|
||||
main > .header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3rem; /* Optional: Abstand innen */
|
||||
|
||||
padding-inline: clamp(1.5rem, 4vw + 1rem, 3rem);
|
||||
|
||||
user-select: none;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #eee;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
|
||||
transition: all 0.2s ease-in;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
|
||||
z-index: 9999;
|
||||
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
resources/css/components/nav.css
Normal file
3
resources/css/components/nav.css
Normal file
@@ -0,0 +1,3 @@
|
||||
:root{
|
||||
|
||||
}
|
||||
119
resources/css/components/sidebar.css
Normal file
119
resources/css/components/sidebar.css
Normal file
@@ -0,0 +1,119 @@
|
||||
body:has(aside.show) {
|
||||
overflow: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
aside {
|
||||
--sidebar-width: min(50ch, 100vw);
|
||||
|
||||
position: absolute;
|
||||
z-index: 3000;
|
||||
|
||||
visibility: hidden;
|
||||
|
||||
background-color: #2c1c59;
|
||||
color: #fff;
|
||||
padding: 3rem;
|
||||
border: none;
|
||||
|
||||
/*position: fixed;*/
|
||||
/*position: sticky;*/
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: calc(100vw - var(--sidebar-width));
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
|
||||
transition: opacity 0.25s, translate 0.25s, overlay 0.25s allow-discrete, display 0.25s allow-discrete;
|
||||
|
||||
opacity: 0;
|
||||
translate: 100% 0;
|
||||
|
||||
&.show {
|
||||
visibility: visible;
|
||||
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
|
||||
button {
|
||||
place-content: end;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
|
||||
margin-inline-end: 0.5rem;
|
||||
float: right;
|
||||
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Backdrop */
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1000;
|
||||
|
||||
/*cursor: url(https://s3-us-west-2.amazonaws.com/s.cdpn.io/9632/heart.svg), auto;*/
|
||||
}
|
||||
:is(aside.show) .backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
body:has(aside.show) .backdrop {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
aside > nav > ul {
|
||||
|
||||
padding-block-start: 10rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
list-style: none;
|
||||
gap: 3rem;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 1rem;
|
||||
/*font-size: 1.5rem;*/
|
||||
}
|
||||
|
||||
@media (hover) and (prefers-reduced-motion: no-preference) {
|
||||
& > li {
|
||||
transition: opacity .3s ease;
|
||||
}
|
||||
|
||||
&:has(:hover) > li:not(:hover) {
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
/* for keyboard support */
|
||||
&:is(:hover, :focus-within) > li:not(:hover, :focus-within) {
|
||||
opacity: .85;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop:has( ~ #sidebar a:hover, ~ #sidebar a:focus)/*, .backdrop:has( ~ #sidebar a:focus)*/ {
|
||||
background-color: rgba(0 0 0 / 0.1);
|
||||
}
|
||||
101
resources/css/forms/inputs.css
Normal file
101
resources/css/forms/inputs.css
Normal file
@@ -0,0 +1,101 @@
|
||||
:where(button, input, optgroup, select, textarea) {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea, select, input {
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
/* defensive style - just demo values */
|
||||
textarea {
|
||||
min-block-size: 3lh;
|
||||
max-block-size: 80svh;
|
||||
min-inline-size: 30ch;
|
||||
max-inline-size: 80ch;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
padding: 1em;
|
||||
|
||||
background: radial-gradient(var(--bg), var(--bg-alt)) 50%;
|
||||
|
||||
border-radius: 0.5em;
|
||||
border: 1px solid var(--muted);
|
||||
}
|
||||
|
||||
|
||||
:where(input) {
|
||||
font-size: inherit;
|
||||
|
||||
inline-size: fit-content;
|
||||
|
||||
min-inline-size: 25ch;
|
||||
|
||||
padding: 0.5rem;
|
||||
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
|
||||
color: var(--text);
|
||||
|
||||
&::placeholder {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&:required {
|
||||
|
||||
}
|
||||
|
||||
&:not(:placeholder-shown, :focus):user-invalid {
|
||||
background-color: #300a0a;
|
||||
color: #ffc8c8;
|
||||
}
|
||||
}
|
||||
|
||||
label:has(input:required)::before {
|
||||
content: "* ";
|
||||
}
|
||||
|
||||
label:has(input) {
|
||||
background-color: red;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
inline-size: fit-content;
|
||||
}
|
||||
|
||||
select,
|
||||
::picker(select) {
|
||||
appearance: base-select;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 0.25em;
|
||||
inline-size: fit-content;
|
||||
|
||||
&::marker {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
select::picker-icon {
|
||||
color: #999;
|
||||
transition: 0.4s rotate;
|
||||
}
|
||||
|
||||
select:open::picker-icon {
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
|
||||
/* select items */
|
||||
::picker(select) {
|
||||
appearance: base-select;
|
||||
}
|
||||
|
||||
option:checked {
|
||||
font-weight: bold;
|
||||
}
|
||||
15
resources/css/layout/container.css
Normal file
15
resources/css/layout/container.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.page-container {
|
||||
/*display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;*/
|
||||
|
||||
|
||||
padding-inline: 0; /* horizontale Abstände */
|
||||
max-width: 100%;
|
||||
margin-inline: auto; /* zentriert bei größerem Viewport */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-content * {
|
||||
padding-inline: 10rem; /* horizontale Abstände */
|
||||
}
|
||||
3
resources/css/layout/grid.css
Normal file
3
resources/css/layout/grid.css
Normal file
@@ -0,0 +1,3 @@
|
||||
* {
|
||||
|
||||
}
|
||||
101
resources/css/settings/colors.css
Normal file
101
resources/css/settings/colors.css
Normal file
@@ -0,0 +1,101 @@
|
||||
:root {
|
||||
/* Hue-System */
|
||||
--h-primary: 295; /* Ihr Violett-Rosa */
|
||||
--h-bg: 270; /* Ihr Hintergrund-Violett */
|
||||
--h-success: 145; /* Grün */
|
||||
--h-warning: 65; /* Orange */
|
||||
--h-error: 25; /* Rot */
|
||||
--h-info: 240; /* Blau */
|
||||
|
||||
/* Einheitliche Lightness-Abstufungen */
|
||||
--l-subtle: 25%; /* Für dezente Hintergründe */
|
||||
--l-hover: 50%; /* Für Hover-States */
|
||||
--l-border: 70%; /* Für sichtbare Rahmen */
|
||||
--l-text: 85%; /* Für gut lesbaren Text */
|
||||
|
||||
/* Chroma-System basierend auf Anwendungsfall */
|
||||
--c-vivid: 0.18; /* Für Hauptfarben, Buttons */
|
||||
--c-moderate: 0.12; /* Für UI-Elemente */
|
||||
--c-subtle: 0.06; /* Für Hintergründe */
|
||||
--c-muted: 0.03; /* Für sehr dezente Effekte */
|
||||
|
||||
/**
|
||||
* 🖤 Hintergrundfarben – kein HDR nötig
|
||||
* L: Lightness, C: Chroma, H: Hue
|
||||
*/
|
||||
--bg: oklch(18% 0.01 270); /* sehr dunkles Violettblau */
|
||||
--bg-alt: oklch(26% 0.015 270); /* leicht aufgehellt */
|
||||
|
||||
/**
|
||||
* 🤍 Textfarben – hoher Kontrast
|
||||
*/
|
||||
--text: oklch(95% 0.005 270); /* fast weiß */
|
||||
--muted: oklch(70% 0.01 270); /* leichtes Grau mit Farbstich */
|
||||
|
||||
/**
|
||||
* 🎨 Akzentfarbe – sichtbar, auch auf HDR
|
||||
* oklch ist bevorzugt, da linear; P3 gibt extra Boost bei HDR-Displays
|
||||
*/
|
||||
--accent: oklch(70% 0.2 295); /* sattes Violett-Rosa */
|
||||
--accent-p3: color(display-p3 1 0.3 0.8); /* pink/lila – intensiver auf HDR */
|
||||
|
||||
/** Border */
|
||||
--border: oklch(40% 0.02 var(--h-bg));
|
||||
|
||||
/**
|
||||
* ✨ Glow-Farbe – über 100% L (= HDR-like)
|
||||
*/
|
||||
--glow: oklch(115% 0.22 295); /* extrem helles pink, über weiß hinaus */
|
||||
|
||||
|
||||
/**
|
||||
* Semantische Farbpalette
|
||||
*/
|
||||
--success-base: oklch(60% var(--c-vivid) var(--h-success));
|
||||
--success: var(--success-base);
|
||||
--success-subtle: oklch(var(--l-subtle) var(--c-muted) var(--h-success));
|
||||
--success-hover: oklch(var(--l-hover) var(--c-vivid) var(--h-success));
|
||||
--success-border: oklch(var(--l-border) var(--c-moderate) var(--h-success));
|
||||
--success-text: oklch(var(--l-text) var(--c-subtle) var(--h-success));
|
||||
|
||||
--error-base: oklch(55% 0.18 25);
|
||||
|
||||
|
||||
|
||||
|
||||
--warning-base: oklch(70% 0.12 65);
|
||||
|
||||
--info-base: oklch(60% 0.15 240);
|
||||
|
||||
|
||||
|
||||
/* Manuelle Varianten für bessere Browser-Unterstützung */
|
||||
|
||||
--error: var(--error-base);
|
||||
--error-subtle: oklch(var(--l-subtle) 0.036 25);
|
||||
--error-hover: oklch(var(--l-hover) 0.18 25);
|
||||
--error-border: oklch(var(--l-border) 0.14 25);
|
||||
--error-text: oklch(var(--l-text) 0.12 25);
|
||||
|
||||
--warning: var(--warning-base);
|
||||
--warning-subtle: oklch(25% 0.024 65);
|
||||
--warning-border: oklch(80% 0.096 65);
|
||||
|
||||
/**
|
||||
* 🎯 Fallback für alte Browser: überschreiben P3 mit oklch automatisch
|
||||
*/
|
||||
color-scheme: dark;
|
||||
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🖥️ HDR-Support – bei Geräten mit Dynamic Range Support (aktuell v. a. Safari)
|
||||
*/
|
||||
/* noinspection CssInvalidMediaFeature */
|
||||
@media (dynamic-range: high) {
|
||||
:root {
|
||||
--accent: var(--accent-p3);
|
||||
}
|
||||
}
|
||||
9
resources/css/settings/variables.css
Normal file
9
resources/css/settings/variables.css
Normal file
@@ -0,0 +1,9 @@
|
||||
* {
|
||||
--color-primary: #412785;
|
||||
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 1rem;
|
||||
}
|
||||
@@ -1,7 +1,266 @@
|
||||
p {
|
||||
color: red;
|
||||
@layer reset, base, layout, components, utilities, overrides;
|
||||
|
||||
@import url('settings/colors.css') layer(settings);
|
||||
@import url('settings/variables.css') layer(settings);
|
||||
@import url('base/reset.css') layer(reset);
|
||||
@import url('base') layer(base);
|
||||
@import url('layout/container.css') layer(layout);
|
||||
@import url('components/header.css') layer(components);
|
||||
@import url('components/footer.css') layer(components);
|
||||
@import url('components/sidebar.css') layer(components);
|
||||
@import url('components/nav.css') layer(components);
|
||||
@import url('components/buttons.css') layer(components);
|
||||
@import url('components/card.css') layer(components);
|
||||
@import url('forms/inputs.css') layer(components);
|
||||
@import url('utilities/helpers.css') layer(utilities);
|
||||
@import url('utilities/animations.css') layer(utilities);
|
||||
@import url('utilities/noise.css') layer(utilities);
|
||||
@import url('utilities/scroll.css') layer(utilities);
|
||||
|
||||
@layer overrides {
|
||||
/* Benutzerdefinierte Styles */
|
||||
}
|
||||
|
||||
:root {
|
||||
overscroll-behavior: none;
|
||||
|
||||
accent-color: #412785;
|
||||
|
||||
/* thumb = brighter brand-color */
|
||||
scrollbar-color: #5d37bc rgba(0 0 0 / 0); /* thumb + track */
|
||||
|
||||
|
||||
color-scheme: dark light;
|
||||
|
||||
--duration-default: 0.2s;
|
||||
--duration-medium: 0.35s;
|
||||
--duration-slow: 0.5s;
|
||||
|
||||
--easing-default: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
--easing-bounce: linear(0 0%, 0 0.27%, 0.02 4,53%);
|
||||
}
|
||||
|
||||
::selection {
|
||||
--_selection-bg-color: var(--selection-bg-color, #ff4081);
|
||||
--_selection-text-color: var(--selection-text-color, #fff);
|
||||
|
||||
|
||||
background-color: var(--_selection-bg-color); /* Pink */
|
||||
color: var(--_selection-text-color);
|
||||
|
||||
text-shadow: 0 0 0.25rem var(--_selection-text-color);
|
||||
|
||||
text-decoration: var(--selection-text-decoration, none);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
/*width: 0.5rem;*/
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media(prefers-reduced-motion: no-preference) {
|
||||
:focus {
|
||||
transition: outline-offset .25s ease;
|
||||
}
|
||||
:focus:not(:active) {
|
||||
outline-offset: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
background: blue;
|
||||
/* evtl per Mediaquery auf thin umschalten */
|
||||
scrollbar-width: auto;
|
||||
/*scrollbar-color: #412785 #412785;*/
|
||||
|
||||
/* @link: https://moderncss.dev/12-modern-css-one-line-upgrades/#scrollbar-gutter */
|
||||
scrollbar-gutter: stable /*both-edges*/;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
overflow-x: clip;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
/* move body back in 3d:
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
scale: 95%;
|
||||
background-color: #2c1;
|
||||
|
||||
* {
|
||||
visibility: hidden;
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
|
||||
picture {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/*section,
|
||||
.page-container > * {
|
||||
padding-inline: clamp(2.5rem, 20%, 10rem);
|
||||
}*/
|
||||
|
||||
section.hero {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
/*height: 100%;
|
||||
width: 100%;*/
|
||||
/*background-color: #412785;*/
|
||||
background-color: var(--bg);
|
||||
color: var(--accent);
|
||||
padding-block-start: 10rem;
|
||||
|
||||
/*background-image: url("https://localhost/assets/images/hero_small.jpg");
|
||||
background-size: cover;
|
||||
background-position: center;*/
|
||||
|
||||
:where(:not(picture, img, h1)) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
flex-direction: column;
|
||||
gap: 2ch;
|
||||
}
|
||||
|
||||
.hero picture, .hero img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Lazy loading? */
|
||||
section {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
h1 {
|
||||
user-select: none;
|
||||
padding-block-end: 3.33rem; /* front-size / 3 */
|
||||
/*text-align: center;*/
|
||||
|
||||
|
||||
text-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
oklch(70% 0.25 280),
|
||||
oklch(80% 0.3 340),
|
||||
oklch(70% 0.25 280)
|
||||
);
|
||||
background-size: 200% auto;
|
||||
|
||||
/* Für Firefox */
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
|
||||
animation: shimmer 10s linear infinite;
|
||||
|
||||
/*animation: shimmer 4s alternate-reverse infinite;*/
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*@keyframes shimmer {
|
||||
0% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}*/
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 0 center; }
|
||||
100% { background-position: -200% center; }
|
||||
}
|
||||
|
||||
::backdrop {
|
||||
background-color: #412785;
|
||||
/*opacity: 0.7;*/
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
filter: blur(5px);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@starting-style {
|
||||
#mypopover {
|
||||
&:popover-open {
|
||||
opacity: 0;
|
||||
translate: 100% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html:has(aside.show) {
|
||||
/*scrollbar-width: none;*/
|
||||
}
|
||||
|
||||
.fade-in-on-scroll {
|
||||
opacity: 0;
|
||||
transform: translateY(100px);
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.fade-in-on-scroll.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
|
||||
/*body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: red;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}*/
|
||||
|
||||
[aria-current="page"] {}
|
||||
|
||||
form>div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
border: 1px solid var(--muted);
|
||||
|
||||
&:has(input:user-invalid:not(:placeholder-shown, :focus)) {
|
||||
[role="alert"] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[role="alert"] {
|
||||
visibility: hidden;
|
||||
min-height: 2rem;
|
||||
font-size: 1rem;
|
||||
|
||||
background-color: red;
|
||||
border: darkred 2px solid;
|
||||
border-radius: 0.5em;
|
||||
padding: 0.25em;
|
||||
color: black;
|
||||
}
|
||||
|
||||
span[id^="hint"] {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
38
resources/css/utilities/animations.css
Normal file
38
resources/css/utilities/animations.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.fade {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
}
|
||||
|
||||
.fade.entered {
|
||||
color: #0af;
|
||||
}
|
||||
|
||||
.fade-in { transition: opacity 0.4s ease-out; }
|
||||
.fade-out { transition: opacity 0.4s ease-in; }
|
||||
.zoom-in { transition: transform 0.5s ease-out; }
|
||||
|
||||
/* Beispielanimation */
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
.shake {
|
||||
animation: shake 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.fade-in-on-scroll,
|
||||
.zoom-in {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
18
resources/css/utilities/helpers.css
Normal file
18
resources/css/utilities/helpers.css
Normal file
@@ -0,0 +1,18 @@
|
||||
/* Set some base styles, so it is easy to see */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
padding: .375rem .75rem;
|
||||
line-height: 1;
|
||||
font-size: 1.25rem;
|
||||
background-color: rebeccapurple;
|
||||
color: white;
|
||||
/* Ensure the Y position is set to zero and any movement on the transform property */
|
||||
transform: translateY(0);
|
||||
transition: transform 250ms ease-in;
|
||||
}
|
||||
|
||||
/* When it is not focused, transform its Y position by its total height, using a negative value, so it hides above the viewport */
|
||||
.skip-link:not(:focus) {
|
||||
transform: translateY(-2rem);
|
||||
}
|
||||
62
resources/css/utilities/noise.css
Normal file
62
resources/css/utilities/noise.css
Normal file
@@ -0,0 +1,62 @@
|
||||
.noise-bg {
|
||||
--noise-opacity: 0.15;
|
||||
|
||||
background-image:
|
||||
repeating-radial-gradient(circle at 1px 1px,
|
||||
rgba(255, 255, 255, var(--noise-opacity, 0.03)) 0,
|
||||
rgba(0, 0, 0, var(--noise-opacity, 0.03)) 1px,
|
||||
transparent 2px
|
||||
);
|
||||
background-size: 3px 3px;
|
||||
}
|
||||
|
||||
.grain {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
|
||||
background-color: #000;
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 0.5;
|
||||
|
||||
animation: noise 1s steps(10) infinite;
|
||||
}
|
||||
|
||||
@keyframes noise {
|
||||
0% { filter: hue-rotate(0deg); }
|
||||
100% { filter: hue-rotate(360deg); }
|
||||
}
|
||||
|
||||
.noise-overlay {
|
||||
--noise-intensity: 0.03;
|
||||
|
||||
view-transition-name: noise;
|
||||
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
|
||||
mix-blend-mode: overlay;
|
||||
|
||||
background-size: cover;
|
||||
background: url("data:image/svg+xml;utf8,\
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%'>\
|
||||
<filter id='noiseFilter'>\
|
||||
<feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/>\
|
||||
</filter>\
|
||||
<rect width='100%' height='100%' filter='url(%23noiseFilter)'/>\
|
||||
</svg>") repeat, rgba(0, 0, 0, var(--noise-intensity, 0.03));
|
||||
}
|
||||
|
||||
.noise-overlay {
|
||||
opacity: var(--noise-opacity, 0.05);
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.noise-overlay.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
200
resources/css/utilities/scroll.css
Normal file
200
resources/css/utilities/scroll.css
Normal file
@@ -0,0 +1,200 @@
|
||||
body {
|
||||
transition: background-color 0.5s ease, color 0.5s ease;
|
||||
}
|
||||
|
||||
/* FOR REFERENCE::
|
||||
|
||||
body[data-active-scroll-step="1"] header {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body[data-active-scroll-step="2"] header {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
body[data-active-scroll-step="3"] header {
|
||||
transform: translateY(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}*/
|
||||
|
||||
body[data-active-scroll-step="1"] {
|
||||
background-color: #1a1a1a;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
body[data-active-scroll-step="2"] {
|
||||
background-color: #000;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
body[data-active-scroll-step="3"] {
|
||||
background-color: #2c1c59;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body[data-active-scroll-step="1"] {
|
||||
/* z.B. Schriftart, Hintergrund etc. */
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
body[data-active-scroll-step="2"] {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
[data-scroll-step] {
|
||||
opacity: 0;
|
||||
transform: translateY(2rem);
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Aktivierte Scroll-Section */
|
||||
[data-scroll-step].active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
|
||||
[data-scroll-step].active {
|
||||
border-left: 4px solid var(--accent-color, #6c4dff);
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Basis-Stil für Sticky Steps */
|
||||
[data-sticky-step] {
|
||||
position: sticky;
|
||||
top: 20vh;
|
||||
margin: 2rem 0;
|
||||
padding: 2rem;
|
||||
background: #1e1e1e;
|
||||
border-radius: 0.5rem;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
[data-sticky-step].is-sticky-active {
|
||||
opacity: 1;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 1rem rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Beispielhafte visuelle Steuerung per container-Datensatz */
|
||||
[data-sticky-container][data-active-sticky-step="0"] [data-sticky-step]:nth-child(1),
|
||||
[data-sticky-container][data-active-sticky-step="1"] [data-sticky-step]:nth-child(2),
|
||||
[data-sticky-container][data-active-sticky-step="2"] [data-sticky-step]:nth-child(3) {
|
||||
border-left: 4px solid var(--accent-color, #6c4dff);
|
||||
background: linear-gradient(to right, rgba(108, 77, 255, 0.1), transparent);
|
||||
}
|
||||
|
||||
/* Optional: sanfte Farbübergänge */
|
||||
[data-sticky-step] {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease, background 0.4s ease;
|
||||
}
|
||||
|
||||
/* Sticky Container zur Sicherheit sichtbar machen */
|
||||
[data-sticky-container] {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
|
||||
height: 300vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
Scroll LOOPS
|
||||
*/
|
||||
|
||||
.loop-container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.loop-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Basisstil für scroll-loop-Elemente */
|
||||
[data-scroll-loop] {
|
||||
will-change: transform;
|
||||
display: inline-block;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Empfohlene Containerstruktur */
|
||||
.scroll-loop-container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 200px; /* anpassen je nach Bedarf */
|
||||
}
|
||||
|
||||
/* Optional für automatische Verdopplung */
|
||||
.scroll-loop-container > [data-scroll-loop] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Für Background-Loop */
|
||||
[data-scroll-type="background"] {
|
||||
background-repeat: repeat;
|
||||
background-size: auto 100%;
|
||||
}
|
||||
|
||||
/* Pausieren bei Hover oder Aktivierung */
|
||||
[data-scroll-loop][data-loop-pause="true"]:hover,
|
||||
[data-scroll-loop][data-loop-pause="true"]:active {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
SCROLL_FADE
|
||||
*/
|
||||
|
||||
/* Optionaler Grundstil für sticky-fade Elemente */
|
||||
/* Basisstil für sticky-fade Elemente */
|
||||
[data-sticky-fade] {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition-property: opacity, transform;
|
||||
transition-duration: 0.4s;
|
||||
transition-timing-function: ease-out;
|
||||
will-change: opacity, transform;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
[data-sticky-fade][data-fade-distance="10"] {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
[data-sticky-fade][data-fade-distance="30"] {
|
||||
transform: translateY(30px);
|
||||
}
|
||||
[data-sticky-fade][data-fade-distance="40"] {
|
||||
transform: translateY(40px);
|
||||
}
|
||||
|
||||
[data-sticky-fade][data-fade-duration="fast"] {
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
[data-sticky-fade][data-fade-duration="slow"] {
|
||||
transition-duration: 0.8s;
|
||||
}
|
||||
|
||||
[data-sticky-fade].visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.1);
|
||||
filter: brightness(1.05);
|
||||
transition-property: opacity, transform, box-shadow;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
45
resources/css/utilities/transitions.css
Normal file
45
resources/css/utilities/transitions.css
Normal file
@@ -0,0 +1,45 @@
|
||||
@view-transition {
|
||||
navigation: auto;
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from { transform: translateY(-2rem); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(2rem); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes blur-in {
|
||||
from { filter: blur(10px); opacity: 0; }
|
||||
to { filter: blur(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 🎯 Transitions für benannte Bereiche */
|
||||
|
||||
::view-transition-old(main-content),
|
||||
::view-transition-new(main-content) {
|
||||
animation: fade 0.4s ease;
|
||||
}
|
||||
|
||||
::view-transition-old(site-header),
|
||||
::view-transition-new(site-header) {
|
||||
animation: slide-down 0.4s ease;
|
||||
}
|
||||
|
||||
::view-transition-old(site-footer),
|
||||
::view-transition-new(site-footer) {
|
||||
animation: slide-up 0.4s ease;
|
||||
}
|
||||
|
||||
::view-transition-old(sidebar),
|
||||
::view-transition-new(sidebar) {
|
||||
animation: blur-in 0.3s ease;
|
||||
}
|
||||
136
resources/js/core/ClickManager.js
Normal file
136
resources/js/core/ClickManager.js
Normal 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');
|
||||
}
|
||||
49
resources/js/core/EventManager.js
Normal file
49
resources/js/core/EventManager.js
Normal 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
|
||||
})));
|
||||
}
|
||||
};
|
||||
73
resources/js/core/PerformanceMonitor.js
Normal file
73
resources/js/core/PerformanceMonitor.js
Normal 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);
|
||||
}
|
||||
}
|
||||
46
resources/js/core/events.js
Normal file
46
resources/js/core/events.js
Normal 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 }));
|
||||
}
|
||||
83
resources/js/core/frameloop.js
Normal file
83
resources/js/core/frameloop.js
Normal 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
|
||||
}
|
||||
2
resources/js/core/index.js
Normal file
2
resources/js/core/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './logger.js';
|
||||
export * from './useEvent';
|
||||
125
resources/js/core/init.js
Normal file
125
resources/js/core/init.js
Normal 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');*/
|
||||
}
|
||||
277
resources/js/core/logger-next.js
Normal file
277
resources/js/core/logger-next.js
Normal 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();
|
||||
37
resources/js/core/logger.js
Normal file
37
resources/js/core/logger.js
Normal 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)
|
||||
|
||||
}
|
||||
}
|
||||
51
resources/js/core/navigateTo.js
Normal file
51
resources/js/core/navigateTo.js
Normal 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();
|
||||
}
|
||||
}
|
||||
14
resources/js/core/removeModules.js
Normal file
14
resources/js/core/removeModules.js
Normal 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
138
resources/js/core/router.js
Normal 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 };
|
||||
99
resources/js/core/secureLogger.js
Normal file
99
resources/js/core/secureLogger.js
Normal 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
213
resources/js/core/state.js
Normal 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);
|
||||
});
|
||||
}
|
||||
23
resources/js/core/useEvent.js
Normal file
23
resources/js/core/useEvent.js
Normal 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);
|
||||
}
|
||||
}
|
||||
115
resources/js/docs/SCROLLER.md
Normal file
115
resources/js/docs/SCROLLER.md
Normal 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' }
|
||||
};
|
||||
```
|
||||
9
resources/js/docs/SCROLL_LOOP.md
Normal file
9
resources/js/docs/SCROLL_LOOP.md
Normal 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 |
|
||||
29
resources/js/docs/prefetch.md
Normal file
29
resources/js/docs/prefetch.md
Normal 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);
|
||||
}
|
||||
```
|
||||
34
resources/js/docs/router.md
Normal file
34
resources/js/docs/router.md
Normal 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
197
resources/js/docs/state.md
Normal 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 |
|
||||
@@ -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!');
|
||||
}
|
||||
*/
|
||||
|
||||
25
resources/js/modules/config.js
Normal file
25
resources/js/modules/config.js
Normal 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,
|
||||
}
|
||||
};
|
||||
37
resources/js/modules/example-module/index.js
Normal file
37
resources/js/modules/example-module/index.js
Normal 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
|
||||
}
|
||||
47
resources/js/modules/index.js
Normal file
47
resources/js/modules/index.js
Normal 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();
|
||||
}
|
||||
63
resources/js/modules/inertia-scroll/index.js
Normal file
63
resources/js/modules/inertia-scroll/index.js
Normal 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;
|
||||
}
|
||||
26
resources/js/modules/lightbox-trigger/index.js
Normal file
26
resources/js/modules/lightbox-trigger/index.js
Normal 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
|
||||
}
|
||||
42
resources/js/modules/noise/index.js
Normal file
42
resources/js/modules/noise/index.js
Normal 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();
|
||||
}
|
||||
})
|
||||
}
|
||||
27
resources/js/modules/parallax/index.js
Normal file
27
resources/js/modules/parallax/index.js
Normal 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 });
|
||||
}
|
||||
66
resources/js/modules/scroll-loop/index.js
Normal file
66
resources/js/modules/scroll-loop/index.js
Normal 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 });
|
||||
}
|
||||
53
resources/js/modules/scroll-timeline/index.js
Normal file
53
resources/js/modules/scroll-timeline/index.js
Normal 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');
|
||||
}
|
||||
33
resources/js/modules/scroll-timeline/steps.js
Normal file
33
resources/js/modules/scroll-timeline/steps.js
Normal 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'); }
|
||||
36
resources/js/modules/scrollfx/ScrollEngine.js
Normal file
36
resources/js/modules/scrollfx/ScrollEngine.js
Normal 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();
|
||||
69
resources/js/modules/scrollfx/ScrollTrigger.js
Normal file
69
resources/js/modules/scrollfx/ScrollTrigger.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
177
resources/js/modules/scrollfx/Tween.js
Normal file
177
resources/js/modules/scrollfx/Tween.js
Normal 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)
|
||||
});
|
||||
});
|
||||
}
|
||||
57
resources/js/modules/scrollfx/index.js
Normal file
57
resources/js/modules/scrollfx/index.js
Normal 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);
|
||||
}
|
||||
62
resources/js/modules/sidebar/index.js
Normal file
62
resources/js/modules/sidebar/index.js
Normal 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');
|
||||
}
|
||||
118
resources/js/modules/smooth-scroll/index.js
Normal file
118
resources/js/modules/smooth-scroll/index.js
Normal 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;
|
||||
}
|
||||
*/
|
||||
67
resources/js/modules/sticky-fade/index.js
Normal file
67
resources/js/modules/sticky-fade/index.js
Normal 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();
|
||||
}
|
||||
45
resources/js/modules/sticky-steps/index.js
Normal file
45
resources/js/modules/sticky-steps/index.js
Normal 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');
|
||||
}
|
||||
23
resources/js/modules/ui/UIManager.js
Normal file
23
resources/js/modules/ui/UIManager.js
Normal 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();
|
||||
}
|
||||
};
|
||||
41
resources/js/modules/ui/components/Dialog.js
Normal file
41
resources/js/modules/ui/components/Dialog.js
Normal 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?.();
|
||||
}
|
||||
}
|
||||
6
resources/js/modules/ui/components/Lightbox.js
Normal file
6
resources/js/modules/ui/components/Lightbox.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Dialog } from './Dialog.js';
|
||||
export class Lightbox extends Dialog {
|
||||
constructor(props) {
|
||||
super({ ...props, className: 'lightbox' });
|
||||
}
|
||||
}
|
||||
6
resources/js/modules/ui/components/Modal.js
Normal file
6
resources/js/modules/ui/components/Modal.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Dialog } from './Dialog.js';
|
||||
export class Modal extends Dialog {
|
||||
constructor(props) {
|
||||
super({ ...props, className: 'modal' });
|
||||
}
|
||||
}
|
||||
9
resources/js/modules/ui/index.js
Normal file
9
resources/js/modules/ui/index.js
Normal 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')
|
||||
});*/
|
||||
}
|
||||
40
resources/js/modules/wheel-boost/index.js
Normal file
40
resources/js/modules/wheel-boost/index.js
Normal 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;
|
||||
}
|
||||
*/
|
||||
23
resources/js/serviceWorker.js
Normal file
23
resources/js/serviceWorker.js
Normal 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));
|
||||
}
|
||||
}
|
||||
50
resources/js/utils/autoLoadResponsiveVideo.js
Normal file
50
resources/js/utils/autoLoadResponsiveVideo.js
Normal 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>
|
||||
|
||||
*/
|
||||
34
resources/js/utils/cache.js
Normal file
34
resources/js/utils/cache.js
Normal 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();
|
||||
}
|
||||
}
|
||||
1
resources/js/utils/index.js
Normal file
1
resources/js/utils/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './cache'
|
||||
Reference in New Issue
Block a user