diff --git a/AGENTS.md b/AGENTS.md index 7df2d25..9310030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Dieses Dokument hilft AI-Assistenten (Amp, Copilot, etc.) das Projekt zu versteh **dgray.io** ist eine Kleinanzeigen-PWA mit Monero-Bezahlung. -- **Status**: Early Development (Frontend-only) +- **Status**: Active Development (Frontend + Directus Backend) - **Ziel**: Anonyme, dezentrale Marktplatz-Alternative ## Tech-Stack @@ -62,6 +62,7 @@ js/ docs/ ├── DIRECTUS-SETUP.md # Directus Backend Setup +├── DIRECTUS-SCHEMA.md # Collection-Strukturen & Permissions └── MONETIZATION.md # Monetarisierung & Anti-Abuse css/ @@ -114,22 +115,27 @@ locales/ ## Nächste Schritte -1. Seiten für Profil-Dropdown: `page-my-listings.js`, `page-messages.js`, `page-favorites.js`, `page-settings.js` -2. Payment-Integration mit BTCpay Server (https://pay.xmr.rocks/) -3. Reputation-System (5/15/50 Deals Stufen) -4. Suchseite (`page-search.js`) mit Filtern ausbauen +1. ~~Seiten für Profil-Dropdown~~ ✅ Fertig (`page-my-listings.js`, `page-messages.js`, `page-favorites.js`, `page-settings.js`) +2. ~~Suchseite mit Filtern~~ ✅ Merged in `page-home.js` +3. Payment-Integration mit BTCpay Server (https://pay.xmr.rocks/) +4. Reputation-System (5/15/50 Deals Stufen) +5. Push-Benachrichtigungen für neue Nachrichten ## Directus Berechtigungen (Public-Rolle) -| Collection | Read | Create | Hinweise | -|------------|------|--------|----------| -| `listings` | ✓ | ✓ | Nur `status=published` lesen | -| `listings_files` | ✓ | ✓ | Junction-Table für Bilder | -| `directus_files` | ✓ | ✓ | Für Assets/Bilder | -| `categories` | ✓ | - | Nur `status=published` | -| `categories_translations` | ✓ | - | Für i18n | -| `locations` | ✓ | - | Für Standort-Auswahl | -| `languages` | ✓ | - | Für Sprachen-Liste | +| Collection | Read | Create | Update | Hinweise | +|------------|------|--------|--------|----------| +| `listings` | ✓ | ✓ | - | Nur `status=published` lesen | +| `listings_files` | ✓ | ✓ | - | Junction-Table für Bilder | +| `directus_files` | ✓ | ✓ | - | Für Assets/Bilder | +| `categories` | ✓ | - | - | Nur `status=published` | +| `categories_translations` | ✓ | - | - | Für i18n | +| `locations` | ✓ | ✓ | - | User kann neue Orte anlegen | +| `languages` | ✓ | - | - | Für Sprachen-Liste | +| `conversations` | ✓ | ✓ | ✓ | Filter via `participant_hash`, Update nur `status` | +| `messages` | ✓ | ✓ | - | Filter via `conversation` ID | + +Siehe `docs/DIRECTUS-SCHEMA.md` für vollständiges Schema. ## Farbpalette diff --git a/README.md b/README.md index 63822d6..5881aac 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ dgray.io ermöglicht es Nutzern, Kleinanzeigen zu schalten und Waren/Dienstleist | Light/Dark Mode | Niedrig | ✅ Fertig | | i18n (DE/EN/FR) | Niedrig | ✅ Fertig | | Bildergalerie | Niedrig | ✅ Fertig | -| E2E-Chat | Hoch | 🔲 Vorbereitet | +| E2E-Chat (NaCl) | Hoch | ✅ Service fertig | | **Monero MultiSig** | **Sehr hoch** | 🔲 Offen | | Rating-System | Mittel | 🔲 Offen | | 2FA | Mittel | 🔲 Offen | @@ -148,7 +148,9 @@ dgray/ - [x] Such-Komponente mit Accordion-Kategorien - [x] Anzeige-Detailseite mit Bildergalerie - [x] Anzeige-Erstellen-Formular -- [ ] Suchseite mit Filtern +- [x] Suchseite mit Filtern (merged in Home) +- [x] Skeleton Loading, Error Boundary, Offline Indicator +- [x] Lazy Loading für Bilder - [ ] Responsive Optimierungen ### Phase 2: Backend-Integration ⬅️ **Aktuell** @@ -158,16 +160,18 @@ dgray/ - [x] User-Auth (UUID + SHA-256 Hash, anonym) - [x] Bilder-Upload (Junction-Table) - [x] API-Services (`directus.js`, `listings.js`, `categories.js`, `locations.js`) -- [ ] Directus Public-Berechtigungen vervollständigen +- [x] Directus Public-Berechtigungen (siehe `docs/DIRECTUS-SCHEMA.md`) +- [x] Neue Seiten: Favoriten, Meine Anzeigen, Nachrichten, Einstellungen ### Phase 3: Kommunikation -- [ ] Chat-System (E2E-verschlüsselt) -- [ ] Benachrichtigungen -- [ ] Merkliste +- [x] Chat-System (E2E-verschlüsselt mit NaCl) +- [x] Conversations/Messages Services +- [x] Merkliste (Favoriten-Seite) +- [ ] Benachrichtigungen (Push) ### Phase 4: Payments -- [ ] XMR-Kursabfrage API -- [ ] Fiat ↔ XMR Umrechnung +- [x] XMR-Kursabfrage API (CoinGecko) +- [x] Fiat ↔ XMR Umrechnung (Dual-Preis-Anzeige) - [ ] Wallet-Anbindung (monero-wallet-rpc) - [ ] MultiSig Escrow diff --git a/docs/DIRECTUS-SCHEMA.md b/docs/DIRECTUS-SCHEMA.md index 19cb73a..05f4c98 100644 --- a/docs/DIRECTUS-SCHEMA.md +++ b/docs/DIRECTUS-SCHEMA.md @@ -124,31 +124,40 @@ Verfügbare Sprachen. ## conversations -Chat-Konversationen zwischen Usern. +Zero-Knowledge Chat-Konversationen zwischen anonymen Usern. | Feld | Typ | Beschreibung | |------|-----|--------------| | `id` | UUID | Primary Key | -| `listing` | UUID | FK → listings | -| `buyer` | UUID | FK → directus_users (Interessent) | -| `seller` | UUID | FK → directus_users (Verkäufer) | +| `listing_id` | UUID | FK → listings | +| `participant_hash_1` | string(64) | SHA-256 Hash des ersten Teilnehmers (Käufer) | +| `participant_hash_2` | string(64) | SHA-256 Hash des zweiten Teilnehmers (Verkäufer) | +| `public_key_1` | text | NaCl Public Key Teilnehmer 1 | +| `public_key_2` | text | NaCl Public Key Teilnehmer 2 | +| `status` | string | `active`, `closed` | | `date_created` | datetime | Erstellungsdatum | | `date_updated` | datetime | Letzte Nachricht | +**Hinweis:** Die `participant_hash_*` Felder ermöglichen anonyme Zuordnung ohne Directus-User-Accounts. + --- ## messages -Nachrichten in Konversationen. +E2E-verschlüsselte Nachrichten in Konversationen. | Feld | Typ | Beschreibung | |------|-----|--------------| | `id` | UUID | Primary Key | | `conversation` | UUID | FK → conversations | -| `sender` | UUID | FK → directus_users | -| `content` | text | Verschlüsselter Inhalt | +| `sender_hash` | string(64) | SHA-256 Hash des Senders | +| `content_encrypted` | text | NaCl-verschlüsselter Inhalt | +| `nonce` | string | Nonce für Entschlüsselung | +| `type` | string | `text`, `image`, `system` | | `date_created` | datetime | Zeitstempel | +**Hinweis:** Nachrichten sind client-seitig E2E-verschlüsselt. Server sieht nur Ciphertext. + --- ## favorites @@ -191,7 +200,7 @@ Meldungen von Anzeigen. | `categories_translations` | ✓ | - | - | - | Für i18n | | `locations` | ✓ | ✓ | - | - | User kann neue Orte anlegen | | `languages` | ✓ | - | - | - | Für Sprach-Auswahl | -| `conversations` | ✓ | ✓ | - | - | Nur eigene | -| `messages` | ✓ | ✓ | - | - | Nur in eigenen Konversationen | +| `conversations` | ✓ | ✓ | ✓ | - | Filter via API-Query mit `participant_hash`, Update nur `status` | +| `messages` | ✓ | ✓ | - | - | Filter via `conversation` ID | | `favorites` | ✓ | ✓ | - | ✓ | Nur eigene | | `reports` | - | ✓ | - | - | Nur erstellen | diff --git a/js/components/app-shell.js b/js/components/app-shell.js index c47c87d..b729244 100644 --- a/js/components/app-shell.js +++ b/js/components/app-shell.js @@ -7,6 +7,10 @@ import './auth-modal.js' import './pages/page-home.js' import './pages/page-listing.js' import './pages/page-create.js' +import './pages/page-favorites.js' +import './pages/page-my-listings.js' +import './pages/page-messages.js' +import './pages/page-settings.js' import './pages/page-not-found.js' class AppShell extends HTMLElement { @@ -47,6 +51,10 @@ class AppShell extends HTMLElement { .register('/search', 'page-home') // Redirect search to home .register('/listing/:id', 'page-listing') .register('/create', 'page-create') + .register('/favorites', 'page-favorites') + .register('/my-listings', 'page-my-listings') + .register('/messages', 'page-messages') + .register('/settings', 'page-settings') router.handleRouteChange() } diff --git a/js/components/pages/page-favorites.js b/js/components/pages/page-favorites.js new file mode 100644 index 0000000..643b7e8 --- /dev/null +++ b/js/components/pages/page-favorites.js @@ -0,0 +1,171 @@ +import { t, i18n } from '../../i18n.js' +import { directus } from '../../services/directus.js' +import '../listing-card.js' +import '../skeleton-card.js' + +class PageFavorites extends HTMLElement { + constructor() { + super() + this.listings = [] + this.loading = true + this.error = null + } + + connectedCallback() { + this.render() + this.loadFavorites() + this.unsubscribe = i18n.subscribe(() => this.render()) + } + + disconnectedCallback() { + if (this.unsubscribe) this.unsubscribe() + } + + getFavoriteIds() { + return JSON.parse(localStorage.getItem('favorites') || '[]') + } + + async loadFavorites() { + const ids = this.getFavoriteIds() + + if (ids.length === 0) { + this.loading = false + this.listings = [] + this.updateContent() + return + } + + try { + const response = await directus.getListings({ + filter: { + id: { _in: ids }, + status: { _eq: 'published' } + }, + limit: 50 + }) + this.listings = response.items || [] + this.loading = false + this.updateContent() + } catch (err) { + console.error('Failed to load favorites:', err) + this.error = err.message + this.loading = false + this.updateContent() + } + } + + render() { + this.innerHTML = /* html */` +
+ + +
+ ${this.renderContent()} +
+
+ ` + } + + updateContent() { + const container = this.querySelector('#favorites-content') + if (container) { + container.innerHTML = this.renderContent() + } + } + + renderContent() { + if (this.loading) { + return Array(4).fill(0).map(() => '').join('') + } + + if (this.error) { + return /* html */` +
+
⚠️
+

${t('common.error')}

+
+ ` + } + + if (this.listings.length === 0) { + return /* html */` +
+
❤️
+

${t('favorites.empty')}

+

${t('favorites.emptyHint')}

+ ${t('favorites.browse')} +
+ ` + } + + return this.listings.map(listing => { + const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id + const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : '' + const locationName = listing.location?.name || '' + + return /* html */` + + ` + }).join('') + } + + escapeHtml(text) { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML + } +} + +customElements.define('page-favorites', PageFavorites) + +const style = document.createElement('style') +style.textContent = /* css */` + page-favorites .favorites-page { + padding: var(--space-lg) 0; + } + + page-favorites .page-header { + margin-bottom: var(--space-xl); + } + + page-favorites .page-header h1 { + margin: 0 0 var(--space-xs); + } + + page-favorites .page-subtitle { + color: var(--color-text-muted); + margin: 0; + } + + page-favorites .empty-state { + grid-column: 1 / -1; + text-align: center; + padding: var(--space-3xl); + } + + page-favorites .empty-icon { + font-size: 4rem; + margin-bottom: var(--space-md); + filter: grayscale(1); + } + + page-favorites .empty-state h3 { + margin: 0 0 var(--space-sm); + } + + page-favorites .empty-state p { + color: var(--color-text-muted); + margin: 0 0 var(--space-lg); + } +` +document.head.appendChild(style) diff --git a/js/components/pages/page-messages.js b/js/components/pages/page-messages.js new file mode 100644 index 0000000..4f92ef8 --- /dev/null +++ b/js/components/pages/page-messages.js @@ -0,0 +1,333 @@ +import { t, i18n } from '../../i18n.js' +import { auth } from '../../services/auth.js' +import { directus } from '../../services/directus.js' + +class PageMessages extends HTMLElement { + constructor() { + super() + this.conversations = [] + this.loading = true + this.error = null + this.isLoggedIn = false + } + + connectedCallback() { + this.isLoggedIn = auth.isLoggedIn() + this.render() + + if (this.isLoggedIn) { + this.loadConversations() + } else { + this.loading = false + } + + this.unsubscribe = i18n.subscribe(() => this.render()) + this.authUnsubscribe = auth.subscribe(() => { + const wasLoggedIn = this.isLoggedIn + this.isLoggedIn = auth.isLoggedIn() + + if (!wasLoggedIn && this.isLoggedIn) { + this.loading = true + this.render() + this.loadConversations() + } else if (wasLoggedIn && !this.isLoggedIn) { + this.conversations = [] + this.loading = false + this.render() + } + }) + } + + disconnectedCallback() { + if (this.unsubscribe) this.unsubscribe() + if (this.authUnsubscribe) this.authUnsubscribe() + } + + async loadConversations() { + try { + const user = auth.getUser() + if (!user) { + this.loading = false + this.updateContent() + return + } + + // Load conversations for current user (matched by participant hash) + const userHash = await auth.hashString(user.id) + + const response = await directus.get('/items/conversations', { + fields: [ + 'id', + 'date_created', + 'date_updated', + 'listing_id.id', + 'listing_id.title', + 'listing_id.images.directus_files_id.id', + 'status' + ], + filter: { + _or: [ + { participant_hash_1: { _eq: userHash } }, + { participant_hash_2: { _eq: userHash } } + ] + }, + sort: ['-date_updated'], + limit: 50 + }) + + this.conversations = response.data || [] + this.loading = false + this.updateContent() + } catch (err) { + console.error('Failed to load conversations:', err) + this.error = err.message + this.loading = false + this.updateContent() + } + } + + showAuthModal() { + document.querySelector('auth-modal')?.show() + } + + render() { + this.innerHTML = /* html */` +
+ + +
+ ${this.renderContent()} +
+
+ ` + + this.querySelector('#login-btn')?.addEventListener('click', () => this.showAuthModal()) + } + + updateContent() { + const container = this.querySelector('#messages-content') + if (container) { + container.innerHTML = this.renderContent() + this.querySelector('#login-btn')?.addEventListener('click', () => this.showAuthModal()) + } + } + + renderContent() { + if (!this.isLoggedIn) { + return /* html */` +
+
🔐
+

${t('messages.loginRequired')}

+

${t('messages.loginHint')}

+ +
+ ` + } + + if (this.loading) { + return /* html */` +
+
+

${t('common.loading')}

+
+ ` + } + + if (this.error) { + return /* html */` +
+
⚠️
+

${t('common.error')}

+
+ ` + } + + if (this.conversations.length === 0) { + return /* html */` +
+
💬
+

${t('messages.empty')}

+

${t('messages.emptyHint')}

+ ${t('messages.browse')} +
+ ` + } + + return this.conversations.map(conv => { + const listing = conv.listing_id + const imageId = listing?.images?.[0]?.directus_files_id?.id + const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 80) : '' + const title = listing?.title || t('messages.unknownListing') + const dateStr = this.formatDate(conv.date_updated || conv.date_created) + + return /* html */` + +
+ ${imageUrl + ? `` + : `
📦
`} +
+
+

${this.escapeHtml(title)}

+

${dateStr}

+
+
+
+ ` + }).join('') + } + + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const now = new Date() + const diffMs = now - date + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + + if (diffDays === 0) return t('messages.today') + if (diffDays === 1) return t('messages.yesterday') + if (diffDays < 7) return t('messages.daysAgo', { days: diffDays }) + + return date.toLocaleDateString() + } + + escapeHtml(text) { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML + } +} + +customElements.define('page-messages', PageMessages) + +const style = document.createElement('style') +style.textContent = /* css */` + page-messages .messages-page { + padding: var(--space-lg) 0; + } + + page-messages .page-header { + margin-bottom: var(--space-xl); + } + + page-messages .page-header h1 { + margin: 0 0 var(--space-xs); + } + + page-messages .page-subtitle { + color: var(--color-text-muted); + margin: 0; + } + + page-messages .conversations-list { + display: flex; + flex-direction: column; + gap: var(--space-sm); + } + + page-messages .conversation-item { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-md); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + text-decoration: none; + color: inherit; + transition: all var(--transition-fast); + } + + page-messages .conversation-item:hover { + border-color: var(--color-primary); + box-shadow: var(--shadow-sm); + } + + page-messages .conversation-image { + width: 60px; + height: 60px; + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--color-bg-tertiary); + flex-shrink: 0; + } + + page-messages .conversation-image img { + width: 100%; + height: 100%; + object-fit: cover; + } + + page-messages .image-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + filter: grayscale(1); + } + + page-messages .conversation-info { + flex: 1; + min-width: 0; + } + + page-messages .conversation-title { + margin: 0 0 var(--space-xs); + font-size: var(--font-size-base); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + page-messages .conversation-date { + margin: 0; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + + page-messages .conversation-arrow { + color: var(--color-text-muted); + font-size: var(--font-size-lg); + } + + page-messages .loading-state, + page-messages .empty-state { + text-align: center; + padding: var(--space-3xl); + } + + page-messages .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: 0 auto var(--space-md); + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + page-messages .empty-icon { + font-size: 4rem; + margin-bottom: var(--space-md); + filter: grayscale(1); + } + + page-messages .empty-state h3 { + margin: 0 0 var(--space-sm); + } + + page-messages .empty-state p { + color: var(--color-text-muted); + margin: 0 0 var(--space-lg); + } +` +document.head.appendChild(style) diff --git a/js/components/pages/page-my-listings.js b/js/components/pages/page-my-listings.js new file mode 100644 index 0000000..788a391 --- /dev/null +++ b/js/components/pages/page-my-listings.js @@ -0,0 +1,239 @@ +import { t, i18n } from '../../i18n.js' +import { directus } from '../../services/directus.js' +import { auth } from '../../services/auth.js' +import '../listing-card.js' +import '../skeleton-card.js' + +class PageMyListings extends HTMLElement { + constructor() { + super() + this.listings = [] + this.loading = true + this.error = null + this.isLoggedIn = false + } + + connectedCallback() { + this.isLoggedIn = auth.isLoggedIn() + this.render() + + if (this.isLoggedIn) { + this.loadMyListings() + } else { + this.loading = false + } + + this.unsubscribe = i18n.subscribe(() => this.render()) + this.authUnsubscribe = auth.subscribe(() => { + const wasLoggedIn = this.isLoggedIn + this.isLoggedIn = auth.isLoggedIn() + + if (!wasLoggedIn && this.isLoggedIn) { + this.loading = true + this.render() + this.loadMyListings() + } else if (wasLoggedIn && !this.isLoggedIn) { + this.listings = [] + this.loading = false + this.render() + } + }) + } + + disconnectedCallback() { + if (this.unsubscribe) this.unsubscribe() + if (this.authUnsubscribe) this.authUnsubscribe() + } + + async loadMyListings() { + try { + const user = auth.getUser() + if (!user) { + this.loading = false + this.updateContent() + return + } + + const response = await directus.getListings({ + filter: { + user_created: { _eq: user.id } + }, + sort: ['-date_created'], + limit: 50 + }) + this.listings = response.items || [] + this.loading = false + this.updateContent() + } catch (err) { + console.error('Failed to load my listings:', err) + this.error = err.message + this.loading = false + this.updateContent() + } + } + + showAuthModal() { + document.querySelector('auth-modal')?.show() + } + + render() { + this.innerHTML = /* html */` +
+ + +
+ ${this.renderContent()} +
+
+ ` + + this.querySelector('#login-btn')?.addEventListener('click', () => this.showAuthModal()) + } + + updateContent() { + const container = this.querySelector('#my-listings-content') + if (container) { + container.innerHTML = this.renderContent() + this.querySelector('#login-btn')?.addEventListener('click', () => this.showAuthModal()) + } + } + + renderContent() { + if (!this.isLoggedIn) { + return /* html */` +
+
🔐
+

${t('myListings.loginRequired')}

+

${t('myListings.loginHint')}

+ +
+ ` + } + + if (this.loading) { + return Array(4).fill(0).map(() => '').join('') + } + + if (this.error) { + return /* html */` +
+
⚠️
+

${t('common.error')}

+
+ ` + } + + if (this.listings.length === 0) { + return /* html */` +
+
📦
+

${t('myListings.empty')}

+

${t('myListings.emptyHint')}

+ ${t('myListings.create')} +
+ ` + } + + return this.listings.map(listing => { + const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id + const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : '' + const locationName = listing.location?.name || '' + const statusBadge = listing.status !== 'published' + ? `${t(`myListings.status.${listing.status}`)}` + : '' + + return /* html */` +
+ ${statusBadge} + +
+ ` + }).join('') + } + + escapeHtml(text) { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML + } +} + +customElements.define('page-my-listings', PageMyListings) + +const style = document.createElement('style') +style.textContent = /* css */` + page-my-listings .my-listings-page { + padding: var(--space-lg) 0; + } + + page-my-listings .page-header { + margin-bottom: var(--space-xl); + } + + page-my-listings .page-header h1 { + margin: 0 0 var(--space-xs); + } + + page-my-listings .page-subtitle { + color: var(--color-text-muted); + margin: 0; + } + + page-my-listings .listing-wrapper { + position: relative; + } + + page-my-listings .status-badge { + position: absolute; + top: var(--space-sm); + left: var(--space-sm); + padding: var(--space-xs) var(--space-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + border-radius: var(--radius-sm); + z-index: 2; + } + + page-my-listings .status-draft { + background: var(--color-bg-tertiary); + color: var(--color-text-muted); + } + + page-my-listings .status-archived { + background: var(--color-bg-tertiary); + color: var(--color-text-muted); + text-decoration: line-through; + } + + page-my-listings .empty-state { + grid-column: 1 / -1; + text-align: center; + padding: var(--space-3xl); + } + + page-my-listings .empty-icon { + font-size: 4rem; + margin-bottom: var(--space-md); + filter: grayscale(1); + } + + page-my-listings .empty-state h3 { + margin: 0 0 var(--space-sm); + } + + page-my-listings .empty-state p { + color: var(--color-text-muted); + margin: 0 0 var(--space-lg); + } +` +document.head.appendChild(style) diff --git a/js/components/pages/page-settings.js b/js/components/pages/page-settings.js new file mode 100644 index 0000000..e8d9558 --- /dev/null +++ b/js/components/pages/page-settings.js @@ -0,0 +1,382 @@ +import { t, i18n } from '../../i18n.js' +import { auth } from '../../services/auth.js' + +class PageSettings extends HTMLElement { + constructor() { + super() + this.isLoggedIn = false + } + + connectedCallback() { + this.isLoggedIn = auth.isLoggedIn() + this.render() + this.setupEventListeners() + + this.unsubscribe = i18n.subscribe(() => { + this.render() + this.setupEventListeners() + }) + + this.authUnsubscribe = auth.subscribe(() => { + this.isLoggedIn = auth.isLoggedIn() + this.render() + this.setupEventListeners() + }) + } + + disconnectedCallback() { + if (this.unsubscribe) this.unsubscribe() + if (this.authUnsubscribe) this.authUnsubscribe() + } + + setupEventListeners() { + // Theme toggle + this.querySelectorAll('input[name="theme"]').forEach(input => { + input.addEventListener('change', (e) => { + this.setTheme(e.target.value) + }) + }) + + // Language select + this.querySelector('#lang-select')?.addEventListener('change', (e) => { + i18n.setLocale(e.target.value) + }) + + // Clear favorites + this.querySelector('#clear-favorites')?.addEventListener('click', () => { + if (confirm(t('settings.confirmClearFavorites'))) { + localStorage.removeItem('favorites') + this.showToast(t('settings.favoritesCleared')) + } + }) + + // Clear search history + this.querySelector('#clear-search')?.addEventListener('click', () => { + if (confirm(t('settings.confirmClearSearch'))) { + localStorage.removeItem('searchFilters') + this.showToast(t('settings.searchCleared')) + } + }) + + // Logout + this.querySelector('#logout-btn')?.addEventListener('click', () => { + auth.logout() + this.showToast(t('settings.loggedOut')) + }) + + // Login + this.querySelector('#login-btn')?.addEventListener('click', () => { + document.querySelector('auth-modal')?.show() + }) + } + + setTheme(theme) { + if (theme === 'system') { + localStorage.removeItem('theme') + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light' + } else { + localStorage.setItem('theme', theme) + document.documentElement.dataset.theme = theme + } + } + + getCurrentTheme() { + return localStorage.getItem('theme') || 'system' + } + + showToast(message) { + const existing = document.querySelector('.settings-toast') + existing?.remove() + + const toast = document.createElement('div') + toast.className = 'settings-toast' + toast.textContent = message + document.body.appendChild(toast) + + requestAnimationFrame(() => toast.classList.add('visible')) + + setTimeout(() => { + toast.classList.remove('visible') + setTimeout(() => toast.remove(), 300) + }, 2000) + } + + render() { + const currentTheme = this.getCurrentTheme() + const currentLang = i18n.getLocale() + const user = auth.getUser() + + this.innerHTML = /* html */` +
+ + +
+ +
+

${t('settings.appearance')}

+ +
+ +
+ + + +
+
+ +
+ + +
+
+ + +
+

${t('settings.account')}

+ + ${this.isLoggedIn ? /* html */` +
+ + ${user?.id?.slice(0, 8)}... +
+
+ +
+ ` : /* html */` +
+

${t('settings.notLoggedIn')}

+ +
+ `} +
+ + +
+

${t('settings.data')}

+ +
+
+ +

${t('settings.favoritesHint')}

+
+ +
+ +
+
+ +

${t('settings.searchHistoryHint')}

+
+ +
+
+ + +
+

${t('settings.about')}

+ +

dgray.io v1.0.0

+
+
+
+ ` + } +} + +customElements.define('page-settings', PageSettings) + +const style = document.createElement('style') +style.textContent = /* css */` + page-settings .settings-page { + padding: var(--space-lg) 0; + max-width: 600px; + } + + page-settings .page-header { + margin-bottom: var(--space-xl); + } + + page-settings .page-header h1 { + margin: 0; + } + + page-settings .settings-sections { + display: flex; + flex-direction: column; + gap: var(--space-xl); + } + + page-settings .settings-section { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-lg); + } + + page-settings .settings-section h2 { + font-size: var(--font-size-lg); + margin: 0 0 var(--space-lg); + padding-bottom: var(--space-sm); + border-bottom: 1px solid var(--color-border); + } + + page-settings .setting-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-md); + padding: var(--space-sm) 0; + } + + page-settings .setting-item + .setting-item { + border-top: 1px solid var(--color-border); + margin-top: var(--space-sm); + padding-top: var(--space-md); + } + + page-settings .setting-item > label { + font-weight: var(--font-weight-medium); + } + + page-settings .setting-hint { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin: var(--space-xs) 0 0; + } + + page-settings .theme-options { + display: flex; + gap: var(--space-sm); + } + + page-settings .theme-option { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: var(--font-size-sm); + transition: all var(--transition-fast); + } + + page-settings .theme-option:has(input:checked) { + border-color: var(--color-primary); + background: var(--color-primary-light); + } + + page-settings .theme-option input { + display: none; + } + + page-settings select { + padding: var(--space-xs) var(--space-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + } + + page-settings .user-id { + padding: var(--space-xs) var(--space-sm); + background: var(--color-bg-tertiary); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + } + + page-settings .btn-danger { + color: var(--color-error, #dc2626); + border-color: var(--color-error, #dc2626); + } + + page-settings .btn-danger:hover { + background: var(--color-error, #dc2626); + color: white; + } + + page-settings .about-links { + display: flex; + flex-wrap: wrap; + gap: var(--space-md); + margin-bottom: var(--space-md); + } + + page-settings .about-links a { + color: var(--color-text-secondary); + text-decoration: none; + } + + page-settings .about-links a:hover { + color: var(--color-primary); + } + + page-settings .version { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin: 0; + } + + @media (max-width: 768px) { + page-settings .setting-item { + flex-direction: column; + align-items: flex-start; + } + + page-settings .theme-options { + flex-wrap: wrap; + } + } + + /* Toast */ + .settings-toast { + position: fixed; + bottom: var(--space-lg); + left: 50%; + transform: translateX(-50%) translateY(100px); + padding: var(--space-sm) var(--space-lg); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + opacity: 0; + transition: all 0.3s ease; + z-index: 1000; + } + + .settings-toast.visible { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +` +document.head.appendChild(style) diff --git a/locales/de.json b/locales/de.json index 7d2540f..8caa4cf 100644 --- a/locales/de.json +++ b/locales/de.json @@ -224,5 +224,66 @@ "confirmSaved": "Ich habe meine UUID gespeichert", "registrationFailed": "Registrierung fehlgeschlagen", "loginRequired": "Bitte melde dich an, um fortzufahren" + }, + "favorites": { + "title": "Favoriten", + "subtitle": "Deine gemerkten Anzeigen", + "empty": "Keine Favoriten", + "emptyHint": "Klicke auf das Herz-Symbol bei einer Anzeige, um sie zu speichern.", + "browse": "Anzeigen durchsuchen" + }, + "myListings": { + "title": "Meine Anzeigen", + "subtitle": "Verwalte deine Anzeigen", + "empty": "Keine Anzeigen", + "emptyHint": "Du hast noch keine Anzeigen erstellt.", + "create": "Anzeige erstellen", + "loginRequired": "Anmeldung erforderlich", + "loginHint": "Melde dich an, um deine Anzeigen zu sehen.", + "login": "Anmelden", + "status": { + "draft": "Entwurf", + "archived": "Archiviert" + } + }, + "messages": { + "title": "Nachrichten", + "subtitle": "Deine Konversationen", + "empty": "Keine Nachrichten", + "emptyHint": "Kontaktiere einen Verkäufer, um eine Konversation zu starten.", + "browse": "Anzeigen durchsuchen", + "loginRequired": "Anmeldung erforderlich", + "loginHint": "Melde dich an, um deine Nachrichten zu sehen.", + "login": "Anmelden", + "unknownListing": "Unbekannte Anzeige", + "today": "Heute", + "yesterday": "Gestern", + "daysAgo": "Vor {{days}} Tagen" + }, + "settings": { + "title": "Einstellungen", + "appearance": "Darstellung", + "theme": "Design", + "themeLight": "Hell", + "themeDark": "Dunkel", + "themeSystem": "System", + "language": "Sprache", + "account": "Konto", + "userId": "Benutzer-ID", + "logout": "Abmelden", + "login": "Anmelden", + "notLoggedIn": "Du bist nicht angemeldet.", + "loggedOut": "Erfolgreich abgemeldet", + "data": "Daten", + "favorites": "Favoriten", + "favoritesHint": "Lokal gespeicherte Favoriten löschen", + "searchHistory": "Suchverlauf", + "searchHistoryHint": "Gespeicherte Suchfilter löschen", + "clear": "Löschen", + "confirmClearFavorites": "Alle Favoriten löschen?", + "confirmClearSearch": "Suchverlauf löschen?", + "favoritesCleared": "Favoriten gelöscht", + "searchCleared": "Suchverlauf gelöscht", + "about": "Über" } } diff --git a/locales/en.json b/locales/en.json index 184648d..dd422c5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -224,5 +224,66 @@ "confirmSaved": "I have saved my UUID", "registrationFailed": "Registration failed", "loginRequired": "Please log in to continue" + }, + "favorites": { + "title": "Favorites", + "subtitle": "Your saved listings", + "empty": "No favorites", + "emptyHint": "Click the heart icon on a listing to save it.", + "browse": "Browse listings" + }, + "myListings": { + "title": "My Listings", + "subtitle": "Manage your listings", + "empty": "No listings", + "emptyHint": "You haven't created any listings yet.", + "create": "Create listing", + "loginRequired": "Login required", + "loginHint": "Log in to see your listings.", + "login": "Login", + "status": { + "draft": "Draft", + "archived": "Archived" + } + }, + "messages": { + "title": "Messages", + "subtitle": "Your conversations", + "empty": "No messages", + "emptyHint": "Contact a seller to start a conversation.", + "browse": "Browse listings", + "loginRequired": "Login required", + "loginHint": "Log in to see your messages.", + "login": "Login", + "unknownListing": "Unknown listing", + "today": "Today", + "yesterday": "Yesterday", + "daysAgo": "{{days}} days ago" + }, + "settings": { + "title": "Settings", + "appearance": "Appearance", + "theme": "Theme", + "themeLight": "Light", + "themeDark": "Dark", + "themeSystem": "System", + "language": "Language", + "account": "Account", + "userId": "User ID", + "logout": "Logout", + "login": "Login", + "notLoggedIn": "You are not logged in.", + "loggedOut": "Logged out successfully", + "data": "Data", + "favorites": "Favorites", + "favoritesHint": "Delete locally saved favorites", + "searchHistory": "Search history", + "searchHistoryHint": "Delete saved search filters", + "clear": "Clear", + "confirmClearFavorites": "Delete all favorites?", + "confirmClearSearch": "Delete search history?", + "favoritesCleared": "Favorites deleted", + "searchCleared": "Search history deleted", + "about": "About" } } diff --git a/locales/fr.json b/locales/fr.json index 137faee..dd8367d 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -224,5 +224,66 @@ "confirmSaved": "J'ai sauvegardé mon UUID", "registrationFailed": "Échec de l'inscription", "loginRequired": "Veuillez vous connecter pour continuer" + }, + "favorites": { + "title": "Favoris", + "subtitle": "Vos annonces sauvegardées", + "empty": "Aucun favori", + "emptyHint": "Cliquez sur l'icône cœur d'une annonce pour la sauvegarder.", + "browse": "Parcourir les annonces" + }, + "myListings": { + "title": "Mes annonces", + "subtitle": "Gérez vos annonces", + "empty": "Aucune annonce", + "emptyHint": "Vous n'avez pas encore créé d'annonce.", + "create": "Créer une annonce", + "loginRequired": "Connexion requise", + "loginHint": "Connectez-vous pour voir vos annonces.", + "login": "Connexion", + "status": { + "draft": "Brouillon", + "archived": "Archivé" + } + }, + "messages": { + "title": "Messages", + "subtitle": "Vos conversations", + "empty": "Aucun message", + "emptyHint": "Contactez un vendeur pour démarrer une conversation.", + "browse": "Parcourir les annonces", + "loginRequired": "Connexion requise", + "loginHint": "Connectez-vous pour voir vos messages.", + "login": "Connexion", + "unknownListing": "Annonce inconnue", + "today": "Aujourd'hui", + "yesterday": "Hier", + "daysAgo": "Il y a {{days}} jours" + }, + "settings": { + "title": "Paramètres", + "appearance": "Apparence", + "theme": "Thème", + "themeLight": "Clair", + "themeDark": "Sombre", + "themeSystem": "Système", + "language": "Langue", + "account": "Compte", + "userId": "ID utilisateur", + "logout": "Déconnexion", + "login": "Connexion", + "notLoggedIn": "Vous n'êtes pas connecté.", + "loggedOut": "Déconnecté avec succès", + "data": "Données", + "favorites": "Favoris", + "favoritesHint": "Supprimer les favoris enregistrés localement", + "searchHistory": "Historique de recherche", + "searchHistoryHint": "Supprimer les filtres de recherche enregistrés", + "clear": "Effacer", + "confirmClearFavorites": "Supprimer tous les favoris ?", + "confirmClearSearch": "Supprimer l'historique de recherche ?", + "favoritesCleared": "Favoris supprimés", + "searchCleared": "Historique de recherche supprimé", + "about": "À propos" } }