diff --git a/js/app.js b/js/app.js index 681e902..ff011d1 100644 --- a/js/app.js +++ b/js/app.js @@ -1,5 +1,6 @@ import { i18n } from './i18n.js' import { auth } from './services/auth.js' +import { favoritesService } from './services/favorites.js' import { setupGlobalErrorHandler } from './components/error-boundary.js' async function initApp() { @@ -15,6 +16,7 @@ async function initApp() { // Restore auth session before loading components await auth.tryRestoreSession() + favoritesService.init() await import('./components/app-shell.js') diff --git a/js/components/listing-card.js b/js/components/listing-card.js index 44fc2b1..59fe596 100644 --- a/js/components/listing-card.js +++ b/js/components/listing-card.js @@ -2,6 +2,7 @@ import { t, i18n } from '../i18n.js' import { escapeHTML } from '../utils/helpers.js' import { getXmrRates, formatPrice as formatCurrencyPrice } from '../services/currency.js' import { auth } from '../services/auth.js' +import { favoritesService } from '../services/favorites.js' let cachedRates = null @@ -68,26 +69,10 @@ class ListingCard extends HTMLElement { loadFavoriteState() { const id = this.getAttribute('listing-id') if (id) { - const favorites = JSON.parse(localStorage.getItem('favorites') || '[]') - this.isFavorite = favorites.includes(id) + this.isFavorite = favoritesService.isFavorite(id) } } - saveFavoriteState() { - const id = this.getAttribute('listing-id') - if (!id) return - - let favorites = JSON.parse(localStorage.getItem('favorites') || '[]') - - if (this.isFavorite) { - if (!favorites.includes(id)) favorites.push(id) - } else { - favorites = favorites.filter(f => f !== id) - } - - localStorage.setItem('favorites', JSON.stringify(favorites)) - } - render() { const id = this.getAttribute('listing-id') || '' const title = this.getAttribute('title') || t('home.placeholderTitle') @@ -183,8 +168,10 @@ class ListingCard extends HTMLElement { } toggleFavorite() { + const id = this.getAttribute('listing-id') + if (!id) return this.isFavorite = !this.isFavorite - this.saveFavoriteState() + favoritesService.toggle(id) const btn = this.querySelector('.favorite-btn') btn?.classList.toggle('active', this.isFavorite) diff --git a/js/components/pages/page-favorites.js b/js/components/pages/page-favorites.js index 5ed6de0..cc67341 100644 --- a/js/components/pages/page-favorites.js +++ b/js/components/pages/page-favorites.js @@ -1,6 +1,7 @@ import { t, i18n } from '../../i18n.js' import { directus } from '../../services/directus.js' import { auth } from '../../services/auth.js' +import { favoritesService } from '../../services/favorites.js' import { escapeHTML } from '../../utils/helpers.js' import '../listing-card.js' import '../skeleton-card.js' @@ -14,32 +15,19 @@ class PageFavorites extends HTMLElement { } connectedCallback() { - if (!auth.isLoggedIn()) { - window.location.hash = '#/' - return - } - this.render() this.loadFavorites() this.unsubscribe = i18n.subscribe(() => this.render()) - this.authUnsubscribe = auth.subscribe(() => { - if (!auth.isLoggedIn()) { - window.location.hash = '#/' - } - }) + this.favUnsubscribe = favoritesService.subscribe(() => this.loadFavorites()) } disconnectedCallback() { if (this.unsubscribe) this.unsubscribe() - if (this.authUnsubscribe) this.authUnsubscribe() - } - - getFavoriteIds() { - return JSON.parse(localStorage.getItem('favorites') || '[]') + if (this.favUnsubscribe) this.favUnsubscribe() } async loadFavorites() { - const ids = this.getFavoriteIds() + const ids = favoritesService.getAll() if (ids.length === 0) { this.loading = false diff --git a/js/components/pages/page-listing.js b/js/components/pages/page-listing.js index 7dc31fd..d00654f 100644 --- a/js/components/pages/page-listing.js +++ b/js/components/pages/page-listing.js @@ -1,6 +1,7 @@ import { t, i18n } from '../../i18n.js' import { directus } from '../../services/directus.js' import { auth } from '../../services/auth.js' +import { favoritesService } from '../../services/favorites.js' import { getXmrRates, formatPrice as formatCurrencyPrice } from '../../services/currency.js' import { escapeHTML } from '../../utils/helpers.js' import '../chat-widget.js' @@ -99,20 +100,11 @@ class PageListing extends HTMLElement { } loadFavoriteState() { - const favorites = JSON.parse(localStorage.getItem('favorites') || '[]') - this.isFavorite = favorites.includes(this.listingId) + this.isFavorite = favoritesService.isFavorite(this.listingId) } toggleFavorite() { - let favorites = JSON.parse(localStorage.getItem('favorites') || '[]') - - if (this.isFavorite) { - favorites = favorites.filter(f => f !== this.listingId) - } else { - favorites.push(this.listingId) - } - - localStorage.setItem('favorites', JSON.stringify(favorites)) + favoritesService.toggle(this.listingId) this.isFavorite = !this.isFavorite const btn = this.querySelector('#favorite-btn') diff --git a/js/components/pages/page-settings.js b/js/components/pages/page-settings.js index e694559..c1c8811 100644 --- a/js/components/pages/page-settings.js +++ b/js/components/pages/page-settings.js @@ -1,5 +1,6 @@ import { t, i18n } from '../../i18n.js' import { auth } from '../../services/auth.js' +import { favoritesService } from '../../services/favorites.js' class PageSettings extends HTMLElement { constructor() { @@ -63,9 +64,12 @@ class PageSettings extends HTMLElement { }) // Clear favorites - this.querySelector('#clear-favorites')?.addEventListener('click', () => { + this.querySelector('#clear-favorites')?.addEventListener('click', async () => { if (confirm(t('settings.confirmClearFavorites'))) { - localStorage.removeItem('favorites') + const ids = favoritesService.getAll() + for (const id of ids) { + await favoritesService.toggle(id) + } this.showToast(t('settings.favoritesCleared')) } }) diff --git a/js/services/favorites.js b/js/services/favorites.js new file mode 100644 index 0000000..0226674 --- /dev/null +++ b/js/services/favorites.js @@ -0,0 +1,180 @@ +import { directus } from './directus.js' +import { auth } from './auth.js' + +const ANON_KEY = 'dgray_favorites' + +class FavoritesService { + constructor() { + this.ids = new Set() + this.loaded = false + this.syncing = false + this.listeners = [] + } + + init() { + // Migrate old localStorage key + const oldFavs = localStorage.getItem('favorites') + if (oldFavs && !localStorage.getItem(ANON_KEY)) { + localStorage.setItem(ANON_KEY, oldFavs) + localStorage.removeItem('favorites') + } + + this.ids = new Set(this.getAnonIds()) + this.loaded = true + + auth.subscribe((loggedIn) => { + if (loggedIn) { + this.mergeOnLogin() + } else { + this.saveAnonIds() + this.loaded = true + this.notify() + } + }) + + if (auth.isLoggedIn()) { + this.mergeOnLogin() + } + } + + subscribe(cb) { + this.listeners.push(cb) + return () => { + this.listeners = this.listeners.filter(l => l !== cb) + } + } + + notify() { + this.listeners.forEach(cb => cb(this.ids)) + window.dispatchEvent(new CustomEvent('favorites-changed')) + } + + // localStorage helpers + getAnonIds() { + try { + return JSON.parse(localStorage.getItem(ANON_KEY) || '[]') + } catch { + return [] + } + } + + saveAnonIds() { + localStorage.setItem(ANON_KEY, JSON.stringify([...this.ids])) + } + + // Public API + isFavorite(id) { + return this.ids.has(id) + } + + getAll() { + return [...this.ids] + } + + async toggle(id) { + const wasFavorite = this.ids.has(id) + + // Optimistic update + if (wasFavorite) { + this.ids.delete(id) + } else { + this.ids.add(id) + } + this.notify() + + if (auth.isLoggedIn()) { + try { + if (wasFavorite) { + await this.removeRemote(id) + } else { + await this.addRemote(id) + } + } catch (e) { + // Revert on failure + if (wasFavorite) { + this.ids.add(id) + } else { + this.ids.delete(id) + } + this.notify() + console.error('Failed to sync favorite:', e) + } + } else { + this.saveAnonIds() + } + } + + // Directus API + async addRemote(listingId) { + const user = await auth.getUser() + if (!user) return + await directus.post('/items/favorites', { + user: user.id, + listing: listingId + }) + } + + async removeRemote(listingId) { + const user = await auth.getUser() + if (!user) return + const response = await directus.get('/items/favorites', { + filter: { + user: { _eq: user.id }, + listing: { _eq: listingId } + }, + fields: ['id'], + limit: 1 + }) + const item = response.data?.[0] + if (item) { + await directus.delete(`/items/favorites/${item.id}`) + } + } + + async fetchRemote() { + const user = await auth.getUser() + if (!user) return [] + const response = await directus.get('/items/favorites', { + filter: { + user: { _eq: user.id } + }, + fields: ['listing'], + limit: 200 + }) + return (response.data || []).map(f => f.listing).filter(Boolean) + } + + async mergeOnLogin() { + this.syncing = true + try { + const remoteIds = await this.fetchRemote() + const localIds = this.getAnonIds() + + // Union merge + const merged = new Set([...remoteIds, ...localIds]) + this.ids = merged + + // Push local-only favorites to server + const remoteSet = new Set(remoteIds) + const toAdd = localIds.filter(id => !remoteSet.has(id)) + for (const id of toAdd) { + try { + await this.addRemote(id) + } catch (e) { + console.error('Failed to sync favorite to server:', e) + } + } + + localStorage.removeItem(ANON_KEY) + this.loaded = true + this.notify() + } catch (e) { + console.error('Failed to merge favorites:', e) + this.loaded = true + } + this.syncing = false + } +} + +export const favoritesService = new FavoritesService() +export default favoritesService diff --git a/service-worker.js b/service-worker.js index f716316..2448f9e 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'dgray-v43'; +const CACHE_NAME = 'dgray-v44'; const STATIC_ASSETS = [ '/', '/index.html', @@ -30,6 +30,7 @@ const STATIC_ASSETS = [ '/js/services/currency.js', '/js/services/pow-captcha.js', '/js/services/btcpay.js', + '/js/services/favorites.js', // Components '/js/components/app-shell.js',