search/app/stores/favorites.ts

292 lines
8.9 KiB
TypeScript

import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import type { SearchHit } from '~/types'
/**
* Cualquier identificador de colección sirve (`activities`, `conferences`, y
* cualquier otra que se sume en el futuro). El store no asume un set cerrado
* para que añadir nuevos buscadores no requiera tocar este archivo.
*/
export type Collection = string
export interface FavoriteItem {
collection: Collection
_id: string | number
/** Snapshot del documento de Meilisearch para que el favorito se pueda
* renderizar (título, fecha, ubicación, body) sin volver a consultar. */
hit: SearchHit
addedAt: number
}
export interface FavoritesFile {
version: 1
exportedAt: string
items: FavoriteItem[]
}
export interface ImportResult {
added: number
skipped: number
invalid: number
total: number
}
const STORAGE_KEY = 'lgcc:favorites:v1'
function isValidFavorite(x: unknown): x is FavoriteItem {
if (!x || typeof x !== 'object') return false
const o = x as Record<string, unknown>
if (typeof o.collection !== 'string') return false
if (typeof o._id !== 'string' && typeof o._id !== 'number') return false
if (!o.hit || typeof o.hit !== 'object') return false
return true
}
function readStorage(): FavoriteItem[] {
if (typeof window === 'undefined') return []
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isValidFavorite)
} catch {
return []
}
}
function writeStorage(items: FavoriteItem[]) {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
} catch (e) {
console.warn('No se pudieron guardar los favoritos', e)
}
}
function sameKey(a: { collection: Collection, _id: string | number }, b: { collection: Collection, _id: string | number }) {
return a.collection === b.collection && String(a._id) === String(b._id)
}
/**
* Store de Pinia para "Mi lista" / favoritos.
*
* - Persiste en `localStorage` con la clave `lgcc:favorites:v1`.
* - Hidrata en el cliente vía el plugin `~/plugins/favorites.client.ts`,
* que se asegura de correr DESPUÉS de que Pinia haya aplicado el estado
* SSR (si no, el SSR vacío pisaría la hidratación local).
* - Sincroniza entre pestañas escuchando el evento `storage`.
* - Soporta cualquier colección (actividades, conferencias y futuras).
*/
export const useFavoritesStore = defineStore('favorites', () => {
const items = ref<FavoriteItem[]>([])
const ready = ref(false)
let listenerAttached = false
let hydrated = false
/**
* Hidrata desde localStorage. Idempotente: se puede llamar varias veces.
* El plugin cliente lo invoca al boot; otros consumidores también pueden
* llamarlo sin riesgo (no recarga si ya estamos hidratados).
*/
function hydrate() {
if (typeof window === 'undefined') return
if (hydrated) return
items.value = readStorage()
ready.value = true
hydrated = true
if (!listenerAttached) {
// Sincronizar la lista entre pestañas del navegador.
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY) return
items.value = readStorage()
})
listenerAttached = true
}
}
/**
* Cualquier mutación pasa por aquí: actualiza el estado y persiste
* atómicamente. La doble red de seguridad (commit + watch más abajo)
* es intencional: garantiza que la lista quede en `localStorage` incluso
* si en el futuro alguien muta `items` directamente sin usar `commit()`.
*/
function commit(next: FavoriteItem[]) {
items.value = next
writeStorage(next)
}
// Persistencia defensiva. El `watch` se dispara después de `commit()`
// (y escribiría redundantemente lo mismo) pero también atrapa cualquier
// mutación externa de `items.value`. Sólo activo en cliente y sólo
// después de hidratar — así no escribimos `[]` encima del storage
// antes de leer su contenido en el primer arranque.
if (typeof window !== 'undefined') {
watch(items, (next) => {
if (!hydrated) return
writeStorage(next)
}, { deep: true })
}
// ---- Getters ----------------------------------------------------------
const total = computed(() => items.value.length)
/** Conjunto de colecciones presentes en la lista — útil para pintar tabs. */
const collections = computed<string[]>(() => {
const seen = new Set<string>()
for (const it of items.value) seen.add(it.collection)
return Array.from(seen).sort()
})
function isFavorite(collection: Collection, id: string | number): boolean {
return items.value.some(it => sameKey(it, { collection, _id: id }))
}
function byCollection(collection: Collection) {
return computed(() => items.value.filter(it => it.collection === collection))
}
function countByCollection(collection: Collection) {
return computed(() => items.value.filter(it => it.collection === collection).length)
}
// ---- Acciones ---------------------------------------------------------
function add(collection: Collection, hit: SearchHit) {
if (!hit?._id) return
if (isFavorite(collection, hit._id)) return
/** Quita campos transitorios de Meilisearch para no inflar el JSON
* exportable con datos que cambian entre búsquedas. */
const cleanHit = { ...hit } as SearchHit
delete (cleanHit as Record<string, unknown>)._matchesPosition
delete (cleanHit as Record<string, unknown>)._formatted
commit([
{ collection, _id: hit._id, hit: cleanHit, addedAt: Date.now() },
...items.value
])
}
function remove(collection: Collection, id: string | number) {
commit(items.value.filter(it => !sameKey(it, { collection, _id: id })))
}
function toggle(collection: Collection, hit: SearchHit) {
if (!hit?._id) return
if (isFavorite(collection, hit._id)) remove(collection, hit._id)
else add(collection, hit)
}
function clear() {
commit([])
}
function clearCollection(collection: Collection) {
commit(items.value.filter(it => it.collection !== collection))
}
// ---- Export / Import --------------------------------------------------
function exportToJson(): string {
const file: FavoritesFile = {
version: 1,
exportedAt: new Date().toISOString(),
items: items.value
}
return JSON.stringify(file, null, 2)
}
function downloadJson(filename?: string) {
if (typeof window === 'undefined') return
const name = filename || `mi-lista-${new Date().toISOString().slice(0, 10)}.json`
const blob = new Blob([exportToJson()], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
async function copyJsonToClipboard(): Promise<boolean> {
if (typeof window === 'undefined') return false
const text = exportToJson()
try {
await navigator.clipboard.writeText(text)
return true
} catch {
return false
}
}
function importFromJson(json: string, mode: 'merge' | 'replace' = 'merge'): ImportResult {
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch {
throw new Error('El archivo no es un JSON válido.')
}
let incoming: unknown[]
if (Array.isArray(parsed)) {
incoming = parsed
} else if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { items?: unknown[] }).items)) {
incoming = (parsed as { items: unknown[] }).items
} else {
throw new Error('El JSON no tiene la forma esperada (debe contener una lista de favoritos).')
}
const valid = incoming.filter(isValidFavorite)
const invalid = incoming.length - valid.length
if (mode === 'replace') {
commit(valid.map(v => ({ ...v, addedAt: v.addedAt || Date.now() })))
return { added: valid.length, skipped: 0, invalid, total: items.value.length }
}
const next = [...items.value]
let added = 0
let skipped = 0
for (const inc of valid) {
if (next.some(it => sameKey(it, inc))) { skipped++; continue }
next.unshift({ ...inc, addedAt: inc.addedAt || Date.now() })
added++
}
commit(next)
return { added, skipped, invalid, total: items.value.length }
}
async function importFromFile(file: File, mode: 'merge' | 'replace' = 'merge') {
const text = await file.text()
return importFromJson(text, mode)
}
return {
// state
items,
ready,
// getters
total,
collections,
// queries
isFavorite,
byCollection,
countByCollection,
// mutations
hydrate,
add,
remove,
toggle,
clear,
clearCollection,
// export/import
exportToJson,
downloadJson,
copyJsonToClipboard,
importFromJson,
importFromFile
}
})