feat: add FavoritesService with Directus sync, union merge on login, localStorage migration
This commit is contained in:
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
180
js/services/favorites.js
Normal 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
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user