Files
kashilo/js/components/pages/page-create.js
2026-02-03 14:44:36 +01:00

730 lines
26 KiB
JavaScript

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'
const STORAGE_KEY = 'dgray_create_draft'
class PageCreate extends HTMLElement {
constructor() {
super()
this.formData = this.loadDraft() || {
title: '',
description: '',
price: '',
currency: 'EUR',
price_mode: 'fiat',
price_type: 'fixed',
category: '',
condition: 'good',
location: '',
shipping: false,
moneroAddress: ''
}
this.imageFiles = []
this.imagePreviews = []
this.categories = []
this.submitting = false
}
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
}
this.hasDraft = !!localStorage.getItem(STORAGE_KEY)
await this.loadCategories()
this.render()
this.unsubscribe = i18n.subscribe(() => this.render())
}
showLoginRequired() {
this.innerHTML = /* html */`
<div class="create-page">
<div class="login-required">
<h2>${t('auth.loginRequired')}</h2>
<button class="btn btn-primary btn-lg" id="login-btn">${t('auth.login')}</button>
</div>
</div>
`
this.querySelector('#login-btn')?.addEventListener('click', () => {
const authModal = document.querySelector('auth-modal')
if (authModal) {
authModal.show('login')
authModal.addEventListener('login', () => {
this.connectedCallback()
}, { once: true })
}
})
}
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() {
this.innerHTML = /* html */`
<div class="create-page">
<h1 data-i18n="create.title">${t('create.title')}</h1>
${this.hasDraft ? `
<div class="draft-notice">
<span>${t('create.draftRestored')}</span>
<button type="button" class="btn-link" id="clear-draft-btn">${t('create.clearDraft')}</button>
</div>
` : ''}
<form id="create-form" class="create-form">
<div class="form-group">
<label class="label" for="title" data-i18n="create.listingTitle">${t('create.listingTitle')}</label>
<input
type="text"
class="input"
id="title"
name="title"
value="${this.escapeHtml(this.formData.title)}"
required
data-i18n-placeholder="create.titlePlaceholder"
placeholder="${t('create.titlePlaceholder')}"
>
</div>
<div class="form-group">
<label class="label" for="category">${t('create.category')}</label>
<select class="input" id="category" name="category" required>
<option value="">${t('create.selectCategory')}</option>
${this.categories.map(cat => `
<option value="${cat.id}" ${this.formData.category === cat.id ? 'selected' : ''}>
${t(`categories.${cat.slug}`) || cat.name}
</option>
`).join('')}
</select>
</div>
<div class="form-group">
<label class="label" for="condition">${t('create.condition')}</label>
<select class="input" id="condition" name="condition" required>
<option value="new" ${this.formData.condition === 'new' ? 'selected' : ''}>${t('create.conditionNew')}</option>
<option value="like_new" ${this.formData.condition === 'like_new' ? 'selected' : ''}>${t('create.conditionLikeNew')}</option>
<option value="good" ${this.formData.condition === 'good' ? 'selected' : ''}>${t('create.conditionGood')}</option>
<option value="fair" ${this.formData.condition === 'fair' ? 'selected' : ''}>${t('create.conditionFair')}</option>
<option value="poor" ${this.formData.condition === 'poor' ? 'selected' : ''}>${t('create.conditionPoor')}</option>
</select>
</div>
<div class="form-row">
<div class="form-group form-group-price">
<label class="label" for="price">${t('create.price')}</label>
<input
type="number"
class="input"
id="price"
name="price"
value="${this.formData.price}"
min="0"
step="0.0001"
required
placeholder="0.00"
>
</div>
<div class="form-group form-group-currency">
<label class="label" for="currency">${t('create.currency')}</label>
<select class="input" id="currency" name="currency">
${SUPPORTED_CURRENCIES.map(cur => `
<option value="${cur}" ${this.formData.currency === cur ? 'selected' : ''}>${cur}</option>
`).join('')}
</select>
</div>
</div>
<div class="form-group">
<label class="label" for="price_mode">${t('create.priceMode')}</label>
<select class="input" id="price_mode" name="price_mode">
<option value="fiat" ${this.formData.price_mode === 'fiat' ? 'selected' : ''}>${t('create.priceModeFiat')}</option>
<option value="xmr" ${this.formData.price_mode === 'xmr' ? 'selected' : ''}>${t('create.priceModeXmr')}</option>
</select>
<p class="field-hint">${t('create.priceModeHint')}</p>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="shipping" name="shipping" ${this.formData.shipping ? 'checked' : ''}>
<span>${t('create.shippingAvailable')}</span>
</label>
</div>
<div class="form-group">
<label class="label" for="location" data-i18n="create.location">${t('create.location')}</label>
<location-picker
id="location-picker"
placeholder="${t('create.locationPlaceholder')}"
></location-picker>
<p class="field-hint">${t('create.locationHint') || 'Stadt oder PLZ eingeben'}</p>
</div>
<div class="form-group">
<label class="label" for="description" data-i18n="create.description">${t('create.description')}</label>
<textarea
class="input"
id="description"
name="description"
rows="5"
required
data-i18n-placeholder="create.descriptionPlaceholder"
placeholder="${t('create.descriptionPlaceholder')}"
>${this.escapeHtml(this.formData.description)}</textarea>
</div>
<div class="form-group">
<label class="label" data-i18n="create.images">${t('create.images')}</label>
<div class="image-upload">
<input type="file" id="images" name="images" accept="image/*" multiple hidden>
<label for="images" class="upload-area" id="upload-area">
<span class="upload-icon">📷</span>
<span data-i18n="create.uploadImages">${t('create.uploadImages')}</span>
</label>
<div class="image-previews" id="image-previews">
${this.renderImagePreviews()}
</div>
</div>
</div>
<div class="form-group">
<label class="label" for="moneroAddress">${t('create.moneroAddress')}</label>
<input
type="text"
class="input"
id="moneroAddress"
name="moneroAddress"
value="${this.escapeHtml(this.formData.moneroAddress)}"
required
placeholder="${t('create.moneroPlaceholder')}"
>
<p class="field-hint">${t('create.moneroHint')}</p>
</div>
<div class="form-group">
<pow-captcha id="pow-captcha"></pow-captcha>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline btn-lg" id="cancel-btn">
${t('create.cancel')}
</button>
<button type="submit" class="btn btn-primary btn-lg">
${t('create.publish')}
</button>
</div>
</form>
</div>
`
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
const shippingCheckbox = this.querySelector('#shipping')
shippingCheckbox?.addEventListener('change', (e) => {
this.formData.shipping = e.target.checked
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)
files.forEach(file => {
if (this.imageFiles.length >= 5) return
this.imageFiles.push(file)
const reader = new FileReader()
reader.onload = (e) => {
this.imagePreviews.push(e.target.result)
this.updateImagePreviews()
}
reader.readAsDataURL(file)
})
}
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 */`
<div class="image-preview">
<img src="${src}" alt="Preview ${index + 1}">
<button type="button" class="remove-image" data-index="${index}" aria-label="Remove">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`).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
// Validate PoW Captcha
const captcha = this.querySelector('#pow-captcha')
if (!captcha?.isSolved()) {
this.showError(t('captcha.error'))
return
}
// Validate Monero address
if (this.formData.moneroAddress && !this.validateMoneroAddress(this.formData.moneroAddress)) {
this.showError(t('create.invalidMoneroAddress'))
return
}
this.submitting = true
this.clearError()
const form = e.target
const submitBtn = form.querySelector('[type="submit"]')
submitBtn.disabled = true
submitBtn.textContent = t('create.publishing')
try {
// Upload images first
let imageIds = []
if (this.imageFiles.length > 0) {
const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles)
imageIds = uploadedFiles.map(f => f.id)
}
// Create listing
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'
}
// 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
// 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
}
}
// Add images in junction table format
if (imageIds.length > 0) {
listingData.images = {
create: imageIds.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 {
// Listing created but no ID returned - go to home
router.navigate('/')
}
} catch (error) {
console.error('Failed to create listing:', error)
console.error('Error details:', JSON.stringify(error.data, null, 2))
submitBtn.disabled = false
submitBtn.textContent = 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)