Compare commits

..

No commits in common. "main" and "jr-branch-1" have entirely different histories.

59 changed files with 4133 additions and 4534 deletions

View File

@ -1,86 +0,0 @@
name: Deploy Search Typesense
on:
push:
branches:
- staging
- production
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
# Route each branch to a specific directory and PM2 environment
- name: Setup Environment Variables
run: |
if [[ "${{ github.ref_name }}" == "production" ]]; then
TARGET="/var/www/search.carpa.com"
echo "PM2_ENV=production" >> $GITHUB_ENV
elif [[ "${{ github.ref_name }}" == "staging" ]]; then
TARGET="/var/www/dev.search.carpa.com"
echo "PM2_ENV=staging" >> $GITHUB_ENV
fi
PM2_NAME="search-${{ github.ref_name }}"
echo "TARGET_DIR=$TARGET" >> $GITHUB_ENV
echo "PM2_NAME=$PM2_NAME" >> $GITHUB_ENV
echo 'NUXT_PUBLIC_FEEDBACK_WEBHOOK=${{ secrets.NUXT_PUBLIC_FEEDBACK_WEBHOOK }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_RECAPTCHA_SITE_KEY=${{ secrets.NUXT_PUBLIC_RECAPTCHA_SITE_KEY }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_FEEDBACK_MAX_PER_HOUR=${{ secrets.NUXT_PUBLIC_FEEDBACK_MAX_PER_HOUR }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION=${{ secrets.NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC=${{ secrets.NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_FEEDBACK_MIN_SECONDS=${{ secrets.NUXT_PUBLIC_FEEDBACK_MIN_SECONDS }}' >> $GITHUB_ENV
echo 'NUXT_FEEDBACK_TOKEN=${{ secrets.NUXT_FEEDBACK_TOKEN }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_TYPESENSE_API_KEY=${{ secrets.NUXT_PUBLIC_TYPESENSE_API_KEY }}' >> $GITHUB_ENV
echo 'TYPESENSE_NODES=${{ secrets.TYPESENSE_NODES }}' >> $GITHUB_ENV
echo 'NUXT_PUBLIC_ENTRELINEAS_DEV_KEY=${{ secrets.NUXT_PUBLIC_ENTRELINEAS_DEV_KEY }}' >> $GITHUB_ENV
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Build Nuxt Application
run: npm run build
env:
NITRO_PRESET: node-server
TYPESENSE_NODES: ${{ secrets.TYPESENSE_NODES }}
- name: Copy Build Files to Vultr
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: 22
source: ".output/*,package.json,ecosystem.config.cjs"
target: "${{ env.TARGET_DIR }}"
- name: Restart PM2 on Vultr
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: 22
script: |
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd ${{ env.TARGET_DIR }}
PM2_NAME=${{ env.PM2_NAME }} \
NUXT_FEEDBACK_TOKEN=${{ secrets.NUXT_FEEDBACK_TOKEN }} \
TYPESENSE_NODES=${{ secrets.TYPESENSE_NODES }} \
pm2 reload ecosystem.config.cjs --update-env --env ${{ env.PM2_ENV }} || \
PM2_NAME=${{ env.PM2_NAME }} \
NUXT_FEEDBACK_TOKEN=${{ secrets.NUXT_FEEDBACK_TOKEN }} \
TYPESENSE_NODES=${{ secrets.TYPESENSE_NODES }} \
pm2 start ecosystem.config.cjs --update-env --env ${{ env.PM2_ENV }}

13
.gitignore vendored
View File

@ -17,21 +17,8 @@ logs
.DS_Store
.fleet
.idea
--port
# Local env files
.env
.env.*
!.env.example
.bruno
# Claude local settings
.claude/settings.local.json
# Bruno sensitive environments and collections
bruno/environments/Produccion.bru
bruno/Collections/entrelineas.bru
bruno/Collections/entrelineas_add_draft_field.bru
bruno/Collections/entrelineas_create_collection.bru
bruno/Collections/entrelineas_delete_collection.bru
bruno/Collections/entrelineas_documents.bru

View File

@ -61,4 +61,4 @@ Check out the [deployment documentation](https://nuxt.com/docs/getting-started/d
## Renovate integration
Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go. chage v4
Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go.

View File

@ -1,11 +1,4 @@
<script setup lang="ts">
const nuxtApp = useNuxtApp();
const { $i18n } = useNuxtApp();
const t = $i18n.t;
const title = t('seo.title')
const description = t('seo.description')
useHead({
meta: [
{ charset: 'utf-8' },
@ -15,143 +8,19 @@ useHead({
{ rel: 'icon', href: '/favicon.ico' }
],
htmlAttrs: {
lang: $i18n.locale
lang: 'es'
}
})
const title = 'La Gran Carpa Catedral - Buscador'
const description = 'Buscador de actividades y conferencias.'
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description
})
const steps = [
{
element: "#bible-studies",
popover: {
title: t('nav.bible_studies'),
description: t('tour.bible_studies_description'),
side: "right",
},
},
{
element: "#conferences",
popover: {
title: t('nav.conferences'),
description: t('tour.conferences_description'),
side: "right",
},
},
{
element: "#betweenthelines",
popover: {
title: t('nav.between_the_lines'),
description: t('tour.betweenthelines_description'),
side: "right",
},
},
{
element: "#favorites",
popover: {
title: t('nav.my_list'),
description: t('tour.favorites_description'),
side: "right",
},
},
{
element: "#history",
popover: {
title: t('nav.history'),
description: t('tour.history_description'),
side: "right",
},
},
{
element: "#changelog",
popover: {
title: t('nav.changelog'),
description: t('tour.changelog_description'),
side: "right",
},
},
{
element: "#settings",
popover: {
title: t('nav.settings'),
description: t('tour.settings_description'),
side: "right",
},
},
{
element: "#feedback",
popover: {
title: t('feedback.title'),
description: t('tour.feedback_description'),
side: "right",
},
},
{
element: "#localeSelector",
popover: {
title: t('nav.localeselector'),
description: t('tour.localeselector_description'),
side: "right",
},
},
{
element: ".collapse-sidebar-icon",
popover: {
title: t('search.collapse'),
description: t('tour.collapse_sidebar_description'),
side: "bottom"
}
},
{
element: ".total-results",
popover: {
title: t('search.total_results'),
description: t('tour.total_results_description'),
side: "bottom"
}
},
{
element: "#index_changelog",
popover: {
title: t('nav.changelog'),
description: t('tour.changelog_description'),
side: "right",
},
},
{
element: ".favorites_toggle",
popover: {
title: t('tour.favorites_button'),
description: t('tour.favorites_toggle'),
side: "bottom"
}
}
]
const tourConfig = {
nextBtnText: t('tour.next'),
prevBtnText: t('tour.prev'),
doneBtnText: t('tour.done'),
progressText: t('tour.progress', { current: '{{current}}', total: '{{total}}' }),
showProgress: true,
animate: true,
smoothScroll: true,
steps: steps,
}
nuxtApp.hook('tour', (startIndex = 0) => {
const isMobile = window.innerWidth < 768;
if (!isMobile) {
const { driver } = useDriver("onboarding");
const instance = driver(tourConfig)
instance.drive(startIndex)
}
})
</script>
<template>

View File

@ -15,24 +15,6 @@
--color-green-800: #016538;
--color-green-900: #0A5331;
--color-green-950: #052E16;
--color-carpablue: #2C4EA2;
--color-carpagreen: #6B8E23;
--color-carpared: #ff0000;
--color-primary: #6B8E23;
--color-secondary: #2C4EA2;
}
/* Colors for Typesense rich text */
.red {
color: #C00000 !important;
}
.purple {
color: #7030A0 !important;
}
.blue {
color: #0070C0 !important;
}
/* Search match highlighting --------------------------------------------- */

View File

@ -1,71 +0,0 @@
<script setup lang="ts">
const { $i18n } = useNuxtApp()
const t = $i18n.t
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function sanitize(text: string): string {
let clean = text
clean = clean.replace(/<[^>]*>/g, '')
clean = clean.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
clean = clean.replace(/https?:\/\/[^\s]+/gi, '')
clean = clean.replace(/www\.[^\s]+/gi, '')
clean = clean.replace(/javascript\s*:/gi, '')
clean = clean.replace(/\beval\s*\(/gi, '')
clean = clean.replace(/\bon\w+\s*=/gi, '')
clean = clean.replace(/[A-Za-z0-9+/]{50,}={0,2}/g, '')
clean = clean.replace(/'\s*OR\s+\d+\s*=\s*\d+/gi, '')
clean = clean.replace(/'\s*--/g, "'")
clean = clean.replace(/'\s*;\s*DROP\s+TABLE/gi, "'")
clean = clean.replace(/UNION\s+SELECT/gi, '')
clean = clean.replace(/xp_cmdshell/gi, '')
clean = clean.replace(/EXEC\s*\(/gi, '')
clean = clean.replace(/`[^`]*`/g, '')
clean = clean.replace(/\$\(.*?\)/g, '')
clean = clean.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
return clean.slice(0, 500)
}
function onInput(value: string) {
emit('update:modelValue', sanitize(value))
}
</script>
<template>
<UCard class="w-full max-w-xl shadow-sm">
<div class="flex flex-col items-center gap-4 py-4">
<UIcon name="i-lucide-bug" class="size-10 text-carpablue" />
<div class="text-center space-y-1">
<h2 class="text-lg font-semibold text-highlighted">
{{ t('feedback.title') }}
</h2>
<p class="text-sm text-muted leading-relaxed max-w-sm">
{{ t('feedback.description') }}
</p>
</div>
<UTextarea
:model-value="modelValue"
:placeholder="t('feedback.placeholder')"
:rows="6"
:maxlength="500"
autoresize
size="lg"
class="w-full"
@update:model-value="onInput($event)"
/>
<div class="flex items-center justify-between w-full text-xs text-muted">
<span>{{ t('feedback.anonymous_notice') }}</span>
<span>{{ modelValue.length }}/500</span>
</div>
</div>
</UCard>
</template>

View File

@ -18,11 +18,8 @@ interface EntrelineaDoc {
link?: string
locale?: string
origin?: string
filter?: string
page?: number | string
text?: string
html?: string
draft?: boolean
studies?: Study[]
[key: string]: unknown
}
@ -31,7 +28,6 @@ const props = defineProps<{
document: EntrelineaDoc
collection?: string
highlightedText?: string | null
noTrackVisit?: boolean
}>()
const emits = defineEmits<{ close: [] }>()
@ -41,8 +37,6 @@ const showStudies = ref(false)
// Volver al detalle cuando cambia el documento
watch(() => props.document?.id, () => { showStudies.value = false })
const { unlocked } = useDevMode()
const imageUrl = computed<string | null>(() =>
getEntrelineaImageUrl(props.document?.image, 'detail')
)
@ -53,39 +47,8 @@ const title = computed(() =>
origin.value || (props.document?.id as string) || 'Entrelínea'
)
function formatEntrelineaText(html: string): string {
return html;
if (!html) return ''
const sPrefix = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000"
const sBracket = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000"
const sInner = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000;text-decoration:underline"
const wrapLine = (content: string, isFirst: boolean): string =>
(isFirst ? `<span style="${sPrefix}">[WSS] </span>` : '') +
`<span style="${sBracket}">«</span>` +
`<span style="${sInner}">${content}</span>` +
`<span style="${sBracket}">»</span>`
if (/<p[\s>]/i.test(html)) {
const lines = html
.split(/<p(?:[^>]*)>/i)
.map(part => part.replace(/<\/p>/gi, '').trim())
.filter(Boolean)
if (lines.length) return lines.map((l, i) => wrapLine(l, i === 0)).join('<br>')
return html
}
if (/<br/i.test(html)) {
const parts = html.split(/<br\s*\/?>/i).map(p => p.trim()).filter(Boolean)
if (parts.length) return parts.map((l, i) => wrapLine(l, i === 0)).join('<br>')
}
return wrapLine(html.trim(), true)
}
const bodyHtml = computed<string>(() =>
formatEntrelineaText(props.document?.html || props.document?.text || '')
props.highlightedText || props.document?.text || ''
)
/* -------------------------------------------------------------------------- */
@ -98,14 +61,14 @@ 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 }
return { _id: id, id, title: id || 'Entrelínea', body: doc.text, ...doc }
}
watch(
() => [props.collection, props.document?.id] as const,
([collection, id]) => {
if (!collection || !id) return
if (!props.noTrackVisit) history.visit(collection, toSearchHit(props.document))
history.visit(collection, toSearchHit(props.document))
},
{ immediate: true }
)
@ -119,10 +82,9 @@ function onToggleFavorite() {
if (!props.collection || !props.document?.id) return
const wasFav = isFav.value
favorites.toggle(props.collection, toSearchHit(props.document))
console.log("text slice", props.document.text?.slice(0, 100))
toast.add({
title: 'Entrelínea',
description: props.document.text?.slice(0, 100) || props.document.id,
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
@ -272,6 +234,8 @@ function onLightboxTouchStart(e: TouchEvent) {
}
function onLightboxTouchMove(e: TouchEvent) {
// Prevent page scroll while interacting inside the lightbox
e.preventDefault()
if (e.touches.length === 1 && lightboxScale.value > 1) {
const dx = e.touches[0].clientX - lbLastTouchX
const dy = e.touches[0].clientY - lbLastTouchY
@ -340,8 +304,44 @@ const lightboxImgStyle = computed(() => ({
/>
</template>
<template #title>
<span
v-if="showStudies"
class="font-semibold text-sm block"
>
Estudios relacionados
<span class="text-muted font-normal">({{ document.studies?.length }})</span>
</span>
<span
v-else
class="font-semibold text-sm min-w-0 max-w-full block whitespace-normal leading-snug pr-1 [overflow-wrap:anywhere]"
:title="title"
>
{{ title }}
</span>
</template>
<template #right>
<template v-if="!showStudies">
<UTooltip
v-if="document.studies?.length"
:text="`Estudios relacionados (${document.studies.length})`"
>
<UChip
:text="document.studies.length"
color="primary"
size="sm"
:show="!!document.studies?.length"
>
<UButton
icon="i-lucide-book-open"
color="neutral"
variant="ghost"
aria-label="Estudios relacionados"
@click="showStudies = true"
/>
</UChip>
</UTooltip>
<UTooltip v-if="collection" :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'">
<UButton
:icon="isFav ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
@ -368,22 +368,36 @@ const lightboxImgStyle = computed(() => ({
size="xs"
class="shrink-0"
/>
<UBadge
v-if="document.filter"
:label="String(document.filter)"
color="neutral"
variant="subtle"
size="xs"
class="shrink-0 uppercase"
/>
<span
v-if="origin"
v-if="origin && origin !== document.id"
class="inline-flex items-start rounded-md bg-primary/10 text-primary text-xs font-medium px-2 py-0.5 min-w-0 max-w-full whitespace-normal leading-snug [overflow-wrap:anywhere]"
:title="origin"
>
{{ origin }}
</span>
<span v-if="document.page != null" class="text-xs text-muted flex items-center gap-1 shrink-0">
<UIcon name="i-lucide-file-text" class="size-3" />
Pág. {{ document.page }}
</span>
<span
v-if="document.id && document.id !== origin"
class="text-xs text-dimmed font-mono truncate max-w-[160px] shrink-0"
:title="String(document.id)"
>
{{ document.id }}
</span>
</div>
<UButton
v-if="document.link"
:to="document.link"
target="_blank"
icon="i-lucide-external-link"
label="Ver en sitio"
color="primary"
variant="soft"
size="xs"
class="shrink-0"
/>
</div>
<!-- ------------------------------------------------------------------ -->
@ -393,13 +407,13 @@ const lightboxImgStyle = computed(() => ({
<div class="p-4 sm:p-6 pb-10 flex flex-col gap-6">
<!-- Imagen: mobile toca para abrir lightbox, desktop zoom al hover -->
<template v-if="imageUrl && (!document.draft || unlocked)">
<template v-if="imageUrl">
<!-- Mobile: imagen con indicador de zoom táctil -->
<div
class="lg:hidden h-52 rounded-xl overflow-hidden border border-default shadow-sm bg-elevated relative cursor-pointer flex items-center justify-center"
class="lg:hidden rounded-xl overflow-hidden border border-default shadow-sm bg-elevated relative cursor-pointer"
@click="openLightbox"
>
<img :src="imageUrl" :alt="title" loading="lazy" class="w-full h-full object-contain select-none">
<img :src="imageUrl" :alt="title" loading="lazy" class="w-full h-auto select-none">
<div class="absolute bottom-2 right-2 bg-black/50 rounded-full p-1.5 pointer-events-none">
<UIcon name="i-lucide-zoom-in" class="size-4 text-white" />
</div>
@ -408,7 +422,7 @@ const lightboxImgStyle = computed(() => ({
<!-- Desktop: hover zoom con lente -->
<div
ref="zoomBoxRef"
class="hidden lg:flex h-52 rounded-xl bg-elevated border border-default shadow-sm p-4 items-center justify-center relative cursor-zoom-in select-none"
class="hidden lg:flex rounded-xl bg-elevated border border-default shadow-sm p-4 items-center justify-center relative cursor-zoom-in select-none min-h-48"
@mouseenter="onZoomEnter"
@mouseleave="onZoomLeave"
@mousemove="onZoomMove"
@ -432,13 +446,13 @@ const lightboxImgStyle = computed(() => ({
</div>
</template>
<!-- Sin imagen / Borrador -->
<!-- Sin imagen -->
<div
v-else
class="rounded-xl 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>Imagen no disponible</p>
<p>Sin imagen disponible</p>
</div>
<!-- Texto -->
@ -521,47 +535,31 @@ const lightboxImgStyle = computed(() => ({
/>
</Teleport>
<!-- Popup móvil: imagen con pinch-to-zoom -->
<UModal
v-model:open="lightboxOpen"
:ui="{
overlay: 'bg-black/90',
content: 'bg-black text-white max-w-[95vw] sm:max-w-lg',
header: 'py-2 px-3 border-b-0',
body: 'p-0',
}"
>
<template #header>
<div class="w-full flex justify-end">
<UButton
icon="i-lucide-x"
size="sm"
color="neutral"
variant="ghost"
class="text-white hover:bg-white/20"
@click="closeLightbox"
/>
</div>
</template>
<template #body>
<!-- Lightbox mobile con pinch-to-zoom -->
<Teleport to="body">
<div
class="touch-none flex items-center justify-center overflow-hidden"
style="height: 70dvh"
v-if="lightboxOpen"
class="lg:hidden fixed inset-0 bg-black z-[9999] flex items-center justify-center"
@touchstart.passive="onLightboxTouchStart"
@touchmove.passive="onLightboxTouchMove"
@touchend.passive="onLightboxTouchEnd"
@touchmove.prevent="onLightboxTouchMove"
@touchend="onLightboxTouchEnd"
@click.self="closeLightbox"
>
<button
class="absolute top-4 right-4 z-10 text-white bg-black/60 rounded-full p-2"
@click="closeLightbox"
>
<UIcon name="i-lucide-x" class="size-6" />
</button>
<img
v-if="imageUrl"
:src="imageUrl"
:src="imageUrl!"
:alt="title"
class="w-full h-full object-contain select-none"
class="max-w-full max-h-full object-contain select-none"
:style="lightboxImgStyle"
draggable="false"
/>
</div>
</template>
</UModal>
</Teleport>
</UDashboardPanel>
</template>

View File

@ -6,7 +6,6 @@ import { useHistoryStore } from '~/stores/history'
import { storeToRefs } from 'pinia'
import { useSettingsStore } from '~/stores/settings'
import type { SearchHit } from '~/types'
import { pageHeader, select } from '#build/ui'
interface TypesenseHighlight {
field?: string
@ -18,7 +17,6 @@ interface ParagraphDoc {
id?: string
document_id: string
text: string
raw?: string
number: number
locale: string
type: string
@ -52,7 +50,6 @@ interface DocumentDoc {
}
slug?: string
body?: string
draft?: boolean
[key: string]: unknown
}
@ -65,19 +62,12 @@ const props = defineProps<{
query?: string
selectedHit?: TypesenseParagraphHit | null
selectedMatchingHits?: TypesenseParagraphHit[] | null
accentColor?: 'green' | 'blue'
noTrackVisit?: boolean
author?: string
}>()
const emits = defineEmits(['close'])
const nuxtApp = useNuxtApp()
const { locale } = useI18n()
const favorites = useFavoritesStore()
const iconColor = computed(() => props.accentColor === 'blue' ? 'text-carpablue' : 'text-carpagreen')
const history = useHistoryStore()
const { showParagraphNumbers } = storeToRefs(useSettingsStore())
const toast = useToast()
@ -105,7 +95,7 @@ watch(
() => [props.collection, props.document?.id] as const,
([collection, id]) => {
if (!collection || !id || !props.document) return
if (!props.noTrackVisit) history.visit(collection, toSearchHit(props.document))
history.visit(collection, toSearchHit(props.document))
},
{ immediate: true }
)
@ -336,7 +326,6 @@ function normalize(s: string): string {
// ---- Refs de estado ---------------------------------------------------------
const paragraphsContainer = ref<HTMLElement | null>(null)
const scrollContainer = ref<HTMLElement | null>(null)
// 'typesense' = Estado 1 (server-driven), 'local' = Estado 2 (client-driven)
type SearchMode = 'typesense' | 'local'
@ -349,7 +338,7 @@ const currentMatchIdx = ref(0)
function clearMatchMarks() {
const container = paragraphsContainer.value
if (!container) return
container.querySelectorAll('mark.search-match').forEach((m) => {
container.querySelectorAll('mark.search-match').forEach(m => {
const parent = m.parentNode
if (parent) {
parent.replaceChild(document.createTextNode(m.textContent || ''), m)
@ -491,11 +480,9 @@ async function applyTypesenseHighlights() {
matchElements.value = domMarks
currentMatchIdx.value = 0
// Paso 4: scroll sin hits (browse mode) volver al inicio; con hits, ir al párrafo correcto.
if (!hasMatchingHits) {
if (scrollContainer.value) scrollContainer.value.scrollTop = 0
return
}
// Paso 4: scroll solo cuando hay hits de Typesense que ubican el párrafo correcto.
// Sin hits, los marks se muestran para que el usuario navegue, pero no se hace scroll.
if (!hasMatchingHits) return
let targetMark: HTMLElement | null = snippetMarks[0] ?? null
@ -594,21 +581,6 @@ function clearLocalQuery() {
const localQuery = ref('')
const debouncedLocalQuery = useDebounce(localQuery, 200)
const showInternalSearch = ref(true)
const internalSearchRef = ref<{ input?: HTMLInputElement } | null>(null)
const metaExpanded = ref(true)
function toggleInternalSearch() {
showInternalSearch.value = !showInternalSearch.value
if (showInternalSearch.value) {
metaExpanded.value = true
nextTick(() => internalSearchRef.value?.input?.focus())
} else {
clearLocalQuery()
}
}
watch(() => props.document?.id, () => { metaExpanded.value = true; showInternalSearch.value = true })
watch(debouncedLocalQuery, (q) => {
// Cualquier cambio en el input activa el Estado 2
@ -654,12 +626,6 @@ onMounted(async () => {
if (props.paragraphs.length) {
await applyTypesenseHighlights()
}
document.addEventListener('selectionchange', onSelectionChange)
})
onUnmounted(() => {
document.removeEventListener('selectionchange', onSelectionChange)
if (selectionTimer) clearTimeout(selectionTimer)
})
// ---- Utilerías de búsqueda local --------------------------------------------
@ -779,55 +745,13 @@ function highlightTextNodes(root: HTMLElement, terms: string[]): number {
}
return matches.length
}
const isPopOverOpen = ref(false)
const selectionTooltip = ref<{ x: number; y: number } | null>(null)
let selectionTimer: ReturnType<typeof setTimeout> | null = null
function handleSelection(event: MouseEvent) {
const selection = window.getSelection()
if (selection && selection.toString().trim().length > 3) {
if (selectionTimer) clearTimeout(selectionTimer)
selectionTimer = setTimeout(() => {
const x = Math.min(Math.max(event.clientX, 96), window.innerWidth - 96)
selectionTooltip.value = { x, y: event.clientY }
isPopOverOpen.value = true
}, 320)
} else {
clearSelectionTooltip()
}
}
function clearSelectionTooltip() {
if (selectionTimer) { clearTimeout(selectionTimer); selectionTimer = null }
selectionTooltip.value = null
isPopOverOpen.value = false
}
function onSelectionChange() {
const sel = window.getSelection()
if (!sel || sel.toString().trim().length === 0) clearSelectionTooltip()
}
const links = computed(() => [])
const items = computed(() => {
return [
]
})
</script>
<template>
<UDashboardPanel id="estudios-ts-detail">
<UDashboardNavbar :toggle="false">
<template #leading>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="-ms-1.5"
@click="emits('close')"
/>
<UButton icon="i-lucide-arrow-left" color="neutral" variant="ghost" class="-ms-1.5" @click="emits('close')" />
</template>
<template #title>
@ -837,145 +761,60 @@ const items = computed(() => {
</template>
<template #right>
<UTooltip :text="showInternalSearch ? 'Cerrar buscador' : 'Buscar en documento'">
<UButton
icon="i-lucide-search"
:color="showInternalSearch || !!localQuery ? 'primary' : 'neutral'"
:variant="showInternalSearch || !!localQuery ? 'soft' : 'ghost'"
:disabled="!paragraphs.length"
@click="toggleInternalSearch"
/>
</UTooltip>
<UTooltip text="¿Cómo funciona?">
<UButton
icon="ph-student"
color="neutral"
variant="ghost"
:disabled="!document"
@click="nuxtApp.callHook('tour',12)"
/>
</UTooltip>
<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="ghost"
:disabled="!document"
:aria-label="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"
class="favorites_toggle"
@click="onToggleFavorite"
/>
<UButton :icon="isFav ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
:color="isFav ? 'primary' : 'neutral'" variant="ghost" :disabled="!document"
:aria-label="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'" @click="onToggleFavorite" />
</UTooltip>
</template>
</UDashboardNavbar>
<!-- Strip siempre visible para expandir/colapsar metadatos -->
<button
class="w-full flex items-center justify-center gap-1.5 py-1 border-b border-default bg-elevated/60 hover:bg-elevated transition-colors text-xs text-muted"
@click="metaExpanded = !metaExpanded"
>
<UIcon :name="metaExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="size-3.5" />
<span>{{ metaExpanded ? 'Ocultar detalles' : 'Ver detalles' }}</span>
</button>
<!-- Metadata -->
<div v-show="metaExpanded" class="flex flex-col gap-3 p-4 sm:px-6 border-b border-default shadow-sm z-10">
<div class="flex flex-col gap-3 p-4 sm:px-6 border-b border-default shadow-sm z-10">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
<p v-if="document?.draft" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
<UIcon name="ph:file-dashed" class="size-4 text-carpared" />
{{ $t('search.draft') }}
</p>
<p v-if="author" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
<UIcon name="ph:user-circle" :class="['size-4', iconColor]" />
<span class="italic">{{ author }}</span>
</p>
<p v-if="safeDate()" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
<UIcon name="ph:calendar" :class="['size-4', iconColor]" />
<UIcon name="ph:calendar" class="size-4 text-green-600" />
{{ safeDate() }}
</p>
<p v-if="document?.activity" class="text-sm flex items-center gap-1.5 text-muted shrink-0">
<UIcon name="ph:hash" :class="['size-4 shrink-0', iconColor]" />
{{ $t('search.publication') }} {{ document.activity }}
</p>
<p v-if="safeLocation()" class="text-sm flex items-center gap-1.5 text-muted min-w-0">
<UIcon name="ph:map-pin" :class="['size-4 shrink-0', iconColor]" />
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
<span class="truncate max-w-55">{{ safeLocation() }}</span>
</p>
</div>
<UButton
v-if="matchUrl"
:to="matchUrl"
target="_blank"
icon="i-lucide-external-link"
label="Ver en sitio"
color="primary"
variant="soft"
size="xs"
class="shrink-0"
/>
<UButton v-if="matchUrl" :to="matchUrl" target="_blank" icon="i-lucide-external-link" label="Ver en sitio"
color="primary" variant="soft" size="xs" class="shrink-0" />
</div>
<!-- file links hidden -->
<div v-if="fileLinks.length" class="flex flex-wrap gap-2">
<UButton v-for="(f, idx) in fileLinks" :key="idx" :to="f.to" :target="f.target" :icon="f.icon!" :label="f.label"
color="neutral" variant="subtle" size="xs" external />
</div>
<!-- Buscador interno del documento -->
<div v-if="paragraphs.length" v-show="showInternalSearch || !!localQuery" class="flex items-center gap-2">
<UInput
ref="internalSearchRef"
v-model="localQuery"
icon="i-lucide-search"
:placeholder="props.query || 'Buscar en este documento...'"
class="flex-1 min-w-0"
:ui="{ trailing: 'pe-1' }"
@keydown="onInputKey"
>
<div v-if="paragraphs.length" class="flex items-center gap-2">
<UInput v-model="localQuery" icon="i-lucide-search" :placeholder="props.query || 'Buscar en este documento...'"
class="flex-1 min-w-0" :ui="{ trailing: 'pe-1' }" @keydown="onInputKey">
<template #trailing>
<UButton
v-if="localQuery"
icon="i-lucide-x"
variant="link"
aria-label="Limpiar"
@click="clearLocalQuery"
/>
<UButton v-if="localQuery" icon="i-lucide-x" variant="link" aria-label="Limpiar" @click="clearLocalQuery" />
</template>
</UInput>
<template v-if="matchElements.length || searchMode === 'local'">
<UBadge
v-if="searchMode === 'typesense' && matchElements.length"
label="Typesense"
size="xs"
variant="subtle"
color="warning"
class="shrink-0"
/>
<UBadge v-if="searchMode === 'typesense' && matchElements.length" label="Typesense" size="xs" variant="subtle"
color="warning" class="shrink-0" />
<span
class="text-xs tabular-nums whitespace-nowrap shrink-0 px-2 py-1 rounded-md bg-elevated border border-default"
:class="matchElements.length ? 'text-toned font-medium' : 'text-dimmed'"
>
:class="matchElements.length ? 'text-toned font-medium' : 'text-dimmed'">
{{ matchElements.length ? `${currentMatchIdx + 1} / ${matchElements.length}` : '0 / 0' }}
</span>
<div class="flex items-center gap-1 shrink-0">
<UTooltip text="Anterior (Shift+Enter)">
<UButton
icon="i-lucide-chevron-up"
:disabled="!matchElements.length"
aria-label="Anterior"
color="neutral"
variant="ghost"
size="sm"
@click="prevMatch"
/>
<UButton icon="i-lucide-chevron-up" :disabled="!matchElements.length" aria-label="Anterior"
color="neutral" variant="ghost" size="sm" @click="prevMatch" />
</UTooltip>
<UTooltip text="Siguiente (Enter)">
<UButton
icon="i-lucide-chevron-down"
:disabled="!matchElements.length"
aria-label="Siguiente"
color="neutral"
variant="ghost"
size="sm"
@click="nextMatch"
/>
<UButton icon="i-lucide-chevron-down" :disabled="!matchElements.length" aria-label="Siguiente"
color="neutral" variant="ghost" size="sm" @click="nextMatch" />
</UTooltip>
</div>
</template>
@ -983,43 +822,25 @@ const items = computed(() => {
</div>
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative bg-gray-100">
<UPage class="max-w-6xl mx-auto py-0 my-0 " @mouseup="handleSelection">
<UPageBody class="bg-white p-8 rounded-xl shadow-lg">
<UPageHeader
:title="document?.title"
:description="formatSignature(document)"
class="py-0 pb-2"
>
<template #headline>
<div class="flex w-full justify-between" v-if="paragraphs.length>0">
<UBadge variant="outline" color="neutral" :class="document?.type=='activities' ? 'bg-carpagreen/20' : 'bg-carpablue/20'">{{ document?.locale.toUpperCase() }}-{{ document?.code }}</UBadge>
<div class="text-carpared font-semibold" v-if="document?.draft"><UIcon name="ph-file-dashed" class="mr-1" />{{ $t('ui.draft') }}</div>
</div>
</template>
</UPageHeader>
<!-- Cargando párrafos -->
<div v-if="paragraphsLoading" class="flex items-center justify-center gap-2 py-16 text-sm text-muted">
<UIcon name="i-lucide-loader-circle" class="size-5 animate-spin" />
Cargando párrafos...
</div>
<div v-else-if="paragraphs.length" ref="paragraphsContainer">
<div class="">
<!-- Todos los párrafos -->
<div v-else-if="paragraphs.length" ref="paragraphsContainer" class="p-1 sm:p-4 max-w-4xl m-4 sm:mx-auto sm:my-6">
<div class="bg-white rounded-lg shadow-md p-4 mb-4 last:mb-0">
<div v-for="(hit, idx) in paragraphs" :key="idx" :data-paragraph-number="hit.document.number">
<div class="grid grid-cols-1fr items-start gap-2 mb-2" :class="(showParagraphNumbers && 'grid-cols-[20px_1fr]')">
<div v-if="showParagraphNumbers" class="w-full select-none cursor-pointer flex justify-end">
<UBadge
v-if="hit.document.number"
:label="`${hit.document.number}`"
size="md"
variant="link"
class="text-gray-300 font-bold"
:class="(hit.document.type=='activities'?'hover:text-carpagreen':'hover:text-carpablue')"
@click="isPopOverOpen = !isPopOverOpen"
/>
<div class="flex items-start gap-2 mb-2">
<div v-if="showParagraphNumbers" class="w-4 mr-2">
<UBadge v-if="hit.document.number" :label="`#${hit.document.number}`" size="xs" variant="outline"
color="info" />
</div>
<div class="">
<div
class="paragraph-html text-sm leading-relaxed text-gray-800 dark:text-gray-200"
v-html="hit.document.html || hit.document.text"
v-html="hit.document.raw"
/>
</div>
</div>
@ -1036,57 +857,7 @@ const items = computed(() => {
<ULink :to="matchUrl" target="_blank" class="text-primary">verlo en el sitio</ULink>.
</p>
</div>
</UPageBody>
</UPage>
</div>
<!-- Tooltip de selección -->
<Teleport to="body">
<div
v-if="selectionTooltip"
class="fixed z-9999 pointer-events-none"
:style="{ left: selectionTooltip.x + 'px', top: (selectionTooltip.y - 12) + 'px' }"
>
<Transition name="tooltip-pop">
<div
v-if="isPopOverOpen"
class="p-2 selection-tooltip flex items-center bg-white rounded-xl shadow-2xl overflow-hidden select-none pointer-events-auto"
@mousedown.prevent
>
<!-- <UButton
variant="soft"
class="group flex flex-col items-center gap-1 px-4 py-2.5 text-blue-300 hover:bg-white/10 active:bg-white/20 transition-colors"
aria-label="Crear nota"
@click="addNote()"
>
<UIcon name="ph-note-pencil" class="size-5" />
<span class="group-hover:text-black text-[9px] font-semibold leading-none">Nota</span>
</UButton>
<div class="w-px self-stretch bg-white/10" /> -->
<!-- <UButton
variant="soft"
class="group flex flex-col items-center gap-1 px-4 py-2.5 text-green-400 hover:bg-white/10 active:bg-white/20 transition-colors"
aria-label="Resaltar"
@click="highlightParagraph()"
>
<UIcon name="ph-highlighter" class="size-5" />
<span class="group-hover:text-black text-[9px] font-semibold leading-none">Resaltar</span>
</UButton> -->
<div class="w-px self-stretch bg-white/10" />
<UButton
variant="soft"
class="cursor-pointer group flex flex-col items-center gap-1 px-4 py-2.5 text-carpared hover:bg-white/10 active:bg-white/20 transition-colors"
:aria-label="$t('ui.copy')"
@click="copyToClipboard(props.document)"
>
<UIcon name="ph-clipboard-text" class="size-7" />
<span class="group-hover:text-black text-xs font-semibold leading-none">{{ $t('ui.copy') }}</span>
</UButton>
</div>
</Transition>
</div>
</Teleport>
</UDashboardPanel>
</template>
@ -1098,21 +869,4 @@ const items = computed(() => {
.paragraph-html :deep(p:last-child) {
margin-bottom: 0;
}
/* Tooltip de selección de texto */
.selection-tooltip {
transform: translateX(-50%) translateY(-100%);
}
.tooltip-pop-enter-active {
transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.12s ease;
}
.tooltip-pop-leave-active {
transition: transform 0.12s ease-in, opacity 0.1s ease;
}
.tooltip-pop-enter-from,
.tooltip-pop-leave-to {
transform: translateX(-50%) translateY(calc(-100% + 8px));
opacity: 0;
}
</style>

View File

@ -305,15 +305,7 @@ async function renderBody() {
const el = bodyContainer.value
if (!el) return
// const raw = props.activity?.body
let raw = props.activity?.body || ''
if (raw) {
raw = raw
.replaceAll('#00ccff', '#0070C0')
.replaceAll('#FF0000', '#C00000')
.replaceAll('#333399', '#7030A0')
}
const raw = props.activity?.body
el.innerHTML = raw ? ((fixLink(raw) as string) || '') : ''
if (scrollContainer.value) scrollContainer.value.scrollTop = 0
@ -434,7 +426,21 @@ function onInputKey(e: KeyboardEvent) {
/>
</div>
<!-- file links hidden -->
<!-- Fila 2: archivos adjuntos -->
<div v-if="fileLinks.length" class="flex flex-wrap gap-2">
<UButton
v-for="(f, idx) in fileLinks"
:key="idx"
:to="f.to"
:target="f.target"
:icon="f.icon!"
:label="f.label"
color="neutral"
variant="subtle"
size="xs"
external
/>
</div>
<!-- Fila 3: búsqueda local en el documento -->
<div

View File

@ -1,106 +0,0 @@
<script setup lang="ts">
import { breakpointsTailwind } from '@vueuse/core'
interface Props {
activeCount?: number
title?: string
}
withDefaults(defineProps<Props>(), {
activeCount: 0,
title: 'search.filters'
})
/*
RESPONSIVE: un solo componente, dos renders
- Desktop (>lg): acordeón colapsable (#content dentro)
- Mobile (<lg): botón USlideover (#content dentro del slide)
El slot #content es el MISMO en ambos casos
(el input + botón para agregar bible_study)
*/
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
const isExpanded = ref(true)
const isSlideoverOpen = ref(false)
const { $i18n } = useNuxtApp()
const t = $i18n.t
</script>
<template>
<div class="border-b border-default">
<!-- DESKTOP (>lg): ACORDEÓN COLAPSABLE -->
<div v-if="!isMobile" class="select-none">
<button
class="w-full flex items-center justify-between px-4 sm:px-6 py-2 text-sm font-medium text-muted hover:text-foreground transition-colors"
@click="isExpanded = !isExpanded"
>
<span class="flex items-center gap-2">
<UIcon name="i-lucide-filter" class="size-4" />
{{ t(title) }}
<UBadge v-if="activeCount > 0" size="sm" variant="solid" color="primary">
{{ activeCount }}
</UBadge>
</span>
<UIcon
:name="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="size-4 transition-transform"
/>
</button>
<div v-show="isExpanded" class="px-4 sm:px-6 pb-3">
<slot name="content" />
</div>
</div>
<!-- MOBILE (<lg): BOTÓN + SLIDEOVER -->
<div v-else class="px-4 sm:px-6 py-2">
<UButton
size="sm"
color="neutral"
variant="outline"
class="w-full justify-between"
@click="isSlideoverOpen = true"
>
<span class="flex items-center gap-2">
<UIcon name="i-lucide-filter" class="size-4" />
{{ t(title) }}
</span>
<span class="flex items-center gap-2">
<UBadge v-if="activeCount > 0" size="sm" variant="solid" color="primary">
{{ activeCount }}
</UBadge>
<UIcon name="i-lucide-chevron-right" class="size-4" />
</span>
</UButton>
<ClientOnly>
<USlideover v-model:open="isSlideoverOpen">
<template #content>
<div class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold flex items-center gap-2">
<UIcon name="i-lucide-filter" class="size-5" />
{{ t(title) }}
</h3>
<UButton
icon="i-lucide-x"
size="sm"
color="neutral"
variant="ghost"
@click="isSlideoverOpen = false"
/>
</div>
<slot name="content" />
<div v-if="$slots.chips" class="pt-2">
<slot name="chips" />
</div>
</div>
</template>
</USlideover>
</ClientOnly>
</div>
</div>
</template>

View File

@ -1,648 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind } from '@vueuse/core'
import PublicationDetail from '~/components/PublicationDetail.vue'
import FiltersContainer from '~/components/searchPanel/FiltersContainer.vue'
import { useSettingsStore } from '~/stores/settings'
interface Props {
paragraphsCollection: string
mainCollection: string
groupByField: string
favoritesCollection: string
panelId: string
navTitleKey: string
accentColor: 'green' | 'blue'
emptyDetailText: string
showDraft?: boolean
author?: string
showBibleStudyFilter?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showDraft: false,
author: '',
showBibleStudyFilter: false
})
const QUERY_BY = 'text'
const { $i18n } = useNuxtApp()
const t = $i18n.t
const { locale } = useI18n()
const settings = useSettingsStore()
const { unlocked } = useDevMode()
const toast = useToast()
const typesenseClient = useTypesenseClient()
// Restaurar estado desde URL antes de crear los refs
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
// ---- Filtro bible_study multi-chip (solo para Estudios) --------------------
interface BibleStudyChip {
id: number
title: string
}
const bibleStudyInput = ref<number | null>(null)
const activeBibleStudies = ref<BibleStudyChip[]>([])
const isValidating = ref(false)
const {
query, debouncedQuery, loading, loadingMore, errorMsg,
exactSearch, sortMode,
groupedHits, visibleGroupCount, visibleGroups, hasMoreVisible, hasMore,
browseItems, hasMoreBrowse,
displayGroups, activePage, displayTotal, totalPages,
runSearch, runBrowse, loadMore, goToPage, retry
} = useGroupedTypesenseSearch({
paragraphsCollection: props.paragraphsCollection,
mainCollection: props.mainCollection,
groupByField: props.groupByField,
queryBy: QUERY_BY,
filterBy: () => {
let base = `locale:=${locale.value}`
if (activeBibleStudies.value.length > 0) {
const ids = activeBibleStudies.value.map(bs => bs.id).join(',')
base += ` && $${props.mainCollection}(bible_study:=[${ids}])`
}
return base
},
browseFilterBy: () => {
let base = `locale:=${locale.value}`
if (activeBibleStudies.value.length > 0) {
const ids = activeBibleStudies.value.map(bs => bs.id).join(',')
base += ` && bible_study:=[${ids}]`
}
return base
},
isUnlocked: () => unlocked.value,
pageSize: () => settings.pageSize,
paginationType: () => settings.paginationType,
initialQuery: q0,
initialPage: p0
})
function refetchResults() {
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(1, false)
} else {
groupedHits.value = []
runSearch(query.value, 1, false)
}
}
async function applyBibleStudyFilter() {
const val = bibleStudyInput.value
if (val === null || val <= 0) return
if (activeBibleStudies.value.some(bs => bs.id === val)) {
toast.add({ title: 'Estudio ya agregado', description: `El estudio #${val} ya está en el filtro`, color: 'info' })
bibleStudyInput.value = null
return
}
isValidating.value = true
try {
const res = await typesenseClient.multiSearch.perform({
searches: [{
collection: props.mainCollection,
q: '*',
query_by: 'title',
filter_by: `bible_study:=${val}`,
per_page: 1,
include_fields: 'bible_study,title',
use_cache: true,
cache_ttl: 3600
}]
})
const hit = (res?.results?.[0] as { hits?: Array<{ document: { title?: string } }> })?.hits?.[0]
if (hit) {
activeBibleStudies.value = [...activeBibleStudies.value, { id: val, title: hit.document.title || '' }]
bibleStudyInput.value = null
refetchResults()
} else {
toast.add({ title: 'Estudio no encontrado', description: `No existe el estudio #${val}`, color: 'warning' })
}
} catch (err) {
console.error('Error validando bible_study', err)
} finally {
isValidating.value = false
}
}
function removeBibleStudyFilter(id: number) {
activeBibleStudies.value = activeBibleStudies.value.filter(bs => bs.id !== id)
refetchResults()
}
function clearAllBibleStudyFilters() {
activeBibleStudies.value = []
refetchResults()
}
// ---- Types ----------------------------------------------------------------
interface DocumentDoc extends CachedDocMeta {
code: string
locale: string
files?: {
youtube?: string
video?: string
audio?: string
booklet?: string
simple?: string
}
body?: string
[key: string]: unknown
}
// ---- Colors ----------------------------------------------------------------
const colors = computed(() => {
if (props.accentColor === 'green') {
return {
selectedItem: 'border-carpagreen bg-carpagreen/10',
hoverItem: 'border-gray-200 hover:border-carpagreen hover:bg-carpagreen/5',
icon: 'text-carpagreen',
}
}
return {
selectedItem: 'border-carpablue bg-carpablue/10',
hoverItem: 'border-gray-200 hover:border-carpablue hover:bg-carpablue/5',
icon: 'text-carpablue',
}
})
// ---- Scroll infinito y detalle ---------------------------------------------
const listContainer = ref<HTMLElement | null>(null)
function onListScroll() {
if (settings.paginationType !== 'infinite_scroll') return
const el = listContainer.value
if (!el) return
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
if (!debouncedQuery.value.trim()) {
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) runBrowse(1, true)
} else {
if (hasMoreVisible.value) visibleGroupCount.value += 10
else if (hasMore.value && !loadingMore.value && !loading.value) loadMore()
}
}
}
// ---- Selección y carga del detalle ----------------------------------------
const selectedDocId = ref<string | null>(null)
const selectedDocument = ref<DocumentDoc | null>(null)
const documentLoading = ref(false)
const selectedParagraphs = ref<TypesenseGroupedParagraphHit[]>([])
const paragraphsLoading = ref(false)
const selectedHit = ref<TypesenseGroupedParagraphHit | null>(null)
const selectedMatchingHits = ref<TypesenseGroupedParagraphHit[]>([])
let detailSeq = 0
let detailController: AbortController | null = null
onBeforeUnmount(() => { detailController?.abort() })
const { fetchDocumentDetail } = useDocumentDetailFetch()
async function fetchDocumentWithParagraphs(docId: string) {
const seq = ++detailSeq
detailController?.abort()
const controller = new AbortController()
detailController = controller
documentLoading.value = true
paragraphsLoading.value = true
selectedDocument.value = null
selectedParagraphs.value = []
try {
const detail = await fetchDocumentDetail(typesenseClient, props.mainCollection, props.paragraphsCollection, docId, controller.signal)
if (seq !== detailSeq) return
if (detail) {
const rawParagraphs = detail.paragraphs as unknown as ParagraphDoc[]
selectedDocument.value = detail.document as unknown as DocumentDoc
selectedParagraphs.value = [...rawParagraphs]
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
.map(p => ({ document: p }))
}
} catch (err) {
if (seq !== detailSeq) return
if ((err as { name?: string })?.name === 'AbortError') return
console.error('Error fetching document with paragraphs', err)
selectedDocument.value = null
selectedParagraphs.value = []
} finally {
if (seq === detailSeq) {
documentLoading.value = false
paragraphsLoading.value = false
}
}
}
async function selectGroup(group: DisplayGroup) {
selectedDocId.value = group.docId
selectedHit.value = group.firstHit
selectedMatchingHits.value = groupedHits.value.find(g => g.docId === group.docId)?.allHits ?? []
fetchDocumentWithParagraphs(group.docId)
}
const isPanelOpen = computed({
get() { return !!selectedDocId.value },
set(v: boolean) {
if (!v) {
selectedDocId.value = null
selectedDocument.value = null
selectedParagraphs.value = []
selectedHit.value = null
selectedMatchingHits.value = []
}
}
})
watch(groupedHits, () => {
if (!selectedDocId.value || !debouncedQuery.value.trim()) return
if (!groupedHits.value.find(g => g.docId === selectedDocId.value)) {
selectedDocId.value = null
selectedDocument.value = null
selectedParagraphs.value = []
selectedHit.value = null
selectedMatchingHits.value = []
}
})
const selectedId = computed(() => selectedDocId.value)
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listContainer })
onMounted(async () => {
if (q0.trim()) await runSearch(q0, p0, false)
else await runBrowse(p0, false)
restoreScrollPosition(listContainer.value, s0)
if (sid0) {
const group = displayGroups.value.find(g => g.docId === sid0)
if (group) selectGroup(group)
else {
selectedDocId.value = sid0
fetchDocumentWithParagraphs(sid0)
}
}
})
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
useDetailHistory(isPanelOpen, isMobile)
// ---- Helpers de presentación ----------------------------------------------
function highlightedFor(hit: TypesenseGroupedParagraphHit, 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
}
function metaDate(meta: CachedDocMeta | undefined): string {
if (!meta) return ''
const ts = meta.timestamp || (meta.date ? Math.floor(new Date(meta.date).getTime() / 1000) : null)
if (!ts) return meta.date || ''
return formatDate(ts)
}
function metaLocation(meta: CachedDocMeta | undefined): string {
if (!meta) return ''
return formatLocation({
id: meta.id, date: meta.timestamp ?? 0, slug: meta.slug ?? '',
type: meta.type ?? '', place: meta.place ?? '', city: meta.city ?? '',
state: meta.state ?? '', country: meta.country ?? '', thumbnail: ''
})
}
</script>
<template>
<UDashboardPanel
:id="panelId"
:default-size="32"
:min-size="24"
:max-size="45"
resizable
>
<UDashboardNavbar :title="t(navTitleKey)">
<template #leading>
<UDashboardSidebarCollapse :ui="{
base: 'collapse-sidebar-icon'
}" />
</template>
<template #trailing>
<UBadge :label="displayTotal" variant="subtle" :ui="{
base: 'total-results'
}" />
</template>
</UDashboardNavbar>
<div
v-if="author"
class="px-4 sm:px-6 py-2 border-b border-default flex items-center gap-1.5 text-xs text-muted"
>
<UIcon name="ph:user-circle" :class="['size-3.5 shrink-0', colors.icon]" />
<span class="italic">{{ author }}</span>
</div>
<!-- BUSCADOR -->
<div class="px-4 sm:px-6 py-3 border-b border-default flex items-center gap-2" id="inputField">
<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"
>{{ t('search.word') }}</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"
>{{ t('search.phrase') }}</button>
</div>
</div>
<!-- CHIPS ACTIVOS (solo desktop: fuera del FiltersContainer) -->
<div v-if="showBibleStudyFilter && activeBibleStudies.length > 0 && !isMobile" class="px-4 sm:px-6 py-2 border-b border-default">
<div class="flex flex-wrap items-center gap-1.5">
<UBadge
v-for="bs in activeBibleStudies"
:key="bs.id"
size="sm"
variant="subtle"
color="primary"
class="max-w-full"
>
<span class="truncate">{{ $t('search.bible_study_chip', { number: bs.id }) }}</span>
<UButton
icon="i-lucide-x"
size="2xs"
color="neutral"
variant="ghost"
class="ml-1 shrink-0"
@click="removeBibleStudyFilter(bs.id)"
/>
</UBadge>
<UButton
size="2xs"
color="neutral"
variant="ghost"
class="text-xs"
@click="clearAllBibleStudyFilters"
>
{{ $t('search.bible_study_clear') }}
</UButton>
</div>
</div>
<!-- SORT -->
<div v-if="query.trim()" class="px-4 sm:px-6 py-3 flex items-center gap-2">
<p class="text-sm">Busqueda: </p>
<USelect
v-model="sortMode"
:items="[
{ label: t('search.sort.relevance'), value: 'relevance' },
{ label: t('search.sort.date'), value: 'date' }
]"
size="sm"
class="shrink-0 min-w-[130px]"
/>
</div>
<!-- FILTROS: AGREGAR NUEVOS (acordeón desktop / slideover mobile) -->
<template v-if="showBibleStudyFilter">
<FiltersContainer
:active-count="activeBibleStudies.length"
title="search.filters"
>
<template #chips>
<div class="flex flex-wrap items-center gap-1.5">
<UBadge
v-for="bs in activeBibleStudies"
:key="bs.id"
size="sm"
variant="subtle"
color="primary"
class="max-w-full"
>
<span class="truncate">{{ $t('search.bible_study_chip', { number: bs.id }) }}</span>
<UButton
icon="i-lucide-x"
size="2xs"
color="neutral"
variant="ghost"
class="ml-1 shrink-0"
@click="removeBibleStudyFilter(bs.id)"
/>
</UBadge>
<div v-if="activeBibleStudies.length > 0">
<UButton
size="sm"
color="neutral"
variant="ghost"
class="text-xs"
@click="clearAllBibleStudyFilters">
{{ $t('search.bible_study_clear') }}
</UButton>
</div>
</div>
</template>
<template #content>
<div class="flex items-center gap-2">
<UInput
v-model="bibleStudyInput"
type="number"
min="1"
:placeholder="$t('search.bible_study_placeholder')"
size="sm"
class="w-36"
@keyup.enter="applyBibleStudyFilter"
/>
<UButton
size="sm"
color="neutral"
variant="outline"
:loading="isValidating"
:disabled="bibleStudyInput === null || bibleStudyInput <= 0"
@click="applyBibleStudyFilter"
>
+ {{ $t('search.filter') }}
</UButton>
</div>
</template>
</FiltersContainer>
</template>
<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="listContainer" class="overflow-y-auto divide-y divide-default flex-1" @scroll="onListScroll">
<div
v-if="loading && !displayGroups.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="!displayGroups.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="group in displayGroups"
:key="group.docId"
class="p-4 sm:px-6 text-sm cursor-pointer border-b-2 transition-colors"
:class="selectedDocId === group.docId ? colors.selectedItem : colors.hoverItem"
@click="selectGroup(group)"
>
<div class="mb-1">
<p class="text-sm font-semibold line-clamp-2 text-highlighted">
<UTooltip
v-if="showDraft && group.meta?.draft"
:text="$t('search.draft')"
color="error"
>
<UIcon name="ph-file-dashed" class="bg-carpared" />
</UTooltip>
{{ group.meta?.title || group.docId }}
</p>
</div>
<p class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-xs mb-2 text-muted justify-between">
<span v-if="metaDate(group.meta)" class="flex items-center gap-1">
<UIcon name="ph:calendar" :class="['size-4', colors.icon]" />
{{ metaDate(group.meta) }}
</span>
<span v-if="metaLocation(group.meta)" class="flex items-center gap-1 truncate">
<UIcon name="ph:map-pin" :class="['size-4 shrink-0', colors.icon]" />
<span class="truncate">{{ metaLocation(group.meta) }}</span>
</span>
</p>
<div
v-if="group.firstHit"
class="snippet-html text-sm text-dimmed"
v-html="highlightedFor(group.firstHit, 'text') || group.firstHit.document.text"
/>
</div>
<div
v-if="settings.paginationType === 'infinite_scroll' && loadingMore"
class="flex items-center justify-center gap-2 py-4 text-sm text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
Cargando más...
</div>
<div
v-else-if="settings.paginationType === 'infinite_scroll' && displayGroups.length && !hasMoreBrowse && !hasMoreVisible && !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="displayTotal"
:items-per-page="settings.pageSize"
size="sm"
@update:page="goToPage"
/>
</div>
</UDashboardPanel>
<!-- Panel de detalle (escritorio) -->
<PublicationDetail
v-if="selectedDocId && !isMobile"
:document="selectedDocument"
:document-loading="documentLoading"
:paragraphs="selectedParagraphs"
:paragraphs-loading="paragraphsLoading"
:collection="favoritesCollection"
:query="debouncedQuery"
:selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits"
:accent-color="accentColor"
:author="author"
@close="isPanelOpen = false"
/>
<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" />
<p class="text-sm">{{ emptyDetailText }}</p>
</div>
</div>
<!-- Panel de detalle (móvil) -->
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
<template #content>
<PublicationDetail
v-if="selectedDocId"
:document="selectedDocument"
:document-loading="documentLoading"
:paragraphs="selectedParagraphs"
:paragraphs-loading="paragraphsLoading"
:collection="favoritesCollection"
:query="debouncedQuery"
:selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits"
:accent-color="accentColor"
:author="author"
@close="isPanelOpen = false"
/>
</template>
</USlideover>
</ClientOnly>
</template>
<style scoped>
.snippet-html :deep(p) {
display: inline;
margin: 0;
}
.snippet-html :deep(br) {
display: none;
}
</style>

View File

@ -1,33 +0,0 @@
const STORAGE_KEY = 'entrelineas_dev_unlocked'
export function useDevMode() {
const config = useRuntimeConfig()
const devKey = config.public.entrelineasDevKey as string
const unlocked = ref(false)
if (import.meta.client) {
unlocked.value = localStorage.getItem(STORAGE_KEY) === 'true'
}
function unlock(key: string): boolean {
if (!devKey) return false
if (key === devKey) {
unlocked.value = true
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY, 'true')
}
return true
}
return false
}
function lock() {
unlocked.value = false
if (import.meta.client) {
localStorage.removeItem(STORAGE_KEY)
}
}
return { unlocked, unlock, lock }
}

View File

@ -1,56 +0,0 @@
export interface CachedDocMeta {
id: string
title: string
date?: string
timestamp?: number
place?: string
city?: string
state?: string
country?: string
type?: string
slug?: string
draft?: string
[key: string]: unknown
}
const MAX_ENTRIES = 500
function cacheKey(collection: string, id: string): string {
return `${collection}:${id}`
}
/**
* Caché de metadata de documentos (título, fecha, lugar...) compartida entre
* páginas de búsqueda, con vida de sesión (sobrevive entre queries, no se
* vacía al buscar de nuevo). Clave `collection:id` porque los IDs no son
* únicos entre colecciones (p.ej. `conferences` vs `activities`).
*
* Tope de tamaño con eviction LRU simple: en cada lectura/escritura la
* entrada se reinserta al final del Map (orden de inserción = recencia), así
* que la más antigua a evictar siempre es `.keys().next().value`.
*/
export function useDocMetaCache() {
const cache = useState<Map<string, CachedDocMeta>>('doc-meta-cache', () => new Map())
function get(collection: string, id: string): CachedDocMeta | undefined {
const key = cacheKey(collection, id)
const entry = cache.value.get(key)
if (entry) {
cache.value.delete(key)
cache.value.set(key, entry)
}
return entry
}
function set(collection: string, id: string, meta: CachedDocMeta) {
const key = cacheKey(collection, id)
cache.value.delete(key)
cache.value.set(key, meta)
if (cache.value.size > MAX_ENTRIES) {
const oldestKey = cache.value.keys().next().value
if (oldestKey !== undefined) cache.value.delete(oldestKey)
}
}
return { get, set }
}

View File

@ -1,79 +0,0 @@
export interface DocumentDetailResult {
document: Record<string, unknown>
paragraphs: Record<string, unknown>[]
}
const MAX_ENTRIES = 25
function cacheKey(mainCollection: string, docId: string): string {
return `${mainCollection}:${docId}`
}
/**
* Fetch de "documento principal + sus párrafos" (join de Typesense), con
* caché de sesión compartida entre SearchPanel.vue y usePublicationFetch.ts
* antes cada uno hacía la misma query por separado sin cachear nada, así
* que reabrir el mismo documento en la sesión repetía la ida y vuelta de red.
* Tope de ~25 entradas (documentos completos con cuerpo/párrafos pesan más
* que la metadata de useDocMetaCache) con eviction LRU simple.
*/
export function useDocumentDetailFetch() {
const cache = useState<Map<string, DocumentDetailResult>>('doc-detail-cache', () => new Map())
function getCached(mainCollection: string, docId: string): DocumentDetailResult | undefined {
const key = cacheKey(mainCollection, docId)
const entry = cache.value.get(key)
if (entry) {
cache.value.delete(key)
cache.value.set(key, entry)
}
return entry
}
function setCached(mainCollection: string, docId: string, detail: DocumentDetailResult) {
const key = cacheKey(mainCollection, docId)
cache.value.delete(key)
cache.value.set(key, detail)
if (cache.value.size > MAX_ENTRIES) {
const oldestKey = cache.value.keys().next().value
if (oldestKey !== undefined) cache.value.delete(oldestKey)
}
}
async function fetchDocumentDetail(
typesenseClient: ReturnType<typeof useTypesenseClient>,
mainCollection: string,
paragraphsCollection: string,
docId: string,
signal?: AbortSignal
): Promise<DocumentDetailResult | null> {
const cached = getCached(mainCollection, docId)
if (cached) return cached
const res = await typesenseClient.multiSearch.perform({
searches: [{
collection: mainCollection,
q: '*',
query_by: 'title',
filter_by: `id:=${docId} && $${paragraphsCollection}(id: *)`,
include_fields: `*, $${paragraphsCollection}(*)`,
use_cache: true,
cache_ttl: 3600
}]
}, {}, { abortSignal: signal })
const hit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
if (!hit) return null
const docRaw = { ...hit.document }
const raw = docRaw[paragraphsCollection]
const paragraphs = (Array.isArray(raw) ? raw : (raw ? [raw] : [])) as Record<string, unknown>[]
delete docRaw[paragraphsCollection]
const detail: DocumentDetailResult = { document: docRaw, paragraphs }
setCached(mainCollection, docId, detail)
return detail
}
return { fetchDocumentDetail }
}

View File

@ -1,72 +0,0 @@
interface BibleStudyChip {
id: number
title: string
}
const activeBibleStudies = useState<BibleStudyChip[]>('bible-study-chips', () => [])
export function useFilters() {
const typesenseClient = useTypesenseClient()
const toast = useToast()
const bibleStudyInput = ref<number | null>(null)
const isValidating = ref(false)
const activeCount = computed(() => activeBibleStudies.value.length)
async function applyBibleStudyFilter(mainCollection: string) {
const val = bibleStudyInput.value
if (val === null || val <= 0) return
if (activeBibleStudies.value.some(bs => bs.id === val)) {
toast.add({ title: 'Estudio ya agregado', description: `El estudio #${val} ya está en el filtro`, color: 'info' })
bibleStudyInput.value = null
return
}
isValidating.value = true
try {
const res = await typesenseClient.multiSearch.perform({
searches: [{
collection: mainCollection,
q: '*',
query_by: 'title',
filter_by: `bible_study:=${val}`,
per_page: 1,
include_fields: 'bible_study,title',
use_cache: true,
cache_ttl: 3600
}]
})
const hit = (res?.results?.[0] as { hits?: Array<{ document: { title?: string } }> })?.hits?.[0]
if (hit) {
activeBibleStudies.value = [...activeBibleStudies.value, { id: val, title: hit.document.title || '' }]
bibleStudyInput.value = null
} else {
toast.add({ title: 'Estudio no encontrado', description: `No existe el estudio #${val}`, color: 'warning' })
}
} catch (err) {
console.error('Error validando bible_study', err)
} finally {
isValidating.value = false
}
}
function removeBibleStudyFilter(id: number) {
activeBibleStudies.value = activeBibleStudies.value.filter(bs => bs.id !== id)
}
function clearAllBibleStudyFilters() {
activeBibleStudies.value = []
}
return {
activeBibleStudies,
activeCount,
bibleStudyInput,
isValidating,
applyBibleStudyFilter,
removeBibleStudyFilter,
clearAllBibleStudyFilters
}
}

View File

@ -1,115 +0,0 @@
import { ref } from 'vue'
import type { SearchHit } from '~/types'
interface ParagraphDoc {
id?: string
document_id: string
text: string
raw?: string
number: number
locale: string
type: string
}
export interface TypesenseParagraphHit {
document: ParagraphDoc
highlights?: unknown[]
highlight?: Record<string, unknown>
}
export interface DocumentDoc {
id?: string
code: string
locale: string
type: string
title: string
timestamp: number
date: string
place?: string
city?: string
state?: string
country?: string
thumbnail?: string
files?: { youtube?: string; video?: string; audio?: string; booklet?: string; simple?: string }
slug?: string
body?: string
draft?: boolean
[key: string]: unknown
}
// Maps the favorites/history collection name → Typesense main + paragraphs collections.
const COLLECTION_CONFIG: Record<string, { main: string; paragraphs: string }> = {
'bible-studies-ts': { main: 'activities', paragraphs: 'activities_paragraphs' },
'conferences-ts': { main: 'conferences', paragraphs: 'conferences_paragraphs' },
}
export function usePublicationFetch() {
const detailDocument = ref<DocumentDoc | null>(null)
const detailDocumentLoading = ref(false)
const detailParagraphs = ref<TypesenseParagraphHit[]>([])
const detailParagraphsLoading = ref(false)
const typesenseClient = useTypesenseClient()
const { fetchDocumentDetail } = useDocumentDetailFetch()
let fetchSeq = 0
let fetchController: AbortController | null = null
function isAbortError(err: unknown): boolean {
return (err as { name?: string } | null)?.name === 'AbortError'
}
async function fetchDetail(hit: SearchHit, favoritesCollection: string) {
const config = COLLECTION_CONFIG[favoritesCollection]
const docId = String(hit._id || hit.id || '')
if (!config || !docId) {
detailDocument.value = null
detailParagraphs.value = []
return
}
const seq = ++fetchSeq
fetchController?.abort()
const controller = new AbortController()
fetchController = controller
detailDocumentLoading.value = true
detailParagraphsLoading.value = true
detailDocument.value = null
detailParagraphs.value = []
try {
const detail = await fetchDocumentDetail(typesenseClient, config.main, config.paragraphs, docId, controller.signal)
if (seq !== fetchSeq) return
if (detail) {
const rawParagraphs = detail.paragraphs as unknown as ParagraphDoc[]
detailDocument.value = detail.document as unknown as DocumentDoc
detailParagraphs.value = [...rawParagraphs]
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
.map(p => ({ document: p }))
}
} catch (err) {
if (seq !== fetchSeq) return
if (isAbortError(err)) return
console.error('[usePublicationFetch] Error fetching publication detail', err)
detailDocument.value = null
detailParagraphs.value = []
} finally {
if (seq === fetchSeq) {
detailDocumentLoading.value = false
detailParagraphsLoading.value = false
}
}
}
function clearDetail() {
detailDocument.value = null
detailParagraphs.value = []
}
return {
detailDocument,
detailDocumentLoading,
detailParagraphs,
detailParagraphsLoading,
fetchDetail,
clearDetail,
}
}

View File

@ -1,45 +0,0 @@
let loaded = false
let loading = false
let loadPromise: Promise<void> | null = null
function loadScript(siteKey: string): Promise<void> {
if (loaded) return Promise.resolve()
if (loading && loadPromise) return loadPromise
loading = true
loadPromise = new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`
script.async = true
script.defer = true
script.onload = () => {
loaded = true
loading = false
resolve()
}
script.onerror = () => {
loading = false
reject(new Error('Failed to load reCAPTCHA'))
}
document.head.appendChild(script)
})
return loadPromise
}
export function useRecaptcha(siteKey: string) {
async function executeRecaptcha(action: string = 'submit'): Promise<string> {
await loadScript(siteKey)
return new Promise((resolve, reject) => {
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action })
.then((token: string) => resolve(token))
.catch(reject)
})
})
}
return { executeRecaptcha }
}

View File

@ -1,6 +0,0 @@
import type Client from 'typesense/Typesense/Client'
export function useTypesenseClient(): Client {
const nuxtApp = useNuxtApp()
return nuxtApp.$typesenseClient as Client
}

View File

@ -1,533 +0,0 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useDebounce } from '@vueuse/core'
const DEFAULT_TIMEOUT_MS = 15000
function isAbortError(err: unknown): boolean {
return (err as { name?: string } | null)?.name === 'AbortError'
}
/**
* Boilerplate compartido de cancelación: cancela el intento anterior antes de
* lanzar uno nuevo, arma un timeout que aborta si tarda demasiado, y da un
* guard de secuencia para descartar respuestas de requests ya superados.
* Usado por los dos modos de búsqueda (plano y agrupado) de este archivo.
*/
function createAbortableRunner() {
let seq = 0
let controller: AbortController | null = null
let timeoutId: ReturnType<typeof setTimeout> | null = null
function start(onTimeout: () => void, timeoutMs: number) {
const mySeq = ++seq
controller?.abort()
const myController = new AbortController()
controller = myController
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (mySeq === seq) {
myController.abort()
onTimeout()
}
}, timeoutMs)
return { seq: mySeq, signal: myController.signal }
}
function isCurrent(mySeq: number) {
return mySeq === seq
}
function settle(mySeq: number) {
if (mySeq === seq && timeoutId) clearTimeout(timeoutId)
}
function dispose() {
if (timeoutId) clearTimeout(timeoutId)
controller?.abort()
}
return { start, isCurrent, settle, dispose }
}
export interface TypesenseHighlight {
field?: string
snippet?: string
value?: string
matched_tokens?: string[]
}
// ─── Modo plano: una sola colección, sin agrupar (entrelineas.vue) ───────────
export interface TypesenseFlatHit<TDoc> {
document: TDoc
highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number
}
export interface FlatTypesenseSearchOptions {
collection: string
queryBy: string
/** Reevaluado en cada request (p.ej. depende de `locale.value`). */
filterBy: () => string
includeFields?: string
pageSize: () => number
paginationType: () => 'infinite_scroll' | 'numbered'
initialQuery: string
initialPage: number
timeoutMs?: number
}
export function useFlatTypesenseSearch<TDoc = Record<string, unknown>>(options: FlatTypesenseSearchOptions) {
const typesenseClient = useTypesenseClient()
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const query = ref(options.initialQuery)
const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
const exactSearch = ref(false)
const hits = ref<TypesenseFlatHit<TDoc>[]>([])
const total = ref(0)
const currentPage = ref(1)
const activePage = ref(options.initialPage)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / options.pageSize())))
const hasMore = computed(() =>
options.paginationType() === 'infinite_scroll' ? hits.value.length < total.value : false
)
const runner = createAbortableRunner()
async function runSearch(q: string, page = 1, append = false) {
const { seq, signal } = runner.start(() => {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}, timeoutMs)
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
const isInfinite = options.paginationType() === 'infinite_scroll'
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
try {
const multi = await typesenseClient.multiSearch.perform({
searches: [{
collection: options.collection,
q: exactSearch.value && q ? `"${q}"` : q || '*',
query_by: options.queryBy,
include_fields: options.includeFields ?? '*',
filter_by: options.filterBy(),
per_page: options.pageSize(),
page: typePage,
highlight_full_fields: options.queryBy,
highlight_fields: options.queryBy,
highlight_start_tag: '<mark class="search-match">',
highlight_end_tag: '</mark>'
}]
}, {}, { abortSignal: signal })
if (!runner.isCurrent(seq)) return
const res = (multi?.results?.[0] ?? {}) as { found?: number, hits?: TypesenseFlatHit<TDoc>[] }
const newHits = res?.hits ?? []
hits.value = (append ? [...hits.value, ...newHits] : newHits) as TypesenseFlatHit<TDoc>[]
total.value = res?.found ?? hits.value.length
currentPage.value = typePage
if (!append) activePage.value = page
} catch (err: unknown) {
if (!runner.isCurrent(seq)) return
if (isAbortError(err)) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { hits.value = []; total.value = 0 }
} finally {
if (runner.isCurrent(seq)) {
runner.settle(seq)
loading.value = false
loadingMore.value = false
}
}
}
function loadMore() {
if (options.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(() => runner.dispose())
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)
})
return {
query, debouncedQuery, loading, loadingMore, errorMsg, exactSearch,
hits, total, currentPage, activePage, totalPages, hasMore,
runSearch, loadMore, goToPage, retry
}
}
// ─── Modo agrupado: párrafos + join a la colección principal (SearchPanel.vue) ─
export interface ParagraphDoc {
id?: string
document_id: string
text: string
number: number
locale: string
type: string
}
export interface TypesenseGroupedParagraphHit {
document: ParagraphDoc
highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number
}
export interface TypesenseGroupedHit {
group_key: string[]
hits: TypesenseGroupedParagraphHit[]
}
interface GroupedSearchResponse {
found: number
grouped_hits?: TypesenseGroupedHit[]
hits?: Array<{ document: Record<string, unknown> }>
}
export interface SearchGroup {
docId: string
firstHit: TypesenseGroupedParagraphHit
allHits: TypesenseGroupedParagraphHit[]
}
export interface BrowseItem {
docId: string
meta: CachedDocMeta
}
export interface DisplayGroup {
docId: string
meta: CachedDocMeta | undefined
firstHit: TypesenseGroupedParagraphHit | null
}
export interface GroupedTypesenseSearchOptions {
paragraphsCollection: string
mainCollection: string
groupByField: string
queryBy: string
/** Reevaluado en cada request (p.ej. depende de `locale.value`). */
filterBy: () => string
browseFilterBy?: () => string
/** Cuando devuelve false, se excluyen los documentos `private:=true`. */
isUnlocked: () => boolean
pageSize: () => number
paginationType: () => 'infinite_scroll' | 'numbered'
initialQuery: string
initialPage: number
highlightAffixNumTokens?: number
timeoutMs?: number
}
const META_FIELDS = 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
export function useGroupedTypesenseSearch(options: GroupedTypesenseSearchOptions) {
const typesenseClient = useTypesenseClient()
const docMetaCache = useDocMetaCache()
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const highlightAffixNumTokens = options.highlightAffixNumTokens ?? 15
const query = ref(options.initialQuery)
const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
const exactSearch = ref(false)
const sortMode = ref<'relevance' | 'date'>('relevance')
const groupedHits = ref<SearchGroup[]>([])
const total = ref(0)
const currentPage = ref(1)
const activePage = ref(options.initialPage)
const hasMore = computed(() =>
options.paginationType() === 'infinite_scroll' ? groupedHits.value.length < total.value : false
)
const visibleGroupCount = ref(10)
const visibleGroups = computed(() =>
options.paginationType() === 'infinite_scroll'
? groupedHits.value.slice(0, visibleGroupCount.value)
: groupedHits.value
)
const hasMoreVisible = computed(() =>
options.paginationType() === 'infinite_scroll' &&
visibleGroupCount.value < groupedHits.value.length
)
const browseItems = ref<BrowseItem[]>([])
const browseTotal = ref(0)
const browsePage = ref(1)
const hasMoreBrowse = computed(() =>
options.paginationType() === 'infinite_scroll'
? browseItems.value.length < browseTotal.value
: false
)
const displayGroups = computed((): DisplayGroup[] => {
if (!debouncedQuery.value.trim()) {
return browseItems.value.map(item => ({
docId: item.docId,
meta: item.meta,
firstHit: null
}))
}
return visibleGroups.value.map(g => ({
docId: g.docId,
meta: docMetaCache.get(options.mainCollection, g.docId),
firstHit: g.firstHit
}))
})
const displayTotal = computed(() =>
debouncedQuery.value.trim() ? total.value : browseTotal.value
)
const totalPages = computed(() =>
Math.max(1, Math.ceil(displayTotal.value / options.pageSize()))
)
function cacheParentMeta(newGroups: SearchGroup[]) {
for (const g of newGroups) {
if (!g.docId) continue
const parentMeta = (g.firstHit?.document as unknown as Record<string, unknown>)?.[options.mainCollection] as Partial<CachedDocMeta> | undefined
if (parentMeta) docMetaCache.set(options.mainCollection, g.docId, { id: g.docId, ...parentMeta } as CachedDocMeta)
}
}
function searchFilterBy() {
return options.isUnlocked()
? options.filterBy()
: `${options.filterBy()} && $${options.mainCollection}(private:=false)`
}
function browseFilterBy() {
const base = options.browseFilterBy ? options.browseFilterBy() : options.filterBy()
return options.isUnlocked()
? `${base} && has_paragraphs:=true`
: `${base} && private:=false && has_paragraphs:=true`
}
const runner = createAbortableRunner()
async function runSearch(q: string, page = 1, append = false) {
const { seq, signal } = runner.start(() => {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}, timeoutMs)
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
const isInfinite = options.paginationType() === 'infinite_scroll'
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
try {
const shouldSortByDate = sortMode.value === 'date' && q.trim()
const multi = await typesenseClient.multiSearch.perform({
searches: [{
collection: options.paragraphsCollection,
q: exactSearch.value && q ? `"${q}"` : q || '*',
query_by: options.queryBy,
filter_by: searchFilterBy(),
...(shouldSortByDate ? { sort_by: `$${options.mainCollection}(timestamp:desc)` } : {}),
per_page: options.pageSize(),
page: typePage,
highlight_full_fields: options.queryBy,
highlight_fields: options.queryBy,
highlight_start_tag: '<mark class="search-match">',
highlight_end_tag: '</mark>',
highlight_affix_num_tokens: highlightAffixNumTokens,
group_by: options.groupByField,
include_fields: `*, $${options.mainCollection}(${META_FIELDS})`
}]
}, {}, { abortSignal: signal })
if (!runner.isCurrent(seq)) return
const res = (multi?.results?.[0] ?? {}) as GroupedSearchResponse
const rawGroups = res?.grouped_hits ?? []
const newGroups: SearchGroup[] = rawGroups.map(g => ({
docId: g.group_key[0]!,
firstHit: g.hits[0]!,
allHits: g.hits
}))
cacheParentMeta(newGroups)
if (!runner.isCurrent(seq)) return
groupedHits.value = append ? groupedHits.value.concat(newGroups) : newGroups
total.value = res?.found ?? groupedHits.value.length
currentPage.value = typePage
if (!append) activePage.value = page
} catch (err: unknown) {
if (!runner.isCurrent(seq)) return
if (isAbortError(err)) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { groupedHits.value = []; total.value = 0 }
} finally {
if (runner.isCurrent(seq)) {
runner.settle(seq)
loading.value = false
loadingMore.value = false
}
}
}
async function runBrowse(page = 1, append = false) {
const { seq, signal } = runner.start(() => {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}, timeoutMs)
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
const isInfinite = options.paginationType() === 'infinite_scroll'
const typePage = isInfinite ? (append ? browsePage.value + 1 : 1) : page
console.log("testing" + browseFilterBy())
try {
const multi = await typesenseClient.multiSearch.perform({
searches: [{
collection: options.mainCollection,
q: '*',
query_by: 'title',
filter_by: browseFilterBy(),
sort_by: 'timestamp:desc',
per_page: options.pageSize(),
page: typePage,
include_fields: META_FIELDS
}]
}, {}, { abortSignal: signal })
if (!runner.isCurrent(seq)) return
const result = (multi?.results?.[0] as GroupedSearchResponse | undefined)
const rawHits = result?.hits ?? []
const newItems = rawHits.map((h) => {
const meta = h.document as Partial<CachedDocMeta>
const docId = String(meta.id ?? '')
return { docId, meta: { id: docId, ...meta } as CachedDocMeta }
})
browseItems.value = append ? browseItems.value.concat(newItems) : newItems
browseTotal.value = result?.found ?? browseItems.value.length
browsePage.value = typePage
if (!append) activePage.value = page
} catch (err: unknown) {
if (!runner.isCurrent(seq)) return
if (isAbortError(err)) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { browseItems.value = []; browseTotal.value = 0 }
} finally {
if (runner.isCurrent(seq)) {
runner.settle(seq)
loading.value = false
loadingMore.value = false
}
}
}
function loadMore() {
if (options.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
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(p, false)
} else {
groupedHits.value = []
runSearch(query.value, p, false)
}
}
function retry() {
if (!query.value.trim()) runBrowse(activePage.value, false)
else runSearch(query.value, activePage.value, false)
}
onBeforeUnmount(() => runner.dispose())
watch(debouncedQuery, (q) => {
activePage.value = 1
if (!q.trim()) {
groupedHits.value = []; total.value = 0; currentPage.value = 1; visibleGroupCount.value = 10
browseItems.value = []; browseTotal.value = 0; browsePage.value = 1
runBrowse(1, false)
} else {
browseItems.value = []; browseTotal.value = 0; browsePage.value = 1
groupedHits.value = []; total.value = 0; currentPage.value = 1; visibleGroupCount.value = 10
runSearch(q, 1, false)
}
})
watch(exactSearch, () => {
if (query.value.trim()) runSearch(query.value, 1, false)
})
watch(sortMode, () => {
if (query.value.trim()) {
groupedHits.value = []
total.value = 0
currentPage.value = 1
runSearch(query.value, 1, false)
}
})
return {
query, debouncedQuery, loading, loadingMore, errorMsg,
exactSearch, sortMode,
groupedHits, total, currentPage,
visibleGroupCount, visibleGroups, hasMoreVisible, hasMore,
browseItems, browseTotal, browsePage, hasMoreBrowse,
displayGroups, activePage, displayTotal, totalPages,
runSearch, runBrowse, loadMore, goToPage, retry
}
}

View File

@ -4,13 +4,9 @@ import { storeToRefs } from 'pinia'
import type { NavigationMenuItem } from '@nuxt/ui'
import { useFavoritesStore } from '~/stores/favorites'
import { useHistoryStore } from '~/stores/history'
import { chatPalette } from '#build/ui'
const unlocked = ref(useDevMode())
const { locale, locales, setLocale } = useI18n()
const nuxtApp = useNuxtApp()
const { $i18n } = useNuxtApp();
const t = $i18n.t;
@ -22,133 +18,99 @@ const { total: favTotal } = storeToRefs(favorites)
const history = useHistoryStore()
const { total: histTotal } = storeToRefs(history)
const toCarpa = () => {
window.location.href = `https://carpa.com/${$i18n.locale.value}`;
}
const links = computed(() => {
const links = [
return [
{
id: 'bible-studies',
label: t('nav.bible_studies'),
icon: 'ph-books',
to: '/estudios',
onSelect: () => { open.value = false },
label: $i18n.t('nav.bible_studies'),
icon: 'ph:books',
to: '/actividades',
locale: locale.value,
onSelect: () => { open.value = false }
},
{
id: 'conferences',
label: t('nav.conferences'),
icon: 'ph-books',
label: t("nav.conferences"),
icon: 'ph:books',
to: '/conferencias',
onSelect: () => { open.value = false }
},
{
id: 'betweenthelines',
label: t('nav.between_the_lines'),
icon: 'ph-list-magnifying-glass',
label: t("nav.bible_studies_ts"),
icon: 'i-lucide-database',
to: '/estudios-typensense',
onSelect: () => { open.value = false }
},
{
label: t("nav.conferences_ts"),
icon: 'i-lucide-database',
to: '/conferencias-typensense',
onSelect: () => { open.value = false }
},
{
label: t("nav.between_the_lines"),
icon: 'ph:list-magnifying-glass',
to: '/entrelineas',
onSelect: () => { open.value = false }
},
{
id: 'favorites',
label: t('nav.my_list'),
label: t("nav.my_list"),
icon: 'i-lucide-bookmark',
to: '/favoritos',
badge: favTotal.value > 0 ? String(favTotal.value) : undefined,
onSelect: () => { open.value = false }
},
{
id: 'history',
label: t('nav.history'),
label: t("nav.history"),
icon: 'i-lucide-history',
to: '/historial',
badge: histTotal.value > 0 ? String(histTotal.value) : undefined,
onSelect: () => { open.value = false }
},
{
id: 'changelog',
label: t('nav.changelog'),
icon: 'i-lucide-megaphone',
to: '/changelog',
onSelect: () => { open.value = false }
},
{
id: 'settings',
label: t('nav.settings'),
label: t("nav.settings"),
icon: 'i-lucide-settings',
to: '/configuracion',
onSelect: () => { open.value = false }
},
{
id: 'feedback',
label: t('feedback.title'),
icon: 'i-lucide-bug',
to: '/feedback',
onSelect: () => { open.value = false }
},
{
id: 'wizard',
class: 'hidden sm:flex mt-4 border-t-2 border-gray-300 pt-4',
label: t('nav.tour'),
icon: 'ph-student',
onSelect: () => { doTour() },
chip: {
color: 'error'
}
}
] satisfies NavigationMenuItem[]
function doTour() {
nuxtApp.callHook('tour',0)
}
const homeLink = {
id: 'home',
label: t('nav.home'),
icon: 'ph-house',
to: '/'
}
return [homeLink, ...links]
})
</script>
<template>
<UDashboardGroup unit="rem">
<UDashboardSidebar id="default" v-model:open="open" collapsible resizable
class="bg-elevated/25 bg-gradient-to-tr from-blue-100 to-white" :ui="{ footer: 'lg:border-t lg:border-default' }">
<UDashboardSidebar
id="default"
v-model:open="open"
collapsible
resizable
class="bg-elevated/25 bg-gradient-to-tr from-blue-100 to-white"
:ui="{ footer: 'lg:border-t lg:border-default' }"
>
<template #header="{ collapsed }">
<div v-if="!collapsed" class="mt-2 flex justify-center">
<img v-on:click="toCarpa" src="/logo.svg" class="w-full cursor-pointer" alt="Buscador - La Gran Carpa Catedral" />
<img src="/logo.svg" class="w-full" alt="Buscador - La Gran Carpa Catedral" />
</div>
<img v-if="collapsed" v-on:click="toCarpa" src="/logo_round.svg" class="w-full cursor-pointer" alt="Buscador - La Gran Carpa Catedral" />
</template>
<template #default="{ collapsed }">
<UNavigationMenu :collapsed="collapsed" :items="links" orientation="vertical" tooltip popover color="neutral"
variant="link">
<template #item="{ item }">
<div :id="item.id"
class="group relative w-full flex items-center gap-1.5 font-medium text-sm before:absolute before:z-[-1] before:rounded-md focus:outline-none focus-visible:outline-none dark:focus-visible:outline-none focus-visible:before:ring-inset focus-visible:before:ring-2 focus-visible:before:ring-inverted flex-row">
<UChip :color="item.chip?.color" :show="item.chip?.color ? true : false" inset>
<UIcon :name="item.icon" class="text-2xl" />
</UChip>
<div :class="!collapsed ? 'flex justify-between w-full' : 'hidden'">{{ item.label }}
<UBadge v-if="item.badge" variant="outline" color="neutral" size="sm" :label="item.badge" />
</div>
</div>
</template>
</UNavigationMenu>
<UNavigationMenu
:collapsed="collapsed"
:items="links"
orientation="vertical"
tooltip
popover
/>
</template>
<template #footer="{ collapsed }">
<div id="localeSelector">
<ULocaleSelect :model-value="$i18n.locale.value" :locales="Object.values(locales)"
@update:model-value="setLocale($event)" />
</div>
<ULocaleSelect
:model-value="$i18n.locale.value"
:locales="Object.values(locales)"
@update:model-value="setLocale($event)"
/>
</template>
</UDashboardSidebar>

262
app/pages/actividades.vue Executable file
View File

@ -0,0 +1,262 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
import type { SearchHit } from '~/types'
import InboxActivity from '~/components/inbox/InboxActivity.vue'
import { useSettingsStore } from '~/stores/settings'
const { $i18n } = useNuxtApp();
const t = $i18n.t;
const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore()
// Restaurar estado desde URL antes de crear los refs
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 meili = useMeiliSearchRef()
const hits = ref<SearchHit[]>([])
const total = ref(0)
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
)
let searchSeq = 0
let abortController: AbortController | null = null
async function runSearch(q: string, page = 1, append = false) {
abortController?.abort()
const ac = new AbortController()
abortController = ac
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
const timeoutId = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
const isInfinite = settings.paginationType === 'infinite_scroll'
const offset = isInfinite
? (append ? hits.value.length : 0)
: (page - 1) * settings.pageSize
try {
const res = await meili.index(`activities_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
attributesToRetrieve: ['*'],
showMatchesPosition: true,
limit: settings.pageSize,
offset,
sort: q ? undefined : ['isodate:desc']
}, { signal: ac.signal })
if (seq !== searchSeq) return
const newHits = (res?.hits ?? []) as SearchHit[]
hits.value = append ? hits.value.concat(newHits) : newHits
total.value = res?.estimatedTotalHits ?? hits.value.length
if (!append) activePage.value = page
} catch (err: unknown) {
const name = (err as { name?: string })?.name
if (name === 'AbortError') return
if (seq !== searchSeq) return
console.error('Meilisearch error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) {
hits.value = []
total.value = 0
}
} finally {
clearTimeout(timeoutId)
if (seq === searchSeq) {
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, activePage.value, true)
}
function goToPage(p: number) {
activePage.value = p
hits.value = []
runSearch(query.value, p, false)
}
function retry() {
runSearch(query.value, activePage.value, false)
}
// Selección
const selectedActivity = ref<SearchHit | null>(null)
const isActivityPanelOpen = computed({
get() { return !!selectedActivity.value },
set(value: boolean) { if (!value) selectedActivity.value = null }
})
// ID del item seleccionado para sincronizar con la URL
const selectedId = computed(() => selectedActivity.value?._id?.toString() ?? null)
// Scroll ref expuesto desde InboxList
const inboxListRef = ref<{ listEl: HTMLElement | null } | null>(null)
const listEl = computed(() => inboxListRef.value?.listEl ?? null)
// Sincronización con URL
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listEl })
// Ciclo de vida
onMounted(async () => {
await runSearch(q0, p0, false)
// Restaurar scroll
restoreScrollPosition(listEl.value, s0)
// Restaurar item seleccionado
if (sid0) {
const found = hits.value.find(h => String(h._id) === sid0)
if (found) selectedActivity.value = found
}
})
onBeforeUnmount(() => {
abortController?.abort()
})
// Query debounced: solo dispara cuando el usuario cambia la búsqueda,
// NO en la carga inicial (debouncedQuery ya arranca en q0).
watch(debouncedQuery, (q) => {
hits.value = []
total.value = 0
activePage.value = 1
runSearch(q, 1, false)
})
watch(hits, () => {
if (!selectedActivity.value) return
const stillThere = hits.value.find(h => h._id === selectedActivity.value?._id)
if (!stillThere) selectedActivity.value = null
})
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
useDetailHistory(isActivityPanelOpen, isMobile)
</script>
<template>
<UDashboardPanel
id="activities-list"
:default-size="28"
:min-size="22"
:max-size="40"
resizable
>
<UDashboardNavbar :title="t('nav.bible_studies')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #trailing>
<UBadge :label="total" variant="subtle" />
</template>
</UDashboardNavbar>
<div class="px-4 sm:px-6 py-3 border-b border-default">
<UInput
v-model="query"
icon="i-lucide-search"
:placeholder="t('search.placeholder')"
:loading="loading"
size="md"
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')" />
</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 }]"
/>
<InboxList
ref="inboxListRef"
v-model="selectedActivity"
:activities="hits"
:query="debouncedQuery"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
:show-end-message="settings.paginationType === 'infinite_scroll'"
collection="activities"
@load-more="loadMore"
/>
<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>
</UDashboardPanel>
<InboxActivity
v-if="selectedActivity"
:activity="selectedActivity"
collection="activities"
:query="debouncedQuery"
@close="selectedActivity = null"
/>
<div v-else 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" />
<p class="text-sm">
{{ query ? t('search.listselect') : t('search.emptytext') }}
</p>
</div>
</div>
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isActivityPanelOpen">
<template #content>
<InboxActivity
v-if="selectedActivity"
:activity="selectedActivity"
collection="activities"
:query="debouncedQuery"
@close="selectedActivity = null"
/>
</template>
</USlideover>
</ClientOnly>
</template>

View File

@ -1,58 +0,0 @@
<script setup lang="ts">
const { $i18n } = useNuxtApp()
const t = $i18n.t
</script>
<template>
<UDashboardPanel id="changelog-panel" grow>
<UDashboardNavbar :title="t('nav.changelog')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
<div class="overflow-y-auto flex-1">
<div class="max-w-2xl mx-auto p-6 space-y-10">
<div v-for="release in releases" :key="release.version" class="relative pl-6 border-l-2 border-default">
<!-- Dot en la línea de tiempo -->
<div class="absolute -left-[5px] top-1 w-2 h-2 rounded-full bg-primary" />
<!-- Cabecera del release -->
<div class="mb-3">
<div class="flex items-center gap-2 flex-wrap">
<UBadge color="primary" variant="soft" size="sm">
v{{ release.version }}
</UBadge>
<span class="text-xs text-muted">{{ release.date }}</span>
</div>
<h2 class="text-base font-semibold text-highlighted mt-1">
{{ release.title }}
</h2>
<p v-if="release.description" class="text-sm text-muted mt-0.5">
{{ release.description }}
</p>
</div>
<!-- Lista de cambios -->
<ul class="space-y-2">
<li
v-for="(change, i) in release.changes"
:key="i"
class="flex items-start gap-2 text-sm"
>
<UBadge
:color="typeConfig[change.type].color as any"
variant="subtle"
size="xs"
class="mt-0.5 shrink-0"
>
{{ typeConfig[change.type].label }}
</UBadge>
<span class="text-default">{{ change.text }}</span>
</li>
</ul>
</div>
</div>
</div>
</UDashboardPanel>
</template>

View File

@ -0,0 +1,782 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
import EstudiosTypensenseDetail from '~/components/estudiosTypensense/EstudiosTypensenseDetail.vue'
import { useSettingsStore } from '~/stores/settings'
const PARAGRAPHS_COLLECTION = 'paragraphs'
const DOCUMENTS_COLLECTION = 'documents'
const QUERY_BY = 'text'
const FAVORITES_COLLECTION = 'conferences-ts'
const { $i18n } = useNuxtApp()
const t = $i18n.t
const { locale } = useI18n()
const filterBy = computed(() => `locale:=${locale.value} && type:=conferences`)
const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore()
// Restaurar estado desde URL antes de crear los refs
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)
// ---- Types ----------------------------------------------------------------
interface ParagraphDoc {
id?: string
document_id: string
text: string
number: number
locale: string
type: string
}
interface DocMeta {
id: string
title: string
date?: string
timestamp?: number
place?: string
city?: string
state?: string
country?: string
type?: string
slug?: string
}
export interface DocumentDoc extends DocMeta {
code: string
locale: string
files?: {
youtube?: string
video?: string
audio?: string
booklet?: string
simple?: string
}
body?: string
[key: string]: unknown
}
interface TypesenseHighlight {
field?: string
snippet?: string
value?: string
matched_tokens?: string[]
}
export interface TypesenseParagraphHit {
document: ParagraphDoc
highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number
}
interface TypesenseSearchResponse {
found: number
hits?: TypesenseParagraphHit[]
}
interface BrowseItem {
docId: string
meta: DocMeta
}
interface DisplayGroup {
docId: string
meta: DocMeta | undefined
firstHit: TypesenseParagraphHit | null
}
// ---- State ----------------------------------------------------------------
// Modo búsqueda (con query): hits de párrafos agrupados por documento
const hits = ref<TypesenseParagraphHit[]>([])
const total = ref(0)
const currentPage = ref(1)
const hasMore = computed(() =>
settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false
)
// Progressive display (sólo en scroll infinito)
const visibleGroupCount = ref(10)
const groupedHits = computed(() => {
const map = new Map<string, TypesenseParagraphHit[]>()
for (const hit of hits.value) {
const id = hit.document.document_id
if (!map.has(id)) map.set(id, [])
map.get(id)!.push(hit)
}
return [...map.entries()].map(([docId, docHits]) => ({
docId,
firstHit: docHits[0]!
}))
})
const visibleGroups = computed(() =>
settings.paginationType === 'infinite_scroll'
? groupedHits.value.slice(0, visibleGroupCount.value)
: groupedHits.value
)
const hasMoreVisible = computed(() =>
settings.paginationType === 'infinite_scroll' &&
visibleGroupCount.value < groupedHits.value.length
)
// Modo exploración (sin query): documentos ordenados por fecha
const browseItems = ref<BrowseItem[]>([])
const browseTotal = ref(0)
const browsePage = ref(1)
const hasMoreBrowse = computed(() =>
settings.paginationType === 'infinite_scroll'
? browseItems.value.length < browseTotal.value
: false
)
// Grupo unificado para el template
const displayGroups = computed((): DisplayGroup[] => {
if (!debouncedQuery.value.trim()) {
return browseItems.value.map(item => ({
docId: item.docId,
meta: item.meta,
firstHit: null
}))
}
return visibleGroups.value.map(g => ({
docId: g.docId,
meta: docCache.value[g.docId],
firstHit: g.firstHit
}))
})
// Paginación activa (compartida entre browse y search)
const activePage = ref(p0)
const displayTotal = computed(() =>
debouncedQuery.value.trim() ? total.value : browseTotal.value
)
const totalPages = computed(() =>
Math.max(1, Math.ceil(displayTotal.value / settings.pageSize))
)
// Cache de metadatos por document_id (sólo modo búsqueda)
const docCache = ref<Record<string, DocMeta>>({})
const { documentsApi } = useTypesenseApi()
// ---- Batch fetch de metadatos de documentos -------------------------------
async function fetchDocumentMeta(docIds: string[]) {
const unique = docIds.filter(id => id && !(id in docCache.value))
console.log('Fetching metadata for documents', unique)
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: DOCUMENTS_COLLECTION,
q: '*',
queryBy: 'title',
filterBy: `id:=[${unique.join(',')}]`,
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug',
perPage: unique.length,
page: 1
}]
}
})
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocMeta }> })?.hits ?? []
for (const hit of docHits) {
if (hit.document.id) {
docCache.value[hit.document.id] = hit.document
}
}
} catch (err) {
console.error('Error fetching document metadata', err)
}
}
// ---- Búsqueda de párrafos (con query) -------------------------------------
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: PARAGRAPHS_COLLECTION,
q: q || '*',
queryBy: QUERY_BY,
filterBy: filterBy.value,
perPage: settings.pageSize,
page: typePage,
highlightFullFields: QUERY_BY,
highlightFields: QUERY_BY,
highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>',
snippetThreshold: 100
}]
}
})
if (seq !== searchSeq) return
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
const newHits = res?.hits ?? []
if (!append) docCache.value = {}
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))]
await fetchDocumentMeta(newDocIds)
if (seq !== searchSeq) return
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
}
}
}
// ---- Exploración por fecha (sin query) ------------------------------------
async function runBrowse(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 ? browsePage.value + 1 : 1) : page
try {
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: DOCUMENTS_COLLECTION,
q: '*',
queryBy: 'title',
filterBy: filterBy.value,
sortBy: 'timestamp:desc',
perPage: settings.pageSize,
page: typePage,
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug'
}]
}
})
if (seq !== searchSeq) return
const result = (multi?.results?.[0] as { found?: number, hits?: Array<{ document: DocMeta }> } | undefined)
const newItems = (result?.hits ?? []).map(h => ({
docId: h.document.id!,
meta: h.document
}))
browseItems.value = append ? browseItems.value.concat(newItems) : newItems
browseTotal.value = result?.found ?? browseItems.value.length
browsePage.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) {
browseItems.value = []
browseTotal.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
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(p, false)
} else {
hits.value = []
runSearch(query.value, p, false)
}
}
const listContainer = ref<HTMLElement | null>(null)
function onListScroll() {
if (settings.paginationType !== 'infinite_scroll') return
const el = listContainer.value
if (!el) return
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
if (!debouncedQuery.value.trim()) {
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) {
runBrowse(browsePage.value, true)
}
} else {
if (hasMoreVisible.value) {
visibleGroupCount.value += 10
} else if (hasMore.value && !loadingMore.value && !loading.value) {
loadMore()
}
}
}
}
function retry() {
if (!query.value.trim()) {
runBrowse(activePage.value, false)
} else {
runSearch(query.value, activePage.value, false)
}
}
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
watch(debouncedQuery, (q) => {
activePage.value = 1
if (!q.trim()) {
hits.value = []
total.value = 0
currentPage.value = 1
visibleGroupCount.value = 10
browseItems.value = []
browseTotal.value = 0
browsePage.value = 1
runBrowse(1, false)
} else {
browseItems.value = []
browseTotal.value = 0
browsePage.value = 1
hits.value = []
total.value = 0
currentPage.value = 1
visibleGroupCount.value = 10
runSearch(q, 1, false)
}
})
// ---- Selección y carga del detalle ----------------------------------------
const selectedDocId = ref<string | null>(null)
const selectedDocument = ref<DocumentDoc | null>(null)
const documentLoading = ref(false)
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
const paragraphsLoading = ref(false)
const selectedHit = ref<TypesenseParagraphHit | null>(null)
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
async function fetchFullDocument(docId: string) {
documentLoading.value = true
selectedDocument.value = null
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: DOCUMENTS_COLLECTION,
q: '*',
queryBy: 'title',
filterBy: `id:=${docId}`,
perPage: 1,
page: 1
}]
}
})
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocumentDoc }> })?.hits ?? []
selectedDocument.value = docHits[0]?.document ?? null
} catch (err) {
console.error('Error fetching document', err)
selectedDocument.value = null
} finally {
documentLoading.value = false
}
}
async function fetchDocumentParagraphs(docId: string) {
paragraphsLoading.value = true
selectedParagraphs.value = []
const PER_PAGE = 250
let page = 1
let totalParagraphs = 0
const all: Array<{ document: ParagraphDoc }> = []
try {
do {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: PARAGRAPHS_COLLECTION,
q: '*',
queryBy: '',
filterBy: `document_id:=${docId} && ${filterBy.value}`,
perPage: PER_PAGE,
page,
sortBy: 'number:asc'
}]
}
})
const result = (res?.results?.[0] as { found?: number, hits?: Array<{ document: ParagraphDoc }> } | undefined)
if (!result) break
if (page === 1) totalParagraphs = result.found ?? 0
all.push(...(result.hits ?? []))
page++
} while (all.length < totalParagraphs)
selectedParagraphs.value = all.map(h => ({ document: h.document }))
} catch (err) {
console.error('Error fetching paragraphs', err)
selectedParagraphs.value = []
} finally {
paragraphsLoading.value = false
}
}
async function selectGroup(group: DisplayGroup) {
selectedDocId.value = group.docId
selectedHit.value = group.firstHit
selectedMatchingHits.value = group.firstHit
? hits.value.filter(h => h.document.document_id === group.docId)
: []
fetchFullDocument(group.docId)
fetchDocumentParagraphs(group.docId)
}
const isPanelOpen = computed({
get() { return !!selectedDocId.value },
set(v: boolean) {
if (!v) {
selectedDocId.value = null
selectedDocument.value = null
selectedParagraphs.value = []
selectedHit.value = null
selectedMatchingHits.value = []
}
}
})
watch(groupedHits, () => {
if (!selectedDocId.value || !debouncedQuery.value.trim()) return
const still = groupedHits.value.find(g => g.docId === selectedDocId.value)
if (!still) {
selectedDocId.value = null
selectedDocument.value = null
selectedParagraphs.value = []
selectedHit.value = null
selectedMatchingHits.value = []
}
})
// ID del documento seleccionado para sincronizar con la URL
const selectedId = computed(() => selectedDocId.value)
// Sincronización con URL
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listContainer })
// Ciclo de vida
onMounted(async () => {
// Carga inicial: browse si no hay query, search si hay query
if (q0.trim()) {
await runSearch(q0, p0, false)
} else {
await runBrowse(p0, false)
}
// Restaurar scroll
restoreScrollPosition(listContainer.value, s0)
// Restaurar documento seleccionado
if (sid0) {
const group = displayGroups.value.find(g => g.docId === sid0)
if (group) {
selectGroup(group)
} else {
selectedDocId.value = sid0
fetchFullDocument(sid0)
fetchDocumentParagraphs(sid0)
}
}
})
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
useDetailHistory(isPanelOpen, isMobile)
// ---- Helpers de presentación ----------------------------------------------
function highlightedFor(hit: TypesenseParagraphHit, 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
}
function metaDate(meta: DocMeta | undefined): string {
if (!meta) return ''
const ts = meta.timestamp || (meta.date ? Math.floor(new Date(meta.date).getTime() / 1000) : null)
if (!ts) return meta.date || ''
return formatDate(ts)
}
function metaLocation(meta: DocMeta | undefined): string {
if (!meta) return ''
return formatLocation({
id: meta.id,
date: meta.timestamp ?? 0,
slug: meta.slug ?? '',
type: meta.type ?? '',
place: meta.place ?? '',
city: meta.city ?? '',
state: meta.state ?? '',
country: meta.country ?? '',
thumbnail: ''
})
}
</script>
<template>
<UDashboardPanel
id="conferencias-ts-list"
:default-size="32"
:min-size="24"
:max-size="45"
resizable
>
<UDashboardNavbar :title="t('nav.conferences_ts')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #trailing>
<UBadge :label="displayTotal" variant="subtle" />
</template>
</UDashboardNavbar>
<div class="px-4 sm:px-6 py-3 border-b border-default">
<UInput
v-model="query"
icon="i-lucide-search"
:placeholder="t('search.placeholder')"
:loading="loading"
size="md"
class="w-full"
/>
</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="listContainer" class="overflow-y-auto divide-y divide-default flex-1" @scroll="onListScroll">
<div
v-if="loading && !displayGroups.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="!displayGroups.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="group in displayGroups"
:key="group.docId"
class="bg-white p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
:class="[
selectedDocId === group.docId
? 'border-primary bg-primary/10'
: 'border-transparent hover:border-primary hover:bg-primary/5'
]"
@click="selectGroup(group)"
>
<div class="mb-1">
<p class="text-sm font-semibold line-clamp-2 text-highlighted">
{{ group.meta?.title || group.docId }}
</p>
</div>
<p class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-xs mb-2 text-muted">
<span
v-if="metaDate(group.meta)"
class="flex items-center gap-1"
>
<UIcon name="ph:calendar" class="size-4 text-green-600" />
{{ metaDate(group.meta) }}
</span>
<span
v-if="metaLocation(group.meta)"
class="flex items-center gap-1 truncate"
>
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
<span class="truncate">{{ metaLocation(group.meta) }}</span>
</span>
</p>
<!-- Snippet con highlight: solo en modo búsqueda -->
<div
v-if="group.firstHit"
class="snippet-html text-sm text-dimmed"
v-html="highlightedFor(group.firstHit, 'text') || group.firstHit.document.text"
/>
</div>
<!-- Infinite scroll: cargando más -->
<div
v-if="settings.paginationType === 'infinite_scroll' && loadingMore"
class="flex items-center justify-center gap-2 py-4 text-sm text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
Cargando más...
</div>
<div
v-else-if="settings.paginationType === 'infinite_scroll' && displayGroups.length && !hasMoreBrowse && !hasMoreVisible && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados
</div>
</div>
<!-- Paginación numerada -->
<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="displayTotal"
:items-per-page="settings.pageSize"
size="sm"
@update:page="goToPage"
/>
</div>
</UDashboardPanel>
<!-- Panel de detalle (escritorio) -->
<EstudiosTypensenseDetail
v-if="selectedDocId && !isMobile"
:document="selectedDocument"
:document-loading="documentLoading"
:paragraphs="selectedParagraphs"
:paragraphs-loading="paragraphsLoading"
:collection="FAVORITES_COLLECTION"
:query="debouncedQuery"
:selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits"
@close="isPanelOpen = false"
/>
<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" />
<p class="text-sm">Selecciona una conferencia para ver el detalle</p>
</div>
</div>
<!-- Panel de detalle (móvil) -->
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
<template #content>
<EstudiosTypensenseDetail
v-if="selectedDocId"
:document="selectedDocument"
:document-loading="documentLoading"
:paragraphs="selectedParagraphs"
:paragraphs-loading="paragraphsLoading"
:collection="FAVORITES_COLLECTION"
:query="debouncedQuery"
:selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits"
@close="isPanelOpen = false"
/>
</template>
</USlideover>
</ClientOnly>
</template>
<style scoped>
.snippet-html :deep(p) {
display: inline;
margin: 0;
}
.snippet-html :deep(br) {
display: none;
}
</style>

273
app/pages/conferencias.vue Normal file → Executable file
View File

@ -1,13 +1,266 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
import type { SearchHit } from '~/types'
import InboxActivity from '~/components/inbox/InboxActivity.vue'
import { useSettingsStore } from '~/stores/settings'
const { $i18n } = useNuxtApp();
const t = $i18n.t;
const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore()
// Restore state from URL before creating refs
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 meili = useMeiliSearchRef()
const hits = ref<SearchHit[]>([])
const total = ref(0)
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
)
let searchSeq = 0
let abortController: AbortController | null = null
async function runSearch(q: string, page = 1, append = false) {
abortController?.abort()
const ac = new AbortController()
abortController = ac
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
const timeoutId = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
const isInfinite = settings.paginationType === 'infinite_scroll'
const offset = isInfinite
? (append ? hits.value.length : 0)
: (page - 1) * settings.pageSize
try {
const res = await meili.index(`conferences_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
attributesToRetrieve: ['*'],
showMatchesPosition: true,
limit: settings.pageSize,
offset,
sort: q ? undefined : ['isodate:desc']
}, { signal: ac.signal })
if (seq !== searchSeq) return
const newHits = (res?.hits ?? []) as SearchHit[]
hits.value = append ? hits.value.concat(newHits) : newHits
total.value = res?.estimatedTotalHits ?? hits.value.length
if (!append) activePage.value = page
} catch (err: unknown) {
const name = (err as { name?: string })?.name
if (name === 'AbortError') return
if (seq !== searchSeq) return
console.error('Meilisearch error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) {
hits.value = []
total.value = 0
}
} finally {
clearTimeout(timeoutId)
if (seq === searchSeq) {
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, activePage.value, true)
}
function goToPage(p: number) {
activePage.value = p
hits.value = []
runSearch(query.value, p, false)
}
function retry() {
runSearch(query.value, activePage.value, false)
}
// Selección
const selected = ref<SearchHit | null>(null)
const isPanelOpen = computed({
get() { return !!selected.value },
set(value: boolean) { if (!value) selected.value = null }
})
// ID del item seleccionado para sincronizar con la URL
const selectedId = computed(() => selected.value?._id?.toString() ?? null)
// Scroll ref expuesto desde InboxList
const inboxListRef = ref<{ listEl: HTMLElement | null } | null>(null)
const listEl = computed(() => inboxListRef.value?.listEl ?? null)
// Sincronización con URL
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listEl })
// Ciclo de vida
onMounted(async () => {
await runSearch(q0, p0, false)
// Restaurar scroll
restoreScrollPosition(listEl.value, s0)
// Restaurar item seleccionado
if (sid0) {
const found = hits.value.find(h => String(h._id) === sid0)
if (found) selected.value = found
}
})
onBeforeUnmount(() => {
abortController?.abort()
})
// Query debounced: solo dispara cuando el usuario cambia la búsqueda,
// NO en la carga inicial (debouncedQuery ya arranca en q0).
watch(debouncedQuery, (q) => {
hits.value = []
total.value = 0
activePage.value = 1
runSearch(q, 1, false)
})
watch(hits, () => {
if (!selected.value) return
const stillThere = hits.value.find(h => h._id === selected.value?._id)
if (!stillThere) selected.value = null
})
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
useDetailHistory(isPanelOpen, isMobile)
</script>
<template>
<SearchPanel
paragraphs-collection="conferences_paragraphs"
main-collection="conferences"
group-by-field="conferences_id"
favorites-collection="conferences-ts"
panel-id="conferencias-ts-list"
nav-title-key="nav.conferences_ts"
accent-color="blue"
:empty-detail-text="$t('ui.empty_conferences')"
author="Dr. William Soto Santiago"
<UDashboardPanel
id="conferences-list"
:default-size="28"
:min-size="22"
:max-size="40"
resizable
>
<UDashboardNavbar :title="t('nav.conferences')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #trailing>
<UBadge :label="total" variant="subtle" />
</template>
</UDashboardNavbar>
<div class="px-4 sm:px-6 py-3 border-b border-default">
<UInput
v-model="query"
icon="i-lucide-search"
placeholder='Buscar conferencias... (usa "comillas" para frase exacta)'
:loading="loading"
size="md"
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>
Tip: envuelve en
<code class="px-1 rounded bg-elevated text-toned font-mono">"comillas"</code>
para frase exacta en ese orden.
</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 }]"
/>
<InboxList
ref="inboxListRef"
v-model="selected"
:activities="hits"
:query="debouncedQuery"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
:show-end-message="settings.paginationType === 'infinite_scroll'"
collection="conferences"
@load-more="loadMore"
/>
<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>
</UDashboardPanel>
<InboxActivity
v-if="selected"
:activity="selected"
collection="conferences"
:query="debouncedQuery"
@close="selected = null"
/>
<div v-else 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" />
<p class="text-sm">
{{ query ? 'Selecciona una coincidencia para ver el detalle' : 'Escribe arriba para buscar' }}
</p>
</div>
</div>
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
<template #content>
<InboxActivity
v-if="selected"
:activity="selected"
collection="conferences"
:query="debouncedQuery"
@close="selected = null"
/>
</template>
</USlideover>
</ClientOnly>
</template>

View File

@ -25,19 +25,6 @@ const paginationItems = [
description: t('settings.numbered_desc')
}
]
const { unlocked, unlock, lock } = useDevMode()
const devKeyInput = ref('')
const devKeyError = ref('')
function tryUnlock() {
devKeyError.value = ''
if (unlock(devKeyInput.value)) {
devKeyInput.value = ''
} else {
devKeyError.value = t('settings.dev_wrong_key')
}
}
</script>
<template>
@ -82,37 +69,6 @@ function tryUnlock() {
</div>
<USwitch v-model="showParagraphNumbers" />
</div>
<USeparator />
<!-- Acceso desarrollador -->
<div>
<p class="text-sm font-semibold text-highlighted mb-1">
{{ t('settings.dev_access_title') }}
</p>
<p class="text-xs text-muted mb-4">{{ t('settings.dev_access_desc') }}</p>
<div v-if="!unlocked" class="flex gap-2">
<UInput
v-model="devKeyInput"
type="password"
:placeholder="t('settings.dev_key_placeholder')"
class="flex-1"
@keyup.enter="tryUnlock"
/>
<UButton @click="tryUnlock">
{{ t('settings.dev_unlock') }}
</UButton>
</div>
<div v-else class="flex items-center gap-2">
<UIcon name="i-lucide-lock-open" class="size-5 text-green-500" />
<span class="text-sm text-green-600 font-medium">{{ t('settings.dev_unlocked') }}</span>
<UButton color="neutral" variant="outline" size="sm" @click="lock">
{{ t('settings.dev_lock') }}
</UButton>
</div>
<p v-if="devKeyError" class="text-xs text-red-500 mt-1">{{ devKeyError }}</p>
</div>
</div>
</div>
</UDashboardPanel>

View File

@ -1,32 +1,48 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import { breakpointsTailwind } from '@vueuse/core'
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'
/* -------------------------------------------------------------------------- */
/* CONFIGURACIÓN — lo único que necesitas tocar */
/* -------------------------------------------------------------------------- */
const COLLECTION = 'entrelineas'
const QUERY_BY = 'text'
const INCLUDE_FIELDS = '*'
const EXTRA_FILTER_BY = ''
const FAVORITES_COLLECTION = 'entrelineas'
/* -------------------------------------------------------------------------- */
/* Estado */
/* -------------------------------------------------------------------------- */
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()
// Restaurar estado desde URL antes de crear los refs
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)
interface Study {
id?: number
title?: string
@ -40,30 +56,152 @@ interface EntrelineaDoc {
image?: string
link?: string
locale?: string
description?: string
origin?: string
page?: number | string
text?: string
html?: string
draft?: boolean
studies?: Study[]
[key: string]: unknown
}
const {
query, debouncedQuery, loading, loadingMore, errorMsg, exactSearch,
hits, total, activePage, totalPages, hasMore,
runSearch, loadMore, goToPage, retry
} = useFlatTypesenseSearch<EntrelineaDoc>({
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
)
/* -------------------------------------------------------------------------- */
/* Búsqueda */
/* -------------------------------------------------------------------------- */
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: q || '*',
queryBy: QUERY_BY,
filterBy: () => filterBy.value,
includeFields: INCLUDE_FIELDS,
pageSize: () => settings.pageSize,
paginationType: () => settings.paginationType,
initialQuery: q0,
initialPage: p0
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)
})
/* -------------------------------------------------------------------------- */
/* Selección / panel de detalle */
/* -------------------------------------------------------------------------- */
const selected = ref<EntrelineaDoc | null>(null)
const isPanelOpen = computed({
@ -77,17 +215,23 @@ watch(hits, () => {
if (!stillThere) selected.value = null
})
// ID del item seleccionado para sincronizar con la URL
const selectedId = computed(() => selected.value?.id?.toString() ?? null)
// Contenedor scrollable de la lista
const listEl = ref<HTMLElement | null>(null)
// Sincronización con URL
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listEl })
// Ciclo de vida
onMounted(async () => {
await runSearch(q0, p0, false)
// Restaurar scroll
restoreScrollPosition(listEl.value, s0)
// Restaurar item seleccionado
if (sid0) {
const found = hits.value.find(h => h.document.id === sid0)
if (found) selected.value = found.document
@ -99,6 +243,10 @@ const isMobile = breakpoints.smaller('lg')
useDetailHistory(isPanelOpen, isMobile)
/* -------------------------------------------------------------------------- */
/* Favoritos */
/* -------------------------------------------------------------------------- */
const favorites = useFavoritesStore()
const toast = useToast()
@ -107,7 +255,7 @@ function toSearchHit(doc: EntrelineaDoc): SearchHit {
return {
_id: id,
id,
title: doc.text?.slice(0, 100) || 'Entrelínea',
title: id || 'Entrelínea',
body: doc.text,
...doc
}
@ -125,14 +273,18 @@ function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
favorites.toggle(FAVORITES_COLLECTION, toSearchHit(doc))
toast.add({
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
description: doc.text?.slice(0, 100),
description: doc.id,
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
color: wasFav ? 'neutral' : 'primary',
duration: 1800
})
}
function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): string | null {
/* -------------------------------------------------------------------------- */
/* Helpers de presentación */
/* -------------------------------------------------------------------------- */
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
@ -155,68 +307,21 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #trailing>
<UBadge :label="total" variant="subtle" :ui="{
base: 'total-results'
}" />
<UBadge :label="total" variant="subtle" />
</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"
class="w-full"
/>
<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"
>{{ t('search.word') }}</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"
>{{ t('search.phrase') }}</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>
@ -269,6 +374,22 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
>
<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?.studies?.[0]?.date"
:label="hit.document?.studies?.[0]?.date"
size="sm"
variant="subtle"
color="info"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.page"
:label="`Página ${hit.document?.page}`"
size="sm"
variant="subtle"
color="error"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.filter"
:label="hit.document?.filter"
@ -285,14 +406,6 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
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
@ -306,6 +419,10 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
</UTooltip>
</div>
<div class="text-sm font-semibold tracking-wide truncate mb-2">
{{ (hit.document?.studies?.[0]?.title as string) || hit.document.id || `entrelinea_${index}` }}
</div>
<div
v-if="highlightedFor(hit, 'text') || hit.document.text"
class="snippet-html text-sm text-toned"
@ -313,8 +430,13 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
/>
<USeparator class="my-2"/>
<div class="text-xs text-dimmed">
{{ hit.document?.origin }}
</div>
</div>
<!-- Infinite scroll: cargando más -->
<div
v-if="settings.paginationType === 'infinite_scroll' && hasMore && !loading"
class="p-4 flex justify-center"
@ -338,6 +460,7 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
</div>
</div>
<!-- Paginación numerada -->
<div
v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading"
class="px-4 py-3 border-t border-default flex justify-center shrink-0"
@ -350,10 +473,9 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
@update:page="goToPage"
/>
</div>
</template>
</UDashboardPanel>
<template v-if="unlocked">
<!-- Panel de detalle (escritorio) -->
<EntrelineaDetail
v-if="selected && !isMobile"
:document="selected"
@ -370,6 +492,7 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
</div>
</div>
<!-- Panel de detalle (móvil) -->
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
<template #content>
@ -384,7 +507,6 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
</USlideover>
</ClientOnly>
</template>
</template>
<style scoped>
.snippet-html :deep(p) {

View File

@ -0,0 +1,784 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
import EstudiosTypensenseDetail from '~/components/estudiosTypensense/EstudiosTypensenseDetail.vue'
import { useSettingsStore } from '~/stores/settings'
const PARAGRAPHS_COLLECTION = 'paragraphs'
const DOCUMENTS_COLLECTION = 'documents'
const QUERY_BY = 'text'
const FAVORITES_COLLECTION = 'bible-studies-ts'
const { $i18n } = useNuxtApp()
const t = $i18n.t
const { locale } = useI18n()
const filterBy = computed(() => `locale:=${locale.value} && type:=activities`)
const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore()
// Restaurar estado desde URL antes de crear los refs
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)
// ---- Types ----------------------------------------------------------------
interface ParagraphDoc {
id?: string
document_id: string
text: string
number: number
locale: string
type: string
}
interface DocMeta {
id: string
title: string
date?: string
timestamp?: number
place?: string
city?: string
state?: string
country?: string
type?: string
slug?: string
}
export interface DocumentDoc extends DocMeta {
code: string
locale: string
files?: {
youtube?: string
video?: string
audio?: string
booklet?: string
simple?: string
}
body?: string
[key: string]: unknown
}
interface TypesenseHighlight {
field?: string
snippet?: string
value?: string
matched_tokens?: string[]
}
export interface TypesenseParagraphHit {
document: ParagraphDoc
highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number
}
interface TypesenseSearchResponse {
found: number
hits?: TypesenseParagraphHit[]
}
interface BrowseItem {
docId: string
meta: DocMeta
}
interface DisplayGroup {
docId: string
meta: DocMeta | undefined
firstHit: TypesenseParagraphHit | null
}
// ---- State ----------------------------------------------------------------
// Modo búsqueda (con query): hits de párrafos agrupados por documento
const hits = ref<TypesenseParagraphHit[]>([])
const total = ref(0)
const currentPage = ref(1)
const hasMore = computed(() =>
settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false
)
// Progressive display (sólo en scroll infinito)
const visibleGroupCount = ref(10)
const groupedHits = computed(() => {
const map = new Map<string, TypesenseParagraphHit[]>()
for (const hit of hits.value) {
const id = hit.document.document_id
if (!map.has(id)) map.set(id, [])
map.get(id)!.push(hit)
}
return [...map.entries()].map(([docId, docHits]) => ({
docId,
firstHit: docHits[0]!
}))
})
const visibleGroups = computed(() =>
settings.paginationType === 'infinite_scroll'
? groupedHits.value.slice(0, visibleGroupCount.value)
: groupedHits.value
)
const hasMoreVisible = computed(() =>
settings.paginationType === 'infinite_scroll' &&
visibleGroupCount.value < groupedHits.value.length
)
// Modo exploración (sin query): documentos ordenados por fecha
const browseItems = ref<BrowseItem[]>([])
const browseTotal = ref(0)
const browsePage = ref(1)
const hasMoreBrowse = computed(() =>
settings.paginationType === 'infinite_scroll'
? browseItems.value.length < browseTotal.value
: false
)
// Grupo unificado para el template
const displayGroups = computed((): DisplayGroup[] => {
if (!debouncedQuery.value.trim()) {
return browseItems.value.map(item => ({
docId: item.docId,
meta: item.meta,
firstHit: null
}))
}
return visibleGroups.value.map(g => ({
docId: g.docId,
meta: docCache.value[g.docId],
firstHit: g.firstHit
}))
})
// Paginación activa (compartida entre browse y search)
const activePage = ref(p0)
const displayTotal = computed(() =>
debouncedQuery.value.trim() ? total.value : browseTotal.value
)
const totalPages = computed(() =>
Math.max(1, Math.ceil(displayTotal.value / settings.pageSize))
)
// Cache de metadatos por document_id (sólo modo búsqueda)
const docCache = ref<Record<string, DocMeta>>({})
const { documentsApi } = useTypesenseApi()
// ---- Batch fetch de metadatos de documentos -------------------------------
async function fetchDocumentMeta(docIds: string[]) {
const unique = docIds.filter(id => id && !(id in docCache.value))
console.log('Fetching metadata for documents', unique)
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: DOCUMENTS_COLLECTION,
q: '*',
queryBy: 'title',
filterBy: `id:=[${unique.join(',')}]`,
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug',
perPage: unique.length,
page: 1
}]
}
})
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocMeta }> })?.hits ?? []
for (const hit of docHits) {
if (hit.document.id) {
docCache.value[hit.document.id] = hit.document
}
}
} catch (err) {
console.error('Error fetching document metadata', err)
}
}
// ---- Búsqueda de párrafos (con query) -------------------------------------
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: PARAGRAPHS_COLLECTION,
q: q || '*',
queryBy: QUERY_BY,
filterBy: filterBy.value,
perPage: settings.pageSize,
page: typePage,
highlightFullFields: QUERY_BY,
highlightFields: QUERY_BY,
highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>',
highlightAffixNumTokens: 30
}]
}
})
if (seq !== searchSeq) return
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
const newHits = res?.hits ?? []
if (!append) docCache.value = {}
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))]
await fetchDocumentMeta(newDocIds)
if (seq !== searchSeq) return
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
}
}
}
// ---- Exploración por fecha (sin query) ------------------------------------
async function runBrowse(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 ? browsePage.value + 1 : 1) : page
try {
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: DOCUMENTS_COLLECTION,
q: '*',
queryBy: 'title',
filterBy: filterBy.value,
sortBy: 'timestamp:desc',
perPage: settings.pageSize,
page: typePage,
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug'
}]
}
})
if (seq !== searchSeq) return
const result = (multi?.results?.[0] as { found?: number, hits?: Array<{ document: DocMeta }> } | undefined)
const newItems = (result?.hits ?? []).map(h => ({
docId: h.document.id!,
meta: h.document
}))
browseItems.value = append ? browseItems.value.concat(newItems) : newItems
browseTotal.value = result?.found ?? browseItems.value.length
browsePage.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) {
browseItems.value = []
browseTotal.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
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(p, false)
} else {
hits.value = []
runSearch(query.value, p, false)
}
}
const listContainer = ref<HTMLElement | null>(null)
function onListScroll() {
if (settings.paginationType !== 'infinite_scroll') return
const el = listContainer.value
if (!el) return
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
if (!debouncedQuery.value.trim()) {
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) {
runBrowse(browsePage.value, true)
}
} else {
if (hasMoreVisible.value) {
visibleGroupCount.value += 10
} else if (hasMore.value && !loadingMore.value && !loading.value) {
loadMore()
}
}
}
}
function retry() {
if (!query.value.trim()) {
runBrowse(activePage.value, false)
} else {
runSearch(query.value, activePage.value, false)
}
}
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
watch(debouncedQuery, (q) => {
activePage.value = 1
if (!q.trim()) {
hits.value = []
total.value = 0
currentPage.value = 1
visibleGroupCount.value = 10
browseItems.value = []
browseTotal.value = 0
browsePage.value = 1
runBrowse(1, false)
} else {
browseItems.value = []
browseTotal.value = 0
browsePage.value = 1
hits.value = []
total.value = 0
currentPage.value = 1
visibleGroupCount.value = 10
runSearch(q, 1, false)
}
})
// ---- Selección y carga del detalle ----------------------------------------
const selectedDocId = ref<string | null>(null)
const selectedDocument = ref<DocumentDoc | null>(null)
const documentLoading = ref(false)
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
const paragraphsLoading = ref(false)
const selectedHit = ref<TypesenseParagraphHit | null>(null)
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
async function fetchFullDocument(docId: string) {
documentLoading.value = true
selectedDocument.value = null
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: DOCUMENTS_COLLECTION,
q: '*',
queryBy: 'title',
filterBy: `id:=${docId}`,
perPage: 1,
page: 1
}]
}
})
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocumentDoc }> })?.hits ?? []
selectedDocument.value = docHits[0]?.document ?? null
} catch (err) {
console.error('Error fetching document', err)
selectedDocument.value = null
} finally {
documentLoading.value = false
}
}
async function fetchDocumentParagraphs(docId: string) {
paragraphsLoading.value = true
selectedParagraphs.value = []
const PER_PAGE = 250
let page = 1
let totalParagraphs = 0
const all: Array<{ document: ParagraphDoc }> = []
try {
do {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: PARAGRAPHS_COLLECTION,
q: '*',
queryBy: '',
filterBy: `document_id:=${docId} && ${filterBy.value}`,
perPage: PER_PAGE,
page,
sortBy: 'number:asc'
}]
}
})
const result = (res?.results?.[0] as { found?: number, hits?: Array<{ document: ParagraphDoc }> } | undefined)
if (!result) break
if (page === 1) totalParagraphs = result.found ?? 0
all.push(...(result.hits ?? []))
page++
} while (all.length < totalParagraphs)
selectedParagraphs.value = all.map(h => ({ document: h.document }))
} catch (err) {
console.error('Error fetching paragraphs', err)
selectedParagraphs.value = []
} finally {
paragraphsLoading.value = false
}
}
async function selectGroup(group: DisplayGroup) {
selectedDocId.value = group.docId
selectedHit.value = group.firstHit
selectedMatchingHits.value = group.firstHit
? hits.value.filter(h => h.document.document_id === group.docId)
: []
fetchFullDocument(group.docId)
fetchDocumentParagraphs(group.docId)
}
const isPanelOpen = computed({
get() { return !!selectedDocId.value },
set(v: boolean) {
if (!v) {
selectedDocId.value = null
selectedDocument.value = null
selectedParagraphs.value = []
selectedHit.value = null
selectedMatchingHits.value = []
}
}
})
watch(groupedHits, () => {
if (!selectedDocId.value || !debouncedQuery.value.trim()) return
const still = groupedHits.value.find(g => g.docId === selectedDocId.value)
if (!still) {
selectedDocId.value = null
selectedDocument.value = null
selectedParagraphs.value = []
selectedHit.value = null
selectedMatchingHits.value = []
}
})
// ID del documento seleccionado para sincronizar con la URL
const selectedId = computed(() => selectedDocId.value)
// Sincronización con URL
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listContainer })
// Ciclo de vida
onMounted(async () => {
// Carga inicial: browse si no hay query, search si hay query
if (q0.trim()) {
await runSearch(q0, p0, false)
} else {
await runBrowse(p0, false)
}
// Restaurar scroll
restoreScrollPosition(listContainer.value, s0)
// Restaurar documento seleccionado
if (sid0) {
const group = displayGroups.value.find(g => g.docId === sid0)
if (group) {
selectGroup(group)
} else {
// El documento puede no estar en la lista visible (p.ej. browse con paginación)
// Lo cargamos directamente por ID
selectedDocId.value = sid0
fetchFullDocument(sid0)
fetchDocumentParagraphs(sid0)
}
}
})
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
useDetailHistory(isPanelOpen, isMobile)
// ---- Helpers de presentación ----------------------------------------------
function highlightedFor(hit: TypesenseParagraphHit, 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
}
function metaDate(meta: DocMeta | undefined): string {
if (!meta) return ''
const ts = meta.timestamp || (meta.date ? Math.floor(new Date(meta.date).getTime() / 1000) : null)
if (!ts) return meta.date || ''
return formatDate(ts)
}
function metaLocation(meta: DocMeta | undefined): string {
if (!meta) return ''
return formatLocation({
id: meta.id,
date: meta.timestamp ?? 0,
slug: meta.slug ?? '',
type: meta.type ?? '',
place: meta.place ?? '',
city: meta.city ?? '',
state: meta.state ?? '',
country: meta.country ?? '',
thumbnail: ''
})
}
</script>
<template>
<UDashboardPanel
id="estudios-ts-list"
:default-size="32"
:min-size="24"
:max-size="45"
resizable
>
<UDashboardNavbar :title="t('nav.bible_studies_ts')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #trailing>
<UBadge :label="displayTotal" variant="subtle" />
</template>
</UDashboardNavbar>
<div class="px-4 sm:px-6 py-3 border-b border-default">
<UInput
v-model="query"
icon="i-lucide-search"
:placeholder="t('search.placeholder')"
:loading="loading"
size="md"
class="w-full"
/>
</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="listContainer" class="overflow-y-auto divide-y divide-default flex-1" @scroll="onListScroll">
<div
v-if="loading && !displayGroups.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="!displayGroups.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="group in displayGroups"
:key="group.docId"
class="bg-white p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
:class="[
selectedDocId === group.docId
? 'border-primary bg-primary/10'
: 'border-transparent hover:border-primary hover:bg-primary/5'
]"
@click="selectGroup(group)"
>
<div class="mb-1">
<p class="text-sm font-semibold line-clamp-2 text-highlighted">
{{ group.meta?.title || group.docId }}
</p>
</div>
<p class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-xs mb-2 text-muted">
<span
v-if="metaDate(group.meta)"
class="flex items-center gap-1"
>
<UIcon name="ph:calendar" class="size-4 text-green-600" />
{{ metaDate(group.meta) }}
</span>
<span
v-if="metaLocation(group.meta)"
class="flex items-center gap-1 truncate"
>
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
<span class="truncate">{{ metaLocation(group.meta) }}</span>
</span>
</p>
<!-- Snippet con highlight: solo en modo búsqueda -->
<div
v-if="group.firstHit"
class="snippet-html text-sm text-dimmed"
v-html="highlightedFor(group.firstHit, 'text') || group.firstHit.document.text"
/>
</div>
<!-- Infinite scroll: cargando más -->
<div
v-if="settings.paginationType === 'infinite_scroll' && loadingMore"
class="flex items-center justify-center gap-2 py-4 text-sm text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
Cargando más...
</div>
<div
v-else-if="settings.paginationType === 'infinite_scroll' && displayGroups.length && !hasMoreBrowse && !hasMoreVisible && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados
</div>
</div>
<!-- Paginación numerada -->
<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="displayTotal"
:items-per-page="settings.pageSize"
size="sm"
@update:page="goToPage"
/>
</div>
</UDashboardPanel>
<!-- Panel de detalle (escritorio) -->
<EstudiosTypensenseDetail
v-if="selectedDocId && !isMobile"
:document="selectedDocument"
:document-loading="documentLoading"
:paragraphs="selectedParagraphs"
:paragraphs-loading="paragraphsLoading"
:collection="FAVORITES_COLLECTION"
:query="debouncedQuery"
:selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits"
@close="isPanelOpen = false"
/>
<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" />
<p class="text-sm">Selecciona una actividad para ver el detalle</p>
</div>
</div>
<!-- Panel de detalle (móvil) -->
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
<template #content>
<EstudiosTypensenseDetail
v-if="selectedDocId"
:document="selectedDocument"
:document-loading="documentLoading"
:paragraphs="selectedParagraphs"
:paragraphs-loading="paragraphsLoading"
:collection="FAVORITES_COLLECTION"
:query="debouncedQuery"
:selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits"
@close="isPanelOpen = false"
/>
</template>
</USlideover>
</ClientOnly>
</template>
<style scoped>
.snippet-html :deep(p) {
display: inline;
margin: 0;
}
.snippet-html :deep(br) {
display: none;
}
</style>

View File

@ -1,15 +0,0 @@
<template>
<SearchPanel
paragraphs-collection="activities_paragraphs"
main-collection="activities"
group-by-field="activities_id"
favorites-collection="bible-studies-ts"
panel-id="estudios-ts-list"
nav-title-key="nav.bible_studies_ts"
accent-color="green"
:empty-detail-text="$t('ui.empty_bible_studies')"
:show-draft="true"
author="Dr. José Benjamín Pérez Matos"
:show-bible-study-filter="true"
/>
</template>

View File

@ -5,7 +5,7 @@ import { breakpointsTailwind } from '@vueuse/core'
import type { DropdownMenuItem } from '@nuxt/ui'
import type { SearchHit } from '~/types'
import { useFavoritesStore, type FavoriteItem } from '~/stores/favorites'
import PublicationDetail from '~/components/PublicationDetail.vue'
import InboxActivity from '~/components/inbox/InboxActivity.vue'
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
const favorites = useFavoritesStore()
@ -17,15 +17,6 @@ const toast = useToast()
* Debe coincidir con `FAVORITES_COLLECTION` en `pages/entrelineas.vue`. */
const ENTRELINEAS_COLLECTION = 'entrelineas'
const {
detailDocument,
detailDocumentLoading,
detailParagraphs,
detailParagraphsLoading,
fetchDetail,
clearDetail,
} = usePublicationFetch()
// 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.
@ -125,14 +116,6 @@ watch(favItems, (items) => {
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
watch(selected, (item) => {
if (!item || item.collection === ENTRELINEAS_COLLECTION) {
clearDetail()
return
}
fetchDetail(item.hit, item.collection)
})
// ---- Helpers de fila ---------------------------------------------------
function safeDate(hit: SearchHit) {
@ -494,14 +477,11 @@ const mobileActions = computed<DropdownMenuItem[][]>(() => [[
:collection="selectedCollection"
@close="selected = null"
/>
<!-- Resto (actividades, conferencias) detalle con párrafos de Typesense. -->
<PublicationDetail
<!-- Resto (actividades, conferencias) InboxActivity. -->
<InboxActivity
v-else-if="selected && !isMobile"
:document="detailDocument"
:document-loading="detailDocumentLoading"
:paragraphs="detailParagraphs"
:paragraphs-loading="detailParagraphsLoading"
:collection="selectedCollection!"
:activity="selectedHit!"
:collection="selectedCollection"
@close="selected = null"
/>
<div v-else-if="!selected" class="hidden lg:flex flex-1 items-center justify-center">
@ -522,13 +502,10 @@ const mobileActions = computed<DropdownMenuItem[][]>(() => [[
:collection="selectedCollection"
@close="selected = null"
/>
<PublicationDetail
<InboxActivity
v-else-if="selected"
:document="detailDocument"
:document-loading="detailDocumentLoading"
:paragraphs="detailParagraphs"
:paragraphs-loading="detailParagraphsLoading"
:collection="selectedCollection!"
:activity="selectedHit!"
:collection="selectedCollection"
@close="selected = null"
/>
</template>

View File

@ -1,196 +0,0 @@
<script setup lang="ts">
const { $i18n } = useNuxtApp()
const t = $i18n.t
const message = ref('')
const sending = ref(false)
const sent = ref(false)
const error = ref('')
const cooldown = ref(0)
const honeypot = ref('')
const mountTime = Date.now()
let cooldownTimer: ReturnType<typeof setInterval> | null = null
const {
recaptchaSiteKey: siteKey,
feedbackMaxPerHour: MAX_PER_HOUR,
feedbackMaxPerSession: MAX_PER_SESSION,
feedbackCooldownSec: COOLDOWN_SEC,
feedbackMinSeconds: MIN_SECONDS
} = useRuntimeConfig().public
const RATE_LIMIT_KEY = 'feedback_ratelimit'
const SESSION_LIMIT_KEY = 'feedback_session'
function sanitize(text: string): string {
let clean = text
clean = clean.replace(/<[^>]*>/g, '')
clean = clean.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
clean = clean.replace(/https?:\/\/[^\s]+/gi, '')
clean = clean.replace(/www\.[^\s]+/gi, '')
clean = clean.replace(/javascript\s*:/gi, '')
clean = clean.replace(/\beval\s*\(/gi, '')
clean = clean.replace(/\bon\w+\s*=/gi, '')
clean = clean.replace(/[A-Za-z0-9+/]{50,}={0,2}/g, '')
clean = clean.replace(/'\s*OR\s+\d+\s*=\s*\d+/gi, '')
clean = clean.replace(/'\s*--/g, "'")
clean = clean.replace(/'\s*;\s*DROP\s+TABLE/gi, "'")
clean = clean.replace(/UNION\s+SELECT/gi, '')
clean = clean.replace(/xp_cmdshell/gi, '')
clean = clean.replace(/EXEC\s*\(/gi, '')
clean = clean.replace(/`[^`]*`/g, '')
clean = clean.replace(/\$\(.*?\)/g, '')
clean = clean.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
return clean.slice(0, 500)
}
function getRateLimitCount(): number {
try {
const raw = localStorage.getItem(RATE_LIMIT_KEY)
if (!raw) return 0
const data: { count: number; reset: number } = JSON.parse(raw)
if (Date.now() > data.reset) {
localStorage.removeItem(RATE_LIMIT_KEY)
return 0
}
return data.count
} catch {
return 0
}
}
function incrementRateLimit() {
const raw = localStorage.getItem(RATE_LIMIT_KEY)
const now = Date.now()
let data = { count: 0, reset: now + 3600000 }
if (raw) {
try {
const parsed = JSON.parse(raw)
if (now <= parsed.reset) {
data = parsed
}
} catch { /* usar default */ }
}
data.count++
localStorage.setItem(RATE_LIMIT_KEY, JSON.stringify(data))
}
function getSessionCount(): number {
try {
const raw = sessionStorage.getItem(SESSION_LIMIT_KEY)
return raw ? Number(raw) : 0
} catch {
return 0
}
}
function incrementSession() {
const count = getSessionCount() + 1
sessionStorage.setItem(SESSION_LIMIT_KEY, String(count))
}
function checkBlocked(): string | null {
const elapsed = (Date.now() - mountTime) / 1000
if (elapsed < MIN_SECONDS) return t('feedback.error_cooldown')
const rateCount = getRateLimitCount()
if (rateCount >= MAX_PER_HOUR) return t('feedback.error_rate_limit')
const sessionCount = getSessionCount()
if (sessionCount >= MAX_PER_SESSION) return t('feedback.error_session_limit')
return null
}
function startCooldown() {
cooldown.value = COOLDOWN_SEC
cooldownTimer = setInterval(() => {
cooldown.value--
if (cooldown.value <= 0) {
if (cooldownTimer) clearInterval(cooldownTimer)
cooldown.value = 0
}
}, 1000)
}
const { executeRecaptcha } = useRecaptcha(siteKey)
async function send() {
const body = sanitize(message.value)
if (!body.trim()) return
if (honeypot.value.trim()) return
const blocked = checkBlocked()
if (blocked !== null) {
error.value = blocked
return
}
sending.value = true
error.value = ''
try {
const token = siteKey ? await executeRecaptcha('submit_feedback') : null
await $fetch('/api/feedback', {
method: 'POST',
body: { message: body, recaptchaToken: token }
})
sent.value = true
message.value = ''
incrementRateLimit()
incrementSession()
startCooldown()
} catch (e) {
error.value = t('feedback.error_generic')
} finally {
sending.value = false
}
}
</script>
<template>
<UDashboardPanel id="bugreport-panel" grow>
<UDashboardNavbar :title="t('feedback.title')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
<div class="flex-1 flex items-center justify-center p-6">
<div v-if="sent" class="text-center space-y-3">
<UIcon name="i-lucide-circle-check" class="size-16 text-green-500" />
<p class="text-sm text-muted">{{ t('feedback.success_message') }}</p>
<UButton color="neutral" variant="outline" @click="sent = false">
{{ t('feedback.send_another') }}
</UButton>
</div>
<div v-else class="flex flex-col items-center gap-6 w-full max-w-xl">
<BugReportInput v-model="message" />
<input
v-model="honeypot"
type="text"
tabindex="-1"
autocomplete="off"
class="absolute -left-[9999px] -top-[9999px] opacity-0 pointer-events-none size-0"
aria-hidden="true"
/>
<UButton
:loading="sending"
:disabled="!message.trim() || cooldown > 0"
size="lg"
class="max-w-xl"
@click="send"
>
{{ sending ? t('feedback.sending') : cooldown > 0 ? t('feedback.wait_seconds', { seconds: cooldown }) : t('feedback.send') }}
</UButton>
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
</div>
</div>
</UDashboardPanel>
</template>

View File

@ -5,7 +5,7 @@ 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 PublicationDetail from '~/components/PublicationDetail.vue'
import InboxActivity from '~/components/inbox/InboxActivity.vue'
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
/**
@ -20,7 +20,7 @@ import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
* - 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 (`PublicationDetail.vue` y
* - El registro lo hace el detalle al abrirse (ver `InboxActivity.vue` y
* `EntrelineaDetail.vue` ambos llaman a `history.visit(...)`).
*/
@ -30,28 +30,14 @@ const toast = useToast()
const ENTRELINEAS_COLLECTION = 'entrelineas'
const { t } = useI18n()
const COLLECTION_LABELS: Record<string, string> = {
activities: 'Actividades',
conferences: 'Conferencias',
entrelineas: 'Entre Líneas'
}
function labelFor(c: string): string {
const labels: Record<string, string> = {
activities: t('nav.bible_studies_ts'),
'bible-studies-ts': t('nav.bible_studies_ts'),
conferences: t('nav.conferences_ts'),
'conferences-ts': t('nav.conferences_ts'),
entrelineas: t('nav.between_the_lines'),
}
return labels[c] || c.charAt(0).toUpperCase() + c.slice(1)
}
const COLLECTION_AUTHORS: Record<string, string> = {
'bible-studies-ts': 'Dr. José Benjamín Pérez Matos',
activities: 'Dr. José Benjamín Pérez Matos',
'conferences-ts': 'Dr. William Soto Santiago',
conferences: 'Dr. William Soto Santiago',
}
function authorFor(c: string): string {
return COLLECTION_AUTHORS[c] || ''
return COLLECTION_LABELS[c] || c.charAt(0).toUpperCase() + c.slice(1)
}
// Filtros: pestaña por colección o "todos".
@ -71,9 +57,9 @@ const tabs = computed(() => {
items.push({
value: c,
label: `${labelFor(c)} (${count})`,
icon: (c === 'activities' || c === 'bible-studies-ts')
icon: c === 'activities'
? 'i-lucide-calendar-days'
: (c === 'conferences' || c === 'conferences-ts')
: c === 'conferences'
? 'i-lucide-mic'
: c === 'entrelineas'
? 'i-lucide-book-open'
@ -106,7 +92,6 @@ const selected = ref<HistoryItem | null>(null)
const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null)
const selectedCollection = computed<string | undefined>(() => selected.value?.collection)
const selectedAuthor = computed(() => authorFor(selectedCollection.value ?? ''))
const isEntrelinea = computed(() => selectedCollection.value === ENTRELINEAS_COLLECTION)
@ -122,23 +107,6 @@ const isPanelOpen = computed({
set(v: boolean) { if (!v) selected.value = null }
})
const {
detailDocument,
detailDocumentLoading,
detailParagraphs,
detailParagraphsLoading,
fetchDetail,
clearDetail,
} = usePublicationFetch()
watch(selected, (item) => {
if (!item || item.collection === ENTRELINEAS_COLLECTION) {
clearDetail()
return
}
fetchDetail(item.hit, item.collection)
})
// Si el item seleccionado desaparece del historial (eliminado desde otra
// pestaña o desde aquí mismo), cerramos el panel de detalle.
watch(histItems, (items) => {
@ -542,9 +510,7 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
/>
</div>
<div class="text-sm font-semibold line-clamp-2">
{{ it.collection === ENTRELINEAS_COLLECTION
? (it.hit?.title || 'Sin título')
: (it.hit?.origin || it.hit?.title || 'Sin título') }}
{{ it.hit?.origin || it.hit?.title || 'Sin título' }}
</div>
</div>
<UButton
@ -567,13 +533,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
<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>
<template v-if="authorFor(it.collection)">
<USeparator orientation="vertical" class="h-3 hidden sm:block" />
<span class="inline-flex items-center gap-1 italic truncate">
<UIcon name="ph:user-circle" class="size-3 shrink-0" />
{{ authorFor(it.collection) }}
</span>
</template>
</p>
</div>
</div>
@ -585,19 +544,13 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
v-if="selected && !isMobile && isEntrelinea"
:document="selectedEntrelineaDoc!"
:collection="selectedCollection"
no-track-visit
@close="selected = null"
/>
<!-- Resto (actividades, conferencias) detalle completo con párrafos. -->
<PublicationDetail
<!-- Resto (actividades, conferencias) InboxActivity. -->
<InboxActivity
v-else-if="selected && !isMobile"
:document="detailDocument"
:document-loading="detailDocumentLoading"
:paragraphs="detailParagraphs"
:paragraphs-loading="detailParagraphsLoading"
:collection="selectedCollection!"
:author="selectedAuthor"
no-track-visit
:activity="selectedHit!"
:collection="selectedCollection"
@close="selected = null"
/>
<div v-else-if="!selected" class="hidden lg:flex flex-1 items-center justify-center">
@ -616,18 +569,12 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
v-if="selected && isEntrelinea"
:document="selectedEntrelineaDoc!"
:collection="selectedCollection"
no-track-visit
@close="selected = null"
/>
<PublicationDetail
<InboxActivity
v-else-if="selected"
:document="detailDocument"
:document-loading="detailDocumentLoading"
:paragraphs="detailParagraphs"
:paragraphs-loading="detailParagraphsLoading"
:collection="selectedCollection!"
:author="selectedAuthor"
no-track-visit
:activity="selectedHit!"
:collection="selectedCollection"
@close="selected = null"
/>
</template>

View File

@ -1,101 +1,10 @@
<script setup lang="ts">
// Home redirects straight to the search experience.
import type { ButtonProps } from '@nuxt/ui'
const nuxtApp = useNuxtApp()
const { $i18n } = useNuxtApp()
const t = $i18n.t
const release = releases[0]
const links = ref<ButtonProps[]>([
{
label: t('nav.tour'),
icon: 'ph-student',
color: 'error',
class: 'hidden sm:flex',
onClick: () => nuxtApp.callHook('tour',0)
},
{
label: t('nav.bible_studies'),
to: `/${$i18n.locale.value}/estudios`,
icon: 'ph-books',
color: 'primary'
},
{
label: t('nav.conferences'),
to: `/${$i18n.locale.value}/conferencias`,
icon: 'ph-books',
color: 'secondary'
},
])
definePageMeta({
middleware: () => navigateTo('/actividades', { redirectCode: 302 })
})
</script>
<template>
<UDashboardPanel id="changelog-panel" grow>
<UDashboardNavbar :title="t('nav.home')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
<div class="flex-1 overflow-y-auto">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<div class="flex flex-col lg:flex-row lg:items-start gap-8 lg:gap-12">
<!-- Hero: título, descripción y accesos -->
<div class="flex-1 flex flex-col gap-6">
<UBadge variant="subtle" color="neutral" size="sm" class="w-fit">
{{ release?.date }}
</UBadge>
<div>
<h1 class="text-3xl sm:text-4xl font-bold text-highlighted tracking-tight">
{{ $t('nav.search_title') }}
</h1>
<p class="mt-3 text-base text-muted leading-relaxed">
{{ $t('home.instructions') }}
</p>
</div>
<div class="flex flex-col sm:flex-row flex-wrap gap-3">
<UButton
v-for="link in links"
:key="link.label"
v-bind="link"
/>
</div>
</div>
<!-- Changelog card -->
<div id="index_changelog" class="w-full lg:w-80 xl:w-96 shrink-0">
<UCard variant="soft">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="ph-git-branch" class="size-4 text-muted shrink-0" />
<div class="flex-1 min-w-0">
<p class="font-semibold text-highlighted text-sm truncate">{{ release?.title }}</p>
<p v-if="release?.description" class="text-xs text-muted truncate">{{ release?.description }}</p>
</div>
<UBadge variant="outline" color="neutral" size="xs" class="shrink-0">
v{{ release?.version }}
</UBadge>
</div>
</template>
<ul class="space-y-2">
<li v-for="(change, i) in release?.changes" :key="i" class="flex items-start gap-2 text-sm">
<UBadge :color="typeConfig[change.type].color as any" variant="subtle" size="xs" class="mt-0.5 shrink-0">
{{ typeConfig[change.type].label }}
</UBadge>
<span class="text-default leading-snug">{{ change.text }}</span>
</li>
</ul>
</UCard>
</div>
</div>
</div>
</div>
</UDashboardPanel>
<div />
</template>

View File

@ -1,24 +0,0 @@
import Typesense from 'typesense'
export default defineNuxtPlugin({
name: 'typesense-client',
setup() {
const config = useRuntimeConfig()
const nodes = JSON.parse(config.public.typeSenseNodes as string)
const apiKey = config.public.typeSenseApiKey as string
const client = new Typesense.Client({
nodes,
apiKey,
numRetries: 3,
retryIntervalSeconds: 0.5,
healthcheckIntervalSeconds: 30
})
return {
provide: {
typesenseClient: client
}
}
}
})

View File

@ -118,11 +118,10 @@ export const useHistoryStore = defineStore('history', () => {
// 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)
}
// Única vía de persistencia: cualquier mutación de `items.value` (incluida
// la de `commit`) se guarda aquí. No duplicar con un `writeStorage` extra
// en `commit`, o cada visita serializa y escribe el historial dos veces.
// Red de seguridad: cualquier mutación directa de `items.value` se persiste.
if (typeof window !== 'undefined') {
watch(items, (next) => {
if (!hydrated) return

10
app/types/index.d.ts vendored
View File

@ -55,16 +55,6 @@ export interface Member {
avatar: AvatarProps
}
declare global {
interface Window {
grecaptcha: {
ready: (callback: () => void) => void
execute: (siteKey: string, options: { action: string }) => Promise<string>
render: (container: string | HTMLElement, options: Record<string, unknown>) => void
}
}
}
export interface Notification {
id: number
unread?: boolean

View File

@ -1,158 +0,0 @@
export const typeConfig: Record<ChangeEntry['type'], { label: string; color: string }> = {
nuevo: { label: 'Nuevo', color: 'success' },
mejora: { label: 'Mejora', color: 'info' },
fix: { label: 'Fix', color: 'warning' },
eliminado: { label: 'Eliminado', color: 'error' }
}
export interface ChangeEntry {
type: 'nuevo' | 'mejora' | 'fix' | 'eliminado'
text: string
}
export interface Release {
version: string
date: string
title: string
description?: string
changes: ChangeEntry[]
}
export const releases: Release[] = [
{
version: '0.9',
date: '20 de julio, 2026',
title: 'Filtro por N° de Estudio',
changes: [
{ type: 'nuevo', text: 'Nuevo filtro por número de Estudio en la sección de Estudios: ingresá el número y se agrega como chip para buscar dentro de ese estudio' },
{ type: 'nuevo', text: 'Se pueden agregar varios estudios a la vez, cada uno como un chip individual' },
{ type: 'nuevo', text: 'Los chips con los filtros activos se muestran siempre visibles debajo de la barra de búsqueda' },
{ type: 'mejora', text: 'En escritorio los filtros se agrupan en una sección colapsable para no ocupar espacio innecesario' },
{ type: 'mejora', text: 'En móvil los filtros se configuran desde un panel deslizante para aprovechar mejor la pantalla' }
]
},
{
version: '0.8',
date: '5 de junio, 2026',
title: 'Autoría visible en Estudios Bíblicos, Conferencias e Historial',
changes: [
{ type: 'nuevo', text: 'Nombre del autor visible en el panel lateral de Estudios Bíblicos (Dr. José Benjamín Pérez Matos)' },
{ type: 'nuevo', text: 'Nombre del autor visible en el panel lateral de Conferencias (Dr. William Soto Santiago)' },
{ type: 'nuevo', text: 'Nombre del autor visible en el panel de detalle del documento para ambas secciones' },
{ type: 'nuevo', text: 'El historial muestra el nombre del autor en cada entrada de Estudios Bíblicos y Conferencias' },
{ type: 'nuevo', text: 'Se agregó al momento de hacer una búsqueda un desplegable con los resultados "Más recientes" y "Normal"' },
{ type: 'mejora', text: 'El panel de detalle abierto desde el historial también muestra el autor correspondiente' },
{ type: 'fix', text: 'Las colecciones en el historial ahora muestran "Estudios Bíblicos" y "Conferencias" en lugar de los identificadores internos' },
{ type: 'fix', text: 'Corrección de etiquetas en inglés para las pestañas del historial' }
]
},
{
version: '0.7',
date: '31 de mayo, 2026 11:50PM',
title: 'Tour y optimizaciones',
changes: [
{ type: 'nuevo', text: 'Agregado tour virtual con localización para explicar funcionamiento del buscador.'},
{ type: 'nuevo', text: '_Agregada página de inicio que muestra la versión más reciente del changelog.' },
{ type: 'mejora', text: 'Separación de changelog a un TS aparte, para utilizar en changelog y en el home sin duplicación de código.'},
{ type: 'nuevo', text: 'Finalizado flow de automatizacion entre backend y typesense'}
]
},
{
version: '0.6',
date: '31 de mayo, 2026',
title: 'Feedback con traducciones, bloqueo de Entrelíneas y acceso desarrollador',
changes: [
{ type: 'nuevo', text: 'Página de Feedback con traducciones completas en 4 idiomas' },
{ type: 'nuevo', text: 'Sistema de bloqueo por clave de desarrollador para secciones en desarrollo' },
{ type: 'nuevo', text: 'Acceso de desarrollador en Configuración con desbloqueo por clave' },
{ type: 'nuevo', text: 'Banner visual mejorado para Entrelíneas cuando está bloqueado' },
{ type: 'mejora', text: 'Traducciones añadidas al componente BugReportInput' },
{ type: 'mejora', text: 'Nombre del tab Feedback ahora usa traducciones' },
{ type: 'mejora', text: 'Textos del banner de Entrelíneas traducidos a 4 idiomas' }
]
},
{
version: '0.5',
date: '30 de mayo, 2026',
title: 'Soporte de HTML en Entrelíneas',
description: 'El visor de Entrelíneas ahora renderiza contenido en HTML además de texto plano.',
changes: [
{ type: 'nuevo', text: 'Renderizado de contenido HTML en el detalle de Entrelíneas' },
{ type: 'mejora', text: 'Los fragmentos HTML se muestran con formato original preservado' },
{ type: 'fix', text: 'Corrección de errores de sintaxis en el componente de detalle' },
{ type: 'mejora', text: 'El historial ahora muestra correctamente documentos de Entrelíneas con HTML' }
]
},
{
version: '0.4',
date: '26 de mayo, 2026',
title: 'Mejoras de interfaz y exploracion',
changes: [
{ type: 'mejora', text: 'El código del documento aparece visible en la pantalla de detalle' },
{ type: 'mejora', text: 'El badge "Borrador" solo se muestra cuando el documento es borrador' },
{ type: 'mejora', text: 'Tooltip mejorado al copiar párrafos al portapapeles' },
{ type: 'mejora', text: 'El explorador filtra resultados mostrando solo ítems con párrafos' },
{ type: 'mejora', text: 'Panel lateral preparado para notas y resaltados (próximamente)' },
{ type: 'fix', text: 'Posicionamiento del panel lateral corregido en documentos cortos' },
{ type: 'fix', text: 'Corrección de traducciones en francés' },
{ type: 'fix', text: 'Ajustes visuales en múltiples vistas' }
]
},
{
version: '0.3',
date: '25 de mayo, 2026',
title: 'Portapapeles y corrección de bugs',
changes: [
{ type: 'nuevo', text: 'Panel de copiado mejorado al seleccionar párrafos' },
{ type: 'mejora', text: 'Función de copiar al portapapeles más robusta e independiente' },
{ type: 'fix', text: 'Corrección de bug al guardar y mostrar favoritos' },
{ type: 'fix', text: 'Corrección de bug en la carga de referencias' },
{ type: 'fix', text: 'Corrección de bug en el detalle de publicaciones de referencia' }
]
},
{
version: '0.2',
date: '2124 de mayo, 2026',
title: 'Conferencias, copiar párrafos y estabilidad',
changes: [
{ type: 'nuevo', text: 'Soporte de documentos de Conferencias y Actividades' },
{ type: 'nuevo', text: 'Clic en párrafo para copiarlo directamente al portapapeles' },
{ type: 'nuevo', text: 'Zoom de imagen en el visor de Entrelíneas' },
{ type: 'mejora', text: 'Número de párrafo más discreto en el detalle del documento' },
{ type: 'mejora', text: 'Publicación del documento visible en todas las vistas y traducida' },
{ type: 'mejora', text: 'Corrección de estilos visuales para contenido antiguo' },
{ type: 'fix', text: 'Corrección de bug en favoritos (detalle)' },
{ type: 'fix', text: 'Badge "Borrador" agregado en lista y detalle de Estudios Bíblicos' },
{ type: 'fix', text: 'Rutas de navegación migradas correctamente a nuevo motor de búsqueda' },
{ type: 'fix', text: 'Corrección de scroll infinito en móvil para Entrelíneas' }
]
},
{
version: '0.1',
date: '1012 de mayo, 2026',
title: 'Historial, Favoritos y multilengua',
description: 'Primera versión funcional con las secciones principales activas.',
changes: [
{ type: 'nuevo', text: 'Historial de búsqueda y exploración' },
{ type: 'nuevo', text: 'Favoritos: guarda y accede a documentos desde Mi Listado' },
{ type: 'nuevo', text: 'Selector de idioma: Español, English, Français, Português' },
{ type: 'nuevo', text: 'Cantidad aproximada de resultados visible en los listados' },
{ type: 'mejora', text: 'Paginación configurable: scroll infinito o páginas numeradas' },
{ type: 'mejora', text: 'Ajuste de cantidad de resultados por página en Configuración' },
{ type: 'fix', text: 'Corrección de traducciones en el menú principal' },
{ type: 'fix', text: 'Ajustes de colores y badges en listas de resultados' }
]
},
{
version: '0.0.1',
date: '7 de mayo, 2026',
title: 'Lanzamiento inicial',
description: 'Primera versión del buscador con Estudios Bíblicos y Entrelíneas.',
changes: [
{ type: 'nuevo', text: 'Búsqueda en Estudios Bíblicos' },
{ type: 'nuevo', text: 'Visor de Entrelíneas con imagen y texto' },
{ type: 'nuevo', text: 'Búsqueda por palabras o por frase exacta' },
{ type: 'nuevo', text: 'Estructura base de la aplicación' }
]
}
]

View File

@ -1,31 +1,30 @@
import dayjs from 'dayjs'
import dayjs from "dayjs";
export interface ItemObject {
id: string
date: number
timestamp: number
slug: string
type: string
place: string
city: string
state: string
country: string
thumbnail: string
id: string;
date: number;
slug: string;
type: string;
place: string;
city: string;
state: string;
country: string;
thumbnail: string;
}
export interface FilesObject {
youtube?: string
video?: string
audio?: string
booklet?: string
simple?: string
youtube?: string;
video?: string;
audio?: string;
booklet?: string;
simple?: string;
}
export function formatFiles( files:FilesObject ){
const { $i18n } = useNuxtApp()
const t = $i18n.t
const { $i18n } = useNuxtApp();
const t = $i18n.t;
const items = []
let items = []
if (files) {
if (files.youtube) {
items.push({
@ -33,62 +32,41 @@ export function formatFiles(files: FilesObject) {
target: '_blank',
label: 'Youtube',
icon: 'ph:youtube-logo-thin',
labelClass: 'text-xs'
labelClass: 'text-xs',
})
}
if (files.video) {
items.push({
to: files.video,
target: '_blank',
label: t('downloads.video'),
label: t('Activities.video'),
icon: 'ph:file-video-thin',
labelClass: 'text-xs'
})
}
if (files.audio) {
let fileUrl = ''
if (files.audio.startsWith('http')) {
fileUrl = files.audio
} else {
fileUrl = `https://actividadeswp.carpa.com/wp-content/uploads/${files.audio}`
}
items.push({
to: fileUrl,
to: files.audio,
target: '_blank',
label: t('downloads.audio'),
label: t('Activities.audio'),
icon: 'ph:file-audio-thin',
labelClass: 'text-xs'
})
}
if (files.booklet) {
let fileUrl = ''
if (files.booklet.startsWith('http')) {
fileUrl = files.booklet
} else {
fileUrl = `https://actividadeswp.carpa.com/wp-content/uploads/${files.booklet}`
}
items.push({
to: fileUrl,
to: files.booklet,
target: '_blank',
label: t('downloads.book'),
label: t('Activities.book'),
icon: 'ph:notebook-thin',
labelClass: 'text-xs'
})
}
if (files.simple) {
let fileUrl = ''
if (files.simple.startsWith('http')) {
fileUrl = files.simple
} else {
fileUrl = `https://actividadeswp.carpa.com/wp-content/uploads/${files.simple}`
}
items.push({
to: fileUrl,
to: files.simple,
target: '_blank',
label: t('downloads.simple'),
label: t('Activities.simple'),
icon: 'ph:note-thin',
labelClass: 'text-xs'
})
@ -98,103 +76,76 @@ export function formatFiles(files: FilesObject) {
}
export function formatDate( d:number ){
if (!d) {
return
}
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
let date = new Date(d * 1000)
const date = new Date(d * 1000)
const dateString = date.toLocaleString(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
timeZone: 'UTC'
})
return capitalizeFirstLetter(dateString)
let dateString = date.toLocaleString(locale.value, { year: 'numeric', month:'long', day: 'numeric', weekday:'long', timeZone:'UTC' })
return capitalizeFirstLetter( dateString );
}
export function formatLocation( i: ItemObject){
if (!i) {
return
}
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const regionNames = new Intl.DisplayNames([locale.value], { type: 'region' })
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
const regionNames = new Intl.DisplayNames(
[ locale.value], {type: 'region'}
);
const locationParts = []
locationParts.push(i?.place)
locationParts.push(i?.city)
locationParts.push(i?.state)
var locationParts = [];
locationParts.push(i?.place);
locationParts.push(i?.city);
locationParts.push(i?.state);
if( i.country ){
locationParts.push(regionNames.of(i.country))
locationParts.push(regionNames.of(i.country));
}
return locationParts.filter(Boolean).join(', ')
}
export function formatSignature(i: ItemObject) {
const date = formatDate(i?.timestamp)
const location = formatLocation(i)
if (date === undefined || location === undefined) {
return
}
const val = `${date} - ${location}`
if (!val) {
return
}
return val
return locationParts.filter(Boolean).join(', ');
}
export function getDay( d:number ){
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const date = new Date(d * 1000)
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
return date.toLocaleString(locale.value, {
weekday: 'long',
timeZone: 'utc'
})
return date.toLocaleString(locale.value, { weekday: 'long', timeZone: 'utc' });
}
export function getDayDate( d:number ){
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const date = new Date(d * 1000)
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
return date.toLocaleString(locale.value, { day: 'numeric', timeZone: 'utc' })
return date.toLocaleString(locale.value, { day: 'numeric', timeZone: 'utc' });
}
export function getMonth( d:number ){
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const date = new Date(d * 1000)
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
return date.toLocaleString(locale.value, { month: 'long', timeZone: 'utc' })
return date.toLocaleString(locale.value, { month: 'long', timeZone: 'utc' });
}
export function setUrl( item:ItemObject, isSearch:boolean ) {
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const date = dayjs(item.date * 1000)
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
const date = dayjs(item.date*1000);
const month = (date.month()+1).toString()
const slug = item.slug || item.slug === 'undefined' ? item.slug : item.id
const slug = (item.slug || item.slug === 'undefined' ? item.slug : item.id);
if( isSearch ){
return false
return false;
} else {
return `/${locale.value}/${item.type}/${date.year()}/${month.padStart(2, '0')}/${slug}`
return `/${locale.value}/${item.type}/${date.year()}/${month.padStart(2, '0')}/${slug}`;
}
}
export function getThumbnail( item:ItemObject ) {
const path = item.thumbnail
console.log("ITEM",item);
let path = item.thumbnail
if (!path) {
return 'https://images.carpa.com/tr:w-900,f-auto/youtube_thumbnail_46396.png'
return "https://images.carpa.com/tr:w-900,f-auto/youtube_thumbnail_46396.png"
} else {
return `https://images.carpa.com/${item.type}/${path}?tr=w-900`
}
@ -203,14 +154,14 @@ export function getThumbnail(item: ItemObject) {
export function getAuthor( type:string ){
let author = ''
switch (type){
case 'activities':
case "activities":
author = 'Dr. José Benjamín Pérez Matos'
break
case 'conferences':
case "conferences":
author = 'Dr. William Soto Santiago'
break
case 'sermons':
author = 'William Marrion Branham'
case "sermons":
author = 'William Marrion Branham';
break
}
return author

View File

@ -1,52 +0,0 @@
/* Copy to Clipboard */
export async function copyToClipboard(doc: ItemObject) {
const toast = useToast()
const selection = window.getSelection()
const container = document.createElement('div')
const range = selection?.getRangeAt(0)
container.appendChild(range.cloneContents())
const htmlOutput = container.innerHTML + '<br/>' + doc.title + '<br/>' + formatSignature(doc)
const textOutput = selection?.toString() + '\n\n' + doc.title + '\n' + formatSignature(doc)
try {
// Modern Clipboard API requires ClipboardItem
const clipboardItem = new ClipboardItem({
'text/html': new Blob([htmlOutput], { type: 'text/html' }),
'text/plain': new Blob([textOutput], { type: 'text/plain' })
})
await navigator.clipboard.write([clipboardItem])
toast.add({
title: 'Texto copiado!',
description: `${truncateAtWord(textOutput, 150)}`,
icon: 'ph-clipboard-text',
color: doc.type == 'activities' ? 'success' : 'info'
})
} catch (err) {
console.error('Failed to copy: ', err)
}
}
const truncateAtWord = (str: string, limit: number) => {
if (str.length <= limit) return str
const subString = str.slice(0, limit)
return subString.slice(0, subString.lastIndexOf('')) + '...'
}
export function debugDocument(json: []) {
let data = ''
// Iterate through keys and values
for (const key in json) {
if (json.hasOwnProperty(key)) {
data += `${key}: ${json[key]}\n\r`
}
}
return data
}
export function addNote() {
return 'yay'
}

View File

@ -1,15 +0,0 @@
meta {
name: Listar todas las collections
type: http
seq: 1
}
get {
url: {{baseUrl}}/collections
body: none
auth: none
}
headers {
X-TYPESENSE-API-KEY: {{adminApiKey}}
}

View File

@ -1,15 +0,0 @@
meta {
name: activities_paragraphs
type: http
seq: 4
}
get {
url: {{baseUrl}}/collections/activities_paragraphs
body: none
auth: none
}
headers {
X-TYPESENSE-API-KEY: {{adminApiKey}}
}

View File

@ -1,15 +0,0 @@
meta {
name: conferences_paragraphs
type: http
seq: 3
}
get {
url: {{baseUrl}}/collections/conferences_paragraphs
body: none
auth: none
}
headers {
X-TYPESENSE-API-KEY: {{adminApiKey}}
}

View File

@ -1,3 +0,0 @@
meta {
name: Collections
}

View File

@ -1,6 +0,0 @@
{
"version": "1",
"name": "Typesense - LGCC Search",
"type": "collection",
"ignore": []
}

View File

@ -1,119 +0,0 @@
# Limpieza de HTML embebido (Publicaciones y Entrelíneas)
Notas técnicas sobre el saneamiento de HTML que llega desde el CMS/índice de búsqueda
y se renderiza con `v-html` en los paneles de detalle. Documenta el problema, la causa
raíz y el fix aplicado en cada caso, para poder depurar o mejorar esto más adelante.
## Contexto general
Varios documentos (publicaciones, entrelíneas) traen un campo `html` que en teoría es
un fragmento de texto formateado, pero en la práctica viene "contaminado" con marcado
que no debería estar ahí: estilos de Word, anchos fijos, o incluso HTML ya renderizado
de otro componente que fue copiado/pegado en Directus por error. Ese marcado extra
puede romper el layout porque se inyecta directo en el DOM de la app (clases de
Tailwind, `data-*`, tablas de Word con `width` en `cm`/`pt`, etc.).
Cada vista que hace `v-html` de estos campos tiene su propia función de limpieza
(basada en regex, no en un parser DOM) justo antes de renderizar.
---
## Caso 1 — `PublicationDetail.vue`: HTML pegado desde Word
**Archivo:** [`app/components/PublicationDetail.vue`](../app/components/PublicationDetail.vue)
**Síntoma:** tablas/celdas con overflow horizontal o contenido comprimido en el panel
de detalle de publicaciones.
**Causa raíz:** el HTML exportado/pegado desde Word trae:
- Bloques `<style>` completos con reglas `mso-*`.
- Anchos fijos en unidades absolutas (`width: 15.5cm`, `pt`, etc.) en tablas y celdas,
que no responden al layout del contenedor.
**Fix:** función `cleanWordHtml()` (cerca de la línea 85) aplicada al `v-html` del
párrafo:
- Elimina bloques `<style>`.
- Elimina declaraciones `width: <número><cm|mm|pt|px|em|rem|in|pc>`.
- Colapsa saltos de línea literales a un espacio.
Reforzado con CSS en `.paragraph-html` (`:deep(p|span|li|td|th)` → `white-space:
normal`, tablas a `width: 100%` / `table-layout: auto`, celdas y divs a `width: auto` /
`max-width: 100%`) para cubrir estilos que la regex no puede tocar.
---
## Caso 2 — `EntrelineaDetail.vue`: HTML contaminado con clases de otro componente
**Archivo:** [`app/components/entrelineas/EntrelineaDetail.vue`](../app/components/entrelineas/EntrelineaDetail.vue)
**Síntoma:** el texto de la entrelínea se mostraba con **una palabra por línea**,
dejando la mayor parte del panel en blanco.
**Causa raíz (confirmada consultando el documento directo en Typesense):** el campo
`html` de ese registro no contenía un `<p>` por palabra (esa fue la hipótesis inicial,
descartada). Contenía el HTML **ya renderizado de `PublicationDetail.vue`** pegado por
error en Directus, incluyendo:
- Atributos de scoping de Vue (`data-v-d5ee3d80`).
- `data-paragraph-number="37"`.
- Clases de Tailwind reales: `grid grid-cols-1fr items-start gap-2 mb-2
grid-cols-[20px_1fr]`.
Como esas clases son utilidades globales de Tailwind, se aplicaban igual dentro de
`EntrelineaDetail`. El contenedor quedaba como grid de 2 columnas (`20px 1fr`) y, al
tener un solo hijo, el auto-placement lo metía en la columna de **20px** → todo el
párrafo se comprimía a un ancho mínimo y cada palabra terminaba en su propia línea al
hacer wrap.
**Cómo se verificó:** se consultó el documento crudo directo en Typesense:
```bash
curl -s "$NUXT_PUBLIC_TYPESENSE_URL/multi_search" \
-H "X-TYPESENSE-API-KEY: $NUXT_PUBLIC_TYPESENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"searches":[{"collection":"entrelineas","q":"*","filter_by":"id:=<ID_DEL_DOC>","include_fields":"*"}]}'
```
Esto es útil para cualquier bug futuro de renderizado: primero confirmar qué HTML
crudo hay realmente en el índice antes de asumir la causa.
**Fix:** función `formatEntrelineaText()` (cerca de la línea 56), agrega dos
reemplazos antes de los ya existentes:
- Elimina cualquier atributo `class="..."` / `class='...'`.
- Elimina cualquier atributo `data-*="..."` / `data-*='...'`.
Esto neutraliza clases o atributos de scoping filtrados desde cualquier otra fuente,
sin tocar los `style` inline (que sí son necesarios: cursiva, colores, fuente del
documento original).
---
## Limitaciones conocidas
- Ambas limpiezas son **basadas en regex**, no en un parser DOM real. Cubren los casos
vistos hasta ahora, pero no garantizan sanear cualquier HTML arbitrario (por ejemplo,
no tocan `style="display: grid; ..."` puesto inline, solo `width` en unidades
absolutas).
- La lógica está **duplicada** entre `cleanWordHtml` (Publicaciones) y
`formatEntrelineaText` (Entrelíneas). Si aparece un caso nuevo, hay que recordar
aplicarlo en los dos lugares (o consolidarlos, ver abajo).
- El origen del problema está en los datos (Directus / proceso de carga), no en el
frontend. Estas funciones son un parche en el punto de renderizado, no una
corrección en la fuente.
## Ideas para mejorar esto a futuro
1. **Consolidar en un solo util compartido**, por ejemplo en
[`app/utils/textUtilities.ts`](../app/utils/textUtilities.ts) o un nuevo
`app/utils/htmlSanitizer.ts`, con funciones nombradas por lo que hacen
(`stripStyleBlocks`, `stripAbsoluteWidths`, `stripClassAndDataAttrs`,
`collapseNewlines`) y componerlas según necesite cada vista, en vez de tener dos
funciones casi idénticas.
2. **Agregar tests de regresión** con fixtures de HTML "sucio" ya vistos en producción
(el de Word con `mso-*`, el de la entrelínea contaminada con clases de grid) para
que un cambio futuro no reintroduzca estos bugs silenciosamente.
3. **Sanear en el origen** (al indexar en Typesense o al guardar en Directus) en lugar
de en cada punto de render, para que cualquier consumidor futuro del campo `html`
(snippets en listas, exportaciones, etc.) reciba datos ya limpios.
4. Si se detectan más casos de contaminación cruzada entre componentes, vale la pena
revisar el flujo de carga de contenido en Directus para encontrar dónde se está
pegando HTML renderizado en vez de HTML fuente.

View File

@ -1,25 +0,0 @@
module.exports = {
apps: [
{
// You can append the PM2_ENV dynamically in the script if you want unique names in the PM2 list,
// or rely on the directory path to keep them separate.
name: process.env.PM2_NAME || 'SearchTs',
// port: '3000',
// exec_mode: 'cluster',
// instances: 'max',
script: './.output/server/index.mjs',
// Triggered by 'staging' branch
env_staging: {
NODE_ENV: 'staging',
PORT: 3005
},
// Triggered by 'production' branch
env_production: {
NODE_ENV: 'production',
PORT: 3010
}
}
]
};

View File

@ -2,33 +2,19 @@
"name": "English",
"nav": {
"home": "Home",
"bible_studies": "Studies",
"bible_studies_ts": "Studies",
"conferences_ts": "Conferences",
"bible_studies": "Bible Studies",
"bible_studies_ts": "Bible Studies Typesense",
"conferences_ts": "Conferences Typesense",
"conferences": "Conferences",
"between_the_lines": "Between the Lines",
"my_list": "My List",
"history": "History",
"settings": "Settings",
"changelog": "What's New",
"search_title": "Search of La Gran Carpa Catedral"
"settings": "Settings"
},
"search": {
"sort": {
"relevance": "Normal",
"date": "Most recent"
},
"word": "Word",
"phrase": "Phrase",
"bible_study_placeholder": "Study no...",
"bible_study_chip": "Study #{number} {title}",
"bible_study_clear": "Clear filters",
"filters": "Filters",
"placeholder": "Search for...",
"searching": "Searching...",
"tip": "Tip: wrap in \"quotes\" for exact phrase in that order.",
"publication": "Publication",
"draft": "Draft",
"country": "Country",
"state": "State",
"city": "City",
@ -57,44 +43,6 @@
"numbered": "Numbered pages",
"numbered_desc": "Navigate between pages with pagination controls.",
"paragraph_numbers_title": "Paragraph numbers",
"paragraph_numbers_desc": "Show the paragraph number next to each paragraph in the document detail.",
"dev_access_title": "Developer access",
"dev_access_desc": "Enter the key to enable sections in development.",
"dev_key_placeholder": "Development key",
"dev_unlock": "Unlock",
"dev_lock": "Lock",
"dev_unlocked": "Unlocked",
"dev_wrong_key": "Wrong key"
},
"downloads": {
"audio": "Audio",
"book": "Book",
"simple": "Simple"
},
"feedback": {
"title": "Report an error",
"description": "If you find an error or have a suggestion to improve the search, write it here. Your feedback helps us improve.",
"placeholder": "Describe the error or suggestion...",
"anonymous_notice": "Anonymous report. Do not include personal information or passwords.",
"success_message": "Message sent successfully. Thank you for your feedback.",
"send_another": "Send another",
"send": "Send report",
"sending": "Sending...",
"wait_seconds": "Wait {seconds}s",
"error_generic": "Could not send the message. Try again.",
"error_cooldown": "Wait a few seconds before sending.",
"error_rate_limit": "You have reached the hourly limit. Try again later.",
"error_session_limit": "You have reached the session limit."
},
"entrelineas": {
"development_banner": "This section is under development",
"development_subtitle_line1": "We're preparing details for this section.",
"development_subtitle_line2": "Come back soon."
},
"ui": {
"copy": "Copy",
"draft": "Draft",
"empty_bible_studies": "Choose a Study to see the detail",
"empty_conferences": "Choose a Conference to see the detail"
"paragraph_numbers_desc": "Show the paragraph number next to each paragraph in the document detail."
}
}

View File

@ -1,50 +1,19 @@
{
"nav": {
"home": "Inicio",
"bible_studies": "Estudios",
"bible_studies_ts": "Estudios",
"conferences_ts": "Conferencias",
"bible_studies": "Estudios Bíblicos",
"bible_studies_ts": "Estudios Bíblicos Typesense",
"conferences_ts": "Conferencias Typesense",
"conferences": "Conferencias",
"between_the_lines": "Entrelíneas",
"my_list": "Mi Listado",
"history": "Historial",
"settings": "Configuración",
"changelog": "Novedades",
"tour": "Toma el tour",
"localeselector": "Selector de idioma",
"search_title": "Buscador de La Gran Carpa Catedral"
},
"tour": {
"progress": "{current} de {total}",
"next": "Siguiente",
"prev": "Anterior",
"done": "Finalizar",
"bible_studies_description": "Realiza búsquedas en los Estudios predicados por el Dr. José Benjamín Pérez Matos",
"conferences_description": "Realiza búsquedas en las conferencias predicadas por el Dr. William Soto Santiago",
"betweenthelines_description": "Realiza búsquedas en las imágenes de entrelíneas de los estudios del Dr. José Benjamín Pérez Matos",
"favorites_description": "Listado de resultados guardados como favoritos para fácil acceso futuro",
"history_description": "Historial de los resultados de búsqueda que has visto",
"settings_description": "Configuración, cantidad de resultados por página, tipo de paginación, entre otros",
"changelog_description": "Bitácora de cambios realizados al sitio en orden cronológico",
"feedback_description": "¿Tienes alguna sugerencia o queja? Realízala aquí.",
"localeselector_description": "Cambia fácilmente el idioma de la página",
"index_changelog": "Panel de últimos cambios subidos",
"favorites_toggle": "Botón para guardar / quitar este documento de mis favoritos",
"collapse_sidebar_description": "Oculta o muestra la barra de navegación lateral para tener más espacio en pantalla",
"total_results_description": "Muestra la cantidad total de resultados encontrados para tu búsqueda",
"favorites_button": "Botón de favoritos"
},
"home": {
"instructions": "Bienvenidos, aquí podrán buscar, entre los Estudios de las escrituras, las conferencias y las entrelíneas que están disponibles en el material de archivo de La Gran Carpa Catedral."
"settings": "Configuración"
},
"search": {
"placeholder": "Buscar...",
"searching": "Buscando...",
"tip": "Consejo: envuelve en \"comillas\" para frase exacta en ese orden.",
"collapse": "Colapsar menú lateral",
"total_results": "Total de resultados",
"publication": "Publicación",
"draft": "Borrador",
"country": "País",
"state": "Estado",
"city": "Ciudad",
@ -56,21 +25,11 @@
"hits_per_page": "aciertos por página",
"hits_retrieved_in": "aciertos logrados en",
"for": "Buscando",
"sort": {
"relevance": "Normal",
"date": "Más recientes"
},
"word": "Palabra",
"phrase": "Frase",
"words": "palabras",
"phrases": "frases",
"bible_study_placeholder": "N° de estudio...",
"bible_study_chip": "Estudio #{number} {title}",
"bible_study_clear": "Limpiar filtros",
"filters": "Filtros",
"words_tooltip": "Buscar por palabras",
"phrases_tooltip": "Buscar por frases",
"instructions": "Selecciona un resultado de búsqueda...",
"instructions": "Selecciona un resultado de busqueda...",
"tab1": {
"tab_title": "Actividades"
},
@ -83,54 +42,12 @@
"page_size_desc": "Cuántos resultados cargar en cada página o petición.",
"results": "resultados",
"pagination_title": "Tipo de paginación",
"pagination_desc": "¿Cómo quieres navegar entre los resultados?",
"pagination_desc": "Cómo quieres navegar entre los resultados.",
"infinite_scroll": "Scroll infinito",
"infinite_scroll_desc": "Los resultados se cargan automáticamente al llegar al final de la lista.",
"numbered": "Páginas numeradas",
"numbered_desc": "Navega entre páginas con controles de paginación.",
"paragraph_numbers_title": "Números de párrafo",
"paragraph_numbers_desc": "Muestra el número de párrafo al lado de cada párrafo en el detalle del documento.",
"dev_access_title": "Acceso de desarrollador",
"dev_access_desc": "Ingresa la clave para habilitar secciones en desarrollo.",
"dev_key_placeholder": "Clave de desarrollo",
"dev_unlock": "Desbloquear",
"dev_lock": "Bloquear",
"dev_unlocked": "Desbloqueado",
"dev_wrong_key": "Clave incorrecta"
},
"downloads": {
"audio": "Audio",
"book": "Libro",
"simple": "Sencillo"
},
"feedback": {
"title": "Reportar un error",
"description": "Si encuentras un error o tienes una sugerencia para mejorar el buscador, escríbelo aquí. Tu retroalimentación nos ayuda a mejorar.",
"placeholder": "Describe el error o sugerencia...",
"anonymous_notice": "Reporte anónimo. No incluyas información personal ni contraseñas.",
"success_message": "Mensaje enviado correctamente. Gracias por tu retroalimentación.",
"send_another": "Enviar otro",
"send": "Enviar reporte",
"sending": "Enviando...",
"wait_seconds": "Espera {seconds}s",
"error_generic": "No se pudo enviar el mensaje. Intenta de nuevo.",
"error_cooldown": "Espera unos segundos antes de enviar.",
"error_rate_limit": "Has alcanzado el límite de envíos por hora. Intenta más tarde.",
"error_session_limit": "Has alcanzado el límite de envíos en esta sesión."
},
"entrelineas": {
"development_banner": "Esta sección se encuentra en desarrollo",
"development_subtitle_line1": "Estamos preparando detalles para esta sección.",
"development_subtitle_line2": "Vuelve pronto."
},
"ui": {
"copy": "Copiar",
"draft": "Borrador",
"empty_bible_studies": "Selecciona un Estudio para ver el detalle",
"empty_conferences": "Selecciona una Conferencia para ver el detalle"
},
"seo": {
"title": "La Gran Carpa Catedral - Buscador",
"description": "Buscador de actividades y conferencias."
"paragraph_numbers_desc": "Muestra el número de párrafo al lado de cada párrafo en el detalle del documento."
}
}

View File

@ -2,31 +2,17 @@
"name": "Français",
"nav": {
"home": "Commencer",
"bible_studies": "Études",
"bible_studies_ts": "Études",
"conferences_ts": "Conférences",
"bible_studies": "Études Bibliques",
"bible_studies_ts": "Études Bibliques Typesense",
"conferences_ts": "Conférences Typesense",
"conferences": "Conférences",
"between_the_lines": "Entre les lignes",
"my_list": "Ma liste",
"history": "Historique",
"settings": "Paramètres",
"changelog": "Nouveautés",
"search_title": "Buscador de La Gran Carpa Catedral"
"settings": "Paramètres"
},
"search": {
"sort": {
"relevance": "Normal",
"date": "Plus récents"
},
"bible_study_placeholder": "N° d'étude...",
"bible_study_chip": "Étude #{number} {title}",
"bible_study_clear": "Effacer les filtres",
"filters": "Filtres",
"word": "Mot",
"phrase": "Phrase",
"placeholder": "Rechercher des activités",
"publication": "Publicación",
"draft": ""
"placeholder": "Rechercher des activités"
},
"locale": {
"en": "English",
@ -45,44 +31,6 @@
"numbered": "Pages numérotées",
"numbered_desc": "Naviguez entre les pages avec des contrôles de pagination.",
"paragraph_numbers_title": "Numéros de paragraphe",
"paragraph_numbers_desc": "Affiche le numéro de paragraphe à côté de chaque paragraphe dans le détail du document.",
"dev_access_title": "Accès développeur",
"dev_access_desc": "Entrez la clé pour activer les sections en développement.",
"dev_key_placeholder": "Clé de développement",
"dev_unlock": "Déverrouiller",
"dev_lock": "Verrouiller",
"dev_unlocked": "Déverrouillé",
"dev_wrong_key": "Clé incorrecte"
},
"downloads": {
"audio": "Audio",
"book": "Libre",
"simple": "Simple"
},
"feedback": {
"title": "Rapporter une erreur",
"description": "Si vous trouvez une erreur ou avez une suggestion pour améliorer la recherche, écrivez-la ici. Votre retour nous aide à nous améliorer.",
"placeholder": "Décrivez l'erreur ou la suggestion...",
"anonymous_notice": "Rapport anonyme. N'incluez pas d'informations personnelles ni de mots de passe.",
"success_message": "Message envoyé avec succès. Merci pour votre retour.",
"send_another": "Envoyer un autre",
"send": "Envoyer le rapport",
"sending": "Envoi en cours...",
"wait_seconds": "Attendez {seconds}s",
"error_generic": "Impossible d'envoyer le message. Réessayez.",
"error_cooldown": "Attendez quelques secondes avant d'envoyer.",
"error_rate_limit": "Vous avez atteint la limite horaire. Réessayez plus tard.",
"error_session_limit": "Vous avez atteint la limite de cette session."
},
"entrelineas": {
"development_banner": "Cette section est en cours de développement",
"development_subtitle_line1": "Nous préparons les détails de cette section.",
"development_subtitle_line2": "Revenez bientôt."
},
"ui": {
"copy": "Copie",
"draft": "Brouillon",
"empty_bible_studies": "Choisissez une étude pour voir le détail",
"empty_conferences": "Choisissez une conférence pour voir le détail"
"paragraph_numbers_desc": "Affiche le numéro de paragraphe à côté de chaque paragraphe dans le détail du document."
}
}

View File

@ -2,31 +2,17 @@
"name": "Português",
"nav": {
"home": "Inicio",
"bible_studies": "Estudios",
"bible_studies_ts": "Estudos",
"bible_studies": "Estudios Bíblicos",
"bible_studies_ts": "Estudos Bíblicos Typesense",
"conferences_ts": "Conferências Typesense",
"conferences": "Conferências",
"between_the_lines": "Entre as linhas",
"my_list": "Minha lista",
"history": "Registro",
"settings": "Configurações",
"changelog": "Novidades",
"search_title": "Buscador de La Gran Carpa Catedral"
"settings": "Configurações"
},
"search": {
"sort": {
"relevance": "Normal",
"date": "Mais recentes"
},
"bible_study_placeholder": "N° do estudo...",
"bible_study_chip": "Estudo #{number} {title}",
"bible_study_clear": "Limpar filtros",
"filters": "Filtros",
"word": "Palavra",
"phrase": "Frase",
"placeholder": "Digite para pesquisar...",
"publication": "Publicação",
"draft": "Borrador",
"for": "Para",
"city": "Cidade",
"page": "Página",
@ -55,42 +41,6 @@
"numbered": "Páginas numeradas",
"numbered_desc": "Navegue entre páginas com controles de paginação.",
"paragraph_numbers_title": "Números de parágrafo",
"paragraph_numbers_desc": "Exibe o número do parágrafo ao lado de cada parágrafo no detalhe do documento.",
"dev_access_title": "Acesso de desenvolvedor",
"dev_access_desc": "Insira a chave para ativar seções em desenvolvimento.",
"dev_key_placeholder": "Chave de desenvolvimento",
"dev_unlock": "Desbloquear",
"dev_lock": "Bloquear",
"dev_unlocked": "Desbloqueado",
"dev_wrong_key": "Chave incorreta"
},
"downloads": {
"audio": "Áudio",
"book": "Livro",
"simple": "Simples"
},
"feedback": {
"title": "Reportar um erro",
"description": "Se encontrar um erro ou tem uma sugestão, escreva-la aqui. A sua opinião ira ajudar-nos a melhorar.",
"placeholder": "Descreva o erro ou sugestão",
"anonymous_notice": "Reporte anônimo. Não inclua informações pessoais nem senhas.",
"success_message": "Mensagem enviada corretamente. Obrigado por sua opinião.",
"send_another": "Enviar outro",
"send": "Enviar reporte",
"sending": "Enviando...",
"wait_seconds": "Aguarde {seconds}s",
"error_generic": "Não foi possível enviar a mensagem. Tente novamente.",
"error_cooldown": "Aguarde alguns segundos antes de enviar.",
"error_rate_limit": "Você atingiu o limite horário. Tente novamente mais tarde.",
"error_session_limit": "Você atingiu o limite desta sessão."
},
"entrelineas": {
"development_banner": "Esta seção está em desenvolvimento",
"development_subtitle_line1": "Estamos preparando detalhes para esta seleção.",
"development_subtitle_line2": "Volte logo."
},
"ui": {
"copy": "Cópia",
"draft": "Rascunho"
"paragraph_numbers_desc": "Exibe o número do parágrafo ao lado de cada parágrafo no detalhe do documento."
}
}

View File

@ -1,13 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: ['@nuxt/eslint', '@nuxt/ui', '@vueuse/nuxt', '@nuxtjs/i18n', '@pinia/nuxt', 'nuxt-driver.js'],
app: {
head: {
htmlAttrs: { translate: 'no' },
meta: [{ name: 'google', content: 'notranslate' }]
}
},
modules: ['@nuxt/eslint', '@nuxt/ui', '@vueuse/nuxt', 'nuxt-meilisearch', '@nuxtjs/i18n', '@pinia/nuxt', '@sfxcode/nuxt-typesense'],
devtools: {
enabled: true
@ -19,21 +12,6 @@ export default defineNuxtConfig({
colorMode: false
},
runtimeConfig: {
feedbackToken: process.env.NUXT_FEEDBACK_TOKEN || '',
public: {
feedbackWebhook: process.env.NUXT_PUBLIC_FEEDBACK_WEBHOOK || '',
recaptchaSiteKey: process.env.NUXT_PUBLIC_RECAPTCHA_SITE_KEY || '',
feedbackMaxPerHour: Number(process.env.NUXT_PUBLIC_FEEDBACK_MAX_PER_HOUR) || 5,
feedbackMaxPerSession: Number(process.env.NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION) || 3,
feedbackCooldownSec: Number(process.env.NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC) || 45,
feedbackMinSeconds: Number(process.env.NUXT_PUBLIC_FEEDBACK_MIN_SECONDS) || 4,
entrelineasDevKey: process.env.NUXT_PUBLIC_ENTRELINEAS_DEV_KEY || '',
typeSenseNodes: process.env.TYPESENSE_NODES || '[]',
typeSenseApiKey: process.env.NUXT_PUBLIC_TYPESENSE_API_KEY || ''
}
},
routeRules: {
'/api/**': {
cors: true
@ -89,4 +67,19 @@ export default defineNuxtConfig({
optimizeTranslationDirective: false,
},
},
meilisearch: {
hostUrl: 'https://search.carpa.com', // required
searchApiKey: '04be59c1f633e2bb434082fc1a6fcc6ce97e3630e3fcf9e814e1f03a386c03e1', // required
serverSideUsage: true // default: false
},
typesense: {
url: 'https://searchts.carpa.com', // Your Typesense server URL
apiKey: 'a2lbIMTxh48KVteLLndpBfo4tuOIGiwD', // 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
}
})

751
package-lock.json generated
View File

@ -23,7 +23,7 @@
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"nuxt": "^4.4.2",
"nuxt-driver.js": "^0.1.1",
"nuxt-meilisearch": "^1.4.17",
"pinia": "^3.0.4",
"scule": "^1.3.0",
"tailwindcss": "^4.2.4",
@ -39,6 +39,267 @@
"vue-tsc": "^3.2.7"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "2.0.86",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.86.tgz",
"integrity": "sha512-pP9F5G7C5sqZtAwquFB+g50lVS/s4Wf/ll2WSm0ODk0Iix27trVgBDpFK5CBletcQXSDlAvSQQi25nBodYLt3g==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.3",
"@ai-sdk/provider-utils": "3.0.25",
"@vercel/oidc": "3.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz",
"integrity": "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "3.0.25",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.25.tgz",
"integrity": "sha512-CvsRu+32Y8a167s+lrIBtsybvgTHp8j9y+6BeTvLeoW3Q+okw/b4CnNUFOLIXsRaKHQKAH+IHNJPYWywfpw0LA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "2.0.3",
"@standard-schema/spec": "^1.0.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@algolia/abtesting": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz",
"integrity": "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-abtesting": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.0.tgz",
"integrity": "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-analytics": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.0.tgz",
"integrity": "sha512-9KY36bRl4AH7RjqSeDDOKnjsz4IxQFBEOB8/fWmEbdQe+Isbs5jGzVJu9NEPQ1Tgwxlf8Uf07Swj3jZyMNUZ2g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-common": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.0.tgz",
"integrity": "sha512-3a/qM3dzJqqfTx7Yrw7uGQ98I3Q0rDfb4Vkv0wEzko96l7YQMxfBVz/VbLq2N+c59GweYv6Vhp8mPeqnWJSITw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-insights": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.0.tgz",
"integrity": "sha512-Rki7ACbMcvbQW0BuM84x9dkGHY47ABmv4jU6tYssat2k02p3mIUms2YOLUAMeknhmnFsj6lb6ZzOXdMWMyc1sA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-personalization": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.0.tgz",
"integrity": "sha512-96s4Uzc3kk+/f4jJXIVVGWP5XlngOGNQ1x6hW9AT59pOixHlOs5tqJg+ZUS/GQ6h/iYP0ceQcmxDQeLyCLTaDQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-query-suggestions": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.0.tgz",
"integrity": "sha512-lqeycNpSPe5Qa0OUWpejVvYQjQWV5nQuLT0a4aq7XzRAvCxprV/6Lf841EygdD2nrFnuS58ok7Au1uOtXzpnkg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-search": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.0.tgz",
"integrity": "sha512-ly1wETVGRo30cx61O7fetESN+ElL9c9K+bD/AVgnT1ar4c6v+/Yqjrhdtu6Fm4D0s4NZP081Isf6tunH1wUXHg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/events": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz",
"integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==",
"license": "MIT"
},
"node_modules/@algolia/ingestion": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.0.tgz",
"integrity": "sha512-U4EeTvgmluRjj39ykZSAd5X+a6LD5m7/mcOWDmB7hqm1R6QY0yT8jLxpNVEjYhzgEN5hcDGW6X67EWQY8KiYGQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/monitoring": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.0.tgz",
"integrity": "sha512-FCPnDcILfpTE94u7BVlV4DmnSV5wE3+j25EEF+3dYPrVzkVCSoAHs318oWDGxnxsAgiL4HpL12Jc4XHmw9shpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/recommend": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.0.tgz",
"integrity": "sha512-br3DO7n4N8CXwTRbZS0MnB4WQ9YHfNjCwkCEzVR/wek/qNTDQKDb0nROmkFaNZ8ucUqUVKZi074dbwMwRDlK8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/requester-browser-xhr": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.0.tgz",
"integrity": "sha512-b0T/Ca2c9KyEslKsVrGZvbe1UrrKKSdfXhBZ2pbpKahFUzJfziRZ0urbOm7V65O0tO/jwU+Lo/+bIiiyhzGt8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/requester-fetch": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.0.tgz",
"integrity": "sha512-ozBT8J/mtD4H4IAojw8QPirlcL2gHrI1BGuZ4/ZXXO/rTE1yQ4VIPJj4mTTbwo4FbkS1MoJsD/DsrqLzhnc4/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/requester-node-http": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.0.tgz",
"integrity": "sha512-gyyWcLD22tnabmoit4iukCXuoRc5HYJuUjPSEa8a0D/f/NlRafpWi52AlAaa4Uu/rsl7saHsJFTNjTptWbu2+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@ -2012,6 +2273,21 @@
"node": ">=6.0.0"
}
},
"node_modules/@meilisearch/instant-meilisearch": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@meilisearch/instant-meilisearch/-/instant-meilisearch-0.28.0.tgz",
"integrity": "sha512-QeY8w0PsoZUwzrKJyrmmi3lGRXDhzPr38t1Ns9iezoA5CD9L3YARjJJKgExybQF6KIReFw1zfJaKbPRA1xTDCA==",
"license": "MIT",
"dependencies": {
"meilisearch": "0.53"
}
},
"node_modules/@meilisearch/instant-meilisearch/node_modules/meilisearch": {
"version": "0.53.0",
"resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.53.0.tgz",
"integrity": "sha512-nG4VXbEOSzUmtbfsgOo+t6yX1ECEgXaT4hC0ap9MBpQGK5xwT+NWYDENYsKWR75cVaWaAqva+ok4zHlgtdXlLw==",
"license": "MIT"
},
"node_modules/@miyaneee/rollup-plugin-json5": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@miyaneee/rollup-plugin-json5/-/rollup-plugin-json5-1.2.0.tgz",
@ -2156,6 +2432,12 @@
}
}
},
"node_modules/@nuxt/cli/node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"license": "MIT"
},
"node_modules/@nuxt/devalue": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz",
@ -2382,25 +2664,25 @@
}
},
"node_modules/@nuxt/icon": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.2.tgz",
"integrity": "sha512-K9wINW21M9x5GcKF5JEXzPKAT/Kfxl/vdnEyppw54hh5qoLcdi5HmsYoTfDP9gbJ6Z1T6IdH5JxBWk72HMe1Zg==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz",
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==",
"license": "MIT",
"dependencies": {
"@iconify/collections": "^1.0.679",
"@iconify/collections": "^1.0.641",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.1",
"@iconify/utils": "^3.1.0",
"@iconify/vue": "^5.0.0",
"@nuxt/devtools-kit": "^3.2.4",
"@nuxt/kit": "^4.4.4",
"@nuxt/devtools-kit": "^3.1.1",
"@nuxt/kit": "^4.2.2",
"consola": "^3.4.2",
"local-pkg": "^1.1.2",
"mlly": "^1.8.2",
"mlly": "^1.8.0",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"picomatch": "^4.0.4",
"std-env": "^4.1.0",
"tinyglobby": "^0.2.16"
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinyglobby": "^0.2.15"
}
},
"node_modules/@nuxt/kit": {
@ -2498,6 +2780,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@nuxt/nitro-server/node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"license": "MIT"
},
"node_modules/@nuxt/schema": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.4.tgz",
@ -2514,6 +2802,12 @@
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/@nuxt/schema/node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"license": "MIT"
},
"node_modules/@nuxt/telemetry": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@nuxt/telemetry/-/telemetry-2.8.0.tgz",
@ -2542,6 +2836,12 @@
"integrity": "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==",
"license": "MIT"
},
"node_modules/@nuxt/telemetry/node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"license": "MIT"
},
"node_modules/@nuxt/ui": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/@nuxt/ui/-/ui-4.7.1.tgz",
@ -2740,6 +3040,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@nuxt/vite-builder/node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"license": "MIT"
},
"node_modules/@nuxtjs/color-mode": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@nuxtjs/color-mode/-/color-mode-3.5.2.tgz",
@ -3837,6 +4143,15 @@
"vue": "^3.5.0"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@oxc-minify/binding-android-arm-eabi": {
"version": "0.128.0",
"resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.128.0.tgz",
@ -7056,6 +7371,12 @@
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
"license": "MIT"
},
"node_modules/@types/dom-speech-recognition": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.1.tgz",
"integrity": "sha512-udCxb8DvjcDKfk1WTBzDsxFbLgYxmQGKrE/ricoMqHRNjSlSUCcamVTA5lIQqzY10mY5qCY0QDwBfFEwhfoDPw==",
"license": "MIT"
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -7074,6 +7395,18 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/google.maps": {
"version": "3.64.0",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.0.tgz",
"integrity": "sha512-dN0H6tB4lgLQLovcbPXFYYOEV41TpyyJghzb5jrzjB96FZmjeOghevVdC+BMGd6YqyCqXaggyEtqRXLRjzCBZA==",
"license": "MIT"
},
"node_modules/@types/hogan.js": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/hogan.js/-/hogan.js-3.0.5.tgz",
"integrity": "sha512-/uRaY3HGPWyLqOyhgvW9Aa43BNnLZrNeQxl2p8wqId4UHMfPKolSB+U7BlZyO1ng7MkLnyEAItsBzCG0SDhqrA==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -7118,6 +7451,12 @@
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
"license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
"license": "MIT"
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -7880,6 +8219,15 @@
"node": ">=8"
}
},
"node_modules/@vercel/oidc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
@ -8376,6 +8724,24 @@
"node": ">= 14"
}
},
"node_modules/ai": {
"version": "5.0.183",
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.183.tgz",
"integrity": "sha512-lmLFkxJ2epeUXi6QXi/9VYs1HF61vikpP8vGnGd3Erdh/syUyfZ/DC1to2AoNwytBNpICN3OGbTpwc7jfPewgg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "2.0.86",
"@ai-sdk/provider": "2.0.3",
"@ai-sdk/provider-utils": "3.0.25",
"@opentelemetry/api": "1.9.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@ -8392,6 +8758,44 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/algoliasearch": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.0.tgz",
"integrity": "sha512-0ZzY9mjqV7gop/AH8pIBiAS8giXP7WcSiUfoFYIzYAK9QC5c37E4SIVtJVBMwlURc0/uNt2o4RcNRvdHa4CJ5w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.18.0",
"@algolia/client-abtesting": "5.52.0",
"@algolia/client-analytics": "5.52.0",
"@algolia/client-common": "5.52.0",
"@algolia/client-insights": "5.52.0",
"@algolia/client-personalization": "5.52.0",
"@algolia/client-query-suggestions": "5.52.0",
"@algolia/client-search": "5.52.0",
"@algolia/ingestion": "1.52.0",
"@algolia/monitoring": "1.52.0",
"@algolia/recommend": "5.52.0",
"@algolia/requester-browser-xhr": "5.52.0",
"@algolia/requester-fetch": "5.52.0",
"@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/algoliasearch-helper": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.26.0.tgz",
"integrity": "sha512-Rv2x3GXleQ3ygwhkhJubhhYGsICmShLAiqtUuJTUkr9uOCOXyF2E71LVT4XDnVffbknv8XgScP4U0Oxtgm+hIw==",
"license": "MIT",
"dependencies": {
"@algolia/events": "^4.0.1"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 6"
}
},
"node_modules/alien-signals": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
@ -10416,12 +10820,6 @@
"url": "https://dotenvx.com"
}
},
"node_modules/driver.js": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz",
"integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -11397,6 +11795,15 @@
"bare-events": "^2.7.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
"integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
@ -12213,12 +12620,51 @@
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==",
"license": "MIT"
},
"node_modules/hogan.js": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz",
"integrity": "sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==",
"dependencies": {
"mkdirp": "0.3.0",
"nopt": "1.0.10"
},
"bin": {
"hulk": "bin/hulk"
}
},
"node_modules/hogan.js/node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC"
},
"node_modules/hogan.js/node_modules/nopt": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
"integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==",
"license": "MIT",
"dependencies": {
"abbrev": "1"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "*"
}
},
"node_modules/hookable": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz",
"integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==",
"license": "MIT"
},
"node_modules/htm": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
"integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
"license": "Apache-2.0"
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
@ -12404,6 +12850,88 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/instantsearch-ui-components": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/instantsearch-ui-components/-/instantsearch-ui-components-0.12.0.tgz",
"integrity": "sha512-WcyrNJ5sqeqAWLibztnssSKDhgFEdj4C1prZOHSCONxF+5RSfCsD0oqKiZrTMx3dlAL/Qx7eZxKhD56u5mxJMA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"ai": "^5.0.18",
"markdown-to-jsx": "^7.7.15",
"zod": "^3.25.76 || ^4",
"zod-to-json-schema": "3.24.6"
}
},
"node_modules/instantsearch-ui-components/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/instantsearch-ui-components/node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
},
"node_modules/instantsearch.css": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/instantsearch.css/-/instantsearch.css-8.6.0.tgz",
"integrity": "sha512-YweaPxsvaITBgKPxEZnuTX0XLfa4JeLBGkhrlcN2glCH1Ipk9XU1M9X+clcAEottTe8K1/q5COePW0Ozr2+0MA==",
"license": "MIT"
},
"node_modules/instantsearch.js": {
"version": "4.81.0",
"resolved": "https://registry.npmjs.org/instantsearch.js/-/instantsearch.js-4.81.0.tgz",
"integrity": "sha512-zyIUSrikpst5eaBMrruGRGmRHOHQciDyJ8UW7i4xhPVNRwxr5Ls/NBez9AdNne+7OvALCOeaTI3t9LoBQakWhw==",
"license": "MIT",
"dependencies": {
"@algolia/events": "^4.0.1",
"@types/dom-speech-recognition": "^0.0.1",
"@types/google.maps": "^3.55.12",
"@types/hogan.js": "^3.0.0",
"@types/qs": "^6.5.3",
"ai": "^5.0.18",
"algoliasearch-helper": "3.26.0",
"hogan.js": "^3.0.2",
"htm": "^3.0.0",
"instantsearch-ui-components": "0.12.0",
"preact": "^10.10.0",
"qs": "^6.5.1 < 6.10",
"react": ">= 0.14.0",
"search-insights": "^2.17.2",
"zod": "^3.25.76 || ^4",
"zod-to-json-schema": "3.24.6"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 6"
}
},
"node_modules/instantsearch.js/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/instantsearch.js/node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@ -12748,6 +13276,12 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-to-typescript-lite": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/json-schema-to-typescript-lite/-/json-schema-to-typescript-lite-15.0.0.tgz",
@ -13274,6 +13808,12 @@
"listhen": "bin/listhen.mjs"
}
},
"node_modules/listhen/node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"license": "MIT"
},
"node_modules/load-tsconfig": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz",
@ -13482,6 +14022,23 @@
"vt-pbf": "^3.1.3"
}
},
"node_modules/markdown-to-jsx": {
"version": "7.7.17",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.17.tgz",
"integrity": "sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"peerDependencies": {
"react": ">= 0.14.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
},
"node_modules/marked": {
"version": "17.0.6",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz",
@ -13509,6 +14066,12 @@
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0"
},
"node_modules/meilisearch": {
"version": "0.54.0",
"resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.54.0.tgz",
"integrity": "sha512-b1bwJAEfj8C6hgSN88+/LvW3pe3nWC+thBS2seAbPZGakf/vzsLqppgZquiomzCr2GhU7U7H289625qhYe3rbw==",
"license": "MIT"
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -13646,6 +14209,22 @@
"node": ">= 18"
}
},
"node_modules/mitt": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
"integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==",
"license": "MIT"
},
"node_modules/mkdirp": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz",
"integrity": "sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==",
"deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)",
"license": "MIT/X11",
"engines": {
"node": "*"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@ -14377,6 +14956,12 @@
"node": ">= 12"
}
},
"node_modules/nitropack/node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"license": "MIT"
},
"node_modules/nitropack/node_modules/unimport": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/unimport/-/unimport-6.2.0.tgz",
@ -14619,14 +15204,58 @@
}
}
},
"node_modules/nuxt-driver.js": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/nuxt-driver.js/-/nuxt-driver.js-0.1.1.tgz",
"integrity": "sha512-K8SJBzLD8oyu3mhp2vv6F6/SUEaXSp067iGLCPDiMGh4w86VdDArapPmyylH9ZvYq/aeltVPbS6S/uOuVtAXHw==",
"node_modules/nuxt-meilisearch": {
"version": "1.4.17",
"resolved": "https://registry.npmjs.org/nuxt-meilisearch/-/nuxt-meilisearch-1.4.17.tgz",
"integrity": "sha512-9O0dUIwuu00YAGVqQ0BVp/7kgLf3a6ONeOy6gDYtgj8sw/VI1o5tYIwg9hMjesOD1Jw/2WGL8dORkNRN2Mwedg==",
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^4.4.4",
"driver.js": "^1.4.0"
"@meilisearch/instant-meilisearch": "0.28.0",
"@nuxt/kit": "4.2.0",
"instantsearch.css": "8.6.0",
"meilisearch": "0.54.0",
"vue-instantsearch": "4.22.0"
}
},
"node_modules/nuxt-meilisearch/node_modules/@nuxt/kit": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.2.0.tgz",
"integrity": "sha512-1yN3LL6RDN5GjkNLPUYCbNRkaYnat6hqejPyfIBBVzrWOrpiQeNMGxQM/IcVdaSuBJXAnu0sUvTKXpXkmPhljg==",
"license": "MIT",
"dependencies": {
"c12": "^3.3.1",
"consola": "^3.4.2",
"defu": "^6.1.4",
"destr": "^2.0.5",
"errx": "^0.1.0",
"exsolve": "^1.0.7",
"ignore": "^7.0.5",
"jiti": "^2.6.1",
"klona": "^2.0.6",
"mlly": "^1.8.0",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"pkg-types": "^2.3.0",
"rc9": "^2.1.2",
"scule": "^1.3.0",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ufo": "^1.6.1",
"unctx": "^2.4.1",
"untyped": "^2.0.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/nuxt-meilisearch/node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
"destr": "^2.0.3"
}
},
"node_modules/nuxt/node_modules/cookie-es": {
@ -14656,6 +15285,12 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/nuxt/node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"license": "MIT"
},
"node_modules/nuxt/node_modules/unimport": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/unimport/-/unimport-6.2.0.tgz",
@ -15727,6 +16362,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/preact": {
"version": "10.29.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
"integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -15933,6 +16578,18 @@
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.9.9",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.9.tgz",
"integrity": "sha512-4mp+ySf3n7scxSqU6LIO9jjYCJWIdbXh3EQ8uvEc1uXiZm8sLI7moRTOvZb66c8zEHelBGEVRkSW6e9JCaayTw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -16000,6 +16657,15 @@
"destr": "^2.0.5"
}
},
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
@ -16445,6 +17111,12 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
"node_modules/search-insights": {
"version": "2.17.3",
"resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz",
"integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -16747,9 +17419,9 @@
}
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"license": "MIT"
},
"node_modules/streamx": {
@ -18515,6 +19187,31 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-instantsearch": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/vue-instantsearch/-/vue-instantsearch-4.22.0.tgz",
"integrity": "sha512-/BUdzD+NAybC81FGzEeSuCSYobYUVgHEHI7QY0HlTzdxIHOqWlw2y152/80kGKndS0C+Yu5ifGGg+xVIcFc4Dg==",
"license": "MIT",
"dependencies": {
"instantsearch-ui-components": "0.12.0",
"instantsearch.js": "4.81.0",
"mitt": "^2.1.0"
},
"peerDependencies": {
"@vue/server-renderer": "^3.1.2",
"algoliasearch": ">= 3.32.0 < 6",
"vue": "^2.6.0 || >=3.0.0-rc.0",
"vue-server-renderer": "^2.6.11"
},
"peerDependenciesMeta": {
"@vue/server-renderer": {
"optional": true
},
"vue-server-renderer": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.6.tgz",

View File

@ -8,8 +8,7 @@
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck",
"benchmark:search": "node --env-file=.env scripts/benchmark-search.mjs"
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@babel/runtime": "^7.29.2",
@ -19,6 +18,7 @@
"@nuxt/ui": "^4.7.0",
"@nuxtjs/i18n": "^9.5.6",
"@pinia/nuxt": "^0.11.2",
"@sfxcode/nuxt-typesense": "^1.2.0",
"@tanstack/table-core": "^8.21.3",
"@unovis/ts": "^1.6.5",
"@unovis/vue": "^1.6.5",
@ -27,7 +27,7 @@
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"nuxt": "^4.4.2",
"nuxt-driver.js": "^0.1.1",
"nuxt-meilisearch": "^1.4.17",
"pinia": "^3.0.4",
"scule": "^1.3.0",
"tailwindcss": "^4.2.4",
@ -42,5 +42,5 @@
"typescript": "^6.0.3",
"vue-tsc": "^3.2.7"
},
"packageManager": "pnpm@11.5.0"
"packageManager": "pnpm@10.33.2"
}

View File

@ -22,13 +22,16 @@ importers:
version: 3.12.1
'@nuxt/ui':
specifier: ^4.7.0
version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(axios@1.16.0)(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.3.0)(typescript@6.0.3)(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))(yjs@13.6.30)(zod@4.4.3)
version: 4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(axios@1.16.0)(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(react@19.2.6)(tailwindcss@4.3.0)(typescript@6.0.3)(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))(yjs@13.6.30)(zod@4.4.3)
'@nuxtjs/i18n':
specifier: ^9.5.6
version: 9.5.6(@vue/compiler-dom@3.5.34)(eslint@10.3.0(jiti@2.7.0))(magicast@0.5.2)(rollup@4.60.3)(vue@3.5.34(typescript@6.0.3))
'@pinia/nuxt':
specifier: ^0.11.2
version: 0.11.3(magicast@0.5.2)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))
'@sfxcode/nuxt-typesense':
specifier: ^1.2.0
version: 1.2.0(magicast@0.5.2)
'@tanstack/table-core':
specifier: ^8.21.3
version: 8.21.3
@ -53,9 +56,9 @@ importers:
nuxt:
specifier: ^4.4.2
version: 4.4.5(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4)(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.3))(rollup@4.60.3)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.2.8(typescript@6.0.3))(yaml@2.9.0)
nuxt-driver.js:
specifier: ^0.1.1
version: 0.1.1(magicast@0.5.2)
nuxt-meilisearch:
specifier: ^1.4.17
version: 1.4.17(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(algoliasearch@5.52.1)(magicast@0.5.2)(react@19.2.6)(vue@3.5.34(typescript@6.0.3))
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
@ -93,6 +96,81 @@ importers:
packages:
'@ai-sdk/gateway@2.0.88':
resolution: {integrity: sha512-H62l0gxr4K0rdR2WHbvck2wOKMsocAjdZg41Exsj9Qf5/TyAuHzcNt9jKNv5t2vRFXFZaCpbC5uCCxgUC/GiaA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@3.0.25':
resolution: {integrity: sha512-CvsRu+32Y8a167s+lrIBtsybvgTHp8j9y+6BeTvLeoW3Q+okw/b4CnNUFOLIXsRaKHQKAH+IHNJPYWywfpw0LA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@2.0.3':
resolution: {integrity: sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww==}
engines: {node: '>=18'}
'@algolia/abtesting@1.18.1':
resolution: {integrity: sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==}
engines: {node: '>= 14.0.0'}
'@algolia/client-abtesting@5.52.1':
resolution: {integrity: sha512-HmXOGBOAOJPounpBzBpuY0zDYeiCpxgHnQmuA7JO6ScukcBdGp3/XM9zJk5pJx/xNGD68mbPGXWpDxGtl6BwDQ==}
engines: {node: '>= 14.0.0'}
'@algolia/client-analytics@5.52.1':
resolution: {integrity: sha512-5oo4+I8iixie9vXhCyNFCzeIr8pqA3FQ//VsLHTDvZAV4ttYOPGvYHGQq5NSalrLx5Jc3dRro/5uDOlnUMcBJg==}
engines: {node: '>= 14.0.0'}
'@algolia/client-common@5.52.1':
resolution: {integrity: sha512-qCDoZfx5MpX7XQzvQ3bC4tSEMkQWQMaF/ABtLuoze03Y/flR563CCSws02qIJ23oX7lxl92LsilZjINVyTdtLw==}
engines: {node: '>= 14.0.0'}
'@algolia/client-insights@5.52.1':
resolution: {integrity: sha512-hnGs0/lsFJ2PWDxNBz7pxreXo/Xz7gxYRcfePBUjsH26ad0kU/sgnVZd9LwWBpsQv65z2jlb5dkyaB9WE9M9FQ==}
engines: {node: '>= 14.0.0'}
'@algolia/client-personalization@5.52.1':
resolution: {integrity: sha512-2VxxNc/uBysyKvGeBdSM5n9eIDKH8kWD7wd9/yqbJAiVwU4Yv6tU1LSJusHKrXV/aCu1KW7t9Gug9QyeEmtn/Q==}
engines: {node: '>= 14.0.0'}
'@algolia/client-query-suggestions@5.52.1':
resolution: {integrity: sha512-O6mPtsw3xEfNOe6gWFpYLeAZAIljNa4Hgna3bq15PwyN7nbjTY0wXJFRbzs/0YVf75Br+SbOQUmjKxXYjDiSiQ==}
engines: {node: '>= 14.0.0'}
'@algolia/client-search@5.52.1':
resolution: {integrity: sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==}
engines: {node: '>= 14.0.0'}
'@algolia/events@4.0.1':
resolution: {integrity: sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==}
'@algolia/ingestion@1.52.1':
resolution: {integrity: sha512-U9zZfc5xIu9wRxZkt+HceJUAD4VKHKbAyLSloJdEyMRmphXeibfrY9cxqIXBcmPeZzGhn3Imb35Dq8l19PkJhw==}
engines: {node: '>= 14.0.0'}
'@algolia/monitoring@1.52.1':
resolution: {integrity: sha512-a3SGNceHmkQfq77iG8Ka+w1pvwfZa/0lzEIgse30fL0kD+yKnd/dg0dQvSfFPAEt2f21DMcGkDSSeJlO3KdQjQ==}
engines: {node: '>= 14.0.0'}
'@algolia/recommend@5.52.1':
resolution: {integrity: sha512-z98QEguCFDpxb4S/PyrUK1igqF8tPsdbqOUUO6ON91vJ58w+Gwa6ncrI0oNXSFcrkxA5EqPKPQ2A1PBCn08TYQ==}
engines: {node: '>= 14.0.0'}
'@algolia/requester-browser-xhr@5.52.1':
resolution: {integrity: sha512-CI7+/0I11QeZM59Uc8whd2or0kqzFVjpaPn9Qpwll/krHcBAxk24WkAQ6WX+IwDVMfpont4YGbKwAmCre3vE8Q==}
engines: {node: '>= 14.0.0'}
'@algolia/requester-fetch@5.52.1':
resolution: {integrity: sha512-S6bDuw9byfOvm3T71cgdoZgrgnZq6hpdMLkx52Louh57nUAmvGQESz2aojOynQHjbTiV55smvAFbgn0qT4tJrg==}
engines: {node: '>= 14.0.0'}
'@algolia/requester-node-http@5.52.1':
resolution: {integrity: sha512-tqZXM+54rWo4mk5jL5Z/flE11nPmNEdXwFBM5py9DkOmbjeCNemfVd45FyM97XdzfZ0dl9uOJC6PYn1FpkeyQg==}
engines: {node: '>= 14.0.0'}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@ -1043,6 +1121,9 @@ packages:
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
engines: {node: '>=6.0.0'}
'@meilisearch/instant-meilisearch@0.28.0':
resolution: {integrity: sha512-QeY8w0PsoZUwzrKJyrmmi3lGRXDhzPr38t1Ns9iezoA5CD9L3YARjJJKgExybQF6KIReFw1zfJaKbPRA1xTDCA==}
'@miyaneee/rollup-plugin-json5@1.2.0':
resolution: {integrity: sha512-JjTIaXZp9WzhUHpElrqPnl1AzBi/rvRs065F71+aTmlqvTMVkdbjZ8vfFl4nRlgJy+TPBw69ZK4pwFdmOAt4aA==}
peerDependencies:
@ -1137,6 +1218,10 @@ packages:
resolution: {integrity: sha512-eGo9DjJ9NzKMbJpFU/UTd4c5iOSYuivghKD8W/jVGHs7kew+hdSMvUy401IfQB7EObKPvt/WXEutAIaTg9OsyA==}
engines: {node: '>=18.12.0'}
'@nuxt/kit@4.2.0':
resolution: {integrity: sha512-1yN3LL6RDN5GjkNLPUYCbNRkaYnat6hqejPyfIBBVzrWOrpiQeNMGxQM/IcVdaSuBJXAnu0sUvTKXpXkmPhljg==}
engines: {node: '>=18.12.0'}
'@nuxt/kit@4.4.5':
resolution: {integrity: sha512-J0BpoOomzd3iVZozYlZJ7AwAVliXRgeChZnAkQLfg8d0h/Q+aMK9kkHuhwFULASaRn5idiD4BIhOUz7/uoLbSw==}
engines: {node: '>=18.12.0'}
@ -1231,6 +1316,10 @@ packages:
resolution: {integrity: sha512-PhrQtJT6Di9uoslL5BTrBFqntFlfCaUKlO3T9ORJwmWFdowPqQeFjQ9OjVbKA6TNWr3kQhDqLbIcGlhbuG1USQ==}
engines: {node: '>=18.12.0'}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@oxc-minify/binding-android-arm-eabi@0.128.0':
resolution: {integrity: sha512-EwdDhZLRmXxSnfy0v9gdOru7TutM8ItRg1Xv8e2B4boWMnHlFCIH38JfwgQnenbkF8SVTwVJtDCkmwEzN4q3xA==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -2057,6 +2146,9 @@ packages:
cpu: [x64]
os: [win32]
'@sfxcode/nuxt-typesense@1.2.0':
resolution: {integrity: sha512-5h/nc7AL4POo/RBG+M7zl2fcK1aMED+LWuP/Ob18g9rGZa4cgArLNtp+8GwYFzGIr7aZa6fH6KM+Uuu4kbXFjQ==}
'@simple-git/args-pathspec@1.0.3':
resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==}
@ -2533,6 +2625,9 @@ packages:
'@types/dagre@0.7.54':
resolution: {integrity: sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==}
'@types/dom-speech-recognition@0.0.1':
resolution: {integrity: sha512-udCxb8DvjcDKfk1WTBzDsxFbLgYxmQGKrE/ricoMqHRNjSlSUCcamVTA5lIQqzY10mY5qCY0QDwBfFEwhfoDPw==}
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@ -2545,6 +2640,12 @@ packages:
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/google.maps@3.64.0':
resolution: {integrity: sha512-dN0H6tB4lgLQLovcbPXFYYOEV41TpyyJghzb5jrzjB96FZmjeOghevVdC+BMGd6YqyCqXaggyEtqRXLRjzCBZA==}
'@types/hogan.js@3.0.5':
resolution: {integrity: sha512-/uRaY3HGPWyLqOyhgvW9Aa43BNnLZrNeQxl2p8wqId4UHMfPKolSB+U7BlZyO1ng7MkLnyEAItsBzCG0SDhqrA==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -2563,6 +2664,9 @@ packages:
'@types/pbf@3.0.5':
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@ -2783,6 +2887,10 @@ packages:
engines: {node: '>=20'}
hasBin: true
'@vercel/oidc@3.1.0':
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
engines: {node: '>= 20'}
'@vitejs/plugin-vue-jsx@5.1.5':
resolution: {integrity: sha512-jIAsvHOEtWpslLOI2MeElGFxH7M8pM83BU/Tor4RLyiwH0FM4nUW3xdvbw20EeU9wc5IspQwMq225K3CMnJEpA==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -2968,6 +3076,9 @@ packages:
peerDependencies:
vue: ^3.5.0
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0}
@ -2995,9 +3106,24 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ai@5.0.186:
resolution: {integrity: sha512-0HVwYO9k/x5eSNggqya/75uirBLjkZoL5QdNp9ftjOCl/IXWSzqys/SzsL3ifWBz603a0KbW+EZyYVtmbFJrTQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
algoliasearch-helper@3.26.0:
resolution: {integrity: sha512-Rv2x3GXleQ3ygwhkhJubhhYGsICmShLAiqtUuJTUkr9uOCOXyF2E71LVT4XDnVffbknv8XgScP4U0Oxtgm+hIw==}
peerDependencies:
algoliasearch: '>= 3.1 < 6'
algoliasearch@5.52.1:
resolution: {integrity: sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==}
engines: {node: '>= 14.0.0'}
alien-signals@3.1.2:
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
@ -3686,9 +3812,6 @@ packages:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
driver.js@1.4.0:
resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@ -4011,6 +4134,10 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
eventsource-parser@3.0.8:
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
engines: {node: '>=18.0.0'}
execa@8.0.1:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'}
@ -4276,12 +4403,19 @@ packages:
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
hogan.js@3.0.2:
resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==}
hasBin: true
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
hookable@6.1.1:
resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==}
htm@3.1.1:
resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==}
html-entities@2.6.0:
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
@ -4350,6 +4484,17 @@ packages:
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
instantsearch-ui-components@0.12.0:
resolution: {integrity: sha512-WcyrNJ5sqeqAWLibztnssSKDhgFEdj4C1prZOHSCONxF+5RSfCsD0oqKiZrTMx3dlAL/Qx7eZxKhD56u5mxJMA==}
instantsearch.css@8.6.0:
resolution: {integrity: sha512-YweaPxsvaITBgKPxEZnuTX0XLfa4JeLBGkhrlcN2glCH1Ipk9XU1M9X+clcAEottTe8K1/q5COePW0Ozr2+0MA==}
instantsearch.js@4.81.0:
resolution: {integrity: sha512-zyIUSrikpst5eaBMrruGRGmRHOHQciDyJ8UW7i4xhPVNRwxr5Ls/NBez9AdNne+7OvALCOeaTI3t9LoBQakWhw==}
peerDependencies:
algoliasearch: '>= 3.1 < 6'
internmap@1.0.1:
resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
@ -4486,6 +4631,9 @@ packages:
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@ -4694,6 +4842,15 @@ packages:
maplibre-gl@2.4.0:
resolution: {integrity: sha512-csNFylzntPmHWidczfgCZpvbTSmhaWvLRj9e1ezUDBEPizGgshgm3ea1T5TCNEEBq0roauu7BPuRZjA3wO4KqA==}
markdown-to-jsx@7.7.17:
resolution: {integrity: sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==}
engines: {node: '>= 10'}
peerDependencies:
react: '>= 0.14.0'
peerDependenciesMeta:
react:
optional: true
marked@17.0.6:
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
engines: {node: '>= 20'}
@ -4709,6 +4866,12 @@ packages:
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
meilisearch@0.53.0:
resolution: {integrity: sha512-nG4VXbEOSzUmtbfsgOo+t6yX1ECEgXaT4hC0ap9MBpQGK5xwT+NWYDENYsKWR75cVaWaAqva+ok4zHlgtdXlLw==}
meilisearch@0.54.0:
resolution: {integrity: sha512-b1bwJAEfj8C6hgSN88+/LvW3pe3nWC+thBS2seAbPZGakf/vzsLqppgZquiomzCr2GhU7U7H289625qhYe3rbw==}
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@ -4768,9 +4931,16 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mitt@2.1.0:
resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp@0.3.0:
resolution: {integrity: sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==}
deprecated: Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
@ -4857,6 +5027,10 @@ packages:
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
nopt@1.0.10:
resolution: {integrity: sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==}
hasBin: true
nopt@8.1.0:
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
engines: {node: ^18.17.0 || >=20.5.0}
@ -4877,8 +5051,8 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
nuxt-driver.js@0.1.1:
resolution: {integrity: sha512-K8SJBzLD8oyu3mhp2vv6F6/SUEaXSp067iGLCPDiMGh4w86VdDArapPmyylH9ZvYq/aeltVPbS6S/uOuVtAXHw==}
nuxt-meilisearch@1.4.17:
resolution: {integrity: sha512-9O0dUIwuu00YAGVqQ0BVp/7kgLf3a6ONeOy6gDYtgj8sw/VI1o5tYIwg9hMjesOD1Jw/2WGL8dORkNRN2Mwedg==}
nuxt@4.4.5:
resolution: {integrity: sha512-MwTf3wyaEIm1U9/T1VKpqg7rGhhrn5Cx2ZS40lwo8GxsiY9xE7UOj5Cg0eAI0fSbJzyXlzdxspytgqWsgL+nIA==}
@ -5257,6 +5431,9 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
preact@10.29.1:
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -5322,6 +5499,10 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs@6.9.9:
resolution: {integrity: sha512-4mp+ySf3n7scxSqU6LIO9jjYCJWIdbXh3EQ8uvEc1uXiZm8sLI7moRTOvZb66c8zEHelBGEVRkSW6e9JCaayTw==}
engines: {node: '>=0.6'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@ -5338,9 +5519,16 @@ packages:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
rc9@3.0.1:
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
@ -5479,6 +5667,9 @@ packages:
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
search-insights@2.17.3:
resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@ -6151,6 +6342,19 @@ packages:
peerDependencies:
vue: ^3.0.0
vue-instantsearch@4.22.0:
resolution: {integrity: sha512-/BUdzD+NAybC81FGzEeSuCSYobYUVgHEHI7QY0HlTzdxIHOqWlw2y152/80kGKndS0C+Yu5ifGGg+xVIcFc4Dg==}
peerDependencies:
'@vue/server-renderer': ^3.1.2
algoliasearch: '>= 3.32.0 < 6'
vue: ^2.6.0 || >=3.0.0-rc.0
vue-server-renderer: ^2.6.11
peerDependenciesMeta:
'@vue/server-renderer':
optional: true
vue-server-renderer:
optional: true
vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
@ -6315,11 +6519,120 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
zod-to-json-schema@3.24.6:
resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==}
peerDependencies:
zod: ^3.24.1
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
'@ai-sdk/gateway@2.0.88(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 2.0.3
'@ai-sdk/provider-utils': 3.0.25(zod@4.4.3)
'@vercel/oidc': 3.1.0
zod: 4.4.3
'@ai-sdk/provider-utils@3.0.25(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 2.0.3
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.8
zod: 4.4.3
'@ai-sdk/provider@2.0.3':
dependencies:
json-schema: 0.4.0
'@algolia/abtesting@1.18.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
'@algolia/client-abtesting@5.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
'@algolia/client-analytics@5.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
'@algolia/client-common@5.52.1': {}
'@algolia/client-insights@5.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
'@algolia/client-personalization@5.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
'@algolia/client-query-suggestions@5.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
'@algolia/client-search@5.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
'@algolia/events@4.0.1': {}
'@algolia/ingestion@1.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
'@algolia/monitoring@1.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
'@algolia/recommend@5.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
'@algolia/requester-browser-xhr@5.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-fetch@5.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@algolia/requester-node-http@5.52.1':
dependencies:
'@algolia/client-common': 5.52.1
'@alloc/quick-lru@5.2.0': {}
'@antfu/install-pkg@1.1.0':
@ -7144,6 +7457,10 @@ snapshots:
'@mapbox/whoots-js@3.1.0': {}
'@meilisearch/instant-meilisearch@0.28.0':
dependencies:
meilisearch: 0.53.0
'@miyaneee/rollup-plugin-json5@1.2.0(rollup@4.60.3)':
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.3)
@ -7431,6 +7748,31 @@ snapshots:
transitivePeerDependencies:
- magicast
'@nuxt/kit@4.2.0(magicast@0.5.2)':
dependencies:
c12: 3.3.4(magicast@0.5.2)
consola: 3.4.2
defu: 6.1.7
destr: 2.0.5
errx: 0.1.0
exsolve: 1.0.8
ignore: 7.0.5
jiti: 2.7.0
klona: 2.0.6
mlly: 1.8.2
ohash: 2.0.11
pathe: 2.0.3
pkg-types: 2.3.1
rc9: 2.1.2
scule: 1.3.0
semver: 7.8.0
tinyglobby: 0.2.16
ufo: 1.6.4
unctx: 2.5.0
untyped: 2.0.0
transitivePeerDependencies:
- magicast
'@nuxt/kit@4.4.5(magicast@0.5.2)':
dependencies:
c12: 3.3.4(magicast@0.5.2)
@ -7543,7 +7885,7 @@ snapshots:
rc9: 3.0.1
std-env: 4.1.0
'@nuxt/ui@4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(axios@1.16.0)(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.3.0)(typescript@6.0.3)(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))(yjs@13.6.30)(zod@4.4.3)':
'@nuxt/ui@4.7.1(@internationalized/date@3.12.1)(@internationalized/number@3.6.6)(@tiptap/extensions@3.23.1(@tiptap/core@3.23.1(@tiptap/pm@3.23.1))(@tiptap/pm@3.23.1))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(axios@1.16.0)(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(react@19.2.6)(tailwindcss@4.3.0)(typescript@6.0.3)(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))(yjs@13.6.30)(zod@4.4.3)':
dependencies:
'@floating-ui/dom': 1.7.6
'@iconify/vue': 5.0.1(vue@3.5.34(typescript@6.0.3))
@ -7593,7 +7935,7 @@ snapshots:
knitwork: 1.3.0
magic-string: 0.30.21
mlly: 1.8.2
motion-v: 2.2.1(@vueuse/core@14.3.0(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
motion-v: 2.2.1(@vueuse/core@14.3.0(vue@3.5.34(typescript@6.0.3)))(react@19.2.6)(vue@3.5.34(typescript@6.0.3))
ohash: 2.0.11
pathe: 2.0.3
reka-ui: 2.9.6(vue@3.5.34(typescript@6.0.3))
@ -7761,6 +8103,8 @@ snapshots:
- supports-color
- vue
'@opentelemetry/api@1.9.0': {}
'@oxc-minify/binding-android-arm-eabi@0.128.0':
optional: true
@ -8248,6 +8592,14 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.3':
optional: true
'@sfxcode/nuxt-typesense@1.2.0(magicast@0.5.2)':
dependencies:
'@nuxt/kit': 4.4.5(magicast@0.5.2)
consola: 3.4.2
defu: 6.1.7
transitivePeerDependencies:
- magicast
'@simple-git/args-pathspec@1.0.3': {}
'@simple-git/argv-parser@1.1.1':
@ -8728,6 +9080,8 @@ snapshots:
'@types/dagre@0.7.54': {}
'@types/dom-speech-recognition@0.0.1': {}
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {}
@ -8736,6 +9090,10 @@ snapshots:
'@types/geojson@7946.0.16': {}
'@types/google.maps@3.64.0': {}
'@types/hogan.js@3.0.5': {}
'@types/json-schema@7.0.15': {}
'@types/leaflet@1.7.6':
@ -8754,6 +9112,8 @@ snapshots:
'@types/pbf@3.0.5': {}
'@types/qs@6.15.1': {}
'@types/resolve@1.20.2': {}
'@types/supercluster@5.0.3':
@ -9085,6 +9445,8 @@ snapshots:
- rollup
- supports-color
'@vercel/oidc@3.1.0': {}
'@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@babel/core': 7.29.0
@ -9321,6 +9683,8 @@ snapshots:
dependencies:
vue: 3.5.34(typescript@6.0.3)
abbrev@1.1.1: {}
abbrev@3.0.1: {}
abort-controller@3.0.0:
@ -9339,6 +9703,14 @@ snapshots:
agent-base@7.1.4: {}
ai@5.0.186(zod@4.4.3):
dependencies:
'@ai-sdk/gateway': 2.0.88(zod@4.4.3)
'@ai-sdk/provider': 2.0.3
'@ai-sdk/provider-utils': 3.0.25(zod@4.4.3)
'@opentelemetry/api': 1.9.0
zod: 4.4.3
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
@ -9346,6 +9718,28 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
algoliasearch-helper@3.26.0(algoliasearch@5.52.1):
dependencies:
'@algolia/events': 4.0.1
algoliasearch: 5.52.1
algoliasearch@5.52.1:
dependencies:
'@algolia/abtesting': 1.18.1
'@algolia/client-abtesting': 5.52.1
'@algolia/client-analytics': 5.52.1
'@algolia/client-common': 5.52.1
'@algolia/client-insights': 5.52.1
'@algolia/client-personalization': 5.52.1
'@algolia/client-query-suggestions': 5.52.1
'@algolia/client-search': 5.52.1
'@algolia/ingestion': 1.52.1
'@algolia/monitoring': 1.52.1
'@algolia/recommend': 5.52.1
'@algolia/requester-browser-xhr': 5.52.1
'@algolia/requester-fetch': 5.52.1
'@algolia/requester-node-http': 5.52.1
alien-signals@3.1.2: {}
ansi-regex@5.0.1: {}
@ -10018,8 +10412,6 @@ snapshots:
dotenv@17.4.2: {}
driver.js@1.4.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@ -10441,6 +10833,8 @@ snapshots:
events@3.3.0: {}
eventsource-parser@3.0.8: {}
execa@8.0.1:
dependencies:
cross-spawn: 7.0.6
@ -10591,11 +10985,13 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@12.38.0:
framer-motion@12.38.0(react@19.2.6):
dependencies:
motion-dom: 12.38.0
motion-utils: 12.36.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.6
fresh@2.0.0: {}
@ -10728,10 +11124,17 @@ snapshots:
hey-listen@1.0.8: {}
hogan.js@3.0.2:
dependencies:
mkdirp: 0.3.0
nopt: 1.0.10
hookable@5.5.3: {}
hookable@6.1.1: {}
htm@3.1.1: {}
html-entities@2.6.0: {}
http-errors@2.0.1:
@ -10792,6 +11195,38 @@ snapshots:
ini@4.1.1: {}
instantsearch-ui-components@0.12.0(react@19.2.6):
dependencies:
'@babel/runtime': 7.29.2
ai: 5.0.186(zod@4.4.3)
markdown-to-jsx: 7.7.17(react@19.2.6)
zod: 4.4.3
zod-to-json-schema: 3.24.6(zod@4.4.3)
transitivePeerDependencies:
- react
instantsearch.css@8.6.0: {}
instantsearch.js@4.81.0(algoliasearch@5.52.1):
dependencies:
'@algolia/events': 4.0.1
'@types/dom-speech-recognition': 0.0.1
'@types/google.maps': 3.64.0
'@types/hogan.js': 3.0.5
'@types/qs': 6.15.1
ai: 5.0.186(zod@4.4.3)
algoliasearch: 5.52.1
algoliasearch-helper: 3.26.0(algoliasearch@5.52.1)
hogan.js: 3.0.2
htm: 3.1.1
instantsearch-ui-components: 0.12.0(react@19.2.6)
preact: 10.29.1
qs: 6.9.9
react: 19.2.6
search-insights: 2.17.3
zod: 4.4.3
zod-to-json-schema: 3.24.6(zod@4.4.3)
internmap@1.0.1: {}
internmap@2.0.3: {}
@ -10902,6 +11337,8 @@ snapshots:
json-schema-traverse@0.4.1: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@2.2.3: {}
@ -11118,6 +11555,10 @@ snapshots:
tinyqueue: 2.0.3
vt-pbf: 3.1.3
markdown-to-jsx@7.7.17(react@19.2.6):
optionalDependencies:
react: 19.2.6
marked@17.0.6: {}
math-intrinsics@1.1.0: {}
@ -11126,6 +11567,10 @@ snapshots:
mdn-data@2.27.1: {}
meilisearch@0.53.0: {}
meilisearch@0.54.0: {}
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@ -11171,8 +11616,12 @@ snapshots:
dependencies:
minipass: 7.1.3
mitt@2.1.0: {}
mitt@3.0.1: {}
mkdirp@0.3.0: {}
mlly@1.8.2:
dependencies:
acorn: 8.16.0
@ -11188,10 +11637,10 @@ snapshots:
motion-utils@12.36.0: {}
motion-v@2.2.1(@vueuse/core@14.3.0(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)):
motion-v@2.2.1(@vueuse/core@14.3.0(vue@3.5.34(typescript@6.0.3)))(react@19.2.6)(vue@3.5.34(typescript@6.0.3)):
dependencies:
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@6.0.3))
framer-motion: 12.38.0
framer-motion: 12.38.0(react@19.2.6)
hey-listen: 1.0.8
motion-dom: 12.38.0
motion-utils: 12.36.0
@ -11338,6 +11787,10 @@ snapshots:
node-releases@2.0.38: {}
nopt@1.0.10:
dependencies:
abbrev: 1.1.1
nopt@8.1.0:
dependencies:
abbrev: 3.0.1
@ -11357,12 +11810,20 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuxt-driver.js@0.1.1(magicast@0.5.2):
nuxt-meilisearch@1.4.17(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(algoliasearch@5.52.1)(magicast@0.5.2)(react@19.2.6)(vue@3.5.34(typescript@6.0.3)):
dependencies:
'@nuxt/kit': 4.4.5(magicast@0.5.2)
driver.js: 1.4.0
'@meilisearch/instant-meilisearch': 0.28.0
'@nuxt/kit': 4.2.0(magicast@0.5.2)
instantsearch.css: 8.6.0
meilisearch: 0.54.0
vue-instantsearch: 4.22.0(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(algoliasearch@5.52.1)(react@19.2.6)(vue@3.5.34(typescript@6.0.3))
transitivePeerDependencies:
- '@vue/server-renderer'
- algoliasearch
- magicast
- react
- vue
- vue-server-renderer
nuxt@4.4.5(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4)(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.3))(rollup@4.60.3)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.2.8(typescript@6.0.3))(yaml@2.9.0):
dependencies:
@ -11914,6 +12375,8 @@ snapshots:
powershell-utils@0.1.0: {}
preact@10.29.1: {}
prelude-ls@1.2.1: {}
pretty-bytes@7.1.0: {}
@ -12003,6 +12466,8 @@ snapshots:
punycode@2.3.1: {}
qs@6.9.9: {}
quansync@0.2.11: {}
queue-microtask@1.2.3: {}
@ -12013,11 +12478,18 @@ snapshots:
range-parser@1.2.1: {}
rc9@2.1.2:
dependencies:
defu: 6.1.7
destr: 2.0.5
rc9@3.0.1:
dependencies:
defu: 6.1.7
destr: 2.0.5
react@19.2.6: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
@ -12176,6 +12648,8 @@ snapshots:
scule@1.3.0: {}
search-insights@2.17.3: {}
semver@6.3.1: {}
semver@7.8.0: {}
@ -12857,6 +13331,18 @@ snapshots:
'@vue/devtools-api': 6.6.4
vue: 3.5.34(typescript@6.0.3)
vue-instantsearch@4.22.0(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(algoliasearch@5.52.1)(react@19.2.6)(vue@3.5.34(typescript@6.0.3)):
dependencies:
algoliasearch: 5.52.1
instantsearch-ui-components: 0.12.0(react@19.2.6)
instantsearch.js: 4.81.0(algoliasearch@5.52.1)
mitt: 2.1.0
vue: 3.5.34(typescript@6.0.3)
optionalDependencies:
'@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@6.0.3))
transitivePeerDependencies:
- react
vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)):
dependencies:
'@vue/devtools-api': 6.6.4
@ -13018,4 +13504,8 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.7.0
zod-to-json-schema@3.24.6(zod@4.4.3):
dependencies:
zod: 4.4.3
zod@4.4.3: {}

View File

@ -1,6 +0,0 @@
allowBuilds:
'@parcel/watcher': true
esbuild: true
maplibre-gl: true
unrs-resolver: true
vue-demi: true

BIN
public/favicon.ico Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<clipPath id="_clip1">
<circle cx="500" cy="500" r="470"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g transform="matrix(41.330743,0,0,41.330743,-2116.926503,-46.100585)">
<path d="M88.473,25.492C79.652,22.367 63.422,23.441 59.117,25.473C77.457,13.191 100.926,2.789 128.516,0.344C134.094,-0.098 141.602,-0.145 148.871,0.402C183.086,2.969 210.52,21.004 226.969,43C211.91,29.102 187.027,10.215 147.359,10.215C116.781,10.215 93.781,22.441 88.473,25.492Z" style="fill:rgb(236,27,35);"/>
</g>
<g transform="matrix(41.330743,0,0,41.330743,-2116.926503,-46.100585)">
<path d="M29.234,25.473C20.656,22.078 4.145,23.656 0.199,25.418C22.531,9.531 54.453,-1.191 101.211,0.512C57.047,3.617 32.789,23.152 29.234,25.473Z" style="fill:rgb(45,77,162);"/>
</g>
<g transform="matrix(41.330743,0,0,41.330743,-2116.926503,-46.100585)">
<path d="M59.117,25.535C50.535,22.141 33.18,23.711 29.234,25.473C51.57,9.586 79.883,-2.051 128.961,0.473C94.258,2.949 69.059,19.316 59.117,25.535Z" style="fill:rgb(254,254,254);"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,202 +0,0 @@
#!/usr/bin/env node
/**
* Benchmark de red para el buscador: compara la latencia del patrón de
* búsqueda ANTES de las optimizaciones (varias requests secuenciales) contra
* el patrón DESPUÉS (una sola request con join), golpeando directamente el
* cluster real de Typesense sin pasar por el navegador ni por Nuxt.
*
* Sirve como línea base repetible: correr este script antes/después de un
* cambio futuro en las queries de búsqueda muestra si mejoró o empeoró la
* latencia real contra el servidor, no solo "se siente más rápido".
*
* Uso:
* node --env-file=.env scripts/benchmark-search.mjs
* node --env-file=.env scripts/benchmark-search.mjs --iterations 20 --query "amor"
* pnpm run benchmark:search -- --iterations 20
*
* Requiere NUXT_PUBLIC_TYPESENSE_URL y NUXT_PUBLIC_TYPESENSE_API_KEY en el
* entorno (--env-file=.env los carga automáticamente en Node 20.6+).
*/
const args = process.argv.slice(2)
function argValue(name, fallback) {
const idx = args.indexOf(`--${name}`)
return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback
}
const ITERATIONS = Number(argValue('iterations', '15'))
const QUERY = argValue('query', 'amor')
const LOCALE = argValue('locale', 'es')
const TYPESENSE_URL = process.env.NUXT_PUBLIC_TYPESENSE_URL
const API_KEY = process.env.NUXT_PUBLIC_TYPESENSE_API_KEY
if (!TYPESENSE_URL || !API_KEY) {
console.error('Faltan NUXT_PUBLIC_TYPESENSE_URL / NUXT_PUBLIC_TYPESENSE_API_KEY.')
console.error('Corré con: node --env-file=.env scripts/benchmark-search.mjs')
process.exit(1)
}
async function multiSearch(searches) {
const res = await fetch(`${TYPESENSE_URL}/multi_search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-TYPESENSE-API-KEY': API_KEY
},
body: JSON.stringify({ searches })
})
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
return res.json()
}
async function timeIt(fn) {
const start = performance.now()
await fn()
return performance.now() - start
}
function stats(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const sum = sorted.reduce((a, b) => a + b, 0)
const p = q => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]
return {
mean: sum / sorted.length,
median: p(0.5),
p95: p(0.95),
min: sorted[0],
max: sorted[sorted.length - 1]
}
}
async function runScenario(name, fn) {
const samples = []
// Un warmup fuera de la medición, para no medir handshake TLS/DNS frío.
await fn().catch(() => {})
for (let i = 0; i < ITERATIONS; i++) {
samples.push(await timeIt(fn))
}
return { name, ...stats(samples) }
}
// ── Escenarios por colección (conferences / activities) ─────────────────────
const COLLECTIONS = [
{ label: 'conferences', main: 'conferences', paragraphs: 'conferences_paragraphs', groupBy: 'conferences_id' },
{ label: 'activities', main: 'activities', paragraphs: 'activities_paragraphs', groupBy: 'activities_id' }
]
function oldSearchScenario({ main, paragraphs, groupBy }) {
return async () => {
// 1) búsqueda de párrafos, SIN join (como antes de la Fase 1.1)
const r1 = await multiSearch([{
collection: paragraphs,
q: QUERY,
query_by: 'text',
filter_by: `locale:=${LOCALE}`,
per_page: 10,
highlight_full_fields: 'text',
highlight_fields: 'text',
highlight_affix_num_tokens: 30,
group_by: groupBy
}])
const ids = (r1.results?.[0]?.grouped_hits ?? [])
.map(g => g.group_key?.[0])
.filter(Boolean)
if (!ids.length) return
// 2) segunda request para la metadata (la cascada eliminada en 1.1)
await multiSearch([{
collection: main,
q: '*',
query_by: 'title',
filter_by: `id:=[${ids.join(',')}]`,
per_page: ids.length,
include_fields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
}])
}
}
function newSearchScenario({ main, paragraphs, groupBy }) {
return async () => {
// Una sola request: join a la colección principal + highlight recortado
await multiSearch([{
collection: paragraphs,
q: QUERY,
query_by: 'text',
filter_by: `locale:=${LOCALE}`,
per_page: 10,
highlight_full_fields: 'text',
highlight_fields: 'text',
highlight_affix_num_tokens: 15,
group_by: groupBy,
include_fields: `*, $${main}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
}])
}
}
function oldBrowseScenario({ main, paragraphs, groupBy }) {
return async () => {
// Explorar sin query contra la colección grande de párrafos (antes de 1.2)
await multiSearch([{
collection: paragraphs,
q: '*',
query_by: 'text',
filter_by: `locale:=${LOCALE} && $${main}(locale:=${LOCALE})`,
sort_by: `$${main}(timestamp:desc)`,
group_by: groupBy,
per_page: 10,
include_fields: `$${main}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
}])
}
}
function newBrowseScenario({ main }) {
return async () => {
// Explorar sin query contra la colección principal, directo (después de 1.2)
await multiSearch([{
collection: main,
q: '*',
query_by: 'title',
filter_by: `locale:=${LOCALE}`,
sort_by: 'timestamp:desc',
per_page: 10,
include_fields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
}])
}
}
function printTable(rows) {
const cols = ['name', 'mean', 'median', 'p95', 'min', 'max']
const widths = cols.map(c => Math.max(c.length, ...rows.map(r => String(typeof r[c] === 'number' ? r[c].toFixed(1) : r[c]).length)))
const fmtRow = vals => vals.map((v, i) => String(v).padEnd(widths[i])).join(' ')
console.log(fmtRow(cols.map(c => c.toUpperCase())))
console.log(widths.map(w => '-'.repeat(w)).join(' '))
for (const r of rows) {
console.log(fmtRow(cols.map(c => (typeof r[c] === 'number' ? r[c].toFixed(1) + 'ms' : r[c]))))
}
}
async function main() {
console.log(`Typesense: ${TYPESENSE_URL} | query="${QUERY}" | locale=${LOCALE} | iteraciones=${ITERATIONS}\n`)
for (const col of COLLECTIONS) {
console.log(`\n=== ${col.label} — búsqueda con texto ===`)
const oldR = await runScenario('antes (2 requests)', oldSearchScenario(col))
const newR = await runScenario('después (1 request)', newSearchScenario(col))
printTable([oldR, newR])
const improvement = ((oldR.mean - newR.mean) / oldR.mean * 100).toFixed(1)
console.log(`${improvement}% más rápido en promedio`)
console.log(`\n=== ${col.label} — explorar sin query ===`)
const oldB = await runScenario('antes (colección párrafos)', oldBrowseScenario(col))
const newB = await runScenario('después (colección principal)', newBrowseScenario(col))
printTable([oldB, newB])
const improvementB = ((oldB.mean - newB.mean) / oldB.mean * 100).toFixed(1)
console.log(`${improvementB}% más rápido en promedio`)
}
}
main().catch((err) => {
console.error('Error corriendo el benchmark:', err)
process.exit(1)
})

View File

@ -1,47 +0,0 @@
import { defineEventHandler, readBody, createError } from 'h3'
export default defineEventHandler(async (event) => {
const { message, recaptchaToken } = await readBody(event)
if (!message?.trim()) {
throw createError({ statusCode: 400, message: 'Mensaje vacío' })
}
const config = useRuntimeConfig(event)
const webhookUrl = config.public.feedbackWebhook
const feedbackToken = config.feedbackToken
if (!webhookUrl) {
console.error('[feedback] Webhook URL no configurada')
throw createError({ statusCode: 500, message: 'Webhook no configurado' })
}
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (feedbackToken) {
headers['X-Feedback-Token'] = feedbackToken
}
try {
const res = await $fetch(webhookUrl, {
method: 'POST',
headers,
body: { message, recaptchaToken },
retry: 1,
timeout: 10000
})
return { ok: true, data: res }
} catch (e: any) {
console.error('[feedback] Error al reenviar al webhook:', {
url: webhookUrl,
status: e?.response?.status,
statusText: e?.response?.statusText,
message: e?.message
})
const statusCode = e?.response?.status || 502
const detail = e?.response?.statusText || e?.message || 'Error al reenviar el feedback'
throw createError({ statusCode, message: detail })
}
})

View File

@ -1,8 +0,0 @@
export default defineEventHandler((event) => {
const url = getRequestURL(event)
if (url.pathname.includes('estudios-biblicos')) {
const newPath = url.pathname.replace('estudios-biblicos', 'estudios')
return sendRedirect(event, newPath + url.search, 301)
}
})