feat: add profile pages (favorites, messages, my-listings, settings) and update docs for zero-knowledge chat schema
This commit is contained in:
34
AGENTS.md
34
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.
|
**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
|
- **Ziel**: Anonyme, dezentrale Marktplatz-Alternative
|
||||||
|
|
||||||
## Tech-Stack
|
## Tech-Stack
|
||||||
@@ -62,6 +62,7 @@ js/
|
|||||||
|
|
||||||
docs/
|
docs/
|
||||||
├── DIRECTUS-SETUP.md # Directus Backend Setup
|
├── DIRECTUS-SETUP.md # Directus Backend Setup
|
||||||
|
├── DIRECTUS-SCHEMA.md # Collection-Strukturen & Permissions
|
||||||
└── MONETIZATION.md # Monetarisierung & Anti-Abuse
|
└── MONETIZATION.md # Monetarisierung & Anti-Abuse
|
||||||
|
|
||||||
css/
|
css/
|
||||||
@@ -114,22 +115,27 @@ locales/
|
|||||||
|
|
||||||
## Nächste Schritte
|
## Nächste Schritte
|
||||||
|
|
||||||
1. Seiten für Profil-Dropdown: `page-my-listings.js`, `page-messages.js`, `page-favorites.js`, `page-settings.js`
|
1. ~~Seiten für Profil-Dropdown~~ ✅ Fertig (`page-my-listings.js`, `page-messages.js`, `page-favorites.js`, `page-settings.js`)
|
||||||
2. Payment-Integration mit BTCpay Server (https://pay.xmr.rocks/)
|
2. ~~Suchseite mit Filtern~~ ✅ Merged in `page-home.js`
|
||||||
3. Reputation-System (5/15/50 Deals Stufen)
|
3. Payment-Integration mit BTCpay Server (https://pay.xmr.rocks/)
|
||||||
4. Suchseite (`page-search.js`) mit Filtern ausbauen
|
4. Reputation-System (5/15/50 Deals Stufen)
|
||||||
|
5. Push-Benachrichtigungen für neue Nachrichten
|
||||||
|
|
||||||
## Directus Berechtigungen (Public-Rolle)
|
## Directus Berechtigungen (Public-Rolle)
|
||||||
|
|
||||||
| Collection | Read | Create | Hinweise |
|
| Collection | Read | Create | Update | Hinweise |
|
||||||
|------------|------|--------|----------|
|
|------------|------|--------|--------|----------|
|
||||||
| `listings` | ✓ | ✓ | Nur `status=published` lesen |
|
| `listings` | ✓ | ✓ | - | Nur `status=published` lesen |
|
||||||
| `listings_files` | ✓ | ✓ | Junction-Table für Bilder |
|
| `listings_files` | ✓ | ✓ | - | Junction-Table für Bilder |
|
||||||
| `directus_files` | ✓ | ✓ | Für Assets/Bilder |
|
| `directus_files` | ✓ | ✓ | - | Für Assets/Bilder |
|
||||||
| `categories` | ✓ | - | Nur `status=published` |
|
| `categories` | ✓ | - | - | Nur `status=published` |
|
||||||
| `categories_translations` | ✓ | - | Für i18n |
|
| `categories_translations` | ✓ | - | - | Für i18n |
|
||||||
| `locations` | ✓ | - | Für Standort-Auswahl |
|
| `locations` | ✓ | ✓ | - | User kann neue Orte anlegen |
|
||||||
| `languages` | ✓ | - | Für Sprachen-Liste |
|
| `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
|
## Farbpalette
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -26,7 +26,7 @@ dgray.io ermöglicht es Nutzern, Kleinanzeigen zu schalten und Waren/Dienstleist
|
|||||||
| Light/Dark Mode | Niedrig | ✅ Fertig |
|
| Light/Dark Mode | Niedrig | ✅ Fertig |
|
||||||
| i18n (DE/EN/FR) | Niedrig | ✅ Fertig |
|
| i18n (DE/EN/FR) | Niedrig | ✅ Fertig |
|
||||||
| Bildergalerie | Niedrig | ✅ Fertig |
|
| Bildergalerie | Niedrig | ✅ Fertig |
|
||||||
| E2E-Chat | Hoch | 🔲 Vorbereitet |
|
| E2E-Chat (NaCl) | Hoch | ✅ Service fertig |
|
||||||
| **Monero MultiSig** | **Sehr hoch** | 🔲 Offen |
|
| **Monero MultiSig** | **Sehr hoch** | 🔲 Offen |
|
||||||
| Rating-System | Mittel | 🔲 Offen |
|
| Rating-System | Mittel | 🔲 Offen |
|
||||||
| 2FA | Mittel | 🔲 Offen |
|
| 2FA | Mittel | 🔲 Offen |
|
||||||
@@ -148,7 +148,9 @@ dgray/
|
|||||||
- [x] Such-Komponente mit Accordion-Kategorien
|
- [x] Such-Komponente mit Accordion-Kategorien
|
||||||
- [x] Anzeige-Detailseite mit Bildergalerie
|
- [x] Anzeige-Detailseite mit Bildergalerie
|
||||||
- [x] Anzeige-Erstellen-Formular
|
- [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
|
- [ ] Responsive Optimierungen
|
||||||
|
|
||||||
### Phase 2: Backend-Integration ⬅️ **Aktuell**
|
### Phase 2: Backend-Integration ⬅️ **Aktuell**
|
||||||
@@ -158,16 +160,18 @@ dgray/
|
|||||||
- [x] User-Auth (UUID + SHA-256 Hash, anonym)
|
- [x] User-Auth (UUID + SHA-256 Hash, anonym)
|
||||||
- [x] Bilder-Upload (Junction-Table)
|
- [x] Bilder-Upload (Junction-Table)
|
||||||
- [x] API-Services (`directus.js`, `listings.js`, `categories.js`, `locations.js`)
|
- [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
|
### Phase 3: Kommunikation
|
||||||
- [ ] Chat-System (E2E-verschlüsselt)
|
- [x] Chat-System (E2E-verschlüsselt mit NaCl)
|
||||||
- [ ] Benachrichtigungen
|
- [x] Conversations/Messages Services
|
||||||
- [ ] Merkliste
|
- [x] Merkliste (Favoriten-Seite)
|
||||||
|
- [ ] Benachrichtigungen (Push)
|
||||||
|
|
||||||
### Phase 4: Payments
|
### Phase 4: Payments
|
||||||
- [ ] XMR-Kursabfrage API
|
- [x] XMR-Kursabfrage API (CoinGecko)
|
||||||
- [ ] Fiat ↔ XMR Umrechnung
|
- [x] Fiat ↔ XMR Umrechnung (Dual-Preis-Anzeige)
|
||||||
- [ ] Wallet-Anbindung (monero-wallet-rpc)
|
- [ ] Wallet-Anbindung (monero-wallet-rpc)
|
||||||
- [ ] MultiSig Escrow
|
- [ ] MultiSig Escrow
|
||||||
|
|
||||||
|
|||||||
@@ -124,31 +124,40 @@ Verfügbare Sprachen.
|
|||||||
|
|
||||||
## conversations
|
## conversations
|
||||||
|
|
||||||
Chat-Konversationen zwischen Usern.
|
Zero-Knowledge Chat-Konversationen zwischen anonymen Usern.
|
||||||
|
|
||||||
| Feld | Typ | Beschreibung |
|
| Feld | Typ | Beschreibung |
|
||||||
|------|-----|--------------|
|
|------|-----|--------------|
|
||||||
| `id` | UUID | Primary Key |
|
| `id` | UUID | Primary Key |
|
||||||
| `listing` | UUID | FK → listings |
|
| `listing_id` | UUID | FK → listings |
|
||||||
| `buyer` | UUID | FK → directus_users (Interessent) |
|
| `participant_hash_1` | string(64) | SHA-256 Hash des ersten Teilnehmers (Käufer) |
|
||||||
| `seller` | UUID | FK → directus_users (Verkä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_created` | datetime | Erstellungsdatum |
|
||||||
| `date_updated` | datetime | Letzte Nachricht |
|
| `date_updated` | datetime | Letzte Nachricht |
|
||||||
|
|
||||||
|
**Hinweis:** Die `participant_hash_*` Felder ermöglichen anonyme Zuordnung ohne Directus-User-Accounts.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## messages
|
## messages
|
||||||
|
|
||||||
Nachrichten in Konversationen.
|
E2E-verschlüsselte Nachrichten in Konversationen.
|
||||||
|
|
||||||
| Feld | Typ | Beschreibung |
|
| Feld | Typ | Beschreibung |
|
||||||
|------|-----|--------------|
|
|------|-----|--------------|
|
||||||
| `id` | UUID | Primary Key |
|
| `id` | UUID | Primary Key |
|
||||||
| `conversation` | UUID | FK → conversations |
|
| `conversation` | UUID | FK → conversations |
|
||||||
| `sender` | UUID | FK → directus_users |
|
| `sender_hash` | string(64) | SHA-256 Hash des Senders |
|
||||||
| `content` | text | Verschlüsselter Inhalt |
|
| `content_encrypted` | text | NaCl-verschlüsselter Inhalt |
|
||||||
|
| `nonce` | string | Nonce für Entschlüsselung |
|
||||||
|
| `type` | string | `text`, `image`, `system` |
|
||||||
| `date_created` | datetime | Zeitstempel |
|
| `date_created` | datetime | Zeitstempel |
|
||||||
|
|
||||||
|
**Hinweis:** Nachrichten sind client-seitig E2E-verschlüsselt. Server sieht nur Ciphertext.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## favorites
|
## favorites
|
||||||
@@ -191,7 +200,7 @@ Meldungen von Anzeigen.
|
|||||||
| `categories_translations` | ✓ | - | - | - | Für i18n |
|
| `categories_translations` | ✓ | - | - | - | Für i18n |
|
||||||
| `locations` | ✓ | ✓ | - | - | User kann neue Orte anlegen |
|
| `locations` | ✓ | ✓ | - | - | User kann neue Orte anlegen |
|
||||||
| `languages` | ✓ | - | - | - | Für Sprach-Auswahl |
|
| `languages` | ✓ | - | - | - | Für Sprach-Auswahl |
|
||||||
| `conversations` | ✓ | ✓ | - | - | Nur eigene |
|
| `conversations` | ✓ | ✓ | ✓ | - | Filter via API-Query mit `participant_hash`, Update nur `status` |
|
||||||
| `messages` | ✓ | ✓ | - | - | Nur in eigenen Konversationen |
|
| `messages` | ✓ | ✓ | - | - | Filter via `conversation` ID |
|
||||||
| `favorites` | ✓ | ✓ | - | ✓ | Nur eigene |
|
| `favorites` | ✓ | ✓ | - | ✓ | Nur eigene |
|
||||||
| `reports` | - | ✓ | - | - | Nur erstellen |
|
| `reports` | - | ✓ | - | - | Nur erstellen |
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import './auth-modal.js'
|
|||||||
import './pages/page-home.js'
|
import './pages/page-home.js'
|
||||||
import './pages/page-listing.js'
|
import './pages/page-listing.js'
|
||||||
import './pages/page-create.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'
|
import './pages/page-not-found.js'
|
||||||
|
|
||||||
class AppShell extends HTMLElement {
|
class AppShell extends HTMLElement {
|
||||||
@@ -47,6 +51,10 @@ class AppShell extends HTMLElement {
|
|||||||
.register('/search', 'page-home') // Redirect search to home
|
.register('/search', 'page-home') // Redirect search to home
|
||||||
.register('/listing/:id', 'page-listing')
|
.register('/listing/:id', 'page-listing')
|
||||||
.register('/create', 'page-create')
|
.register('/create', 'page-create')
|
||||||
|
.register('/favorites', 'page-favorites')
|
||||||
|
.register('/my-listings', 'page-my-listings')
|
||||||
|
.register('/messages', 'page-messages')
|
||||||
|
.register('/settings', 'page-settings')
|
||||||
|
|
||||||
router.handleRouteChange()
|
router.handleRouteChange()
|
||||||
}
|
}
|
||||||
|
|||||||
171
js/components/pages/page-favorites.js
Normal file
171
js/components/pages/page-favorites.js
Normal file
@@ -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 */`
|
||||||
|
<div class="favorites-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>${t('favorites.title')}</h1>
|
||||||
|
<p class="page-subtitle">${t('favorites.subtitle')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="favorites-content" class="listings-grid">
|
||||||
|
${this.renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContent() {
|
||||||
|
const container = this.querySelector('#favorites-content')
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = this.renderContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContent() {
|
||||||
|
if (this.loading) {
|
||||||
|
return Array(4).fill(0).map(() => '<skeleton-card></skeleton-card>').join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.error) {
|
||||||
|
return /* html */`
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">⚠️</div>
|
||||||
|
<p>${t('common.error')}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.listings.length === 0) {
|
||||||
|
return /* html */`
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">❤️</div>
|
||||||
|
<h3>${t('favorites.empty')}</h3>
|
||||||
|
<p>${t('favorites.emptyHint')}</p>
|
||||||
|
<a href="#/" class="btn btn-primary">${t('favorites.browse')}</a>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 */`
|
||||||
|
<listing-card
|
||||||
|
listing-id="${listing.id}"
|
||||||
|
title="${this.escapeHtml(listing.title || '')}"
|
||||||
|
price="${listing.price || ''}"
|
||||||
|
currency="${listing.currency || 'EUR'}"
|
||||||
|
location="${this.escapeHtml(locationName)}"
|
||||||
|
image="${imageUrl}"
|
||||||
|
></listing-card>
|
||||||
|
`
|
||||||
|
}).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)
|
||||||
333
js/components/pages/page-messages.js
Normal file
333
js/components/pages/page-messages.js
Normal file
@@ -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 */`
|
||||||
|
<div class="messages-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>${t('messages.title')}</h1>
|
||||||
|
<p class="page-subtitle">${t('messages.subtitle')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="messages-content" class="conversations-list">
|
||||||
|
${this.renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
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 */`
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">🔐</div>
|
||||||
|
<h3>${t('messages.loginRequired')}</h3>
|
||||||
|
<p>${t('messages.loginHint')}</p>
|
||||||
|
<button id="login-btn" class="btn btn-primary">${t('messages.login')}</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.loading) {
|
||||||
|
return /* html */`
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>${t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.error) {
|
||||||
|
return /* html */`
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">⚠️</div>
|
||||||
|
<p>${t('common.error')}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.conversations.length === 0) {
|
||||||
|
return /* html */`
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">💬</div>
|
||||||
|
<h3>${t('messages.empty')}</h3>
|
||||||
|
<p>${t('messages.emptyHint')}</p>
|
||||||
|
<a href="#/" class="btn btn-primary">${t('messages.browse')}</a>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 */`
|
||||||
|
<a href="#/listing/${listing?.id}" class="conversation-item">
|
||||||
|
<div class="conversation-image">
|
||||||
|
${imageUrl
|
||||||
|
? `<img src="${imageUrl}" alt="" loading="lazy">`
|
||||||
|
: `<div class="image-placeholder">📦</div>`}
|
||||||
|
</div>
|
||||||
|
<div class="conversation-info">
|
||||||
|
<h3 class="conversation-title">${this.escapeHtml(title)}</h3>
|
||||||
|
<p class="conversation-date">${dateStr}</p>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-arrow">→</div>
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
}).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)
|
||||||
239
js/components/pages/page-my-listings.js
Normal file
239
js/components/pages/page-my-listings.js
Normal file
@@ -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 */`
|
||||||
|
<div class="my-listings-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>${t('myListings.title')}</h1>
|
||||||
|
<p class="page-subtitle">${t('myListings.subtitle')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="my-listings-content" class="listings-grid">
|
||||||
|
${this.renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
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 */`
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">🔐</div>
|
||||||
|
<h3>${t('myListings.loginRequired')}</h3>
|
||||||
|
<p>${t('myListings.loginHint')}</p>
|
||||||
|
<button id="login-btn" class="btn btn-primary">${t('myListings.login')}</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.loading) {
|
||||||
|
return Array(4).fill(0).map(() => '<skeleton-card></skeleton-card>').join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.error) {
|
||||||
|
return /* html */`
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">⚠️</div>
|
||||||
|
<p>${t('common.error')}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.listings.length === 0) {
|
||||||
|
return /* html */`
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📦</div>
|
||||||
|
<h3>${t('myListings.empty')}</h3>
|
||||||
|
<p>${t('myListings.emptyHint')}</p>
|
||||||
|
<a href="#/create" class="btn btn-primary">${t('myListings.create')}</a>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
? `<span class="status-badge status-${listing.status}">${t(`myListings.status.${listing.status}`)}</span>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return /* html */`
|
||||||
|
<div class="listing-wrapper">
|
||||||
|
${statusBadge}
|
||||||
|
<listing-card
|
||||||
|
listing-id="${listing.id}"
|
||||||
|
title="${this.escapeHtml(listing.title || '')}"
|
||||||
|
price="${listing.price || ''}"
|
||||||
|
currency="${listing.currency || 'EUR'}"
|
||||||
|
location="${this.escapeHtml(locationName)}"
|
||||||
|
image="${imageUrl}"
|
||||||
|
></listing-card>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}).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)
|
||||||
382
js/components/pages/page-settings.js
Normal file
382
js/components/pages/page-settings.js
Normal file
@@ -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 */`
|
||||||
|
<div class="settings-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>${t('settings.title')}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-sections">
|
||||||
|
<!-- Appearance -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>${t('settings.appearance')}</h2>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label>${t('settings.theme')}</label>
|
||||||
|
<div class="theme-options">
|
||||||
|
<label class="theme-option">
|
||||||
|
<input type="radio" name="theme" value="light" ${currentTheme === 'light' ? 'checked' : ''}>
|
||||||
|
<span>☀️ ${t('settings.themeLight')}</span>
|
||||||
|
</label>
|
||||||
|
<label class="theme-option">
|
||||||
|
<input type="radio" name="theme" value="dark" ${currentTheme === 'dark' ? 'checked' : ''}>
|
||||||
|
<span>🌙 ${t('settings.themeDark')}</span>
|
||||||
|
</label>
|
||||||
|
<label class="theme-option">
|
||||||
|
<input type="radio" name="theme" value="system" ${currentTheme === 'system' ? 'checked' : ''}>
|
||||||
|
<span>💻 ${t('settings.themeSystem')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<label for="lang-select">${t('settings.language')}</label>
|
||||||
|
<select id="lang-select">
|
||||||
|
<option value="de" ${currentLang === 'de' ? 'selected' : ''}>Deutsch</option>
|
||||||
|
<option value="en" ${currentLang === 'en' ? 'selected' : ''}>English</option>
|
||||||
|
<option value="fr" ${currentLang === 'fr' ? 'selected' : ''}>Français</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Account -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>${t('settings.account')}</h2>
|
||||||
|
|
||||||
|
${this.isLoggedIn ? /* html */`
|
||||||
|
<div class="setting-item">
|
||||||
|
<label>${t('settings.userId')}</label>
|
||||||
|
<code class="user-id">${user?.id?.slice(0, 8)}...</code>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<button id="logout-btn" class="btn btn-outline btn-danger">
|
||||||
|
${t('settings.logout')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : /* html */`
|
||||||
|
<div class="setting-item">
|
||||||
|
<p class="setting-hint">${t('settings.notLoggedIn')}</p>
|
||||||
|
<button id="login-btn" class="btn btn-primary">
|
||||||
|
${t('settings.login')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Data -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>${t('settings.data')}</h2>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div>
|
||||||
|
<label>${t('settings.favorites')}</label>
|
||||||
|
<p class="setting-hint">${t('settings.favoritesHint')}</p>
|
||||||
|
</div>
|
||||||
|
<button id="clear-favorites" class="btn btn-outline btn-sm">
|
||||||
|
${t('settings.clear')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div>
|
||||||
|
<label>${t('settings.searchHistory')}</label>
|
||||||
|
<p class="setting-hint">${t('settings.searchHistoryHint')}</p>
|
||||||
|
</div>
|
||||||
|
<button id="clear-search" class="btn btn-outline btn-sm">
|
||||||
|
${t('settings.clear')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- About -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2>${t('settings.about')}</h2>
|
||||||
|
<div class="about-links">
|
||||||
|
<a href="#/about">${t('footer.about')}</a>
|
||||||
|
<a href="#/privacy">${t('footer.privacy')}</a>
|
||||||
|
<a href="#/terms">${t('footer.terms')}</a>
|
||||||
|
<a href="#/contact">${t('footer.contact')}</a>
|
||||||
|
</div>
|
||||||
|
<p class="version">dgray.io v1.0.0</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -224,5 +224,66 @@
|
|||||||
"confirmSaved": "Ich habe meine UUID gespeichert",
|
"confirmSaved": "Ich habe meine UUID gespeichert",
|
||||||
"registrationFailed": "Registrierung fehlgeschlagen",
|
"registrationFailed": "Registrierung fehlgeschlagen",
|
||||||
"loginRequired": "Bitte melde dich an, um fortzufahren"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,5 +224,66 @@
|
|||||||
"confirmSaved": "I have saved my UUID",
|
"confirmSaved": "I have saved my UUID",
|
||||||
"registrationFailed": "Registration failed",
|
"registrationFailed": "Registration failed",
|
||||||
"loginRequired": "Please log in to continue"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,5 +224,66 @@
|
|||||||
"confirmSaved": "J'ai sauvegardé mon UUID",
|
"confirmSaved": "J'ai sauvegardé mon UUID",
|
||||||
"registrationFailed": "Échec de l'inscription",
|
"registrationFailed": "Échec de l'inscription",
|
||||||
"loginRequired": "Veuillez vous connecter pour continuer"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user