feat: add listing edit mode with owner detection, fix service worker API caching for external domain

This commit is contained in:
2026-02-04 14:57:34 +01:00
parent 0830af9c0e
commit 5895ab7e98
10 changed files with 245 additions and 63 deletions

View File

@@ -124,9 +124,10 @@ locales/
1. ~~Seiten für Profil-Dropdown~~ ✅ Fertig (`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. ~~Suchseite mit Filtern~~ ✅ Merged in `page-home.js` 2. ~~Suchseite mit Filtern~~ ✅ Merged in `page-home.js`
3. Payment-Integration mit BTCpay Server (https://pay.xmr.rocks/) 3. ~~Listings bearbeiten~~ ✅ Edit-Modus via `#/edit/:id`
4. Reputation-System (5/15/50 Deals Stufen) 4. Payment-Integration mit BTCpay Server (https://pay.xmr.rocks/)
5. Push-Benachrichtigungen für neue Nachrichten 5. Reputation-System (5/15/50 Deals Stufen)
6. Push-Benachrichtigungen für neue Nachrichten
## Directus Berechtigungen (Public-Rolle) ## Directus Berechtigungen (Public-Rolle)

View File

@@ -162,6 +162,7 @@ dgray/
- [x] API-Services (`directus.js`, `listings.js`, `categories.js`, `locations.js`) - [x] API-Services (`directus.js`, `listings.js`, `categories.js`, `locations.js`)
- [x] Directus Public-Berechtigungen (siehe `docs/DIRECTUS-SCHEMA.md`) - [x] Directus Public-Berechtigungen (siehe `docs/DIRECTUS-SCHEMA.md`)
- [x] Neue Seiten: Favoriten, Meine Anzeigen, Nachrichten, Einstellungen - [x] Neue Seiten: Favoriten, Meine Anzeigen, Nachrichten, Einstellungen
- [x] Listings bearbeiten (Edit-Modus für eigene Anzeigen)
### Phase 3: Kommunikation ### Phase 3: Kommunikation
- [x] Chat-System (E2E-verschlüsselt mit NaCl) - [x] Chat-System (E2E-verschlüsselt mit NaCl)

View File

@@ -193,7 +193,7 @@ Meldungen von Anzeigen.
| Collection | Read | Create | Update | Delete | Hinweise | | Collection | Read | Create | Update | Delete | Hinweise |
|------------|:----:|:------:|:------:|:------:|----------| |------------|:----:|:------:|:------:|:------:|----------|
| `listings` | ✓ | ✓ | ✓* | - | Nur `status=published` lesen, *Update nur `views` (via Flow) | | `listings` | ✓ | ✓ | ✓ | - | Siehe Details unten |
| `listings_files` | ✓ | ✓ | - | - | Für Bilder-Upload | | `listings_files` | ✓ | ✓ | - | - | Für Bilder-Upload |
| `directus_files` | ✓ | ✓ | - | - | Asset-Upload | | `directus_files` | ✓ | ✓ | - | - | Asset-Upload |
| `categories` | ✓ | - | - | - | Nur `status=published` | | `categories` | ✓ | - | - | - | Nur `status=published` |
@@ -205,6 +205,27 @@ Meldungen von Anzeigen.
| `favorites` | ✓ | ✓ | - | ✓ | Nur eigene | | `favorites` | ✓ | ✓ | - | ✓ | Nur eigene |
| `reports` | - | ✓ | - | - | Nur erstellen | | `reports` | - | ✓ | - | - | Nur erstellen |
### Listings Update-Berechtigungen (Detail)
**Custom Filter:**
```json
{ "user_created": { "_eq": "$CURRENT_USER" } }
```
**Field Permissions (Update):**
- `title`, `slug`, `description`
- `price`, `currency`, `price_mode`, `price_type`
- `category`, `condition`, `location`
- `shipping`, `shipping_cost`
- `monero_address`
- `images`
- `views` (geschützt durch Flow)
**Read Filter:**
```json
{ "status": { "_eq": "published" } }
```
--- ---
## Directus Flows ## Directus Flows

View File

@@ -51,6 +51,7 @@ 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('/edit/:id', 'page-create')
.register('/favorites', 'page-favorites') .register('/favorites', 'page-favorites')
.register('/my-listings', 'page-my-listings') .register('/my-listings', 'page-my-listings')
.register('/messages', 'page-messages') .register('/messages', 'page-messages')

View File

@@ -11,7 +11,19 @@ const STORAGE_KEY = 'dgray_create_draft'
class PageCreate extends HTMLElement { class PageCreate extends HTMLElement {
constructor() { constructor() {
super() super()
this.formData = this.loadDraft() || { this.editMode = false
this.editId = null
this.existingImages = []
this.formData = this.loadDraft() || this.getEmptyFormData()
this.imageFiles = []
this.imagePreviews = []
this.categories = []
this.submitting = false
this.isNewAccount = true
}
getEmptyFormData() {
return {
title: '', title: '',
description: '', description: '',
price: '', price: '',
@@ -24,11 +36,6 @@ class PageCreate extends HTMLElement {
shipping: false, shipping: false,
moneroAddress: '' moneroAddress: ''
} }
this.imageFiles = []
this.imagePreviews = []
this.categories = []
this.submitting = false
this.isNewAccount = true
} }
loadDraft() { loadDraft() {
@@ -59,13 +66,63 @@ class PageCreate extends HTMLElement {
return return
} }
this.hasDraft = !!localStorage.getItem(STORAGE_KEY) // Check if edit mode
if (this.dataset.id) {
this.editMode = true
this.editId = this.dataset.id
await this.loadExistingListing()
} else {
this.hasDraft = !!localStorage.getItem(STORAGE_KEY)
}
await this.loadCategories() await this.loadCategories()
await this.checkAccountStatus() await this.checkAccountStatus()
this.render() this.render()
this.unsubscribe = i18n.subscribe(() => this.render()) this.unsubscribe = i18n.subscribe(() => this.render())
} }
async loadExistingListing() {
try {
const listing = await directus.getListing(this.editId)
// Verify ownership
const user = await auth.getUser()
if (listing.user_created !== user?.id) {
window.location.hash = '#/'
return
}
this.formData = {
title: listing.title || '',
description: listing.description || '',
price: listing.price?.toString() || '',
currency: listing.currency || 'EUR',
price_mode: listing.price_mode || 'fiat',
price_type: listing.price_type || 'fixed',
category: listing.category?.id || listing.category || '',
condition: listing.condition || 'good',
location: listing.location?.id || listing.location || '',
shipping: listing.shipping || false,
moneroAddress: listing.monero_address || '',
status: listing.status || 'published'
}
// Store existing images
this.existingImages = (listing.images || []).map(img => ({
id: img.id,
fileId: img.directus_files_id?.id || img.directus_files_id
}))
// Create previews for existing images
this.imagePreviews = this.existingImages.map(img =>
directus.getThumbnailUrl(img.fileId, 200)
)
} catch (e) {
console.error('Failed to load listing for edit:', e)
window.location.hash = '#/'
}
}
async checkAccountStatus() { async checkAccountStatus() {
try { try {
// Check if user has any published listings // Check if user has any published listings
@@ -138,11 +195,13 @@ class PageCreate extends HTMLElement {
} }
render() { render() {
const pageTitle = this.editMode ? t('create.editTitle') : t('create.title')
this.innerHTML = /* html */` this.innerHTML = /* html */`
<div class="create-page"> <div class="create-page">
<h1 data-i18n="create.title">${t('create.title')}</h1> <h1>${pageTitle}</h1>
${this.hasDraft ? ` ${!this.editMode && this.hasDraft ? `
<div class="draft-notice"> <div class="draft-notice">
<span>${t('create.draftRestored')}</span> <span>${t('create.draftRestored')}</span>
<button type="button" class="btn-link" id="clear-draft-btn">${t('create.clearDraft')}</button> <button type="button" class="btn-link" id="clear-draft-btn">${t('create.clearDraft')}</button>
@@ -289,7 +348,7 @@ class PageCreate extends HTMLElement {
${t('create.cancel')} ${t('create.cancel')}
</button> </button>
<button type="submit" class="btn btn-primary btn-lg"> <button type="submit" class="btn btn-primary btn-lg">
${t('create.publish')} ${this.editMode ? t('create.saveChanges') : t('create.publish')}
</button> </button>
</div> </div>
</form> </form>
@@ -417,8 +476,23 @@ class PageCreate extends HTMLElement {
if (this.submitting) return if (this.submitting) return
// Validate PoW Captcha (only for new accounts) const form = e.target
if (this.isNewAccount) {
// Read current form values directly (more reliable than event listeners)
const formElements = {
title: form.querySelector('#title')?.value || '',
description: form.querySelector('#description')?.value || '',
price: form.querySelector('#price')?.value || '',
currency: form.querySelector('#currency')?.value || 'EUR',
price_mode: form.querySelector('#price_mode')?.value || 'fiat',
category: form.querySelector('#category')?.value || '',
condition: form.querySelector('#condition')?.value || 'good',
shipping: form.querySelector('#shipping')?.checked || false,
moneroAddress: form.querySelector('#moneroAddress')?.value || ''
}
// Validate PoW Captcha (only for new accounts and new listings)
if (!this.editMode && this.isNewAccount) {
const captcha = this.querySelector('#pow-captcha') const captcha = this.querySelector('#pow-captcha')
if (!captcha?.isSolved()) { if (!captcha?.isSolved()) {
this.showError(t('captcha.error')) this.showError(t('captcha.error'))
@@ -427,7 +501,7 @@ class PageCreate extends HTMLElement {
} }
// Validate Monero address // Validate Monero address
if (this.formData.moneroAddress && !this.validateMoneroAddress(this.formData.moneroAddress)) { if (formElements.moneroAddress && !this.validateMoneroAddress(formElements.moneroAddress)) {
this.showError(t('create.invalidMoneroAddress')) this.showError(t('create.invalidMoneroAddress'))
return return
} }
@@ -435,35 +509,38 @@ class PageCreate extends HTMLElement {
this.submitting = true this.submitting = true
this.clearError() this.clearError()
const form = e.target
const submitBtn = form.querySelector('[type="submit"]') const submitBtn = form.querySelector('[type="submit"]')
submitBtn.disabled = true submitBtn.disabled = true
submitBtn.textContent = t('create.publishing') submitBtn.textContent = this.editMode ? t('create.saving') : t('create.publishing')
try { try {
// Upload images first // Upload new images first
let imageIds = [] let newImageIds = []
if (this.imageFiles.length > 0) { if (this.imageFiles.length > 0) {
const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles) const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles)
imageIds = uploadedFiles.map(f => f.id) newImageIds = uploadedFiles.map(f => f.id)
} }
// Create listing // Build listing data from form values
const listingData = { const listingData = {
title: this.formData.title, title: formElements.title,
slug: this.generateSlug(this.formData.title), slug: this.generateSlug(formElements.title),
description: this.formData.description, description: formElements.description,
price: String(parseFloat(this.formData.price) || 0), price: String(parseFloat(formElements.price) || 0),
currency: this.formData.currency, currency: formElements.currency
status: 'published' }
// Only set status on create, not on edit
if (!this.editMode) {
listingData.status = 'published'
} }
// Add optional fields only if set // Add optional fields only if set
if (this.formData.price_mode) listingData.price_mode = this.formData.price_mode if (formElements.price_mode) listingData.price_mode = formElements.price_mode
if (this.formData.category) listingData.category = this.formData.category if (formElements.category) listingData.category = formElements.category
if (this.formData.condition) listingData.condition = this.formData.condition if (formElements.condition) listingData.condition = formElements.condition
if (this.formData.shipping) listingData.shipping = this.formData.shipping listingData.shipping = formElements.shipping
if (this.formData.moneroAddress) listingData.monero_address = this.formData.moneroAddress if (formElements.moneroAddress) listingData.monero_address = formElements.moneroAddress
// Handle location - find or create in locations collection // Handle location - find or create in locations collection
if (this.formData.locationData) { if (this.formData.locationData) {
@@ -473,31 +550,45 @@ class PageCreate extends HTMLElement {
} }
} }
// Add images in junction table format if (this.editMode) {
if (imageIds.length > 0) { // Update existing listing
listingData.images = { // Add new images if any
create: imageIds.map((id, index) => ({ if (newImageIds.length > 0) {
directus_files_id: id, listingData.images = {
sort: index create: newImageIds.map((id, index) => ({
})) directus_files_id: id,
sort: this.existingImages.length + index
}))
}
}
await directus.updateListing(this.editId, listingData)
router.navigate(`/listing/${this.editId}`)
} else {
// Create new listing
if (newImageIds.length > 0) {
listingData.images = {
create: newImageIds.map((id, index) => ({
directus_files_id: id,
sort: index
}))
}
}
const listing = await directus.createListing(listingData)
this.clearDraft()
if (listing?.id) {
router.navigate(`/listing/${listing.id}`)
} else {
router.navigate('/')
} }
} }
const listing = await directus.createListing(listingData)
this.clearDraft()
if (listing?.id) {
router.navigate(`/listing/${listing.id}`)
} else {
// Listing created but no ID returned - go to home
router.navigate('/')
}
} catch (error) { } catch (error) {
console.error('Failed to create listing:', error) console.error('Failed to save listing:', error)
console.error('Error details:', JSON.stringify(error.data, null, 2)) console.error('Error details:', JSON.stringify(error.data, null, 2))
submitBtn.disabled = false submitBtn.disabled = false
submitBtn.textContent = t('create.publish') submitBtn.textContent = this.editMode ? t('create.saveChanges') : t('create.publish')
this.submitting = false this.submitting = false
// Extract detailed error message // Extract detailed error message

View File

@@ -1,5 +1,6 @@
import { t, i18n } from '../../i18n.js' import { t, i18n } from '../../i18n.js'
import { directus } from '../../services/directus.js' import { directus } from '../../services/directus.js'
import { auth } from '../../services/auth.js'
import { listingsService } from '../../services/listings.js' import { listingsService } from '../../services/listings.js'
import { getXmrRates, formatPrice as formatCurrencyPrice } from '../../services/currency.js' import { getXmrRates, formatPrice as formatCurrencyPrice } from '../../services/currency.js'
import '../chat-widget.js' import '../chat-widget.js'
@@ -14,6 +15,7 @@ class PageListing extends HTMLElement {
this.loading = true this.loading = true
this.isFavorite = false this.isFavorite = false
this.rates = null this.rates = null
this.isOwner = false
} }
connectedCallback() { connectedCallback() {
@@ -33,10 +35,15 @@ class PageListing extends HTMLElement {
this.rates = await getXmrRates() this.rates = await getXmrRates()
this.loadFavoriteState() this.loadFavoriteState()
// Increment view counter and update local state // Check if current user is owner
const newViews = await directus.incrementListingViews(this.listingId) await this.checkOwnership()
if (newViews !== null) {
this.listing.views = newViews // Increment view counter only if not owner
if (!this.isOwner) {
const newViews = await directus.incrementListingViews(this.listingId)
if (newViews !== null) {
this.listing.views = newViews
}
} }
// Load other listings from same seller // Load other listings from same seller
@@ -53,6 +60,20 @@ class PageListing extends HTMLElement {
this.setupEventListeners() this.setupEventListeners()
} }
async checkOwnership() {
if (!auth.isLoggedIn() || !this.listing?.user_created) {
this.isOwner = false
return
}
try {
const user = await auth.getUser()
this.isOwner = user?.id === this.listing.user_created
} catch (e) {
this.isOwner = false
}
}
async loadSellerListings() { async loadSellerListings() {
try { try {
const response = await directus.getListings({ const response = await directus.getListings({
@@ -234,6 +255,34 @@ class PageListing extends HTMLElement {
} }
renderSidebar() { renderSidebar() {
// Owner view: show edit button instead of contact
if (this.isOwner) {
return /* html */`
<div class="sidebar-card">
<a href="#/edit/${this.listingId}" class="btn btn-primary btn-lg sidebar-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
${t('listing.edit')}
</a>
<div class="sidebar-actions">
<button class="action-btn" id="share-btn" title="${t('listing.share')}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
<span>${t('listing.share')}</span>
</button>
</div>
</div>
`
}
return /* html */` return /* html */`
<div class="sidebar-card"> <div class="sidebar-card">
<button class="btn btn-primary btn-lg sidebar-btn" id="contact-btn"> <button class="btn btn-primary btn-lg sidebar-btn" id="contact-btn">

View File

@@ -139,7 +139,8 @@
"viewPlural": "Aufrufe", "viewPlural": "Aufrufe",
"share": "Teilen", "share": "Teilen",
"report": "Melden", "report": "Melden",
"moreFromSeller": "Weitere Anzeigen des Anbieters" "moreFromSeller": "Weitere Anzeigen des Anbieters",
"edit": "Bearbeiten"
}, },
"chat": { "chat": {
"title": "Nachricht senden", "title": "Nachricht senden",
@@ -150,6 +151,7 @@
}, },
"create": { "create": {
"title": "Anzeige erstellen", "title": "Anzeige erstellen",
"editTitle": "Anzeige bearbeiten",
"listingTitle": "Titel", "listingTitle": "Titel",
"titlePlaceholder": "Was möchtest du verkaufen?", "titlePlaceholder": "Was möchtest du verkaufen?",
"category": "Kategorie", "category": "Kategorie",
@@ -180,6 +182,8 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"publish": "Veröffentlichen", "publish": "Veröffentlichen",
"publishing": "Wird veröffentlicht...", "publishing": "Wird veröffentlicht...",
"saveChanges": "Änderungen speichern",
"saving": "Wird gespeichert...",
"publishFailed": "Veröffentlichung fehlgeschlagen. Bitte versuche es erneut.", "publishFailed": "Veröffentlichung fehlgeschlagen. Bitte versuche es erneut.",
"invalidMoneroAddress": "Ungültige Monero-Adresse. Bitte prüfe das Format.", "invalidMoneroAddress": "Ungültige Monero-Adresse. Bitte prüfe das Format.",
"draftRestored": "Entwurf wiederhergestellt", "draftRestored": "Entwurf wiederhergestellt",

View File

@@ -139,7 +139,8 @@
"viewPlural": "views", "viewPlural": "views",
"share": "Share", "share": "Share",
"report": "Report", "report": "Report",
"moreFromSeller": "More from this seller" "moreFromSeller": "More from this seller",
"edit": "Edit"
}, },
"chat": { "chat": {
"title": "Send Message", "title": "Send Message",
@@ -150,6 +151,7 @@
}, },
"create": { "create": {
"title": "Create Listing", "title": "Create Listing",
"editTitle": "Edit Listing",
"listingTitle": "Title", "listingTitle": "Title",
"titlePlaceholder": "What do you want to sell?", "titlePlaceholder": "What do you want to sell?",
"category": "Category", "category": "Category",
@@ -180,6 +182,8 @@
"cancel": "Cancel", "cancel": "Cancel",
"publish": "Publish", "publish": "Publish",
"publishing": "Publishing...", "publishing": "Publishing...",
"saveChanges": "Save Changes",
"saving": "Saving...",
"publishFailed": "Publishing failed. Please try again.", "publishFailed": "Publishing failed. Please try again.",
"invalidMoneroAddress": "Invalid Monero address. Please check the format.", "invalidMoneroAddress": "Invalid Monero address. Please check the format.",
"draftRestored": "Draft restored", "draftRestored": "Draft restored",

View File

@@ -139,7 +139,8 @@
"viewPlural": "vues", "viewPlural": "vues",
"share": "Partager", "share": "Partager",
"report": "Signaler", "report": "Signaler",
"moreFromSeller": "Autres annonces du vendeur" "moreFromSeller": "Autres annonces du vendeur",
"edit": "Modifier"
}, },
"chat": { "chat": {
"title": "Envoyer un message", "title": "Envoyer un message",
@@ -150,6 +151,7 @@
}, },
"create": { "create": {
"title": "Créer une annonce", "title": "Créer une annonce",
"editTitle": "Modifier l'annonce",
"listingTitle": "Titre", "listingTitle": "Titre",
"titlePlaceholder": "Que voulez-vous vendre ?", "titlePlaceholder": "Que voulez-vous vendre ?",
"category": "Catégorie", "category": "Catégorie",
@@ -180,6 +182,8 @@
"cancel": "Annuler", "cancel": "Annuler",
"publish": "Publier", "publish": "Publier",
"publishing": "Publication en cours...", "publishing": "Publication en cours...",
"saveChanges": "Enregistrer les modifications",
"saving": "Enregistrement...",
"publishFailed": "La publication a échoué. Veuillez réessayer.", "publishFailed": "La publication a échoué. Veuillez réessayer.",
"invalidMoneroAddress": "Adresse Monero invalide. Veuillez vérifier le format.", "invalidMoneroAddress": "Adresse Monero invalide. Veuillez vérifier le format.",
"draftRestored": "Brouillon restauré", "draftRestored": "Brouillon restauré",

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'dgray-v28'; const CACHE_NAME = 'dgray-v29';
const STATIC_ASSETS = [ const STATIC_ASSETS = [
'/', '/',
'/index.html', '/index.html',
@@ -57,7 +57,13 @@ self.addEventListener('fetch', (event) => {
if (request.method !== 'GET') return; if (request.method !== 'GET') return;
// API calls: Network First // API calls: Network First (external API domain)
if (url.hostname === 'api.dgray.io') {
event.respondWith(networkFirst(request));
return;
}
// Legacy: API calls via path
if (url.pathname.includes('/api/')) { if (url.pathname.includes('/api/')) {
event.respondWith(networkFirst(request)); event.respondWith(networkFirst(request));
return; return;