initial commit

This commit is contained in:
2026-01-28 07:02:55 +01:00
commit 1ab026c35c
37 changed files with 4447 additions and 0 deletions

112
AGENTS.md Normal file
View 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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

16
assets/icons/icon.svg Normal file
View 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

File diff suppressed because one or more lines are too long

103
css/base.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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();

View 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">
&copy; ${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
View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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
View 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
View 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
View 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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 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
View 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
View 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
View 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
View 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
View 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' }
});
}
}