cleanup semicolon from js
This commit is contained in:
20
js/app.js
20
js/app.js
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
118
js/i18n.js
118
js/i18n.js
@@ -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)
|
||||
|
||||
130
js/router.js
130
js/router.js
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user