add favorites
This commit is contained in:
parent
15d4e4b08c
commit
ae7739db2b
|
|
@ -2,10 +2,13 @@
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useDebounce } from '@vueuse/core'
|
import { useDebounce } from '@vueuse/core'
|
||||||
import type { SearchHit } from '~/types'
|
import type { SearchHit } from '~/types'
|
||||||
|
import { useFavoritesStore } from '~/stores/favorites'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activity: SearchHit
|
activity: SearchHit
|
||||||
collection?: 'activities' | 'conferences'
|
/** Permitir cualquier nombre de colección para que el detalle siga siendo
|
||||||
|
* reusable cuando se añadan otros buscadores en el futuro. */
|
||||||
|
collection?: string
|
||||||
query?: string
|
query?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|
@ -13,6 +16,27 @@ const emits = defineEmits(['close'])
|
||||||
|
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const favorites = useFavoritesStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const isFav = computed(() => {
|
||||||
|
if (!props.collection || !props.activity?._id) return false
|
||||||
|
return favorites.isFavorite(props.collection, props.activity._id)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onToggleFavorite() {
|
||||||
|
if (!props.collection || !props.activity) return
|
||||||
|
const wasFav = isFav.value
|
||||||
|
favorites.toggle(props.collection, props.activity)
|
||||||
|
toast.add({
|
||||||
|
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
|
||||||
|
description: props.activity.title,
|
||||||
|
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
|
||||||
|
color: wasFav ? 'neutral' : 'primary',
|
||||||
|
duration: 1800
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Match URL & metadata ----------------------------------------------
|
// ---- Match URL & metadata ----------------------------------------------
|
||||||
|
|
||||||
function getTimestamp(i: SearchHit): number | null {
|
function getTimestamp(i: SearchHit): number | null {
|
||||||
|
|
@ -45,7 +69,11 @@ function safeDate(i: SearchHit) {
|
||||||
const bodyContainer = ref<HTMLElement | null>(null)
|
const bodyContainer = ref<HTMLElement | null>(null)
|
||||||
const scrollContainer = ref<HTMLElement | null>(null)
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const localQuery = ref(props.query || '')
|
function stripOuterQuotes(s: string): string {
|
||||||
|
return s.trim().replace(/^"+|"+$/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const localQuery = ref(stripOuterQuotes(props.query || ''))
|
||||||
const debouncedLocalQuery = useDebounce(localQuery, 200)
|
const debouncedLocalQuery = useDebounce(localQuery, 200)
|
||||||
|
|
||||||
const currentIdx = ref(0)
|
const currentIdx = ref(0)
|
||||||
|
|
@ -55,7 +83,7 @@ const totalMatches = ref(0)
|
||||||
watch(
|
watch(
|
||||||
() => props.activity?._id,
|
() => props.activity?._id,
|
||||||
() => {
|
() => {
|
||||||
localQuery.value = props.query || ''
|
localQuery.value = stripOuterQuotes(props.query || '')
|
||||||
currentIdx.value = 0
|
currentIdx.value = 0
|
||||||
totalMatches.value = 0
|
totalMatches.value = 0
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +92,8 @@ watch(
|
||||||
// Keep local query in sync if the parent search changes while the same
|
// Keep local query in sync if the parent search changes while the same
|
||||||
// activity stays open.
|
// activity stays open.
|
||||||
watch(() => props.query, (q) => {
|
watch(() => props.query, (q) => {
|
||||||
if (q !== undefined && q !== localQuery.value) localQuery.value = q || ''
|
const stripped = stripOuterQuotes(q || '')
|
||||||
|
if (stripped !== localQuery.value) localQuery.value = stripped
|
||||||
})
|
})
|
||||||
|
|
||||||
function escapeRegex(s: string) {
|
function escapeRegex(s: string) {
|
||||||
|
|
@ -107,11 +136,36 @@ function findMatchesInText(text: string, terms: string[]): Array<{ start: number
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** In the detail view, the entire query is treated as ONE exact phrase — no
|
// "los paises" -> ['los paises'] | los paises -> ['los', 'paises']
|
||||||
* word splitting. Surrounding double quotes are tolerated and stripped. */
|
// Palabras de 1 caracter se descartan (ruido); usa comillas si las necesitas.
|
||||||
function parseTerms(query: string): string[] {
|
function parseTerms(query: string): string[] {
|
||||||
const trimmed = (query || '').trim().replace(/^"+|"+$/g, '').trim()
|
if (!query) return []
|
||||||
return trimmed.length > 0 ? [trimmed] : []
|
const tokens: string[] = []
|
||||||
|
const tokenRe = /"([^"]+)"|(\S+)/g
|
||||||
|
for (const match of query.matchAll(tokenRe)) {
|
||||||
|
const [, phrase, word] = match
|
||||||
|
if (phrase) tokens.push(phrase.trim())
|
||||||
|
else if (word && word.length > 1) tokens.push(word)
|
||||||
|
}
|
||||||
|
return tokens.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decide cómo trocear la query antes de buscarla en el cuerpo del detalle.
|
||||||
|
*
|
||||||
|
* - Si la query coincide con la que viene del buscador padre (`props.query`),
|
||||||
|
* se tokeniza igual que en el listado: cada palabra se resalta por separado,
|
||||||
|
* para que el usuario vea TODOS los términos del search global.
|
||||||
|
* - Si el usuario ha modificado el input local (escribió algo distinto),
|
||||||
|
* tratamos toda la cadena como una FRASE EXACTA — sin tokenizar — porque
|
||||||
|
* cuando alguien escribe "ley de extranjeria" dentro del detalle quiere
|
||||||
|
* encontrar exactamente esa secuencia, no las tres palabras sueltas. */
|
||||||
|
function termsFor(query: string): string[] {
|
||||||
|
if (!query) return []
|
||||||
|
if (query !== (props.query || '')) {
|
||||||
|
const stripped = stripOuterQuotes(query)
|
||||||
|
return stripped ? [stripped] : []
|
||||||
|
}
|
||||||
|
return parseTerms(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
function termToRegexSource(term: string): string {
|
function termToRegexSource(term: string): string {
|
||||||
|
|
@ -239,10 +293,16 @@ async function renderBody() {
|
||||||
|
|
||||||
if (scrollContainer.value) scrollContainer.value.scrollTop = 0
|
if (scrollContainer.value) scrollContainer.value.scrollTop = 0
|
||||||
|
|
||||||
applyHighlights(false)
|
// Importante: usamos `localQuery.value` directamente (no la versión
|
||||||
|
// debounced) para que el primer scroll-a-coincidencia tras cambiar de
|
||||||
|
// detalle refleje la query recién reseteada. Si leyéramos del debounce,
|
||||||
|
// tendría aún el valor antiguo del input local (200ms de retraso) y
|
||||||
|
// buscaríamos el término viejo en el cuerpo nuevo → sin coincidencias →
|
||||||
|
// sin scroll. Bug clásico de carrera entre el reset síncrono y el debounce.
|
||||||
|
applyHighlights({ query: localQuery.value, scroll: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyHighlights(preserveScrollIfSameQuery = false) {
|
function applyHighlights({ query, scroll }: { query: string, scroll: boolean }) {
|
||||||
const el = bodyContainer.value
|
const el = bodyContainer.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
|
|
@ -255,24 +315,25 @@ function applyHighlights(preserveScrollIfSameQuery = false) {
|
||||||
// Merge adjacent text nodes so the next regex pass works cleanly.
|
// Merge adjacent text nodes so the next regex pass works cleanly.
|
||||||
el.normalize()
|
el.normalize()
|
||||||
|
|
||||||
const terms = parseTerms(debouncedLocalQuery.value || '')
|
const terms = termsFor(query)
|
||||||
|
|
||||||
const count = terms.length ? highlightTextNodes(el, terms) : 0
|
const count = terms.length ? highlightTextNodes(el, terms) : 0
|
||||||
totalMatches.value = count
|
totalMatches.value = count
|
||||||
currentIdx.value = 0
|
currentIdx.value = 0
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
nextTick(() => applyCurrent(!preserveScrollIfSameQuery))
|
nextTick(() => applyCurrent(scroll))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-render entire body when the activity changes.
|
// Re-render entire body when the activity changes.
|
||||||
watch(() => props.activity?._id, renderBody, { immediate: true })
|
watch(() => props.activity?._id, renderBody, { immediate: true })
|
||||||
|
|
||||||
// Re-apply highlights when the local search query changes.
|
// Re-apply highlights when the user edits the local search query.
|
||||||
watch(debouncedLocalQuery, () => {
|
// `scroll: false` porque mientras teclea no queremos saltar en cada pulsación;
|
||||||
// Don't auto-scroll on every keystroke; jump to first match only on activity change.
|
// el salto inicial a la primera coincidencia ya lo hace `renderBody`.
|
||||||
applyHighlights(true)
|
watch(debouncedLocalQuery, (q) => {
|
||||||
|
applyHighlights({ query: q, scroll: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(renderBody)
|
onMounted(renderBody)
|
||||||
|
|
@ -303,6 +364,9 @@ function onInputKey(e: KeyboardEvent) {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #right>
|
<template #right>
|
||||||
|
<!-- El botón de favorito ya no vive aquí: ahora es un FAB flotante
|
||||||
|
en la esquina inferior derecha del panel para que siga visible
|
||||||
|
mientras se lee. Ver más abajo en el scroll container. -->
|
||||||
<UButton
|
<UButton
|
||||||
v-if="matchUrl"
|
v-if="matchUrl"
|
||||||
:to="matchUrl"
|
:to="matchUrl"
|
||||||
|
|
@ -411,7 +475,7 @@ function onInputKey(e: KeyboardEvent) {
|
||||||
<article
|
<article
|
||||||
v-if="activity?.body"
|
v-if="activity?.body"
|
||||||
ref="bodyContainer"
|
ref="bodyContainer"
|
||||||
class="prose prose-sm max-w-none dark:prose-invert p-4 sm:p-6"
|
class="prose prose-sm max-w-none dark:prose-invert p-4 sm:p-6 pb-24"
|
||||||
/>
|
/>
|
||||||
<p v-else class="p-4 sm:p-6 text-sm text-muted">
|
<p v-else class="p-4 sm:p-6 text-sm text-muted">
|
||||||
No hay contenido disponible para esta coincidencia.
|
No hay contenido disponible para esta coincidencia.
|
||||||
|
|
@ -420,6 +484,35 @@ function onInputKey(e: KeyboardEvent) {
|
||||||
<ULink :to="matchUrl" target="_blank" class="text-primary">verla en el sitio</ULink>.
|
<ULink :to="matchUrl" target="_blank" class="text-primary">verla en el sitio</ULink>.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Botón flotante (FAB) para guardar/quitar de favoritos.
|
||||||
|
- Vive dentro del contenedor de scroll para anclarse al panel y no
|
||||||
|
interferir con otros layouts (e.g. layouts multi-panel).
|
||||||
|
- Truco: el wrapper es `sticky bottom-0 h-0` con `pointer-events-none`,
|
||||||
|
de forma que ocupa cero altura en el flujo del documento pero
|
||||||
|
mantiene al hijo absoluto pegado al borde inferior visible mientras
|
||||||
|
el usuario hace scroll. Así el botón siempre está a un toque,
|
||||||
|
igual en escritorio y en móvil.
|
||||||
|
- El padding-bottom extra del article (`pb-24`) evita que el FAB
|
||||||
|
tape el final del texto cuando se llega al final del documento.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
v-if="collection"
|
||||||
|
class="sticky bottom-0 inset-x-0 h-0 z-20 pointer-events-none"
|
||||||
|
>
|
||||||
|
<UTooltip :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'">
|
||||||
|
<UButton
|
||||||
|
:icon="isFav ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
|
||||||
|
:color="isFav ? 'primary' : 'neutral'"
|
||||||
|
variant="solid"
|
||||||
|
size="xl"
|
||||||
|
:aria-label="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"
|
||||||
|
class="absolute bottom-4 end-4 sm:bottom-6 sm:end-6 rounded-full shadow-lg shadow-black/15 dark:shadow-black/40 ring-1 ring-default pointer-events-auto transition-transform hover:scale-105 active:scale-95"
|
||||||
|
@click="onToggleFavorite"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useIntersectionObserver } from '@vueuse/core'
|
import { useIntersectionObserver } from '@vueuse/core'
|
||||||
import type { SearchHit } from '~/types'
|
import type { SearchHit } from '~/types'
|
||||||
|
import { useFavoritesStore } from '~/stores/favorites'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activities: SearchHit[]
|
activities: SearchHit[]
|
||||||
|
|
@ -8,8 +9,30 @@ const props = defineProps<{
|
||||||
hasMore?: boolean
|
hasMore?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
loadingMore?: boolean
|
loadingMore?: boolean
|
||||||
|
/** Identificador de la colección a la que pertenecen estos resultados.
|
||||||
|
* Se propaga al sistema de favoritos para distinguir entre tipos de
|
||||||
|
* contenido (actividades, conferencias, etc.). */
|
||||||
|
collection?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const favorites = useFavoritesStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
function onToggleFavorite(hit: SearchHit, ev: Event) {
|
||||||
|
// Evita que el click en la estrella abra el detalle.
|
||||||
|
ev.stopPropagation()
|
||||||
|
if (!props.collection) return
|
||||||
|
const wasFav = favorites.isFavorite(props.collection, hit._id)
|
||||||
|
favorites.toggle(props.collection, hit)
|
||||||
|
toast.add({
|
||||||
|
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
|
||||||
|
description: hit.title,
|
||||||
|
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
|
||||||
|
color: wasFav ? 'neutral' : 'primary',
|
||||||
|
duration: 1800
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
loadMore: []
|
loadMore: []
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -662,7 +685,22 @@ useIntersectionObserver(
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2 mb-1">
|
<div class="flex items-start justify-between gap-2 mb-1">
|
||||||
<div class="text-sm font-semibold line-clamp-2" v-html="row.title" />
|
<div class="text-sm font-semibold line-clamp-2" v-html="row.title" />
|
||||||
<UChip v-if="row.activity.unread" />
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<UChip v-if="row.activity.unread" />
|
||||||
|
<UTooltip
|
||||||
|
v-if="collection"
|
||||||
|
:text="favorites.isFavorite(collection, row.activity._id) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
:icon="favorites.isFavorite(collection, row.activity._id) ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
|
||||||
|
:color="favorites.isFavorite(collection, row.activity._id) ? 'primary' : 'neutral'"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
:aria-label="favorites.isFavorite(collection, row.activity._id) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
|
||||||
|
@click="(ev: MouseEvent) => onToggleFavorite(row.activity, ev)"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="flex items-center gap-2 text-xs text-muted mb-1">
|
<p class="flex items-center gap-2 text-xs text-muted mb-1">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ const _useDashboard = () => {
|
||||||
|
|
||||||
defineShortcuts({
|
defineShortcuts({
|
||||||
'g-a': () => router.push('/actividades'),
|
'g-a': () => router.push('/actividades'),
|
||||||
'g-c': () => router.push('/conferencias')
|
'g-c': () => router.push('/conferencias'),
|
||||||
|
'g-f': () => router.push('/favoritos')
|
||||||
})
|
})
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Re-export del store de Pinia como composable para mantener compatibilidad
|
||||||
|
* con cualquier import previo y permitir un alias más corto en plantillas.
|
||||||
|
*
|
||||||
|
* La fuente única de verdad es `app/stores/favorites.ts`.
|
||||||
|
*/
|
||||||
|
export { useFavoritesStore as useFavorites } from '~/stores/favorites'
|
||||||
|
export type {
|
||||||
|
FavoriteItem,
|
||||||
|
FavoritesFile,
|
||||||
|
ImportResult,
|
||||||
|
Collection
|
||||||
|
} from '~/stores/favorites'
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import type { NavigationMenuItem } from '@nuxt/ui'
|
import type { NavigationMenuItem } from '@nuxt/ui'
|
||||||
|
import { useFavoritesStore } from '~/stores/favorites'
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
|
||||||
const links = [[{
|
// Activa los atajos de teclado g-a / g-c / g-f.
|
||||||
|
useDashboard()
|
||||||
|
|
||||||
|
const favorites = useFavoritesStore()
|
||||||
|
const { total: favTotal } = storeToRefs(favorites)
|
||||||
|
|
||||||
|
const links = computed<NavigationMenuItem[][]>(() => [[{
|
||||||
label: 'Actividades',
|
label: 'Actividades',
|
||||||
icon: 'i-lucide-calendar-days',
|
icon: 'i-lucide-calendar-days',
|
||||||
to: '/actividades',
|
to: '/actividades',
|
||||||
|
|
@ -13,7 +22,13 @@ const links = [[{
|
||||||
icon: 'i-lucide-mic',
|
icon: 'i-lucide-mic',
|
||||||
to: '/conferencias',
|
to: '/conferencias',
|
||||||
onSelect: () => { open.value = false }
|
onSelect: () => { open.value = false }
|
||||||
}]] satisfies NavigationMenuItem[][]
|
}, {
|
||||||
|
label: 'Mi lista',
|
||||||
|
icon: 'i-lucide-bookmark',
|
||||||
|
to: '/favoritos',
|
||||||
|
badge: favTotal.value > 0 ? String(favTotal.value) : undefined,
|
||||||
|
onSelect: () => { open.value = false }
|
||||||
|
}]])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -26,8 +41,8 @@ const links = [[{
|
||||||
class="bg-elevated/25"
|
class="bg-elevated/25"
|
||||||
:ui="{ footer: 'lg:border-t lg:border-default' }"
|
:ui="{ footer: 'lg:border-t lg:border-default' }"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header="{ collapsed }">
|
||||||
<ULink as="button" class="font-semibold">La Gran Carpa Catedral</ULink>
|
<ULink v-if="!collapsed" as="button" class="font-semibold">La Gran Carpa Catedral</ULink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ collapsed }">
|
<template #default="{ collapsed }">
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ function retry() {
|
||||||
:has-more="hasMore"
|
:has-more="hasMore"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:loading-more="loadingMore"
|
:loading-more="loadingMore"
|
||||||
|
collection="activities"
|
||||||
@load-more="loadMore"
|
@load-more="loadMore"
|
||||||
/>
|
/>
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ function retry() {
|
||||||
:has-more="hasMore"
|
:has-more="hasMore"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:loading-more="loadingMore"
|
:loading-more="loadingMore"
|
||||||
|
collection="conferences"
|
||||||
@load-more="loadMore"
|
@load-more="loadMore"
|
||||||
/>
|
/>
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,593 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { breakpointsTailwind } from '@vueuse/core'
|
||||||
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||||
|
import type { SearchHit } from '~/types'
|
||||||
|
import { useFavoritesStore, type FavoriteItem } from '~/stores/favorites'
|
||||||
|
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
||||||
|
|
||||||
|
const favorites = useFavoritesStore()
|
||||||
|
// Refs reactivos para usar en watchers / template (Pinia setup store).
|
||||||
|
const { items: favItems, total: favTotal, collections: favCollections } = storeToRefs(favorites)
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const COLLECTION_LABELS: Record<string, string> = {
|
||||||
|
activities: 'Actividades',
|
||||||
|
conferences: 'Conferencias'
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(c: string): string {
|
||||||
|
return COLLECTION_LABELS[c] || c.charAt(0).toUpperCase() + c.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtros: pestaña por colección o "todos".
|
||||||
|
const activeCollection = ref<string>('all')
|
||||||
|
|
||||||
|
// Reset del filtro si la colección activa se queda sin elementos.
|
||||||
|
watch(favCollections, (cols) => {
|
||||||
|
if (activeCollection.value !== 'all' && !cols.includes(activeCollection.value)) {
|
||||||
|
activeCollection.value = 'all'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
const items = [{ value: 'all', label: `Todos (${favTotal.value})`, icon: 'i-lucide-bookmark' }]
|
||||||
|
for (const c of favCollections.value) {
|
||||||
|
const count = favItems.value.filter(it => it.collection === c).length
|
||||||
|
items.push({
|
||||||
|
value: c,
|
||||||
|
label: `${labelFor(c)} (${count})`,
|
||||||
|
icon: c === 'activities' ? 'i-lucide-calendar-days' : c === 'conferences' ? 'i-lucide-mic' : 'i-lucide-folder'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const localQuery = ref('')
|
||||||
|
|
||||||
|
const filteredItems = computed<FavoriteItem[]>(() => {
|
||||||
|
let list = favItems.value
|
||||||
|
if (activeCollection.value !== 'all') {
|
||||||
|
list = list.filter(it => it.collection === activeCollection.value)
|
||||||
|
}
|
||||||
|
const q = localQuery.value.trim().toLowerCase()
|
||||||
|
if (q) {
|
||||||
|
list = list.filter((it) => {
|
||||||
|
const t = (it.hit?.title || '').toLowerCase()
|
||||||
|
return t.includes(q)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Selección y panel de detalle --------------------------------------
|
||||||
|
|
||||||
|
const selected = ref<FavoriteItem | null>(null)
|
||||||
|
|
||||||
|
const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null)
|
||||||
|
const selectedCollection = computed<string | undefined>(() => selected.value?.collection)
|
||||||
|
|
||||||
|
const isPanelOpen = computed({
|
||||||
|
get() { return !!selected.value },
|
||||||
|
set(v: boolean) { if (!v) selected.value = null }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Si el item seleccionado se quita de favoritos desde otra pestaña o desde el
|
||||||
|
// botón del propio detalle, cerrar el panel.
|
||||||
|
watch(favItems, (items) => {
|
||||||
|
if (!selected.value) return
|
||||||
|
const stillThere = items.find(it =>
|
||||||
|
it.collection === selected.value!.collection
|
||||||
|
&& String(it._id) === String(selected.value!._id))
|
||||||
|
if (!stillThere) selected.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
|
const isMobile = breakpoints.smaller('lg')
|
||||||
|
|
||||||
|
// ---- Helpers de fila ---------------------------------------------------
|
||||||
|
|
||||||
|
function safeDate(hit: SearchHit) {
|
||||||
|
const d = hit.date ?? hit.isodate
|
||||||
|
if (!d) return ''
|
||||||
|
const ts = typeof d === 'string' ? new Date(d).getTime() : (d as number) * 1000
|
||||||
|
if (!Number.isFinite(ts)) return ''
|
||||||
|
return formatDate(ts / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDate(hit: SearchHit) {
|
||||||
|
return hit.date != null || hit.isodate != null
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(it: FavoriteItem, ev: Event) {
|
||||||
|
ev.stopPropagation()
|
||||||
|
favorites.remove(it.collection, it._id)
|
||||||
|
toast.add({
|
||||||
|
title: 'Eliminado de tu lista',
|
||||||
|
description: it.hit?.title,
|
||||||
|
icon: 'i-lucide-bookmark-x',
|
||||||
|
color: 'neutral',
|
||||||
|
duration: 1500
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Exportar / Compartir ----------------------------------------------
|
||||||
|
|
||||||
|
async function copyList() {
|
||||||
|
if (!favTotal.value) return
|
||||||
|
const ok = await favorites.copyJsonToClipboard()
|
||||||
|
toast.add({
|
||||||
|
title: ok ? 'Lista copiada al portapapeles' : 'No se pudo copiar la lista',
|
||||||
|
description: ok ? 'Pégala en otro lugar para compartirla.' : 'Tu navegador bloqueó el acceso al portapapeles.',
|
||||||
|
icon: ok ? 'i-lucide-clipboard-check' : 'i-lucide-triangle-alert',
|
||||||
|
color: ok ? 'primary' : 'error',
|
||||||
|
duration: 2400
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadList() {
|
||||||
|
if (!favTotal.value) return
|
||||||
|
favorites.downloadJson()
|
||||||
|
toast.add({
|
||||||
|
title: 'Descargando tu lista',
|
||||||
|
icon: 'i-lucide-download',
|
||||||
|
color: 'primary',
|
||||||
|
duration: 1500
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showClearConfirm = ref(false)
|
||||||
|
function confirmClear() {
|
||||||
|
favorites.clear()
|
||||||
|
showClearConfirm.value = false
|
||||||
|
toast.add({
|
||||||
|
title: 'Lista vacía',
|
||||||
|
icon: 'i-lucide-trash-2',
|
||||||
|
color: 'neutral',
|
||||||
|
duration: 1500
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Importar ----------------------------------------------------------
|
||||||
|
|
||||||
|
const showImport = ref(false)
|
||||||
|
const importMode = ref<'merge' | 'replace'>('merge')
|
||||||
|
const importText = ref('')
|
||||||
|
const importFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const importing = ref(false)
|
||||||
|
|
||||||
|
function openImport() {
|
||||||
|
importText.value = ''
|
||||||
|
importMode.value = 'merge'
|
||||||
|
showImport.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickImportFile() {
|
||||||
|
importFileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onImportFile(ev: Event) {
|
||||||
|
const input = ev.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
const result = await favorites.importFromFile(file, importMode.value)
|
||||||
|
toast.add({
|
||||||
|
title: `Importados ${result.added}`,
|
||||||
|
description: result.skipped
|
||||||
|
? `${result.skipped} ya estaban en tu lista${result.invalid ? `, ${result.invalid} inválidos` : ''}.`
|
||||||
|
: result.invalid
|
||||||
|
? `${result.invalid} inválidos.`
|
||||||
|
: 'Tu lista se actualizó.',
|
||||||
|
icon: 'i-lucide-bookmark-plus',
|
||||||
|
color: 'primary',
|
||||||
|
duration: 2800
|
||||||
|
})
|
||||||
|
showImport.value = false
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({
|
||||||
|
title: 'No se pudo importar',
|
||||||
|
description: (e as Error)?.message || 'Archivo inválido.',
|
||||||
|
icon: 'i-lucide-triangle-alert',
|
||||||
|
color: 'error',
|
||||||
|
duration: 3200
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextImport() {
|
||||||
|
if (!importText.value.trim()) return
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
const result = favorites.importFromJson(importText.value, importMode.value)
|
||||||
|
toast.add({
|
||||||
|
title: `Importados ${result.added}`,
|
||||||
|
description: result.skipped
|
||||||
|
? `${result.skipped} ya estaban en tu lista${result.invalid ? `, ${result.invalid} inválidos` : ''}.`
|
||||||
|
: result.invalid
|
||||||
|
? `${result.invalid} inválidos.`
|
||||||
|
: 'Tu lista se actualizó.',
|
||||||
|
icon: 'i-lucide-bookmark-plus',
|
||||||
|
color: 'primary',
|
||||||
|
duration: 2800
|
||||||
|
})
|
||||||
|
showImport.value = false
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({
|
||||||
|
title: 'No se pudo importar',
|
||||||
|
description: (e as Error)?.message || 'JSON inválido.',
|
||||||
|
icon: 'i-lucide-triangle-alert',
|
||||||
|
color: 'error',
|
||||||
|
duration: 3200
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menú compacto para móvil — los 4 iconos sueltos del navbar son ambiguos sin
|
||||||
|
// tooltip (el tooltip no aparece en touch), así que en pantallas chicas los
|
||||||
|
// agrupamos en un dropdown con etiquetas explícitas.
|
||||||
|
const mobileActions = computed<DropdownMenuItem[][]>(() => [[
|
||||||
|
{
|
||||||
|
label: 'Importar lista',
|
||||||
|
icon: 'i-lucide-upload',
|
||||||
|
onSelect: () => openImport()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Copiar al portapapeles',
|
||||||
|
icon: 'i-lucide-clipboard-copy',
|
||||||
|
disabled: !favTotal.value,
|
||||||
|
onSelect: () => copyList()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Descargar (.json)',
|
||||||
|
icon: 'i-lucide-download',
|
||||||
|
disabled: !favTotal.value,
|
||||||
|
onSelect: () => downloadList()
|
||||||
|
}
|
||||||
|
], [
|
||||||
|
{
|
||||||
|
label: 'Vaciar mi lista',
|
||||||
|
icon: 'i-lucide-trash-2',
|
||||||
|
color: 'error',
|
||||||
|
disabled: !favTotal.value,
|
||||||
|
onSelect: () => { showClearConfirm.value = true }
|
||||||
|
}
|
||||||
|
]])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UDashboardPanel
|
||||||
|
id="favorites-list"
|
||||||
|
:default-size="32"
|
||||||
|
:min-size="24"
|
||||||
|
:max-size="44"
|
||||||
|
resizable
|
||||||
|
>
|
||||||
|
<UDashboardNavbar title="Mi lista">
|
||||||
|
<template #leading>
|
||||||
|
<UDashboardSidebarCollapse />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #trailing>
|
||||||
|
<UBadge v-if="favTotal" :label="favTotal" variant="subtle" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<!-- Desktop: iconos individuales (rápidos y con tooltip) -->
|
||||||
|
<div class="hidden sm:flex items-center gap-0.5">
|
||||||
|
<UTooltip text="Importar lista">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-upload"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Importar lista"
|
||||||
|
@click="openImport"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
<UTooltip text="Copiar lista al portapapeles">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-clipboard-copy"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!favTotal"
|
||||||
|
aria-label="Copiar lista"
|
||||||
|
@click="copyList"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
<UTooltip text="Descargar lista (.json)">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-download"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!favTotal"
|
||||||
|
aria-label="Descargar lista"
|
||||||
|
@click="downloadList"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
<UTooltip text="Vaciar la lista">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!favTotal"
|
||||||
|
aria-label="Vaciar lista"
|
||||||
|
@click="showClearConfirm = true"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Móvil: un único botón con menú etiquetado -->
|
||||||
|
<UDropdownMenu :items="mobileActions" :content="{ align: 'end' }" class="sm:hidden">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-ellipsis-vertical"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Más acciones"
|
||||||
|
/>
|
||||||
|
</UDropdownMenu>
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
|
||||||
|
<div class="px-4 sm:px-6 py-3 border-b border-default flex flex-col gap-2.5">
|
||||||
|
<UInput
|
||||||
|
v-model="localQuery"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
placeholder="Filtrar mi lista por título..."
|
||||||
|
size="md"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<!-- Filtros por colección. En móvil van con scroll horizontal para que
|
||||||
|
los botones siempre aparezcan completos en una sola línea. -->
|
||||||
|
<div class="-mx-4 sm:mx-0 px-4 sm:px-0 flex gap-1.5 overflow-x-auto pb-0.5 scrollbar-thin">
|
||||||
|
<UButton
|
||||||
|
v-for="t in tabs"
|
||||||
|
:key="t.value"
|
||||||
|
:icon="t.icon"
|
||||||
|
:label="t.label"
|
||||||
|
size="xs"
|
||||||
|
class="shrink-0"
|
||||||
|
:color="activeCollection === t.value ? 'primary' : 'neutral'"
|
||||||
|
:variant="activeCollection === t.value ? 'soft' : 'ghost'"
|
||||||
|
@click="activeCollection = t.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-auto divide-y divide-default flex-1">
|
||||||
|
<div
|
||||||
|
v-if="!filteredItems.length"
|
||||||
|
class="flex flex-col items-center justify-center gap-3 py-12 sm:py-16 text-dimmed text-sm px-6 text-center"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-bookmark" class="size-10" />
|
||||||
|
<template v-if="!favTotal">
|
||||||
|
<p class="font-medium text-toned">
|
||||||
|
Tu lista está vacía
|
||||||
|
</p>
|
||||||
|
<p class="max-w-xs">
|
||||||
|
Toca el icono
|
||||||
|
<UIcon name="i-lucide-bookmark-plus" class="size-4 inline-block align-text-bottom mx-0.5" />
|
||||||
|
que aparece junto a cada resultado para guardarlo aquí.
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-upload"
|
||||||
|
label="Importar una lista compartida"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
class="mt-1"
|
||||||
|
@click="openImport"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<p v-else>
|
||||||
|
No hay coincidencias en tu lista para este filtro.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(it, index) in filteredItems"
|
||||||
|
:key="`${it.collection}-${it._id}-${index}`"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors text-toned"
|
||||||
|
:class="selected && selected.collection === it.collection && String(selected._id) === String(it._id)
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-transparent hover:border-primary hover:bg-primary/5'"
|
||||||
|
@click="selected = it"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<UBadge
|
||||||
|
:label="labelFor(it.collection)"
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="neutral"
|
||||||
|
class="mb-1 capitalize"
|
||||||
|
/>
|
||||||
|
<div class="text-sm font-semibold line-clamp-2">
|
||||||
|
{{ it.hit?.title || 'Sin título' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-bookmark-x"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="-mt-1 -me-1 shrink-0"
|
||||||
|
aria-label="Quitar de mi lista"
|
||||||
|
@click="(ev: MouseEvent) => removeItem(it, ev)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted">
|
||||||
|
<span v-if="hasDate(it.hit)">{{ safeDate(it.hit) }}</span>
|
||||||
|
<USeparator v-if="hasDate(it.hit) && formatLocation(it.hit)" orientation="vertical" class="h-3 hidden sm:block" />
|
||||||
|
<span class="truncate">{{ formatLocation(it.hit) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UDashboardPanel>
|
||||||
|
|
||||||
|
<InboxActivity
|
||||||
|
v-if="selected && !isMobile"
|
||||||
|
:activity="selectedHit!"
|
||||||
|
:collection="selectedCollection"
|
||||||
|
@close="selected = null"
|
||||||
|
/>
|
||||||
|
<div v-else-if="!selected" class="hidden lg:flex flex-1 items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-2 text-dimmed">
|
||||||
|
<UIcon name="i-lucide-bookmark" class="size-16" />
|
||||||
|
<p class="text-sm">
|
||||||
|
Selecciona un elemento para ver el detalle
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
|
||||||
|
<template #content>
|
||||||
|
<InboxActivity
|
||||||
|
v-if="selected"
|
||||||
|
:activity="selectedHit!"
|
||||||
|
:collection="selectedCollection"
|
||||||
|
@close="selected = null"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</USlideover>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<!-- Confirmación de vaciado ------------------------------------------- -->
|
||||||
|
<UModal v-model:open="showClearConfirm" title="Vaciar mi lista">
|
||||||
|
<template #body>
|
||||||
|
<p class="text-sm text-toned">
|
||||||
|
Esto eliminará los <strong>{{ favTotal }}</strong> elementos guardados en este dispositivo.
|
||||||
|
La acción no se puede deshacer (a menos que tengas un export previo).
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 w-full">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
label="Cancelar"
|
||||||
|
block
|
||||||
|
class="sm:w-auto"
|
||||||
|
@click="showClearConfirm = false"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
label="Vaciar lista"
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
block
|
||||||
|
class="sm:w-auto"
|
||||||
|
@click="confirmClear"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
|
<!-- Importar ---------------------------------------------------------- -->
|
||||||
|
<UModal v-model:open="showImport" title="Importar lista" :ui="{ footer: 'justify-end' }">
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
Carga una lista compartida desde un archivo
|
||||||
|
<code class="px-1 rounded bg-elevated text-toned font-mono text-xs">.json</code>
|
||||||
|
o pega su contenido. Funciona para cualquier tipo de búsqueda
|
||||||
|
(actividades, conferencias y futuras secciones).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
|
label="Subir archivo .json"
|
||||||
|
size="md"
|
||||||
|
block
|
||||||
|
@click="pickImportFile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<USeparator label="o pega el contenido" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-toned mb-1.5">
|
||||||
|
¿Qué hacer si ya tienes favoritos guardados?
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 gap-1.5">
|
||||||
|
<UButton
|
||||||
|
label="Combinar"
|
||||||
|
icon="i-lucide-merge"
|
||||||
|
size="sm"
|
||||||
|
block
|
||||||
|
:color="importMode === 'merge' ? 'primary' : 'neutral'"
|
||||||
|
:variant="importMode === 'merge' ? 'soft' : 'outline'"
|
||||||
|
@click="importMode = 'merge'"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
label="Reemplazar"
|
||||||
|
icon="i-lucide-replace"
|
||||||
|
size="sm"
|
||||||
|
block
|
||||||
|
:color="importMode === 'replace' ? 'primary' : 'neutral'"
|
||||||
|
:variant="importMode === 'replace' ? 'soft' : 'outline'"
|
||||||
|
@click="importMode = 'replace'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-dimmed mt-1.5">
|
||||||
|
{{ importMode === 'merge'
|
||||||
|
? 'Conserva los favoritos actuales y añade los nuevos.'
|
||||||
|
: 'Borra tu lista actual y la sustituye por la importada.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UTextarea
|
||||||
|
v-model="importText"
|
||||||
|
:rows="6"
|
||||||
|
placeholder='{"version":1,"items":[ ... ]}'
|
||||||
|
class="w-full font-mono text-xs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="importFileInput"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="hidden"
|
||||||
|
@change="onImportFile"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 w-full">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
label="Cancelar"
|
||||||
|
block
|
||||||
|
class="sm:w-auto"
|
||||||
|
@click="showImport = false"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
icon="i-lucide-bookmark-plus"
|
||||||
|
label="Importar desde texto"
|
||||||
|
:loading="importing"
|
||||||
|
:disabled="!importText.trim() || importing"
|
||||||
|
block
|
||||||
|
class="sm:w-auto"
|
||||||
|
@click="applyTextImport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { useFavoritesStore } from '~/stores/favorites'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hidratación cliente del store de favoritos.
|
||||||
|
*
|
||||||
|
* Por qué un plugin (y no sólo el `hydrate()` interno del store):
|
||||||
|
*
|
||||||
|
* Con Nuxt + @pinia/nuxt, el flujo en cliente es:
|
||||||
|
* 1. Se llama por primera vez a `useFavoritesStore()` desde un componente
|
||||||
|
* o layout → corre el factory del setup store. Si ahí leyéramos
|
||||||
|
* `localStorage`, los `items` quedarían poblados…
|
||||||
|
* 2. …pero inmediatamente después Pinia aplica el payload SSR (en el que
|
||||||
|
* `items` viaja vacío, porque el servidor no tiene `localStorage`),
|
||||||
|
* sobrescribiendo lo que acabábamos de hidratar.
|
||||||
|
*
|
||||||
|
* Resultado: la lista "se perdía" al recargar la página, aunque estaba
|
||||||
|
* perfectamente guardada en `localStorage`.
|
||||||
|
*
|
||||||
|
* Solución: este plugin corre con `enforce: 'post'`, o sea **después** de
|
||||||
|
* que el módulo de Pinia haya inyectado y rehidratado todos los stores con
|
||||||
|
* el estado SSR. En ese momento llamamos a `hydrate()`, que pisa el
|
||||||
|
* `items: []` recién aplicado con la lista real del navegador.
|
||||||
|
*
|
||||||
|
* Es `.client.ts` para que no corra en el servidor (no hay `localStorage`)
|
||||||
|
* y para evitar mismatches de hidratación de Vue: durante la primera
|
||||||
|
* pintura el HTML coincide con el servidor (lista vacía) y luego, ya en
|
||||||
|
* cliente, el contenido se rellena reactivamente al hidratar.
|
||||||
|
*/
|
||||||
|
export default defineNuxtPlugin({
|
||||||
|
name: 'favorites-hydration',
|
||||||
|
enforce: 'post',
|
||||||
|
setup() {
|
||||||
|
const favorites = useFavoritesStore()
|
||||||
|
favorites.hydrate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,291 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxt/eslint', '@nuxt/ui', '@vueuse/nuxt', 'nuxt-meilisearch', '@nuxtjs/i18n'],
|
modules: ['@nuxt/eslint', '@nuxt/ui', '@vueuse/nuxt', 'nuxt-meilisearch', '@nuxtjs/i18n', '@pinia/nuxt'],
|
||||||
|
|
||||||
meilisearch: {
|
meilisearch: {
|
||||||
hostUrl: 'https://search.carpa.com', //required
|
hostUrl: 'https://search.carpa.com', //required
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"@internationalized/date": "^3.12.1",
|
"@internationalized/date": "^3.12.1",
|
||||||
"@nuxt/ui": "^4.7.0",
|
"@nuxt/ui": "^4.7.0",
|
||||||
"@nuxtjs/i18n": "^9.5.6",
|
"@nuxtjs/i18n": "^9.5.6",
|
||||||
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@tanstack/table-core": "^8.21.3",
|
"@tanstack/table-core": "^8.21.3",
|
||||||
"@unovis/ts": "^1.6.5",
|
"@unovis/ts": "^1.6.5",
|
||||||
"@unovis/vue": "^1.6.5",
|
"@unovis/vue": "^1.6.5",
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"nuxt-meilisearch": "^1.4.17",
|
"nuxt-meilisearch": "^1.4.17",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"scule": "^1.3.0",
|
"scule": "^1.3.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"vue": "^3.5.33",
|
"vue": "^3.5.33",
|
||||||
|
|
@ -5494,6 +5496,21 @@
|
||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pinia/nuxt": {
|
||||||
|
"version": "0.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.3.tgz",
|
||||||
|
"integrity": "sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/kit": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pinia": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
|
|
@ -9694,6 +9711,21 @@
|
||||||
"integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
|
"integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/copy-anything": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-what": "^5.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/core-js-compat": {
|
"node_modules/core-js-compat": {
|
||||||
"version": "3.49.0",
|
"version": "3.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
|
||||||
|
|
@ -12892,6 +12924,18 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-what": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-wsl": {
|
"node_modules/is-wsl": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
|
||||||
|
|
@ -15490,6 +15534,87 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pinia": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^7.7.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.5.0",
|
||||||
|
"vue": "^3.5.11"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||||
|
"version": "7.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||||
|
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-kit": "^7.7.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pinia/node_modules/@vue/devtools-kit": {
|
||||||
|
"version": "7.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||||
|
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-shared": "^7.7.9",
|
||||||
|
"birpc": "^2.3.0",
|
||||||
|
"hookable": "^5.5.3",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"perfect-debounce": "^1.0.0",
|
||||||
|
"speakingurl": "^14.0.1",
|
||||||
|
"superjson": "^2.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pinia/node_modules/@vue/devtools-shared": {
|
||||||
|
"version": "7.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||||
|
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"rfdc": "^1.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pinia/node_modules/birpc": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pinia/node_modules/hookable": {
|
||||||
|
"version": "5.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||||
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pinia/node_modules/mitt": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pinia/node_modules/perfect-debounce": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pkg-types": {
|
"node_modules/pkg-types": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||||
|
|
@ -16525,6 +16650,12 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rfdc": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/robust-predicates": {
|
"node_modules/robust-predicates": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||||
|
|
@ -16984,6 +17115,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/speakingurl": {
|
||||||
|
"version": "14.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||||
|
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/srvx": {
|
"node_modules/srvx": {
|
||||||
"version": "0.11.15",
|
"version": "0.11.15",
|
||||||
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
|
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
|
||||||
|
|
@ -17229,6 +17369,18 @@
|
||||||
"kdbush": "^3.0.0"
|
"kdbush": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/superjson": {
|
||||||
|
"version": "2.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||||
|
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"copy-anything": "^4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "10.2.2",
|
"version": "10.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"nuxt-meilisearch": "^1.4.17",
|
"nuxt-meilisearch": "^1.4.17",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"scule": "^1.3.0",
|
"scule": "^1.3.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"vue": "^3.5.33",
|
"vue": "^3.5.33",
|
||||||
|
|
|
||||||
11506
pnpm-lock.yaml
11506
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue