perf: lighthouse optimizations - inline critical CSS, lazy-load routes, WebP images, fix CLS and contrast

This commit is contained in:
2026-02-08 11:22:36 +01:00
parent 013d591e75
commit c66c80adcc
23 changed files with 448 additions and 101 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
__pycache__/

42
.htaccess Normal file
View File

@@ -0,0 +1,42 @@
<IfModule mod_expires.c>
ExpiresActive On
# Fonts (1 year - they never change)
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType application/font-woff2 "access plus 1 year"
# Images (1 year)
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/x-icon "access plus 1 year"
# CSS & JS (1 week - updated via deploy)
ExpiresByType text/css "access plus 1 week"
ExpiresByType application/javascript "access plus 1 week"
ExpiresByType text/javascript "access plus 1 week"
# JSON (1 day - locales may change)
ExpiresByType application/json "access plus 1 day"
# HTML (no cache - always fresh)
ExpiresByType text/html "access plus 0 seconds"
# Manifest & Service Worker (no cache)
<FilesMatch "(manifest\.json|service-worker\.js)$">
ExpiresDefault "access plus 0 seconds"
</FilesMatch>
</IfModule>
<IfModule mod_headers.c>
# Service Worker must not be cached
<FilesMatch "service-worker\.js$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
</FilesMatch>
# Fonts: immutable
<FilesMatch "\.(woff2|woff)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>

View File

@@ -31,6 +31,13 @@ python3 -m http.server 8080
# Oder mit Live-Reload # Oder mit Live-Reload
npx live-server npx live-server
# Build (JS/CSS minifizieren → dist/)
pip3 install rjsmin rcssmin # einmalig
python3 build.py
# Deploy (baut automatisch, dann rsync von dist/)
./deploy.sh
# Tests ausführen (im Browser) # Tests ausführen (im Browser)
# Server starten, dann http://localhost:8080/tests/ öffnen # Server starten, dann http://localhost:8080/tests/ öffnen

View File

@@ -116,6 +116,18 @@ Für Produktion werden nur diese Dateien benötigt:
**Nicht deployen:** `tests/`, `docs/`, `AGENTS.md`, `README.md`, `.git/`, `deploy.sh` **Nicht deployen:** `tests/`, `docs/`, `AGENTS.md`, `README.md`, `.git/`, `deploy.sh`
#### Build (Minifizierung)
```bash
# Einmalig: Dependencies installieren
pip3 install rjsmin rcssmin
# Build ausführen (erstellt dist/ mit minifizierten Dateien)
python3 build.py
```
Das Build-Script minifiziert alle JS- und CSS-Dateien (~111 KiB Ersparnis) und kopiert alles nach `dist/`.
#### Deploy via Script #### Deploy via Script
```bash ```bash
@@ -126,10 +138,10 @@ Für Produktion werden nur diese Dateien benötigt:
./deploy.sh ./deploy.sh
``` ```
Das Script nutzt `rsync` über SSH und synchronisiert nur geänderte Dateien. Das Script führt automatisch `python3 build.py` aus, dann `rsync` von `dist/` zum Server.
Ausgeschlossene Dateien/Ordner (tests, docs, .git, etc.) werden automatisch übersprungen.
**Voraussetzungen:** **Voraussetzungen:**
- Python 3 + `rjsmin` + `rcssmin` (für Build)
- SSH-Key-Authentifizierung zum Server - SSH-Key-Authentifizierung zum Server
- `rsync` lokal und auf dem Server installiert - `rsync` lokal und auf dem Server installiert

82
build.py Normal file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Build script for dgray.io
Minifies JS and CSS files into dist/ directory.
Usage: python3 build.py
Requirements: pip3 install rjsmin rcssmin
"""
import os
import shutil
import sys
try:
import rjsmin
import rcssmin
except ImportError:
print("Missing dependencies. Install with:")
print(" pip3 install rjsmin rcssmin")
sys.exit(1)
SRC_DIR = os.path.dirname(os.path.abspath(__file__))
DIST_DIR = os.path.join(SRC_DIR, 'dist')
EXCLUDE_DIRS = {'.git', 'dist', 'node_modules', 'docs', 'tests', '.vscode', '.idea', '__pycache__'}
EXCLUDE_FILES = {'AGENTS.md', 'deploy.sh', 'build.py', 'README.md', '.gitignore'}
def should_exclude(path):
parts = os.path.relpath(path, SRC_DIR).split(os.sep)
return any(p in EXCLUDE_DIRS for p in parts) or os.path.basename(path) in EXCLUDE_FILES
def build():
if os.path.exists(DIST_DIR):
shutil.rmtree(DIST_DIR)
js_saved = 0
css_saved = 0
files_copied = 0
for root, dirs, files in os.walk(SRC_DIR):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
for filename in files:
src_path = os.path.join(root, filename)
if should_exclude(src_path):
continue
rel_path = os.path.relpath(src_path, SRC_DIR)
dest_path = os.path.join(DIST_DIR, rel_path)
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
if filename.endswith('.js') and not filename.endswith('.min.js'):
with open(src_path, 'r', encoding='utf-8') as f:
original = f.read()
minified = rjsmin.jsmin(original)
with open(dest_path, 'w', encoding='utf-8') as f:
f.write(minified)
saved = len(original.encode('utf-8')) - len(minified.encode('utf-8'))
js_saved += saved
files_copied += 1
elif filename.endswith('.css') and not filename.endswith('.min.css'):
with open(src_path, 'r', encoding='utf-8') as f:
original = f.read()
minified = rcssmin.cssmin(original)
with open(dest_path, 'w', encoding='utf-8') as f:
f.write(minified)
saved = len(original.encode('utf-8')) - len(minified.encode('utf-8'))
css_saved += saved
files_copied += 1
else:
shutil.copy2(src_path, dest_path)
files_copied += 1
print(f"Build complete: {files_copied} files -> dist/")
print(f" JS savings: {js_saved / 1024:.1f} KiB")
print(f" CSS savings: {css_saved / 1024:.1f} KiB")
print(f" Total saved: {(js_saved + css_saved) / 1024:.1f} KiB")
if __name__ == '__main__':
build()

44
css/animate.custom.css Normal file
View File

@@ -0,0 +1,44 @@
:root {
--animate-duration: 1s;
}
.animate__animated {
animation-duration: var(--animate-duration);
animation-fill-mode: both;
}
.animate__faster {
animation-duration: calc(var(--animate-duration) / 2);
}
@keyframes fadeIn {
0% { opacity: 0; }
to { opacity: 1; }
}
.animate__fadeIn {
animation-name: fadeIn;
}
@keyframes fadeOut {
0% { opacity: 1; }
to { opacity: 0; }
}
.animate__fadeOut {
animation-name: fadeOut;
}
@keyframes heartBeat {
0% { transform: scale(1); }
14% { transform: scale(1.3); }
28% { transform: scale(1); }
42% { transform: scale(1.3); }
70% { transform: scale(1); }
}
.animate__heartBeat {
animation-name: heartBeat;
animation-duration: calc(var(--animate-duration) * 1.3);
animation-timing-function: ease-in-out;
}

View File

@@ -215,7 +215,7 @@ app-header .logo {
app-header .logo-img { app-header .logo-img {
height: 28px; height: 28px;
width: auto; width: 100px;
} }
app-header .logo-dark { app-header .logo-dark {
@@ -299,6 +299,8 @@ app-footer {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding: var(--space-lg) 0; padding: var(--space-lg) 0;
margin-top: auto; margin-top: auto;
min-height: 5.5rem;
contain: layout style;
} }
app-footer .footer-inner { app-footer .footer-inner {

View File

@@ -13,7 +13,7 @@
font-family: 'Inter'; font-family: 'Inter';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: optional;
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2'); src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
} }
@@ -21,7 +21,7 @@
font-family: 'Inter'; font-family: 'Inter';
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 500;
font-display: swap; font-display: optional;
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2'); src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
} }
@@ -29,7 +29,7 @@
font-family: 'Inter'; font-family: 'Inter';
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-display: swap; font-display: optional;
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2'); src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
} }
@@ -37,7 +37,7 @@
font-family: 'Inter'; font-family: 'Inter';
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: optional;
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2'); src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
} }

View File

@@ -26,7 +26,7 @@
--color-text: #1A1A1A; --color-text: #1A1A1A;
--color-text-secondary: #4A4A4A; --color-text-secondary: #4A4A4A;
--color-text-muted: #8A8A8A; --color-text-muted: #737373;
--color-border: #D0D0D0; --color-border: #D0D0D0;
--color-border-focus: #555555; --color-border-focus: #555555;
@@ -118,7 +118,7 @@
--color-text: #F0F0F0; --color-text: #F0F0F0;
--color-text-secondary: #C0C0C0; --color-text-secondary: #C0C0C0;
--color-text-muted: #808080; --color-text-muted: #949494;
--color-border: #3A3A3A; --color-border: #3A3A3A;
--color-border-focus: #AAAAAA; --color-border-focus: #AAAAAA;
@@ -151,7 +151,7 @@
--color-text: #F0F0F0; --color-text: #F0F0F0;
--color-text-secondary: #C0C0C0; --color-text-secondary: #C0C0C0;
--color-text-muted: #808080; --color-text-muted: #949494;
--color-border: #3A3A3A; --color-border: #3A3A3A;
--color-border-focus: #AAAAAA; --color-border-focus: #AAAAAA;
@@ -182,7 +182,7 @@
--color-text: #1A1A1A; --color-text: #1A1A1A;
--color-text-secondary: #4A4A4A; --color-text-secondary: #4A4A4A;
--color-text-muted: #8A8A8A; --color-text-muted: #737373;
--color-border: #D0D0D0; --color-border: #D0D0D0;
--color-border-focus: #555555; --color-border-focus: #555555;

View File

@@ -14,35 +14,21 @@ set -e
REMOTE_USER_HOST="${1:-admin@dgray.io}" REMOTE_USER_HOST="${1:-admin@dgray.io}"
REMOTE_PATH="${2:-/home/admin/web/dgray.io/public_html}" REMOTE_PATH="${2:-/home/admin/web/dgray.io/public_html}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DIST_DIR="$SCRIPT_DIR/dist"
# Files/directories to exclude from deployment # --- Build (minify JS + CSS) ---
EXCLUDES=( echo "Building..."
".git" python3 "$SCRIPT_DIR/build.py"
".gitignore" echo ""
"AGENTS.md"
"deploy.sh"
"docs/"
"tests/"
"README.md"
".vscode"
".idea"
)
# --- Build exclude arguments --- # --- Deploy from dist/ ---
EXCLUDE_ARGS=""
for item in "${EXCLUDES[@]}"; do
EXCLUDE_ARGS="$EXCLUDE_ARGS --exclude=$item"
done
# --- Deploy ---
echo "Deploying dgray.io" echo "Deploying dgray.io"
echo " From: $SCRIPT_DIR" echo " From: $DIST_DIR"
echo " To: $REMOTE_USER_HOST:$REMOTE_PATH" echo " To: $REMOTE_USER_HOST:$REMOTE_PATH"
echo "" echo ""
rsync -avz --delete \ rsync -avz --delete \
$EXCLUDE_ARGS \ "$DIST_DIR/" \
"$SCRIPT_DIR/" \
"$REMOTE_USER_HOST:$REMOTE_PATH/" "$REMOTE_USER_HOST:$REMOTE_PATH/"
echo "" echo ""

View File

@@ -33,19 +33,152 @@
<link rel="icon" type="image/png" sizes="32x32" href="favicon.png"> <link rel="icon" type="image/png" sizes="32x32" href="favicon.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
<!-- Preload Logo Font --> <!-- Preload critical fonts -->
<link rel="preload" href="assets/fonts/Inter-Regular.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="assets/fonts/SpaceGrotesk-Bold.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="assets/fonts/SpaceGrotesk-Bold.woff2" as="font" type="font/woff2" crossorigin>
<!-- Modulepreload critical chain -->
<link rel="modulepreload" href="js/app.js">
<link rel="modulepreload" href="js/i18n.js">
<link rel="modulepreload" href="js/router.js">
<link rel="modulepreload" href="js/services/auth.js">
<link rel="modulepreload" href="js/services/directus.js">
<link rel="modulepreload" href="js/components/app-shell.js">
<link rel="modulepreload" href="js/components/app-header.js">
<link rel="modulepreload" href="js/components/app-footer.js">
<link rel="stylesheet" href="css/fonts.css"> <!-- Critical CSS inlined (fonts + variables + base) -->
<link rel="stylesheet" href="css/variables.css"> <style>
<link rel="stylesheet" href="css/base.css"> /* fonts.css */
<link rel="stylesheet" href="css/components.css"> @font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: optional; src: url('assets/fonts/Inter-Regular.woff2') format('woff2'); }
<link rel="stylesheet" href="css/animate.min.css"> @font-face { font-family: 'Inter'; font-style: normal; font-weight: 500; font-display: optional; src: url('assets/fonts/Inter-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 600; font-display: optional; src: url('assets/fonts/Inter-SemiBold.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 700; font-display: optional; src: url('assets/fonts/Inter-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 500; font-display: swap; src: url('assets/fonts/SpaceGrotesk-Medium.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 600; font-display: swap; src: url('assets/fonts/SpaceGrotesk-Bold.woff2') format('woff2'); }
@font-face { font-family: 'Space Grotesk'; font-style: normal; font-weight: 700; font-display: swap; src: url('assets/fonts/SpaceGrotesk-Bold.woff2') format('woff2'); }
/* variables.css */
:root {
--color-primary: #555555; --color-primary-hover: #404040; --color-primary-light: #E8E8E8;
--color-secondary: #777777; --color-secondary-hover: #5A5A5A;
--color-accent: #047857; --color-accent-hover: #065f46; --color-accent-text: #fff;
--color-success: #666666; --color-warning: #888888; --color-error: #444444;
--color-bg: #FAFAFA; --color-bg-secondary: #F0F0F0; --color-bg-tertiary: #E5E5E5;
--color-text: #1A1A1A; --color-text-secondary: #4A4A4A; --color-text-muted: #737373;
--color-border: #D0D0D0; --color-border-focus: #555555;
--color-shadow: rgba(0, 0, 0, 0.1); --color-overlay: rgba(0, 0, 0, 0.5);
--space-xs: 0.25rem; --space-sm: 0.5rem; --space-md: 1rem; --space-lg: 1.5rem; --space-xl: 2rem; --space-2xl: 3rem; --space-3xl: 4rem;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-family-heading: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-size-xs: 0.75rem; --font-size-sm: 0.875rem; --font-size-base: 1rem; --font-size-lg: 1.125rem; --font-size-xl: 1.25rem; --font-size-2xl: 1.5rem; --font-size-3xl: 2rem;
--font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700;
--line-height-tight: 1.25; --line-height-normal: 1.5; --line-height-relaxed: 1.75;
--radius-sm: 0.25rem; --radius-md: 0.5rem; --radius-lg: 0.75rem; --radius-xl: 1rem; --radius-full: 9999px;
--shadow-sm: 0 1px 2px var(--color-shadow); --shadow-md: 0 4px 6px var(--color-shadow); --shadow-lg: 0 10px 15px var(--color-shadow); --shadow-xl: 0 20px 25px var(--color-shadow);
--transition-fast: 150ms ease; --transition-normal: 250ms ease; --transition-slow: 350ms ease;
--header-height: 4rem; --footer-height: 3rem; --container-max: 1200px; --sidebar-width: 280px;
--z-dropdown: 100; --z-sticky: 200; --z-modal: 300; --z-toast: 400;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-primary: #AAAAAA; --color-primary-hover: #C0C0C0; --color-primary-light: #2A2A2A;
--color-secondary: #909090; --color-secondary-hover: #A5A5A5;
--color-accent: #10B981; --color-accent-hover: #34D399; --color-accent-text: #052e16;
--color-success: #999999; --color-warning: #AAAAAA; --color-error: #888888;
--color-bg: #141414; --color-bg-secondary: #1E1E1E; --color-bg-tertiary: #2A2A2A;
--color-text: #F0F0F0; --color-text-secondary: #C0C0C0; --color-text-muted: #949494;
--color-border: #3A3A3A; --color-border-focus: #AAAAAA;
--color-shadow: rgba(0, 0, 0, 0.4); --color-overlay: rgba(0, 0, 0, 0.7);
}
}
[data-theme="dark"] {
--color-primary: #AAAAAA; --color-primary-hover: #C0C0C0; --color-primary-light: #2A2A2A;
--color-secondary: #909090; --color-secondary-hover: #A5A5A5;
--color-accent: #10B981; --color-accent-hover: #34D399; --color-accent-text: #052e16;
--color-success: #999999; --color-warning: #AAAAAA; --color-error: #888888;
--color-bg: #141414; --color-bg-secondary: #1E1E1E; --color-bg-tertiary: #2A2A2A;
--color-text: #F0F0F0; --color-text-secondary: #C0C0C0; --color-text-muted: #949494;
--color-border: #3A3A3A; --color-border-focus: #AAAAAA;
--color-shadow: rgba(0, 0, 0, 0.4); --color-overlay: rgba(0, 0, 0, 0.7);
}
[data-theme="light"] {
--color-primary: #555555; --color-primary-hover: #404040; --color-primary-light: #E8E8E8;
--color-secondary: #777777; --color-secondary-hover: #5A5A5A;
--color-accent: #059669; --color-accent-hover: #047857;
--color-success: #666666; --color-warning: #888888; --color-error: #444444;
--color-bg: #FAFAFA; --color-bg-secondary: #F0F0F0; --color-bg-tertiary: #E5E5E5;
--color-text: #1A1A1A; --color-text-secondary: #4A4A4A; --color-text-muted: #737373;
--color-border: #D0D0D0; --color-border-focus: #555555;
--color-shadow: rgba(0, 0, 0, 0.1); --color-overlay: rgba(0, 0, 0, 0.5);
}
/* base.css */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; scroll-behavior: smooth; scrollbar-gutter: stable; }
body { font-family: var(--font-family); font-size: var(--font-size-base); line-height: var(--line-height-normal); color: var(--color-text); background-color: var(--color-bg); min-height: 100vh; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
a { color: var(--color-primary); text-decoration: none; transition: color var(--transition-fast); }
a:hover { color: var(--color-primary-hover); }
img, svg { display: block; max-width: 100%; height: auto; }
button, input, textarea, select { font: inherit; color: inherit; }
input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus, input:-webkit-autofill:active, textarea:-webkit-autofill, select:-webkit-autofill { -webkit-box-shadow: 0 0 0 1000px var(--color-bg) inset !important; -webkit-text-fill-color: var(--color-text) !important; box-shadow: 0 0 0 1000px var(--color-bg) inset !important; transition: background-color 5000s ease-in-out 0s; }
button { cursor: pointer; border: none; background: none; }
ul, ol { list-style: none; }
h1, h2, h3, h4, h5, h6 { font-family: var(--font-family-heading); font-weight: var(--font-weight-semibold); line-height: var(--line-height-tight); letter-spacing: -0.02em; }
h1 { font-size: var(--font-size-3xl); } h2 { font-size: var(--font-size-2xl); } h3 { font-size: var(--font-size-xl); } h4 { font-size: var(--font-size-lg); }
p + p { margin-top: var(--space-md); }
:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
::selection { background-color: var(--color-primary); color: white; }
.emoji-icon, .upload-icon, .empty-state-icon, [class*="encrypted"], .listing-location { filter: grayscale(1); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.container { width: 100%; max-width: var(--container-max); margin: 0 auto; padding: 0 var(--space-md); }
@media (min-width: 768px) { .container { padding: 0 var(--space-lg); } }
app-shell { display: flex; flex-direction: column; min-height: 100vh; }
app-shell main { flex: 1; padding: var(--space-lg) 0; }
app-header { display: block; min-height: var(--header-height); }
app-header .logo-img { height: 28px; width: 100px; display: block; }
app-header .logo-dark { display: none; }
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) app-header .logo-light { display: none; } html:not([data-theme="light"]) app-header .logo-dark { display: block; } }
[data-theme="dark"] app-header .logo-light { display: none; }
[data-theme="dark"] app-header .logo-dark { display: block; }
app-footer { display: block; padding: var(--space-lg) 0; margin-top: auto; contain: layout style; }
app-footer .footer-inner { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-md); }
app-footer .footer-links { display: flex; gap: var(--space-md); }
</style>
<!-- Non-critical CSS deferred -->
<link rel="stylesheet" href="css/components.css" media="print" onload="this.media='all'">
<link rel="stylesheet" href="css/animate.custom.css" media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="css/components.css">
<link rel="stylesheet" href="css/animate.custom.css">
</noscript>
<script type="module" src="js/app.js"></script> <script type="module" src="js/app.js"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app">
<app-shell>
<app-header></app-header>
<main class="container" id="router-outlet"></main>
<app-footer>
<div class="footer-inner container">
<p class="footer-copyright">
&copy; 2026 dgray.io - <span data-i18n="footer.rights">Alle Rechte vorbehalten.</span>
<span class="footer-swiss">🇨🇭 Made in Switzerland</span>
</p>
<span class="xmr-rate" title="CoinGecko">1 XMR ≈ ...</span>
<nav class="footer-links">
<a href="#/about" data-i18n="footer.about">Über uns</a>
<a href="#/privacy" data-i18n="footer.privacy">Datenschutz</a>
<a href="#/terms" data-i18n="footer.terms">AGB</a>
<a href="#/contact" data-i18n="footer.contact">Kontakt</a>
</nav>
</div>
</app-footer>
<div id="auth-modal-slot"></div>
</app-shell>
</div>
<noscript> <noscript>
<p>Diese App benötigt JavaScript.</p> <p>Diese App benötigt JavaScript.</p>

View File

@@ -30,7 +30,10 @@ async function initApp() {
await import('./components/app-shell.js') await import('./components/app-shell.js')
document.getElementById('app').innerHTML = '<app-shell></app-shell>' const appEl = document.getElementById('app')
if (!appEl.querySelector('app-shell')) {
appEl.innerHTML = '<app-shell></app-shell>'
}
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
try { try {

View File

@@ -9,7 +9,10 @@ class AppFooter extends HTMLElement {
} }
async connectedCallback() { async connectedCallback() {
this.render() if (!this.querySelector('.footer-inner')) {
this.render()
}
this.updateTextContent()
await this.loadXmrRates() await this.loadXmrRates()
window.addEventListener('currency-changed', this.handleCurrencyChange) window.addEventListener('currency-changed', this.handleCurrencyChange)
} }
@@ -65,8 +68,14 @@ class AppFooter extends HTMLElement {
` `
} }
updateTextContent() {
this.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n)
})
}
updateTranslations() { updateTranslations() {
this.render() this.updateTextContent()
if (this.rates) this.updateRateDisplay() if (this.rates) this.updateRateDisplay()
} }
} }

View File

@@ -3,20 +3,6 @@ import { i18n } from '../i18n.js'
import { auth } from '../services/auth.js' import { auth } from '../services/auth.js'
import './app-header.js' import './app-header.js'
import './app-footer.js' import './app-footer.js'
import './auth-modal.js'
import './pages/page-home.js'
import './pages/page-listing.js'
import './pages/page-create.js'
import './pages/page-favorites.js'
import './pages/page-my-listings.js'
import './pages/page-messages.js'
import './pages/page-settings.js'
import './pages/page-notifications.js'
import './pages/page-not-found.js'
import './pages/page-privacy.js'
import './pages/page-terms.js'
import './pages/page-about.js'
import './pages/page-contact.js'
class AppShell extends HTMLElement { class AppShell extends HTMLElement {
constructor() { constructor() {
@@ -25,8 +11,17 @@ class AppShell extends HTMLElement {
} }
connectedCallback() { connectedCallback() {
this.render() if (!this.querySelector('#router-outlet')) {
this.innerHTML = /* html */`
<app-header></app-header>
<main class="container" id="router-outlet"></main>
<app-footer></app-footer>
<div id="auth-modal-slot"></div>
`
}
this.main = this.querySelector('#router-outlet')
this.setupRouter() this.setupRouter()
this.loadAuthModal()
i18n.subscribe(() => { i18n.subscribe(() => {
this.querySelector('app-header').updateTranslations() this.querySelector('app-header').updateTranslations()
@@ -34,35 +29,32 @@ class AppShell extends HTMLElement {
}) })
} }
render() { async loadAuthModal() {
this.innerHTML = /* html */` await import('./auth-modal.js')
<app-header></app-header> this.querySelector('#auth-modal-slot').innerHTML = '<auth-modal hidden></auth-modal>'
<main class="container" id="router-outlet"></main>
<app-footer></app-footer>
<auth-modal hidden></auth-modal>
`
this.main = this.querySelector('#router-outlet')
} }
setupRouter() { setupRouter() {
router.setOutlet(this.main) router.setOutlet(this.main)
const lazy = (path) => () => import(path)
router.setNotFoundLoader(lazy('./pages/page-not-found.js'))
router router
.register('/', 'page-home') .register('/', 'page-home', lazy('./pages/page-home.js'))
.register('/search', 'page-home') // Redirect search to home .register('/search', 'page-home', lazy('./pages/page-home.js'))
.register('/listing/:id', 'page-listing') .register('/listing/:id', 'page-listing', lazy('./pages/page-listing.js'))
.register('/create', 'page-create') .register('/create', 'page-create', lazy('./pages/page-create.js'))
.register('/edit/:id', 'page-create') .register('/edit/:id', 'page-create', lazy('./pages/page-create.js'))
.register('/favorites', 'page-favorites') .register('/favorites', 'page-favorites', lazy('./pages/page-favorites.js'))
.register('/my-listings', 'page-my-listings') .register('/my-listings', 'page-my-listings', lazy('./pages/page-my-listings.js'))
.register('/messages', 'page-messages') .register('/messages', 'page-messages', lazy('./pages/page-messages.js'))
.register('/settings', 'page-settings') .register('/settings', 'page-settings', lazy('./pages/page-settings.js'))
.register('/notifications', 'page-notifications') .register('/notifications', 'page-notifications', lazy('./pages/page-notifications.js'))
.register('/privacy', 'page-privacy') .register('/privacy', 'page-privacy', lazy('./pages/page-privacy.js'))
.register('/terms', 'page-terms') .register('/terms', 'page-terms', lazy('./pages/page-terms.js'))
.register('/about', 'page-about') .register('/about', 'page-about', lazy('./pages/page-about.js'))
.register('/contact', 'page-contact') .register('/contact', 'page-contact', lazy('./pages/page-contact.js'))
router.handleRouteChange() router.handleRouteChange()
} }

View File

@@ -8,7 +8,7 @@ let cachedRates = null
class ListingCard extends HTMLElement { class ListingCard extends HTMLElement {
static get observedAttributes() { static get observedAttributes() {
return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status'] return ['listing-id', 'title', 'price', 'currency', 'location', 'image', 'owner-id', 'payment-status', 'status', 'priority']
} }
constructor() { constructor() {
@@ -141,7 +141,7 @@ class ListingCard extends HTMLElement {
<${linkTag} ${linkAttr} class="listing-link"> <${linkTag} ${linkAttr} class="listing-link">
<div class="listing-image"> <div class="listing-image">
${image ${image
? `<img src="${escapeHTML(image)}" alt="${escapeHTML(title)}" loading="lazy">` ? `<img src="${escapeHTML(image)}" alt="${escapeHTML(title)}"${this.hasAttribute('priority') ? ' fetchpriority="high"' : ' loading="lazy"'}>`
: placeholderSvg} : placeholderSvg}
${paymentBadge} ${paymentBadge}
</div> </div>

View File

@@ -105,7 +105,7 @@ class PageFavorites extends HTMLElement {
return this.listings.map(listing => { return this.listings.map(listing => {
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : '' const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
const locationName = listing.location?.name || '' const locationName = listing.location?.name || ''
return /* html */` return /* html */`

View File

@@ -243,6 +243,7 @@ class PageHome extends HTMLElement {
if (this.page === 1) { if (this.page === 1) {
this.listings = newItems this.listings = newItems
this.preloadLcpImage(newItems)
} else { } else {
this.listings = [...this.listings, ...newItems] this.listings = [...this.listings, ...newItems]
} }
@@ -261,6 +262,21 @@ class PageHome extends HTMLElement {
} }
} }
preloadLcpImage(listings) {
const first = listings[0]
if (!first) return
const imageId = first.images?.[0]?.directus_files_id?.id || first.images?.[0]?.directus_files_id
if (!imageId) return
const url = directus.getThumbnailUrl(imageId, 180)
if (document.querySelector(`link[rel="preload"][href="${url}"]`)) return
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'image'
link.href = url
link.fetchPriority = 'high'
document.head.appendChild(link)
}
sortByDistance(listings) { sortByDistance(listings) {
if (!this.hasUserLocation()) return listings if (!this.hasUserLocation()) return listings
@@ -419,6 +435,7 @@ class PageHome extends HTMLElement {
${showFilters ? `<h2 class="listings-title">${this.getListingsTitle()}</h2>` : ''} ${showFilters ? `<h2 class="listings-title">${this.getListingsTitle()}</h2>` : ''}
<div class="listings-toolbar"> <div class="listings-toolbar">
<div class="sort-inline"> <div class="sort-inline">
<label for="sort-select" class="sr-only">${t('search.sortBy')}</label>
<select id="sort-select"> <select id="sort-select">
${this.hasUserLocation() ? `<option value="distance" ${this.sort === 'distance' ? 'selected' : ''}>${t('search.sortDistance')}</option>` : ''} ${this.hasUserLocation() ? `<option value="distance" ${this.sort === 'distance' ? 'selected' : ''}>${t('search.sortDistance')}</option>` : ''}
<option value="newest" ${this.sort === 'newest' ? 'selected' : ''}>${t('search.sortNewest')}</option> <option value="newest" ${this.sort === 'newest' ? 'selected' : ''}>${t('search.sortNewest')}</option>
@@ -503,9 +520,9 @@ class PageHome extends HTMLElement {
` `
} }
const listingsHtml = this.listings.map(listing => { const listingsHtml = this.listings.map((listing, index) => {
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : '' const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
const locationName = listing.location?.name || '' const locationName = listing.location?.name || ''
return /* html */` return /* html */`
@@ -517,6 +534,7 @@ class PageHome extends HTMLElement {
location="${escapeHTML(locationName)}" location="${escapeHTML(locationName)}"
image="${imageUrl}" image="${imageUrl}"
owner-id="${listing.user_created || ''}" owner-id="${listing.user_created || ''}"
${index < 4 ? 'priority' : ''}
></listing-card> ></listing-card>
` `
}).join('') }).join('')

View File

@@ -416,7 +416,7 @@ class PageListing extends HTMLElement {
renderListingCard(listing) { renderListingCard(listing) {
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : '' const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
const locationName = listing.location?.name || '' const locationName = listing.location?.name || ''
return /* html */` return /* html */`

View File

@@ -237,7 +237,7 @@ class PageMyListings extends HTMLElement {
const html = this.listings.map(listing => { const html = this.listings.map(listing => {
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : '' const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
const locationName = listing.location?.name || '' const locationName = listing.location?.name || ''
const statusBadge = this.getStatusBadge(listing) const statusBadge = this.getStatusBadge(listing)

View File

@@ -336,6 +336,7 @@ class SearchBox extends HTMLElement {
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path> <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle> <circle cx="12" cy="10" r="3"></circle>
</svg> </svg>
<label for="country-select-mobile" class="sr-only">${t('search.currentLocation')}</label>
<select id="country-select-mobile"> <select id="country-select-mobile">
<option value="current" ${this.useCurrentLocation ? 'selected' : ''}> <option value="current" ${this.useCurrentLocation ? 'selected' : ''}>
📍 ${t('search.currentLocation')} 📍 ${t('search.currentLocation')}

View File

@@ -42,10 +42,11 @@ class Router {
* Register a route * Register a route
* @param {string} path - Route path (e.g., '/listing/:id') * @param {string} path - Route path (e.g., '/listing/:id')
* @param {string} componentTag - Web component tag name * @param {string} componentTag - Web component tag name
* @param {Function} [loader] - Optional dynamic import function for lazy loading
* @returns {Router} this for chaining * @returns {Router} this for chaining
*/ */
register(path, componentTag) { register(path, componentTag, loader) {
this.routes.set(path, componentTag) this.routes.set(path, { componentTag, loader })
return this return this
} }
@@ -70,10 +71,11 @@ class Router {
*/ */
matchRoute(path) { matchRoute(path) {
if (this.routes.has(path)) { if (this.routes.has(path)) {
return { componentTag: this.routes.get(path), params: {} } const { componentTag, loader } = this.routes.get(path)
return { componentTag, loader, params: {} }
} }
for (const [routePath, componentTag] of this.routes) { for (const [routePath, route] of this.routes) {
const routeParts = routePath.split('/') const routeParts = routePath.split('/')
const pathParts = path.split('/') const pathParts = path.split('/')
@@ -92,7 +94,7 @@ class Router {
} }
if (match) { if (match) {
return { componentTag, params } return { componentTag: route.componentTag, loader: route.loader, params }
} }
} }
@@ -117,7 +119,11 @@ class Router {
return return
} }
const { componentTag, params: routeParams } = match const { componentTag, loader, params: routeParams } = match
if (loader && !customElements.get(componentTag)) {
await loader()
}
this.currentRoute = { this.currentRoute = {
path, path,
@@ -171,14 +177,25 @@ class Router {
} }
/** @private */ /** @private */
renderNotFound() { async renderNotFound() {
if (!this.outlet) return if (!this.outlet) return
if (!customElements.get('page-not-found') && this._notFoundLoader) {
await this._notFoundLoader()
}
this.outlet.innerHTML = '' this.outlet.innerHTML = ''
const notFound = document.createElement('page-not-found') const notFound = document.createElement('page-not-found')
this.outlet.appendChild(notFound) this.outlet.appendChild(notFound)
} }
/**
* Set the loader for the 404 page
* @param {Function} loader - Dynamic import function
*/
setNotFoundLoader(loader) {
this._notFoundLoader = loader
}
/** /**
* Navigate to a path * Navigate to a path
* @param {string} path - Target path * @param {string} path - Target path

View File

@@ -816,7 +816,7 @@ class DirectusService {
} }
getThumbnailUrl(fileId, size = 300) { getThumbnailUrl(fileId, size = 300) {
return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover' }) return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover', format: 'webp', quality: 80 })
} }
} }

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'dgray-v48'; const CACHE_NAME = 'dgray-v49';
const STATIC_ASSETS = [ const STATIC_ASSETS = [
'/', '/',
'/index.html', '/index.html',
@@ -6,11 +6,8 @@ const STATIC_ASSETS = [
'/manifest.json', '/manifest.json',
// CSS // CSS
'/css/fonts.css',
'/css/variables.css',
'/css/base.css',
'/css/components.css', '/css/components.css',
'/css/animate.min.css', '/css/animate.custom.css',
'/css/vendor/cropper.min.css', '/css/vendor/cropper.min.css',
// Core JS // Core JS