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 { i18n } from './i18n.js'
|
||||||
import { auth } from './services/auth.js'
|
import { auth } from './services/auth.js'
|
||||||
|
import { favoritesService } from './services/favorites.js'
|
||||||
import { setupGlobalErrorHandler } from './components/error-boundary.js'
|
import { setupGlobalErrorHandler } from './components/error-boundary.js'
|
||||||
|
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
@@ -15,6 +16,7 @@ async function initApp() {
|
|||||||
|
|
||||||
// Restore auth session before loading components
|
// Restore auth session before loading components
|
||||||
await auth.tryRestoreSession()
|
await auth.tryRestoreSession()
|
||||||
|
favoritesService.init()
|
||||||
|
|
||||||
await import('./components/app-shell.js')
|
await import('./components/app-shell.js')
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { t, i18n } from '../i18n.js'
|
|||||||
import { escapeHTML } from '../utils/helpers.js'
|
import { escapeHTML } from '../utils/helpers.js'
|
||||||
import { getXmrRates, formatPrice as formatCurrencyPrice } from '../services/currency.js'
|
import { getXmrRates, formatPrice as formatCurrencyPrice } from '../services/currency.js'
|
||||||
import { auth } from '../services/auth.js'
|
import { auth } from '../services/auth.js'
|
||||||
|
import { favoritesService } from '../services/favorites.js'
|
||||||
|
|
||||||
let cachedRates = null
|
let cachedRates = null
|
||||||
|
|
||||||
@@ -68,26 +69,10 @@ class ListingCard extends HTMLElement {
|
|||||||
loadFavoriteState() {
|
loadFavoriteState() {
|
||||||
const id = this.getAttribute('listing-id')
|
const id = this.getAttribute('listing-id')
|
||||||
if (id) {
|
if (id) {
|
||||||
const favorites = JSON.parse(localStorage.getItem('favorites') || '[]')
|
this.isFavorite = favoritesService.isFavorite(id)
|
||||||
this.isFavorite = favorites.includes(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() {
|
render() {
|
||||||
const id = this.getAttribute('listing-id') || ''
|
const id = this.getAttribute('listing-id') || ''
|
||||||
const title = this.getAttribute('title') || t('home.placeholderTitle')
|
const title = this.getAttribute('title') || t('home.placeholderTitle')
|
||||||
@@ -183,8 +168,10 @@ class ListingCard extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleFavorite() {
|
toggleFavorite() {
|
||||||
|
const id = this.getAttribute('listing-id')
|
||||||
|
if (!id) return
|
||||||
this.isFavorite = !this.isFavorite
|
this.isFavorite = !this.isFavorite
|
||||||
this.saveFavoriteState()
|
favoritesService.toggle(id)
|
||||||
|
|
||||||
const btn = this.querySelector('.favorite-btn')
|
const btn = this.querySelector('.favorite-btn')
|
||||||
btn?.classList.toggle('active', this.isFavorite)
|
btn?.classList.toggle('active', this.isFavorite)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { t, i18n } from '../../i18n.js'
|
import { t, i18n } from '../../i18n.js'
|
||||||
import { directus } from '../../services/directus.js'
|
import { directus } from '../../services/directus.js'
|
||||||
import { auth } from '../../services/auth.js'
|
import { auth } from '../../services/auth.js'
|
||||||
|
import { favoritesService } from '../../services/favorites.js'
|
||||||
import { escapeHTML } from '../../utils/helpers.js'
|
import { escapeHTML } from '../../utils/helpers.js'
|
||||||
import '../listing-card.js'
|
import '../listing-card.js'
|
||||||
import '../skeleton-card.js'
|
import '../skeleton-card.js'
|
||||||
@@ -14,32 +15,19 @@ class PageFavorites extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
if (!auth.isLoggedIn()) {
|
|
||||||
window.location.hash = '#/'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.render()
|
this.render()
|
||||||
this.loadFavorites()
|
this.loadFavorites()
|
||||||
this.unsubscribe = i18n.subscribe(() => this.render())
|
this.unsubscribe = i18n.subscribe(() => this.render())
|
||||||
this.authUnsubscribe = auth.subscribe(() => {
|
this.favUnsubscribe = favoritesService.subscribe(() => this.loadFavorites())
|
||||||
if (!auth.isLoggedIn()) {
|
|
||||||
window.location.hash = '#/'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
if (this.unsubscribe) this.unsubscribe()
|
if (this.unsubscribe) this.unsubscribe()
|
||||||
if (this.authUnsubscribe) this.authUnsubscribe()
|
if (this.favUnsubscribe) this.favUnsubscribe()
|
||||||
}
|
|
||||||
|
|
||||||
getFavoriteIds() {
|
|
||||||
return JSON.parse(localStorage.getItem('favorites') || '[]')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFavorites() {
|
async loadFavorites() {
|
||||||
const ids = this.getFavoriteIds()
|
const ids = favoritesService.getAll()
|
||||||
|
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { t, i18n } from '../../i18n.js'
|
import { t, i18n } from '../../i18n.js'
|
||||||
import { directus } from '../../services/directus.js'
|
import { directus } from '../../services/directus.js'
|
||||||
import { auth } from '../../services/auth.js'
|
import { auth } from '../../services/auth.js'
|
||||||
|
import { favoritesService } from '../../services/favorites.js'
|
||||||
import { getXmrRates, formatPrice as formatCurrencyPrice } from '../../services/currency.js'
|
import { getXmrRates, formatPrice as formatCurrencyPrice } from '../../services/currency.js'
|
||||||
import { escapeHTML } from '../../utils/helpers.js'
|
import { escapeHTML } from '../../utils/helpers.js'
|
||||||
import '../chat-widget.js'
|
import '../chat-widget.js'
|
||||||
@@ -99,20 +100,11 @@ class PageListing extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadFavoriteState() {
|
loadFavoriteState() {
|
||||||
const favorites = JSON.parse(localStorage.getItem('favorites') || '[]')
|
this.isFavorite = favoritesService.isFavorite(this.listingId)
|
||||||
this.isFavorite = favorites.includes(this.listingId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleFavorite() {
|
toggleFavorite() {
|
||||||
let favorites = JSON.parse(localStorage.getItem('favorites') || '[]')
|
favoritesService.toggle(this.listingId)
|
||||||
|
|
||||||
if (this.isFavorite) {
|
|
||||||
favorites = favorites.filter(f => f !== this.listingId)
|
|
||||||
} else {
|
|
||||||
favorites.push(this.listingId)
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem('favorites', JSON.stringify(favorites))
|
|
||||||
this.isFavorite = !this.isFavorite
|
this.isFavorite = !this.isFavorite
|
||||||
|
|
||||||
const btn = this.querySelector('#favorite-btn')
|
const btn = this.querySelector('#favorite-btn')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { t, i18n } from '../../i18n.js'
|
import { t, i18n } from '../../i18n.js'
|
||||||
import { auth } from '../../services/auth.js'
|
import { auth } from '../../services/auth.js'
|
||||||
|
import { favoritesService } from '../../services/favorites.js'
|
||||||
|
|
||||||
class PageSettings extends HTMLElement {
|
class PageSettings extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -63,9 +64,12 @@ class PageSettings extends HTMLElement {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Clear favorites
|
// Clear favorites
|
||||||
this.querySelector('#clear-favorites')?.addEventListener('click', () => {
|
this.querySelector('#clear-favorites')?.addEventListener('click', async () => {
|
||||||
if (confirm(t('settings.confirmClearFavorites'))) {
|
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'))
|
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 = [
|
const STATIC_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@@ -30,6 +30,7 @@ const STATIC_ASSETS = [
|
|||||||
'/js/services/currency.js',
|
'/js/services/currency.js',
|
||||||
'/js/services/pow-captcha.js',
|
'/js/services/pow-captcha.js',
|
||||||
'/js/services/btcpay.js',
|
'/js/services/btcpay.js',
|
||||||
|
'/js/services/favorites.js',
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
'/js/components/app-shell.js',
|
'/js/components/app-shell.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user