From 5895ab7e98123d5dd4910902a82a3330367c5dce Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Wed, 4 Feb 2026 14:57:34 +0100 Subject: [PATCH] feat: add listing edit mode with owner detection, fix service worker API caching for external domain --- AGENTS.md | 7 +- README.md | 1 + docs/DIRECTUS-SCHEMA.md | 23 +++- js/components/app-shell.js | 1 + js/components/pages/page-create.js | 191 ++++++++++++++++++++-------- js/components/pages/page-listing.js | 57 ++++++++- locales/de.json | 6 +- locales/en.json | 6 +- locales/fr.json | 6 +- service-worker.js | 10 +- 10 files changed, 245 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 133a432..5cb8243 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`) 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 +3. ~~Listings bearbeiten~~ ✅ Edit-Modus via `#/edit/:id` +4. Payment-Integration mit BTCpay Server (https://pay.xmr.rocks/) +5. Reputation-System (5/15/50 Deals Stufen) +6. Push-Benachrichtigungen für neue Nachrichten ## Directus Berechtigungen (Public-Rolle) diff --git a/README.md b/README.md index 5881aac..f503f65 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ dgray/ - [x] API-Services (`directus.js`, `listings.js`, `categories.js`, `locations.js`) - [x] Directus Public-Berechtigungen (siehe `docs/DIRECTUS-SCHEMA.md`) - [x] Neue Seiten: Favoriten, Meine Anzeigen, Nachrichten, Einstellungen +- [x] Listings bearbeiten (Edit-Modus für eigene Anzeigen) ### Phase 3: Kommunikation - [x] Chat-System (E2E-verschlüsselt mit NaCl) diff --git a/docs/DIRECTUS-SCHEMA.md b/docs/DIRECTUS-SCHEMA.md index 4d4a043..1481b50 100644 --- a/docs/DIRECTUS-SCHEMA.md +++ b/docs/DIRECTUS-SCHEMA.md @@ -193,7 +193,7 @@ Meldungen von Anzeigen. | 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 | | `directus_files` | ✓ | ✓ | - | - | Asset-Upload | | `categories` | ✓ | - | - | - | Nur `status=published` | @@ -205,6 +205,27 @@ Meldungen von Anzeigen. | `favorites` | ✓ | ✓ | - | ✓ | Nur eigene | | `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 diff --git a/js/components/app-shell.js b/js/components/app-shell.js index b729244..3fea09e 100644 --- a/js/components/app-shell.js +++ b/js/components/app-shell.js @@ -51,6 +51,7 @@ class AppShell extends HTMLElement { .register('/search', 'page-home') // Redirect search to home .register('/listing/:id', 'page-listing') .register('/create', 'page-create') + .register('/edit/:id', 'page-create') .register('/favorites', 'page-favorites') .register('/my-listings', 'page-my-listings') .register('/messages', 'page-messages') diff --git a/js/components/pages/page-create.js b/js/components/pages/page-create.js index 57c7d51..f3f43d3 100644 --- a/js/components/pages/page-create.js +++ b/js/components/pages/page-create.js @@ -11,7 +11,19 @@ const STORAGE_KEY = 'dgray_create_draft' class PageCreate extends HTMLElement { constructor() { 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: '', description: '', price: '', @@ -24,11 +36,6 @@ class PageCreate extends HTMLElement { shipping: false, moneroAddress: '' } - this.imageFiles = [] - this.imagePreviews = [] - this.categories = [] - this.submitting = false - this.isNewAccount = true } loadDraft() { @@ -59,13 +66,63 @@ class PageCreate extends HTMLElement { 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.checkAccountStatus() 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() { try { // Check if user has any published listings @@ -138,11 +195,13 @@ class PageCreate extends HTMLElement { } render() { + const pageTitle = this.editMode ? t('create.editTitle') : t('create.title') + this.innerHTML = /* html */`
-

${t('create.title')}

+

${pageTitle}

- ${this.hasDraft ? ` + ${!this.editMode && this.hasDraft ? `
${t('create.draftRestored')} @@ -289,7 +348,7 @@ class PageCreate extends HTMLElement { ${t('create.cancel')}
@@ -417,8 +476,23 @@ class PageCreate extends HTMLElement { if (this.submitting) return - // Validate PoW Captcha (only for new accounts) - if (this.isNewAccount) { + const form = e.target + + // 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') if (!captcha?.isSolved()) { this.showError(t('captcha.error')) @@ -427,7 +501,7 @@ class PageCreate extends HTMLElement { } // Validate Monero address - if (this.formData.moneroAddress && !this.validateMoneroAddress(this.formData.moneroAddress)) { + if (formElements.moneroAddress && !this.validateMoneroAddress(formElements.moneroAddress)) { this.showError(t('create.invalidMoneroAddress')) return } @@ -435,35 +509,38 @@ class PageCreate extends HTMLElement { this.submitting = true this.clearError() - const form = e.target const submitBtn = form.querySelector('[type="submit"]') submitBtn.disabled = true - submitBtn.textContent = t('create.publishing') + submitBtn.textContent = this.editMode ? t('create.saving') : t('create.publishing') try { - // Upload images first - let imageIds = [] + // Upload new images first + let newImageIds = [] if (this.imageFiles.length > 0) { 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 = { - title: this.formData.title, - slug: this.generateSlug(this.formData.title), - description: this.formData.description, - price: String(parseFloat(this.formData.price) || 0), - currency: this.formData.currency, - status: 'published' + title: formElements.title, + slug: this.generateSlug(formElements.title), + description: formElements.description, + price: String(parseFloat(formElements.price) || 0), + currency: formElements.currency + } + + // Only set status on create, not on edit + if (!this.editMode) { + listingData.status = 'published' } // Add optional fields only if set - if (this.formData.price_mode) listingData.price_mode = this.formData.price_mode - if (this.formData.category) listingData.category = this.formData.category - if (this.formData.condition) listingData.condition = this.formData.condition - if (this.formData.shipping) listingData.shipping = this.formData.shipping - if (this.formData.moneroAddress) listingData.monero_address = this.formData.moneroAddress + if (formElements.price_mode) listingData.price_mode = formElements.price_mode + if (formElements.category) listingData.category = formElements.category + if (formElements.condition) listingData.condition = formElements.condition + listingData.shipping = formElements.shipping + if (formElements.moneroAddress) listingData.monero_address = formElements.moneroAddress // Handle location - find or create in locations collection if (this.formData.locationData) { @@ -473,31 +550,45 @@ class PageCreate extends HTMLElement { } } - // Add images in junction table format - if (imageIds.length > 0) { - listingData.images = { - create: imageIds.map((id, index) => ({ - directus_files_id: id, - sort: index - })) + if (this.editMode) { + // Update existing listing + // Add new images if any + if (newImageIds.length > 0) { + listingData.images = { + 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) { - console.error('Failed to create listing:', error) + console.error('Failed to save listing:', error) console.error('Error details:', JSON.stringify(error.data, null, 2)) submitBtn.disabled = false - submitBtn.textContent = t('create.publish') + submitBtn.textContent = this.editMode ? t('create.saveChanges') : t('create.publish') this.submitting = false // Extract detailed error message diff --git a/js/components/pages/page-listing.js b/js/components/pages/page-listing.js index 2df8c3f..d113e1e 100644 --- a/js/components/pages/page-listing.js +++ b/js/components/pages/page-listing.js @@ -1,5 +1,6 @@ import { t, i18n } from '../../i18n.js' import { directus } from '../../services/directus.js' +import { auth } from '../../services/auth.js' import { listingsService } from '../../services/listings.js' import { getXmrRates, formatPrice as formatCurrencyPrice } from '../../services/currency.js' import '../chat-widget.js' @@ -14,6 +15,7 @@ class PageListing extends HTMLElement { this.loading = true this.isFavorite = false this.rates = null + this.isOwner = false } connectedCallback() { @@ -33,10 +35,15 @@ class PageListing extends HTMLElement { this.rates = await getXmrRates() this.loadFavoriteState() - // Increment view counter and update local state - const newViews = await directus.incrementListingViews(this.listingId) - if (newViews !== null) { - this.listing.views = newViews + // Check if current user is owner + await this.checkOwnership() + + // 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 @@ -53,6 +60,20 @@ class PageListing extends HTMLElement { 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() { try { const response = await directus.getListings({ @@ -234,6 +255,34 @@ class PageListing extends HTMLElement { } renderSidebar() { + // Owner view: show edit button instead of contact + if (this.isOwner) { + return /* html */` + + ` + } + return /* html */`