cleanup semicolon from js

This commit is contained in:
2026-01-31 15:43:58 +01:00
parent f919079f69
commit 640e7a3a4f
22 changed files with 1258 additions and 1269 deletions

View File

@@ -1,25 +1,25 @@
import { i18n } from './i18n.js';
import { i18n } from './i18n.js'
async function initApp() {
const savedTheme = localStorage.getItem('theme');
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
document.documentElement.dataset.theme = savedTheme;
document.documentElement.dataset.theme = savedTheme
}
await i18n.init();
await i18n.init()
await import('./components/app-shell.js');
await import('./components/app-shell.js')
document.getElementById('app').innerHTML = '<app-shell></app-shell>';
document.getElementById('app').innerHTML = '<app-shell></app-shell>'
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js');
console.log('SW registered:', registration.scope);
const registration = await navigator.serviceWorker.register('/service-worker.js')
console.log('SW registered:', registration.scope)
} catch (error) {
console.log('SW registration failed:', error);
console.log('SW registration failed:', error)
}
}
}
initApp();
initApp()

View File

@@ -1,12 +1,12 @@
import { t } from '../i18n.js';
import { t } from '../i18n.js'
class AppFooter extends HTMLElement {
connectedCallback() {
this.render();
this.render()
}
render() {
const year = new Date().getFullYear();
const year = new Date().getFullYear()
this.innerHTML = /* html */`
<div class="footer-inner container">
@@ -20,12 +20,12 @@ class AppFooter extends HTMLElement {
<a href="#/contact" data-i18n="footer.contact">${t('footer.contact')}</a>
</nav>
</div>
`;
`
}
updateTranslations() {
this.render();
this.render()
}
}
customElements.define('app-footer', AppFooter);
customElements.define('app-footer', AppFooter)

View File

@@ -1,80 +1,80 @@
import { i18n, t } from '../i18n.js';
import { router } from '../router.js';
import { auth } from '../services/auth.js';
import { i18n, t } from '../i18n.js'
import { router } from '../router.js'
import { auth } from '../services/auth.js'
class AppHeader extends HTMLElement {
constructor() {
super();
this.langDropdownOpen = false;
this.handleOutsideClick = this.handleOutsideClick.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
super()
this.langDropdownOpen = false
this.handleOutsideClick = this.handleOutsideClick.bind(this)
this.handleKeydown = this.handleKeydown.bind(this)
}
connectedCallback() {
this.render();
this.setupEventListeners();
document.addEventListener('click', this.handleOutsideClick);
document.addEventListener('keydown', this.handleKeydown);
this.render()
this.setupEventListeners()
document.addEventListener('click', this.handleOutsideClick)
document.addEventListener('keydown', this.handleKeydown)
// Subscribe to auth changes (only once!)
this.authUnsubscribe = auth.subscribe(() => {
this.render();
this.setupEventListeners();
});
this.render()
this.setupEventListeners()
})
}
disconnectedCallback() {
document.removeEventListener('click', this.handleOutsideClick);
document.removeEventListener('keydown', this.handleKeydown);
if (this.authUnsubscribe) this.authUnsubscribe();
document.removeEventListener('click', this.handleOutsideClick)
document.removeEventListener('keydown', this.handleKeydown)
if (this.authUnsubscribe) this.authUnsubscribe()
}
handleOutsideClick() {
if (this.langDropdownOpen) {
this.closeDropdown();
this.closeDropdown()
}
}
handleKeydown(e) {
if (!this.langDropdownOpen) return;
if (!this.langDropdownOpen) return
const items = Array.from(this.querySelectorAll('.dropdown-item'));
const currentIndex = items.findIndex(item => item === document.activeElement);
const items = Array.from(this.querySelectorAll('.dropdown-item'))
const currentIndex = items.findIndex(item => item === document.activeElement)
switch (e.key) {
case 'Escape':
e.preventDefault();
this.closeDropdown();
this.querySelector('#lang-toggle')?.focus();
break;
e.preventDefault()
this.closeDropdown()
this.querySelector('#lang-toggle')?.focus()
break
case 'ArrowDown':
e.preventDefault();
const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
items[nextIndex]?.focus();
break;
e.preventDefault()
const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0
items[nextIndex]?.focus()
break
case 'ArrowUp':
e.preventDefault();
const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
items[prevIndex]?.focus();
break;
e.preventDefault()
const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1
items[prevIndex]?.focus()
break
}
}
closeDropdown() {
this.langDropdownOpen = false;
const langDropdown = this.querySelector('#lang-dropdown');
const langToggle = this.querySelector('#lang-toggle');
langDropdown?.classList.remove('open');
langToggle?.setAttribute('aria-expanded', 'false');
this.langDropdownOpen = false
const langDropdown = this.querySelector('#lang-dropdown')
const langToggle = this.querySelector('#lang-toggle')
langDropdown?.classList.remove('open')
langToggle?.setAttribute('aria-expanded', 'false')
}
openDropdown() {
this.langDropdownOpen = true;
const langDropdown = this.querySelector('#lang-dropdown');
const langToggle = this.querySelector('#lang-toggle');
langDropdown?.classList.add('open');
langToggle?.setAttribute('aria-expanded', 'true');
this.querySelector('.dropdown-item.active')?.focus();
this.langDropdownOpen = true
const langDropdown = this.querySelector('#lang-dropdown')
const langToggle = this.querySelector('#lang-toggle')
langDropdown?.classList.add('open')
langToggle?.setAttribute('aria-expanded', 'true')
this.querySelector('.dropdown-item.active')?.focus()
}
render() {
@@ -162,88 +162,88 @@ class AppHeader extends HTMLElement {
`}
</div>
</div>
`;
`
}
setupEventListeners() {
const themeToggle = this.querySelector('#theme-toggle');
themeToggle.addEventListener('click', () => this.toggleTheme());
const themeToggle = this.querySelector('#theme-toggle')
themeToggle.addEventListener('click', () => this.toggleTheme())
const langDropdown = this.querySelector('#lang-dropdown');
const langToggle = this.querySelector('#lang-toggle');
const langDropdown = this.querySelector('#lang-dropdown')
const langToggle = this.querySelector('#lang-toggle')
langToggle.addEventListener('click', (e) => {
e.stopPropagation();
e.stopPropagation()
if (this.langDropdownOpen) {
this.closeDropdown();
this.closeDropdown()
} else {
this.openDropdown();
this.openDropdown()
}
});
})
langDropdown.querySelectorAll('[data-locale]').forEach(btn => {
btn.addEventListener('click', async () => {
await i18n.setLocale(btn.dataset.locale);
this.closeDropdown();
this.render();
this.setupEventListeners();
});
});
await i18n.setLocale(btn.dataset.locale)
this.closeDropdown()
this.render()
this.setupEventListeners()
})
})
// Login button
const loginBtn = this.querySelector('#login-btn');
const loginBtn = this.querySelector('#login-btn')
loginBtn?.addEventListener('click', () => {
const authModal = document.querySelector('auth-modal');
const authModal = document.querySelector('auth-modal')
if (authModal) {
authModal.show('login');
authModal.show('login')
}
});
})
// Profile button
const profileBtn = this.querySelector('#profile-btn');
const profileBtn = this.querySelector('#profile-btn')
profileBtn?.addEventListener('click', () => {
router.navigate('/profile');
});
router.navigate('/profile')
})
this.updateThemeIcon();
this.updateThemeIcon()
}
toggleTheme() {
const currentTheme = document.documentElement.dataset.theme;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const currentTheme = document.documentElement.dataset.theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
let newTheme;
let newTheme
if (!currentTheme) {
newTheme = prefersDark ? 'light' : 'dark';
newTheme = prefersDark ? 'light' : 'dark'
} else if (currentTheme === 'dark') {
newTheme = 'light';
newTheme = 'light'
} else {
newTheme = 'dark';
newTheme = 'dark'
}
document.documentElement.dataset.theme = newTheme;
localStorage.setItem('theme', newTheme);
this.updateThemeIcon();
document.documentElement.dataset.theme = newTheme
localStorage.setItem('theme', newTheme)
this.updateThemeIcon()
}
updateThemeIcon() {
const theme = document.documentElement.dataset.theme;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = theme === 'dark' || (!theme && prefersDark);
const theme = document.documentElement.dataset.theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const isDark = theme === 'dark' || (!theme && prefersDark)
const sunIcon = this.querySelector('.icon-sun');
const moonIcon = this.querySelector('.icon-moon');
const sunIcon = this.querySelector('.icon-sun')
const moonIcon = this.querySelector('.icon-moon')
if (sunIcon && moonIcon) {
sunIcon.style.display = isDark ? 'block' : 'none';
moonIcon.style.display = isDark ? 'none' : 'block';
sunIcon.style.display = isDark ? 'block' : 'none'
moonIcon.style.display = isDark ? 'none' : 'block'
}
}
updateTranslations() {
i18n.updateDOM();
this.querySelector('#current-lang').textContent = i18n.getLocale().toUpperCase();
i18n.updateDOM()
this.querySelector('#current-lang').textContent = i18n.getLocale().toUpperCase()
}
}
customElements.define('app-header', AppHeader);
customElements.define('app-header', AppHeader)

View File

@@ -1,29 +1,29 @@
import { router } from '../router.js';
import { i18n } from '../i18n.js';
import { auth } from '../services/auth.js';
import './app-header.js';
import './app-footer.js';
import './auth-modal.js';
import './pages/page-home.js';
import './pages/page-search.js';
import './pages/page-listing.js';
import './pages/page-create.js';
import './pages/page-not-found.js';
import { router } from '../router.js'
import { i18n } from '../i18n.js'
import { auth } from '../services/auth.js'
import './app-header.js'
import './app-footer.js'
import './auth-modal.js'
import './pages/page-home.js'
import './pages/page-search.js'
import './pages/page-listing.js'
import './pages/page-create.js'
import './pages/page-not-found.js'
class AppShell extends HTMLElement {
constructor() {
super();
this.main = null;
super()
this.main = null
}
connectedCallback() {
this.render();
this.setupRouter();
this.render()
this.setupRouter()
i18n.subscribe(() => {
this.querySelector('app-header').updateTranslations();
this.querySelector('app-footer').updateTranslations();
});
this.querySelector('app-header').updateTranslations()
this.querySelector('app-footer').updateTranslations()
})
}
render() {
@@ -32,25 +32,25 @@ class AppShell extends HTMLElement {
<main class="container" id="router-outlet"></main>
<app-footer></app-footer>
<auth-modal hidden></auth-modal>
`;
`
this.main = this.querySelector('#router-outlet');
this.main = this.querySelector('#router-outlet')
// Try to restore session
auth.tryRestoreSession();
auth.tryRestoreSession()
}
setupRouter() {
router.setOutlet(this.main);
router.setOutlet(this.main)
router
.register('/', 'page-home')
.register('/search', 'page-search')
.register('/listing/:id', 'page-listing')
.register('/create', 'page-create');
.register('/create', 'page-create')
router.handleRouteChange();
router.handleRouteChange()
}
}
customElements.define('app-shell', AppShell);
customElements.define('app-shell', AppShell)

View File

@@ -2,52 +2,52 @@
* Auth Modal - Login/Register with UUID
*/
import { t, i18n } from '../i18n.js';
import { auth } from '../services/auth.js';
import { t, i18n } from '../i18n.js'
import { auth } from '../services/auth.js'
class AuthModal extends HTMLElement {
constructor() {
super();
this.mode = 'login'; // 'login' | 'register' | 'show-uuid'
this.generatedUuid = null;
this.error = null;
this.loading = false;
super()
this.mode = 'login' // 'login' | 'register' | 'show-uuid'
this.generatedUuid = null
this.error = null
this.loading = false
}
connectedCallback() {
this.render();
this.unsubscribe = i18n.subscribe(() => this.render());
this.render()
this.unsubscribe = i18n.subscribe(() => this.render())
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe();
if (this.unsubscribe) this.unsubscribe()
}
show(mode = 'login') {
this.mode = mode;
this.error = null;
this.generatedUuid = null;
this.hidden = false;
this.render();
document.body.style.overflow = 'hidden';
this.mode = mode
this.error = null
this.generatedUuid = null
this.hidden = false
this.render()
document.body.style.overflow = 'hidden'
}
hide() {
this.hidden = true;
document.body.style.overflow = '';
this.dispatchEvent(new CustomEvent('close'));
this.hidden = true
document.body.style.overflow = ''
this.dispatchEvent(new CustomEvent('close'))
}
switchMode(mode) {
this.mode = mode;
this.error = null;
this.render();
this.mode = mode
this.error = null
this.render()
}
render() {
if (this.hidden) {
this.innerHTML = '';
return;
this.innerHTML = ''
return
}
this.innerHTML = /* html */`
@@ -65,13 +65,13 @@ class AuthModal extends HTMLElement {
${this.mode === 'show-uuid' ? this.renderShowUuid() : ''}
</div>
</div>
`;
`
this.setupEventListeners();
this.setupEventListeners()
}
renderLogin() {
const storedUuid = auth.getStoredUuid();
const storedUuid = auth.getStoredUuid()
return /* html */`
<h2 class="modal-title">${t('auth.login')}</h2>
@@ -104,7 +104,7 @@ class AuthModal extends HTMLElement {
<button class="link-btn" id="switch-register">${t('auth.createAccount')}</button>
</p>
</div>
`;
`
}
renderRegister() {
@@ -130,7 +130,7 @@ class AuthModal extends HTMLElement {
<button class="link-btn" id="switch-login">${t('auth.login')}</button>
</p>
</div>
`;
`
}
renderShowUuid() {
@@ -161,100 +161,100 @@ class AuthModal extends HTMLElement {
<button class="btn btn-primary btn-lg btn-block" id="confirm-saved">
${t('auth.confirmSaved')}
</button>
`;
`
}
setupEventListeners() {
// Close modal
this.querySelector('#modal-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'modal-overlay') this.hide();
});
this.querySelector('#modal-close')?.addEventListener('click', () => this.hide());
if (e.target.id === 'modal-overlay') this.hide()
})
this.querySelector('#modal-close')?.addEventListener('click', () => this.hide())
// Switch modes
this.querySelector('#switch-register')?.addEventListener('click', () => this.switchMode('register'));
this.querySelector('#switch-login')?.addEventListener('click', () => this.switchMode('login'));
this.querySelector('#switch-register')?.addEventListener('click', () => this.switchMode('register'))
this.querySelector('#switch-login')?.addEventListener('click', () => this.switchMode('login'))
// Login form
this.querySelector('#login-form')?.addEventListener('submit', (e) => this.handleLogin(e));
this.querySelector('#login-form')?.addEventListener('submit', (e) => this.handleLogin(e))
// Generate UUID
this.querySelector('#generate-uuid')?.addEventListener('click', () => this.handleRegister());
this.querySelector('#generate-uuid')?.addEventListener('click', () => this.handleRegister())
// Copy UUID
this.querySelector('#copy-uuid')?.addEventListener('click', () => this.copyUuid());
this.querySelector('#copy-uuid')?.addEventListener('click', () => this.copyUuid())
// Download backup
this.querySelector('#download-uuid')?.addEventListener('click', () => this.downloadBackup());
this.querySelector('#download-uuid')?.addEventListener('click', () => this.downloadBackup())
// Confirm saved
this.querySelector('#confirm-saved')?.addEventListener('click', () => this.hide());
this.querySelector('#confirm-saved')?.addEventListener('click', () => this.hide())
// Escape key
document.addEventListener('keydown', this.handleKeydown.bind(this));
document.addEventListener('keydown', this.handleKeydown.bind(this))
}
handleKeydown(e) {
if (e.key === 'Escape' && !this.hidden) {
this.hide();
this.hide()
}
}
async handleLogin(e) {
e.preventDefault();
const uuid = this.querySelector('#uuid').value.trim();
e.preventDefault()
const uuid = this.querySelector('#uuid').value.trim()
if (!uuid) {
this.error = t('auth.enterUuid');
this.render();
return;
this.error = t('auth.enterUuid')
this.render()
return
}
this.loading = true;
this.error = null;
this.render();
this.loading = true
this.error = null
this.render()
const result = await auth.login(uuid);
const result = await auth.login(uuid)
this.loading = false;
this.loading = false
if (result.success) {
this.hide();
this.dispatchEvent(new CustomEvent('login', { detail: { success: true } }));
this.hide()
this.dispatchEvent(new CustomEvent('login', { detail: { success: true } }))
} else {
this.error = result.error || t('auth.invalidUuid');
this.render();
this.error = result.error || t('auth.invalidUuid')
this.render()
}
}
async handleRegister() {
this.loading = true;
this.error = null;
this.render();
this.loading = true
this.error = null
this.render()
const result = await auth.createAccount();
const result = await auth.createAccount()
this.loading = false;
this.loading = false
if (result.success) {
this.generatedUuid = result.uuid;
this.mode = 'show-uuid';
this.render();
this.dispatchEvent(new CustomEvent('register', { detail: { uuid: result.uuid } }));
this.generatedUuid = result.uuid
this.mode = 'show-uuid'
this.render()
this.dispatchEvent(new CustomEvent('register', { detail: { uuid: result.uuid } }))
} else {
this.error = result.error || t('auth.registrationFailed');
this.render();
this.error = result.error || t('auth.registrationFailed')
this.render()
}
}
async copyUuid() {
try {
await navigator.clipboard.writeText(this.generatedUuid);
const btn = this.querySelector('#copy-uuid');
btn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>';
setTimeout(() => this.render(), 2000);
await navigator.clipboard.writeText(this.generatedUuid)
const btn = this.querySelector('#copy-uuid')
btn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>'
setTimeout(() => this.render(), 2000)
} catch (e) {
console.error('Copy failed:', e);
console.error('Copy failed:', e)
}
}
@@ -271,21 +271,21 @@ https://dgray.io/#/login
Created: ${new Date().toISOString()}
WARNING: If you lose this UUID, you cannot recover your account!
`;
`
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `dgray-backup-${this.generatedUuid.slice(0, 8)}.txt`;
a.click();
URL.revokeObjectURL(url);
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dgray-backup-${this.generatedUuid.slice(0, 8)}.txt`
a.click()
URL.revokeObjectURL(url)
}
}
customElements.define('auth-modal', AuthModal);
customElements.define('auth-modal', AuthModal)
const style = document.createElement('style');
const style = document.createElement('style')
style.textContent = /* css */`
auth-modal {
position: fixed;
@@ -420,7 +420,7 @@ style.textContent = /* css */`
auth-modal .link-btn:hover {
color: var(--color-accent-hover);
}
`;
document.head.appendChild(style);
`
document.head.appendChild(style)
export default AuthModal;
export default AuthModal

View File

@@ -3,60 +3,60 @@
* Embedded chat for buyer-seller communication
*/
import { t, i18n } from '../i18n.js';
import { chatService } from '../services/chat.js';
import { cryptoService } from '../services/crypto.js';
import { t, i18n } from '../i18n.js'
import { chatService } from '../services/chat.js'
import { cryptoService } from '../services/crypto.js'
class ChatWidget extends HTMLElement {
static get observedAttributes() {
return ['listing-id', 'recipient-id', 'recipient-key', 'recipient-name'];
return ['listing-id', 'recipient-id', 'recipient-key', 'recipient-name']
}
constructor() {
super();
this.chat = null;
this.messages = [];
this.unsubscribe = null;
super()
this.chat = null
this.messages = []
this.unsubscribe = null
}
async connectedCallback() {
await cryptoService.ready;
await cryptoService.ready
this.listingId = this.getAttribute('listing-id');
this.recipientId = this.getAttribute('recipient-id');
this.recipientKey = this.getAttribute('recipient-key');
this.recipientName = this.getAttribute('recipient-name') || 'Seller';
this.listingId = this.getAttribute('listing-id')
this.recipientId = this.getAttribute('recipient-id')
this.recipientKey = this.getAttribute('recipient-key')
this.recipientName = this.getAttribute('recipient-name') || 'Seller'
if (this.listingId && this.recipientId && this.recipientKey) {
this.chat = chatService.getOrCreateChat(
this.recipientId,
this.recipientKey,
this.listingId
);
await this.loadMessages();
)
await this.loadMessages()
}
this.render();
this.setupEventListeners();
this.render()
this.setupEventListeners()
this.unsubscribe = chatService.subscribe(() => this.refreshMessages());
this.i18nUnsubscribe = i18n.subscribe(() => this.render());
this.unsubscribe = chatService.subscribe(() => this.refreshMessages())
this.i18nUnsubscribe = i18n.subscribe(() => this.render())
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe();
if (this.i18nUnsubscribe) this.i18nUnsubscribe();
if (this.unsubscribe) this.unsubscribe()
if (this.i18nUnsubscribe) this.i18nUnsubscribe()
}
async loadMessages() {
if (!this.chat) return;
this.messages = await chatService.getMessages(this.chat.id, this.recipientKey);
if (!this.chat) return
this.messages = await chatService.getMessages(this.chat.id, this.recipientKey)
}
async refreshMessages() {
await this.loadMessages();
this.renderMessages();
this.scrollToBottom();
await this.loadMessages()
this.renderMessages()
this.scrollToBottom()
}
render() {
@@ -86,10 +86,10 @@ class ChatWidget extends HTMLElement {
</button>
</form>
</div>
`;
`
this.setupEventListeners();
this.scrollToBottom();
this.setupEventListeners()
this.scrollToBottom()
}
renderMessagesHtml() {
@@ -98,7 +98,7 @@ class ChatWidget extends HTMLElement {
<div class="chat-empty">
<p>${t('chat.startConversation')}</p>
</div>
`;
`
}
return this.messages.map(msg => /* html */`
@@ -108,60 +108,60 @@ class ChatWidget extends HTMLElement {
<span class="message-time">${this.formatTime(msg.timestamp)}</span>
</div>
</div>
`).join('');
`).join('')
}
renderMessages() {
const container = this.querySelector('#chat-messages');
const container = this.querySelector('#chat-messages')
if (container) {
container.innerHTML = this.renderMessagesHtml();
container.innerHTML = this.renderMessagesHtml()
}
}
setupEventListeners() {
const form = this.querySelector('#chat-form');
form?.addEventListener('submit', (e) => this.handleSubmit(e));
const form = this.querySelector('#chat-form')
form?.addEventListener('submit', (e) => this.handleSubmit(e))
}
async handleSubmit(e) {
e.preventDefault();
e.preventDefault()
const input = this.querySelector('#message-input');
const text = input?.value.trim();
const input = this.querySelector('#message-input')
const text = input?.value.trim()
if (!text || !this.chat) return;
if (!text || !this.chat) return
input.value = '';
input.value = ''
await chatService.sendMessage(
this.chat.id,
this.recipientKey,
text
);
)
}
scrollToBottom() {
const container = this.querySelector('#chat-messages');
const container = this.querySelector('#chat-messages')
if (container) {
container.scrollTop = container.scrollHeight;
container.scrollTop = container.scrollHeight
}
}
formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const date = new Date(timestamp)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}
customElements.define('chat-widget', ChatWidget);
customElements.define('chat-widget', ChatWidget)
const style = document.createElement('style');
const style = document.createElement('style')
style.textContent = /* css */`
chat-widget {
display: block;
@@ -295,7 +295,7 @@ style.textContent = /* css */`
chat-widget .chat-input button:hover {
background: var(--color-text-secondary);
}
`;
document.head.appendChild(style);
`
document.head.appendChild(style)
export { ChatWidget };
export { ChatWidget }

View File

@@ -1,62 +1,62 @@
import { t, i18n } from '../i18n.js';
import { escapeHTML, formatPrice } from '../utils/helpers.js';
import { t, i18n } from '../i18n.js'
import { escapeHTML, formatPrice } from '../utils/helpers.js'
class ListingCard extends HTMLElement {
static get observedAttributes() {
return ['listing-id', 'title', 'price', 'currency', 'location', 'image'];
return ['listing-id', 'title', 'price', 'currency', 'location', 'image']
}
constructor() {
super();
this.isFavorite = false;
super()
this.isFavorite = false
}
connectedCallback() {
this.loadFavoriteState();
this.render();
this.setupEventListeners();
this.loadFavoriteState()
this.render()
this.setupEventListeners()
}
attributeChangedCallback() {
if (this.isConnected) {
this.render();
this.setupEventListeners();
this.render()
this.setupEventListeners()
}
}
loadFavoriteState() {
const id = this.getAttribute('listing-id');
const id = this.getAttribute('listing-id')
if (id) {
const favorites = JSON.parse(localStorage.getItem('favorites') || '[]');
this.isFavorite = favorites.includes(id);
const favorites = JSON.parse(localStorage.getItem('favorites') || '[]')
this.isFavorite = favorites.includes(id)
}
}
saveFavoriteState() {
const id = this.getAttribute('listing-id');
if (!id) return;
const id = this.getAttribute('listing-id')
if (!id) return
let favorites = JSON.parse(localStorage.getItem('favorites') || '[]');
let favorites = JSON.parse(localStorage.getItem('favorites') || '[]')
if (this.isFavorite) {
if (!favorites.includes(id)) favorites.push(id);
if (!favorites.includes(id)) favorites.push(id)
} else {
favorites = favorites.filter(f => f !== id);
favorites = favorites.filter(f => f !== id)
}
localStorage.setItem('favorites', JSON.stringify(favorites));
localStorage.setItem('favorites', JSON.stringify(favorites))
}
render() {
const id = this.getAttribute('listing-id') || '';
const title = this.getAttribute('title') || t('home.placeholderTitle');
const price = this.getAttribute('price');
const currency = this.getAttribute('currency') || 'EUR';
const location = this.getAttribute('location') || t('home.placeholderLocation');
const image = this.getAttribute('image');
const id = this.getAttribute('listing-id') || ''
const title = this.getAttribute('title') || t('home.placeholderTitle')
const price = this.getAttribute('price')
const currency = this.getAttribute('currency') || 'EUR'
const location = this.getAttribute('location') || t('home.placeholderLocation')
const image = this.getAttribute('image')
const priceDisplay = price ? formatPrice(parseFloat(price), currency) : '';
const favoriteLabel = this.isFavorite ? t('home.removeFavorite') : t('home.addFavorite');
const priceDisplay = price ? formatPrice(parseFloat(price), currency) : ''
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">
@@ -64,7 +64,7 @@ class ListingCard extends HTMLElement {
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`;
`
this.innerHTML = /* html */`
<a href="#/listing/${escapeHTML(id)}" class="listing-link">
@@ -86,42 +86,42 @@ class ListingCard extends HTMLElement {
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
</button>
`;
`
}
setupEventListeners() {
const btn = this.querySelector('.favorite-btn');
const btn = this.querySelector('.favorite-btn')
btn?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.toggleFavorite();
});
e.preventDefault()
e.stopPropagation()
this.toggleFavorite()
})
}
toggleFavorite() {
this.isFavorite = !this.isFavorite;
this.saveFavoriteState();
this.isFavorite = !this.isFavorite
this.saveFavoriteState()
const btn = this.querySelector('.favorite-btn');
btn?.classList.toggle('active', this.isFavorite);
btn?.setAttribute('aria-pressed', this.isFavorite);
btn?.setAttribute('aria-label', this.isFavorite ? t('home.removeFavorite') : t('home.addFavorite'));
const btn = this.querySelector('.favorite-btn')
btn?.classList.toggle('active', this.isFavorite)
btn?.setAttribute('aria-pressed', this.isFavorite)
btn?.setAttribute('aria-label', this.isFavorite ? t('home.removeFavorite') : t('home.addFavorite'))
btn?.classList.add('animate__animated', 'animate__heartBeat');
btn?.classList.add('animate__animated', 'animate__heartBeat')
btn?.addEventListener('animationend', () => {
btn?.classList.remove('animate__animated', 'animate__heartBeat');
}, { once: true });
btn?.classList.remove('animate__animated', 'animate__heartBeat')
}, { once: true })
this.dispatchEvent(new CustomEvent('favorite-toggle', {
bubbles: true,
detail: { id: this.getAttribute('listing-id'), isFavorite: this.isFavorite }
}));
}))
}
}
customElements.define('listing-card', ListingCard);
customElements.define('listing-card', ListingCard)
const style = document.createElement('style');
const style = document.createElement('style')
style.textContent = /* css */`
listing-card {
display: block;
@@ -221,5 +221,5 @@ style.textContent = /* css */`
listing-card .favorite-btn:hover .heart-icon {
color: var(--color-accent);
}
`;
document.head.appendChild(style);
`
document.head.appendChild(style)

View File

@@ -1,12 +1,12 @@
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 { 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'
class PageCreate extends HTMLElement {
constructor() {
super();
super()
this.formData = {
title: '',
description: '',
@@ -19,23 +19,23 @@ class PageCreate extends HTMLElement {
location: '',
shipping: false,
moneroAddress: ''
};
this.imageFiles = [];
this.imagePreviews = [];
this.categories = [];
this.submitting = false;
}
this.imageFiles = []
this.imagePreviews = []
this.categories = []
this.submitting = false
}
async connectedCallback() {
// Check if logged in
if (!auth.isLoggedIn()) {
this.showLoginRequired();
return;
this.showLoginRequired()
return
}
await this.loadCategories();
this.render();
this.unsubscribe = i18n.subscribe(() => this.render());
await this.loadCategories()
this.render()
this.unsubscribe = i18n.subscribe(() => this.render())
}
showLoginRequired() {
@@ -46,29 +46,29 @@ class PageCreate extends HTMLElement {
<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');
const authModal = document.querySelector('auth-modal')
if (authModal) {
authModal.show('login');
authModal.show('login')
authModal.addEventListener('login', () => {
this.connectedCallback();
}, { once: true });
this.connectedCallback()
}, { once: true })
}
});
})
}
async loadCategories() {
try {
this.categories = await directus.getCategories();
this.categories = await directus.getCategories()
} catch (e) {
console.error('Failed to load categories:', e);
this.categories = [];
console.error('Failed to load categories:', e)
this.categories = []
}
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe();
if (this.unsubscribe) this.unsubscribe()
}
render() {
@@ -220,69 +220,68 @@ class PageCreate extends HTMLElement {
</div>
</form>
</div>
`;
`
this.setupEventListeners();
this.setupEventListeners()
}
setupEventListeners() {
const form = this.querySelector('#create-form');
const cancelBtn = this.querySelector('#cancel-btn');
const imageInput = this.querySelector('#images');
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.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.formData[e.target.name] = e.target.value
}
});
});
})
})
// Checkbox handler
const shippingCheckbox = this.querySelector('#shipping');
const shippingCheckbox = this.querySelector('#shipping')
shippingCheckbox?.addEventListener('change', (e) => {
this.formData.shipping = e.target.checked;
});
this.formData.shipping = e.target.checked
})
imageInput?.addEventListener('change', (e) => this.handleImageSelect(e));
imageInput?.addEventListener('change', (e) => this.handleImageSelect(e))
}
handleImageSelect(e) {
const files = Array.from(e.target.files);
const files = Array.from(e.target.files)
files.forEach(file => {
if (this.imageFiles.length >= 5) return;
if (this.imageFiles.length >= 5) return
this.imageFiles.push(file)
this.imageFiles.push(file);
const reader = new FileReader();
reader.onload = (event) => {
this.imagePreviews.push(event.target.result);
this.updateImagePreviews();
};
reader.readAsDataURL(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');
const container = this.querySelector('#image-previews')
const uploadArea = this.querySelector('#upload-area')
if (container) {
container.innerHTML = this.renderImagePreviews();
this.setupRemoveListeners();
container.innerHTML = this.renderImagePreviews()
this.setupRemoveListeners()
}
if (uploadArea) {
uploadArea.style.display = this.imageFiles.length >= 5 ? 'none' : 'flex';
uploadArea.style.display = this.imageFiles.length >= 5 ? 'none' : 'flex'
}
}
renderImagePreviews() {
if (this.imagePreviews.length === 0) return '';
if (this.imagePreviews.length === 0) return ''
return this.imagePreviews.map((src, index) => /* html */`
<div class="image-preview">
@@ -294,37 +293,37 @@ class PageCreate extends HTMLElement {
</svg>
</button>
</div>
`).join('');
`).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();
});
});
const index = parseInt(e.currentTarget.dataset.index)
this.imageFiles.splice(index, 1)
this.imagePreviews.splice(index, 1)
this.updateImagePreviews()
})
})
}
async handleSubmit(e) {
e.preventDefault();
e.preventDefault()
if (this.submitting) return;
this.submitting = true;
if (this.submitting) return
this.submitting = true
const form = e.target;
const submitBtn = form.querySelector('[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = t('create.publishing');
const form = e.target
const submitBtn = form.querySelector('[type="submit"]')
submitBtn.disabled = true
submitBtn.textContent = t('create.publishing')
try {
// Upload images first
let imageIds = [];
let imageIds = []
if (this.imageFiles.length > 0) {
const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles);
imageIds = uploadedFiles.map(f => f.id);
const uploadedFiles = await directus.uploadMultipleFiles(this.imageFiles)
imageIds = uploadedFiles.map(f => f.id)
}
// Create listing
@@ -340,38 +339,38 @@ class PageCreate extends HTMLElement {
shipping: this.formData.shipping,
monero_address: this.formData.moneroAddress,
status: 'published'
};
}
// Add images if uploaded
if (imageIds.length > 0) {
listingData.images = imageIds.map((id, index) => ({
directus_files_id: id,
sort: index
}));
}))
}
const listing = await directus.createListing(listingData);
const listing = await directus.createListing(listingData)
router.navigate(`/listing/${listing.id}`);
router.navigate(`/listing/${listing.id}`)
} catch (error) {
console.error('Failed to create listing:', error);
submitBtn.disabled = false;
submitBtn.textContent = t('create.publish');
this.submitting = false;
alert(error.message || 'Failed to create listing');
console.error('Failed to create listing:', error)
submitBtn.disabled = false
submitBtn.textContent = t('create.publish')
this.submitting = false
alert(error.message || 'Failed to create listing')
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}
customElements.define('page-create', PageCreate);
customElements.define('page-create', PageCreate)
const style = document.createElement('style');
const style = document.createElement('style')
style.textContent = /* css */`
page-create .create-page {
max-width: 600px;
@@ -509,5 +508,5 @@ style.textContent = /* css */`
justify-content: flex-end;
margin-top: var(--space-xl);
}
`;
document.head.appendChild(style);
`
document.head.appendChild(style)

View File

@@ -1,16 +1,16 @@
import { t, i18n } from '../../i18n.js';
import { mockListings } from '../../data/mock-listings.js';
import '../listing-card.js';
import '../search-box.js';
import { t, i18n } from '../../i18n.js'
import { mockListings } from '../../data/mock-listings.js'
import '../listing-card.js'
import '../search-box.js'
class PageHome extends HTMLElement {
connectedCallback() {
this.render();
this.unsubscribe = i18n.subscribe(() => this.render());
this.render()
this.unsubscribe = i18n.subscribe(() => this.render())
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe();
if (this.unsubscribe) this.unsubscribe()
}
render() {
@@ -25,7 +25,7 @@ class PageHome extends HTMLElement {
${this.renderListings()}
</div>
</section>
`;
`
}
renderListings() {
@@ -36,13 +36,13 @@ class PageHome extends HTMLElement {
price="${listing.price}"
location="${listing.location}"
></listing-card>
`).join('');
`).join('')
}
}
customElements.define('page-home', PageHome);
customElements.define('page-home', PageHome)
const style = document.createElement('style');
const style = document.createElement('style')
style.textContent = /* css */`
/* Search Section */
page-home .search-section {
@@ -57,5 +57,5 @@ style.textContent = /* css */`
page-home section h2 {
margin-bottom: var(--space-lg);
}
`;
document.head.appendChild(style);
`
document.head.appendChild(style)

View File

@@ -1,32 +1,32 @@
import { t, i18n } from '../../i18n.js';
import { getListingById } from '../../data/mock-listings.js';
import '../chat-widget.js';
import { t, i18n } from '../../i18n.js'
import { getListingById } from '../../data/mock-listings.js'
import '../chat-widget.js'
class PageListing extends HTMLElement {
constructor() {
super();
this.listing = null;
this.loading = true;
super()
this.listing = null
this.loading = true
}
connectedCallback() {
this.listingId = this.dataset.id;
this.render();
this.loadListing();
this.unsubscribe = i18n.subscribe(() => this.render());
this.listingId = this.dataset.id
this.render()
this.loadListing()
this.unsubscribe = i18n.subscribe(() => this.render())
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe();
if (this.unsubscribe) this.unsubscribe()
}
async loadListing() {
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise(resolve => setTimeout(resolve, 300))
this.listing = getListingById(this.listingId);
this.listing = getListingById(this.listingId)
this.loading = false;
this.render();
this.loading = false
this.render()
}
render() {
@@ -35,8 +35,8 @@ class PageListing extends HTMLElement {
<div class="loading">
<div class="spinner"></div>
</div>
`;
return;
`
return
}
if (!this.listing) {
@@ -46,18 +46,18 @@ class PageListing extends HTMLElement {
<p data-i18n="listing.notFound">${t('listing.notFound')}</p>
<a href="#/" class="btn btn-primary">${t('listing.backHome')}</a>
</div>
`;
return;
`
return
}
const hasImages = this.listing.images && this.listing.images.length > 0;
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 */`
<article class="listing-detail">
@@ -141,65 +141,65 @@ class PageListing extends HTMLElement {
<p class="dialog-hint">${t('listing.contactHint')}</p>
</div>
</dialog>
`;
`
this.setupEventListeners();
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');
const tabBtns = this.querySelectorAll('.tab-btn');
const contactBtn = this.querySelector('#contact-btn')
const dialog = this.querySelector('#contact-dialog')
const closeBtn = this.querySelector('#dialog-close')
const copyBtn = this.querySelector('#copy-btn')
const tabBtns = this.querySelectorAll('.tab-btn')
contactBtn?.addEventListener('click', () => {
dialog?.showModal();
});
dialog?.showModal()
})
closeBtn?.addEventListener('click', () => {
dialog?.close();
});
dialog?.close()
})
dialog?.addEventListener('click', (e) => {
if (e.target === dialog) {
dialog.close();
dialog.close()
}
});
})
copyBtn?.addEventListener('click', async () => {
const addr = this.querySelector('#monero-addr')?.textContent;
const addr = this.querySelector('#monero-addr')?.textContent
if (addr) {
await navigator.clipboard.writeText(addr);
copyBtn.classList.add('copied');
setTimeout(() => copyBtn.classList.remove('copied'), 2000);
await navigator.clipboard.writeText(addr)
copyBtn.classList.add('copied')
setTimeout(() => copyBtn.classList.remove('copied'), 2000)
}
});
})
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
const tab = btn.dataset.tab
tabBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
tabBtns.forEach(b => b.classList.remove('active'))
btn.classList.add('active')
this.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== `tab-${tab}`);
});
});
});
content.classList.toggle('hidden', content.id !== `tab-${tab}`)
})
})
})
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}
customElements.define('page-listing', PageListing);
customElements.define('page-listing', PageListing)
const style = document.createElement('style');
const style = document.createElement('style')
style.textContent = /* css */`
page-listing .listing-detail {
display: grid;
@@ -425,5 +425,5 @@ style.textContent = /* css */`
color: var(--color-text-muted);
text-align: center;
}
`;
document.head.appendChild(style);
`
document.head.appendChild(style)

View File

@@ -1,13 +1,13 @@
import { t, i18n } from '../../i18n.js';
import { t, i18n } from '../../i18n.js'
class PageNotFound extends HTMLElement {
connectedCallback() {
this.render();
this.unsubscribe = i18n.subscribe(() => this.render());
this.render()
this.unsubscribe = i18n.subscribe(() => this.render())
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe();
if (this.unsubscribe) this.unsubscribe()
}
render() {
@@ -20,13 +20,13 @@ class PageNotFound extends HTMLElement {
<span data-i18n="notFound.backHome">${t('notFound.backHome')}</span>
</a>
</div>
`;
`
}
}
customElements.define('page-not-found', PageNotFound);
customElements.define('page-not-found', PageNotFound)
const style = document.createElement('style');
const style = document.createElement('style')
style.textContent = /* css */`
page-not-found .not-found {
text-align: center;
@@ -48,5 +48,5 @@ style.textContent = /* css */`
color: var(--color-text-secondary);
margin-bottom: var(--space-xl);
}
`;
document.head.appendChild(style);
`
document.head.appendChild(style)

View File

@@ -1,52 +1,52 @@
import { t, i18n } from '../../i18n.js';
import { router } from '../../router.js';
import { searchListings } from '../../data/mock-listings.js';
import '../search-box.js';
import '../listing-card.js';
import { t, i18n } from '../../i18n.js'
import { router } from '../../router.js'
import { searchListings } from '../../data/mock-listings.js'
import '../search-box.js'
import '../listing-card.js'
class PageSearch extends HTMLElement {
constructor() {
super();
this.results = [];
this.loading = false;
super()
this.results = []
this.loading = false
}
connectedCallback() {
this.parseUrlParams();
this.render();
this.afterRender();
this.parseUrlParams()
this.render()
this.afterRender()
this.unsubscribe = i18n.subscribe(() => {
this.render();
this.afterRender();
});
this.render()
this.afterRender()
})
if (this.hasFilters()) {
this.performSearch();
this.performSearch()
}
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe();
if (this.unsubscribe) this.unsubscribe()
}
parseUrlParams() {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '');
this.query = params.get('q') || '';
this.category = params.get('category') || '';
this.subcategory = params.get('sub') || '';
this.country = params.get('country') || 'ch';
this.useCurrentLocation = params.get('location') === 'current';
this.radius = parseInt(params.get('radius')) || 50;
this.lat = params.get('lat') ? parseFloat(params.get('lat')) : null;
this.lng = params.get('lng') ? parseFloat(params.get('lng')) : null;
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
this.query = params.get('q') || ''
this.category = params.get('category') || ''
this.subcategory = params.get('sub') || ''
this.country = params.get('country') || 'ch'
this.useCurrentLocation = params.get('location') === 'current'
this.radius = parseInt(params.get('radius')) || 50
this.lat = params.get('lat') ? parseFloat(params.get('lat')) : null
this.lng = params.get('lng') ? parseFloat(params.get('lng')) : null
}
hasFilters() {
return this.query || this.category || this.subcategory;
return this.query || this.category || this.subcategory
}
afterRender() {
const searchBox = this.querySelector('search-box');
const searchBox = this.querySelector('search-box')
if (searchBox) {
searchBox.setFilters({
query: this.query,
@@ -55,25 +55,25 @@ class PageSearch extends HTMLElement {
country: this.country,
useCurrentLocation: this.useCurrentLocation,
radius: this.radius
});
})
searchBox.addEventListener('search', (e) => {
this.handleSearch(e.detail);
});
this.handleSearch(e.detail)
})
}
}
handleSearch(filters) {
this.query = filters.query;
this.category = filters.category;
this.subcategory = filters.subcategory;
this.country = filters.country;
this.useCurrentLocation = filters.useCurrentLocation;
this.radius = filters.radius;
this.lat = filters.lat;
this.lng = filters.lng;
this.query = filters.query
this.category = filters.category
this.subcategory = filters.subcategory
this.country = filters.country
this.useCurrentLocation = filters.useCurrentLocation
this.radius = filters.radius
this.lat = filters.lat
this.lng = filters.lng
this.performSearch();
this.performSearch()
}
render() {
@@ -87,28 +87,28 @@ class PageSearch extends HTMLElement {
${this.renderResults()}
</section>
</div>
`;
`
}
async performSearch() {
this.loading = true;
this.updateResults();
this.loading = true
this.updateResults()
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise(resolve => setTimeout(resolve, 500))
this.results = this.getMockResults();
this.loading = false;
this.updateResults();
this.results = this.getMockResults()
this.loading = false
this.updateResults()
}
getMockResults() {
return searchListings(this.query, this.category, this.subcategory);
return searchListings(this.query, this.category, this.subcategory)
}
updateResults() {
const resultsContainer = this.querySelector('#results');
const resultsContainer = this.querySelector('#results')
if (resultsContainer) {
resultsContainer.innerHTML = this.renderResults();
resultsContainer.innerHTML = this.renderResults()
}
}
@@ -119,7 +119,7 @@ class PageSearch extends HTMLElement {
<div class="spinner"></div>
<p>${t('search.loading')}</p>
</div>
`;
`
}
if (!this.hasFilters() && this.results.length === 0) {
@@ -128,7 +128,7 @@ class PageSearch extends HTMLElement {
<div class="empty-state-icon">🔍</div>
<p>${t('search.enterQuery')}</p>
</div>
`;
`
}
if (this.results.length === 0) {
@@ -137,7 +137,7 @@ class PageSearch extends HTMLElement {
<div class="empty-state-icon">😕</div>
<p>${t('search.noResults')}</p>
</div>
`;
`
}
return /* html */`
@@ -152,19 +152,19 @@ class PageSearch extends HTMLElement {
></listing-card>
`).join('')}
</div>
`;
`
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}
customElements.define('page-search', PageSearch);
customElements.define('page-search', PageSearch)
const style = document.createElement('style');
const style = document.createElement('style')
style.textContent = /* css */`
page-search .search-page {
padding: var(--space-lg) 0;
@@ -212,5 +212,5 @@ style.textContent = /* css */`
font-size: 3rem;
margin-bottom: var(--space-md);
}
`;
document.head.appendChild(style);
`
document.head.appendChild(style)

View File

@@ -1,4 +1,4 @@
import { t, i18n } from '../i18n.js';
import { t, i18n } from '../i18n.js'
const CATEGORIES = {
electronics: ['phones', 'computers', 'tv_audio', 'gaming', 'appliances'],
@@ -9,50 +9,50 @@ const CATEGORIES = {
books: ['fiction', 'nonfiction', 'textbooks', 'music_movies'],
garden: ['plants', 'tools', 'outdoor_living', 'decoration'],
other: ['collectibles', 'art', 'handmade', 'services']
};
}
const COUNTRIES = ['ch', 'de', 'at', 'fr', 'it', 'li'];
const RADIUS_OPTIONS = [5, 10, 20, 50, 100, 200];
const COUNTRIES = ['ch', 'de', 'at', 'fr', 'it', 'li']
const RADIUS_OPTIONS = [5, 10, 20, 50, 100, 200]
class SearchBox extends HTMLElement {
static get observedAttributes() {
return ['category', 'subcategory', 'country', 'query'];
return ['category', 'subcategory', 'country', 'query']
}
constructor() {
super();
this.loadFiltersFromStorage();
super()
this.loadFiltersFromStorage()
}
loadFiltersFromStorage() {
const saved = localStorage.getItem('searchFilters');
const saved = localStorage.getItem('searchFilters')
if (saved) {
try {
const filters = JSON.parse(saved);
this.selectedCategory = filters.category || '';
this.selectedSubcategory = filters.subcategory || '';
this.selectedCountry = filters.country || 'ch';
this.selectedRadius = filters.radius || 50;
this.useCurrentLocation = filters.useCurrentLocation || false;
this.searchQuery = filters.query || '';
const filters = JSON.parse(saved)
this.selectedCategory = filters.category || ''
this.selectedSubcategory = filters.subcategory || ''
this.selectedCountry = filters.country || 'ch'
this.selectedRadius = filters.radius || 50
this.useCurrentLocation = filters.useCurrentLocation || false
this.searchQuery = filters.query || ''
} catch (e) {
this.resetFilters();
this.resetFilters()
}
} else {
this.resetFilters();
this.resetFilters()
}
}
resetFilters() {
this.selectedCategory = '';
this.selectedSubcategory = '';
this.selectedCountry = 'ch';
this.selectedRadius = 50;
this.useCurrentLocation = false;
this.searchQuery = '';
this.geoLoading = false;
this.currentLat = null;
this.currentLng = null;
this.selectedCategory = ''
this.selectedSubcategory = ''
this.selectedCountry = 'ch'
this.selectedRadius = 50
this.useCurrentLocation = false
this.searchQuery = ''
this.geoLoading = false
this.currentLat = null
this.currentLng = null
}
saveFiltersToStorage() {
@@ -63,61 +63,61 @@ class SearchBox extends HTMLElement {
radius: this.selectedRadius,
useCurrentLocation: this.useCurrentLocation,
query: this.searchQuery
};
localStorage.setItem('searchFilters', JSON.stringify(filters));
}
localStorage.setItem('searchFilters', JSON.stringify(filters))
}
connectedCallback() {
// Override from attributes if provided
if (this.hasAttribute('category')) {
this.selectedCategory = this.getAttribute('category');
this.selectedCategory = this.getAttribute('category')
}
if (this.hasAttribute('subcategory')) {
this.selectedSubcategory = this.getAttribute('subcategory');
this.selectedSubcategory = this.getAttribute('subcategory')
}
if (this.hasAttribute('country')) {
this.selectedCountry = this.getAttribute('country');
this.selectedCountry = this.getAttribute('country')
}
if (this.hasAttribute('query')) {
this.searchQuery = this.getAttribute('query');
this.searchQuery = this.getAttribute('query')
}
this.render();
this.setupEventListeners();
this.render()
this.setupEventListeners()
this.unsubscribe = i18n.subscribe(() => {
this.render();
this.setupEventListeners();
});
this.render()
this.setupEventListeners()
})
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe();
if (this.unsubscribe) this.unsubscribe()
if (this._closeDropdown) {
document.removeEventListener('click', this._closeDropdown);
document.removeEventListener('click', this._closeDropdown)
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (oldValue === newValue) return
switch (name) {
case 'category':
this.selectedCategory = newValue || '';
break;
this.selectedCategory = newValue || ''
break
case 'subcategory':
this.selectedSubcategory = newValue || '';
break;
this.selectedSubcategory = newValue || ''
break
case 'country':
this.selectedCountry = newValue || 'ch';
break;
this.selectedCountry = newValue || 'ch'
break
case 'query':
this.searchQuery = newValue || '';
break;
this.searchQuery = newValue || ''
break
}
if (this.isConnected) {
this.render();
this.setupEventListeners();
this.render()
this.setupEventListeners()
}
}
@@ -146,12 +146,12 @@ class SearchBox extends HTMLElement {
</button>
</div>
</form>
`;
`
}
renderFilters() {
// Track which category accordion is expanded
this._expandedCategory = this._expandedCategory || '';
this._expandedCategory = this._expandedCategory || ''
return /* html */`
<!-- Accordion Category Dropdown -->
@@ -263,169 +263,169 @@ class SearchBox extends HTMLElement {
</select>
</div>
</div>
`;
`
}
setupEventListeners() {
const form = this.querySelector('#search-form');
const queryInput = this.querySelector('#search-query');
const form = this.querySelector('#search-form')
const queryInput = this.querySelector('#search-query')
// Desktop selects
const countrySelect = this.querySelector('#country-select');
const radiusSelect = this.querySelector('#radius-select');
const countrySelect = this.querySelector('#country-select')
const radiusSelect = this.querySelector('#radius-select')
// Mobile selects
const countrySelectMobile = this.querySelector('#country-select-mobile');
const radiusSelectMobile = this.querySelector('#radius-select-mobile');
const countrySelectMobile = this.querySelector('#country-select-mobile')
const radiusSelectMobile = this.querySelector('#radius-select-mobile')
// Accordion dropdown
const categoryTrigger = this.querySelector('#category-trigger');
const categoryMenu = this.querySelector('#category-menu');
const categoryTrigger = this.querySelector('#category-trigger')
const categoryMenu = this.querySelector('#category-menu')
form?.addEventListener('submit', (e) => {
e.preventDefault();
this.handleSearch();
});
e.preventDefault()
this.handleSearch()
})
queryInput?.addEventListener('input', (e) => {
this.searchQuery = e.target.value;
});
this.searchQuery = e.target.value
})
// Toggle dropdown
if (categoryTrigger && categoryMenu) {
categoryTrigger.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
categoryMenu.classList.toggle('open');
});
e.preventDefault()
e.stopPropagation()
categoryMenu.classList.toggle('open')
})
}
// Close dropdown on outside click
this._closeDropdown = (e) => {
if (!this.contains(e.target)) {
categoryMenu?.classList.remove('open');
categoryMenu?.classList.remove('open')
}
};
document.addEventListener('click', this._closeDropdown);
}
document.addEventListener('click', this._closeDropdown)
// Category accordion headers - toggle expand
this.querySelectorAll('.category-accordion > .category-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const cat = item.dataset.category;
const accordion = item.closest('.category-accordion');
e.preventDefault()
e.stopPropagation()
const cat = item.dataset.category
const accordion = item.closest('.category-accordion')
// Toggle this accordion
if (this._expandedCategory === cat) {
this._expandedCategory = '';
accordion?.classList.remove('expanded');
this._expandedCategory = ''
accordion?.classList.remove('expanded')
} else {
// Close other accordions
this.querySelectorAll('.category-accordion.expanded').forEach(el => {
el.classList.remove('expanded');
});
this._expandedCategory = cat;
accordion?.classList.add('expanded');
el.classList.remove('expanded')
})
this._expandedCategory = cat
accordion?.classList.add('expanded')
}
});
});
})
})
// "All categories" button
this.querySelector('.category-item--all')?.addEventListener('click', (e) => {
e.stopPropagation();
this.selectedCategory = '';
this.selectedSubcategory = '';
this._expandedCategory = '';
this.saveFiltersToStorage();
categoryMenu?.classList.remove('open');
this.render();
this.setupEventListeners();
});
e.stopPropagation()
this.selectedCategory = ''
this.selectedSubcategory = ''
this._expandedCategory = ''
this.saveFiltersToStorage()
categoryMenu?.classList.remove('open')
this.render()
this.setupEventListeners()
})
// Subcategory items - select category + subcategory
this.querySelectorAll('.subcategory-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
this.selectedCategory = item.dataset.category;
this.selectedSubcategory = item.dataset.subcategory;
this._expandedCategory = '';
this.saveFiltersToStorage();
categoryMenu?.classList.remove('open');
this.render();
this.setupEventListeners();
});
});
e.stopPropagation()
this.selectedCategory = item.dataset.category
this.selectedSubcategory = item.dataset.subcategory
this._expandedCategory = ''
this.saveFiltersToStorage()
categoryMenu?.classList.remove('open')
this.render()
this.setupEventListeners()
})
})
// Country select handler (both desktop and mobile)
const handleCountryChange = (e) => {
if (e.target.value === 'current') {
this.useCurrentLocation = true;
this.requestGeolocation();
this.useCurrentLocation = true
this.requestGeolocation()
} else {
this.useCurrentLocation = false;
this.selectedCountry = e.target.value;
this.useCurrentLocation = false
this.selectedCountry = e.target.value
}
this.saveFiltersToStorage();
this.render();
this.setupEventListeners();
};
this.saveFiltersToStorage()
this.render()
this.setupEventListeners()
}
countrySelect?.addEventListener('change', handleCountryChange);
countrySelectMobile?.addEventListener('change', handleCountryChange);
countrySelect?.addEventListener('change', handleCountryChange)
countrySelectMobile?.addEventListener('change', handleCountryChange)
// Radius select handler (both desktop and mobile)
const handleRadiusChange = (e) => {
this.selectedRadius = parseInt(e.target.value);
this.saveFiltersToStorage();
};
this.selectedRadius = parseInt(e.target.value)
this.saveFiltersToStorage()
}
radiusSelect?.addEventListener('change', handleRadiusChange);
radiusSelectMobile?.addEventListener('change', handleRadiusChange);
radiusSelect?.addEventListener('change', handleRadiusChange)
radiusSelectMobile?.addEventListener('change', handleRadiusChange)
// Adjust select width to selected option (desktop only)
this.adjustSelectWidth(countrySelect);
this.adjustSelectWidth(radiusSelect);
this.adjustSelectWidth(countrySelect)
this.adjustSelectWidth(radiusSelect)
}
adjustSelectWidth(select) {
if (!select) return;
if (!select) return
// Only apply fixed width on desktop (768px+)
if (window.innerWidth < 768) {
select.style.width = '';
return;
select.style.width = ''
return
}
// Create hidden span to measure text width
const measurer = document.createElement('span');
measurer.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit;';
select.parentElement.appendChild(measurer);
const measurer = document.createElement('span')
measurer.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit;'
select.parentElement.appendChild(measurer)
const selectedOption = select.options[select.selectedIndex];
measurer.textContent = selectedOption ? selectedOption.textContent : '';
const selectedOption = select.options[select.selectedIndex]
measurer.textContent = selectedOption ? selectedOption.textContent : ''
// Add padding for arrow, icon and buffer
select.style.width = (measurer.offsetWidth + 90) + 'px';
measurer.remove();
select.style.width = (measurer.offsetWidth + 90) + 'px'
measurer.remove()
}
handleSearch() {
const params = new URLSearchParams();
const params = new URLSearchParams()
if (this.searchQuery) params.set('q', this.searchQuery);
if (this.selectedCategory) params.set('category', this.selectedCategory);
if (this.selectedSubcategory) params.set('sub', this.selectedSubcategory);
if (this.searchQuery) params.set('q', this.searchQuery)
if (this.selectedCategory) params.set('category', this.selectedCategory)
if (this.selectedSubcategory) params.set('sub', this.selectedSubcategory)
if (this.useCurrentLocation && this.currentLat && this.currentLng) {
params.set('lat', this.currentLat);
params.set('lng', this.currentLng);
params.set('radius', this.selectedRadius);
params.set('lat', this.currentLat)
params.set('lng', this.currentLng)
params.set('radius', this.selectedRadius)
} else if (!this.useCurrentLocation) {
params.set('country', this.selectedCountry);
params.set('country', this.selectedCountry)
}
this.saveFiltersToStorage();
this.saveFiltersToStorage()
// Emit custom event
const event = new CustomEvent('search', {
@@ -442,63 +442,63 @@ class SearchBox extends HTMLElement {
radius: this.selectedRadius,
params: params.toString()
}
});
})
const cancelled = !this.dispatchEvent(event);
const cancelled = !this.dispatchEvent(event)
// Navigate to search page unless event was cancelled
if (!cancelled && !this.hasAttribute('no-navigate')) {
const url = '#/search' + (params.toString() ? '?' + params.toString() : '');
window.location.hash = url;
const url = '#/search' + (params.toString() ? '?' + params.toString() : '')
window.location.hash = url
}
}
requestGeolocation() {
if (!('geolocation' in navigator)) {
this.handleGeoError();
return;
this.handleGeoError()
return
}
this.geoLoading = true;
this.updateGeoButton();
this.geoLoading = true
this.updateGeoButton()
navigator.geolocation.getCurrentPosition(
(position) => {
this.currentLat = position.coords.latitude;
this.currentLng = position.coords.longitude;
this.geoLoading = false;
this.updateGeoButton();
this.currentLat = position.coords.latitude
this.currentLng = position.coords.longitude
this.geoLoading = false
this.updateGeoButton()
},
(error) => {
console.warn('Geolocation error:', error);
this.handleGeoError();
console.warn('Geolocation error:', error)
this.handleGeoError()
},
{ timeout: 10000, enableHighAccuracy: false }
);
)
}
handleGeoError() {
// Keep useCurrentLocation = true, just stop loading indicator
// User can still search by current location (backend will handle it)
this.geoLoading = false;
this.updateGeoButton();
this.geoLoading = false
this.updateGeoButton()
}
updateGeoButton() {
const countrySelect = this.querySelector('#country-select');
if (!countrySelect) return;
const countrySelect = this.querySelector('#country-select')
if (!countrySelect) return
if (this.geoLoading) {
countrySelect.disabled = true;
const currentOption = countrySelect.querySelector('option[value="current"]');
countrySelect.disabled = true
const currentOption = countrySelect.querySelector('option[value="current"]')
if (currentOption) {
currentOption.textContent = `${t('search.locating')}`;
currentOption.textContent = `${t('search.locating')}`
}
} else {
countrySelect.disabled = false;
const currentOption = countrySelect.querySelector('option[value="current"]');
countrySelect.disabled = false
const currentOption = countrySelect.querySelector('option[value="current"]')
if (currentOption) {
currentOption.textContent = `📍 ${t('search.currentLocation')}`;
currentOption.textContent = `📍 ${t('search.currentLocation')}`
}
}
}
@@ -514,33 +514,33 @@ class SearchBox extends HTMLElement {
lat: this.currentLat,
lng: this.currentLng,
radius: this.selectedRadius
};
}
}
setFilters(filters) {
if (filters.query !== undefined) this.searchQuery = filters.query;
if (filters.category !== undefined) this.selectedCategory = filters.category;
if (filters.subcategory !== undefined) this.selectedSubcategory = filters.subcategory;
if (filters.country !== undefined) this.selectedCountry = filters.country;
if (filters.radius !== undefined) this.selectedRadius = filters.radius;
if (filters.useCurrentLocation !== undefined) this.useCurrentLocation = filters.useCurrentLocation;
if (filters.query !== undefined) this.searchQuery = filters.query
if (filters.category !== undefined) this.selectedCategory = filters.category
if (filters.subcategory !== undefined) this.selectedSubcategory = filters.subcategory
if (filters.country !== undefined) this.selectedCountry = filters.country
if (filters.radius !== undefined) this.selectedRadius = filters.radius
if (filters.useCurrentLocation !== undefined) this.useCurrentLocation = filters.useCurrentLocation
this.saveFiltersToStorage();
this.render();
this.setupEventListeners();
this.saveFiltersToStorage()
this.render()
this.setupEventListeners()
}
clearFilters() {
this.resetFilters();
localStorage.removeItem('searchFilters');
this.render();
this.setupEventListeners();
this.resetFilters()
localStorage.removeItem('searchFilters')
this.render()
this.setupEventListeners()
}
}
customElements.define('search-box', SearchBox);
customElements.define('search-box', SearchBox)
const style = document.createElement('style');
const style = document.createElement('style')
style.textContent = /* css */`
search-box {
display: block;
@@ -905,7 +905,7 @@ style.textContent = /* css */`
background: var(--color-primary-light);
color: var(--color-primary);
}
`;
document.head.appendChild(style);
`
document.head.appendChild(style)
export { SearchBox, CATEGORIES, COUNTRIES, RADIUS_OPTIONS };
export { SearchBox, CATEGORIES, COUNTRIES, RADIUS_OPTIONS }

View File

@@ -159,19 +159,19 @@ export const mockListings = [
publicKey: 'dGVzdC1wdWJsaWMta2V5LW5pbmEtbC0xMjM0NTY3ODkw'
}
}
];
]
export function getListingById(id) {
return mockListings.find(l => l.id === id) || null;
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;
});
listing.description.toLowerCase().includes(query.toLowerCase())
const matchesCategory = !category || listing.category === category
const matchesSubcategory = !subcategory || listing.subcategory === subcategory
return matchesQuery && matchesCategory && matchesSubcategory
})
}

View File

@@ -1,125 +1,125 @@
class I18n {
constructor() {
this.translations = {};
this.currentLocale = 'de';
this.fallbackLocale = 'de';
this.supportedLocales = ['de', 'en', 'fr'];
this.subscribers = new Set();
this.loaded = false;
this.translations = {}
this.currentLocale = 'de'
this.fallbackLocale = 'de'
this.supportedLocales = ['de', 'en', 'fr']
this.subscribers = new Set()
this.loaded = false
}
async init() {
const savedLocale = localStorage.getItem('locale');
const browserLocale = navigator.language.split('-')[0];
const savedLocale = localStorage.getItem('locale')
const browserLocale = navigator.language.split('-')[0]
this.currentLocale = savedLocale
|| (this.supportedLocales.includes(browserLocale) ? browserLocale : this.fallbackLocale);
|| (this.supportedLocales.includes(browserLocale) ? browserLocale : this.fallbackLocale)
await this.loadTranslations(this.currentLocale);
this.loaded = true;
this.updateDOM();
await this.loadTranslations(this.currentLocale)
this.loaded = true
this.updateDOM()
}
async loadTranslations(locale) {
if (this.translations[locale]) return;
if (this.translations[locale]) return
try {
const response = await fetch(`/locales/${locale}.json`);
if (!response.ok) throw new Error(`Failed to load ${locale}`);
this.translations[locale] = await response.json();
const response = await fetch(`/locales/${locale}.json`)
if (!response.ok) throw new Error(`Failed to load ${locale}`)
this.translations[locale] = await response.json()
} catch (error) {
console.error(`Failed to load translations for ${locale}:`, error);
console.error(`Failed to load translations for ${locale}:`, error)
if (locale !== this.fallbackLocale) {
await this.loadTranslations(this.fallbackLocale);
await this.loadTranslations(this.fallbackLocale)
}
}
}
async setLocale(locale) {
if (!this.supportedLocales.includes(locale)) {
console.warn(`Locale ${locale} is not supported`);
return;
console.warn(`Locale ${locale} is not supported`)
return
}
await this.loadTranslations(locale);
this.currentLocale = locale;
localStorage.setItem('locale', locale);
document.documentElement.lang = locale;
await this.loadTranslations(locale)
this.currentLocale = locale
localStorage.setItem('locale', locale)
document.documentElement.lang = locale
this.updateDOM();
this.notifySubscribers();
this.updateDOM()
this.notifySubscribers()
}
getLocale() {
return this.currentLocale;
return this.currentLocale
}
t(key, params = {}) {
const translations = this.translations[this.currentLocale]
|| this.translations[this.fallbackLocale]
|| {};
|| {}
let text = this.getNestedValue(translations, key);
let text = this.getNestedValue(translations, key)
if (text === undefined) {
console.warn(`Missing translation: ${key}`);
return key;
console.warn(`Missing translation: ${key}`)
return key
}
Object.entries(params).forEach(([param, value]) => {
text = text.replace(new RegExp(`{{${param}}}`, 'g'), value);
});
text = text.replace(new RegExp(`{{${param}}}`, 'g'), value)
})
return text;
return text
}
getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : undefined;
}, obj);
return current && current[key] !== undefined ? current[key] : undefined
}, obj)
}
updateDOM() {
document.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n');
let params = {};
const key = el.getAttribute('data-i18n')
let params = {}
if (el.dataset.i18nParams) {
try {
params = JSON.parse(el.dataset.i18nParams);
params = JSON.parse(el.dataset.i18nParams)
} catch (e) {
console.warn(`Invalid i18n params for key "${key}":`, e);
console.warn(`Invalid i18n params for key "${key}":`, e)
}
}
el.textContent = this.t(key, params);
});
el.textContent = this.t(key, params)
})
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = this.t(key);
});
const key = el.getAttribute('data-i18n-placeholder')
el.placeholder = this.t(key)
})
document.querySelectorAll('[data-i18n-title]').forEach((el) => {
const key = el.getAttribute('data-i18n-title');
el.title = this.t(key);
});
const key = el.getAttribute('data-i18n-title')
el.title = this.t(key)
})
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
const key = el.getAttribute('data-i18n-aria');
el.setAttribute('aria-label', this.t(key));
});
const key = el.getAttribute('data-i18n-aria')
el.setAttribute('aria-label', this.t(key))
})
}
subscribe(callback) {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
this.subscribers.add(callback)
return () => this.subscribers.delete(callback)
}
notifySubscribers() {
this.subscribers.forEach(callback => callback(this.currentLocale));
this.subscribers.forEach(callback => callback(this.currentLocale))
}
getSupportedLocales() {
return this.supportedLocales;
return this.supportedLocales
}
getLocaleDisplayName(locale) {
@@ -127,10 +127,10 @@ class I18n {
de: 'Deutsch',
en: 'English',
fr: 'Français'
};
return names[locale] || locale;
}
return names[locale] || locale
}
}
export const i18n = new I18n();
export const t = (key, params) => i18n.t(key, params);
export const i18n = new I18n()
export const t = (key, params) => i18n.t(key, params)

View File

@@ -1,155 +1,155 @@
class Router {
constructor() {
this.routes = new Map();
this.currentRoute = null;
this.outlet = null;
this.beforeNavigate = null;
this.afterNavigate = null;
this.routes = new Map()
this.currentRoute = null
this.outlet = null
this.beforeNavigate = null
this.afterNavigate = null
window.addEventListener('hashchange', () => this.handleRouteChange());
window.addEventListener('hashchange', () => this.handleRouteChange())
}
setOutlet(element) {
this.outlet = element;
this.outlet = element
}
register(path, componentTag) {
this.routes.set(path, componentTag);
return this;
this.routes.set(path, componentTag)
return this
}
parseHash() {
const hash = window.location.hash.slice(1) || '/';
const [path, queryString] = hash.split('?');
const params = new URLSearchParams(queryString || '');
const hash = window.location.hash.slice(1) || '/'
const [path, queryString] = hash.split('?')
const params = new URLSearchParams(queryString || '')
return { path, params: Object.fromEntries(params) };
return { path, params: Object.fromEntries(params) }
}
matchRoute(path) {
if (this.routes.has(path)) {
return { componentTag: this.routes.get(path), params: {} };
return { componentTag: this.routes.get(path), params: {} }
}
for (const [routePath, componentTag] of this.routes) {
const routeParts = routePath.split('/');
const pathParts = path.split('/');
const routeParts = routePath.split('/')
const pathParts = path.split('/')
if (routeParts.length !== pathParts.length) continue;
if (routeParts.length !== pathParts.length) continue
const params = {};
let match = true;
const params = {}
let match = true
for (let i = 0; i < routeParts.length; i++) {
if (routeParts[i].startsWith(':')) {
params[routeParts[i].slice(1)] = pathParts[i];
params[routeParts[i].slice(1)] = pathParts[i]
} else if (routeParts[i] !== pathParts[i]) {
match = false;
break;
match = false
break
}
}
if (match) {
return { componentTag, params };
return { componentTag, params }
}
}
return null;
return null
}
async handleRouteChange() {
const { path, params: queryParams } = this.parseHash();
const match = this.matchRoute(path);
const { path, params: queryParams } = this.parseHash()
const match = this.matchRoute(path)
if (this.beforeNavigate) {
const shouldContinue = await this.beforeNavigate(path);
if (!shouldContinue) return;
const shouldContinue = await this.beforeNavigate(path)
if (!shouldContinue) return
}
if (!match) {
this.renderNotFound();
return;
this.renderNotFound()
return
}
const { componentTag, params: routeParams } = match;
const { componentTag, params: routeParams } = match
this.currentRoute = {
path,
params: { ...routeParams, ...queryParams },
componentTag
};
}
this.render();
this.render()
if (this.afterNavigate) {
this.afterNavigate(this.currentRoute);
this.afterNavigate(this.currentRoute)
}
}
render() {
if (!this.outlet || !this.currentRoute) return;
if (!this.outlet || !this.currentRoute) return
const { componentTag, params } = this.currentRoute;
const oldComponent = this.outlet.firstElementChild;
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const { componentTag, params } = this.currentRoute
const oldComponent = this.outlet.firstElementChild
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const component = document.createElement(componentTag);
const component = document.createElement(componentTag)
Object.entries(params).forEach(([key, value]) => {
component.setAttribute(`data-${key}`, value);
});
component.setAttribute(`data-${key}`, value)
})
if (prefersReducedMotion || !oldComponent) {
if (oldComponent) oldComponent.remove();
this.outlet.appendChild(component);
return;
if (oldComponent) oldComponent.remove()
this.outlet.appendChild(component)
return
}
component.classList.add('animate__animated', 'animate__fadeIn', 'animate__faster');
oldComponent.classList.add('animate__animated', 'animate__fadeOut', 'animate__faster');
component.classList.add('animate__animated', 'animate__fadeIn', 'animate__faster')
oldComponent.classList.add('animate__animated', 'animate__fadeOut', 'animate__faster')
const fallbackTimer = setTimeout(() => {
oldComponent.remove();
this.outlet.appendChild(component);
}, 300);
oldComponent.remove()
this.outlet.appendChild(component)
}, 300)
oldComponent.addEventListener('animationend', () => {
clearTimeout(fallbackTimer);
oldComponent.remove();
this.outlet.appendChild(component);
}, { once: true });
clearTimeout(fallbackTimer)
oldComponent.remove()
this.outlet.appendChild(component)
}, { once: true })
}
renderNotFound() {
if (!this.outlet) return;
if (!this.outlet) return
this.outlet.innerHTML = '';
const notFound = document.createElement('page-not-found');
this.outlet.appendChild(notFound);
this.outlet.innerHTML = ''
const notFound = document.createElement('page-not-found')
this.outlet.appendChild(notFound)
}
navigate(path, params = {}) {
let url = `#${path}`;
let url = `#${path}`
if (Object.keys(params).length > 0) {
const queryString = new URLSearchParams(params).toString();
url += `?${queryString}`;
const queryString = new URLSearchParams(params).toString()
url += `?${queryString}`
}
window.location.hash = url.slice(1);
window.location.hash = url.slice(1)
}
back() {
window.history.back();
window.history.back()
}
forward() {
window.history.forward();
window.history.forward()
}
getCurrentRoute() {
return this.currentRoute;
return this.currentRoute
}
}
export const router = new Router();
export const router = new Router()

View File

@@ -1,138 +1,138 @@
const API_BASE_URL = '/api';
const API_BASE_URL = '/api'
class ApiService {
constructor() {
this.baseUrl = API_BASE_URL;
this.token = null;
this.baseUrl = API_BASE_URL
this.token = null
}
setToken(token) {
this.token = token;
this.token = token
}
clearToken() {
this.token = null;
this.token = null
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const url = `${this.baseUrl}${endpoint}`
const headers = {
'Content-Type': 'application/json',
...options.headers
};
}
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
headers['Authorization'] = `Bearer ${this.token}`
}
try {
const response = await fetch(url, {
...options,
headers
});
})
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(response.status, error.message || 'Request failed', error);
const error = await response.json().catch(() => ({}))
throw new ApiError(response.status, error.message || 'Request failed', error)
}
if (response.status === 204) {
return null;
return null
}
return await response.json();
return await response.json()
} catch (error) {
if (error instanceof ApiError) throw error;
throw new ApiError(0, 'Network error', { originalError: error });
if (error instanceof ApiError) throw error
throw new ApiError(0, 'Network error', { originalError: error })
}
}
async get(endpoint, params = {}) {
const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
return this.request(url, { method: 'GET' });
const queryString = new URLSearchParams(params).toString()
const url = queryString ? `${endpoint}?${queryString}` : endpoint
return this.request(url, { method: 'GET' })
}
async post(endpoint, data) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
})
}
async put(endpoint, data) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
})
}
async patch(endpoint, data) {
return this.request(endpoint, {
method: 'PATCH',
body: JSON.stringify(data)
});
})
}
async delete(endpoint) {
return this.request(endpoint, { method: 'DELETE' });
return this.request(endpoint, { method: 'DELETE' })
}
async getListings(params = {}) {
return this.get('/items/listings', params);
return this.get('/items/listings', params)
}
async getListing(id) {
return this.get(`/items/listings/${id}`);
return this.get(`/items/listings/${id}`)
}
async createListing(data) {
return this.post('/items/listings', data);
return this.post('/items/listings', data)
}
async updateListing(id, data) {
return this.patch(`/items/listings/${id}`, data);
return this.patch(`/items/listings/${id}`, data)
}
async deleteListing(id) {
return this.delete(`/items/listings/${id}`);
return this.delete(`/items/listings/${id}`)
}
async searchListings(query, params = {}) {
return this.get('/items/listings', { search: query, ...params });
return this.get('/items/listings', { search: query, ...params })
}
async getCategories() {
return this.get('/items/categories');
return this.get('/items/categories')
}
async uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
const formData = new FormData()
formData.append('file', file)
const response = await fetch(`${this.baseUrl}/files`, {
method: 'POST',
headers: this.token ? { 'Authorization': `Bearer ${this.token}` } : {},
body: formData
});
})
if (!response.ok) {
throw new ApiError(response.status, 'Upload failed');
throw new ApiError(response.status, 'Upload failed')
}
return response.json();
return response.json()
}
}
class ApiError extends Error {
constructor(status, message, data = {}) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
super(message)
this.name = 'ApiError'
this.status = status
this.data = data
}
}
export const api = new ApiService();
export { ApiError };
export const api = new ApiService()
export { ApiError }

View File

@@ -5,14 +5,14 @@
* User remembers only their UUID
*/
import { directus } from './directus.js';
import { directus } from './directus.js'
const AUTH_DOMAIN = 'dgray.io';
const AUTH_DOMAIN = 'dgray.io'
class AuthService {
constructor() {
this.currentUser = null;
this.listeners = new Set();
this.currentUser = null
this.listeners = new Set()
}
/**
@@ -22,15 +22,15 @@ class AuthService {
generateUuid() {
// Use native if available (secure contexts)
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
return crypto.randomUUID()
}
// Fallback for non-secure contexts
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
/**
@@ -39,7 +39,7 @@ class AuthService {
* @returns {string} Fake email address
*/
uuidToEmail(uuid) {
return `${uuid}@${AUTH_DOMAIN}`;
return `${uuid}@${AUTH_DOMAIN}`
}
/**
@@ -47,14 +47,14 @@ class AuthService {
* @returns {Promise<{uuid: string, success: boolean, error?: string}>}
*/
async createAccount() {
const uuid = this.generateUuid();
const email = this.uuidToEmail(uuid);
const uuid = this.generateUuid()
const email = this.uuidToEmail(uuid)
try {
await directus.register(email, uuid);
await directus.register(email, uuid)
// Try auto-login (may fail if verification required)
const loginResult = await this.login(uuid);
const loginResult = await this.login(uuid)
if (!loginResult.success) {
// Registration worked but login failed (verification pending)
@@ -63,17 +63,17 @@ class AuthService {
success: true,
pendingVerification: true,
message: 'Account created. Login may require activation.'
};
}
}
return { uuid, success: true };
return { uuid, success: true }
} catch (error) {
console.error('Registration failed:', error);
console.error('Registration failed:', error)
return {
uuid: null,
success: false,
error: error.message || 'Registration failed'
};
}
}
}
@@ -83,21 +83,21 @@ class AuthService {
* @returns {Promise<{success: boolean, error?: string}>}
*/
async login(uuid) {
const email = this.uuidToEmail(uuid);
const email = this.uuidToEmail(uuid)
try {
await directus.login(email, uuid);
this.currentUser = await directus.getCurrentUser();
this.notifyListeners();
this.storeUuid(uuid);
await directus.login(email, uuid)
this.currentUser = await directus.getCurrentUser()
this.notifyListeners()
this.storeUuid(uuid)
return { success: true };
return { success: true }
} catch (error) {
console.error('Login failed:', error);
console.error('Login failed:', error)
return {
success: false,
error: error.message || 'Invalid UUID'
};
}
}
}
@@ -106,14 +106,14 @@ class AuthService {
*/
async logout() {
try {
await directus.logout();
await directus.logout()
} catch (e) {
// Ignore errors
}
this.currentUser = null;
this.clearStoredUuid();
this.notifyListeners();
this.currentUser = null
this.clearStoredUuid()
this.notifyListeners()
}
/**
@@ -121,7 +121,7 @@ class AuthService {
* @returns {boolean}
*/
isLoggedIn() {
return directus.isAuthenticated();
return directus.isAuthenticated()
}
/**
@@ -129,17 +129,17 @@ class AuthService {
* @returns {Promise<Object|null>}
*/
async getUser() {
if (!this.isLoggedIn()) return null;
if (!this.isLoggedIn()) return null
if (!this.currentUser) {
try {
this.currentUser = await directus.getCurrentUser();
this.currentUser = await directus.getCurrentUser()
} catch (e) {
return null;
return null
}
}
return this.currentUser;
return this.currentUser
}
/**
@@ -148,7 +148,7 @@ class AuthService {
* @param {string} uuid
*/
storeUuid(uuid) {
localStorage.setItem('dgray_uuid', uuid);
localStorage.setItem('dgray_uuid', uuid)
}
/**
@@ -156,14 +156,14 @@ class AuthService {
* @returns {string|null}
*/
getStoredUuid() {
return localStorage.getItem('dgray_uuid');
return localStorage.getItem('dgray_uuid')
}
/**
* Clears stored UUID
*/
clearStoredUuid() {
localStorage.removeItem('dgray_uuid');
localStorage.removeItem('dgray_uuid')
}
/**
@@ -172,15 +172,15 @@ class AuthService {
* @returns {Function} Unsubscribe function
*/
subscribe(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
this.listeners.add(callback)
return () => this.listeners.delete(callback)
}
/**
* Notifies all listeners of auth state change
*/
notifyListeners() {
this.listeners.forEach(cb => cb(this.isLoggedIn(), this.currentUser));
this.listeners.forEach(cb => cb(this.isLoggedIn(), this.currentUser))
}
/**
@@ -189,16 +189,16 @@ class AuthService {
async tryRestoreSession() {
if (directus.isAuthenticated()) {
try {
this.currentUser = await directus.getCurrentUser();
this.notifyListeners();
return true;
this.currentUser = await directus.getCurrentUser()
this.notifyListeners()
return true
} catch (e) {
return false;
return false
}
}
return false;
return false
}
}
export const auth = new AuthService();
export default auth;
export const auth = new AuthService()
export default auth

View File

@@ -3,14 +3,14 @@
* Uses LocalStorage as mock backend until Directus is ready
*/
import { cryptoService } from './crypto.js';
import { cryptoService } from './crypto.js'
const CHATS_STORAGE_KEY = 'dgray_chats';
const MESSAGES_STORAGE_KEY = 'dgray_messages';
const CHATS_STORAGE_KEY = 'dgray_chats'
const MESSAGES_STORAGE_KEY = 'dgray_messages'
class ChatService {
constructor() {
this.subscribers = new Set();
this.subscribers = new Set()
}
/**
@@ -21,14 +21,14 @@ class ChatService {
* @returns {object} - Chat object
*/
getOrCreateChat(recipientId, recipientPublicKey, listingId) {
const chats = this.getAllChats();
const myPublicKey = cryptoService.getPublicKey();
const chats = this.getAllChats()
const myPublicKey = cryptoService.getPublicKey()
// Find existing chat for this listing + recipient
let chat = chats.find(c =>
c.listingId === listingId &&
c.recipientId === recipientId
);
)
if (!chat) {
chat = {
@@ -39,24 +39,24 @@ class ChatService {
myPublicKey,
createdAt: new Date().toISOString(),
lastMessageAt: null
};
chats.push(chat);
this.saveChats(chats);
}
chats.push(chat)
this.saveChats(chats)
}
return chat;
return chat
}
getAllChats() {
try {
return JSON.parse(localStorage.getItem(CHATS_STORAGE_KEY) || '[]');
return JSON.parse(localStorage.getItem(CHATS_STORAGE_KEY) || '[]')
} catch {
return [];
return []
}
}
saveChats(chats) {
localStorage.setItem(CHATS_STORAGE_KEY, JSON.stringify(chats));
localStorage.setItem(CHATS_STORAGE_KEY, JSON.stringify(chats))
}
/**
@@ -67,9 +67,9 @@ class ChatService {
* @returns {object} - The saved message
*/
async sendMessage(chatId, recipientPublicKey, plainText) {
await cryptoService.ready;
await cryptoService.ready
const { nonce, ciphertext } = cryptoService.encrypt(plainText, recipientPublicKey);
const { nonce, ciphertext } = cryptoService.encrypt(plainText, recipientPublicKey)
const message = {
id: this.generateId(),
@@ -80,23 +80,23 @@ class ChatService {
timestamp: new Date().toISOString(),
// Store plain text for own messages (we can't decrypt our own box messages)
_plainText: plainText
};
const messages = this.getAllMessages();
messages.push(message);
this.saveMessages(messages);
// Update chat's lastMessageAt
const chats = this.getAllChats();
const chat = chats.find(c => c.id === chatId);
if (chat) {
chat.lastMessageAt = message.timestamp;
this.saveChats(chats);
}
this.notifySubscribers();
const messages = this.getAllMessages()
messages.push(message)
this.saveMessages(messages)
return message;
// Update chat's lastMessageAt
const chats = this.getAllChats()
const chat = chats.find(c => c.id === chatId)
if (chat) {
chat.lastMessageAt = message.timestamp
this.saveChats(chats)
}
this.notifySubscribers()
return message
}
/**
@@ -106,22 +106,22 @@ class ChatService {
* @returns {Array} - Decrypted messages
*/
async getMessages(chatId, otherPublicKey) {
await cryptoService.ready;
await cryptoService.ready
const messages = this.getAllMessages().filter(m => m.chatId === chatId);
const myPublicKey = cryptoService.getPublicKey();
const messages = this.getAllMessages().filter(m => m.chatId === chatId)
const myPublicKey = cryptoService.getPublicKey()
return messages.map(msg => {
const isOwn = msg.senderPublicKey === myPublicKey;
const isOwn = msg.senderPublicKey === myPublicKey
let text;
let text
if (isOwn) {
// Use stored plain text for own messages
text = msg._plainText || '[Encrypted]';
text = msg._plainText || '[Encrypted]'
} else {
// Decrypt messages from others
text = cryptoService.decrypt(msg.ciphertext, msg.nonce, msg.senderPublicKey);
if (!text) text = '[Decryption failed]';
text = cryptoService.decrypt(msg.ciphertext, msg.nonce, msg.senderPublicKey)
if (!text) text = '[Decryption failed]'
}
return {
@@ -129,33 +129,33 @@ class ChatService {
text,
isOwn,
timestamp: msg.timestamp
};
});
}
})
}
getAllMessages() {
try {
return JSON.parse(localStorage.getItem(MESSAGES_STORAGE_KEY) || '[]');
return JSON.parse(localStorage.getItem(MESSAGES_STORAGE_KEY) || '[]')
} catch {
return [];
return []
}
}
saveMessages(messages) {
localStorage.setItem(MESSAGES_STORAGE_KEY, JSON.stringify(messages));
localStorage.setItem(MESSAGES_STORAGE_KEY, JSON.stringify(messages))
}
generateId() {
return 'msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
return 'msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
}
subscribe(callback) {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
this.subscribers.add(callback)
return () => this.subscribers.delete(callback)
}
notifySubscribers() {
this.subscribers.forEach(cb => cb());
this.subscribers.forEach(cb => cb())
}
/**
@@ -163,10 +163,10 @@ class ChatService {
* In production, this would come from Directus/WebSocket
*/
async simulateIncomingMessage(chatId, senderPublicKey, plainText) {
await cryptoService.ready;
await cryptoService.ready
const myPublicKey = cryptoService.getPublicKey();
const { nonce, ciphertext } = this.encryptForRecipient(plainText, myPublicKey, senderPublicKey);
const myPublicKey = cryptoService.getPublicKey()
const { nonce, ciphertext } = this.encryptForRecipient(plainText, myPublicKey, senderPublicKey)
const message = {
id: this.generateId(),
@@ -175,43 +175,43 @@ class ChatService {
nonce,
ciphertext,
timestamp: new Date().toISOString()
};
}
const messages = this.getAllMessages();
messages.push(message);
this.saveMessages(messages);
const messages = this.getAllMessages()
messages.push(message)
this.saveMessages(messages)
this.notifySubscribers();
this.notifySubscribers()
return message;
return message
}
// Helper for simulation - encrypt as if from another user
encryptForRecipient(message, recipientPublicKey, senderSecretKey) {
// This is a simplified simulation - in reality the sender would have their own keypair
const nacl = window.nacl;
const naclUtil = window.nacl.util;
const nacl = window.nacl
const naclUtil = window.nacl.util
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const messageUint8 = naclUtil.decodeUTF8(message);
const nonce = nacl.randomBytes(nacl.box.nonceLength)
const messageUint8 = naclUtil.decodeUTF8(message)
// For simulation, we create a temporary keypair for the "sender"
const senderKeyPair = nacl.box.keyPair.fromSecretKey(
naclUtil.decodeBase64(senderSecretKey || naclUtil.encodeBase64(nacl.randomBytes(32)))
);
)
const encrypted = nacl.box(
messageUint8,
nonce,
naclUtil.decodeBase64(recipientPublicKey),
senderKeyPair.secretKey
);
)
return {
nonce: naclUtil.encodeBase64(nonce),
ciphertext: naclUtil.encodeBase64(encrypted)
};
}
}
}
export const chatService = new ChatService();
export const chatService = new ChatService()

View File

@@ -3,76 +3,76 @@
* https://tweetnacl.js.org/
*/
const STORAGE_KEY = 'dgray_keypair';
const STORAGE_KEY = 'dgray_keypair'
class CryptoService {
constructor() {
this.nacl = null;
this.naclUtil = null;
this.keyPair = null;
this.ready = this.init();
this.nacl = null
this.naclUtil = null
this.keyPair = null
this.ready = this.init()
}
async init() {
// Dynamically import TweetNaCl from CDN
if (!window.nacl) {
await this.loadScript('https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js');
await this.loadScript('https://cdn.jsdelivr.net/npm/tweetnacl-util@0.15.1/nacl-util.min.js');
await this.loadScript('https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js')
await this.loadScript('https://cdn.jsdelivr.net/npm/tweetnacl-util@0.15.1/nacl-util.min.js')
}
this.nacl = window.nacl;
this.naclUtil = window.nacl.util;
this.nacl = window.nacl
this.naclUtil = window.nacl.util
this.loadOrCreateKeyPair();
this.loadOrCreateKeyPair()
}
loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
resolve();
return;
resolve()
return
}
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
const script = document.createElement('script')
script.src = src
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
}
loadOrCreateKeyPair() {
const stored = localStorage.getItem(STORAGE_KEY);
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
const parsed = JSON.parse(stored);
const parsed = JSON.parse(stored)
this.keyPair = {
publicKey: this.naclUtil.decodeBase64(parsed.publicKey),
secretKey: this.naclUtil.decodeBase64(parsed.secretKey)
};
return;
}
return
} catch (e) {
console.warn('Failed to load keypair, generating new one');
console.warn('Failed to load keypair, generating new one')
}
}
this.generateKeyPair();
this.generateKeyPair()
}
generateKeyPair() {
this.keyPair = this.nacl.box.keyPair();
this.keyPair = this.nacl.box.keyPair()
const toStore = {
publicKey: this.naclUtil.encodeBase64(this.keyPair.publicKey),
secretKey: this.naclUtil.encodeBase64(this.keyPair.secretKey)
};
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore))
}
getPublicKey() {
if (!this.keyPair) return null;
return this.naclUtil.encodeBase64(this.keyPair.publicKey);
if (!this.keyPair) return null
return this.naclUtil.encodeBase64(this.keyPair.publicKey)
}
/**
@@ -82,21 +82,21 @@ class CryptoService {
* @returns {object} - { nonce, ciphertext } both base64 encoded
*/
encrypt(message, recipientPublicKey) {
const nonce = this.nacl.randomBytes(this.nacl.box.nonceLength);
const messageUint8 = this.naclUtil.decodeUTF8(message);
const recipientKey = this.naclUtil.decodeBase64(recipientPublicKey);
const nonce = this.nacl.randomBytes(this.nacl.box.nonceLength)
const messageUint8 = this.naclUtil.decodeUTF8(message)
const recipientKey = this.naclUtil.decodeBase64(recipientPublicKey)
const encrypted = this.nacl.box(
messageUint8,
nonce,
recipientKey,
this.keyPair.secretKey
);
)
return {
nonce: this.naclUtil.encodeBase64(nonce),
ciphertext: this.naclUtil.encodeBase64(encrypted)
};
}
}
/**
@@ -113,15 +113,15 @@ class CryptoService {
this.naclUtil.decodeBase64(nonce),
this.naclUtil.decodeBase64(senderPublicKey),
this.keyPair.secretKey
);
)
if (!decrypted) return null;
return this.naclUtil.encodeUTF8(decrypted);
if (!decrypted) return null
return this.naclUtil.encodeUTF8(decrypted)
} catch (e) {
console.error('Decryption failed:', e);
return null;
console.error('Decryption failed:', e)
return null
}
}
}
export const cryptoService = new CryptoService();
export const cryptoService = new CryptoService()

View File

@@ -5,7 +5,7 @@
* Supports two modes: fiat-fix and xmr-fix
*/
const KRAKEN_API = 'https://api.kraken.com/0/public/Ticker';
const KRAKEN_API = 'https://api.kraken.com/0/public/Ticker'
const PAIRS = {
USD: 'XXMRZUSD',
@@ -13,7 +13,7 @@ const PAIRS = {
GBP: 'XXMRZGBP',
CHF: 'XMRCHF',
JPY: 'XMRJPY'
};
}
const CURRENCY_SYMBOLS = {
XMR: 'ɱ',
@@ -22,12 +22,12 @@ const CURRENCY_SYMBOLS = {
GBP: '£',
CHF: 'CHF',
JPY: '¥'
};
}
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
let cachedRates = null;
let cacheTimestamp = 0;
let cachedRates = null
let cacheTimestamp = 0
/**
* Fetches current XMR rates from Kraken
@@ -36,35 +36,35 @@ let cacheTimestamp = 0;
export async function getXmrRates() {
// Check cache
if (cachedRates && Date.now() - cacheTimestamp < CACHE_DURATION) {
return cachedRates;
return cachedRates
}
try {
const pairs = Object.values(PAIRS).join(',');
const response = await fetch(`${KRAKEN_API}?pair=${pairs}`);
const data = await response.json();
const pairs = Object.values(PAIRS).join(',')
const response = await fetch(`${KRAKEN_API}?pair=${pairs}`)
const data = await response.json()
if (data.error && data.error.length > 0) {
console.error('Kraken API Error:', data.error);
return cachedRates || getDefaultRates();
console.error('Kraken API Error:', data.error)
return cachedRates || getDefaultRates()
}
const rates = {};
const rates = {}
for (const [currency, pair] of Object.entries(PAIRS)) {
const ticker = data.result[pair];
const ticker = data.result[pair]
if (ticker) {
rates[currency] = parseFloat(ticker.c[0]); // Last trade price
rates[currency] = parseFloat(ticker.c[0]) // Last trade price
}
}
// Update cache
cachedRates = rates;
cacheTimestamp = Date.now();
cachedRates = rates
cacheTimestamp = Date.now()
return rates;
return rates
} catch (error) {
console.error('Failed to fetch XMR rates:', error);
return cachedRates || getDefaultRates();
console.error('Failed to fetch XMR rates:', error)
return cachedRates || getDefaultRates()
}
}
@@ -78,7 +78,7 @@ function getDefaultRates() {
GBP: 130,
CHF: 145,
JPY: 24000
};
}
}
/**
@@ -89,9 +89,9 @@ function getDefaultRates() {
* @returns {number} Amount in XMR
*/
export function convertToXmr(amount, currency, rates) {
if (currency === 'XMR') return amount;
if (!rates[currency]) return amount;
return amount / rates[currency];
if (currency === 'XMR') return amount
if (!rates[currency]) return amount
return amount / rates[currency]
}
/**
@@ -102,9 +102,9 @@ export function convertToXmr(amount, currency, rates) {
* @returns {number} Amount in target currency
*/
export function convertFromXmr(xmrAmount, currency, rates) {
if (currency === 'XMR') return xmrAmount;
if (!rates[currency]) return xmrAmount;
return xmrAmount * rates[currency];
if (currency === 'XMR') return xmrAmount
if (!rates[currency]) return xmrAmount
return xmrAmount * rates[currency]
}
/**
@@ -114,36 +114,36 @@ export function convertFromXmr(xmrAmount, currency, rates) {
* @returns {Object} { primary, secondary, xmrAmount }
*/
export function formatPrice(listing, rates) {
const { price, currency, price_mode } = listing;
const { price, currency, price_mode } = listing
if (!price || price === 0) {
return {
primary: 'Free',
secondary: null,
xmrAmount: 0
};
}
}
// XMR mode: XMR is the reference price
if (price_mode === 'xmr' || currency === 'XMR') {
const xmrPrice = currency === 'XMR' ? price : convertToXmr(price, currency, rates);
const fiatEquivalent = currency !== 'XMR' ? price : convertFromXmr(price, 'EUR', rates);
const xmrPrice = currency === 'XMR' ? price : convertToXmr(price, currency, rates)
const fiatEquivalent = currency !== 'XMR' ? price : convertFromXmr(price, 'EUR', rates)
return {
primary: formatXmr(xmrPrice),
secondary: currency !== 'XMR' ? `${formatFiat(price, currency)}` : `${formatFiat(fiatEquivalent, 'EUR')}`,
xmrAmount: xmrPrice
};
}
}
// Fiat mode: Fiat is the reference price
const xmrEquivalent = convertToXmr(price, currency, rates);
const xmrEquivalent = convertToXmr(price, currency, rates)
return {
primary: formatFiat(price, currency),
secondary: `${formatXmr(xmrEquivalent)}`,
xmrAmount: xmrEquivalent
};
}
}
/**
@@ -153,9 +153,9 @@ export function formatPrice(listing, rates) {
*/
export function formatXmr(amount) {
if (amount >= 1) {
return `${amount.toFixed(4)} XMR`;
return `${amount.toFixed(4)} XMR`
}
return `${amount.toFixed(6)} XMR`;
return `${amount.toFixed(6)} XMR`
}
/**
@@ -165,18 +165,18 @@ export function formatXmr(amount) {
* @returns {string} Formatted string (e.g. "€ 150,00")
*/
export function formatFiat(amount, currency) {
const symbol = CURRENCY_SYMBOLS[currency] || currency;
const symbol = CURRENCY_SYMBOLS[currency] || currency
const formatted = new Intl.NumberFormat('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
}).format(amount)
// Symbol before or after amount
if (['EUR', 'GBP', 'USD'].includes(currency)) {
return `${symbol} ${formatted}`;
return `${symbol} ${formatted}`
}
return `${formatted} ${symbol}`;
return `${formatted} ${symbol}`
}
/**
@@ -185,13 +185,13 @@ export function formatFiat(amount, currency) {
* @returns {string} Symbol
*/
export function getCurrencySymbol(currency) {
return CURRENCY_SYMBOLS[currency] || currency;
return CURRENCY_SYMBOLS[currency] || currency
}
/**
* List of supported currencies
*/
export const SUPPORTED_CURRENCIES = ['XMR', 'EUR', 'CHF', 'USD', 'GBP', 'JPY'];
export const SUPPORTED_CURRENCIES = ['XMR', 'EUR', 'CHF', 'USD', 'GBP', 'JPY']
export default {
getXmrRates,
@@ -202,4 +202,4 @@ export default {
formatFiat,
getCurrencySymbol,
SUPPORTED_CURRENCIES
};
}

View File

@@ -1,247 +1,247 @@
/**
* Directus API Service für dgray.io
* Verbindet sich mit https://api.dgray.io/
* Directus API Service for dgray.io
* Connects to https://api.dgray.io/
*/
const DIRECTUS_URL = 'https://api.dgray.io';
const DIRECTUS_URL = 'https://api.dgray.io'
class DirectusService {
constructor() {
this.baseUrl = DIRECTUS_URL;
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
this.refreshTimeout = null;
this.baseUrl = DIRECTUS_URL
this.accessToken = null
this.refreshToken = null
this.tokenExpiry = null
this.refreshTimeout = null
this.loadTokens();
this.loadTokens()
}
// ==================== Token Management ====================
loadTokens() {
const stored = localStorage.getItem('dgray_auth');
const stored = localStorage.getItem('dgray_auth')
if (stored) {
try {
const { accessToken, refreshToken, expiry } = JSON.parse(stored);
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.tokenExpiry = expiry;
this.scheduleTokenRefresh();
const { accessToken, refreshToken, expiry } = JSON.parse(stored)
this.accessToken = accessToken
this.refreshToken = refreshToken
this.tokenExpiry = expiry
this.scheduleTokenRefresh()
} catch (e) {
this.clearTokens();
this.clearTokens()
}
}
}
saveTokens(accessToken, refreshToken, expiresIn) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.tokenExpiry = Date.now() + (expiresIn * 1000);
this.accessToken = accessToken
this.refreshToken = refreshToken
this.tokenExpiry = Date.now() + (expiresIn * 1000)
localStorage.setItem('dgray_auth', JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
expiry: this.tokenExpiry
}));
}))
this.scheduleTokenRefresh();
this.scheduleTokenRefresh()
}
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
localStorage.removeItem('dgray_auth');
this.accessToken = null
this.refreshToken = null
this.tokenExpiry = null
localStorage.removeItem('dgray_auth')
if (this.refreshTimeout) {
clearTimeout(this.refreshTimeout);
this.refreshTimeout = null;
clearTimeout(this.refreshTimeout)
this.refreshTimeout = null
}
}
scheduleTokenRefresh() {
if (this.refreshTimeout) {
clearTimeout(this.refreshTimeout);
clearTimeout(this.refreshTimeout)
}
if (!this.tokenExpiry || !this.refreshToken) return;
if (!this.tokenExpiry || !this.refreshToken) return
// Refresh 1 Minute vor Ablauf
const refreshIn = this.tokenExpiry - Date.now() - 60000;
// Refresh 1 minute before expiry
const refreshIn = this.tokenExpiry - Date.now() - 60000
if (refreshIn > 0) {
this.refreshTimeout = setTimeout(() => this.refreshSession(), refreshIn);
this.refreshTimeout = setTimeout(() => this.refreshSession(), refreshIn)
} else if (this.refreshToken) {
this.refreshSession();
this.refreshSession()
}
}
isAuthenticated() {
return !!this.accessToken && (!this.tokenExpiry || Date.now() < this.tokenExpiry);
return !!this.accessToken && (!this.tokenExpiry || Date.now() < this.tokenExpiry)
}
// ==================== HTTP Methods ====================
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const url = `${this.baseUrl}${endpoint}`
const headers = {
'Content-Type': 'application/json',
...options.headers
};
}
if (this.accessToken) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
headers['Authorization'] = `Bearer ${this.accessToken}`
}
try {
const response = await fetch(url, {
...options,
headers
});
})
// Token abgelaufen - versuche Refresh (aber nicht für auth-Endpoints)
// Token expired - try refresh (but not for auth endpoints)
if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/')) {
const refreshed = await this.refreshSession();
const refreshed = await this.refreshSession()
if (refreshed) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
return this.request(endpoint, options);
headers['Authorization'] = `Bearer ${this.accessToken}`
return this.request(endpoint, options)
} else {
// Refresh failed - clear tokens to prevent loops
this.clearTokens();
this.clearTokens()
}
}
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new DirectusError(response.status, error.errors?.[0]?.message || 'Request failed', error);
const error = await response.json().catch(() => ({}))
throw new DirectusError(response.status, error.errors?.[0]?.message || 'Request failed', error)
}
if (response.status === 204) {
return null;
return null
}
return await response.json();
return await response.json()
} catch (error) {
if (error instanceof DirectusError) throw error;
throw new DirectusError(0, 'Network error', { originalError: error });
if (error instanceof DirectusError) throw error
throw new DirectusError(0, 'Network error', { originalError: error })
}
}
async get(endpoint, params = {}) {
const queryString = this.buildQueryString(params);
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
return this.request(url, { method: 'GET' });
const queryString = this.buildQueryString(params)
const url = queryString ? `${endpoint}?${queryString}` : endpoint
return this.request(url, { method: 'GET' })
}
async post(endpoint, data) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
})
}
async patch(endpoint, data) {
return this.request(endpoint, {
method: 'PATCH',
body: JSON.stringify(data)
});
})
}
async delete(endpoint) {
return this.request(endpoint, { method: 'DELETE' });
return this.request(endpoint, { method: 'DELETE' })
}
buildQueryString(params) {
const searchParams = new URLSearchParams();
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
if (value === undefined || value === null) continue
if (typeof value === 'object') {
searchParams.set(key, JSON.stringify(value));
searchParams.set(key, JSON.stringify(value))
} else {
searchParams.set(key, value);
searchParams.set(key, value)
}
}
return searchParams.toString();
return searchParams.toString()
}
// ==================== Authentication ====================
async login(email, password) {
const response = await this.post('/auth/login', { email, password });
const response = await this.post('/auth/login', { email, password })
if (response.data) {
this.saveTokens(
response.data.access_token,
response.data.refresh_token,
response.data.expires
);
)
}
return response.data;
return response.data
}
async logout() {
if (this.refreshToken) {
try {
await this.post('/auth/logout', { refresh_token: this.refreshToken });
await this.post('/auth/logout', { refresh_token: this.refreshToken })
} catch (e) {
// Ignorieren - Token wird trotzdem gelöscht
// Ignore errors - tokens will be cleared anyway
}
}
this.clearTokens();
this.clearTokens()
}
async refreshSession() {
if (!this.refreshToken) return false;
if (!this.refreshToken) return false
try {
const response = await this.post('/auth/refresh', {
refresh_token: this.refreshToken,
mode: 'json'
});
})
if (response.data) {
this.saveTokens(
response.data.access_token,
response.data.refresh_token,
response.data.expires
);
return true;
)
return true
}
} catch (e) {
this.clearTokens();
this.clearTokens()
}
return false;
return false
}
async register(email, password) {
// Public registration (no verification required)
return this.post('/users/register', { email, password });
return this.post('/users/register', { email, password })
}
async requestPasswordReset(email) {
return this.post('/auth/password/request', { email });
return this.post('/auth/password/request', { email })
}
async resetPassword(token, password) {
return this.post('/auth/password/reset', { token, password });
return this.post('/auth/password/reset', { token, password })
}
async getCurrentUser() {
const response = await this.get('/users/me', {
fields: ['id', 'email', 'first_name', 'last_name', 'avatar', 'role.name', 'status']
});
return response.data;
})
return response.data
}
async updateCurrentUser(data) {
const response = await this.patch('/users/me', data);
return response.data;
const response = await this.patch('/users/me', data)
return response.data
}
// ==================== Listings (Anzeigen) ====================
@@ -250,99 +250,89 @@ class DirectusService {
const params = {
fields: options.fields || [
'*',
'user_created.id',
'user_created.first_name',
'images.directus_files_id.id',
'images.directus_files_id.title',
'category.id',
'category.name',
'category.translations.*',
'images.directus_files_id.*'
'user_created.id',
'user_created.first_name'
],
filter: options.filter || { status: { _eq: 'published' } },
sort: options.sort || ['-date_created'],
limit: options.limit || 20,
page: options.page || 1,
meta: 'total_count,filter_count'
};
if (options.search) {
params.search = options.search;
page: options.page || 1
}
const response = await this.get('/items/listings', params);
if (options.search) {
params.search = options.search
}
const response = await this.get('/items/listings', params)
return {
items: response.data,
meta: response.meta
};
}
}
async getListing(id) {
const response = await this.get(`/items/listings/${id}`, {
fields: [
'*',
'images.directus_files_id.*',
'category.*',
'user_created.id',
'user_created.first_name',
'user_created.avatar',
'category.*',
'category.translations.*',
'images.directus_files_id.*',
'location.*'
'user_created.date_created'
]
});
return response.data;
})
return response.data
}
async createListing(data) {
const response = await this.post('/items/listings', data);
return response.data;
const response = await this.post('/items/listings', data)
return response.data
}
async updateListing(id, data) {
const response = await this.patch(`/items/listings/${id}`, data);
return response.data;
const response = await this.patch(`/items/listings/${id}`, data)
return response.data
}
async deleteListing(id) {
return this.delete(`/items/listings/${id}`);
return this.delete(`/items/listings/${id}`)
}
async getMyListings(options = {}) {
return this.getListings({
...options,
filter: { user_created: { _eq: '$CURRENT_USER' } }
});
async getMyListings() {
const response = await this.get('/items/listings', {
fields: ['*', 'images.directus_files_id.id'],
filter: { user_created: { _eq: '$CURRENT_USER' } },
sort: ['-date_created']
})
return response.data
}
async searchListings(query, options = {}) {
return this.getListings({
...options,
search: query
});
}
async getListingsByCategory(categoryId, options = {}) {
return this.getListings({
...options,
filter: {
status: { _eq: 'published' },
category: { _eq: categoryId }
}
});
search: query,
...options
})
}
// ==================== Categories (Kategorien) ====================
async getCategories() {
const response = await this.get('/items/categories', {
fields: ['*', 'translations.*', 'parent.*'],
filter: { status: { _eq: 'published' } },
sort: ['sort', 'name']
});
return response.data;
fields: ['id', 'name', 'slug', 'icon', 'parent.id', 'parent.name'],
sort: ['sort', 'name'],
limit: -1
})
return response.data
}
async getCategoryTree() {
const categories = await this.getCategories();
return this.buildCategoryTree(categories);
const categories = await this.getCategories()
return this.buildCategoryTree(categories)
}
buildCategoryTree(categories, parentId = null) {
@@ -351,7 +341,7 @@ class DirectusService {
.map(cat => ({
...cat,
children: this.buildCategoryTree(categories, cat.id)
}));
}))
}
// ==================== Messages (Nachrichten) ====================
@@ -376,8 +366,8 @@ class DirectusService {
]
},
sort: ['-date_updated']
});
return response.data;
})
return response.data
}
async getConversation(id) {
@@ -391,16 +381,16 @@ class DirectusService {
'messages.*',
'messages.sender.*'
]
});
return response.data;
})
return response.data
}
async sendMessage(conversationId, content) {
const response = await this.post('/items/messages', {
conversation: conversationId,
content
});
return response.data;
})
return response.data
}
async startConversation(listingId, message) {
@@ -409,8 +399,8 @@ class DirectusService {
messages: {
create: [{ content: message }]
}
});
return response.data;
})
return response.data
}
// ==================== Favorites (Favoriten) ====================
@@ -419,19 +409,19 @@ class DirectusService {
const response = await this.get('/items/favorites', {
fields: ['*', 'listing.*', 'listing.images.directus_files_id.*'],
filter: { user: { _eq: '$CURRENT_USER' } }
});
return response.data;
})
return response.data
}
async addFavorite(listingId) {
const response = await this.post('/items/favorites', {
listing: listingId
});
return response.data;
})
return response.data
}
async removeFavorite(favoriteId) {
return this.delete(`/items/favorites/${favoriteId}`);
return this.delete(`/items/favorites/${favoriteId}`)
}
async isFavorite(listingId) {
@@ -441,8 +431,8 @@ class DirectusService {
listing: { _eq: listingId }
},
limit: 1
});
return response.data.length > 0 ? response.data[0] : null;
})
return response.data.length > 0 ? response.data[0] : null
}
// ==================== Reports (Meldungen) ====================
@@ -452,8 +442,8 @@ class DirectusService {
listing: listingId,
reason,
details
});
return response.data;
})
return response.data
}
async reportUser(userId, reason, details = '') {
@@ -461,57 +451,57 @@ class DirectusService {
reported_user: userId,
reason,
details
});
return response.data;
})
return response.data
}
// ==================== Files (Dateien/Bilder) ====================
async uploadFile(file, options = {}) {
const formData = new FormData();
formData.append('file', file);
const formData = new FormData()
formData.append('file', file)
if (options.folder) {
formData.append('folder', options.folder);
formData.append('folder', options.folder)
}
const response = await fetch(`${this.baseUrl}/files`, {
method: 'POST',
headers: this.accessToken ? { 'Authorization': `Bearer ${this.accessToken}` } : {},
body: formData
});
})
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new DirectusError(response.status, 'Upload failed', error);
const error = await response.json().catch(() => ({}))
throw new DirectusError(response.status, 'Upload failed', error)
}
const result = await response.json();
return result.data;
const result = await response.json()
return result.data
}
async uploadMultipleFiles(files, options = {}) {
const uploads = Array.from(files).map(file => this.uploadFile(file, options));
return Promise.all(uploads);
const uploads = Array.from(files).map(file => this.uploadFile(file, options))
return Promise.all(uploads)
}
getFileUrl(fileId, options = {}) {
if (!fileId) return null;
if (!fileId) return null
const params = new URLSearchParams();
const params = new URLSearchParams()
if (options.width) params.set('width', options.width);
if (options.height) params.set('height', options.height);
if (options.fit) params.set('fit', options.fit);
if (options.quality) params.set('quality', options.quality);
if (options.format) params.set('format', options.format);
if (options.width) params.set('width', options.width)
if (options.height) params.set('height', options.height)
if (options.fit) params.set('fit', options.fit)
if (options.quality) params.set('quality', options.quality)
if (options.format) params.set('format', options.format)
const queryString = params.toString();
return `${this.baseUrl}/assets/${fileId}${queryString ? '?' + queryString : ''}`;
const queryString = params.toString()
return `${this.baseUrl}/assets/${fileId}${queryString ? '?' + queryString : ''}`
}
getThumbnailUrl(fileId, size = 300) {
return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover' });
return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover' })
}
// ==================== Search ====================
@@ -523,12 +513,12 @@ class DirectusService {
search: query,
limit: options.categoryLimit || 5
})
]);
])
return {
listings: listings.items,
categories: categories.data
};
}
}
// ==================== Stats / Dashboard ====================
@@ -552,37 +542,37 @@ class DirectusService {
},
aggregate: { count: '*' }
})
]);
])
return {
listingsCount: listings.data?.[0]?.count || 0,
favoritesCount: favorites.data?.[0]?.count || 0,
conversationsCount: conversations.data?.[0]?.count || 0
};
}
}
}
class DirectusError extends Error {
constructor(status, message, data = {}) {
super(message);
this.name = 'DirectusError';
this.status = status;
this.data = data;
super(message)
this.name = 'DirectusError'
this.status = status
this.data = data
}
isAuthError() {
return this.status === 401 || this.status === 403;
return this.status === 401 || this.status === 403
}
isNotFound() {
return this.status === 404;
return this.status === 404
}
isValidationError() {
return this.status === 400;
return this.status === 400
}
}
// Singleton Export
export const directus = new DirectusService();
export { DirectusError };
export const directus = new DirectusService()
export { DirectusError }