feat: add FavoritesService with Directus sync, union merge on login, localStorage migration

This commit is contained in:
2026-02-07 10:41:28 +01:00
parent fc1a1ede66
commit 0c51542df8
7 changed files with 202 additions and 48 deletions

View File

@@ -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')

View File

@@ -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)

View File

@@ -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

View File

@@ -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')

View File

@@ -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'))
}
})

180
js/services/favorites.js Normal file
View File

@@ -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

View File

@@ -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',