import { t, i18n } from '../../i18n.js' import { router } from '../../router.js' import { auth } from '../../services/auth.js' import { directus } from '../../services/directus.js' import { SUPPORTED_CURRENCIES } from '../../services/currency.js' import '../location-picker.js' import '../pow-captcha.js' import '../image-cropper.js' const STORAGE_KEY = 'dgray_create_draft' class PageCreate extends HTMLElement { constructor() { super() 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: '', currency: 'EUR', price_mode: 'fiat', price_type: 'fixed', category: '', condition: 'good', location: '', shipping: false, shipping_cost: '', moneroAddress: '' } } loadDraft() { try { const saved = localStorage.getItem(STORAGE_KEY) return saved ? JSON.parse(saved) : null } catch (e) { return null } } saveDraft() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(this.formData)) } catch (e) { // Storage full or unavailable } } clearDraft() { localStorage.removeItem(STORAGE_KEY) } async connectedCallback() { // Check if logged in if (!auth.isLoggedIn()) { this.showLoginRequired() return } // 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, shipping_cost: listing.shipping_cost?.toString() || '', 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 const user = await auth.getUser() if (user?.id) { const listings = await directus.get('/items/listings', { filter: { user_created: { _eq: user.id } }, limit: 1 }) this.isNewAccount = !listings.data || listings.data.length === 0 } } catch (e) { // Default to new account (show captcha) this.isNewAccount = true } } showLoginRequired() { // Show login modal directly const authModal = document.querySelector('auth-modal') if (authModal) { authModal.show('login') authModal.addEventListener('login', async () => { // After login, load the create page this.hasDraft = !!localStorage.getItem(STORAGE_KEY) await this.loadCategories() this.render() this.unsubscribe = i18n.subscribe(() => this.render()) }, { once: true }) authModal.addEventListener('close', () => { // If closed without login, go back if (!auth.isLoggedIn()) { router.back() } }, { once: true }) } // Show minimal loading state while modal is open this.innerHTML = /* html */`

${t('auth.loginRequired')}

` } async loadCategories() { try { this.categories = await directus.getCategories() } catch (e) { console.error('Failed to load categories:', e) this.categories = [] } } validateMoneroAddress(address) { if (!address) return false // Standard addresses: start with 4, 95 chars // Subaddresses: start with 8, 95 chars // Integrated addresses: start with 4, 106 chars const standardRegex = /^4[0-9A-Za-z]{94}$/ const subaddressRegex = /^8[0-9A-Za-z]{94}$/ const integratedRegex = /^4[0-9A-Za-z]{105}$/ return standardRegex.test(address) || subaddressRegex.test(address) || integratedRegex.test(address) } disconnectedCallback() { if (this.unsubscribe) this.unsubscribe() } render() { const pageTitle = this.editMode ? t('create.editTitle') : t('create.title') this.innerHTML = /* html */`

${pageTitle}

${!this.editMode && this.hasDraft ? `
${t('create.draftRestored')}
` : ''}

${t('create.priceModeHint')}

${t('create.locationHint') || 'Stadt oder PLZ eingeben'}

${this.renderImagePreviews()}

${t('create.moneroHint')}

${this.isNewAccount ? `
` : ''}
` this.setupEventListeners() } setupEventListeners() { const form = this.querySelector('#create-form') const cancelBtn = this.querySelector('#cancel-btn') const imageInput = this.querySelector('#images') form.addEventListener('submit', (e) => this.handleSubmit(e)) cancelBtn.addEventListener('click', () => router.back()) form.querySelectorAll('input:not([type="checkbox"]), textarea, select').forEach(input => { input.addEventListener('input', (e) => { if (e.target.name) { this.formData[e.target.name] = e.target.value this.saveDraft() } }) }) // Checkbox handler with shipping cost toggle const shippingCheckbox = this.querySelector('#shipping') const shippingCostGroup = this.querySelector('#shipping-cost-group') shippingCheckbox?.addEventListener('change', (e) => { this.formData.shipping = e.target.checked if (shippingCostGroup) { shippingCostGroup.style.display = e.target.checked ? 'block' : 'none' } this.saveDraft() }) // Location picker handler const locationPicker = this.querySelector('#location-picker') locationPicker?.addEventListener('location-select', (e) => { this.formData.locationData = e.detail this.formData.location = e.detail.name this.saveDraft() }) imageInput?.addEventListener('change', (e) => this.handleImageSelect(e)) // Clear draft button this.querySelector('#clear-draft-btn')?.addEventListener('click', () => { this.clearDraft() this.formData = { title: '', description: '', price: '', currency: 'EUR', price_mode: 'fiat', price_type: 'fixed', category: '', condition: 'good', location: '', shipping: false, moneroAddress: '' } this.hasDraft = false this.render() this.setupEventListeners() }) } handleImageSelect(e) { const files = Array.from(e.target.files) this.pendingFiles = [...files] this.processNextImage() } processNextImage() { if (this.pendingFiles.length === 0) return if (this.imageFiles.length >= 5) { this.pendingFiles = [] return } const file = this.pendingFiles.shift() const cropper = this.querySelector('#image-cropper') if (cropper) { cropper.open( file, (croppedFile, previewUrl) => { // On crop complete this.imageFiles.push(croppedFile) this.imagePreviews.push(previewUrl) this.updateImagePreviews() // Process next image this.processNextImage() }, () => { // On cancel - skip this image this.processNextImage() } ) } } updateImagePreviews() { const container = this.querySelector('#image-previews') const uploadArea = this.querySelector('#upload-area') if (container) { container.innerHTML = this.renderImagePreviews() this.setupRemoveListeners() } if (uploadArea) { uploadArea.style.display = this.imageFiles.length >= 5 ? 'none' : 'flex' } } renderImagePreviews() { if (this.imagePreviews.length === 0) return '' return this.imagePreviews.map((src, index) => /* html */`
Preview ${index + 1}
`).join('') } setupRemoveListeners() { this.querySelectorAll('.remove-image').forEach(btn => { btn.addEventListener('click', (e) => { const index = parseInt(e.currentTarget.dataset.index) this.imageFiles.splice(index, 1) this.imagePreviews.splice(index, 1) this.updateImagePreviews() }) }) } async handleSubmit(e) { e.preventDefault() if (this.submitting) return 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, shipping_cost: form.querySelector('#shipping_cost')?.value || '', 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')) return } } // Validate Monero address if (formElements.moneroAddress && !this.validateMoneroAddress(formElements.moneroAddress)) { this.showError(t('create.invalidMoneroAddress')) return } this.submitting = true this.clearError() const submitBtn = form.querySelector('[type="submit"]') submitBtn.disabled = true submitBtn.textContent = this.editMode ? t('create.saving') : t('create.publishing') try { // Upload new images first let newImageIds = [] if (this.imageFiles.length > 0) { const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles) newImageIds = uploadedFiles.map(f => f.id) } // Build listing data from form values const listingData = { 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 (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.shipping && formElements.shipping_cost) { listingData.shipping_cost = parseFloat(formElements.shipping_cost) } if (formElements.moneroAddress) listingData.monero_address = formElements.moneroAddress // Calculate expires_at (only on create) - 30 days for regular users, 60 for power users if (!this.editMode) { const days = 30 // TODO: 60 for power users const expiresAt = new Date() expiresAt.setDate(expiresAt.getDate() + days) listingData.expires_at = expiresAt.toISOString() } // Handle location - find or create in locations collection if (this.formData.locationData) { const locationId = await this.findOrCreateLocation(this.formData.locationData) if (locationId) { listingData.location = locationId } } 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('/') } } } catch (error) { console.error('Failed to save listing:', error) console.error('Error details:', JSON.stringify(error.data, null, 2)) submitBtn.disabled = false submitBtn.textContent = this.editMode ? t('create.saveChanges') : t('create.publish') this.submitting = false // Extract detailed error message const errorMsg = error.data?.errors?.[0]?.message || error.message || t('create.publishFailed') this.showError(errorMsg) } } showError(message) { let errorDiv = this.querySelector('.form-error') if (!errorDiv) { errorDiv = document.createElement('div') errorDiv.className = 'form-error' this.querySelector('.form-actions')?.insertAdjacentElement('beforebegin', errorDiv) } errorDiv.textContent = message } clearError() { this.querySelector('.form-error')?.remove() } escapeHtml(text) { const div = document.createElement('div') div.textContent = text return div.innerHTML } generateSlug(title) { return title .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .substring(0, 100) } async findOrCreateLocation(locationData) { if (!locationData || !locationData.name) return null try { // Build filter based on available data const filter = {} if (locationData.postalCode) { filter.postal_code = { _eq: locationData.postalCode } } if (locationData.countryCode) { filter.country = { _eq: locationData.countryCode } } if (locationData.name) { filter.name = { _eq: locationData.name } } // Only search if we have at least one filter if (Object.keys(filter).length > 0) { const existing = await directus.get('/items/locations', { filter, limit: 1 }) if (existing.data && existing.data.length > 0) { return existing.data[0].id } } // Create new location const newLocation = await directus.post('/items/locations', { name: locationData.name || '', postal_code: locationData.postalCode || null, region: locationData.region || null, country: locationData.countryCode || null }) return newLocation.data?.id } catch (error) { console.error('Failed to find/create location:', error) return null } } } customElements.define('page-create', PageCreate) const style = document.createElement('style') style.textContent = /* css */` page-create .create-page { max-width: 600px; margin: 0 auto; padding: var(--space-lg) 0; } page-create .create-page h1 { margin-bottom: var(--space-xl); } page-create .create-form .form-group { margin-bottom: var(--space-lg); } page-create textarea.input { resize: vertical; min-height: 120px; } page-create .image-upload { border: 2px dashed var(--color-border); border-radius: var(--radius-lg); overflow: hidden; } page-create .upload-area { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-2xl); cursor: pointer; transition: background-color var(--transition-fast); } page-create .upload-area:hover { background-color: var(--color-bg-secondary); } page-create .upload-icon { font-size: 2rem; margin-bottom: var(--space-sm); } page-create .image-previews { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: var(--space-sm); padding: var(--space-sm); } page-create .image-previews:empty { display: none; } page-create .image-preview { position: relative; aspect-ratio: 1; border-radius: var(--radius-md); overflow: hidden; } page-create .image-preview img { width: 100%; height: 100%; object-fit: cover; } page-create .remove-image { position: absolute; top: var(--space-xs); right: var(--space-xs); width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; background: var(--color-error); color: white; border-radius: var(--radius-full); cursor: pointer; opacity: 0; transition: opacity var(--transition-fast); } page-create .image-preview:hover .remove-image { opacity: 1; } page-create .field-hint { font-size: var(--font-size-sm); color: var(--color-text-muted); margin-top: var(--space-xs); } page-create .form-row { display: flex; gap: var(--space-md); } page-create .form-group-price { flex: 2; } page-create .form-group-currency { flex: 1; } page-create .checkbox-label { display: flex; align-items: center; gap: var(--space-sm); cursor: pointer; } page-create .checkbox-label input { width: 18px; height: 18px; accent-color: var(--color-accent); } page-create .login-required { text-align: center; padding: var(--space-3xl) 0; } page-create .login-required h2 { margin-bottom: var(--space-lg); } page-create .form-actions { display: flex; gap: var(--space-md); justify-content: flex-end; margin-top: var(--space-xl); } page-create .form-error { padding: var(--space-md); background: var(--color-bg-tertiary); border: 1px solid var(--color-error); border-radius: var(--radius-md); color: var(--color-text); margin-bottom: var(--space-md); font-size: var(--font-size-sm); } page-create .draft-notice { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); padding: var(--space-sm) var(--space-md); background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-md); margin-bottom: var(--space-lg); font-size: var(--font-size-sm); color: var(--color-text-secondary); } page-create .btn-link { background: none; border: none; color: var(--color-primary); font-size: var(--font-size-sm); cursor: pointer; text-decoration: underline; } page-create .btn-link:hover { color: var(--color-primary-hover); } ` document.head.appendChild(style)