523 lines
16 KiB
Vue
523 lines
16 KiB
Vue
<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 { useSettingsStore } from '~/stores/settings'
|
|
import type { SearchHit } from '~/types'
|
|
|
|
const COLLECTION = 'entrelineas'
|
|
const QUERY_BY = 'text'
|
|
const INCLUDE_FIELDS = '*'
|
|
const EXTRA_FILTER_BY = ''
|
|
const FAVORITES_COLLECTION = 'entrelineas'
|
|
|
|
const { $i18n } = useNuxtApp()
|
|
const t = $i18n.t
|
|
const { locale } = useI18n()
|
|
|
|
const { unlocked } = useDevMode()
|
|
|
|
const filterBy = computed(() => {
|
|
const localeFilter = `locale:=${locale.value}`
|
|
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
|
|
})
|
|
|
|
const REQUEST_TIMEOUT_MS = 15000
|
|
|
|
const settings = useSettingsStore()
|
|
|
|
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
|
|
|
|
const query = ref(q0)
|
|
const debouncedQuery = useDebounce(query, 150)
|
|
|
|
const loading = ref(false)
|
|
const loadingMore = ref(false)
|
|
const errorMsg = ref<string | null>(null)
|
|
const exactSearch = ref(false)
|
|
|
|
interface Study {
|
|
id?: number
|
|
title?: string
|
|
date?: string
|
|
place?: string
|
|
link?: string
|
|
}
|
|
|
|
interface EntrelineaDoc {
|
|
id?: string
|
|
image?: string
|
|
link?: string
|
|
locale?: string
|
|
description?: string
|
|
page?: number | string
|
|
text?: string
|
|
html?: string
|
|
draft?: boolean
|
|
studies?: Study[]
|
|
[key: string]: unknown
|
|
}
|
|
|
|
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 activePage = ref(p0)
|
|
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
|
|
|
|
const hasMore = computed(() =>
|
|
settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false
|
|
)
|
|
|
|
const { documentsApi } = useTypesenseApi()
|
|
|
|
let searchSeq = 0
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
|
|
async function runSearch(q: string, page = 1, append = false) {
|
|
const seq = ++searchSeq
|
|
if (append) loadingMore.value = true
|
|
else loading.value = true
|
|
errorMsg.value = null
|
|
|
|
if (timeoutId) clearTimeout(timeoutId)
|
|
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 isInfinite = settings.paginationType === 'infinite_scroll'
|
|
const typePage = isInfinite
|
|
? (append ? currentPage.value + 1 : 1)
|
|
: page
|
|
|
|
try {
|
|
const multi = await documentsApi.multiSearch({
|
|
multiSearchParameters: {},
|
|
multiSearchSearchesParameter: {
|
|
searches: [{
|
|
collection: COLLECTION,
|
|
q: exactSearch.value && q ? `"${q}"` : q || '*',
|
|
queryBy: QUERY_BY,
|
|
includeFields: INCLUDE_FIELDS,
|
|
filterBy: filterBy.value,
|
|
perPage: settings.pageSize,
|
|
page: typePage,
|
|
highlightFullFields: QUERY_BY,
|
|
highlightFields: QUERY_BY,
|
|
highlightStartTag: '<mark class="search-match">',
|
|
highlightEndTag: '</mark>'
|
|
}]
|
|
}
|
|
})
|
|
|
|
if (seq !== searchSeq) return
|
|
|
|
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
|
const newHits = res?.hits ?? []
|
|
|
|
hits.value = append ? hits.value.concat(newHits) : newHits
|
|
total.value = res?.found ?? hits.value.length
|
|
currentPage.value = typePage
|
|
if (!append) activePage.value = page
|
|
} catch (err: unknown) {
|
|
if (seq !== searchSeq) return
|
|
console.error('Typesense error', err)
|
|
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
|
|
if (!append) {
|
|
hits.value = []
|
|
total.value = 0
|
|
}
|
|
} finally {
|
|
if (seq === searchSeq) {
|
|
if (timeoutId) clearTimeout(timeoutId)
|
|
loading.value = false
|
|
loadingMore.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
function loadMore() {
|
|
if (settings.paginationType !== 'infinite_scroll') return
|
|
if (loadingMore.value || loading.value || !hasMore.value) return
|
|
runSearch(query.value, currentPage.value, true)
|
|
}
|
|
|
|
function goToPage(p: number) {
|
|
activePage.value = p
|
|
hits.value = []
|
|
runSearch(query.value, p, false)
|
|
}
|
|
|
|
function retry() {
|
|
runSearch(query.value, activePage.value, false)
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
if (timeoutId) clearTimeout(timeoutId)
|
|
})
|
|
|
|
watch(debouncedQuery, (q) => {
|
|
hits.value = []
|
|
total.value = 0
|
|
currentPage.value = 1
|
|
activePage.value = 1
|
|
runSearch(q, 1, false)
|
|
})
|
|
|
|
watch(exactSearch, () => {
|
|
if (query.value.trim()) runSearch(query.value, 1, false)
|
|
})
|
|
|
|
const selected = ref<EntrelineaDoc | null>(null)
|
|
|
|
const isPanelOpen = computed({
|
|
get() { return !!selected.value },
|
|
set(v: boolean) { if (!v) selected.value = null }
|
|
})
|
|
|
|
watch(hits, () => {
|
|
if (!selected.value) return
|
|
const stillThere = hits.value.find(h => h.document.id === selected.value?.id)
|
|
if (!stillThere) selected.value = null
|
|
})
|
|
|
|
const selectedId = computed(() => selected.value?.id?.toString() ?? null)
|
|
|
|
const listEl = ref<HTMLElement | null>(null)
|
|
|
|
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listEl })
|
|
|
|
onMounted(async () => {
|
|
await runSearch(q0, p0, false)
|
|
|
|
restoreScrollPosition(listEl.value, s0)
|
|
|
|
if (sid0) {
|
|
const found = hits.value.find(h => h.document.id === sid0)
|
|
if (found) selected.value = found.document
|
|
}
|
|
})
|
|
|
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
|
const isMobile = breakpoints.smaller('lg')
|
|
|
|
useDetailHistory(isPanelOpen, isMobile)
|
|
|
|
const favorites = useFavoritesStore()
|
|
const toast = useToast()
|
|
|
|
function toSearchHit(doc: EntrelineaDoc): SearchHit {
|
|
const id = doc.id || ''
|
|
return {
|
|
_id: id,
|
|
id,
|
|
title: doc.text?.slice(0, 100) || '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) {
|
|
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.text?.slice(0, 100),
|
|
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
|
|
color: wasFav ? 'neutral' : 'primary',
|
|
duration: 1800
|
|
})
|
|
}
|
|
|
|
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
|
|
const fromObj = hit.highlight?.[field]
|
|
if (fromObj?.snippet) return fromObj.snippet
|
|
if (fromObj?.value) return fromObj.value
|
|
return null
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel
|
|
id="entrelineas-list"
|
|
:default-size="32"
|
|
:min-size="24"
|
|
:max-size="45"
|
|
resizable
|
|
>
|
|
<UDashboardNavbar :title="t('nav.between_the_lines')">
|
|
<template #leading>
|
|
<UDashboardSidebarCollapse />
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<!-- Banner: se muestra cuando NO hay clave de desarrollador -->
|
|
<template v-if="!unlocked">
|
|
<div class="flex-1 flex items-center justify-center p-8 bg-gradient-to-br from-amber-50 via-white to-amber-100/60">
|
|
<div class="flex flex-col items-center gap-6 text-center max-w-sm">
|
|
<div class="size-28 rounded-full bg-gradient-to-br from-amber-300 to-amber-500 flex items-center justify-center shadow-lg shadow-amber-200/50 ring-4 ring-white">
|
|
<UIcon name="i-lucide-flask-conical" class="size-14 text-white" />
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<h2 class="text-2xl font-bold text-highlighted">
|
|
{{ t('entrelineas.development_banner') }}
|
|
</h2>
|
|
<p class="text-sm text-toned leading-relaxed">
|
|
{{ t('entrelineas.development_subtitle_line1') }}<br>
|
|
{{ t('entrelineas.development_subtitle_line2') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 pt-2">
|
|
<span class="size-2 rounded-full bg-amber-300 animate-pulse" />
|
|
<span class="size-2 rounded-full bg-amber-400 animate-pulse" style="animation-delay: 0.2s" />
|
|
<span class="size-2 rounded-full bg-amber-500 animate-pulse" style="animation-delay: 0.4s" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Funcional: se muestra cuando la clave de desarrollador es correcta -->
|
|
<template v-else>
|
|
<div class="px-4 sm:px-6 py-3 border-b border-default">
|
|
<div class="flex items-center gap-2">
|
|
<UInput
|
|
v-model="query"
|
|
icon="i-lucide-search"
|
|
:placeholder="t('Search.placeholder')"
|
|
:loading="loading"
|
|
size="md"
|
|
class="flex-1 min-w-0"
|
|
/>
|
|
<div
|
|
class="flex rounded-full p-0.5 shrink-0 transition-colors duration-200"
|
|
:class="exactSearch ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'"
|
|
>
|
|
<button
|
|
class="px-2.5 py-0.5 rounded-full text-xs transition-all duration-200 whitespace-nowrap"
|
|
:class="!exactSearch ? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white font-semibold shadow-sm' : 'text-white/40 font-normal'"
|
|
@click.stop="exactSearch = false"
|
|
>Palabra</button>
|
|
<button
|
|
class="px-2.5 py-0.5 rounded-full text-xs transition-all duration-200 whitespace-nowrap"
|
|
:class="exactSearch ? 'bg-white text-primary font-semibold shadow-sm' : 'text-gray-400 dark:text-gray-400 font-normal'"
|
|
@click.stop="exactSearch = true"
|
|
>Frase</button>
|
|
</div>
|
|
</div>
|
|
<p class="mt-1.5 flex items-center gap-1 text-[11px] text-dimmed">
|
|
<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">{{ filterBy }}</code>
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="errorMsg"
|
|
:title="errorMsg"
|
|
color="error"
|
|
variant="subtle"
|
|
icon="i-lucide-triangle-alert"
|
|
class="mx-4 my-2"
|
|
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
|
|
/>
|
|
|
|
<div ref="listEl" 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 transition-colors border-b-2"
|
|
:class="[
|
|
selected && selected.id === hit.document.id
|
|
? 'bg-primary/10 text-highlighted'
|
|
: 'border-gray-200 text-toned hover:bg-primary/5'
|
|
]"
|
|
@click="selected = hit.document"
|
|
>
|
|
<div class="flex items-start justify-between gap-2 mb-1">
|
|
<div class="min-w-0 flex-1 flex gap-2">
|
|
<UBadge
|
|
v-if="hit.document?.filter"
|
|
:label="hit.document?.filter"
|
|
size="sm"
|
|
variant="subtle"
|
|
color="neutral"
|
|
class="mb-1 uppercase"
|
|
/>
|
|
<UBadge
|
|
v-if="hit.document?.type"
|
|
:label="hit.document?.type"
|
|
size="sm"
|
|
variant="subtle"
|
|
color="neutral"
|
|
class="mb-1 uppercase"
|
|
/>
|
|
<UBadge
|
|
v-if="hit.document?.draft"
|
|
label="Borrador"
|
|
size="sm"
|
|
variant="subtle"
|
|
color="warning"
|
|
class="mb-1"
|
|
/>
|
|
</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>
|
|
|
|
<div
|
|
v-if="highlightedFor(hit, 'text') || hit.document.text"
|
|
class="snippet-html text-sm text-toned"
|
|
v-html="highlightedFor(hit, 'text') || hit.document.text"
|
|
/>
|
|
|
|
<USeparator class="my-2"/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="settings.paginationType === 'infinite_scroll' && 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="settings.paginationType === 'infinite_scroll' && hits.length && !hasMore && !loading"
|
|
class="py-3 text-center text-xs text-dimmed"
|
|
>
|
|
No hay más resultados
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading"
|
|
class="px-4 py-3 border-t border-default flex justify-center shrink-0"
|
|
>
|
|
<UPagination
|
|
:page="activePage"
|
|
:total="total"
|
|
:items-per-page="settings.pageSize"
|
|
size="sm"
|
|
@update:page="goToPage"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</UDashboardPanel>
|
|
|
|
<template v-if="unlocked">
|
|
<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-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-mouse-pointer-click" class="size-16" />
|
|
<p class="text-sm">
|
|
Selecciona una entrelínea para ver el detalle
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<ClientOnly>
|
|
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
|
|
<template #content>
|
|
<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>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.snippet-html :deep(p) {
|
|
display: inline;
|
|
margin: 0;
|
|
}
|
|
.snippet-html :deep(br) {
|
|
display: none;
|
|
}
|
|
</style>
|