Compare commits

..

2 Commits

Author SHA1 Message Date
David Ascanio b22c58cc5a Merge branch 'main' of https://gitea.carpa.com/LGCC/search 2026-05-12 12:29:31 -03:00
David Ascanio fb2fa8bb0b feat historial 2026-05-12 12:28:42 -03:00
12 changed files with 1694 additions and 77 deletions

View File

@ -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>

View File

@ -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)

View File

@ -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 {}

View File

@ -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'

View File

@ -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>

View File

@ -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>

View File

@ -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"

689
app/pages/historial.vue Normal file
View File

@ -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>

View File

@ -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()
}
})

303
app/stores/history.ts Normal file
View File

@ -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
}
})

View File

@ -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 8090 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]}`
}

View File

@ -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
}
})