initial commit
This commit is contained in:
112
AGENTS.md
Normal file
112
AGENTS.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# AGENTS.md - AI Assistant Context
|
||||
|
||||
Dieses Dokument hilft AI-Assistenten (Amp, Copilot, etc.) das Projekt zu verstehen.
|
||||
|
||||
## Projekt-Überblick
|
||||
|
||||
**dgray.io** ist eine Kleinanzeigen-PWA mit Monero-Bezahlung.
|
||||
|
||||
- **Status**: Early Development (Frontend-only)
|
||||
- **Ziel**: Anonyme, dezentrale Marktplatz-Alternative
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
| Layer | Technologie |
|
||||
|-------|-------------|
|
||||
| Frontend | Vanilla JS, Web Components, CSS Custom Properties |
|
||||
| Routing | Hash-basierter Client-Side Router (`js/router.js`) |
|
||||
| i18n | Custom System (`js/i18n.js`), JSON-Dateien in `/locales/` |
|
||||
| Theming | CSS Variables, Dark/Light Mode |
|
||||
| Backend | Geplant: Directus |
|
||||
|
||||
## Häufige Befehle
|
||||
|
||||
```bash
|
||||
# Lokaler Entwicklungsserver
|
||||
python3 -m http.server 8080
|
||||
|
||||
# Oder mit Live-Reload
|
||||
npx live-server
|
||||
|
||||
# Git Push (Token in URL)
|
||||
git push origin master
|
||||
```
|
||||
|
||||
## Dateistruktur
|
||||
|
||||
```
|
||||
js/
|
||||
├── app.js # Entry, lädt i18n, dann Shell
|
||||
├── router.js # Hash-Router mit :params
|
||||
├── i18n.js # t('key'), subscribe(), updateDOM()
|
||||
└── components/
|
||||
├── app-shell.js # Layout, registriert Routes
|
||||
├── app-header.js # Header (Theme-Toggle, Lang-Dropdown)
|
||||
├── app-footer.js # Footer
|
||||
└── pages/ # Seiten als Web Components
|
||||
|
||||
css/
|
||||
├── fonts.css # @font-face Definitionen (Inter, Space Grotesk)
|
||||
├── variables.css # CSS Custom Properties (Farben, Spacing)
|
||||
├── base.css # Reset, Container
|
||||
└── components.css # UI-Komponenten (Buttons, Cards, etc.)
|
||||
|
||||
assets/
|
||||
└── fonts/ # Self-hosted Fonts (Inter, Space Grotesk)
|
||||
|
||||
locales/
|
||||
├── de.json # Deutsch (Fallback)
|
||||
├── en.json
|
||||
└── fr.json
|
||||
```
|
||||
|
||||
## Konventionen
|
||||
|
||||
### Web Components
|
||||
- Custom Elements mit `class extends HTMLElement`
|
||||
- Styles als `<style>` im `document.head` oder inline
|
||||
- i18n: `render()` Methode, Subscribe auf Sprachwechsel
|
||||
|
||||
### CSS
|
||||
- **Keine festen Farben** - immer `var(--color-*)` nutzen
|
||||
- **Spacing**: `var(--space-xs|sm|md|lg|xl|2xl|3xl)`
|
||||
- **Font-Size**: `var(--font-size-xs|sm|base|lg|xl|2xl|3xl)`
|
||||
- **Font-Family**: `var(--font-family)` für Body, `var(--font-family-heading)` für Headlines
|
||||
- **Border-Radius**: `var(--radius-sm|md|lg|xl|full)`
|
||||
|
||||
### i18n
|
||||
- Schlüssel: `section.key` (z.B. `home.title`)
|
||||
- Im HTML: `data-i18n="key"` oder `${t('key')}`
|
||||
- Placeholder: `data-i18n-placeholder="key"`
|
||||
- Neue Texte in **allen 3 Sprachen** hinzufügen
|
||||
|
||||
## Aktuelle Probleme / Hinweise
|
||||
|
||||
1. **SSH funktioniert nicht** mit diesem Repo auf gitea.pro - HTTPS mit Token nutzen
|
||||
2. **Service Worker** kann lokale Änderungen cachen - bei Problemen Cache leeren
|
||||
3. **i18n muss vor Shell laden** - siehe `app.js` (dynamischer Import)
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. Suchseite (`page-search.js`) mit Filtern ausbauen
|
||||
2. Anzeige-Detailseite (`page-listing.js`) gestalten
|
||||
3. Anzeige-Erstellen-Formular (`page-create.js`) fertigstellen
|
||||
4. Directus Backend aufsetzen
|
||||
|
||||
## Farbpalette
|
||||
|
||||
```
|
||||
Light Mode:
|
||||
- BG: #FAF8FC (Lavender Tint)
|
||||
- Text: #2A2633
|
||||
- Primary: #6B5B95 (Amethyst)
|
||||
|
||||
Dark Mode:
|
||||
- BG: #1A1424 (Deep Purple)
|
||||
- Text: #F5F2FA
|
||||
- Primary: #B8A5D6 (Soft Lilac)
|
||||
```
|
||||
|
||||
## Ansprechpartner
|
||||
|
||||
- Repo: https://gitea.pro/schmidt1024/tausch.app
|
||||
214
README.md
Normal file
214
README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# dgray.io
|
||||
|
||||
Eine anonyme, dezentrale Kleinanzeigen-Plattform mit Monero-Bezahlung.
|
||||
|
||||
## 🎯 Vision
|
||||
|
||||
dgray.io ermöglicht es Nutzern, Kleinanzeigen zu schalten und Waren/Dienstleistungen sicher über Monero (XMR) zu handeln. Besonderheiten:
|
||||
|
||||
- **Anonymität**: Nutzung ohne Account möglich
|
||||
- **Käuferschutz**: Monero MultiSig Escrow
|
||||
- **Dezentral**: Keine zentrale Zahlungsabwicklung
|
||||
- **Privacy-First**: E2E-Verschlüsselung für Kommunikation
|
||||
|
||||
---
|
||||
|
||||
## 📊 Machbarkeitsanalyse
|
||||
|
||||
### Technisch realisierbar
|
||||
|
||||
| Feature | Komplexität | Status |
|
||||
|---------|-------------|--------|
|
||||
| Anzeigen CRUD | Niedrig | 🔲 Offen |
|
||||
| Fiat/XMR Preisanzeige | Niedrig | 🔲 Offen |
|
||||
| Anonyme Nutzung | Mittel | 🔲 Offen |
|
||||
| PWA | Mittel | ✅ Grundgerüst |
|
||||
| Light/Dark Mode | Niedrig | ✅ Fertig |
|
||||
| i18n (DE/EN/FR) | Niedrig | ✅ Fertig |
|
||||
| E2E-Chat | Hoch | 🔲 Offen |
|
||||
| **Monero MultiSig** | **Sehr hoch** | 🔲 Offen |
|
||||
| KI-Assistenz | Mittel | 🔲 Offen |
|
||||
| Rating-System | Mittel | 🔲 Offen |
|
||||
| 2FA | Mittel | 🔲 Offen |
|
||||
| Forum/Gruppen | Hoch | 🔲 Offen |
|
||||
|
||||
### ⚠️ Kritische Punkte
|
||||
|
||||
1. **Monero MultiSig Escrow**
|
||||
- Komplexeste Komponente
|
||||
- Erfordert eigene Wallet-Integration via `monero-wallet-rpc`
|
||||
- Nutzer müssen Wallet-Keys verwalten → UX-Hürde
|
||||
- Alternative für MVP: Trusted Escrow-Service
|
||||
|
||||
2. **Rechtliche Aspekte**
|
||||
- AGB, Datenschutz, Impressum erforderlich
|
||||
- Geldwäsche-Regularien (je nach Jurisdiktion)
|
||||
- Haftung bei Betrug klären
|
||||
|
||||
3. **E2E-Verschlüsselung**
|
||||
- Key-Management für anonyme Nutzer
|
||||
- Optionen: Signal-Protokoll, Matrix-Protokoll
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tech-Stack
|
||||
|
||||
### Frontend (aktuell)
|
||||
- **Vanilla JavaScript** (ES Modules)
|
||||
- **Web Components** (Custom Elements)
|
||||
- **CSS Custom Properties** (Theming)
|
||||
- **PWA** (Service Worker, Manifest)
|
||||
|
||||
### Backend (geplant)
|
||||
- **Directus** (Headless CMS)
|
||||
- REST/GraphQL API
|
||||
- Auth, Rollen, Berechtigungen
|
||||
- Custom Extensions für XMR-Integration
|
||||
|
||||
### Infrastruktur (geplant)
|
||||
- PostgreSQL + Redis
|
||||
- monero-wallet-rpc
|
||||
- Matrix/Signal für Chat
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Setup
|
||||
|
||||
### Voraussetzungen
|
||||
- Moderner Browser mit ES Module Support
|
||||
- Python 3 (für lokalen Server) oder beliebiger HTTP-Server
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Repository klonen
|
||||
git clone https://gitea.pro/schmidt1024/tausch.app.git
|
||||
cd tausch.app
|
||||
|
||||
# Lokalen Server starten
|
||||
python3 -m http.server 8080
|
||||
|
||||
# Oder mit Live-Reload (Node.js erforderlich)
|
||||
npx live-server
|
||||
```
|
||||
|
||||
Öffne http://localhost:8080
|
||||
|
||||
### Projektstruktur
|
||||
|
||||
```
|
||||
tausch.app/
|
||||
├── index.html # Entry Point
|
||||
├── manifest.json # PWA Manifest
|
||||
├── service-worker.js # Offline-Support
|
||||
├── css/
|
||||
│ ├── fonts.css # Web Fonts (Inter, Space Grotesk)
|
||||
│ ├── variables.css # Theming (Light/Dark)
|
||||
│ ├── base.css # Reset, Grundstyles
|
||||
│ └── components.css # UI-Komponenten
|
||||
├── js/
|
||||
│ ├── app.js # App-Initialisierung
|
||||
│ ├── router.js # Hash-basiertes Routing
|
||||
│ ├── i18n.js # Übersetzungssystem
|
||||
│ ├── services/
|
||||
│ │ └── api.js # API-Client (Vorbereitung)
|
||||
│ └── components/
|
||||
│ ├── app-shell.js # Layout-Container
|
||||
│ ├── app-header.js # Header mit Navigation
|
||||
│ ├── app-footer.js # Footer
|
||||
│ └── pages/ # Seiten-Komponenten
|
||||
├── locales/
|
||||
│ ├── de.json # Deutsch
|
||||
│ ├── en.json # English
|
||||
│ └── fr.json # Français
|
||||
└── assets/
|
||||
├── fonts/ # Self-hosted Fonts (Inter, Space Grotesk)
|
||||
└── icons/ # App-Icons
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Offene Punkte / Roadmap
|
||||
|
||||
### Phase 1: MVP Frontend ⬅️ **Aktuell**
|
||||
- [x] Projektstruktur
|
||||
- [x] Routing
|
||||
- [x] i18n (DE/EN/FR)
|
||||
- [x] Light/Dark Mode
|
||||
- [x] PWA Grundgerüst
|
||||
- [x] Startseite mit Suche, Kategorien, Listings-Grid
|
||||
- [x] Typografie (Inter + Space Grotesk)
|
||||
- [x] Such-Komponente mit Accordion-Kategorien
|
||||
- [ ] Suchseite mit Filtern
|
||||
- [ ] Anzeige-Detailseite
|
||||
- [ ] Anzeige-Erstellen-Formular
|
||||
- [ ] Responsive Optimierungen
|
||||
|
||||
### Phase 2: Backend-Integration
|
||||
- [ ] Directus aufsetzen
|
||||
- [ ] Anzeigen-Collection
|
||||
- [ ] User-Auth (optional/anonym)
|
||||
- [ ] Bilder-Upload
|
||||
- [ ] API-Anbindung im Frontend
|
||||
|
||||
### Phase 3: Kommunikation
|
||||
- [ ] Chat-System (E2E-verschlüsselt)
|
||||
- [ ] Benachrichtigungen
|
||||
- [ ] Merkliste
|
||||
|
||||
### Phase 4: Payments
|
||||
- [ ] XMR-Kursabfrage API
|
||||
- [ ] Fiat ↔ XMR Umrechnung
|
||||
- [ ] Wallet-Anbindung (monero-wallet-rpc)
|
||||
- [ ] MultiSig Escrow
|
||||
|
||||
### Phase 5: Trust & Safety
|
||||
- [ ] Rating-System
|
||||
- [ ] 2FA
|
||||
- [ ] Reporting/Moderation
|
||||
- [ ] AGB, Datenschutz
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design-Entscheidungen
|
||||
|
||||
### Typografie
|
||||
- **Headlines**: Space Grotesk (Medium, Bold)
|
||||
- **Body**: Inter (Regular, Medium, SemiBold, Bold)
|
||||
- Self-hosted Fonts (SIL Open Font License)
|
||||
|
||||
### Farbpalette
|
||||
- **Light Mode**: Lavender-Töne (#FAF8FC, #6B5B95)
|
||||
- **Dark Mode**: Deep Purple (#1A1424, #B8A5D6)
|
||||
- WCAG AAA konform (Kontrast > 7:1)
|
||||
|
||||
### Mobile-First
|
||||
- Responsive Grid (2 Spalten Mobile, 5 Spalten Desktop)
|
||||
- Touch-optimierte Buttons
|
||||
- Icon-only Buttons auf kleinen Screens
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Feature-Branch erstellen
|
||||
2. Änderungen committen
|
||||
3. Pull Request öffnen
|
||||
|
||||
### Code-Konventionen
|
||||
- ES Modules verwenden
|
||||
- Web Components für UI-Komponenten
|
||||
- CSS Custom Properties für Theming
|
||||
- Übersetzungsschlüssel für alle Texte
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
TBD
|
||||
|
||||
---
|
||||
|
||||
## 📞 Kontakt
|
||||
|
||||
TBD
|
||||
BIN
assets/fonts/Inter-Bold.woff2
Normal file
BIN
assets/fonts/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-Medium.woff2
Normal file
BIN
assets/fonts/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-Regular.woff2
Normal file
BIN
assets/fonts/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-SemiBold.woff2
Normal file
BIN
assets/fonts/Inter-SemiBold.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/SpaceGrotesk-Bold.woff2
Normal file
BIN
assets/fonts/SpaceGrotesk-Bold.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/SpaceGrotesk-Light.woff2
Normal file
BIN
assets/fonts/SpaceGrotesk-Light.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/SpaceGrotesk-Medium.woff2
Normal file
BIN
assets/fonts/SpaceGrotesk-Medium.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/SpaceGrotesk-Regular.woff2
Normal file
BIN
assets/fonts/SpaceGrotesk-Regular.woff2
Normal file
Binary file not shown.
16
assets/icons/icon.svg
Normal file
16
assets/icons/icon.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3b82f6"/>
|
||||
<stop offset="100%" style="stop-color:#2563eb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#grad)"/>
|
||||
<g fill="white">
|
||||
<path d="M160 180h192v40H160z"/>
|
||||
<path d="M180 140h40v232h-40z"/>
|
||||
<path d="M292 140h40v232h-40z"/>
|
||||
<path d="M160 292h192v40H160z"/>
|
||||
<circle cx="256" cy="256" r="24"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 560 B |
7
css/animate.min.css
vendored
Normal file
7
css/animate.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
103
css/base.css
Normal file
103
css/base.css
Normal file
@@ -0,0 +1,103 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
353
css/components.css
Normal file
353
css/components.css
Normal file
@@ -0,0 +1,353 @@
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-border-focus);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
aspect-ratio: 4 / 3;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.card-price {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.grid-cols-4 { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-4, .grid-cols-3 { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.grid-cols-4, .grid-cols-3, .grid-cols-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Header Component */
|
||||
app-header {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-sticky);
|
||||
background-color: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
app-header .header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--header-height);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
app-header .logo {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
app-header .search-box {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
app-header .header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
app-header .btn-create {
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
app-header .btn-create-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
app-header .btn-create {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
app-header .btn-create-text {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer Component */
|
||||
app-footer {
|
||||
display: block;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: var(--space-lg) 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
app-footer .footer-links a {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* App Shell */
|
||||
app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
app-shell main {
|
||||
flex: 1;
|
||||
padding: var(--space-lg) 0;
|
||||
}
|
||||
|
||||
/* Page Transitions */
|
||||
.page-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.page-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity var(--transition-normal), transform var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-3xl);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 150px;
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--space-xs);
|
||||
z-index: var(--z-dropdown);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.dropdown.open .dropdown-menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
text-align: left;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background-color: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
67
css/fonts.css
Normal file
67
css/fonts.css
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Typography: Space Grotesk (Headlines) + Inter (Body)
|
||||
*
|
||||
* Download fonts from:
|
||||
* - Inter: https://github.com/rsms/inter/releases (Variable font)
|
||||
* - Space Grotesk: https://github.com/nicholasrougeux/space-grotesk/releases
|
||||
*
|
||||
* Place files in /assets/fonts/
|
||||
*/
|
||||
|
||||
/* Inter - Body Text */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Space Grotesk - Headlines */
|
||||
@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');
|
||||
}
|
||||
189
css/variables.css
Normal file
189
css/variables.css
Normal file
@@ -0,0 +1,189 @@
|
||||
:root {
|
||||
/*
|
||||
* Lavender Light + Deep Purple Dark
|
||||
* WCAG AAA konform (Kontrast > 7:1)
|
||||
*/
|
||||
|
||||
/* Colors - Light Mode */
|
||||
--color-primary: #6B5B95;
|
||||
--color-primary-hover: #574A7A;
|
||||
--color-primary-light: #E8E4F0;
|
||||
|
||||
--color-secondary: #9B8AA6;
|
||||
--color-secondary-hover: #7D6E8A;
|
||||
|
||||
--color-accent: #A67B9E;
|
||||
--color-accent-hover: #8A6283;
|
||||
|
||||
--color-success: #5A8F6B;
|
||||
--color-warning: #C49A3C;
|
||||
--color-error: #B54747;
|
||||
|
||||
--color-bg: #FAF8FC;
|
||||
--color-bg-secondary: #F3F0F7;
|
||||
--color-bg-tertiary: #EBE6F2;
|
||||
|
||||
--color-text: #2A2633;
|
||||
--color-text-secondary: #524A5E;
|
||||
--color-text-muted: #8A8295;
|
||||
|
||||
--color-border: #DDD8E6;
|
||||
--color-border-focus: #6B5B95;
|
||||
|
||||
--color-shadow: rgba(42, 38, 51, 0.1);
|
||||
--color-overlay: rgba(42, 38, 51, 0.5);
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
--space-2xl: 3rem;
|
||||
--space-3xl: 4rem;
|
||||
|
||||
/* Typography */
|
||||
--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;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--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);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
--transition-slow: 350ms ease;
|
||||
|
||||
/* Layout */
|
||||
--header-height: 4rem;
|
||||
--footer-height: 3rem;
|
||||
--container-max: 1200px;
|
||||
--sidebar-width: 280px;
|
||||
|
||||
/* Z-Index */
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-modal: 300;
|
||||
--z-toast: 400;
|
||||
}
|
||||
|
||||
/* Dark Mode - System Preference */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--color-primary: #B8A5D6;
|
||||
--color-primary-hover: #C9BAE3;
|
||||
--color-primary-light: #2D2440;
|
||||
|
||||
--color-secondary: #A699B8;
|
||||
--color-secondary-hover: #BAAFCA;
|
||||
|
||||
--color-accent: #C9A5C4;
|
||||
--color-accent-hover: #D9BAD5;
|
||||
|
||||
--color-success: #7CB88A;
|
||||
--color-warning: #D4B05C;
|
||||
--color-error: #D47070;
|
||||
|
||||
--color-bg: #1A1424;
|
||||
--color-bg-secondary: #252033;
|
||||
--color-bg-tertiary: #312A42;
|
||||
|
||||
--color-text: #F5F2FA;
|
||||
--color-text-secondary: #D4CCDF;
|
||||
--color-text-muted: #9A90A8;
|
||||
|
||||
--color-border: #3D3550;
|
||||
--color-border-focus: #B8A5D6;
|
||||
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode - Manual Override */
|
||||
[data-theme="dark"] {
|
||||
--color-primary: #B8A5D6;
|
||||
--color-primary-hover: #C9BAE3;
|
||||
--color-primary-light: #2D2440;
|
||||
|
||||
--color-secondary: #A699B8;
|
||||
--color-secondary-hover: #BAAFCA;
|
||||
|
||||
--color-accent: #C9A5C4;
|
||||
--color-accent-hover: #D9BAD5;
|
||||
|
||||
--color-success: #7CB88A;
|
||||
--color-warning: #D4B05C;
|
||||
--color-error: #D47070;
|
||||
|
||||
--color-bg: #1A1424;
|
||||
--color-bg-secondary: #252033;
|
||||
--color-bg-tertiary: #312A42;
|
||||
|
||||
--color-text: #F5F2FA;
|
||||
--color-text-secondary: #D4CCDF;
|
||||
--color-text-muted: #9A90A8;
|
||||
|
||||
--color-border: #3D3550;
|
||||
--color-border-focus: #B8A5D6;
|
||||
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Light Mode - Manual Override */
|
||||
[data-theme="light"] {
|
||||
--color-primary: #6B5B95;
|
||||
--color-primary-hover: #574A7A;
|
||||
--color-primary-light: #E8E4F0;
|
||||
|
||||
--color-secondary: #9B8AA6;
|
||||
--color-secondary-hover: #7D6E8A;
|
||||
|
||||
--color-accent: #A67B9E;
|
||||
--color-accent-hover: #8A6283;
|
||||
|
||||
--color-success: #5A8F6B;
|
||||
--color-warning: #C49A3C;
|
||||
--color-error: #B54747;
|
||||
|
||||
--color-bg: #FAF8FC;
|
||||
--color-bg-secondary: #F3F0F7;
|
||||
--color-bg-tertiary: #EBE6F2;
|
||||
|
||||
--color-text: #2A2633;
|
||||
--color-text-secondary: #524A5E;
|
||||
--color-text-muted: #8A8295;
|
||||
|
||||
--color-border: #DDD8E6;
|
||||
--color-border-focus: #6B5B95;
|
||||
|
||||
--color-shadow: rgba(42, 38, 51, 0.1);
|
||||
--color-overlay: rgba(42, 38, 51, 0.5);
|
||||
}
|
||||
30
index.html
Normal file
30
index.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="dgray - deals in gray">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
|
||||
<title>dgray</title>
|
||||
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/svg+xml" href="assets/icons/icon.svg">
|
||||
<link rel="apple-touch-icon" href="assets/icons/icon-192.png">
|
||||
|
||||
<link rel="stylesheet" href="css/fonts.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
<link rel="stylesheet" href="css/animate.min.css">
|
||||
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<noscript>
|
||||
<p>Diese App benötigt JavaScript.</p>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
25
js/app.js
Normal file
25
js/app.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { i18n } from './i18n.js';
|
||||
|
||||
async function initApp() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
document.documentElement.dataset.theme = savedTheme;
|
||||
}
|
||||
|
||||
await i18n.init();
|
||||
|
||||
await import('./components/app-shell.js');
|
||||
|
||||
document.getElementById('app').innerHTML = '<app-shell></app-shell>';
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/service-worker.js');
|
||||
console.log('SW registered:', registration.scope);
|
||||
} catch (error) {
|
||||
console.log('SW registration failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initApp();
|
||||
31
js/components/app-footer.js
Normal file
31
js/components/app-footer.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
class AppFooter extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
this.innerHTML = /* html */ `
|
||||
<div class="footer-inner container">
|
||||
<p class="footer-copyright">
|
||||
© ${year} dgray. <span data-i18n="footer.rights">${t('footer.rights')}</span>
|
||||
</p>
|
||||
<nav class="footer-links">
|
||||
<a href="#/about" data-i18n="footer.about">${t('footer.about')}</a>
|
||||
<a href="#/privacy" data-i18n="footer.privacy">${t('footer.privacy')}</a>
|
||||
<a href="#/terms" data-i18n="footer.terms">${t('footer.terms')}</a>
|
||||
<a href="#/contact" data-i18n="footer.contact">${t('footer.contact')}</a>
|
||||
</nav>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
updateTranslations() {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('app-footer', AppFooter);
|
||||
205
js/components/app-header.js
Normal file
205
js/components/app-header.js
Normal file
@@ -0,0 +1,205 @@
|
||||
import { i18n, t } from '../i18n.js';
|
||||
import { router } from '../router.js';
|
||||
|
||||
class AppHeader extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.langDropdownOpen = false;
|
||||
this.handleOutsideClick = this.handleOutsideClick.bind(this);
|
||||
this.handleKeydown = this.handleKeydown.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
document.addEventListener('click', this.handleOutsideClick);
|
||||
document.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('click', this.handleOutsideClick);
|
||||
document.removeEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
handleOutsideClick() {
|
||||
if (this.langDropdownOpen) {
|
||||
this.closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (!this.langDropdownOpen) return;
|
||||
|
||||
const items = Array.from(this.querySelectorAll('.dropdown-item'));
|
||||
const currentIndex = items.findIndex(item => item === document.activeElement);
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.closeDropdown();
|
||||
this.querySelector('#lang-toggle')?.focus();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
||||
items[nextIndex]?.focus();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
items[prevIndex]?.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
closeDropdown() {
|
||||
this.langDropdownOpen = false;
|
||||
const langDropdown = this.querySelector('#lang-dropdown');
|
||||
const langToggle = this.querySelector('#lang-toggle');
|
||||
langDropdown?.classList.remove('open');
|
||||
langToggle?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
openDropdown() {
|
||||
this.langDropdownOpen = true;
|
||||
const langDropdown = this.querySelector('#lang-dropdown');
|
||||
const langToggle = this.querySelector('#lang-toggle');
|
||||
langDropdown?.classList.add('open');
|
||||
langToggle?.setAttribute('aria-expanded', 'true');
|
||||
this.querySelector('.dropdown-item.active')?.focus();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = /* html */ `
|
||||
<div class="header-inner container">
|
||||
<a href="#/" class="logo">dgray</a>
|
||||
|
||||
<div class="header-actions">
|
||||
<a href="#/create" class="btn btn-primary btn-create" title="${t('header.createListing')}" data-i18n-title="header.createListing">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
<span class="btn-create-text" data-i18n="header.createListing">${t('header.createListing')}</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="btn btn-icon btn-outline"
|
||||
id="theme-toggle"
|
||||
data-i18n-title="header.toggleTheme"
|
||||
title="${t('header.toggleTheme')}"
|
||||
>
|
||||
<svg class="icon-sun" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg class="icon-moon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none;">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="dropdown" id="lang-dropdown">
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
id="lang-toggle"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="${this.langDropdownOpen}"
|
||||
aria-label="${t('header.selectLanguage')}"
|
||||
>
|
||||
<span id="current-lang">${i18n.getLocale().toUpperCase()}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="dropdown-menu" role="listbox" aria-label="${t('header.selectLanguage')}">
|
||||
${i18n.getSupportedLocales().map(locale => `
|
||||
<button
|
||||
class="dropdown-item ${locale === i18n.getLocale() ? 'active' : ''}"
|
||||
data-locale="${locale}"
|
||||
role="option"
|
||||
aria-selected="${locale === i18n.getLocale()}"
|
||||
>
|
||||
${i18n.getLocaleDisplayName(locale)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const themeToggle = this.querySelector('#theme-toggle');
|
||||
themeToggle.addEventListener('click', () => this.toggleTheme());
|
||||
|
||||
const langDropdown = this.querySelector('#lang-dropdown');
|
||||
const langToggle = this.querySelector('#lang-toggle');
|
||||
|
||||
langToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.langDropdownOpen) {
|
||||
this.closeDropdown();
|
||||
} else {
|
||||
this.openDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
langDropdown.querySelectorAll('[data-locale]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
await i18n.setLocale(btn.dataset.locale);
|
||||
this.closeDropdown();
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
});
|
||||
});
|
||||
|
||||
this.updateThemeIcon();
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const currentTheme = document.documentElement.dataset.theme;
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
let newTheme;
|
||||
if (!currentTheme) {
|
||||
newTheme = prefersDark ? 'light' : 'dark';
|
||||
} else if (currentTheme === 'dark') {
|
||||
newTheme = 'light';
|
||||
} else {
|
||||
newTheme = 'dark';
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = newTheme;
|
||||
localStorage.setItem('theme', newTheme);
|
||||
this.updateThemeIcon();
|
||||
}
|
||||
|
||||
updateThemeIcon() {
|
||||
const theme = document.documentElement.dataset.theme;
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = theme === 'dark' || (!theme && prefersDark);
|
||||
|
||||
const sunIcon = this.querySelector('.icon-sun');
|
||||
const moonIcon = this.querySelector('.icon-moon');
|
||||
|
||||
if (sunIcon && moonIcon) {
|
||||
sunIcon.style.display = isDark ? 'block' : 'none';
|
||||
moonIcon.style.display = isDark ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
updateTranslations() {
|
||||
i18n.updateDOM();
|
||||
this.querySelector('#current-lang').textContent = i18n.getLocale().toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('app-header', AppHeader);
|
||||
50
js/components/app-shell.js
Normal file
50
js/components/app-shell.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { router } from '../router.js';
|
||||
import { i18n } from '../i18n.js';
|
||||
import './app-header.js';
|
||||
import './app-footer.js';
|
||||
import './pages/page-home.js';
|
||||
import './pages/page-search.js';
|
||||
import './pages/page-listing.js';
|
||||
import './pages/page-create.js';
|
||||
import './pages/page-not-found.js';
|
||||
|
||||
class AppShell extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.main = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupRouter();
|
||||
|
||||
i18n.subscribe(() => {
|
||||
this.querySelector('app-header').updateTranslations();
|
||||
this.querySelector('app-footer').updateTranslations();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = /* html */ `
|
||||
<app-header></app-header>
|
||||
<main class="container" id="router-outlet"></main>
|
||||
<app-footer></app-footer>
|
||||
`;
|
||||
|
||||
this.main = this.querySelector('#router-outlet');
|
||||
}
|
||||
|
||||
setupRouter() {
|
||||
router.setOutlet(this.main);
|
||||
|
||||
router
|
||||
.register('/', 'page-home')
|
||||
.register('/search', 'page-search')
|
||||
.register('/listing/:id', 'page-listing')
|
||||
.register('/create', 'page-create');
|
||||
|
||||
router.handleRouteChange();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('app-shell', AppShell);
|
||||
206
js/components/listing-card.js
Normal file
206
js/components/listing-card.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import { t, i18n } from '../i18n.js';
|
||||
import { escapeHTML, formatPrice } from '../utils/helpers.js';
|
||||
|
||||
class ListingCard extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['listing-id', 'title', 'price', 'currency', 'location', 'image'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.isFavorite = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.loadFavoriteState();
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
if (this.isConnected) {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
loadFavoriteState() {
|
||||
const id = this.getAttribute('listing-id');
|
||||
if (id) {
|
||||
const favorites = JSON.parse(localStorage.getItem('favorites') || '[]');
|
||||
this.isFavorite = favorites.includes(id);
|
||||
}
|
||||
}
|
||||
|
||||
saveFavoriteState() {
|
||||
const id = this.getAttribute('listing-id');
|
||||
if (!id) return;
|
||||
|
||||
let favorites = JSON.parse(localStorage.getItem('favorites') || '[]');
|
||||
|
||||
if (this.isFavorite) {
|
||||
if (!favorites.includes(id)) favorites.push(id);
|
||||
} else {
|
||||
favorites = favorites.filter(f => f !== id);
|
||||
}
|
||||
|
||||
localStorage.setItem('favorites', JSON.stringify(favorites));
|
||||
}
|
||||
|
||||
render() {
|
||||
const id = this.getAttribute('listing-id') || '';
|
||||
const title = this.getAttribute('title') || t('home.placeholderTitle');
|
||||
const price = this.getAttribute('price');
|
||||
const currency = this.getAttribute('currency') || 'EUR';
|
||||
const location = this.getAttribute('location') || t('home.placeholderLocation');
|
||||
const image = this.getAttribute('image');
|
||||
|
||||
const priceDisplay = price ? formatPrice(parseFloat(price), currency) : '–';
|
||||
const favoriteLabel = this.isFavorite ? t('home.removeFavorite') : t('home.addFavorite');
|
||||
|
||||
this.innerHTML = /* html */ `
|
||||
<a href="#/listing/${escapeHTML(id)}" class="listing-link">
|
||||
<div class="listing-image" ${image ? `style="background-image: url('${escapeHTML(image)}')"` : ''}></div>
|
||||
<div class="listing-info">
|
||||
<h3 class="listing-title">${escapeHTML(title)}</h3>
|
||||
<p class="listing-price">${priceDisplay}</p>
|
||||
<p class="listing-location">${escapeHTML(location)}</p>
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
class="favorite-btn ${this.isFavorite ? 'active' : ''}"
|
||||
aria-label="${favoriteLabel}"
|
||||
aria-pressed="${this.isFavorite}"
|
||||
>
|
||||
<svg class="heart-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const btn = this.querySelector('.favorite-btn');
|
||||
btn?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.toggleFavorite();
|
||||
});
|
||||
}
|
||||
|
||||
toggleFavorite() {
|
||||
this.isFavorite = !this.isFavorite;
|
||||
this.saveFavoriteState();
|
||||
|
||||
const btn = this.querySelector('.favorite-btn');
|
||||
btn?.classList.toggle('active', this.isFavorite);
|
||||
btn?.setAttribute('aria-pressed', this.isFavorite);
|
||||
btn?.setAttribute('aria-label', this.isFavorite ? t('home.removeFavorite') : t('home.addFavorite'));
|
||||
|
||||
btn?.classList.add('animate__animated', 'animate__heartBeat');
|
||||
btn?.addEventListener('animationend', () => {
|
||||
btn?.classList.remove('animate__animated', 'animate__heartBeat');
|
||||
}, { once: true });
|
||||
|
||||
this.dispatchEvent(new CustomEvent('favorite-toggle', {
|
||||
bubbles: true,
|
||||
detail: { id: this.getAttribute('listing-id'), isFavorite: this.isFavorite }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('listing-card', ListingCard);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = /* css */ `
|
||||
listing-card {
|
||||
display: block;
|
||||
position: relative;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
listing-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
listing-card .listing-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
listing-card .listing-image {
|
||||
aspect-ratio: 1;
|
||||
background: var(--color-bg-tertiary);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
listing-card .listing-info {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
listing-card .listing-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin: 0 0 var(--space-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
listing-card .listing-price {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
listing-card .listing-location {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
listing-card .favorite-btn {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg);
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-fast);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
listing-card .favorite-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
listing-card .favorite-btn .heart-icon {
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
listing-card .favorite-btn.active .heart-icon {
|
||||
fill: var(--color-error);
|
||||
stroke: var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
listing-card .favorite-btn:hover .heart-icon {
|
||||
color: var(--color-error);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
218
js/components/pages/page-create.js
Normal file
218
js/components/pages/page-create.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import { t, i18n } from '../../i18n.js';
|
||||
import { router } from '../../router.js';
|
||||
|
||||
class PageCreate extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.formData = {
|
||||
title: '',
|
||||
description: '',
|
||||
price: '',
|
||||
category: '',
|
||||
location: ''
|
||||
};
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.unsubscribe = i18n.subscribe(() => this.render());
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.unsubscribe) this.unsubscribe();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = /* html */ `
|
||||
<div class="create-page">
|
||||
<h1 data-i18n="create.title">${t('create.title')}</h1>
|
||||
|
||||
<form id="create-form" class="create-form">
|
||||
<div class="form-group">
|
||||
<label class="label" for="title" data-i18n="create.listingTitle">${t('create.listingTitle')}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="title"
|
||||
name="title"
|
||||
value="${this.escapeHtml(this.formData.title)}"
|
||||
required
|
||||
data-i18n-placeholder="create.titlePlaceholder"
|
||||
placeholder="${t('create.titlePlaceholder')}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="category" data-i18n="create.category">${t('create.category')}</label>
|
||||
<select class="input" id="category" name="category" required>
|
||||
<option value="">${t('create.selectCategory')}</option>
|
||||
<option value="electronics" ${this.formData.category === 'electronics' ? 'selected' : ''}>${t('categories.electronics')}</option>
|
||||
<option value="furniture" ${this.formData.category === 'furniture' ? 'selected' : ''}>${t('categories.furniture')}</option>
|
||||
<option value="clothing" ${this.formData.category === 'clothing' ? 'selected' : ''}>${t('categories.clothing')}</option>
|
||||
<option value="vehicles" ${this.formData.category === 'vehicles' ? 'selected' : ''}>${t('categories.vehicles')}</option>
|
||||
<option value="sports" ${this.formData.category === 'sports' ? 'selected' : ''}>${t('categories.sports')}</option>
|
||||
<option value="books" ${this.formData.category === 'books' ? 'selected' : ''}>${t('categories.books')}</option>
|
||||
<option value="garden" ${this.formData.category === 'garden' ? 'selected' : ''}>${t('categories.garden')}</option>
|
||||
<option value="other" ${this.formData.category === 'other' ? 'selected' : ''}>${t('categories.other')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="price" data-i18n="create.price">${t('create.price')}</label>
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
id="price"
|
||||
name="price"
|
||||
value="${this.formData.price}"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
placeholder="0.00"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="location" data-i18n="create.location">${t('create.location')}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="location"
|
||||
name="location"
|
||||
value="${this.escapeHtml(this.formData.location)}"
|
||||
required
|
||||
data-i18n-placeholder="create.locationPlaceholder"
|
||||
placeholder="${t('create.locationPlaceholder')}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="description" data-i18n="create.description">${t('create.description')}</label>
|
||||
<textarea
|
||||
class="input"
|
||||
id="description"
|
||||
name="description"
|
||||
rows="5"
|
||||
required
|
||||
data-i18n-placeholder="create.descriptionPlaceholder"
|
||||
placeholder="${t('create.descriptionPlaceholder')}"
|
||||
>${this.escapeHtml(this.formData.description)}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" data-i18n="create.images">${t('create.images')}</label>
|
||||
<div class="image-upload">
|
||||
<input type="file" id="images" name="images" accept="image/*" multiple hidden>
|
||||
<label for="images" class="upload-area">
|
||||
<span class="upload-icon">📷</span>
|
||||
<span data-i18n="create.uploadImages">${t('create.uploadImages')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-outline btn-lg" id="cancel-btn">
|
||||
${t('create.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
${t('create.publish')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const form = this.querySelector('#create-form');
|
||||
const cancelBtn = this.querySelector('#cancel-btn');
|
||||
|
||||
form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
cancelBtn.addEventListener('click', () => router.back());
|
||||
|
||||
form.querySelectorAll('input, textarea, select').forEach(input => {
|
||||
input.addEventListener('input', (e) => {
|
||||
this.formData[e.target.name] = e.target.value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const submitBtn = form.querySelector('[type="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = t('create.publishing');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
console.log('Creating listing:', this.formData);
|
||||
|
||||
router.navigate('/');
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('page-create', PageCreate);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = /* css */ `
|
||||
page-create .create-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
page-create .create-page h1 {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
page-create .create-form .form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
page-create textarea.input {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
page-create .image-upload {
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
page-create .upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
page-create .upload-area:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
page-create .upload-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
page-create .form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
122
js/components/pages/page-home.js
Normal file
122
js/components/pages/page-home.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { t, i18n } from '../../i18n.js';
|
||||
import '../listing-card.js';
|
||||
import '../search-box.js';
|
||||
|
||||
class PageHome extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.unsubscribe = i18n.subscribe(() => this.render());
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.unsubscribe) this.unsubscribe();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = /* html */ `
|
||||
<section class="search-section">
|
||||
<search-box></search-box>
|
||||
</section>
|
||||
|
||||
<section class="categories-desktop">
|
||||
<div class="category-badges">
|
||||
${this.renderCategoryBadges()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="recent-listings">
|
||||
<h2>${t('home.recentListings')}</h2>
|
||||
<div class="listings-grid">
|
||||
${this.renderPlaceholderListings()}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
renderCategoryBadges() {
|
||||
const topCategories = ['electronics', 'vehicles', 'furniture', 'clothing', 'sports'];
|
||||
return topCategories.map(cat => `
|
||||
<a href="#/search?category=${cat}" class="badge">${t(`categories.${cat}`)}</a>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
renderPlaceholderListings() {
|
||||
const placeholders = Array(10).fill(null);
|
||||
return placeholders.map((_, i) => /* html */`
|
||||
<listing-card
|
||||
listing-id="${i + 1}"
|
||||
title="${t('home.placeholderTitle')}"
|
||||
location="${t('home.placeholderLocation')}"
|
||||
></listing-card>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('page-home', PageHome);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = /* css */ `
|
||||
/* Search Section */
|
||||
page-home .search-section {
|
||||
padding: var(--space-xl) 0;
|
||||
}
|
||||
|
||||
/* Category Badges (Desktop only) */
|
||||
page-home .categories-desktop {
|
||||
display: none;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
page-home .categories-desktop {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
page-home .category-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
page-home .badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
page-home .badge:hover {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Listings */
|
||||
page-home section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
page-home section h2 {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
page-home .listings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
page-home .listings-grid {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
208
js/components/pages/page-listing.js
Normal file
208
js/components/pages/page-listing.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { t, i18n } from '../../i18n.js';
|
||||
|
||||
class PageListing extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.listing = null;
|
||||
this.loading = true;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.listingId = this.dataset.id;
|
||||
this.render();
|
||||
this.loadListing();
|
||||
this.unsubscribe = i18n.subscribe(() => this.render());
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.unsubscribe) this.unsubscribe();
|
||||
}
|
||||
|
||||
async loadListing() {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
this.listing = {
|
||||
id: this.listingId,
|
||||
title: 'iPhone 13 Pro - Sehr guter Zustand',
|
||||
description: 'Verkaufe mein iPhone 13 Pro in sehr gutem Zustand. Das Gerät hat keine Kratzer und funktioniert einwandfrei. Originalverpackung und Ladekabel sind dabei.',
|
||||
price: 699,
|
||||
location: 'Berlin, Mitte',
|
||||
category: 'electronics',
|
||||
createdAt: new Date().toISOString(),
|
||||
seller: {
|
||||
name: 'Max M.',
|
||||
memberSince: '2023'
|
||||
},
|
||||
images: []
|
||||
};
|
||||
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading) {
|
||||
this.innerHTML = /* html */ `
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.listing) {
|
||||
this.innerHTML = /* html */ `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">😕</div>
|
||||
<p data-i18n="listing.notFound">${t('listing.notFound')}</p>
|
||||
<a href="#/" class="btn btn-primary">${t('listing.backHome')}</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.innerHTML = /* html */ `
|
||||
<article class="listing-detail">
|
||||
<div class="listing-gallery">
|
||||
<div class="listing-image-main"></div>
|
||||
</div>
|
||||
|
||||
<div class="listing-info">
|
||||
<header>
|
||||
<span class="badge badge-primary">${t(`categories.${this.listing.category}`)}</span>
|
||||
<h1>${this.escapeHtml(this.listing.title)}</h1>
|
||||
<p class="listing-price">€ ${this.listing.price}</p>
|
||||
<p class="listing-location">📍 ${this.escapeHtml(this.listing.location)}</p>
|
||||
</header>
|
||||
|
||||
<section class="listing-description">
|
||||
<h2 data-i18n="listing.description">${t('listing.description')}</h2>
|
||||
<p>${this.escapeHtml(this.listing.description)}</p>
|
||||
</section>
|
||||
|
||||
<section class="listing-seller">
|
||||
<h2 data-i18n="listing.seller">${t('listing.seller')}</h2>
|
||||
<div class="seller-card">
|
||||
<div class="seller-avatar">${this.listing.seller.name.charAt(0)}</div>
|
||||
<div class="seller-info">
|
||||
<strong>${this.escapeHtml(this.listing.seller.name)}</strong>
|
||||
<span>${t('listing.memberSince')} ${this.listing.seller.memberSince}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="listing-actions">
|
||||
<button class="btn btn-primary btn-lg">
|
||||
${t('listing.contactSeller')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('page-listing', PageListing);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = /* css */ `
|
||||
page-listing .listing-detail {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
page-listing .listing-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
page-listing .listing-gallery {
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
page-listing .listing-image-main {
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
page-listing .listing-info header {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
page-listing .listing-info h1 {
|
||||
margin: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
page-listing .listing-price {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
page-listing .listing-location {
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
page-listing .listing-description,
|
||||
page-listing .listing-seller {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
page-listing .listing-description h2,
|
||||
page-listing .listing-seller h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
page-listing .seller-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
page-listing .seller-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
page-listing .seller-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
page-listing .seller-info span {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
page-listing .listing-actions {
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
page-listing .listing-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
52
js/components/pages/page-not-found.js
Normal file
52
js/components/pages/page-not-found.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { t, i18n } from '../../i18n.js';
|
||||
|
||||
class PageNotFound extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.unsubscribe = i18n.subscribe(() => this.render());
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.unsubscribe) this.unsubscribe();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = /* html */ `
|
||||
<div class="not-found">
|
||||
<div class="not-found-icon">404</div>
|
||||
<h1 data-i18n="notFound.title">${t('notFound.title')}</h1>
|
||||
<p data-i18n="notFound.message">${t('notFound.message')}</p>
|
||||
<a href="#/" class="btn btn-primary btn-lg">
|
||||
<span data-i18n="notFound.backHome">${t('notFound.backHome')}</span>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('page-not-found', PageNotFound);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = /* css */ `
|
||||
page-not-found .not-found {
|
||||
text-align: center;
|
||||
padding: var(--space-3xl) 0;
|
||||
}
|
||||
|
||||
page-not-found .not-found-icon {
|
||||
font-size: 6rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
page-not-found h1 {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
page-not-found p {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
234
js/components/pages/page-search.js
Normal file
234
js/components/pages/page-search.js
Normal file
@@ -0,0 +1,234 @@
|
||||
import { t, i18n } from '../../i18n.js';
|
||||
import { router } from '../../router.js';
|
||||
import '../search-box.js';
|
||||
import '../listing-card.js';
|
||||
|
||||
class PageSearch extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.results = [];
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.parseUrlParams();
|
||||
this.render();
|
||||
this.afterRender();
|
||||
this.unsubscribe = i18n.subscribe(() => {
|
||||
this.render();
|
||||
this.afterRender();
|
||||
});
|
||||
|
||||
if (this.hasFilters()) {
|
||||
this.performSearch();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.unsubscribe) this.unsubscribe();
|
||||
}
|
||||
|
||||
parseUrlParams() {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '');
|
||||
this.query = params.get('q') || '';
|
||||
this.category = params.get('category') || '';
|
||||
this.subcategory = params.get('sub') || '';
|
||||
this.country = params.get('country') || 'ch';
|
||||
this.useCurrentLocation = params.get('location') === 'current';
|
||||
this.radius = parseInt(params.get('radius')) || 50;
|
||||
this.lat = params.get('lat') ? parseFloat(params.get('lat')) : null;
|
||||
this.lng = params.get('lng') ? parseFloat(params.get('lng')) : null;
|
||||
}
|
||||
|
||||
hasFilters() {
|
||||
return this.query || this.category || this.subcategory;
|
||||
}
|
||||
|
||||
afterRender() {
|
||||
const searchBox = this.querySelector('search-box');
|
||||
if (searchBox) {
|
||||
searchBox.setFilters({
|
||||
query: this.query,
|
||||
category: this.category,
|
||||
subcategory: this.subcategory,
|
||||
country: this.country,
|
||||
useCurrentLocation: this.useCurrentLocation,
|
||||
radius: this.radius
|
||||
});
|
||||
|
||||
searchBox.addEventListener('search', (e) => {
|
||||
this.handleSearch(e.detail);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleSearch(filters) {
|
||||
this.query = filters.query;
|
||||
this.category = filters.category;
|
||||
this.subcategory = filters.subcategory;
|
||||
this.country = filters.country;
|
||||
this.useCurrentLocation = filters.useCurrentLocation;
|
||||
this.radius = filters.radius;
|
||||
this.lat = filters.lat;
|
||||
this.lng = filters.lng;
|
||||
|
||||
this.performSearch();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = /* html */ `
|
||||
<div class="search-page">
|
||||
<section class="search-header">
|
||||
<search-box compact no-navigate></search-box>
|
||||
</section>
|
||||
|
||||
<section class="search-results" id="results">
|
||||
${this.renderResults()}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async performSearch() {
|
||||
this.loading = true;
|
||||
this.updateResults();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
this.results = this.getMockResults();
|
||||
this.loading = false;
|
||||
this.updateResults();
|
||||
}
|
||||
|
||||
getMockResults() {
|
||||
return [
|
||||
{ id: 1, title: 'iPhone 13 Pro', price: 699, location: 'Berlin' },
|
||||
{ id: 2, title: 'Vintage Sofa', price: 250, location: 'München' },
|
||||
{ id: 3, title: 'Mountain Bike', price: 450, location: 'Hamburg' },
|
||||
{ id: 4, title: 'Gaming PC', price: 1200, location: 'Köln' },
|
||||
{ id: 5, title: 'Schreibtisch', price: 80, location: 'Zürich' },
|
||||
{ id: 6, title: 'Winterjacke', price: 45, location: 'Wien' },
|
||||
];
|
||||
}
|
||||
|
||||
updateResults() {
|
||||
const resultsContainer = this.querySelector('#results');
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
if (this.loading) {
|
||||
return /* html */ `
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>${t('search.loading')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this.hasFilters() && this.results.length === 0) {
|
||||
return /* html */ `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">🔍</div>
|
||||
<p>${t('search.enterQuery')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.results.length === 0) {
|
||||
return /* html */ `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">😕</div>
|
||||
<p>${t('search.noResults')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return /* html */ `
|
||||
<p class="results-count">${t('search.resultsCount', { count: this.results.length })}</p>
|
||||
<div class="listings-grid">
|
||||
${this.results.map(item => /* html */ `
|
||||
<listing-card
|
||||
listing-id="${item.id}"
|
||||
title="${this.escapeHtml(item.title)}"
|
||||
price="${item.price}"
|
||||
location="${this.escapeHtml(item.location)}"
|
||||
></listing-card>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('page-search', PageSearch);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = /* css */ `
|
||||
page-search .search-page {
|
||||
padding: var(--space-lg) 0;
|
||||
}
|
||||
|
||||
page-search .search-header {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
page-search .results-count {
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
page-search .listings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
page-search .listings-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
page-search .loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
page-search .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
page-search .empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-2xl);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
page-search .empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
948
js/components/search-box.js
Normal file
948
js/components/search-box.js
Normal file
@@ -0,0 +1,948 @@
|
||||
import { t, i18n } from '../i18n.js';
|
||||
|
||||
const CATEGORIES = {
|
||||
electronics: ['phones', 'computers', 'tv_audio', 'gaming', 'appliances'],
|
||||
vehicles: ['cars', 'motorcycles', 'bikes', 'parts'],
|
||||
furniture: ['living', 'bedroom', 'office', 'outdoor_furniture'],
|
||||
clothing: ['women', 'men', 'kids', 'shoes', 'accessories'],
|
||||
sports: ['fitness', 'outdoor', 'winter', 'water', 'team_sports'],
|
||||
books: ['fiction', 'nonfiction', 'textbooks', 'music_movies'],
|
||||
garden: ['plants', 'tools', 'outdoor_living', 'decoration'],
|
||||
other: ['collectibles', 'art', 'handmade', 'services']
|
||||
};
|
||||
|
||||
const COUNTRIES = ['ch', 'de', 'at', 'fr', 'it', 'li'];
|
||||
const RADIUS_OPTIONS = [5, 10, 20, 50, 100, 200];
|
||||
|
||||
class SearchBox extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['compact', 'category', 'subcategory', 'country', 'query'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.loadFiltersFromStorage();
|
||||
}
|
||||
|
||||
loadFiltersFromStorage() {
|
||||
const saved = localStorage.getItem('searchFilters');
|
||||
if (saved) {
|
||||
try {
|
||||
const filters = JSON.parse(saved);
|
||||
this.selectedCategory = filters.category || '';
|
||||
this.selectedSubcategory = filters.subcategory || '';
|
||||
this.selectedCountry = filters.country || 'ch';
|
||||
this.selectedRadius = filters.radius || 50;
|
||||
this.useCurrentLocation = filters.useCurrentLocation || false;
|
||||
this.searchQuery = filters.query || '';
|
||||
} catch (e) {
|
||||
this.resetFilters();
|
||||
}
|
||||
} else {
|
||||
this.resetFilters();
|
||||
}
|
||||
}
|
||||
|
||||
resetFilters() {
|
||||
this.selectedCategory = '';
|
||||
this.selectedSubcategory = '';
|
||||
this.selectedCountry = 'ch';
|
||||
this.selectedRadius = 50;
|
||||
this.useCurrentLocation = false;
|
||||
this.searchQuery = '';
|
||||
this.geoLoading = false;
|
||||
this.currentLat = null;
|
||||
this.currentLng = null;
|
||||
}
|
||||
|
||||
saveFiltersToStorage() {
|
||||
const filters = {
|
||||
category: this.selectedCategory,
|
||||
subcategory: this.selectedSubcategory,
|
||||
country: this.selectedCountry,
|
||||
radius: this.selectedRadius,
|
||||
useCurrentLocation: this.useCurrentLocation,
|
||||
query: this.searchQuery
|
||||
};
|
||||
localStorage.setItem('searchFilters', JSON.stringify(filters));
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Override from attributes if provided
|
||||
if (this.hasAttribute('category')) {
|
||||
this.selectedCategory = this.getAttribute('category');
|
||||
}
|
||||
if (this.hasAttribute('subcategory')) {
|
||||
this.selectedSubcategory = this.getAttribute('subcategory');
|
||||
}
|
||||
if (this.hasAttribute('country')) {
|
||||
this.selectedCountry = this.getAttribute('country');
|
||||
}
|
||||
if (this.hasAttribute('query')) {
|
||||
this.searchQuery = this.getAttribute('query');
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
this.unsubscribe = i18n.subscribe(() => {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.unsubscribe) this.unsubscribe();
|
||||
if (this._closeDropdown) {
|
||||
document.removeEventListener('click', this._closeDropdown);
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (oldValue === newValue) return;
|
||||
|
||||
switch (name) {
|
||||
case 'category':
|
||||
this.selectedCategory = newValue || '';
|
||||
break;
|
||||
case 'subcategory':
|
||||
this.selectedSubcategory = newValue || '';
|
||||
break;
|
||||
case 'country':
|
||||
this.selectedCountry = newValue || 'ch';
|
||||
break;
|
||||
case 'query':
|
||||
this.searchQuery = newValue || '';
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.isConnected) {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
get isCompact() {
|
||||
return this.hasAttribute('compact');
|
||||
}
|
||||
|
||||
render() {
|
||||
const compact = this.isCompact;
|
||||
|
||||
this.innerHTML = /* html */ `
|
||||
<form class="search-box ${compact ? 'search-box--compact' : ''}" id="search-form">
|
||||
<div class="search-row search-row-query">
|
||||
<div class="search-field search-field-query">
|
||||
<svg class="field-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input type="text" id="search-query" placeholder="${t('header.searchPlaceholder')}" value="${this.searchQuery}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${!compact ? this.renderFilters() : ''}
|
||||
|
||||
<div class="search-row search-row-submit">
|
||||
<button type="submit" class="btn btn-primary btn-search">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<span class="btn-search-text">${t('search.searchButton')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFilters() {
|
||||
// Track which category accordion is expanded
|
||||
this._expandedCategory = this._expandedCategory || '';
|
||||
|
||||
return /* html */ `
|
||||
<!-- Accordion Category Dropdown -->
|
||||
<div class="search-row search-row-filters">
|
||||
<div class="category-dropdown">
|
||||
<button type="button" class="category-dropdown-trigger" id="category-trigger">
|
||||
<span class="category-dropdown-label">
|
||||
${this.selectedCategory
|
||||
? (this.selectedSubcategory
|
||||
? `${t(`categories.${this.selectedCategory}`)} › ${t(`subcategories.${this.selectedSubcategory}`)}`
|
||||
: t(`categories.${this.selectedCategory}`))
|
||||
: t('search.allCategories')}
|
||||
</span>
|
||||
<svg class="category-dropdown-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="category-menu" id="category-menu">
|
||||
<div class="category-menu-inner">
|
||||
<button type="button" class="category-item category-item--all ${!this.selectedCategory ? 'active' : ''}" data-category="" data-subcategory="">
|
||||
${t('search.allCategories')}
|
||||
</button>
|
||||
${Object.keys(CATEGORIES).map(cat => `
|
||||
<div class="category-accordion ${this._expandedCategory === cat ? 'expanded' : ''}">
|
||||
<button type="button" class="category-item ${this.selectedCategory === cat ? 'active' : ''}" data-category="${cat}">
|
||||
<span>${t(`categories.${cat}`)}</span>
|
||||
<svg class="category-item-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="subcategory-list">
|
||||
<button type="button" class="subcategory-item ${this.selectedCategory === cat && !this.selectedSubcategory ? 'active' : ''}" data-category="${cat}" data-subcategory="">
|
||||
${t('search.allIn')} ${t(`categories.${cat}`)}
|
||||
</button>
|
||||
${CATEGORIES[cat].map(sub => `
|
||||
<button type="button" class="subcategory-item ${this.selectedCategory === cat && this.selectedSubcategory === sub ? 'active' : ''}" data-category="${cat}" data-subcategory="${sub}">
|
||||
${t(`subcategories.${sub}`)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Location (inline on desktop) -->
|
||||
<div class="filter-location">
|
||||
<div class="search-field search-field-country">
|
||||
<svg class="field-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
<select id="country-select">
|
||||
<option value="current" ${this.useCurrentLocation ? 'selected' : ''}>
|
||||
📍 ${t('search.currentLocation')}
|
||||
</option>
|
||||
${COUNTRIES.map(c => `
|
||||
<option value="${c}" ${!this.useCurrentLocation && this.selectedCountry === c ? 'selected' : ''}>
|
||||
${t(`countries.${c}`)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radius (inline on desktop) -->
|
||||
<div class="filter-radius ${!this.useCurrentLocation ? 'hidden' : ''}">
|
||||
<div class="search-field search-field-radius">
|
||||
<select id="radius-select">
|
||||
${RADIUS_OPTIONS.map(r => `
|
||||
<option value="${r}" ${this.selectedRadius === r ? 'selected' : ''}>
|
||||
${r} km
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile only: Location row -->
|
||||
<div class="search-row search-row-location mobile-only">
|
||||
<div class="search-field search-field-country">
|
||||
<svg class="field-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
<select id="country-select-mobile">
|
||||
<option value="current" ${this.useCurrentLocation ? 'selected' : ''}>
|
||||
📍 ${t('search.currentLocation')}
|
||||
</option>
|
||||
${COUNTRIES.map(c => `
|
||||
<option value="${c}" ${!this.useCurrentLocation && this.selectedCountry === c ? 'selected' : ''}>
|
||||
${t(`countries.${c}`)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile only: Radius row -->
|
||||
<div class="search-row search-row-radius mobile-only ${!this.useCurrentLocation ? 'hidden' : ''}">
|
||||
<div class="search-field search-field-radius">
|
||||
<select id="radius-select-mobile">
|
||||
${RADIUS_OPTIONS.map(r => `
|
||||
<option value="${r}" ${this.selectedRadius === r ? 'selected' : ''}>
|
||||
${r} km
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const form = this.querySelector('#search-form');
|
||||
const queryInput = this.querySelector('#search-query');
|
||||
|
||||
// Desktop selects
|
||||
const countrySelect = this.querySelector('#country-select');
|
||||
const radiusSelect = this.querySelector('#radius-select');
|
||||
|
||||
// Mobile selects
|
||||
const countrySelectMobile = this.querySelector('#country-select-mobile');
|
||||
const radiusSelectMobile = this.querySelector('#radius-select-mobile');
|
||||
|
||||
// Accordion dropdown
|
||||
const categoryTrigger = this.querySelector('#category-trigger');
|
||||
const categoryMenu = this.querySelector('#category-menu');
|
||||
|
||||
form?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleSearch();
|
||||
});
|
||||
|
||||
queryInput?.addEventListener('input', (e) => {
|
||||
this.searchQuery = e.target.value;
|
||||
});
|
||||
|
||||
// Toggle dropdown
|
||||
if (categoryTrigger && categoryMenu) {
|
||||
categoryTrigger.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
categoryMenu.classList.toggle('open');
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdown on outside click
|
||||
this._closeDropdown = (e) => {
|
||||
if (!this.contains(e.target)) {
|
||||
categoryMenu?.classList.remove('open');
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', this._closeDropdown);
|
||||
|
||||
// Category accordion headers - toggle expand
|
||||
this.querySelectorAll('.category-accordion > .category-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const cat = item.dataset.category;
|
||||
const accordion = item.closest('.category-accordion');
|
||||
|
||||
// Toggle this accordion
|
||||
if (this._expandedCategory === cat) {
|
||||
this._expandedCategory = '';
|
||||
accordion?.classList.remove('expanded');
|
||||
} else {
|
||||
// Close other accordions
|
||||
this.querySelectorAll('.category-accordion.expanded').forEach(el => {
|
||||
el.classList.remove('expanded');
|
||||
});
|
||||
this._expandedCategory = cat;
|
||||
accordion?.classList.add('expanded');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// "All categories" button
|
||||
this.querySelector('.category-item--all')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.selectedCategory = '';
|
||||
this.selectedSubcategory = '';
|
||||
this._expandedCategory = '';
|
||||
this.saveFiltersToStorage();
|
||||
categoryMenu?.classList.remove('open');
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
});
|
||||
|
||||
// Subcategory items - select category + subcategory
|
||||
this.querySelectorAll('.subcategory-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.selectedCategory = item.dataset.category;
|
||||
this.selectedSubcategory = item.dataset.subcategory;
|
||||
this._expandedCategory = '';
|
||||
this.saveFiltersToStorage();
|
||||
categoryMenu?.classList.remove('open');
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
});
|
||||
});
|
||||
|
||||
// Country select handler (both desktop and mobile)
|
||||
const handleCountryChange = (e) => {
|
||||
if (e.target.value === 'current') {
|
||||
this.useCurrentLocation = true;
|
||||
this.requestGeolocation();
|
||||
} else {
|
||||
this.useCurrentLocation = false;
|
||||
this.selectedCountry = e.target.value;
|
||||
}
|
||||
this.saveFiltersToStorage();
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
};
|
||||
|
||||
countrySelect?.addEventListener('change', handleCountryChange);
|
||||
countrySelectMobile?.addEventListener('change', handleCountryChange);
|
||||
|
||||
// Radius select handler (both desktop and mobile)
|
||||
const handleRadiusChange = (e) => {
|
||||
this.selectedRadius = parseInt(e.target.value);
|
||||
this.saveFiltersToStorage();
|
||||
};
|
||||
|
||||
radiusSelect?.addEventListener('change', handleRadiusChange);
|
||||
radiusSelectMobile?.addEventListener('change', handleRadiusChange);
|
||||
|
||||
// Adjust select width to selected option (desktop only)
|
||||
this.adjustSelectWidth(countrySelect);
|
||||
this.adjustSelectWidth(radiusSelect);
|
||||
}
|
||||
|
||||
adjustSelectWidth(select) {
|
||||
if (!select) return;
|
||||
|
||||
// Only apply fixed width on desktop (768px+)
|
||||
if (window.innerWidth < 768) {
|
||||
select.style.width = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create hidden span to measure text width
|
||||
const measurer = document.createElement('span');
|
||||
measurer.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit;';
|
||||
select.parentElement.appendChild(measurer);
|
||||
|
||||
const selectedOption = select.options[select.selectedIndex];
|
||||
measurer.textContent = selectedOption ? selectedOption.textContent : '';
|
||||
|
||||
// Add padding for arrow, icon and buffer
|
||||
select.style.width = (measurer.offsetWidth + 90) + 'px';
|
||||
measurer.remove();
|
||||
}
|
||||
|
||||
handleSearch() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (this.searchQuery) params.set('q', this.searchQuery);
|
||||
if (this.selectedCategory) params.set('category', this.selectedCategory);
|
||||
if (this.selectedSubcategory) params.set('sub', this.selectedSubcategory);
|
||||
|
||||
if (this.useCurrentLocation && this.currentLat && this.currentLng) {
|
||||
params.set('lat', this.currentLat);
|
||||
params.set('lng', this.currentLng);
|
||||
params.set('radius', this.selectedRadius);
|
||||
} else if (!this.useCurrentLocation) {
|
||||
params.set('country', this.selectedCountry);
|
||||
}
|
||||
|
||||
this.saveFiltersToStorage();
|
||||
|
||||
// Emit custom event
|
||||
const event = new CustomEvent('search', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
detail: {
|
||||
query: this.searchQuery,
|
||||
category: this.selectedCategory,
|
||||
subcategory: this.selectedSubcategory,
|
||||
country: this.selectedCountry,
|
||||
useCurrentLocation: this.useCurrentLocation,
|
||||
lat: this.currentLat,
|
||||
lng: this.currentLng,
|
||||
radius: this.selectedRadius,
|
||||
params: params.toString()
|
||||
}
|
||||
});
|
||||
|
||||
const cancelled = !this.dispatchEvent(event);
|
||||
|
||||
// Navigate to search page unless event was cancelled
|
||||
if (!cancelled && !this.hasAttribute('no-navigate')) {
|
||||
const url = '#/search' + (params.toString() ? '?' + params.toString() : '');
|
||||
window.location.hash = url;
|
||||
}
|
||||
}
|
||||
|
||||
requestGeolocation() {
|
||||
if (!('geolocation' in navigator)) {
|
||||
this.handleGeoError();
|
||||
return;
|
||||
}
|
||||
|
||||
this.geoLoading = true;
|
||||
this.updateGeoButton();
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
this.currentLat = position.coords.latitude;
|
||||
this.currentLng = position.coords.longitude;
|
||||
this.geoLoading = false;
|
||||
this.updateGeoButton();
|
||||
},
|
||||
(error) => {
|
||||
console.warn('Geolocation error:', error);
|
||||
this.handleGeoError();
|
||||
},
|
||||
{ timeout: 10000, enableHighAccuracy: false }
|
||||
);
|
||||
}
|
||||
|
||||
handleGeoError() {
|
||||
// Keep useCurrentLocation = true, just stop loading indicator
|
||||
// User can still search by current location (backend will handle it)
|
||||
this.geoLoading = false;
|
||||
this.updateGeoButton();
|
||||
}
|
||||
|
||||
updateGeoButton() {
|
||||
const countrySelect = this.querySelector('#country-select');
|
||||
if (!countrySelect) return;
|
||||
|
||||
if (this.geoLoading) {
|
||||
countrySelect.disabled = true;
|
||||
const currentOption = countrySelect.querySelector('option[value="current"]');
|
||||
if (currentOption) {
|
||||
currentOption.textContent = `⏳ ${t('search.locating')}`;
|
||||
}
|
||||
} else {
|
||||
countrySelect.disabled = false;
|
||||
const currentOption = countrySelect.querySelector('option[value="current"]');
|
||||
if (currentOption) {
|
||||
currentOption.textContent = `📍 ${t('search.currentLocation')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
getFilters() {
|
||||
return {
|
||||
query: this.searchQuery,
|
||||
category: this.selectedCategory,
|
||||
subcategory: this.selectedSubcategory,
|
||||
country: this.selectedCountry,
|
||||
useCurrentLocation: this.useCurrentLocation,
|
||||
lat: this.currentLat,
|
||||
lng: this.currentLng,
|
||||
radius: this.selectedRadius
|
||||
};
|
||||
}
|
||||
|
||||
setFilters(filters) {
|
||||
if (filters.query !== undefined) this.searchQuery = filters.query;
|
||||
if (filters.category !== undefined) this.selectedCategory = filters.category;
|
||||
if (filters.subcategory !== undefined) this.selectedSubcategory = filters.subcategory;
|
||||
if (filters.country !== undefined) this.selectedCountry = filters.country;
|
||||
if (filters.radius !== undefined) this.selectedRadius = filters.radius;
|
||||
if (filters.useCurrentLocation !== undefined) this.useCurrentLocation = filters.useCurrentLocation;
|
||||
|
||||
this.saveFiltersToStorage();
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.resetFilters();
|
||||
localStorage.removeItem('searchFilters');
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('search-box', SearchBox);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = /* css */ `
|
||||
search-box {
|
||||
display: block;
|
||||
}
|
||||
|
||||
search-box .search-box {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
background: var(--color-bg);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
search-box .search-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
search-box .search-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
search-box .search-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
search-box .search-field + .search-field {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
search-box .search-field.hidden,
|
||||
search-box .search-row.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
search-box .field-icon {
|
||||
position: absolute;
|
||||
left: var(--space-md);
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
search-box .search-field input,
|
||||
search-box .search-field select {
|
||||
width: 100%;
|
||||
padding: var(--space-md);
|
||||
padding-left: 2.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
search-box .search-field select {
|
||||
padding-left: var(--space-md);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B5B95' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--space-md) center;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
search-box .search-field-country select {
|
||||
padding-left: 2.75rem;
|
||||
}
|
||||
|
||||
search-box .search-field input:focus,
|
||||
search-box .search-field select:focus {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
search-box .search-field input:focus-visible,
|
||||
search-box .search-field select:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
search-box .search-field input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
search-box .search-row-submit {
|
||||
padding: var(--space-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
search-box .btn-search {
|
||||
width: 100%;
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Mobile: full width for location/radius */
|
||||
search-box .search-row-location .search-field,
|
||||
search-box .search-row-radius .search-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
search-box .search-row-location .search-field select,
|
||||
search-box .search-row-radius .search-field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile: hide desktop-only filter elements */
|
||||
search-box .filter-location,
|
||||
search-box .filter-radius {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Desktop: two-row layout */
|
||||
@media (min-width: 768px) {
|
||||
/* Hide mobile-only rows on desktop */
|
||||
search-box .mobile-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show inline filters on desktop */
|
||||
search-box .filter-location,
|
||||
search-box .filter-radius {
|
||||
display: flex;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
search-box .filter-radius.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
search-box .filter-location .search-field,
|
||||
search-box .filter-radius .search-field {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
search-box .filter-location select,
|
||||
search-box .filter-radius select {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
search-box .search-box:not(.search-box--compact) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
search-box .search-box:not(.search-box--compact) .search-row {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Row 1: Search field + Button */
|
||||
search-box .search-box:not(.search-box--compact) .search-row-query {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
search-box .search-box:not(.search-box--compact) .search-row-submit {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
padding: var(--space-xs);
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Row 2: Filters */
|
||||
search-box .search-box:not(.search-box--compact) .search-row-filters {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
search-box .search-box:not(.search-box--compact) .btn-search {
|
||||
width: auto;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
search-box .search-box:not(.search-box--compact) .btn-search-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Category truncation */
|
||||
search-box .search-box:not(.search-box--compact) .category-dropdown-label {
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Category Dropdown (Desktop) */
|
||||
search-box .category-dropdown {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
search-box .category-dropdown-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
search-box .category-dropdown-trigger:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
search-box .category-dropdown-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
search-box .category-dropdown-arrow {
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--space-sm);
|
||||
color: var(--color-text-muted);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
search-box .category-menu.open + .category-dropdown-arrow,
|
||||
search-box .category-dropdown-trigger:focus .category-dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
search-box .category-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition: all var(--transition-fast);
|
||||
z-index: var(--z-dropdown);
|
||||
max-width: calc(100vw - 2 * var(--space-md));
|
||||
}
|
||||
|
||||
search-box .category-menu.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
search-box .category-menu-inner {
|
||||
padding: var(--space-xs);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
search-box .category-item,
|
||||
search-box .category-item--all {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
search-box .category-item span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
search-box .category-item:hover,
|
||||
search-box .category-item--all:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
search-box .category-item.active,
|
||||
search-box .category-item--all.active {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
search-box .category-item-arrow {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-muted);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Accordion */
|
||||
search-box .category-accordion {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
search-box .category-accordion:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
search-box .category-accordion .category-item-arrow {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
search-box .category-accordion.expanded .category-item-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
search-box .subcategory-list {
|
||||
display: none;
|
||||
padding: 0 var(--space-xs) var(--space-xs);
|
||||
}
|
||||
|
||||
search-box .category-accordion.expanded .subcategory-list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
search-box .subcategory-item {
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
padding-left: var(--space-xl);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
search-box .subcategory-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
search-box .subcategory-item.active {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Compact mode */
|
||||
search-box .search-box--compact {
|
||||
max-width: 500px;
|
||||
display: flex;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
search-box .search-box--compact .search-row {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
search-box .search-box--compact .search-row-query {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
search-box .search-box--compact .search-row-submit {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--space-xs);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
search-box .search-box--compact .btn-search {
|
||||
width: auto;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
search-box .search-box--compact .btn-search-text {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
export { SearchBox, CATEGORIES, COUNTRIES, RADIUS_OPTIONS };
|
||||
136
js/i18n.js
Normal file
136
js/i18n.js
Normal file
@@ -0,0 +1,136 @@
|
||||
class I18n {
|
||||
constructor() {
|
||||
this.translations = {};
|
||||
this.currentLocale = 'de';
|
||||
this.fallbackLocale = 'de';
|
||||
this.supportedLocales = ['de', 'en', 'fr'];
|
||||
this.subscribers = new Set();
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
const savedLocale = localStorage.getItem('locale');
|
||||
const browserLocale = navigator.language.split('-')[0];
|
||||
|
||||
this.currentLocale = savedLocale
|
||||
|| (this.supportedLocales.includes(browserLocale) ? browserLocale : this.fallbackLocale);
|
||||
|
||||
await this.loadTranslations(this.currentLocale);
|
||||
this.loaded = true;
|
||||
this.updateDOM();
|
||||
}
|
||||
|
||||
async loadTranslations(locale) {
|
||||
if (this.translations[locale]) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/locales/${locale}.json`);
|
||||
if (!response.ok) throw new Error(`Failed to load ${locale}`);
|
||||
this.translations[locale] = await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Failed to load translations for ${locale}:`, error);
|
||||
if (locale !== this.fallbackLocale) {
|
||||
await this.loadTranslations(this.fallbackLocale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setLocale(locale) {
|
||||
if (!this.supportedLocales.includes(locale)) {
|
||||
console.warn(`Locale ${locale} is not supported`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadTranslations(locale);
|
||||
this.currentLocale = locale;
|
||||
localStorage.setItem('locale', locale);
|
||||
document.documentElement.lang = locale;
|
||||
|
||||
this.updateDOM();
|
||||
this.notifySubscribers();
|
||||
}
|
||||
|
||||
getLocale() {
|
||||
return this.currentLocale;
|
||||
}
|
||||
|
||||
t(key, params = {}) {
|
||||
const translations = this.translations[this.currentLocale]
|
||||
|| this.translations[this.fallbackLocale]
|
||||
|| {};
|
||||
|
||||
let text = this.getNestedValue(translations, key);
|
||||
|
||||
if (text === undefined) {
|
||||
console.warn(`Missing translation: ${key}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
Object.entries(params).forEach(([param, value]) => {
|
||||
text = text.replace(new RegExp(`{{${param}}}`, 'g'), value);
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
getNestedValue(obj, path) {
|
||||
return path.split('.').reduce((current, key) => {
|
||||
return current && current[key] !== undefined ? current[key] : undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
updateDOM() {
|
||||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
let params = {};
|
||||
if (el.dataset.i18nParams) {
|
||||
try {
|
||||
params = JSON.parse(el.dataset.i18nParams);
|
||||
} catch (e) {
|
||||
console.warn(`Invalid i18n params for key "${key}":`, e);
|
||||
}
|
||||
}
|
||||
el.textContent = this.t(key, params);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = this.t(key);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-title]').forEach((el) => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = this.t(key);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
|
||||
const key = el.getAttribute('data-i18n-aria');
|
||||
el.setAttribute('aria-label', this.t(key));
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(callback) {
|
||||
this.subscribers.add(callback);
|
||||
return () => this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
notifySubscribers() {
|
||||
this.subscribers.forEach(callback => callback(this.currentLocale));
|
||||
}
|
||||
|
||||
getSupportedLocales() {
|
||||
return this.supportedLocales;
|
||||
}
|
||||
|
||||
getLocaleDisplayName(locale) {
|
||||
const names = {
|
||||
de: 'Deutsch',
|
||||
en: 'English',
|
||||
fr: 'Français'
|
||||
};
|
||||
return names[locale] || locale;
|
||||
}
|
||||
}
|
||||
|
||||
export const i18n = new I18n();
|
||||
export const t = (key, params) => i18n.t(key, params);
|
||||
155
js/router.js
Normal file
155
js/router.js
Normal file
@@ -0,0 +1,155 @@
|
||||
class Router {
|
||||
constructor() {
|
||||
this.routes = new Map();
|
||||
this.currentRoute = null;
|
||||
this.outlet = null;
|
||||
this.beforeNavigate = null;
|
||||
this.afterNavigate = null;
|
||||
|
||||
window.addEventListener('hashchange', () => this.handleRouteChange());
|
||||
}
|
||||
|
||||
setOutlet(element) {
|
||||
this.outlet = element;
|
||||
}
|
||||
|
||||
register(path, componentTag) {
|
||||
this.routes.set(path, componentTag);
|
||||
return this;
|
||||
}
|
||||
|
||||
parseHash() {
|
||||
const hash = window.location.hash.slice(1) || '/';
|
||||
const [path, queryString] = hash.split('?');
|
||||
const params = new URLSearchParams(queryString || '');
|
||||
|
||||
return { path, params: Object.fromEntries(params) };
|
||||
}
|
||||
|
||||
matchRoute(path) {
|
||||
if (this.routes.has(path)) {
|
||||
return { componentTag: this.routes.get(path), params: {} };
|
||||
}
|
||||
|
||||
for (const [routePath, componentTag] of this.routes) {
|
||||
const routeParts = routePath.split('/');
|
||||
const pathParts = path.split('/');
|
||||
|
||||
if (routeParts.length !== pathParts.length) continue;
|
||||
|
||||
const params = {};
|
||||
let match = true;
|
||||
|
||||
for (let i = 0; i < routeParts.length; i++) {
|
||||
if (routeParts[i].startsWith(':')) {
|
||||
params[routeParts[i].slice(1)] = pathParts[i];
|
||||
} else if (routeParts[i] !== pathParts[i]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
return { componentTag, params };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async handleRouteChange() {
|
||||
const { path, params: queryParams } = this.parseHash();
|
||||
const match = this.matchRoute(path);
|
||||
|
||||
if (this.beforeNavigate) {
|
||||
const shouldContinue = await this.beforeNavigate(path);
|
||||
if (!shouldContinue) return;
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
this.renderNotFound();
|
||||
return;
|
||||
}
|
||||
|
||||
const { componentTag, params: routeParams } = match;
|
||||
|
||||
this.currentRoute = {
|
||||
path,
|
||||
params: { ...routeParams, ...queryParams },
|
||||
componentTag
|
||||
};
|
||||
|
||||
this.render();
|
||||
|
||||
if (this.afterNavigate) {
|
||||
this.afterNavigate(this.currentRoute);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.outlet || !this.currentRoute) return;
|
||||
|
||||
const { componentTag, params } = this.currentRoute;
|
||||
const oldComponent = this.outlet.firstElementChild;
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const component = document.createElement(componentTag);
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
component.setAttribute(`data-${key}`, value);
|
||||
});
|
||||
|
||||
if (prefersReducedMotion || !oldComponent) {
|
||||
if (oldComponent) oldComponent.remove();
|
||||
this.outlet.appendChild(component);
|
||||
return;
|
||||
}
|
||||
|
||||
component.classList.add('animate__animated', 'animate__fadeIn', 'animate__faster');
|
||||
oldComponent.classList.add('animate__animated', 'animate__fadeOut', 'animate__faster');
|
||||
|
||||
const fallbackTimer = setTimeout(() => {
|
||||
oldComponent.remove();
|
||||
this.outlet.appendChild(component);
|
||||
}, 300);
|
||||
|
||||
oldComponent.addEventListener('animationend', () => {
|
||||
clearTimeout(fallbackTimer);
|
||||
oldComponent.remove();
|
||||
this.outlet.appendChild(component);
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
renderNotFound() {
|
||||
if (!this.outlet) return;
|
||||
|
||||
this.outlet.innerHTML = '';
|
||||
const notFound = document.createElement('page-not-found');
|
||||
this.outlet.appendChild(notFound);
|
||||
}
|
||||
|
||||
navigate(path, params = {}) {
|
||||
let url = `#${path}`;
|
||||
|
||||
if (Object.keys(params).length > 0) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
url += `?${queryString}`;
|
||||
}
|
||||
|
||||
window.location.hash = url.slice(1);
|
||||
}
|
||||
|
||||
back() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
forward() {
|
||||
window.history.forward();
|
||||
}
|
||||
|
||||
getCurrentRoute() {
|
||||
return this.currentRoute;
|
||||
}
|
||||
}
|
||||
|
||||
export const router = new Router();
|
||||
138
js/services/api.js
Normal file
138
js/services/api.js
Normal file
@@ -0,0 +1,138 @@
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
class ApiService {
|
||||
constructor() {
|
||||
this.baseUrl = API_BASE_URL;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
clearToken() {
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new ApiError(response.status, error.message || 'Request failed', error);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error;
|
||||
throw new ApiError(0, 'Network error', { originalError: error });
|
||||
}
|
||||
}
|
||||
|
||||
async get(endpoint, params = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
|
||||
return this.request(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
async post(endpoint, data) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async put(endpoint, data) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async patch(endpoint, data) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async delete(endpoint) {
|
||||
return this.request(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async getListings(params = {}) {
|
||||
return this.get('/items/listings', params);
|
||||
}
|
||||
|
||||
async getListing(id) {
|
||||
return this.get(`/items/listings/${id}`);
|
||||
}
|
||||
|
||||
async createListing(data) {
|
||||
return this.post('/items/listings', data);
|
||||
}
|
||||
|
||||
async updateListing(id, data) {
|
||||
return this.patch(`/items/listings/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteListing(id) {
|
||||
return this.delete(`/items/listings/${id}`);
|
||||
}
|
||||
|
||||
async searchListings(query, params = {}) {
|
||||
return this.get('/items/listings', { search: query, ...params });
|
||||
}
|
||||
|
||||
async getCategories() {
|
||||
return this.get('/items/categories');
|
||||
}
|
||||
|
||||
async uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/files`, {
|
||||
method: 'POST',
|
||||
headers: this.token ? { 'Authorization': `Bearer ${this.token}` } : {},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, 'Upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, message, data = {}) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiService();
|
||||
export { ApiError };
|
||||
88
js/utils/helpers.js
Normal file
88
js/utils/helpers.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS
|
||||
* Use for any user-generated content rendered via innerHTML
|
||||
* @param {string} str - Untrusted string
|
||||
* @returns {string} - Escaped string safe for innerHTML
|
||||
*/
|
||||
export function escapeHTML(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price with currency symbol
|
||||
* @param {number} price - Price value
|
||||
* @param {string} currency - Currency code (EUR, USD, CHF, XMR)
|
||||
* @returns {string} - Formatted price string
|
||||
*/
|
||||
export function formatPrice(price, currency = 'EUR') {
|
||||
if (price === null || price === undefined) return '–';
|
||||
|
||||
const symbols = {
|
||||
EUR: '€',
|
||||
USD: '$',
|
||||
CHF: 'CHF',
|
||||
XMR: 'ɱ'
|
||||
};
|
||||
|
||||
const symbol = symbols[currency] || currency;
|
||||
|
||||
if (currency === 'XMR') {
|
||||
return `${price.toFixed(4)} ${symbol}`;
|
||||
}
|
||||
|
||||
return `${symbol} ${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "vor 2 Stunden")
|
||||
* @param {Date|string} date - Date to format
|
||||
* @param {string} locale - Locale code
|
||||
* @returns {string} - Relative time string
|
||||
*/
|
||||
export function formatRelativeTime(date, locale = 'de') {
|
||||
const now = new Date();
|
||||
const then = new Date(date);
|
||||
const diffMs = now - then;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
|
||||
if (diffDay > 0) return rtf.format(-diffDay, 'day');
|
||||
if (diffHour > 0) return rtf.format(-diffHour, 'hour');
|
||||
if (diffMin > 0) return rtf.format(-diffMin, 'minute');
|
||||
return rtf.format(-diffSec, 'second');
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function calls
|
||||
* @param {Function} fn - Function to debounce
|
||||
* @param {number} delay - Delay in ms
|
||||
* @returns {Function} - Debounced function
|
||||
*/
|
||||
export function debounce(fn, delay = 300) {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate string with ellipsis
|
||||
* @param {string} str - String to truncate
|
||||
* @param {number} maxLength - Maximum length
|
||||
* @returns {string} - Truncated string
|
||||
*/
|
||||
export function truncate(str, maxLength = 100) {
|
||||
if (!str || str.length <= maxLength) return str;
|
||||
return str.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
126
locales/de.json
Normal file
126
locales/de.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"header": {
|
||||
"searchPlaceholder": "Was suchst du?",
|
||||
"createListing": "Anzeige erstellen",
|
||||
"toggleTheme": "Design wechseln",
|
||||
"selectLanguage": "Sprache auswählen"
|
||||
},
|
||||
"footer": {
|
||||
"rights": "Alle Rechte vorbehalten.",
|
||||
"about": "Über uns",
|
||||
"privacy": "Datenschutz",
|
||||
"terms": "AGB",
|
||||
"contact": "Kontakt"
|
||||
},
|
||||
"home": {
|
||||
"title": "Willkommen bei dgray",
|
||||
"subtitle": "Finde tolle Angebote in deiner Nähe oder verkaufe, was du nicht mehr brauchst.",
|
||||
"browseListings": "Anzeigen durchsuchen",
|
||||
"createListing": "Anzeige erstellen",
|
||||
"categories": "Kategorien",
|
||||
"recentListings": "Neueste Anzeigen",
|
||||
"placeholderTitle": "Beispielanzeige",
|
||||
"placeholderLocation": "Standort",
|
||||
"addFavorite": "Zu Favoriten hinzufügen",
|
||||
"removeFavorite": "Aus Favoriten entfernen"
|
||||
},
|
||||
"categories": {
|
||||
"electronics": "Elektronik",
|
||||
"furniture": "Möbel",
|
||||
"clothing": "Kleidung",
|
||||
"vehicles": "Fahrzeuge",
|
||||
"sports": "Sport & Freizeit",
|
||||
"books": "Bücher & Medien",
|
||||
"garden": "Garten",
|
||||
"other": "Sonstiges"
|
||||
},
|
||||
"search": {
|
||||
"title": "Suche",
|
||||
"placeholder": "Suchbegriff eingeben...",
|
||||
"allCategories": "Alle Kategorien",
|
||||
"allSubcategories": "Alle Unterkategorien",
|
||||
"currentLocation": "Aktueller Standort",
|
||||
"locating": "Standort wird ermittelt...",
|
||||
"searchButton": "Suchen",
|
||||
"loading": "Suche läuft...",
|
||||
"enterQuery": "Gib einen Suchbegriff ein, um Anzeigen zu finden.",
|
||||
"noResults": "Keine Ergebnisse gefunden. Versuche einen anderen Suchbegriff.",
|
||||
"resultsCount": "{{count}} Ergebnisse gefunden",
|
||||
"allIn": "Alles in"
|
||||
},
|
||||
"subcategories": {
|
||||
"phones": "Handy & Telefon",
|
||||
"computers": "Computer & Zubehör",
|
||||
"tv_audio": "TV & Audio",
|
||||
"gaming": "Gaming & Konsolen",
|
||||
"appliances": "Haushaltsgeräte",
|
||||
"cars": "Autos",
|
||||
"motorcycles": "Motorräder",
|
||||
"bikes": "Fahrräder",
|
||||
"parts": "Ersatzteile & Zubehör",
|
||||
"living": "Wohnzimmer",
|
||||
"bedroom": "Schlafzimmer",
|
||||
"office": "Büro",
|
||||
"outdoor_furniture": "Gartenmöbel",
|
||||
"women": "Damen",
|
||||
"men": "Herren",
|
||||
"kids": "Kinder",
|
||||
"shoes": "Schuhe",
|
||||
"accessories": "Accessoires",
|
||||
"fitness": "Fitness",
|
||||
"outdoor": "Outdoor",
|
||||
"winter": "Wintersport",
|
||||
"water": "Wassersport",
|
||||
"team_sports": "Mannschaftssport",
|
||||
"fiction": "Belletristik",
|
||||
"nonfiction": "Sachbücher",
|
||||
"textbooks": "Lehrbücher",
|
||||
"music_movies": "Musik & Filme",
|
||||
"plants": "Pflanzen",
|
||||
"tools": "Werkzeuge",
|
||||
"outdoor_living": "Outdoor-Living",
|
||||
"decoration": "Dekoration",
|
||||
"collectibles": "Sammlerstücke",
|
||||
"art": "Kunst",
|
||||
"handmade": "Handgemacht",
|
||||
"services": "Dienstleistungen"
|
||||
},
|
||||
"countries": {
|
||||
"ch": "Schweiz",
|
||||
"de": "Deutschland",
|
||||
"at": "Österreich",
|
||||
"fr": "Frankreich",
|
||||
"it": "Italien",
|
||||
"li": "Liechtenstein"
|
||||
},
|
||||
"listing": {
|
||||
"notFound": "Diese Anzeige wurde nicht gefunden.",
|
||||
"backHome": "Zur Startseite",
|
||||
"description": "Beschreibung",
|
||||
"seller": "Anbieter",
|
||||
"memberSince": "Mitglied seit",
|
||||
"contactSeller": "Anbieter kontaktieren"
|
||||
},
|
||||
"create": {
|
||||
"title": "Anzeige erstellen",
|
||||
"listingTitle": "Titel",
|
||||
"titlePlaceholder": "Was möchtest du verkaufen?",
|
||||
"category": "Kategorie",
|
||||
"selectCategory": "Kategorie wählen",
|
||||
"price": "Preis (€)",
|
||||
"location": "Standort",
|
||||
"locationPlaceholder": "Stadt, PLZ oder Adresse",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Beschreibe deinen Artikel ausführlich...",
|
||||
"images": "Bilder",
|
||||
"uploadImages": "Bilder hochladen",
|
||||
"cancel": "Abbrechen",
|
||||
"publish": "Veröffentlichen",
|
||||
"publishing": "Wird veröffentlicht..."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Seite nicht gefunden",
|
||||
"message": "Die gesuchte Seite existiert leider nicht.",
|
||||
"backHome": "Zur Startseite"
|
||||
}
|
||||
}
|
||||
126
locales/en.json
Normal file
126
locales/en.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"header": {
|
||||
"searchPlaceholder": "What are you looking for?",
|
||||
"createListing": "Create Listing",
|
||||
"toggleTheme": "Toggle theme",
|
||||
"selectLanguage": "Select language"
|
||||
},
|
||||
"footer": {
|
||||
"rights": "All rights reserved.",
|
||||
"about": "About",
|
||||
"privacy": "Privacy",
|
||||
"terms": "Terms",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"home": {
|
||||
"title": "Welcome to dgray",
|
||||
"subtitle": "Find great deals near you or sell what you no longer need.",
|
||||
"browseListings": "Browse Listings",
|
||||
"createListing": "Create Listing",
|
||||
"categories": "Categories",
|
||||
"recentListings": "Recent Listings",
|
||||
"placeholderTitle": "Sample Listing",
|
||||
"placeholderLocation": "Location",
|
||||
"addFavorite": "Add to favorites",
|
||||
"removeFavorite": "Remove from favorites"
|
||||
},
|
||||
"categories": {
|
||||
"electronics": "Electronics",
|
||||
"furniture": "Furniture",
|
||||
"clothing": "Clothing",
|
||||
"vehicles": "Vehicles",
|
||||
"sports": "Sports & Leisure",
|
||||
"books": "Books & Media",
|
||||
"garden": "Garden",
|
||||
"other": "Other"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"placeholder": "Enter search term...",
|
||||
"allCategories": "All Categories",
|
||||
"allSubcategories": "All Subcategories",
|
||||
"currentLocation": "Current Location",
|
||||
"locating": "Locating...",
|
||||
"searchButton": "Search",
|
||||
"loading": "Searching...",
|
||||
"enterQuery": "Enter a search term to find listings.",
|
||||
"noResults": "No results found. Try a different search term.",
|
||||
"resultsCount": "{{count}} results found",
|
||||
"allIn": "All in"
|
||||
},
|
||||
"subcategories": {
|
||||
"phones": "Phones & Tablets",
|
||||
"computers": "Computers & Accessories",
|
||||
"tv_audio": "TV & Audio",
|
||||
"gaming": "Gaming & Consoles",
|
||||
"appliances": "Appliances",
|
||||
"cars": "Cars",
|
||||
"motorcycles": "Motorcycles",
|
||||
"bikes": "Bicycles",
|
||||
"parts": "Parts & Accessories",
|
||||
"living": "Living Room",
|
||||
"bedroom": "Bedroom",
|
||||
"office": "Office",
|
||||
"outdoor_furniture": "Outdoor Furniture",
|
||||
"women": "Women",
|
||||
"men": "Men",
|
||||
"kids": "Kids",
|
||||
"shoes": "Shoes",
|
||||
"accessories": "Accessories",
|
||||
"fitness": "Fitness",
|
||||
"outdoor": "Outdoor",
|
||||
"winter": "Winter Sports",
|
||||
"water": "Water Sports",
|
||||
"team_sports": "Team Sports",
|
||||
"fiction": "Fiction",
|
||||
"nonfiction": "Non-Fiction",
|
||||
"textbooks": "Textbooks",
|
||||
"music_movies": "Music & Movies",
|
||||
"plants": "Plants",
|
||||
"tools": "Tools",
|
||||
"outdoor_living": "Outdoor Living",
|
||||
"decoration": "Decoration",
|
||||
"collectibles": "Collectibles",
|
||||
"art": "Art",
|
||||
"handmade": "Handmade",
|
||||
"services": "Services"
|
||||
},
|
||||
"countries": {
|
||||
"ch": "Switzerland",
|
||||
"de": "Germany",
|
||||
"at": "Austria",
|
||||
"fr": "France",
|
||||
"it": "Italy",
|
||||
"li": "Liechtenstein"
|
||||
},
|
||||
"listing": {
|
||||
"notFound": "This listing was not found.",
|
||||
"backHome": "Back to Home",
|
||||
"description": "Description",
|
||||
"seller": "Seller",
|
||||
"memberSince": "Member since",
|
||||
"contactSeller": "Contact Seller"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Listing",
|
||||
"listingTitle": "Title",
|
||||
"titlePlaceholder": "What do you want to sell?",
|
||||
"category": "Category",
|
||||
"selectCategory": "Select category",
|
||||
"price": "Price (€)",
|
||||
"location": "Location",
|
||||
"locationPlaceholder": "City, ZIP or address",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe your item in detail...",
|
||||
"images": "Images",
|
||||
"uploadImages": "Upload images",
|
||||
"cancel": "Cancel",
|
||||
"publish": "Publish",
|
||||
"publishing": "Publishing..."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page Not Found",
|
||||
"message": "The page you are looking for does not exist.",
|
||||
"backHome": "Back to Home"
|
||||
}
|
||||
}
|
||||
126
locales/fr.json
Normal file
126
locales/fr.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"header": {
|
||||
"searchPlaceholder": "Que cherchez-vous ?",
|
||||
"createListing": "Créer une annonce",
|
||||
"toggleTheme": "Changer de thème",
|
||||
"selectLanguage": "Choisir la langue"
|
||||
},
|
||||
"footer": {
|
||||
"rights": "Tous droits réservés.",
|
||||
"about": "À propos",
|
||||
"privacy": "Confidentialité",
|
||||
"terms": "CGU",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"home": {
|
||||
"title": "Bienvenue sur dgray",
|
||||
"subtitle": "Trouvez de bonnes affaires près de chez vous ou vendez ce dont vous n'avez plus besoin.",
|
||||
"browseListings": "Parcourir les annonces",
|
||||
"createListing": "Créer une annonce",
|
||||
"categories": "Catégories",
|
||||
"recentListings": "Annonces récentes",
|
||||
"placeholderTitle": "Exemple d'annonce",
|
||||
"placeholderLocation": "Emplacement",
|
||||
"addFavorite": "Ajouter aux favoris",
|
||||
"removeFavorite": "Retirer des favoris"
|
||||
},
|
||||
"categories": {
|
||||
"electronics": "Électronique",
|
||||
"furniture": "Meubles",
|
||||
"clothing": "Vêtements",
|
||||
"vehicles": "Véhicules",
|
||||
"sports": "Sports & Loisirs",
|
||||
"books": "Livres & Médias",
|
||||
"garden": "Jardin",
|
||||
"other": "Autres"
|
||||
},
|
||||
"search": {
|
||||
"title": "Recherche",
|
||||
"placeholder": "Entrez un terme de recherche...",
|
||||
"allCategories": "Toutes les catégories",
|
||||
"allSubcategories": "Toutes les sous-catégories",
|
||||
"currentLocation": "Position actuelle",
|
||||
"locating": "Localisation en cours...",
|
||||
"searchButton": "Rechercher",
|
||||
"loading": "Recherche en cours...",
|
||||
"enterQuery": "Entrez un terme de recherche pour trouver des annonces.",
|
||||
"noResults": "Aucun résultat trouvé. Essayez un autre terme de recherche.",
|
||||
"resultsCount": "{{count}} résultats trouvés",
|
||||
"allIn": "Tout dans"
|
||||
},
|
||||
"subcategories": {
|
||||
"phones": "Téléphones & Tablettes",
|
||||
"computers": "Ordinateurs & Accessoires",
|
||||
"tv_audio": "TV & Audio",
|
||||
"gaming": "Jeux & Consoles",
|
||||
"appliances": "Électroménager",
|
||||
"cars": "Voitures",
|
||||
"motorcycles": "Motos",
|
||||
"bikes": "Vélos",
|
||||
"parts": "Pièces & Accessoires",
|
||||
"living": "Salon",
|
||||
"bedroom": "Chambre",
|
||||
"office": "Bureau",
|
||||
"outdoor_furniture": "Mobilier extérieur",
|
||||
"women": "Femmes",
|
||||
"men": "Hommes",
|
||||
"kids": "Enfants",
|
||||
"shoes": "Chaussures",
|
||||
"accessories": "Accessoires",
|
||||
"fitness": "Fitness",
|
||||
"outdoor": "Plein air",
|
||||
"winter": "Sports d'hiver",
|
||||
"water": "Sports nautiques",
|
||||
"team_sports": "Sports d'équipe",
|
||||
"fiction": "Fiction",
|
||||
"nonfiction": "Non-fiction",
|
||||
"textbooks": "Manuels scolaires",
|
||||
"music_movies": "Musique & Films",
|
||||
"plants": "Plantes",
|
||||
"tools": "Outils",
|
||||
"outdoor_living": "Vie extérieure",
|
||||
"decoration": "Décoration",
|
||||
"collectibles": "Objets de collection",
|
||||
"art": "Art",
|
||||
"handmade": "Fait main",
|
||||
"services": "Services"
|
||||
},
|
||||
"countries": {
|
||||
"ch": "Suisse",
|
||||
"de": "Allemagne",
|
||||
"at": "Autriche",
|
||||
"fr": "France",
|
||||
"it": "Italie",
|
||||
"li": "Liechtenstein"
|
||||
},
|
||||
"listing": {
|
||||
"notFound": "Cette annonce n'a pas été trouvée.",
|
||||
"backHome": "Retour à l'accueil",
|
||||
"description": "Description",
|
||||
"seller": "Vendeur",
|
||||
"memberSince": "Membre depuis",
|
||||
"contactSeller": "Contacter le vendeur"
|
||||
},
|
||||
"create": {
|
||||
"title": "Créer une annonce",
|
||||
"listingTitle": "Titre",
|
||||
"titlePlaceholder": "Que voulez-vous vendre ?",
|
||||
"category": "Catégorie",
|
||||
"selectCategory": "Choisir une catégorie",
|
||||
"price": "Prix (€)",
|
||||
"location": "Emplacement",
|
||||
"locationPlaceholder": "Ville, code postal ou adresse",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Décrivez votre article en détail...",
|
||||
"images": "Images",
|
||||
"uploadImages": "Télécharger des images",
|
||||
"cancel": "Annuler",
|
||||
"publish": "Publier",
|
||||
"publishing": "Publication en cours..."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page non trouvée",
|
||||
"message": "La page que vous recherchez n'existe pas.",
|
||||
"backHome": "Retour à l'accueil"
|
||||
}
|
||||
}
|
||||
71
manifest.json
Normal file
71
manifest.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "dgray - Kleinanzeigen",
|
||||
"short_name": "dgray",
|
||||
"description": "Deine lokale Kleinanzeigen-Plattform zum Tauschen und Handeln",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#2563eb",
|
||||
"orientation": "portrait-primary",
|
||||
"scope": "/",
|
||||
"lang": "de",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icons/icon-72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["shopping", "lifestyle"],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Anzeige erstellen",
|
||||
"short_name": "Erstellen",
|
||||
"url": "/#/create",
|
||||
"icons": [{ "src": "assets/icons/icon-96.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Suchen",
|
||||
"short_name": "Suche",
|
||||
"url": "/#/search",
|
||||
"icons": [{ "src": "assets/icons/icon-96.png", "sizes": "96x96" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
91
service-worker.js
Normal file
91
service-worker.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const CACHE_NAME = 'dgray-v19';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/css/fonts.css',
|
||||
'/css/variables.css',
|
||||
'/css/base.css',
|
||||
'/css/components.css',
|
||||
'/js/app.js',
|
||||
'/js/router.js',
|
||||
'/js/i18n.js',
|
||||
'/js/services/api.js',
|
||||
'/locales/de.json',
|
||||
'/locales/en.json',
|
||||
'/locales/fr.json',
|
||||
'/manifest.json',
|
||||
'/assets/fonts/Inter-Regular.woff2',
|
||||
'/assets/fonts/Inter-Medium.woff2',
|
||||
'/assets/fonts/Inter-SemiBold.woff2',
|
||||
'/assets/fonts/Inter-Bold.woff2',
|
||||
'/assets/fonts/SpaceGrotesk-Medium.woff2',
|
||||
'/assets/fonts/SpaceGrotesk-Bold.woff2'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
if (request.url.includes('/api/')) {
|
||||
event.respondWith(networkFirst(request));
|
||||
} else {
|
||||
event.respondWith(cacheFirst(request));
|
||||
}
|
||||
});
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return new Response('Offline', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
return new Response(JSON.stringify({ error: 'Offline' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user