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
|
# 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
|
||||||
|
|
||||||
|
|||||||
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`
|
**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
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 {
|
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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
30
deploy.sh
30
deploy.sh
@@ -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 ""
|
||||||
|
|||||||
147
index.html
147
index.html
@@ -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>
|
||||||
|
|
||||||
<link rel="stylesheet" href="css/fonts.css">
|
<!-- Modulepreload critical chain -->
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
<link rel="modulepreload" href="js/app.js">
|
||||||
<link rel="stylesheet" href="css/base.css">
|
<link rel="modulepreload" href="js/i18n.js">
|
||||||
<link rel="stylesheet" href="css/components.css">
|
<link rel="modulepreload" href="js/router.js">
|
||||||
<link rel="stylesheet" href="css/animate.min.css">
|
<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.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">
|
||||||
|
© 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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */`
|
||||||
|
|||||||
@@ -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('')
|
||||||
|
|||||||
@@ -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 */`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
31
js/router.js
31
js/router.js
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user