perf: lighthouse optimizations - inline critical CSS, lazy-load routes, WebP images, fix CLS and contrast
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
__pycache__/
|
||||
42
.htaccess
Normal file
42
.htaccess
Normal 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>
|
||||
@@ -31,6 +31,13 @@ python3 -m http.server 8080
|
||||
# Oder mit Live-Reload
|
||||
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)
|
||||
# Server starten, dann http://localhost:8080/tests/ öffnen
|
||||
|
||||
|
||||
16
README.md
16
README.md
@@ -116,6 +116,18 @@ Für Produktion werden nur diese Dateien benötigt:
|
||||
|
||||
**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
|
||||
|
||||
```bash
|
||||
@@ -126,10 +138,10 @@ Für Produktion werden nur diese Dateien benötigt:
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
Das Script nutzt `rsync` über SSH und synchronisiert nur geänderte Dateien.
|
||||
Ausgeschlossene Dateien/Ordner (tests, docs, .git, etc.) werden automatisch übersprungen.
|
||||
Das Script führt automatisch `python3 build.py` aus, dann `rsync` von `dist/` zum Server.
|
||||
|
||||
**Voraussetzungen:**
|
||||
- Python 3 + `rjsmin` + `rcssmin` (für Build)
|
||||
- SSH-Key-Authentifizierung zum Server
|
||||
- `rsync` lokal und auf dem Server installiert
|
||||
|
||||
|
||||
82
build.py
Normal file
82
build.py
Normal 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
44
css/animate.custom.css
Normal 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;
|
||||
}
|
||||
@@ -215,7 +215,7 @@ app-header .logo {
|
||||
|
||||
app-header .logo-img {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
app-header .logo-dark {
|
||||
@@ -299,6 +299,8 @@ app-footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: var(--space-lg) 0;
|
||||
margin-top: auto;
|
||||
min-height: 5.5rem;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
app-footer .footer-inner {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
font-display: optional;
|
||||
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
font-display: optional;
|
||||
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
font-display: optional;
|
||||
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
font-display: optional;
|
||||
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
--color-text: #1A1A1A;
|
||||
--color-text-secondary: #4A4A4A;
|
||||
--color-text-muted: #8A8A8A;
|
||||
--color-text-muted: #737373;
|
||||
|
||||
--color-border: #D0D0D0;
|
||||
--color-border-focus: #555555;
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
--color-text: #F0F0F0;
|
||||
--color-text-secondary: #C0C0C0;
|
||||
--color-text-muted: #808080;
|
||||
--color-text-muted: #949494;
|
||||
|
||||
--color-border: #3A3A3A;
|
||||
--color-border-focus: #AAAAAA;
|
||||
@@ -151,7 +151,7 @@
|
||||
|
||||
--color-text: #F0F0F0;
|
||||
--color-text-secondary: #C0C0C0;
|
||||
--color-text-muted: #808080;
|
||||
--color-text-muted: #949494;
|
||||
|
||||
--color-border: #3A3A3A;
|
||||
--color-border-focus: #AAAAAA;
|
||||
@@ -182,7 +182,7 @@
|
||||
|
||||
--color-text: #1A1A1A;
|
||||
--color-text-secondary: #4A4A4A;
|
||||
--color-text-muted: #8A8A8A;
|
||||
--color-text-muted: #737373;
|
||||
|
||||
--color-border: #D0D0D0;
|
||||
--color-border-focus: #555555;
|
||||
|
||||
30
deploy.sh
30
deploy.sh
@@ -14,35 +14,21 @@ set -e
|
||||
REMOTE_USER_HOST="${1:-admin@dgray.io}"
|
||||
REMOTE_PATH="${2:-/home/admin/web/dgray.io/public_html}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
|
||||
# Files/directories to exclude from deployment
|
||||
EXCLUDES=(
|
||||
".git"
|
||||
".gitignore"
|
||||
"AGENTS.md"
|
||||
"deploy.sh"
|
||||
"docs/"
|
||||
"tests/"
|
||||
"README.md"
|
||||
".vscode"
|
||||
".idea"
|
||||
)
|
||||
# --- Build (minify JS + CSS) ---
|
||||
echo "Building..."
|
||||
python3 "$SCRIPT_DIR/build.py"
|
||||
echo ""
|
||||
|
||||
# --- Build exclude arguments ---
|
||||
EXCLUDE_ARGS=""
|
||||
for item in "${EXCLUDES[@]}"; do
|
||||
EXCLUDE_ARGS="$EXCLUDE_ARGS --exclude=$item"
|
||||
done
|
||||
|
||||
# --- Deploy ---
|
||||
# --- Deploy from dist/ ---
|
||||
echo "Deploying dgray.io"
|
||||
echo " From: $SCRIPT_DIR"
|
||||
echo " From: $DIST_DIR"
|
||||
echo " To: $REMOTE_USER_HOST:$REMOTE_PATH"
|
||||
echo ""
|
||||
|
||||
rsync -avz --delete \
|
||||
$EXCLUDE_ARGS \
|
||||
"$SCRIPT_DIR/" \
|
||||
"$DIST_DIR/" \
|
||||
"$REMOTE_USER_HOST:$REMOTE_PATH/"
|
||||
|
||||
echo ""
|
||||
|
||||
145
index.html
145
index.html
@@ -33,19 +33,152 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon.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="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<!-- 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">
|
||||
|
||||
<!-- Critical CSS inlined (fonts + variables + base) -->
|
||||
<style>
|
||||
/* fonts.css */
|
||||
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 400; font-display: optional; src: url('assets/fonts/Inter-Regular.woff2') format('woff2'); }
|
||||
@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.min.css">
|
||||
<link rel="stylesheet" href="css/animate.custom.css">
|
||||
</noscript>
|
||||
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</head>
|
||||
<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">
|
||||
© 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>
|
||||
<p>Diese App benötigt JavaScript.</p>
|
||||
|
||||
@@ -30,7 +30,10 @@ async function initApp() {
|
||||
|
||||
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) {
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,10 @@ class AppFooter extends HTMLElement {
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
if (!this.querySelector('.footer-inner')) {
|
||||
this.render()
|
||||
}
|
||||
this.updateTextContent()
|
||||
await this.loadXmrRates()
|
||||
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() {
|
||||
this.render()
|
||||
this.updateTextContent()
|
||||
if (this.rates) this.updateRateDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,6 @@ import { i18n } from '../i18n.js'
|
||||
import { auth } from '../services/auth.js'
|
||||
import './app-header.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 {
|
||||
constructor() {
|
||||
@@ -25,8 +11,17 @@ class AppShell extends HTMLElement {
|
||||
}
|
||||
|
||||
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.loadAuthModal()
|
||||
|
||||
i18n.subscribe(() => {
|
||||
this.querySelector('app-header').updateTranslations()
|
||||
@@ -34,35 +29,32 @@ class AppShell extends HTMLElement {
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = /* html */`
|
||||
<app-header></app-header>
|
||||
<main class="container" id="router-outlet"></main>
|
||||
<app-footer></app-footer>
|
||||
<auth-modal hidden></auth-modal>
|
||||
`
|
||||
|
||||
this.main = this.querySelector('#router-outlet')
|
||||
async loadAuthModal() {
|
||||
await import('./auth-modal.js')
|
||||
this.querySelector('#auth-modal-slot').innerHTML = '<auth-modal hidden></auth-modal>'
|
||||
}
|
||||
|
||||
setupRouter() {
|
||||
router.setOutlet(this.main)
|
||||
|
||||
const lazy = (path) => () => import(path)
|
||||
|
||||
router.setNotFoundLoader(lazy('./pages/page-not-found.js'))
|
||||
router
|
||||
.register('/', 'page-home')
|
||||
.register('/search', 'page-home') // Redirect search to home
|
||||
.register('/listing/:id', 'page-listing')
|
||||
.register('/create', 'page-create')
|
||||
.register('/edit/:id', 'page-create')
|
||||
.register('/favorites', 'page-favorites')
|
||||
.register('/my-listings', 'page-my-listings')
|
||||
.register('/messages', 'page-messages')
|
||||
.register('/settings', 'page-settings')
|
||||
.register('/notifications', 'page-notifications')
|
||||
.register('/privacy', 'page-privacy')
|
||||
.register('/terms', 'page-terms')
|
||||
.register('/about', 'page-about')
|
||||
.register('/contact', 'page-contact')
|
||||
.register('/', 'page-home', lazy('./pages/page-home.js'))
|
||||
.register('/search', 'page-home', lazy('./pages/page-home.js'))
|
||||
.register('/listing/:id', 'page-listing', lazy('./pages/page-listing.js'))
|
||||
.register('/create', 'page-create', lazy('./pages/page-create.js'))
|
||||
.register('/edit/:id', 'page-create', lazy('./pages/page-create.js'))
|
||||
.register('/favorites', 'page-favorites', lazy('./pages/page-favorites.js'))
|
||||
.register('/my-listings', 'page-my-listings', lazy('./pages/page-my-listings.js'))
|
||||
.register('/messages', 'page-messages', lazy('./pages/page-messages.js'))
|
||||
.register('/settings', 'page-settings', lazy('./pages/page-settings.js'))
|
||||
.register('/notifications', 'page-notifications', lazy('./pages/page-notifications.js'))
|
||||
.register('/privacy', 'page-privacy', lazy('./pages/page-privacy.js'))
|
||||
.register('/terms', 'page-terms', lazy('./pages/page-terms.js'))
|
||||
.register('/about', 'page-about', lazy('./pages/page-about.js'))
|
||||
.register('/contact', 'page-contact', lazy('./pages/page-contact.js'))
|
||||
|
||||
router.handleRouteChange()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ let cachedRates = null
|
||||
|
||||
class ListingCard extends HTMLElement {
|
||||
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() {
|
||||
@@ -141,7 +141,7 @@ class ListingCard extends HTMLElement {
|
||||
<${linkTag} ${linkAttr} class="listing-link">
|
||||
<div class="listing-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}
|
||||
${paymentBadge}
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@ class PageFavorites extends HTMLElement {
|
||||
|
||||
return this.listings.map(listing => {
|
||||
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 || ''
|
||||
|
||||
return /* html */`
|
||||
|
||||
@@ -243,6 +243,7 @@ class PageHome extends HTMLElement {
|
||||
|
||||
if (this.page === 1) {
|
||||
this.listings = newItems
|
||||
this.preloadLcpImage(newItems)
|
||||
} else {
|
||||
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) {
|
||||
if (!this.hasUserLocation()) return listings
|
||||
|
||||
@@ -419,6 +435,7 @@ class PageHome extends HTMLElement {
|
||||
${showFilters ? `<h2 class="listings-title">${this.getListingsTitle()}</h2>` : ''}
|
||||
<div class="listings-toolbar">
|
||||
<div class="sort-inline">
|
||||
<label for="sort-select" class="sr-only">${t('search.sortBy')}</label>
|
||||
<select id="sort-select">
|
||||
${this.hasUserLocation() ? `<option value="distance" ${this.sort === 'distance' ? 'selected' : ''}>${t('search.sortDistance')}</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 imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : ''
|
||||
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 180) : ''
|
||||
const locationName = listing.location?.name || ''
|
||||
|
||||
return /* html */`
|
||||
@@ -517,6 +534,7 @@ class PageHome extends HTMLElement {
|
||||
location="${escapeHTML(locationName)}"
|
||||
image="${imageUrl}"
|
||||
owner-id="${listing.user_created || ''}"
|
||||
${index < 4 ? 'priority' : ''}
|
||||
></listing-card>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
@@ -416,7 +416,7 @@ class PageListing extends HTMLElement {
|
||||
|
||||
renderListingCard(listing) {
|
||||
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 || ''
|
||||
|
||||
return /* html */`
|
||||
|
||||
@@ -237,7 +237,7 @@ class PageMyListings extends HTMLElement {
|
||||
|
||||
const html = this.listings.map(listing => {
|
||||
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 statusBadge = this.getStatusBadge(listing)
|
||||
|
||||
|
||||
@@ -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>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
</svg>
|
||||
<label for="country-select-mobile" class="sr-only">${t('search.currentLocation')}</label>
|
||||
<select id="country-select-mobile">
|
||||
<option value="current" ${this.useCurrentLocation ? 'selected' : ''}>
|
||||
📍 ${t('search.currentLocation')}
|
||||
|
||||
31
js/router.js
31
js/router.js
@@ -42,10 +42,11 @@ class Router {
|
||||
* Register a route
|
||||
* @param {string} path - Route path (e.g., '/listing/:id')
|
||||
* @param {string} componentTag - Web component tag name
|
||||
* @param {Function} [loader] - Optional dynamic import function for lazy loading
|
||||
* @returns {Router} this for chaining
|
||||
*/
|
||||
register(path, componentTag) {
|
||||
this.routes.set(path, componentTag)
|
||||
register(path, componentTag, loader) {
|
||||
this.routes.set(path, { componentTag, loader })
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -70,10 +71,11 @@ class Router {
|
||||
*/
|
||||
matchRoute(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 pathParts = path.split('/')
|
||||
|
||||
@@ -92,7 +94,7 @@ class Router {
|
||||
}
|
||||
|
||||
if (match) {
|
||||
return { componentTag, params }
|
||||
return { componentTag: route.componentTag, loader: route.loader, params }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +119,11 @@ class Router {
|
||||
return
|
||||
}
|
||||
|
||||
const { componentTag, params: routeParams } = match
|
||||
const { componentTag, loader, params: routeParams } = match
|
||||
|
||||
if (loader && !customElements.get(componentTag)) {
|
||||
await loader()
|
||||
}
|
||||
|
||||
this.currentRoute = {
|
||||
path,
|
||||
@@ -171,14 +177,25 @@ class Router {
|
||||
}
|
||||
|
||||
/** @private */
|
||||
renderNotFound() {
|
||||
async renderNotFound() {
|
||||
if (!this.outlet) return
|
||||
|
||||
if (!customElements.get('page-not-found') && this._notFoundLoader) {
|
||||
await this._notFoundLoader()
|
||||
}
|
||||
this.outlet.innerHTML = ''
|
||||
const notFound = document.createElement('page-not-found')
|
||||
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
|
||||
* @param {string} path - Target path
|
||||
|
||||
@@ -816,7 +816,7 @@ class DirectusService {
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'dgray-v48';
|
||||
const CACHE_NAME = 'dgray-v49';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
@@ -6,11 +6,8 @@ const STATIC_ASSETS = [
|
||||
'/manifest.json',
|
||||
|
||||
// CSS
|
||||
'/css/fonts.css',
|
||||
'/css/variables.css',
|
||||
'/css/base.css',
|
||||
'/css/components.css',
|
||||
'/css/animate.min.css',
|
||||
'/css/animate.custom.css',
|
||||
'/css/vendor/cropper.min.css',
|
||||
|
||||
// Core JS
|
||||
|
||||
Reference in New Issue
Block a user