add image placeholder; add dialog in listing

This commit is contained in:
2026-01-28 08:05:30 +01:00
parent b82c104362
commit 3e66d3977e
8 changed files with 382 additions and 36 deletions

View File

@@ -58,9 +58,19 @@ class ListingCard extends HTMLElement {
const priceDisplay = price ? formatPrice(parseFloat(price), currency) : ''; const priceDisplay = price ? formatPrice(parseFloat(price), currency) : '';
const favoriteLabel = this.isFavorite ? t('home.removeFavorite') : t('home.addFavorite'); const favoriteLabel = this.isFavorite ? t('home.removeFavorite') : t('home.addFavorite');
const placeholderSvg = /* html */`
<svg class="placeholder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`;
this.innerHTML = /* html */` this.innerHTML = /* html */`
<a href="#/listing/${escapeHTML(id)}" class="listing-link"> <a href="#/listing/${escapeHTML(id)}" class="listing-link">
<div class="listing-image" ${image ? `style="background-image: url('${escapeHTML(image)}')"` : ''}></div> <div class="listing-image" ${image ? `style="background-image: url('${escapeHTML(image)}')"` : ''}>
${!image ? placeholderSvg : ''}
</div>
<div class="listing-info"> <div class="listing-info">
<h3 class="listing-title">${escapeHTML(title)}</h3> <h3 class="listing-title">${escapeHTML(title)}</h3>
<p class="listing-price">${priceDisplay}</p> <p class="listing-price">${priceDisplay}</p>
@@ -138,6 +148,15 @@ style.textContent = /* css */`
background: var(--color-bg-tertiary); background: var(--color-bg-tertiary);
background-size: cover; background-size: cover;
background-position: center; background-position: center;
display: flex;
align-items: center;
justify-content: center;
}
listing-card .listing-image .placeholder-icon {
width: 48px;
height: 48px;
color: var(--color-border);
} }
listing-card .listing-info { listing-card .listing-info {

View File

@@ -1,4 +1,5 @@
import { t, i18n } from '../../i18n.js'; import { t, i18n } from '../../i18n.js';
import { mockListings } from '../../data/mock-listings.js';
import '../listing-card.js'; import '../listing-card.js';
import '../search-box.js'; import '../search-box.js';
@@ -21,20 +22,19 @@ class PageHome extends HTMLElement {
<section class="recent-listings"> <section class="recent-listings">
<h2>${t('home.recentListings')}</h2> <h2>${t('home.recentListings')}</h2>
<div class="listings-grid"> <div class="listings-grid">
${this.renderPlaceholderListings()} ${this.renderListings()}
</div> </div>
</section> </section>
`; `;
} }
renderPlaceholderListings() { renderListings() {
const placeholders = Array(10).fill(null); return mockListings.map(listing => /* html */`
return placeholders.map((_, i) => /* html */`
<listing-card <listing-card
listing-id="${i + 1}" listing-id="${listing.id}"
title="${t('home.placeholderTitle')}" title="${listing.title}"
price="699" price="${listing.price}"
location="${t('home.placeholderLocation')}" location="${listing.location}"
></listing-card> ></listing-card>
`).join(''); `).join('');
} }

View File

@@ -1,4 +1,5 @@
import { t, i18n } from '../../i18n.js'; import { t, i18n } from '../../i18n.js';
import { getListingById } from '../../data/mock-listings.js';
class PageListing extends HTMLElement { class PageListing extends HTMLElement {
constructor() { constructor() {
@@ -21,20 +22,7 @@ class PageListing extends HTMLElement {
async loadListing() { async loadListing() {
await new Promise(resolve => setTimeout(resolve, 300)); await new Promise(resolve => setTimeout(resolve, 300));
this.listing = { this.listing = getListingById(this.listingId);
id: this.listingId,
title: 'iPhone 13 Pro - Sehr guter Zustand',
description: 'Verkaufe mein iPhone 13 Pro in sehr gutem Zustand. Das Gerät hat keine Kratzer und funktioniert einwandfrei. Originalverpackung und Ladekabel sind dabei.',
price: 699,
location: 'Berlin, Mitte',
category: 'electronics',
createdAt: new Date().toISOString(),
seller: {
name: 'Max M.',
memberSince: '2023'
},
images: []
};
this.loading = false; this.loading = false;
this.render(); this.render();
@@ -61,10 +49,21 @@ class PageListing extends HTMLElement {
return; return;
} }
const hasImages = this.listing.images && this.listing.images.length > 0;
const placeholderSvg = /* html */`
<svg class="placeholder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`;
this.innerHTML = /* html */` this.innerHTML = /* html */`
<article class="listing-detail"> <article class="listing-detail">
<div class="listing-gallery"> <div class="listing-gallery">
<div class="listing-image-main"></div> <div class="listing-image-main">
${!hasImages ? placeholderSvg : ''}
</div>
</div> </div>
<div class="listing-info"> <div class="listing-info">
@@ -92,13 +91,72 @@ class PageListing extends HTMLElement {
</section> </section>
<div class="listing-actions"> <div class="listing-actions">
<button class="btn btn-primary btn-lg"> <button class="btn btn-primary btn-lg" id="contact-btn">
${t('listing.contactSeller')} ${t('listing.contactSeller')}
</button> </button>
</div> </div>
</div> </div>
</article> </article>
<!-- Contact Dialog -->
<dialog class="contact-dialog" id="contact-dialog">
<button class="dialog-close" id="dialog-close" aria-label="Close">
<svg width="24" height="24" 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>
<h2>${t('listing.contactSeller')}</h2>
<p class="dialog-subtitle">${t('listing.paymentInfo')}</p>
<div class="monero-section">
<label>${t('listing.moneroAddress')}</label>
<div class="monero-address">
<code id="monero-addr">${this.listing.seller.moneroAddress || '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H'}</code>
<button class="btn btn-outline btn-copy" id="copy-btn" title="${t('listing.copyAddress')}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
</div>
<p class="dialog-hint">${t('listing.contactHint')}</p>
</dialog>
`; `;
this.setupEventListeners();
}
setupEventListeners() {
const contactBtn = this.querySelector('#contact-btn');
const dialog = this.querySelector('#contact-dialog');
const closeBtn = this.querySelector('#dialog-close');
const copyBtn = this.querySelector('#copy-btn');
contactBtn?.addEventListener('click', () => {
dialog?.showModal();
});
closeBtn?.addEventListener('click', () => {
dialog?.close();
});
dialog?.addEventListener('click', (e) => {
if (e.target === dialog) {
dialog.close();
}
});
copyBtn?.addEventListener('click', async () => {
const addr = this.querySelector('#monero-addr')?.textContent;
if (addr) {
await navigator.clipboard.writeText(addr);
copyBtn.classList.add('copied');
setTimeout(() => copyBtn.classList.remove('copied'), 2000);
}
});
} }
escapeHtml(text) { escapeHtml(text) {
@@ -134,6 +192,15 @@ style.textContent = /* css */`
page-listing .listing-image-main { page-listing .listing-image-main {
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
background: var(--color-bg-tertiary); background: var(--color-bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
}
page-listing .listing-image-main .placeholder-icon {
width: 80px;
height: 80px;
color: var(--color-border);
} }
page-listing .listing-info header { page-listing .listing-info header {
@@ -205,5 +272,92 @@ style.textContent = /* css */`
page-listing .listing-actions .btn { page-listing .listing-actions .btn {
width: 100%; width: 100%;
} }
/* Dialog */
page-listing .contact-dialog {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
max-width: 500px;
width: calc(100% - 2 * var(--space-md));
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
}
page-listing .contact-dialog::backdrop {
background: var(--color-overlay);
}
page-listing .dialog-close {
position: absolute;
top: var(--space-md);
right: var(--space-md);
padding: var(--space-xs);
color: var(--color-text-muted);
transition: color var(--transition-fast);
}
page-listing .dialog-close:hover {
color: var(--color-text);
}
page-listing .contact-dialog h2 {
margin-bottom: var(--space-sm);
}
page-listing .dialog-subtitle {
color: var(--color-text-secondary);
margin-bottom: var(--space-lg);
}
page-listing .monero-section {
margin-bottom: var(--space-lg);
}
page-listing .monero-section label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
margin-bottom: var(--space-sm);
color: var(--color-text-secondary);
}
page-listing .monero-address {
display: flex;
gap: var(--space-sm);
align-items: stretch;
}
page-listing .monero-address code {
flex: 1;
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--font-size-xs);
word-break: break-all;
line-height: 1.4;
}
page-listing .btn-copy {
padding: var(--space-sm);
flex-shrink: 0;
}
page-listing .btn-copy.copied {
background: var(--color-success);
border-color: var(--color-success);
color: white;
}
page-listing .dialog-hint {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-align: center;
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);

View File

@@ -1,5 +1,6 @@
import { t, i18n } from '../../i18n.js'; import { t, i18n } from '../../i18n.js';
import { router } from '../../router.js'; import { router } from '../../router.js';
import { searchListings } from '../../data/mock-listings.js';
import '../search-box.js'; import '../search-box.js';
import '../listing-card.js'; import '../listing-card.js';
@@ -101,14 +102,7 @@ class PageSearch extends HTMLElement {
} }
getMockResults() { getMockResults() {
return [ return searchListings(this.query, this.category, this.subcategory);
{ id: 1, title: 'iPhone 13 Pro', price: 699, location: 'Berlin' },
{ id: 2, title: 'Vintage Sofa', price: 250, location: 'München' },
{ id: 3, title: 'Mountain Bike', price: 450, location: 'Hamburg' },
{ id: 4, title: 'Gaming PC', price: 1200, location: 'Köln' },
{ id: 5, title: 'Schreibtisch', price: 80, location: 'Zürich' },
{ id: 6, title: 'Winterjacke', price: 45, location: 'Wien' },
];
} }
updateResults() { updateResults() {

167
js/data/mock-listings.js Normal file
View File

@@ -0,0 +1,167 @@
export const mockListings = [
{
id: '1',
title: 'iPhone 13 Pro - Sehr guter Zustand',
description: 'Verkaufe mein iPhone 13 Pro in sehr gutem Zustand. Das Gerät hat keine Kratzer und funktioniert einwandfrei. Originalverpackung und Ladekabel sind dabei.',
price: 699,
location: 'Berlin, Mitte',
category: 'electronics',
subcategory: 'phones',
createdAt: '2026-01-27T10:00:00Z',
seller: {
name: 'Max M.',
memberSince: '2023',
moneroAddress: '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H'
}
},
{
id: '2',
title: 'Vintage Ledersofa 3-Sitzer',
description: 'Wunderschönes Vintage-Ledersofa aus den 70er Jahren. Cognacfarben, leichte Patina die dem Stück Charakter verleiht. Sehr bequem.',
price: 450,
location: 'München, Schwabing',
category: 'furniture',
subcategory: 'living',
createdAt: '2026-01-26T14:30:00Z',
seller: {
name: 'Anna K.',
memberSince: '2024',
moneroAddress: '47sghzufGhJJDQEbScMCwVBimTuq6L5JiRixD8VeGbpjCTA12GwZVPWzjmpfLDJNDAWvuNDAWvuNDAWvuNDAWvuN'
}
},
{
id: '3',
title: 'Canyon Mountainbike 29 Zoll',
description: 'Canyon Spectral AL 6.0 in Größe L. Carbon-Rahmen, Shimano XT Schaltung, frisch gewartet. Ideal für Trails und Touren.',
price: 1200,
location: 'Zürich',
category: 'sports',
subcategory: 'outdoor',
createdAt: '2026-01-25T09:15:00Z',
seller: {
name: 'Thomas B.',
memberSince: '2022',
moneroAddress: '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A'
}
},
{
id: '4',
title: 'Gaming PC - RTX 4070, Ryzen 7',
description: 'Selbstgebauter Gaming-PC: Ryzen 7 5800X, RTX 4070, 32GB RAM, 1TB NVMe SSD. Perfekt für 1440p Gaming. RGB-Beleuchtung.',
price: 1450,
location: 'Hamburg',
category: 'electronics',
subcategory: 'gaming',
createdAt: '2026-01-24T16:45:00Z',
seller: {
name: 'Felix R.',
memberSince: '2023',
moneroAddress: '48iWMy1PH6VGBJVvHDg9mY7mJ6vBDWVHpGgXEtCGp99kT4Xk5QfN3v7nqMrqGpvU'
}
},
{
id: '5',
title: 'Ikea MALM Schreibtisch weiß',
description: 'Ikea MALM Schreibtisch in weiß, 140x65cm. Minimale Gebrauchsspuren, Kabelmanagement integriert. Selbstabholung.',
price: 80,
location: 'Wien, 1050',
category: 'furniture',
subcategory: 'office',
createdAt: '2026-01-23T11:20:00Z',
seller: {
name: 'Lisa S.',
memberSince: '2025',
moneroAddress: '45dEQp8dFKrMXKvFWJFmZKCZhH3ARYMW4MJYM9FJcPuNT5Kek9R'
}
},
{
id: '6',
title: 'Canada Goose Winterjacke M',
description: 'Original Canada Goose Expedition Parka in Schwarz, Größe M. Sehr warm, perfekt für extreme Kälte. NP 1200€.',
price: 550,
location: 'Köln',
category: 'clothing',
subcategory: 'men',
createdAt: '2026-01-22T08:00:00Z',
seller: {
name: 'Jan P.',
memberSince: '2024',
moneroAddress: '42nTNQp8dFKrMXKvFWJFmZKCZhH3ARYMW4MJYM9FJcPuNT5Kek9R'
}
},
{
id: '7',
title: 'Sony A7 III Kamera + 24-70mm',
description: 'Sony A7 III Vollformat-Kamera mit Sony 24-70mm f/2.8 GM Objektiv. 15.000 Auslösungen, einwandfreier Zustand.',
price: 2200,
location: 'Frankfurt',
category: 'electronics',
subcategory: 'tv_audio',
createdAt: '2026-01-21T13:10:00Z',
seller: {
name: 'Sarah M.',
memberSince: '2021',
moneroAddress: '46BeWrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QVy24G3euoGWpzR7T3'
}
},
{
id: '8',
title: 'Elektrische Gitarre Fender Strat',
description: 'Fender Stratocaster Player Series in Sunburst. Ahorn-Hals, 3 Single-Coil Pickups. Inkl. Gigbag.',
price: 650,
location: 'Stuttgart',
category: 'other',
subcategory: 'art',
createdAt: '2026-01-20T17:30:00Z',
seller: {
name: 'Michael W.',
memberSince: '2022',
moneroAddress: '43gFrHpwXmHDpDEUmZBWZfoQpdc6HaERCNmx1pEYL2rAcuwufPN9rXHHtyUA4QVy24G3euoGWpzR7T3'
}
},
{
id: '9',
title: 'Weber Gasgrill Genesis II',
description: 'Weber Genesis II E-310 Gasgrill in Schwarz. 3 Brenner, Sear Station, iGrill kompatibel. Wenig benutzt.',
price: 480,
location: 'Düsseldorf',
category: 'garden',
subcategory: 'outdoor_living',
createdAt: '2026-01-19T10:45:00Z',
seller: {
name: 'Klaus H.',
memberSince: '2023',
moneroAddress: '47sghzufGhJJDQEbScMCwVBimTuq6L5JiRixD8VeGbpjCTA12GwZVPWzjmpfLDJNDAWvuNDAWvuNDAWvuNDAWvuN'
}
},
{
id: '10',
title: 'Adidas Ultra Boost 22 Gr. 43',
description: 'Adidas Ultra Boost 22 Laufschuhe in Core Black, Größe 43. Nur wenige Male getragen, wie neu.',
price: 95,
location: 'Bern',
category: 'clothing',
subcategory: 'shoes',
createdAt: '2026-01-18T15:20:00Z',
seller: {
name: 'Nina L.',
memberSince: '2024',
moneroAddress: '44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A'
}
}
];
export function getListingById(id) {
return mockListings.find(l => l.id === id) || null;
}
export function searchListings(query = '', category = '', subcategory = '') {
return mockListings.filter(listing => {
const matchesQuery = !query ||
listing.title.toLowerCase().includes(query.toLowerCase()) ||
listing.description.toLowerCase().includes(query.toLowerCase());
const matchesCategory = !category || listing.category === category;
const matchesSubcategory = !subcategory || listing.subcategory === subcategory;
return matchesQuery && matchesCategory && matchesSubcategory;
});
}

View File

@@ -99,7 +99,11 @@
"description": "Beschreibung", "description": "Beschreibung",
"seller": "Anbieter", "seller": "Anbieter",
"memberSince": "Mitglied seit", "memberSince": "Mitglied seit",
"contactSeller": "Anbieter kontaktieren" "contactSeller": "Anbieter kontaktieren",
"paymentInfo": "Bezahlung erfolgt direkt über Monero (XMR).",
"moneroAddress": "Monero-Adresse des Anbieters",
"copyAddress": "Adresse kopieren",
"contactHint": "Kopiere die Adresse und sende den Betrag über dein Monero-Wallet."
}, },
"create": { "create": {
"title": "Anzeige erstellen", "title": "Anzeige erstellen",

View File

@@ -99,7 +99,11 @@
"description": "Description", "description": "Description",
"seller": "Seller", "seller": "Seller",
"memberSince": "Member since", "memberSince": "Member since",
"contactSeller": "Contact Seller" "contactSeller": "Contact Seller",
"paymentInfo": "Payment is made directly via Monero (XMR).",
"moneroAddress": "Seller's Monero Address",
"copyAddress": "Copy address",
"contactHint": "Copy the address and send the amount using your Monero wallet."
}, },
"create": { "create": {
"title": "Create Listing", "title": "Create Listing",

View File

@@ -99,7 +99,11 @@
"description": "Description", "description": "Description",
"seller": "Vendeur", "seller": "Vendeur",
"memberSince": "Membre depuis", "memberSince": "Membre depuis",
"contactSeller": "Contacter le vendeur" "contactSeller": "Contacter le vendeur",
"paymentInfo": "Le paiement s'effectue directement via Monero (XMR).",
"moneroAddress": "Adresse Monero du vendeur",
"copyAddress": "Copier l'adresse",
"contactHint": "Copiez l'adresse et envoyez le montant via votre portefeuille Monero."
}, },
"create": { "create": {
"title": "Créer une annonce", "title": "Créer une annonce",