Compare commits
2 Commits
416bdf676a
...
b22c58cc5a
| Author | SHA1 | Date |
|---|---|---|
|
|
b22c58cc5a | |
|
|
fb2fa8bb0b |
|
|
@ -0,0 +1,202 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useFavoritesStore } from '~/stores/favorites'
|
||||
import { useHistoryStore } from '~/stores/history'
|
||||
import type { SearchHit } from '~/types'
|
||||
|
||||
interface EntrelineaDoc {
|
||||
id?: string
|
||||
image?: string
|
||||
link?: string
|
||||
locale?: string
|
||||
page?: number | string
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
document: EntrelineaDoc
|
||||
/** Identificador de la colección dentro del store de favoritos.
|
||||
* Si no se pasa, no se renderiza el botón de favorito. */
|
||||
collection?: string
|
||||
/** Versión del campo `text` con las coincidencias ya envueltas en
|
||||
* <mark class="search-match"> por Typesense. Si viene, se usa para
|
||||
* mostrar el cuerpo con resaltado; si no, cae al `document.text`. */
|
||||
highlightedText?: string | null
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{ close: [] }>()
|
||||
|
||||
/** URL de ImageKit con preset `detail` (1800px, q90). La construcción
|
||||
* centralizada vive en `~/utils/entrelineaImage` para no duplicarla. */
|
||||
const imageUrl = computed<string | null>(() => {
|
||||
return getEntrelineaImageUrl(props.document?.image, 'detail')
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
return (props.document?.id as string) || 'Entrelínea'
|
||||
})
|
||||
|
||||
const bodyHtml = computed<string>(() => {
|
||||
return props.highlightedText || props.document?.text || ''
|
||||
})
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Favoritos */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const favorites = useFavoritesStore()
|
||||
const history = useHistoryStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** Adapta el documento de Typesense al shape SearchHit que pide el store
|
||||
* de favoritos/historial (que se reusa entre todos los buscadores). */
|
||||
function toSearchHit(doc: EntrelineaDoc): SearchHit {
|
||||
const id = doc.id || ''
|
||||
return {
|
||||
_id: id,
|
||||
id,
|
||||
title: id || 'Entrelínea',
|
||||
body: doc.text,
|
||||
...doc
|
||||
}
|
||||
}
|
||||
|
||||
// Registrar la visita en el historial cada vez que cambia el documento mostrado.
|
||||
// El store deduplica por (collection, id): si el usuario reabre la misma
|
||||
// entrelínea, el item salta arriba y se actualiza visitedAt. No registramos
|
||||
// si no nos pasaron `collection` o si el documento aún no tiene id.
|
||||
watch(
|
||||
() => [props.collection, props.document?.id] as const,
|
||||
([collection, id]) => {
|
||||
if (!collection || !id) return
|
||||
history.visit(collection, toSearchHit(props.document))
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const isFav = computed(() => {
|
||||
if (!props.collection || !props.document?.id) return false
|
||||
return favorites.isFavorite(props.collection, props.document.id)
|
||||
})
|
||||
|
||||
function onToggleFavorite() {
|
||||
if (!props.collection || !props.document?.id) return
|
||||
const wasFav = isFav.value
|
||||
favorites.toggle(props.collection, toSearchHit(props.document))
|
||||
toast.add({
|
||||
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
|
||||
description: props.document.id,
|
||||
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
|
||||
color: wasFav ? 'neutral' : 'primary',
|
||||
duration: 1800
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="entrelinea-detail">
|
||||
<UDashboardNavbar :title="title" :toggle="false">
|
||||
<template #leading>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="-ms-1.5"
|
||||
@click="emits('close')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<UButton
|
||||
v-if="document.link"
|
||||
:to="document.link"
|
||||
target="_blank"
|
||||
icon="i-lucide-external-link"
|
||||
label="Ver en sitio"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-between gap-2 p-4 sm:px-6 border-b border-default">
|
||||
<div class="min-w-0 flex items-center gap-2 text-xs text-muted">
|
||||
<UBadge
|
||||
v-if="document.locale"
|
||||
:label="String(document.locale).toUpperCase()"
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
/>
|
||||
<span v-if="document.page != null">Página {{ document.page }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto relative">
|
||||
<!--
|
||||
Padding extra a la derecha (pe-16/20) y al fondo (pb-24) para que el
|
||||
FAB sticky de favorito NUNCA se superponga al contenido: el contenedor
|
||||
reserva una "zona muerta" en la esquina inferior derecha donde el
|
||||
botón puede flotar sin tapar texto ni la imagen.
|
||||
-->
|
||||
<div class="p-4 sm:p-6 pe-16 sm:pe-20 pb-24 flex flex-col gap-6">
|
||||
<!-- Imagen desde ImageKit -->
|
||||
<div
|
||||
v-if="imageUrl"
|
||||
class="rounded-lg overflow-hidden border border-default bg-elevated flex items-center justify-center"
|
||||
>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="title"
|
||||
loading="lazy"
|
||||
class="max-w-full h-auto"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-default p-8 text-center text-xs text-dimmed"
|
||||
>
|
||||
<UIcon name="i-lucide-image-off" class="size-6 mb-1 mx-auto" />
|
||||
<p>Sin imagen disponible</p>
|
||||
</div>
|
||||
|
||||
<!-- Entrelínea (text) — usa la versión resaltada si Typesense la
|
||||
devolvió, si no el HTML crudo del documento. -->
|
||||
<article
|
||||
v-if="bodyHtml"
|
||||
class="prose prose-sm max-w-none dark:prose-invert"
|
||||
v-html="bodyHtml"
|
||||
/>
|
||||
<p v-else class="text-sm text-muted">
|
||||
No hay entrelínea para esta coincidencia.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
FAB de favorito — mismo patrón que en InboxActivity: contenedor
|
||||
`sticky bottom-0 h-0` con `pointer-events-none` para que ocupe cero
|
||||
altura en el flujo y el botón quede anclado al borde inferior
|
||||
visible mientras el usuario hace scroll. El padding `pe-16/pb-24`
|
||||
del contenedor de contenido garantiza que NUNCA tape texto ni imagen.
|
||||
-->
|
||||
<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>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
|
@ -3,6 +3,7 @@ import dayjs from 'dayjs'
|
|||
import { useDebounce } from '@vueuse/core'
|
||||
import type { SearchHit } from '~/types'
|
||||
import { useFavoritesStore } from '~/stores/favorites'
|
||||
import { useHistoryStore } from '~/stores/history'
|
||||
|
||||
const props = defineProps<{
|
||||
activity: SearchHit
|
||||
|
|
@ -17,8 +18,24 @@ const emits = defineEmits(['close'])
|
|||
const { locale } = useI18n()
|
||||
|
||||
const favorites = useFavoritesStore()
|
||||
const history = useHistoryStore()
|
||||
const toast = useToast()
|
||||
|
||||
// Registrar la visita en el historial cada vez que el componente recibe una
|
||||
// actividad distinta (o se monta con la primera). El store deduplica por
|
||||
// (collection, _id) moviendo la entrada al inicio y actualizando la fecha,
|
||||
// así que disparar esto de más es seguro. No registramos si no hay
|
||||
// `collection` (uso fuera de un buscador concreto) ni si el hit aún no
|
||||
// tiene `_id` resoluble.
|
||||
watch(
|
||||
() => [props.collection, props.activity?._id] as const,
|
||||
([collection, id]) => {
|
||||
if (!collection || id == null || id === '') return
|
||||
history.visit(collection, props.activity)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const isFav = computed(() => {
|
||||
if (!props.collection || !props.activity?._id) return false
|
||||
return favorites.isFavorite(props.collection, props.activity._id)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ const _useDashboard = () => {
|
|||
defineShortcuts({
|
||||
'g-a': () => router.push('/actividades'),
|
||||
'g-c': () => router.push('/conferencias'),
|
||||
'g-f': () => router.push('/favoritos')
|
||||
'g-f': () => router.push('/favoritos'),
|
||||
'g-h': () => router.push('/historial')
|
||||
})
|
||||
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Re-export del store de Pinia como composable, paralelo a `useFavorites`.
|
||||
*
|
||||
* La fuente única de verdad es `app/stores/history.ts`.
|
||||
*/
|
||||
export { useHistoryStore as useHistory } from '~/stores/history'
|
||||
export type {
|
||||
HistoryItem,
|
||||
HistoryFile,
|
||||
ImportResult,
|
||||
Collection
|
||||
} from '~/stores/history'
|
||||
|
|
@ -3,6 +3,7 @@ import { computed } from 'vue'
|
|||
import { storeToRefs } from 'pinia'
|
||||
import type { NavigationMenuItem } from '@nuxt/ui'
|
||||
import { useFavoritesStore } from '~/stores/favorites'
|
||||
import { useHistoryStore } from '~/stores/history'
|
||||
|
||||
const { locale, locales, setLocale } = useI18n()
|
||||
|
||||
|
|
@ -14,6 +15,9 @@ const open = ref(false)
|
|||
const favorites = useFavoritesStore()
|
||||
const { total: favTotal } = storeToRefs(favorites)
|
||||
|
||||
const history = useHistoryStore()
|
||||
const { total: histTotal } = storeToRefs(history)
|
||||
|
||||
const links = [[{
|
||||
label: t('nav.bible_studies'),
|
||||
icon: 'ph:books',
|
||||
|
|
@ -51,6 +55,12 @@ const links = [[{
|
|||
to: '/favoritos',
|
||||
badge: favTotal.value > 0 ? String(favTotal.value) : undefined,
|
||||
onSelect: () => { open.value = false }
|
||||
}, {
|
||||
label: 'Historial',
|
||||
icon: 'i-lucide-history',
|
||||
to: '/historial',
|
||||
badge: histTotal.value > 0 ? String(histTotal.value) : undefined,
|
||||
onSelect: () => { open.value = false }
|
||||
}]] satisfies NavigationMenuItem[][]
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,74 +1,183 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
|
||||
import { useFavoritesStore } from '~/stores/favorites'
|
||||
import type { SearchHit } from '~/types'
|
||||
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
||||
|
||||
const { $i18n } = useNuxtApp();
|
||||
const t = $i18n.t;
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* CONFIGURACIÓN — lo único que necesitas tocar */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Nombre de la colección de Typesense a consultar. */
|
||||
const COLLECTION = 'entrelineas'
|
||||
|
||||
/** Campos por los que se hará el match de la búsqueda (separados por coma). */
|
||||
const QUERY_BY = 'text'
|
||||
|
||||
/** Campos a traer en cada hit (separados por coma). Usa '*' para traerlos todos. */
|
||||
const INCLUDE_FIELDS = '*'
|
||||
|
||||
/** Filtros adicionales (sintaxis filter_by de Typesense) a combinar con el
|
||||
* filtro automático por idioma. Ej: 'page:>10', 'origin:=Nota'.
|
||||
* Dejar vacío si no se necesita nada extra — el filtro por `locale` se
|
||||
* añade siempre a partir del idioma actual de i18n. */
|
||||
const EXTRA_FILTER_BY = ''
|
||||
|
||||
/** Cantidad de resultados por página. */
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
/** Identificador de la colección para el store de favoritos.
|
||||
* Vale cualquier string único entre buscadores. */
|
||||
const FAVORITES_COLLECTION = 'entrelineas'
|
||||
|
||||
// Nota: la base de ImageKit y los presets de transformación viven en
|
||||
// `~/utils/entrelineaImage.ts` (auto-importado). Ahí se cambia si toca
|
||||
// migrar de cuenta o ajustar tamaños.
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Estado */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const { $i18n } = useNuxtApp()
|
||||
const t = $i18n.t
|
||||
|
||||
// `locale` es reactivo: cambia cuando el usuario usa el switch de idiomas.
|
||||
// Lo usamos para construir dinámicamente el `filter_by` de Typesense.
|
||||
const { locale } = useI18n()
|
||||
|
||||
/** Filtro completo que mandamos a Typesense — locale dinámico + lo que el
|
||||
* usuario haya puesto en `EXTRA_FILTER_BY`. Es un computed para que se
|
||||
* reevalúe automáticamente cuando cambia el idioma. */
|
||||
const filterBy = computed(() => {
|
||||
const localeFilter = `locale:=${locale.value}`
|
||||
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
|
||||
})
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15000
|
||||
|
||||
const query = ref('')
|
||||
const debouncedQuery = useDebounce(query, 150)
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
// Use the raw Meilisearch client so we can pass an AbortSignal.
|
||||
const meili = useMeiliSearchRef()
|
||||
interface EntrelineaDoc {
|
||||
id?: string
|
||||
image?: string
|
||||
link?: string
|
||||
locale?: string
|
||||
page?: number | string
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const hits = ref<SearchHit[]>([])
|
||||
interface TypesenseHighlight {
|
||||
field?: string
|
||||
snippet?: string
|
||||
value?: string
|
||||
matched_tokens?: string[]
|
||||
}
|
||||
|
||||
interface TypesenseHit {
|
||||
document: EntrelineaDoc
|
||||
highlights?: TypesenseHighlight[]
|
||||
highlight?: Record<string, { snippet?: string, value?: string }>
|
||||
text_match?: number
|
||||
}
|
||||
|
||||
interface TypesenseSearchResponse {
|
||||
found: number
|
||||
out_of?: number
|
||||
page?: number
|
||||
hits?: TypesenseHit[]
|
||||
}
|
||||
|
||||
const hits = ref<TypesenseHit[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
|
||||
const hasMore = computed(() => hits.value.length < total.value)
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Búsqueda */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// Usamos `documentsApi.multiSearch` (POST con body JSON) en vez de
|
||||
// `searchCollection` porque el wrapper auto-generado del módulo serializa mal
|
||||
// los parámetros del GET y Typesense responde con error.
|
||||
const { documentsApi } = useTypesenseApi()
|
||||
|
||||
let searchSeq = 0
|
||||
let abortController: AbortController | null = null
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function runSearch(q: string, append = false) {
|
||||
// Cancel any in-flight search; saves bandwidth and prevents pile-ups.
|
||||
abortController?.abort()
|
||||
const ac = new AbortController()
|
||||
abortController = ac
|
||||
|
||||
const seq = ++searchSeq
|
||||
if (append) loadingMore.value = true
|
||||
else loading.value = true
|
||||
errorMsg.value = null
|
||||
|
||||
// Safety net in case the network just hangs.
|
||||
const timeoutId = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
// Safety net: si la red se cuelga, sacamos los spinners igualmente.
|
||||
timeoutId = setTimeout(() => {
|
||||
if (seq === searchSeq) {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
|
||||
}
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
|
||||
const page = append ? currentPage.value + 1 : 1
|
||||
|
||||
try {
|
||||
const res = await meili.index('activities_ES').search(q || '', {
|
||||
attributesToRetrieve: ['*'],
|
||||
showMatchesPosition: true,
|
||||
limit: PAGE_SIZE,
|
||||
offset: append ? hits.value.length : 0,
|
||||
sort: q ? undefined : ['date:desc']
|
||||
}, { signal: ac.signal })
|
||||
const multi = await documentsApi.multiSearch({
|
||||
// Parámetros comunes a todas las búsquedas; vacío porque cada búsqueda
|
||||
// ya lleva los suyos. El codegen exige el campo aunque no se use.
|
||||
multiSearchParameters: {},
|
||||
multiSearchSearchesParameter: {
|
||||
searches: [{
|
||||
// ⚠️ El serializer del módulo (MultiSearchCollectionParametersToJSON)
|
||||
// espera las claves en camelCase y las traduce a snake_case antes
|
||||
// de mandar el body. Si pasas snake_case directamente las ignora
|
||||
// silenciosamente y Typesense recibe la búsqueda sin filtros.
|
||||
collection: COLLECTION,
|
||||
q: q || '*',
|
||||
queryBy: QUERY_BY,
|
||||
includeFields: INCLUDE_FIELDS,
|
||||
filterBy: filterBy.value,
|
||||
perPage: PAGE_SIZE,
|
||||
page,
|
||||
// Resaltado nativo de Typesense — devuelve `value` con los matches
|
||||
// envueltos en <mark class="search-match"> para que estilemos en
|
||||
// CSS junto con los otros buscadores (ya hay regla global).
|
||||
highlightFullFields: QUERY_BY,
|
||||
highlightFields: QUERY_BY,
|
||||
highlightStartTag: '<mark class="search-match">',
|
||||
highlightEndTag: '</mark>'
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
// Si arrancó otra búsqueda mientras esta venía en camino, descartamos.
|
||||
if (seq !== searchSeq) return
|
||||
|
||||
const newHits = (res?.hits ?? []) as SearchHit[]
|
||||
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
||||
const newHits = res?.hits ?? []
|
||||
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||
total.value = res?.estimatedTotalHits ?? hits.value.length
|
||||
total.value = res?.found ?? hits.value.length
|
||||
currentPage.value = page
|
||||
} catch (err: unknown) {
|
||||
const name = (err as { name?: string })?.name
|
||||
// Aborts are expected when the user types fast; don't surface them.
|
||||
if (name === 'AbortError') return
|
||||
if (seq !== searchSeq) return
|
||||
console.error('Meilisearch error', err)
|
||||
console.error('Typesense error', err)
|
||||
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
|
||||
if (!append) {
|
||||
hits.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
if (seq === searchSeq) {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
|
|
@ -80,46 +189,109 @@ function loadMore() {
|
|||
runSearch(query.value, true)
|
||||
}
|
||||
|
||||
// Initial fetch on the client (avoids blocking SSR).
|
||||
function retry() {
|
||||
runSearch(query.value, false)
|
||||
}
|
||||
|
||||
onMounted(() => runSearch(''))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortController?.abort()
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
})
|
||||
|
||||
watch(debouncedQuery, (q) => {
|
||||
hits.value = []
|
||||
total.value = 0
|
||||
currentPage.value = 1
|
||||
runSearch(q, false)
|
||||
})
|
||||
|
||||
const selectedActivity = ref<SearchHit | null>(null)
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Selección / panel de detalle */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const isActivityPanelOpen = computed({
|
||||
get() { return !!selectedActivity.value },
|
||||
set(value: boolean) { if (!value) selectedActivity.value = null }
|
||||
const selected = ref<EntrelineaDoc | null>(null)
|
||||
|
||||
const isPanelOpen = computed({
|
||||
get() { return !!selected.value },
|
||||
set(v: boolean) { if (!v) selected.value = null }
|
||||
})
|
||||
|
||||
// Si el listado se actualiza y el seleccionado ya no está, cerramos el panel.
|
||||
watch(hits, () => {
|
||||
if (!selectedActivity.value) return
|
||||
const stillThere = hits.value.find(h => h._id === selectedActivity.value?._id)
|
||||
if (!stillThere) selectedActivity.value = null
|
||||
if (!selected.value) return
|
||||
const stillThere = hits.value.find(h => h.document.id === selected.value?.id)
|
||||
if (!stillThere) selected.value = null
|
||||
})
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isMobile = breakpoints.smaller('lg')
|
||||
|
||||
function retry() {
|
||||
runSearch(query.value, false)
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Favoritos */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const favorites = useFavoritesStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** Adapta el documento de Typesense al shape SearchHit que pide el store
|
||||
* de favoritos (que se reusa entre todos los buscadores). */
|
||||
function toSearchHit(doc: EntrelineaDoc): SearchHit {
|
||||
const id = doc.id || ''
|
||||
return {
|
||||
_id: id,
|
||||
id,
|
||||
title: id || 'Entrelínea',
|
||||
body: doc.text,
|
||||
...doc
|
||||
}
|
||||
}
|
||||
|
||||
function isFav(doc: EntrelineaDoc): boolean {
|
||||
if (!doc?.id) return false
|
||||
return favorites.isFavorite(FAVORITES_COLLECTION, doc.id)
|
||||
}
|
||||
|
||||
function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
|
||||
// Evita que el click en la estrella abra el detalle.
|
||||
ev?.stopPropagation()
|
||||
if (!doc?.id) return
|
||||
const wasFav = isFav(doc)
|
||||
favorites.toggle(FAVORITES_COLLECTION, toSearchHit(doc))
|
||||
toast.add({
|
||||
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
|
||||
description: doc.id,
|
||||
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
|
||||
color: wasFav ? 'neutral' : 'primary',
|
||||
duration: 1800
|
||||
})
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers de presentación */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Devuelve el snippet o value resaltado por Typesense para un campo, o null
|
||||
* si no hay match. Se prefiere `snippet` (más corto, recortado alrededor del
|
||||
* match) sobre `value` (campo completo) para no inflar la fila. */
|
||||
function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||
const fromArr = hit.highlights?.find(h => h.field === field)
|
||||
if (fromArr?.snippet) return fromArr.snippet
|
||||
if (fromArr?.value) return fromArr.value
|
||||
// Algunas versiones devuelven `highlight: { field: { snippet, value } }`.
|
||||
const fromObj = hit.highlight?.[field]
|
||||
if (fromObj?.snippet) return fromObj.snippet
|
||||
if (fromObj?.value) return fromObj.value
|
||||
return null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel
|
||||
id="activities-list"
|
||||
:default-size="28"
|
||||
:min-size="22"
|
||||
:max-size="40"
|
||||
id="entrelineas-list"
|
||||
:default-size="32"
|
||||
:min-size="24"
|
||||
:max-size="45"
|
||||
resizable
|
||||
>
|
||||
<UDashboardNavbar :title="t('nav.between_the_lines')">
|
||||
|
|
@ -142,8 +314,14 @@ function retry() {
|
|||
class="w-full"
|
||||
/>
|
||||
<p class="mt-1.5 flex items-center gap-1 text-[11px] text-dimmed">
|
||||
<UIcon name="i-lucide-lightbulb" class="size-3" />
|
||||
<span v-html="t('Search.tip')" />
|
||||
<UIcon name="i-lucide-database" class="size-3" />
|
||||
<span>
|
||||
<code class="text-toned">{{ COLLECTION }}</code>
|
||||
·
|
||||
<code class="text-toned">{{ QUERY_BY }}</code>
|
||||
·
|
||||
<code class="text-toned">{{ FILTER_BY }}</code>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -157,45 +335,128 @@ function retry() {
|
|||
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
|
||||
/>
|
||||
|
||||
<InboxList
|
||||
v-model="selectedActivity"
|
||||
:activities="hits"
|
||||
:query="debouncedQuery"
|
||||
:has-more="hasMore"
|
||||
:loading="loading"
|
||||
:loading-more="loadingMore"
|
||||
collection="activities"
|
||||
@load-more="loadMore"
|
||||
<div class="overflow-y-auto divide-y divide-default flex-1">
|
||||
<div
|
||||
v-if="loading && !hits.length"
|
||||
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
||||
Buscando...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!hits.length"
|
||||
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
|
||||
>
|
||||
<UIcon name="i-lucide-inbox" class="size-10" />
|
||||
<p>{{ query ? `Sin coincidencias para "${query}"` : 'Sin resultados' }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(hit, index) in hits"
|
||||
:key="hit.document.id ?? index"
|
||||
class="p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
||||
:class="[
|
||||
selected && selected.id === hit.document.id
|
||||
? 'border-primary bg-primary/10 text-highlighted'
|
||||
: 'border-transparent text-toned hover:border-primary hover:bg-primary/5'
|
||||
]"
|
||||
@click="selected = hit.document"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<!-- Título pequeño = Origin (cae al id si no hay origin para no
|
||||
dejar la fila huérfana). -->
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-muted truncate">
|
||||
{{ (hit.document.origin as string) || hit.document.id || `entrelinea_${index}` }}
|
||||
</div>
|
||||
<UTooltip :text="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'">
|
||||
<UButton
|
||||
:icon="isFav(hit.document) ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
|
||||
:color="isFav(hit.document) ? 'primary' : 'neutral'"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:aria-label="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
|
||||
@click="(ev: MouseEvent) => toggleFavorite(hit.document, ev)"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
<!-- La entrelínea (text). Si Typesense devolvió un snippet con
|
||||
highlight, lo pintamos con v-html para que aparezca el <mark>;
|
||||
si no, mostramos el HTML crudo de `text`. -->
|
||||
<div
|
||||
v-if="highlightedFor(hit, 'text') || hit.document.text"
|
||||
class="snippet-html text-sm text-toned line-clamp-3"
|
||||
v-html="highlightedFor(hit, 'text') || hit.document.text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasMore && !loading"
|
||||
class="p-4 flex justify-center"
|
||||
>
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:loading="loadingMore"
|
||||
@click="loadMore"
|
||||
>
|
||||
Cargar más
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="hits.length && !hasMore && !loading"
|
||||
class="py-3 text-center text-xs text-dimmed"
|
||||
>
|
||||
No hay más resultados
|
||||
</div>
|
||||
</div>
|
||||
</UDashboardPanel>
|
||||
|
||||
<InboxActivity
|
||||
v-if="selectedActivity"
|
||||
:activity="selectedActivity"
|
||||
collection="activities"
|
||||
:query="debouncedQuery"
|
||||
@close="selectedActivity = null"
|
||||
<!-- Panel de detalle (escritorio) -->
|
||||
<EntrelineaDetail
|
||||
v-if="selected && !isMobile"
|
||||
:document="selected"
|
||||
:collection="FAVORITES_COLLECTION"
|
||||
:highlighted-text="selected.id ? (hits.find(h => h.document.id === selected!.id)?.highlights?.find(hl => hl.field === 'text')?.value ?? null) : null"
|
||||
@close="selected = null"
|
||||
/>
|
||||
<div v-else class="hidden lg:flex flex-1 items-center justify-center">
|
||||
<div v-else-if="!isMobile" 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-search" class="size-16" />
|
||||
<UIcon name="i-lucide-mouse-pointer-click" class="size-16" />
|
||||
<p class="text-sm">
|
||||
{{ query ? 'Selecciona una coincidencia para ver el detalle' : 'Escribe arriba para buscar' }}
|
||||
Selecciona una entrelínea para ver el detalle
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel de detalle (móvil) -->
|
||||
<ClientOnly>
|
||||
<USlideover v-if="isMobile" v-model:open="isActivityPanelOpen">
|
||||
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
|
||||
<template #content>
|
||||
<InboxActivity
|
||||
v-if="selectedActivity"
|
||||
:activity="selectedActivity"
|
||||
collection="activities"
|
||||
:query="debouncedQuery"
|
||||
@close="selectedActivity = null"
|
||||
<EntrelineaDetail
|
||||
v-if="selected"
|
||||
:document="selected"
|
||||
:collection="FAVORITES_COLLECTION"
|
||||
:highlighted-text="selected.id ? (hits.find(h => h.document.id === selected!.id)?.highlights?.find(hl => hl.field === 'text')?.value ?? null) : null"
|
||||
@close="selected = null"
|
||||
/>
|
||||
</template>
|
||||
</USlideover>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* El estilo del <mark class="search-match"> ya está definido globalmente en
|
||||
`assets/css/main.css`. Aquí sólo nos aseguramos de que las etiquetas
|
||||
inline dentro del snippet (mark, strong, em, etc.) no rompan el layout. */
|
||||
.snippet-html :deep(p) {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
.snippet-html :deep(br) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,15 +6,25 @@ 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'
|
||||
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.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()
|
||||
|
||||
/** Identificador de la colección de entrelíneas en el store de favoritos.
|
||||
* Debe coincidir con `FAVORITES_COLLECTION` en `pages/entrelineas.vue`. */
|
||||
const ENTRELINEAS_COLLECTION = 'entrelineas'
|
||||
|
||||
// Nota: la URL de ImageKit y los presets de transformación viven en
|
||||
// `~/utils/entrelineaImage.ts` (auto-importado). EntrelineaDetail los usa
|
||||
// internamente, así que aquí no hay que pasar nada relacionado a ImageKit.
|
||||
|
||||
const COLLECTION_LABELS: Record<string, string> = {
|
||||
activities: 'Actividades',
|
||||
conferences: 'Conferencias'
|
||||
conferences: 'Conferencias',
|
||||
entrelineas: 'Entre Líneas'
|
||||
}
|
||||
|
||||
function labelFor(c: string): string {
|
||||
|
|
@ -38,7 +48,13 @@ const tabs = computed(() => {
|
|||
items.push({
|
||||
value: c,
|
||||
label: `${labelFor(c)} (${count})`,
|
||||
icon: c === 'activities' ? 'i-lucide-calendar-days' : c === 'conferences' ? 'i-lucide-mic' : 'i-lucide-folder'
|
||||
icon: c === 'activities'
|
||||
? 'i-lucide-calendar-days'
|
||||
: c === 'conferences'
|
||||
? 'i-lucide-mic'
|
||||
: c === 'entrelineas'
|
||||
? 'i-lucide-book-open'
|
||||
: 'i-lucide-folder'
|
||||
})
|
||||
}
|
||||
return items
|
||||
|
|
@ -68,6 +84,20 @@ const selected = ref<FavoriteItem | null>(null)
|
|||
const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null)
|
||||
const selectedCollection = computed<string | undefined>(() => selected.value?.collection)
|
||||
|
||||
/** Las entrelíneas tienen un detalle distinto al de actividades/conferencias
|
||||
* (imagen de ImageKit + texto), así que cambiamos el componente según la
|
||||
* colección del item seleccionado. */
|
||||
const isEntrelinea = computed(() => selectedCollection.value === ENTRELINEAS_COLLECTION)
|
||||
|
||||
/** El SearchHit guardado de una entrelínea conserva todos los campos del
|
||||
* documento original (`id`, `image`, `link`, `locale`, `page`, `text`)
|
||||
* porque al guardar hicimos `{ ...doc, _id, title, body }`. Lo casteamos a
|
||||
* un objeto plano para pasárselo a `EntrelineaDetail`. */
|
||||
const selectedEntrelineaDoc = computed<Record<string, unknown> | null>(() => {
|
||||
if (!isEntrelinea.value || !selectedHit.value) return null
|
||||
return selectedHit.value as unknown as Record<string, unknown>
|
||||
})
|
||||
|
||||
const isPanelOpen = computed({
|
||||
get() { return !!selected.value },
|
||||
set(v: boolean) { if (!v) selected.value = null }
|
||||
|
|
@ -440,8 +470,16 @@ const mobileActions = computed<DropdownMenuItem[][]>(() => [[
|
|||
</div>
|
||||
</UDashboardPanel>
|
||||
|
||||
<!-- Entrelíneas → componente propio (imagen ImageKit + texto). -->
|
||||
<EntrelineaDetail
|
||||
v-if="selected && !isMobile && isEntrelinea"
|
||||
:document="selectedEntrelineaDoc!"
|
||||
:collection="selectedCollection"
|
||||
@close="selected = null"
|
||||
/>
|
||||
<!-- Resto (actividades, conferencias) → InboxActivity. -->
|
||||
<InboxActivity
|
||||
v-if="selected && !isMobile"
|
||||
v-else-if="selected && !isMobile"
|
||||
:activity="selectedHit!"
|
||||
:collection="selectedCollection"
|
||||
@close="selected = null"
|
||||
|
|
@ -458,8 +496,14 @@ const mobileActions = computed<DropdownMenuItem[][]>(() => [[
|
|||
<ClientOnly>
|
||||
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
|
||||
<template #content>
|
||||
<EntrelineaDetail
|
||||
v-if="selected && isEntrelinea"
|
||||
:document="selectedEntrelineaDoc!"
|
||||
:collection="selectedCollection"
|
||||
@close="selected = null"
|
||||
/>
|
||||
<InboxActivity
|
||||
v-if="selected"
|
||||
v-else-if="selected"
|
||||
:activity="selectedHit!"
|
||||
:collection="selectedCollection"
|
||||
@close="selected = null"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,689 @@
|
|||
<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 { useHistoryStore, type HistoryItem, HISTORY_LIMIT } from '~/stores/history'
|
||||
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
||||
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
|
||||
|
||||
/**
|
||||
* Pantalla de historial — gemela de `pages/favoritos.vue`.
|
||||
*
|
||||
* Por qué prácticamente idéntica a Mi lista: el usuario espera la misma
|
||||
* ergonomía (lista a la izquierda, panel de detalle a la derecha, búsqueda
|
||||
* arriba, mismas acciones de export/import). Si las dos pantallas divergen
|
||||
* en patrones, la curva de aprendizaje se duplica sin razón.
|
||||
*
|
||||
* Diferencias respecto a Mi lista:
|
||||
* - El store es `useHistoryStore` y los items tienen `visitedAt` (no `addedAt`).
|
||||
* - Cada fila muestra "Visto: <fecha>" para que se entienda que el orden
|
||||
* de la lista es por última visita, no por fecha de creación.
|
||||
* - El registro lo hace el detalle al abrirse (ver `InboxActivity.vue` y
|
||||
* `EntrelineaDetail.vue` — ambos llaman a `history.visit(...)`).
|
||||
*/
|
||||
|
||||
const history = useHistoryStore()
|
||||
const { items: histItems, total: histTotal, collections: histCollections } = storeToRefs(history)
|
||||
const toast = useToast()
|
||||
|
||||
const ENTRELINEAS_COLLECTION = 'entrelineas'
|
||||
|
||||
const COLLECTION_LABELS: Record<string, string> = {
|
||||
activities: 'Actividades',
|
||||
conferences: 'Conferencias',
|
||||
entrelineas: 'Entre Líneas'
|
||||
}
|
||||
|
||||
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(histCollections, (cols) => {
|
||||
if (activeCollection.value !== 'all' && !cols.includes(activeCollection.value)) {
|
||||
activeCollection.value = 'all'
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
const items = [{ value: 'all', label: `Todos (${histTotal.value})`, icon: 'i-lucide-history' }]
|
||||
for (const c of histCollections.value) {
|
||||
const count = histItems.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'
|
||||
: c === 'entrelineas'
|
||||
? 'i-lucide-book-open'
|
||||
: 'i-lucide-folder'
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const localQuery = ref('')
|
||||
|
||||
const filteredItems = computed<HistoryItem[]>(() => {
|
||||
let list = histItems.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<HistoryItem | null>(null)
|
||||
|
||||
const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null)
|
||||
const selectedCollection = computed<string | undefined>(() => selected.value?.collection)
|
||||
|
||||
const isEntrelinea = computed(() => selectedCollection.value === ENTRELINEAS_COLLECTION)
|
||||
|
||||
/** El SearchHit guardado de una entrelínea conserva todos los campos del
|
||||
* documento original. Lo casteamos a un objeto plano para EntrelineaDetail. */
|
||||
const selectedEntrelineaDoc = computed<Record<string, unknown> | null>(() => {
|
||||
if (!isEntrelinea.value || !selectedHit.value) return null
|
||||
return selectedHit.value as unknown as Record<string, unknown>
|
||||
})
|
||||
|
||||
const isPanelOpen = computed({
|
||||
get() { return !!selected.value },
|
||||
set(v: boolean) { if (!v) selected.value = null }
|
||||
})
|
||||
|
||||
// Si el item seleccionado desaparece del historial (eliminado desde otra
|
||||
// pestaña o desde aquí mismo), cerramos el panel de detalle.
|
||||
watch(histItems, (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
|
||||
}
|
||||
|
||||
/** Etiqueta corta y humana para `visitedAt`. Tres tramos:
|
||||
* - menos de un minuto: "ahora"
|
||||
* - mismo día: "hoy HH:mm"
|
||||
* - cualquier otro: fecha completa
|
||||
* Mejor que "hace 3 horas" porque a) no requiere recomputar cada minuto,
|
||||
* b) deja claro de un vistazo si la entrada es del día o de antes. */
|
||||
function formatVisited(ts: number): string {
|
||||
if (!Number.isFinite(ts)) return ''
|
||||
const now = Date.now()
|
||||
const diffMs = now - ts
|
||||
if (diffMs < 60_000) return 'hace un momento'
|
||||
|
||||
const d = new Date(ts)
|
||||
const today = new Date()
|
||||
const sameDay = d.getFullYear() === today.getFullYear()
|
||||
&& d.getMonth() === today.getMonth()
|
||||
&& d.getDate() === today.getDate()
|
||||
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
if (sameDay) return `hoy ${hh}:${mm}`
|
||||
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const sameYesterday = d.getFullYear() === yesterday.getFullYear()
|
||||
&& d.getMonth() === yesterday.getMonth()
|
||||
&& d.getDate() === yesterday.getDate()
|
||||
if (sameYesterday) return `ayer ${hh}:${mm}`
|
||||
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}/${month}/${d.getFullYear()} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
function removeItem(it: HistoryItem, ev: Event) {
|
||||
ev.stopPropagation()
|
||||
history.remove(it.collection, it._id)
|
||||
toast.add({
|
||||
title: 'Eliminado del historial',
|
||||
description: it.hit?.title,
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'neutral',
|
||||
duration: 1500
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Exportar / Compartir ----------------------------------------------
|
||||
|
||||
async function copyHistory() {
|
||||
if (!histTotal.value) return
|
||||
const ok = await history.copyJsonToClipboard()
|
||||
toast.add({
|
||||
title: ok ? 'Historial copiado al portapapeles' : 'No se pudo copiar',
|
||||
description: ok ? 'Pégalo en otro lugar para guardarlo.' : 'Tu navegador bloqueó el acceso al portapapeles.',
|
||||
icon: ok ? 'i-lucide-clipboard-check' : 'i-lucide-triangle-alert',
|
||||
color: ok ? 'primary' : 'error',
|
||||
duration: 2400
|
||||
})
|
||||
}
|
||||
|
||||
function downloadHistory() {
|
||||
if (!histTotal.value) return
|
||||
history.downloadJson()
|
||||
toast.add({
|
||||
title: 'Descargando tu historial',
|
||||
icon: 'i-lucide-download',
|
||||
color: 'primary',
|
||||
duration: 1500
|
||||
})
|
||||
}
|
||||
|
||||
const showClearConfirm = ref(false)
|
||||
function confirmClear() {
|
||||
history.clear()
|
||||
showClearConfirm.value = false
|
||||
toast.add({
|
||||
title: 'Historial vaciado',
|
||||
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 history.importFromFile(file, importMode.value)
|
||||
toast.add({
|
||||
title: `Importados ${result.added}`,
|
||||
description: result.skipped
|
||||
? `${result.skipped} ya estaban en tu historial${result.invalid ? `, ${result.invalid} inválidos` : ''}.`
|
||||
: result.invalid
|
||||
? `${result.invalid} inválidos.`
|
||||
: 'Tu historial se actualizó.',
|
||||
icon: 'i-lucide-history',
|
||||
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 = history.importFromJson(importText.value, importMode.value)
|
||||
toast.add({
|
||||
title: `Importados ${result.added}`,
|
||||
description: result.skipped
|
||||
? `${result.skipped} ya estaban en tu historial${result.invalid ? `, ${result.invalid} inválidos` : ''}.`
|
||||
: result.invalid
|
||||
? `${result.invalid} inválidos.`
|
||||
: 'Tu historial se actualizó.',
|
||||
icon: 'i-lucide-history',
|
||||
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 iconos sueltos del navbar son ambiguos sin
|
||||
// tooltip (que no aparece en touch), así que en pantallas chicas los
|
||||
// agrupamos en un dropdown con etiquetas explícitas.
|
||||
const mobileActions = computed<DropdownMenuItem[][]>(() => [[
|
||||
{
|
||||
label: 'Importar historial',
|
||||
icon: 'i-lucide-upload',
|
||||
onSelect: () => openImport()
|
||||
},
|
||||
{
|
||||
label: 'Copiar al portapapeles',
|
||||
icon: 'i-lucide-clipboard-copy',
|
||||
disabled: !histTotal.value,
|
||||
onSelect: () => copyHistory()
|
||||
},
|
||||
{
|
||||
label: 'Descargar (.json)',
|
||||
icon: 'i-lucide-download',
|
||||
disabled: !histTotal.value,
|
||||
onSelect: () => downloadHistory()
|
||||
}
|
||||
], [
|
||||
{
|
||||
label: 'Vaciar historial',
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error',
|
||||
disabled: !histTotal.value,
|
||||
onSelect: () => { showClearConfirm.value = true }
|
||||
}
|
||||
]])
|
||||
|
||||
/** Aviso de cercanía al tope. El store recorta a `HISTORY_LIMIT`; mostramos
|
||||
* un hint discreto cuando estamos a menos del 10% del tope para que el
|
||||
* usuario sepa por qué las entradas antiguas pueden desaparecer. */
|
||||
const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0.9))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel
|
||||
id="history-list"
|
||||
:default-size="32"
|
||||
:min-size="24"
|
||||
:max-size="44"
|
||||
resizable
|
||||
>
|
||||
<UDashboardNavbar title="Historial">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
|
||||
<template #trailing>
|
||||
<UBadge v-if="histTotal" :label="histTotal" variant="subtle" />
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<!-- Desktop: iconos individuales con tooltip -->
|
||||
<div class="hidden sm:flex items-center gap-0.5">
|
||||
<UTooltip text="Importar historial">
|
||||
<UButton
|
||||
icon="i-lucide-upload"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Importar historial"
|
||||
@click="openImport"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UTooltip text="Copiar historial al portapapeles">
|
||||
<UButton
|
||||
icon="i-lucide-clipboard-copy"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="!histTotal"
|
||||
aria-label="Copiar historial"
|
||||
@click="copyHistory"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UTooltip text="Descargar historial (.json)">
|
||||
<UButton
|
||||
icon="i-lucide-download"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="!histTotal"
|
||||
aria-label="Descargar historial"
|
||||
@click="downloadHistory"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UTooltip text="Vaciar historial">
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="!histTotal"
|
||||
aria-label="Vaciar historial"
|
||||
@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 historial por título..."
|
||||
size="md"
|
||||
class="w-full"
|
||||
/>
|
||||
<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>
|
||||
<p
|
||||
v-if="nearLimit"
|
||||
class="flex items-center gap-1 text-[11px] text-warning"
|
||||
>
|
||||
<UIcon name="i-lucide-info" class="size-3" />
|
||||
El historial guarda hasta {{ HISTORY_LIMIT }} entradas. Al superarlo, las más antiguas se eliminan automáticamente.
|
||||
</p>
|
||||
</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-history" class="size-10" />
|
||||
<template v-if="!histTotal">
|
||||
<p class="font-medium text-toned">
|
||||
Tu historial está vacío
|
||||
</p>
|
||||
<p class="max-w-xs">
|
||||
Cada vez que abras el detalle de un resultado, se guardará aquí
|
||||
automáticamente.
|
||||
</p>
|
||||
<UButton
|
||||
icon="i-lucide-upload"
|
||||
label="Importar un historial compartido"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
class="mt-1"
|
||||
@click="openImport"
|
||||
/>
|
||||
</template>
|
||||
<p v-else>
|
||||
No hay coincidencias en tu historial 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-trash-2"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="-mt-1 -me-1 shrink-0"
|
||||
aria-label="Eliminar del historial"
|
||||
@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 class="inline-flex items-center gap-1 text-[11px] text-dimmed">
|
||||
<UIcon name="i-lucide-clock" class="size-3" />
|
||||
Visto: {{ formatVisited(it.visitedAt) }}
|
||||
</span>
|
||||
<USeparator v-if="hasDate(it.hit)" orientation="vertical" class="h-3 hidden sm:block" />
|
||||
<span v-if="hasDate(it.hit)">{{ safeDate(it.hit) }}</span>
|
||||
<USeparator v-if="formatLocation(it.hit)" orientation="vertical" class="h-3 hidden sm:block" />
|
||||
<span class="truncate">{{ formatLocation(it.hit) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UDashboardPanel>
|
||||
|
||||
<!-- Entrelíneas → componente propio (imagen ImageKit + texto). -->
|
||||
<EntrelineaDetail
|
||||
v-if="selected && !isMobile && isEntrelinea"
|
||||
:document="selectedEntrelineaDoc!"
|
||||
:collection="selectedCollection"
|
||||
@close="selected = null"
|
||||
/>
|
||||
<!-- Resto (actividades, conferencias) → InboxActivity. -->
|
||||
<InboxActivity
|
||||
v-else-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-history" 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>
|
||||
<EntrelineaDetail
|
||||
v-if="selected && isEntrelinea"
|
||||
:document="selectedEntrelineaDoc!"
|
||||
:collection="selectedCollection"
|
||||
@close="selected = null"
|
||||
/>
|
||||
<InboxActivity
|
||||
v-else-if="selected"
|
||||
:activity="selectedHit!"
|
||||
:collection="selectedCollection"
|
||||
@close="selected = null"
|
||||
/>
|
||||
</template>
|
||||
</USlideover>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Confirmación de vaciado ------------------------------------------- -->
|
||||
<UModal v-model:open="showClearConfirm" title="Vaciar historial">
|
||||
<template #body>
|
||||
<p class="text-sm text-toned">
|
||||
Esto eliminará las <strong>{{ histTotal }}</strong> entradas guardadas en este dispositivo.
|
||||
La acción no se puede deshacer (a menos que tengas un export previo).
|
||||
Tu lista de favoritos no se ve afectada.
|
||||
</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 historial"
|
||||
icon="i-lucide-trash-2"
|
||||
block
|
||||
class="sm:w-auto"
|
||||
@click="confirmClear"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<!-- Importar ---------------------------------------------------------- -->
|
||||
<UModal v-model:open="showImport" title="Importar historial" :ui="{ footer: 'justify-end' }">
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-muted">
|
||||
Carga un historial exportado desde un archivo
|
||||
<code class="px-1 rounded bg-elevated text-toned font-mono text-xs">.json</code>
|
||||
o pega su contenido.
|
||||
</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 historial?
|
||||
</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 el historial actual y añade las entradas nuevas.'
|
||||
: 'Borra el historial actual y lo sustituye por el importado.' }}
|
||||
</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-history"
|
||||
label="Importar desde texto"
|
||||
:loading="importing"
|
||||
:disabled="!importText.trim() || importing"
|
||||
block
|
||||
class="sm:w-auto"
|
||||
@click="applyTextImport"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { useHistoryStore } from '~/stores/history'
|
||||
|
||||
/**
|
||||
* Hidratación cliente del store de historial.
|
||||
*
|
||||
* Misma motivación que `favorites.client.ts`: con Nuxt + @pinia/nuxt el
|
||||
* payload SSR aplica DESPUÉS del primer `useStore()`, así que si leyéramos
|
||||
* `localStorage` en la factory del setup-store, Pinia lo pisaría con la
|
||||
* versión "vacía" del servidor. Lanzamos `hydrate()` con `enforce: 'post'`
|
||||
* para correr después de esa rehidratación y plantar la lista real.
|
||||
*/
|
||||
export default defineNuxtPlugin({
|
||||
name: 'history-hydration',
|
||||
enforce: 'post',
|
||||
setup() {
|
||||
const history = useHistoryStore()
|
||||
history.hydrate()
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { SearchHit } from '~/types'
|
||||
|
||||
/**
|
||||
* Historial de "documentos vistos". Cada vez que el usuario abre el panel de
|
||||
* detalle de un resultado, registramos el documento aquí.
|
||||
*
|
||||
* Diseño:
|
||||
* - Misma arquitectura que `~/stores/favorites.ts` (persistencia en
|
||||
* localStorage, hidratación post-SSR vía plugin, sync entre pestañas).
|
||||
* - Una entrada por documento: si vuelve a abrirse, se mueve al inicio y se
|
||||
* actualiza `visitedAt` (comportamiento "como el historial del navegador").
|
||||
* - Límite duro de `HISTORY_LIMIT` entradas: cuando se supera, descartamos las
|
||||
* más antiguas (FIFO al fondo de la lista).
|
||||
* - El identificador de colección no es un set cerrado — funciona con
|
||||
* cualquier colección que se sume en el futuro.
|
||||
*/
|
||||
export type Collection = string
|
||||
|
||||
export interface HistoryItem {
|
||||
collection: Collection
|
||||
_id: string | number
|
||||
/** Snapshot del documento para poder mostrarlo en el historial sin volver a
|
||||
* consultar al backend (igual que hacemos con favoritos). */
|
||||
hit: SearchHit
|
||||
/** Última vez que se abrió el detalle (epoch ms). Si el documento se reabre,
|
||||
* este valor se actualiza y el item salta al inicio. */
|
||||
visitedAt: number
|
||||
}
|
||||
|
||||
export interface HistoryFile {
|
||||
version: 1
|
||||
exportedAt: string
|
||||
items: HistoryItem[]
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
added: number
|
||||
skipped: number
|
||||
invalid: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'lgcc:history:v1'
|
||||
|
||||
/** Tope práctico de entradas. 200 cubre de sobra una sesión de uso intensivo
|
||||
* sin inflar el JSON ni convertir la lista en algo inmanejable. Si llegamos
|
||||
* a este número, las entradas más antiguas se van descartando. */
|
||||
export const HISTORY_LIMIT = 200
|
||||
|
||||
function isValidHistoryItem(x: unknown): x is HistoryItem {
|
||||
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(): HistoryItem[] {
|
||||
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(isValidHistoryItem)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(items: HistoryItem[]) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
|
||||
} catch (e) {
|
||||
console.warn('No se pudo guardar el historial', 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 el historial de detalles abiertos.
|
||||
*
|
||||
* - Persiste en `localStorage` con la clave `lgcc:history:v1`.
|
||||
* - Hidrata en cliente vía el plugin `~/plugins/history.client.ts` para que
|
||||
* el payload SSR vacío no pise lo que ya hay en el navegador.
|
||||
* - Sincroniza entre pestañas escuchando el evento `storage`.
|
||||
*/
|
||||
export const useHistoryStore = defineStore('history', () => {
|
||||
const items = ref<HistoryItem[]>([])
|
||||
const ready = ref(false)
|
||||
let listenerAttached = false
|
||||
let hydrated = false
|
||||
|
||||
function hydrate() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (hydrated) return
|
||||
items.value = readStorage()
|
||||
ready.value = true
|
||||
hydrated = true
|
||||
if (!listenerAttached) {
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key !== STORAGE_KEY) return
|
||||
items.value = readStorage()
|
||||
})
|
||||
listenerAttached = true
|
||||
}
|
||||
}
|
||||
|
||||
function commit(next: HistoryItem[]) {
|
||||
// Aplicamos el límite al persistir: nunca dejamos más de HISTORY_LIMIT
|
||||
// entradas. Como `visit()` siempre añade al inicio, recortamos por el final.
|
||||
const trimmed = next.length > HISTORY_LIMIT ? next.slice(0, HISTORY_LIMIT) : next
|
||||
items.value = trimmed
|
||||
writeStorage(trimmed)
|
||||
}
|
||||
|
||||
// Red de seguridad: cualquier mutación directa de `items.value` se persiste.
|
||||
if (typeof window !== 'undefined') {
|
||||
watch(items, (next) => {
|
||||
if (!hydrated) return
|
||||
writeStorage(next)
|
||||
}, { deep: true })
|
||||
}
|
||||
|
||||
// ---- Getters ----------------------------------------------------------
|
||||
|
||||
const total = computed(() => items.value.length)
|
||||
|
||||
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 inHistory(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 ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Registra (o re-registra) una visita a un documento.
|
||||
*
|
||||
* Comportamiento "tipo historial de navegador": si el documento ya estaba,
|
||||
* se elimina de su posición previa y se reinserta al principio con el
|
||||
* timestamp actualizado, así el orden de la lista refleja la última vez
|
||||
* que se vio cada documento.
|
||||
*/
|
||||
function visit(collection: Collection, hit: SearchHit) {
|
||||
if (!hit?._id) return
|
||||
|
||||
// Limpiamos campos volátiles de Meilisearch/Typesense que cambian entre
|
||||
// búsquedas — sin esto el JSON exportable se llenaría de ruido y, peor,
|
||||
// dos visitas idénticas se considerarían "diferentes" para el watcher
|
||||
// de profundidad. Es la misma higiene que hace el store de favoritos.
|
||||
const cleanHit = { ...hit } as SearchHit
|
||||
delete (cleanHit as Record<string, unknown>)._matchesPosition
|
||||
delete (cleanHit as Record<string, unknown>)._formatted
|
||||
|
||||
const key = { collection, _id: hit._id }
|
||||
const rest = items.value.filter(it => !sameKey(it, key))
|
||||
commit([
|
||||
{ collection, _id: hit._id, hit: cleanHit, visitedAt: Date.now() },
|
||||
...rest
|
||||
])
|
||||
}
|
||||
|
||||
function remove(collection: Collection, id: string | number) {
|
||||
commit(items.value.filter(it => !sameKey(it, { collection, _id: id })))
|
||||
}
|
||||
|
||||
function clear() {
|
||||
commit([])
|
||||
}
|
||||
|
||||
function clearCollection(collection: Collection) {
|
||||
commit(items.value.filter(it => it.collection !== collection))
|
||||
}
|
||||
|
||||
// ---- Export / Import --------------------------------------------------
|
||||
|
||||
function exportToJson(): string {
|
||||
const file: HistoryFile = {
|
||||
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 || `historial-${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 items de historial).')
|
||||
}
|
||||
|
||||
const valid = incoming.filter(isValidHistoryItem)
|
||||
const invalid = incoming.length - valid.length
|
||||
|
||||
if (mode === 'replace') {
|
||||
const next = valid
|
||||
.map(v => ({ ...v, visitedAt: (v as HistoryItem).visitedAt || Date.now() }))
|
||||
// Importamos respetando el orden temporal (más reciente primero).
|
||||
.sort((a, b) => b.visitedAt - a.visitedAt)
|
||||
commit(next)
|
||||
return { added: next.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.push({ ...inc, visitedAt: (inc as HistoryItem).visitedAt || Date.now() })
|
||||
added++
|
||||
}
|
||||
// Reordenamos por visitedAt para mantener invariante "más reciente arriba".
|
||||
next.sort((a, b) => b.visitedAt - a.visitedAt)
|
||||
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
|
||||
inHistory,
|
||||
byCollection,
|
||||
countByCollection,
|
||||
// mutations
|
||||
hydrate,
|
||||
visit,
|
||||
remove,
|
||||
clear,
|
||||
clearCollection,
|
||||
// export/import
|
||||
exportToJson,
|
||||
downloadJson,
|
||||
copyJsonToClipboard,
|
||||
importFromJson,
|
||||
importFromFile
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// Construcción de URLs de ImageKit para los assets de "Entre Líneas".
|
||||
//
|
||||
// El campo `image` que devuelve Typesense ya viene con la subruta completa
|
||||
// dentro del bucket, incluyendo extensión (ej.
|
||||
// "5171cd64-4b42-4e6d-a081-3703efbe7d43.jpeg"). Aquí sólo lo combinamos con
|
||||
// la base pública de ImageKit y le añadimos el preset de transformación
|
||||
// según el contexto (lista, detalle, fullscreen).
|
||||
//
|
||||
// Mismo patrón que `getAssetUrl` del cliente Directus: una sola fuente de
|
||||
// verdad para la URL, todos los componentes leen de aquí.
|
||||
|
||||
/** Base pública de ImageKit donde están alojadas las imágenes de Entre Líneas.
|
||||
* Si cambia la cuenta o el folder, sólo hay que tocar esta constante. */
|
||||
const IMAGEKIT_BASE = 'https://images.carpa.com/entrelineas'
|
||||
|
||||
/**
|
||||
* Transforms de ImageKit por contexto. Los anchos cuentan con high-DPI:
|
||||
* un container de 320 px CSS necesita ~640 px reales en pantallas 2x,
|
||||
* por eso `detail` pide 1800 px. Quality 80–90 es el sweet spot:
|
||||
* notablemente más nítido que 60 con poco aumento de peso.
|
||||
*/
|
||||
const IMAGE_PRESETS = {
|
||||
thumb: 'tr=w-600,q-80', // miniaturas en lista de resultados
|
||||
detail: 'tr=w-1800,q-90', // imagen principal del panel de detalle
|
||||
full: 'tr=q-95' // sin resize, máxima calidad (zoom / lightbox)
|
||||
} as const
|
||||
|
||||
export type EntrelineaImagePreset = keyof typeof IMAGE_PRESETS
|
||||
|
||||
/**
|
||||
* Construye la URL de ImageKit para una imagen de entrelínea.
|
||||
*
|
||||
* - `image` es el valor del campo `image` de Typesense, ya con extensión
|
||||
* incluida (ej. "5171cd64-4b42-4e6d-a081-3703efbe7d43.jpeg"). Se concatena
|
||||
* tal cual a la base.
|
||||
* - `preset` es el preset de transformación a aplicar (default: `detail`).
|
||||
*
|
||||
* Devuelve `null` si no hay imagen para no romper la UI; los componentes
|
||||
* deben mostrar placeholder en ese caso.
|
||||
*
|
||||
* Ejemplo:
|
||||
* getEntrelineaImageUrl('5171cd64-...jpeg')
|
||||
* → 'https://images.carpa.com/entrelineas/5171cd64-...jpeg?tr=w-1800,q-90'
|
||||
*/
|
||||
export function getEntrelineaImageUrl(
|
||||
image: string | null | undefined,
|
||||
preset: EntrelineaImagePreset = 'detail'
|
||||
): string | null {
|
||||
if (!image) return null
|
||||
// Defensivo: por si algún día llega con barra al inicio o el base con
|
||||
// barra al final, evitamos doble slash en la URL final.
|
||||
const base = IMAGEKIT_BASE.replace(/\/+$/, '')
|
||||
const path = String(image).replace(/^\/+/, '')
|
||||
return `${base}/${path}?${IMAGE_PRESETS[preset]}`
|
||||
}
|
||||
|
|
@ -76,6 +76,10 @@ export default defineNuxtConfig({
|
|||
|
||||
typesense: {
|
||||
url: 'https://searchts.carpa.com', // Your Typesense server URL
|
||||
apiKey: 'wXyOg0Vrhm8LXpaXbMaF7k24Bnf2rDAv' // Your Typesense API key
|
||||
apiKey: 'wXyOg0Vrhm8LXpaXbMaF7k24Bnf2rDAv', // Your Typesense API key
|
||||
// Habilita los composables auto-importados en cliente
|
||||
// (useTypesenseDocuments, useTypesenseApi, etc.).
|
||||
// ⚠️ Solo usa una clave de búsqueda (search-only) aquí: queda expuesta al navegador.
|
||||
clientMode: true
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue