Compare commits

..

1 Commits

65 changed files with 2511 additions and 6292 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 --------------------------------------------- */
@ -94,19 +76,15 @@ mark.search-match.is-current {
100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; }
}
/* Span de coincidencia en la vista de detalle de estudios Typesense.
Marca el párrafo que el usuario clickeó desde los resultados de búsqueda. */
#bible-study-search-match {
background-color: #fdff32;
color: #000;
padding: 2px;
border-radius: 2px;
font-weight: 600;
box-shadow: 0 0 0 1px #e9ff32 inset;
/* Colors for Typesense rich text */
.red {
color: #C00000 !important;
}
.dark #bible-study-search-match {
background-color: #fdff32;
color: #000;
box-shadow: 0 0 0 1px #e9ff32 inset;
.purple {
color: #7030A0 !important;
}
.blue {
color: #0070C0 !important;
}

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>

File diff suppressed because it is too large Load Diff

View File

@ -1,111 +1,76 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { computed, watch } from 'vue'
import { useFavoritesStore } from '~/stores/favorites'
import { useHistoryStore } from '~/stores/history'
import type { SearchHit } from '~/types'
interface Study {
id?: number
title?: string
date?: string
place?: string
link?: string
}
interface EntrelineaDoc {
id?: string
image?: string
link?: string
locale?: string
origin?: string
filter?: string
page?: number | string
text?: string
html?: string
draft?: boolean
studies?: Study[]
[key: string]: unknown
}
const props = defineProps<{
document: EntrelineaDoc
/** Identificador de la colección dentro del store de favoritos.
* Si no se pasa, no se renderiza el botón de favorito. */
collection?: string
/** Versión del campo `text` con las coincidencias ya envueltas en
* <mark class="search-match"> por Typesense. Si viene, se usa para
* mostrar el cuerpo con resaltado; si no, cae al `document.text`. */
highlightedText?: string | null
noTrackVisit?: boolean
}>()
const emits = defineEmits<{ close: [] }>()
const showStudies = ref(false)
/** URL de ImageKit con preset `detail` (1800px, q90). La construcción
* centralizada vive en `~/utils/entrelineaImage` para no duplicarla. */
const imageUrl = computed<string | null>(() => {
return getEntrelineaImageUrl(props.document?.image, 'detail')
})
// Volver al detalle cuando cambia el documento
watch(() => props.document?.id, () => { showStudies.value = false })
const title = computed(() => {
return (props.document?.id as string) || 'Entrelínea'
})
const { unlocked } = useDevMode()
const imageUrl = computed<string | null>(() =>
getEntrelineaImageUrl(props.document?.image, 'detail')
)
const origin = computed(() => (props.document?.origin as string) || '')
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 || '')
)
const bodyHtml = computed<string>(() => {
return props.highlightedText || props.document?.text || ''
})
/* -------------------------------------------------------------------------- */
/* Favoritos / Historial */
/* Favoritos */
/* -------------------------------------------------------------------------- */
const favorites = useFavoritesStore()
const history = useHistoryStore()
const toast = useToast()
/** Adapta el documento de Typesense al shape SearchHit que pide el store
* de favoritos/historial (que se reusa entre todos los buscadores). */
function toSearchHit(doc: EntrelineaDoc): SearchHit {
const id = doc.id || ''
return { _id: id, id, title: doc.text?.slice(0, 100) || 'Entrelínea', body: doc.text, ...doc }
return {
_id: id,
id,
title: id || 'Entrelínea',
body: doc.text,
...doc
}
}
// Registrar la visita en el historial cada vez que cambia el documento mostrado.
// El store deduplica por (collection, id): si el usuario reabre la misma
// entrelínea, el item salta arriba y se actualiza visitedAt. No registramos
// si no nos pasaron `collection` o si el documento aún no tiene id.
watch(
() => [props.collection, props.document?.id] as const,
([collection, id]) => {
if (!collection || !id) return
if (!props.noTrackVisit) history.visit(collection, toSearchHit(props.document))
history.visit(collection, toSearchHit(props.document))
},
{ immediate: true }
)
@ -119,449 +84,131 @@ 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
})
}
/* -------------------------------------------------------------------------- */
/* Desktop hover zoom (lente + panel magnificado) */
/* -------------------------------------------------------------------------- */
const zoomBoxRef = ref<HTMLElement | null>(null)
const zoomImgRef = ref<HTMLImageElement | null>(null)
const zoomActive = ref(false)
const cursorX = ref(0)
const cursorY = ref(0)
const imgW = ref(0)
const imgH = ref(0)
const imgOffsetX = ref(0)
const imgOffsetY = ref(0)
const boxRight = ref(0)
const boxLeft = ref(0)
const boxTop = ref(0)
const ZOOM_LEVEL = 2.5
const PANEL_W = 400
const PANEL_H = 400
const LENS_W = PANEL_W / ZOOM_LEVEL
const LENS_H = PANEL_H / ZOOM_LEVEL
function refreshImageRect() {
if (!zoomImgRef.value || !zoomBoxRef.value) return
const ir = zoomImgRef.value.getBoundingClientRect()
const br = zoomBoxRef.value.getBoundingClientRect()
imgW.value = ir.width
imgH.value = ir.height
imgOffsetX.value = ir.left - br.left
imgOffsetY.value = ir.top - br.top
boxRight.value = br.right
boxLeft.value = br.left
boxTop.value = br.top
}
function onZoomEnter() {
refreshImageRect()
zoomActive.value = true
}
function onZoomLeave() {
zoomActive.value = false
}
function onZoomMove(e: MouseEvent) {
if (!zoomImgRef.value || !imgW.value) return
const r = zoomImgRef.value.getBoundingClientRect()
let x = e.clientX - r.left
let y = e.clientY - r.top
x = Math.max(LENS_W / 2, Math.min(imgW.value - LENS_W / 2, x))
y = Math.max(LENS_H / 2, Math.min(imgH.value - LENS_H / 2, y))
cursorX.value = x
cursorY.value = y
}
const lensStyle = computed(() => ({
left: `${imgOffsetX.value + cursorX.value - LENS_W / 2}px`,
top: `${imgOffsetY.value + cursorY.value - LENS_H / 2}px`,
width: `${LENS_W}px`,
height: `${LENS_H}px`,
}))
const zoomPanelStyle = computed(() => {
if (!imgW.value) return {}
const bgX = -(cursorX.value * ZOOM_LEVEL - PANEL_W / 2)
const bgY = -(cursorY.value * ZOOM_LEVEL - PANEL_H / 2)
const vw = typeof window !== 'undefined' ? window.innerWidth : 1600
const left = (boxRight.value + 16 + PANEL_W <= vw)
? boxRight.value + 16
: Math.max(8, boxLeft.value - 16 - PANEL_W)
return {
left: `${left}px`,
top: `${boxTop.value}px`,
width: `${PANEL_W}px`,
height: `${PANEL_H}px`,
backgroundImage: `url("${imageUrl.value}")`,
backgroundSize: `${imgW.value * ZOOM_LEVEL}px ${imgH.value * ZOOM_LEVEL}px`,
backgroundPosition: `${bgX}px ${bgY}px`,
backgroundRepeat: 'no-repeat',
}
})
watch(imageUrl, () => { setTimeout(refreshImageRect, 50) })
onMounted(() => {
window.addEventListener('resize', refreshImageRect)
window.addEventListener('scroll', refreshImageRect, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('resize', refreshImageRect)
window.removeEventListener('scroll', refreshImageRect)
})
/* -------------------------------------------------------------------------- */
/* Mobile lightbox con pinch-to-zoom */
/* -------------------------------------------------------------------------- */
const lightboxOpen = ref(false)
const lightboxScale = ref(1)
const lbTranslateX = ref(0)
const lbTranslateY = ref(0)
let lbInitialDist = 0
let lbInitialScale = 1
let lbLastTouchX = 0
let lbLastTouchY = 0
let lbLastMidX = 0
let lbLastMidY = 0
const LB_MIN_SCALE = 1
const LB_MAX_SCALE = 5
function openLightbox() {
lightboxScale.value = LB_MIN_SCALE
lbTranslateX.value = 0
lbTranslateY.value = 0
lightboxOpen.value = true
}
function closeLightbox() {
lightboxOpen.value = false
}
function getTouchDist(t1: Touch, t2: Touch): number {
return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
}
function onLightboxTouchStart(e: TouchEvent) {
if (e.touches.length === 1) {
lbLastTouchX = e.touches[0].clientX
lbLastTouchY = e.touches[0].clientY
} else if (e.touches.length === 2) {
lbInitialDist = getTouchDist(e.touches[0], e.touches[1])
lbInitialScale = lightboxScale.value
lbLastMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2
lbLastMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2
}
}
function onLightboxTouchMove(e: TouchEvent) {
if (e.touches.length === 1 && lightboxScale.value > 1) {
const dx = e.touches[0].clientX - lbLastTouchX
const dy = e.touches[0].clientY - lbLastTouchY
lbTranslateX.value += dx
lbTranslateY.value += dy
lbLastTouchX = e.touches[0].clientX
lbLastTouchY = e.touches[0].clientY
} else if (e.touches.length === 2) {
const dist = getTouchDist(e.touches[0], e.touches[1])
lightboxScale.value = Math.max(LB_MIN_SCALE, Math.min(LB_MAX_SCALE, lbInitialScale * (dist / lbInitialDist)))
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2
lbTranslateX.value += midX - lbLastMidX
lbTranslateY.value += midY - lbLastMidY
lbLastMidX = midX
lbLastMidY = midY
if (lightboxScale.value <= LB_MIN_SCALE) {
lbTranslateX.value = 0
lbTranslateY.value = 0
}
}
}
function onLightboxTouchEnd() {
if (lightboxScale.value <= LB_MIN_SCALE) {
lightboxScale.value = LB_MIN_SCALE
lbTranslateX.value = 0
lbTranslateY.value = 0
}
}
const lightboxImgStyle = computed(() => ({
transform: `translate(${lbTranslateX.value}px, ${lbTranslateY.value}px) scale(${lightboxScale.value})`,
transformOrigin: 'center center',
}))
</script>
<template>
<UDashboardPanel id="entrelinea-detail">
<UDashboardNavbar
:toggle="false"
:ui="{
root: 'h-auto min-h-(--ui-header-height) py-2 items-start sm:items-center',
left: 'flex-1 min-w-0 py-1',
title: 'flex items-center gap-1.5 font-semibold text-highlighted min-w-0 whitespace-normal'
}"
>
<UDashboardNavbar :title="document.studies?.[0]?.title" :toggle="false">
<template #leading>
<!-- En sub-pantalla de estudios: volver al detalle -->
<UButton
v-if="showStudies"
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="-ms-1.5"
@click="showStudies = false"
/>
<!-- En detalle: cerrar el panel -->
<UButton
v-else
icon="i-lucide-arrow-left"
icon="i-lucide-x"
color="neutral"
variant="ghost"
class="-ms-1.5"
@click="emits('close')"
/>
<UBadge
v-if="document.studies?.[0]?.date"
:label="String(document.studies[0].date).toUpperCase()"
color="primary"
variant="subtle"
/>
</template>
<template #right>
<template v-if="!showStudies">
<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'"
:color="isFav ? 'primary' : 'neutral'"
variant="ghost"
:aria-label="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"
@click="onToggleFavorite"
/>
</UTooltip>
</template>
<UBadge
v-if="document.page"
:label="`Página ${document.page}`"
color="error"
variant="subtle"
size="sm"
/>
<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"
:aria-label="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"
@click="onToggleFavorite"
/>
</UTooltip>
</template>
</UDashboardNavbar>
<div
v-if="!showStudies"
class="flex items-start justify-between gap-3 px-4 sm:px-6 py-2.5 border-b border-default bg-elevated/40 min-w-0 w-full"
>
<div class="flex flex-wrap items-center gap-2 min-w-0 flex-1">
<UBadge
v-if="document.locale"
:label="String(document.locale).toUpperCase()"
color="neutral"
variant="subtle"
size="xs"
class="shrink-0"
/>
<div class="flex flex-col sm:flex-row justify-between gap-2 p-4 sm:px-6 border-b border-default">
<div class="min-w-0 flex items-center gap-2 text-xs text-muted">
<UBadge
v-if="document.filter"
:label="String(document.filter)"
:label="String(document.filter).toUpperCase()"
color="neutral"
variant="subtle"
size="xs"
class="shrink-0 uppercase"
/>
<span
v-if="origin"
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>
<UBadge
v-if="document.type"
:label="String(document.type).toUpperCase()"
color="neutral"
variant="subtle"
/>
<UBadge
v-if="document.origin"
:label="String(document.origin).toUpperCase()"
color="neutral"
variant="soft"
/>
</div>
</div>
<!-- ------------------------------------------------------------------ -->
<!-- VISTA PRINCIPAL: imagen + texto + accesos -->
<!-- ------------------------------------------------------------------ -->
<div v-if="!showStudies" class="flex-1 overflow-y-auto">
<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)">
<!-- 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"
@click="openLightbox"
<div class="flex-1 overflow-y-auto relative bg-gray-100">
<!--
Padding extra a la derecha (pe-16/20) y al fondo (pb-24) para que el
FAB sticky de favorito NUNCA se superponga al contenido: el contenedor
reserva una "zona muerta" en la esquina inferior derecha donde el
botón puede flotar sin tapar texto ni la imagen.
-->
<div class="p-1 sm:p-4 bg-white rounded-lg shadow-md max-w-4xl m-4 sm:mx-auto sm:my-6">
<!-- Imagen desde ImageKit -->
<div
v-if="imageUrl"
class="rounded-lg overflow-hidden border border-default bg-elevated flex items-center justify-center mb-2"
>
<img
:src="imageUrl"
:alt="title"
loading="lazy"
class="max-w-full h-auto"
>
<img :src="imageUrl" :alt="title" loading="lazy" class="w-full h-full object-contain 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>
</div>
<!-- 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"
@mouseenter="onZoomEnter"
@mouseleave="onZoomLeave"
@mousemove="onZoomMove"
>
<img
ref="zoomImgRef"
:src="imageUrl"
:alt="title"
loading="lazy"
class="max-h-full max-w-full object-contain drop-shadow-sm select-none"
draggable="false"
@load="refreshImageRect"
/>
<!-- Lente: marca el área magnificada -->
<div
v-show="zoomActive"
class="absolute pointer-events-none border-2 border-white shadow-lg rounded"
style="background-color: rgba(255,255,255,0.35);"
:style="lensStyle"
/>
</div>
</template>
<!-- Sin imagen / Borrador -->
</div>
<div
v-else
class="rounded-xl border border-dashed border-default p-8 text-center text-xs text-dimmed"
class="rounded-lg border border-dashed border-default p-8 text-center text-xs text-dimmed"
>
<UIcon name="i-lucide-image-off" class="size-6 mb-1 mx-auto" />
<p>Imagen no disponible</p>
<p>Sin imagen disponible</p>
</div>
<!-- Texto -->
<div v-if="bodyHtml" class="rounded-xl border border-default bg-white dark:bg-neutral-900 shadow-sm p-4 sm:p-6">
<article
class="prose prose-sm max-w-none dark:prose-invert leading-relaxed"
v-html="bodyHtml"
/>
</div>
<!-- Entrelínea (text) usa la versión resaltada si Typesense la
devolvió, si no el HTML crudo del documento. -->
<article
v-if="bodyHtml"
>
<div
v-html="bodyHtml"
class="prose prose-sm max-w-none"
/>
<USeparator class="my-4" />
<p class="font-semibold text-sm">{{ document.origin }}</p>
</article>
<p v-else class="text-sm text-muted">
No hay entrelínea para esta coincidencia.
</p>
<!-- Link al sitio -->
<a
v-if="document.link"
:href="document.link"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 hover:bg-primary/10 transition-colors px-4 py-3 group"
>
<UIcon name="i-lucide-external-link" class="size-5 text-primary shrink-0" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-primary">Ver en el sitio</p>
<p class="text-xs text-muted truncate">{{ document.link }}</p>
</div>
<UIcon name="i-lucide-chevron-right" class="size-4 text-primary/60 shrink-0 group-hover:translate-x-0.5 transition-transform" />
</a>
</div>
<!--
FAB de favorito mismo patrón que en InboxActivity: contenedor
`sticky bottom-0 h-0` con `pointer-events-none` para que ocupe cero
altura en el flujo y el botón quede anclado al borde inferior
visible mientras el usuario hace scroll. El padding `pe-16/pb-24`
del contenedor de contenido garantiza que NUNCA tape texto ni imagen.
-->
</div>
<!-- ------------------------------------------------------------------ -->
<!-- SUB-PANTALLA: lista de estudios relacionados -->
<!-- ------------------------------------------------------------------ -->
<div v-else class="flex-1 overflow-y-auto divide-y divide-default">
<a
v-for="study in document.studies"
:key="study.id"
:href="study.link"
target="_blank"
rel="noopener noreferrer"
:title="study.title"
class="flex items-center gap-3 px-4 sm:px-6 py-3.5 hover:bg-primary/5 transition-colors group"
>
<div class="flex flex-col justify-center shrink-0 w-14 text-center">
<template v-if="study.date">
<span class="text-base font-bold text-highlighted tabular-nums leading-none">
{{ new Date(study.date + 'T00:00:00').getUTCDate() }}
</span>
<span class="text-[10px] uppercase tracking-wide text-muted leading-tight mt-0.5">
{{ new Date(study.date + 'T00:00:00').toLocaleDateString('es', { month: 'short', timeZone: 'UTC' }) }}
{{ new Date(study.date + 'T00:00:00').getUTCFullYear() }}
</span>
</template>
<UIcon v-else name="i-lucide-calendar-x" class="size-4 text-dimmed mx-auto" />
</div>
<div class="w-px self-stretch bg-default shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-highlighted leading-snug group-hover:text-primary transition-colors line-clamp-2">
{{ study.title }}
</p>
<p v-if="study.place" class="text-xs text-muted truncate mt-0.5">
{{ study.place }}
</p>
</div>
<UIcon name="i-lucide-arrow-up-right" class="size-4 text-muted shrink-0 group-hover:text-primary transition-colors" />
</a>
</div>
<!-- Panel de zoom desktop: Teleport a body para escapar del stacking context -->
<Teleport to="body">
<div
v-show="zoomActive"
class="hidden lg:block fixed pointer-events-none bg-white border border-gray-200 shadow-2xl rounded-lg overflow-hidden"
style="z-index: 9999;"
:style="zoomPanelStyle"
/>
</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>
<div
class="touch-none flex items-center justify-center overflow-hidden"
style="height: 70dvh"
@touchstart.passive="onLightboxTouchStart"
@touchmove.passive="onLightboxTouchMove"
@touchend.passive="onLightboxTouchEnd"
>
<img
v-if="imageUrl"
:src="imageUrl"
:alt="title"
class="w-full h-full object-contain select-none"
:style="lightboxImgStyle"
draggable="false"
/>
</div>
</template>
</UModal>
</UDashboardPanel>
</template>

View File

@ -305,8 +305,8 @@ 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')
@ -377,10 +377,10 @@ function onInputKey(e: KeyboardEvent) {
<template>
<UDashboardPanel id="activity-detail">
<UDashboardNavbar :toggle="false">
<UDashboardNavbar :title="activity.title" :toggle="false">
<template #leading>
<UButton
icon="i-lucide-arrow-left"
icon="i-lucide-x"
color="neutral"
variant="ghost"
class="-ms-1.5"
@ -388,14 +388,11 @@ function onInputKey(e: KeyboardEvent) {
/>
</template>
<template #title>
<span class="truncate font-semibold text-sm min-w-0 block" :title="activity.title">
{{ activity.title }}
</span>
</template>
<template #right>
<UTooltip :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'">
<!-- El botón de favorito ya no vive aquí: ahora es un FAB flotante
en la esquina inferior derecha del panel para que siga visible
mientras se lee. Ver más abajo en el scroll container. -->
<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'"
@ -404,96 +401,96 @@ function onInputKey(e: KeyboardEvent) {
@click="onToggleFavorite"
/>
</UTooltip>
</template>
</UDashboardNavbar>
<!-- Metadata + controles -->
<div class="flex flex-col gap-3 p-4 sm:px-6 border-b border-default shadow-sm z-10">
<!-- Fila 1: fecha, lugar y enlace externo -->
<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="safeDate(activity)" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
<UIcon name="ph:calendar" class="size-4 text-green-600" />
{{ safeDate(activity) }}
</p>
<p v-if="formatLocation(activity)" class="text-sm flex items-center gap-1.5 text-muted min-w-0">
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
<span class="truncate max-w-[220px]">{{ formatLocation(activity) }}</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"
/>
<div class="flex flex-col sm:flex-row justify-between gap-2 p-4 sm:px-6 border-b border-default shadow-lg z-10">
<div class="min-w-0 flex gap-1 sm:gap-4 sm:items-center flex-col sm:flex-row">
<p class=" text-sm text-highlighted flex items-center gap-1 mb-0.5">
<UIcon name="ph:calendar" class="size-5 text-green-600" />
{{ safeDate(activity) }}
</p>
<p class="text-sm truncate flex items-center gap-1">
<UIcon name="ph:map-pin" class="size-5 text-green-600" />
{{ formatLocation(activity) }}
</p>
</div>
<!-- file links hidden -->
<!-- Fila 3: búsqueda local en el documento -->
<div
v-if="activity?.body"
class="flex items-center gap-2"
>
<UInput
v-model="localQuery"
icon="i-lucide-search"
placeholder="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"
/>
</template>
</UInput>
<template v-if="localQuery">
<span
class="text-xs tabular-nums whitespace-nowrap shrink-0 px-2 py-1 rounded-md bg-elevated border border-default"
:class="totalMatches ? 'text-toned font-medium' : 'text-dimmed'"
<UFieldGroup>
<UInput
v-model="localQuery"
icon="i-lucide-search"
placeholder="Buscar frase exacta en este documento..."
class="w-full"
:ui="{ trailing: 'pe-1' }"
@keydown="onInputKey"
>
{{ totalMatches ? `${currentIdx + 1} / ${totalMatches}` : '0 / 0' }}
</span>
<template #trailing>
<UButton
v-if="localQuery"
icon="i-lucide-x"
variant="link"
aria-label="Limpiar"
@click="clearLocalQuery"
/>
</template>
</UInput>
<div class="flex items-center gap-1 shrink-0">
<UTooltip text="Anterior (Shift+Enter)">
<UButton
icon="i-lucide-chevron-up"
:disabled="!totalMatches"
aria-label="Coincidencia anterior"
color="neutral"
variant="ghost"
size="sm"
@click="prevMatch"
/>
</UTooltip>
<UTooltip text="Siguiente (Enter)">
<UButton
icon="i-lucide-chevron-down"
:disabled="!totalMatches"
aria-label="Coincidencia siguiente"
color="neutral"
variant="ghost"
size="sm"
@click="nextMatch"
/>
</UTooltip>
</div>
</template>
<UBadge v-if="localQuery" class="bg-gray-400 text-white">
<span
class="text-xs tabular-nums whitespace-nowrap min-w-[3.5rem] text-center text-white"
:class="totalMatches ? 'text-toned font-medium' : 'text-dimmed'"
>
<template v-if="localQuery">
{{ totalMatches ? `${currentIdx + 1} / ${totalMatches}` : '0 / 0' }}
</template>
</span>
</UBadge>
<UTooltip text="Anterior (Shift+Enter)">
<UButton
icon="i-lucide-chevron-up"
:disabled="!totalMatches"
aria-label="Coincidencia anterior"
class="bg-gray-400"
color="neutral"
@click="prevMatch"
/>
</UTooltip>
<UTooltip text="Siguiente (Enter)">
<UButton
icon="i-lucide-chevron-down"
:disabled="!totalMatches"
aria-label="Coincidencia siguiente"
class="bg-gray-400"
color="neutral"
@click="nextMatch"
/>
</UTooltip>
</UFieldGroup>
</div>
<!-- <ul v-if="fileLinks.length" class="flex flex-wrap gap-3 text-xs text-muted">
<li
v-for="(f, idx) in fileLinks"
:key="idx"
class="flex items-center"
>
<ULink
:to="f.to"
:target="f.target"
class="flex items-center gap-1 hover:text-primary"
>
<UIcon :name="f.icon!" class="size-4" />
<span>{{ f.label }}</span>
</ULink>
</li>
</ul> -->
</div>
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative bg-gray-100">

View File

@ -16,9 +16,6 @@ const props = defineProps<{
* Se propaga al sistema de favoritos para distinguir entre tipos de
* contenido (actividades, conferencias, etc.). */
collection?: string
/** Muestra el mensaje "No hay más resultados" al llegar al final.
* Poner en false en modo de paginación numerada. */
showEndMessage?: boolean
}>()
const favorites = useFavoritesStore()
@ -642,11 +639,6 @@ const rows = computed<RowVm[]>(() => {
return result
})
// ---- Scroll element (exposed for parent scroll tracking / restoration) ---
const listEl = ref<HTMLElement | null>(null)
defineExpose({ listEl })
// ---- Infinite scroll ---------------------------------------------------
const sentinel = ref<HTMLElement | null>(null)
@ -664,7 +656,7 @@ useIntersectionObserver(
</script>
<template>
<div ref="listEl" class="overflow-y-auto divide-y divide-default flex-1">
<div class="overflow-y-auto divide-y divide-default flex-1">
<div
v-if="loading && !rows.length"
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
@ -779,7 +771,7 @@ useIntersectionObserver(
</div>
<div
v-else-if="rows.length && !hasMore && !loading && showEndMessage !== false"
v-else-if="rows.length && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados

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,56 +0,0 @@
import { watch, onMounted, onBeforeUnmount, toValue } from 'vue'
import type { Ref, WritableComputedRef } from 'vue'
/**
* Intercepts the mobile hardware back button when a detail panel is open,
* closing the panel instead of navigating away from the page.
*
* Strategy: when the panel opens on mobile, push a history entry with the
* same URL. When the user presses back, popstate fires and we close the
* panel. When the user closes via the X button, we programmatically pop
* the entry we pushed so the stack stays clean.
*/
export function useDetailHistory(
isOpen: WritableComputedRef<boolean> | Ref<boolean>,
isMobile: Ref<boolean>
) {
let pushed = false
let fromPopState = false
let suppressNext = false
function handlePopState() {
if (suppressNext) {
suppressNext = false
return
}
if (pushed && toValue(isMobile)) {
fromPopState = true
pushed = false
isOpen.value = false
}
}
watch(isOpen, (open, wasOpen) => {
if (!toValue(isMobile)) return
if (open && !wasOpen) {
history.pushState({ detailPanel: true }, '')
pushed = true
} else if (!open && wasOpen) {
if (fromPopState) {
// Back button closed the panel — state already popped by the browser.
fromPopState = false
} else if (pushed) {
// X button (or any programmatic close) — pop the entry we pushed.
pushed = false
suppressNext = true
history.go(-1)
}
}
})
if (import.meta.client) {
onMounted(() => window.addEventListener('popstate', handlePopState))
onBeforeUnmount(() => window.removeEventListener('popstate', handlePopState))
}
}

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

@ -4,4 +4,10 @@
*
* La fuente única de verdad es `app/stores/favorites.ts`.
*/
export { useFavoritesStore as useFavorites } from '~/stores/favorites'
export { useFavoritesStore as useFavorites } from '~/stores/favorites'
export type {
FavoriteItem,
FavoritesFile,
ImportResult,
Collection
} from '~/stores/favorites'

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

@ -3,4 +3,10 @@
*
* La fuente única de verdad es `app/stores/history.ts`.
*/
export { useHistoryStore as useHistory } from '~/stores/history'
export { useHistoryStore as useHistory } from '~/stores/history'
export type {
HistoryItem,
HistoryFile,
ImportResult,
Collection
} from '~/stores/history'

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,146 +0,0 @@
import { watch, onBeforeUnmount } from 'vue'
import type { Ref, ComputedRef } from 'vue'
// ─── Types ───────────────────────────────────────────────────────────────────
export interface UrlSearchState {
query: string
page: number
scroll: number
selectedId: string | null
}
export interface UseSearchUrlSyncOptions {
query: Ref<string>
page: Ref<number>
/** Computed or ref yielding the ID of the currently selected item */
selectedId: Ref<string | null> | ComputedRef<string | null>
/** The scrollable list container, if any (for scroll tracking & restoration) */
scrollEl?: Ref<HTMLElement | null> | ComputedRef<HTMLElement | null>
}
// ─── Read initial state from URL ─────────────────────────────────────────────
/**
* Reads search state from the current route's query params.
* Safe to call during SSR (falls back to empty defaults).
*
* Call this BEFORE creating your reactive refs and initialise them with
* the returned values so the first search uses the URL state directly
* this avoids a redundant re-search triggered by the debounced watcher.
*
* @example
* const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
* const query = ref(q0) // debouncedQuery starts at q0 → watcher won't re-fire
* const activePage = ref(p0)
*/
export function useSearchUrlState(): UrlSearchState {
const route = useRoute()
return {
query: String(route.query.q ?? ''),
page: Math.max(1, parseInt(String(route.query.page ?? '1'), 10) || 1),
scroll: Math.max(0, parseInt(String(route.query.scroll ?? '0'), 10) || 0),
selectedId: route.query.selected ? String(route.query.selected) : null,
}
}
// ─── Keep state in sync with URL ─────────────────────────────────────────────
/**
* Watches query, page, selectedId and (optionally) scroll on a container
* element, writing each change to the URL via history.replaceState so the
* state can be copied and restored by any user.
*
* Uses replaceState (not pushState) so the browser history stack stays clean.
* Existing URL params unrelated to search are preserved.
*/
export function useSearchUrlSync(options: UseSearchUrlSyncOptions): void {
if (!import.meta.client) return
let scrollTimer: ReturnType<typeof setTimeout> | null = null
let queryTimer: ReturnType<typeof setTimeout> | null = null
// ── Write a partial patch to the current URL ────────────────────────────
function patchUrl(params: Record<string, string | null>) {
const url = new URL(window.location.href)
for (const [key, val] of Object.entries(params)) {
if (val !== null && val !== '') {
url.searchParams.set(key, val)
} else {
url.searchParams.delete(key)
}
}
history.replaceState(history.state ?? null, '', url.toString())
}
// ── Watchers ────────────────────────────────────────────────────────────
// Query: debounce to avoid thrashing on every keystroke.
// Also resets page and scroll since a new query starts fresh.
watch(options.query, (q) => {
if (queryTimer) clearTimeout(queryTimer)
queryTimer = setTimeout(
() => patchUrl({ q: q || null, page: null, scroll: null }),
150,
)
})
// Page: immediate — user navigated intentionally.
// Scroll position is no longer meaningful when page changes.
watch(options.page, (p) => {
patchUrl({ page: p > 1 ? String(p) : null, scroll: null })
})
// Selected item: immediate.
watch(options.selectedId, (id) => {
patchUrl({ selected: id || null })
})
// ── Scroll tracking ─────────────────────────────────────────────────────
if (options.scrollEl) {
function handleScroll(ev: Event) {
if (scrollTimer) clearTimeout(scrollTimer)
scrollTimer = setTimeout(() => {
const top = (ev.target as HTMLElement).scrollTop
patchUrl({ scroll: top > 0 ? String(Math.round(top)) : null })
}, 400)
}
watch(
options.scrollEl,
(el, oldEl) => {
;(oldEl as HTMLElement | null)?.removeEventListener('scroll', handleScroll)
;(el as HTMLElement | null)?.addEventListener('scroll', handleScroll, { passive: true })
},
{ immediate: true },
)
onBeforeUnmount(() => {
;(options.scrollEl!.value as HTMLElement | null)
?.removeEventListener('scroll', handleScroll)
})
}
onBeforeUnmount(() => {
if (scrollTimer) clearTimeout(scrollTimer)
if (queryTimer) clearTimeout(queryTimer)
})
}
// ─── Scroll restoration helper ────────────────────────────────────────────────
/**
* Restores a scroll position on an element after a short settling delay.
* Call this inside onMounted, after awaiting the first search, so the list
* has had time to render its items.
*/
export function restoreScrollPosition(
scrollEl: HTMLElement | null | undefined,
scrollY: number,
delayMs = 80,
): void {
if (!scrollY || !scrollEl || !import.meta.client) return
setTimeout(() => { scrollEl.scrollTop = scrollY }, delayMs)
}

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,81 @@ 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.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'),
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>

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

@ -0,0 +1,201 @@
<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'
const { $i18n } = useNuxtApp();
const t = $i18n.t;
const PAGE_SIZE = 15
const REQUEST_TIMEOUT_MS = 15000
const query = ref('')
const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
// Use the raw Meilisearch client so we can pass an AbortSignal.
const meili = useMeiliSearchRef()
const hits = ref<SearchHit[]>([])
const total = ref(0)
const hasMore = computed(() => hits.value.length < total.value)
let searchSeq = 0
let abortController: AbortController | null = null
async function runSearch(q: string, append = false) {
// Cancel any in-flight search; saves bandwidth and prevents pile-ups.
abortController?.abort()
const ac = new AbortController()
abortController = ac
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
// Safety net in case the network just hangs.
const timeoutId = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
try {
const res = await meili.index(`activities_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
attributesToRetrieve: ['*'],
showMatchesPosition: true,
limit: PAGE_SIZE,
offset: append ? hits.value.length : 0,
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
} catch (err: unknown) {
const name = (err as { name?: string })?.name
// Aborts are expected when the user types fast; don't surface them.
if (name === 'AbortError') return
if (seq !== searchSeq) return
console.error('Meilisearch error', err)
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 (loadingMore.value || loading.value || !hasMore.value) return
runSearch(query.value, true)
}
// Initial fetch on the client (avoids blocking SSR).
onMounted(() => runSearch(''))
onBeforeUnmount(() => {
abortController?.abort()
})
watch(debouncedQuery, (q) => {
hits.value = []
total.value = 0
runSearch(q, false)
})
const selectedActivity = ref<SearchHit | null>(null)
const isActivityPanelOpen = computed({
get() { return !!selectedActivity.value },
set(value: boolean) { if (!value) selectedActivity.value = null }
})
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')
function retry() {
runSearch(query.value, false)
}
</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
v-model="selectedActivity"
:activities="hits"
:query="debouncedQuery"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
collection="activities"
@load-more="loadMore"
/>
</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>

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

@ -1,13 +1,200 @@
<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'
const { $i18n } = useNuxtApp();
const t = $i18n.t;
const PAGE_SIZE = 15
const REQUEST_TIMEOUT_MS = 15000
const query = ref('')
const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
const meili = useMeiliSearchRef()
const hits = ref<SearchHit[]>([])
const total = ref(0)
const hasMore = computed(() => hits.value.length < total.value)
let searchSeq = 0
let abortController: AbortController | null = null
async function runSearch(q: string, 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)
try {
const res = await meili.index(`conferences_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
attributesToRetrieve: ['*'],
showMatchesPosition: true,
limit: PAGE_SIZE,
offset: append ? hits.value.length : 0,
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
} 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 (loadingMore.value || loading.value || !hasMore.value) return
runSearch(query.value, true)
}
onMounted(() => runSearch(''))
onBeforeUnmount(() => {
abortController?.abort()
})
watch(debouncedQuery, (q) => {
hits.value = []
total.value = 0
runSearch(q, false)
})
const selected = ref<SearchHit | null>(null)
const isPanelOpen = computed({
get() { return !!selected.value },
set(value: boolean) { if (!value) selected.value = null }
})
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')
function retry() {
runSearch(query.value, false)
}
</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="Conferencias">
<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
v-model="selected"
:activities="hits"
:query="debouncedQuery"
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
collection="conferences"
@load-more="loadMore"
/>
</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

@ -1,119 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useSettingsStore, PAGE_SIZE_OPTIONS } from '~/stores/settings'
const { $i18n } = useNuxtApp()
const t = $i18n.t
const settings = useSettingsStore()
const { pageSize, paginationType, showParagraphNumbers } = storeToRefs(settings)
const pageSizeItems = PAGE_SIZE_OPTIONS.map(n => ({
value: n,
label: `${n} ${t('settings.results')}`
}))
const paginationItems = [
{
value: 'infinite_scroll' as const,
label: t('settings.infinite_scroll'),
description: t('settings.infinite_scroll_desc')
},
{
value: 'numbered' as const,
label: t('settings.numbered'),
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>
<UDashboardPanel id="settings-panel" grow>
<UDashboardNavbar :title="t('nav.settings')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
<div class="overflow-y-auto flex-1">
<div class="p-6 space-y-8">
<!-- Resultados por búsqueda -->
<div>
<p class="text-sm font-semibold text-highlighted mb-1">
{{ t('settings.page_size_title') }}
</p>
<p class="text-xs text-muted mb-4">{{ t('settings.page_size_desc') }}</p>
<URadioGroup v-model="pageSize" :items="pageSizeItems" />
</div>
<USeparator />
<!-- Tipo de paginación -->
<div>
<p class="text-sm font-semibold text-highlighted mb-1">
{{ t('settings.pagination_title') }}
</p>
<p class="text-xs text-muted mb-4">{{ t('settings.pagination_desc') }}</p>
<URadioGroup v-model="paginationType" :items="paginationItems" />
</div>
<USeparator />
<!-- Números de párrafo -->
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-highlighted mb-1">
{{ t('settings.paragraph_numbers_title') }}
</p>
<p class="text-xs text-muted">{{ t('settings.paragraph_numbers_desc') }}</p>
</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>
</template>

View File

@ -1,69 +1,215 @@
<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 */
/* -------------------------------------------------------------------------- */
/** Nombre de la colección de Typesense a consultar. */
const COLLECTION = 'entrelineas'
/** Campos por los que se hará el match de la búsqueda (separados por coma). */
const QUERY_BY = 'text'
/** Campos a traer en cada hit (separados por coma). Usa '*' para traerlos todos. */
const INCLUDE_FIELDS = '*'
/** Filtros adicionales (sintaxis filter_by de Typesense) a combinar con el
* filtro automático por idioma. Ej: 'page:>10', 'origin:=Nota'.
* Dejar vacío si no se necesita nada extra el filtro por `locale` se
* añade siempre a partir del idioma actual de i18n. */
const EXTRA_FILTER_BY = ''
/** Cantidad de resultados por página. */
const PAGE_SIZE = 15
/** Identificador de la colección para el store de favoritos.
* Vale cualquier string único entre buscadores. */
const FAVORITES_COLLECTION = 'entrelineas'
// Nota: la base de ImageKit y los presets de transformación viven en
// `~/utils/entrelineaImage.ts` (auto-importado). Ahí se cambia si toca
// migrar de cuenta o ajustar tamaños.
/* -------------------------------------------------------------------------- */
/* Estado */
/* -------------------------------------------------------------------------- */
const { $i18n } = useNuxtApp()
const t = $i18n.t
// `locale` es reactivo: cambia cuando el usuario usa el switch de idiomas.
// Lo usamos para construir dinámicamente el `filter_by` de Typesense.
const { locale } = useI18n()
const { unlocked } = useDevMode()
/** Filtro completo que mandamos a Typesense locale dinámico + lo que el
* usuario haya puesto en `EXTRA_FILTER_BY`. Es un computed para que se
* reevalúe automáticamente cuando cambia el idioma. */
const filterBy = computed(() => {
const localeFilter = `locale:=${locale.value}`
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
})
const settings = useSettingsStore()
const REQUEST_TIMEOUT_MS = 15000
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
const query = ref('')
const debouncedQuery = useDebounce(query, 150)
interface Study {
id?: number
title?: string
date?: string
place?: string
link?: string
}
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
interface EntrelineaDoc {
id?: string
image?: string
link?: string
locale?: string
description?: string
page?: number | string
text?: string
html?: string
draft?: boolean
studies?: Study[]
[key: string]: unknown
}
const {
query, debouncedQuery, loading, loadingMore, errorMsg, exactSearch,
hits, total, activePage, totalPages, hasMore,
runSearch, loadMore, goToPage, retry
} = useFlatTypesenseSearch<EntrelineaDoc>({
collection: COLLECTION,
queryBy: QUERY_BY,
filterBy: () => filterBy.value,
includeFields: INCLUDE_FIELDS,
pageSize: () => settings.pageSize,
paginationType: () => settings.paginationType,
initialQuery: q0,
initialPage: p0
interface TypesenseHighlight {
field?: string
snippet?: string
value?: string
matched_tokens?: string[]
}
interface TypesenseHit {
document: EntrelineaDoc
highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number
}
interface TypesenseSearchResponse {
found: number
out_of?: number
page?: number
hits?: TypesenseHit[]
}
const hits = ref<TypesenseHit[]>([])
const total = ref(0)
const currentPage = ref(1)
const hasMore = computed(() => hits.value.length < total.value)
/* -------------------------------------------------------------------------- */
/* Búsqueda */
/* -------------------------------------------------------------------------- */
// Usamos `documentsApi.multiSearch` (POST con body JSON) en vez de
// `searchCollection` porque el wrapper auto-generado del módulo serializa mal
// los parámetros del GET y Typesense responde con error.
const { documentsApi } = useTypesenseApi()
let searchSeq = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
async function runSearch(q: string, append = false) {
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
if (timeoutId) clearTimeout(timeoutId)
// Safety net: si la red se cuelga, sacamos los spinners igualmente.
timeoutId = setTimeout(() => {
if (seq === searchSeq) {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}
}, REQUEST_TIMEOUT_MS)
const page = append ? currentPage.value + 1 : 1
try {
const multi = await documentsApi.multiSearch({
// Parámetros comunes a todas las búsquedas; vacío porque cada búsqueda
// ya lleva los suyos. El codegen exige el campo aunque no se use.
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
// El serializer del módulo (MultiSearchCollectionParametersToJSON)
// espera las claves en camelCase y las traduce a snake_case antes
// de mandar el body. Si pasas snake_case directamente las ignora
// silenciosamente y Typesense recibe la búsqueda sin filtros.
collection: COLLECTION,
q: q || '*',
queryBy: QUERY_BY,
includeFields: INCLUDE_FIELDS,
filterBy: filterBy.value,
perPage: PAGE_SIZE,
page,
// Resaltado nativo de Typesense devuelve `value` con los matches
// envueltos en <mark class="search-match"> para que estilemos en
// CSS junto con los otros buscadores (ya hay regla global).
highlightFullFields: QUERY_BY,
highlightFields: QUERY_BY,
highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>'
}]
}
})
// Si arrancó otra búsqueda mientras esta venía en camino, descartamos.
if (seq !== searchSeq) return
const 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 = 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 (loadingMore.value || loading.value || !hasMore.value) return
runSearch(query.value, true)
}
function retry() {
runSearch(query.value, false)
}
onMounted(() => runSearch(''))
onBeforeUnmount(() => {
if (timeoutId) clearTimeout(timeoutId)
})
watch(debouncedQuery, (q) => {
hits.value = []
total.value = 0
currentPage.value = 1
runSearch(q, false)
})
/* -------------------------------------------------------------------------- */
/* Selección / panel de detalle */
/* -------------------------------------------------------------------------- */
const selected = ref<EntrelineaDoc | null>(null)
const isPanelOpen = computed({
@ -71,43 +217,31 @@ const isPanelOpen = computed({
set(v: boolean) { if (!v) selected.value = null }
})
// Si el listado se actualiza y el seleccionado ya no está, cerramos el panel.
watch(hits, () => {
if (!selected.value) return
const stillThere = hits.value.find(h => h.document.id === selected.value?.id)
if (!stillThere) selected.value = null
})
const selectedId = computed(() => selected.value?.id?.toString() ?? null)
const listEl = ref<HTMLElement | null>(null)
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listEl })
onMounted(async () => {
await runSearch(q0, p0, false)
restoreScrollPosition(listEl.value, s0)
if (sid0) {
const found = hits.value.find(h => h.document.id === sid0)
if (found) selected.value = found.document
}
})
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
useDetailHistory(isPanelOpen, isMobile)
/* -------------------------------------------------------------------------- */
/* Favoritos */
/* -------------------------------------------------------------------------- */
const favorites = useFavoritesStore()
const toast = useToast()
/** Adapta el documento de Typesense al shape SearchHit que pide el store
* de favoritos (que se reusa entre todos los buscadores). */
function toSearchHit(doc: EntrelineaDoc): SearchHit {
const id = doc.id || ''
return {
_id: id,
id,
title: doc.text?.slice(0, 100) || 'Entrelínea',
title: id || 'Entrelínea',
body: doc.text,
...doc
}
@ -119,23 +253,32 @@ function isFav(doc: EntrelineaDoc): boolean {
}
function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
// Evita que el click en la estrella abra el detalle.
ev?.stopPropagation()
if (!doc?.id) return
const wasFav = isFav(doc)
favorites.toggle(FAVORITES_COLLECTION, toSearchHit(doc))
toast.add({
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
description: doc.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 */
/* -------------------------------------------------------------------------- */
/** Devuelve el snippet o value resaltado por Typesense para un campo, o null
* si no hay match. Se prefiere `snippet` (más corto, recortado alrededor del
* match) sobre `value` (campo completo) para no inflar la fila. */
function highlightedFor(hit: TypesenseHit, field: string): string | null {
const fromArr = hit.highlights?.find(h => h.field === field)
if (fromArr?.snippet) return fromArr.snippet
if (fromArr?.value) return fromArr.value
// Algunas versiones devuelven `highlight: { field: { snippet, value } }`.
const fromObj = hit.highlight?.[field]
if (fromObj?.snippet) return fromObj.snippet
if (fromObj?.value) return fromObj.value
@ -155,238 +298,201 @@ 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"
/>
<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>
<code class="text-toned">{{ COLLECTION }}</code>
·
<code class="text-toned">{{ QUERY_BY }}</code>
·
<code class="text-toned">{{ filterBy }}</code>
</span>
</p>
</div>
<UAlert
v-if="errorMsg"
:title="errorMsg"
color="error"
variant="subtle"
icon="i-lucide-triangle-alert"
class="mx-4 my-2"
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
<div 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-database" class="size-3" />
<span>
<code class="text-toned">{{ COLLECTION }}</code>
·
<code class="text-toned">{{ QUERY_BY }}</code>
·
<code class="text-toned">{{ FILTER_BY }}</code>
</span>
</p>
</div>
<div ref="listEl" class="overflow-y-auto divide-y divide-default flex-1">
<div
v-if="loading && !hits.length"
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
Buscando...
</div>
<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
v-else-if="!hits.length"
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
>
<UIcon name="i-lucide-inbox" class="size-10" />
<p>{{ query ? `Sin coincidencias para "${query}"` : 'Sin resultados' }}</p>
</div>
<div class="overflow-y-auto divide-y divide-default flex-1">
<div
v-if="loading && !hits.length"
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
Buscando...
</div>
<div
v-for="(hit, index) in hits"
:key="hit.document.id ?? index"
class="p-4 sm:px-6 text-sm cursor-pointer transition-colors border-b-2"
:class="[
selected && selected.id === hit.document.id
? 'bg-primary/10 text-highlighted'
: 'border-gray-200 text-toned hover:bg-primary/5'
]"
@click="selected = hit.document"
>
<div class="flex items-start justify-between gap-2 mb-1">
<div class="min-w-0 flex-1 flex gap-2">
<UBadge
v-if="hit.document?.filter"
:label="hit.document?.filter"
size="sm"
variant="subtle"
color="neutral"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.type"
:label="hit.document?.type"
size="sm"
variant="subtle"
color="neutral"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.draft"
label="Borrador"
size="sm"
variant="subtle"
color="warning"
class="mb-1"
/>
</div>
<UTooltip :text="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'">
<UButton
:icon="isFav(hit.document) ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
:color="isFav(hit.document) ? 'primary' : 'neutral'"
variant="ghost"
size="xs"
:aria-label="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
@click="(ev: MouseEvent) => toggleFavorite(hit.document, ev)"
/>
</UTooltip>
<div
v-else-if="!hits.length"
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
>
<UIcon name="i-lucide-inbox" class="size-10" />
<p>{{ query ? `Sin coincidencias para "${query}"` : 'Sin resultados' }}</p>
</div>
<div
v-for="(hit, index) in hits"
:key="hit.document.id ?? index"
class="p-4 sm:px-6 text-sm cursor-pointer transition-colors border-b-2"
:class="[
selected && selected.id === hit.document.id
? 'bg-primary/10 text-highlighted'
: 'border-gray-200 text-toned hover:bg-primary/5'
]"
@click="selected = hit.document"
>
<div class="flex items-start justify-between gap-2 mb-1">
<!-- Título pequeño = Origin (cae al id si no hay origin para no
dejar la fila huérfana). -->
<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"
size="sm"
variant="subtle"
color="neutral"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.type"
:label="hit.document?.type"
size="sm"
variant="subtle"
color="neutral"
class="mb-1 uppercase"
/>
</div>
<div
v-if="highlightedFor(hit, 'text') || hit.document.text"
class="snippet-html text-sm text-toned"
v-html="highlightedFor(hit, 'text') || hit.document.text"
/>
<USeparator class="my-2"/>
<UTooltip :text="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'">
<UButton
:icon="isFav(hit.document) ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
:color="isFav(hit.document) ? 'primary' : 'neutral'"
variant="ghost"
size="xs"
:aria-label="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
@click="(ev: MouseEvent) => toggleFavorite(hit.document, ev)"
/>
</UTooltip>
</div>
<div
v-if="settings.paginationType === 'infinite_scroll' && hasMore && !loading"
class="p-4 flex justify-center"
>
<UButton
variant="outline"
color="neutral"
size="sm"
:loading="loadingMore"
@click="loadMore"
>
Cargar más
</UButton>
<div class="text-sm font-semibold tracking-wide truncate mb-2">
{{ (hit.document?.studies?.[0]?.title as string) || hit.document.id || `entrelinea_${index}` }}
</div>
<!-- La entrelínea (text). Si Typesense devolvió un snippet con
highlight, lo pintamos con v-html para que aparezca el <mark>;
si no, mostramos el HTML crudo de `text`. -->
<div
v-else-if="settings.paginationType === 'infinite_scroll' && hits.length && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados
v-if="highlightedFor(hit, 'text') || hit.document.text"
class="snippet-html text-sm text-toned line-clamp-3"
v-html="highlightedFor(hit, 'text') || hit.document.text"
/>
<USeparator class="my-2"/>
<div class="text-xs text-dimmed">
{{ hit.document?.origin }}
</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"
v-if="hasMore && !loading"
class="p-4 flex justify-center"
>
<UPagination
:page="activePage"
:total="total"
:items-per-page="settings.pageSize"
<UButton
variant="outline"
color="neutral"
size="sm"
@update:page="goToPage"
/>
:loading="loadingMore"
@click="loadMore"
>
Cargar más
</UButton>
</div>
</template>
</UDashboardPanel>
<template v-if="unlocked">
<EntrelineaDetail
v-if="selected && !isMobile"
:document="selected"
:collection="FAVORITES_COLLECTION"
:highlighted-text="selected.id ? (hits.find(h => h.document.id === selected!.id)?.highlights?.find(hl => hl.field === 'text')?.value ?? null) : null"
@close="selected = null"
/>
<div v-else-if="!isMobile" class="hidden lg:flex flex-1 items-center justify-center">
<div class="flex flex-col items-center gap-2 text-dimmed">
<UIcon name="i-lucide-mouse-pointer-click" class="size-16" />
<p class="text-sm">
Selecciona una entrelínea para ver el detalle
</p>
<div
v-else-if="hits.length && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados
</div>
</div>
</UDashboardPanel>
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
<template #content>
<EntrelineaDetail
v-if="selected"
:document="selected"
:collection="FAVORITES_COLLECTION"
:highlighted-text="selected.id ? (hits.find(h => h.document.id === selected!.id)?.highlights?.find(hl => hl.field === 'text')?.value ?? null) : null"
@close="selected = null"
/>
</template>
</USlideover>
</ClientOnly>
</template>
<!-- Panel de detalle (escritorio) -->
<EntrelineaDetail
v-if="selected && !isMobile"
:document="selected"
:collection="FAVORITES_COLLECTION"
:highlighted-text="selected.id ? (hits.find(h => h.document.id === selected!.id)?.highlights?.find(hl => hl.field === 'text')?.value ?? null) : null"
@close="selected = null"
/>
<div v-else-if="!isMobile" class="hidden lg:flex flex-1 items-center justify-center">
<div class="flex flex-col items-center gap-2 text-dimmed">
<UIcon name="i-lucide-mouse-pointer-click" class="size-16" />
<p class="text-sm">
Selecciona una entrelínea para ver el detalle
</p>
</div>
</div>
<!-- Panel de detalle (móvil) -->
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
<template #content>
<EntrelineaDetail
v-if="selected"
:document="selected"
:collection="FAVORITES_COLLECTION"
:highlighted-text="selected.id ? (hits.find(h => h.document.id === selected!.id)?.highlights?.find(hl => hl.field === 'text')?.value ?? null) : null"
@close="selected = null"
/>
</template>
</USlideover>
</ClientOnly>
</template>
<style scoped>
/* El estilo del <mark class="search-match"> ya está definido globalmente en
`assets/css/main.css`. Aquí sólo nos aseguramos de que las etiquetas
inline dentro del snippet (mark, strong, em, etc.) no rompan el layout. */
.snippet-html :deep(p) {
display: inline;
margin: 0;

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,16 +0,0 @@
import { useSettingsStore } from '~/stores/settings'
/**
* Hidratación cliente del store de ajustes.
* Mismo patrón que favorites.client.ts: `enforce: 'post'` garantiza que
* este plugin corre DESPUÉS de que Pinia aplique el estado SSR (vacío),
* para que los valores del localStorage no sean sobreescritos.
*/
export default defineNuxtPlugin({
name: 'settings-hydration',
enforce: 'post',
setup() {
const settings = useSettingsStore()
settings.hydrate()
}
})

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

@ -7,10 +7,10 @@ import type { SearchHit } from '~/types'
* cualquier otra que se sume en el futuro). El store no asume un set cerrado
* para que añadir nuevos buscadores no requiera tocar este archivo.
*/
export type FavoritesCollection = string
export type Collection = string
export interface FavoriteItem {
collection: FavoritesCollection
collection: Collection
_id: string | number
/** Snapshot del documento de Meilisearch para que el favorito se pueda
* renderizar (título, fecha, ubicación, body) sin volver a consultar. */
@ -24,7 +24,7 @@ export interface FavoritesFile {
items: FavoriteItem[]
}
export interface ImportFavoritesResult {
export interface ImportResult {
added: number
skipped: number
invalid: number
@ -221,7 +221,7 @@ export const useFavoritesStore = defineStore('favorites', () => {
}
}
function importFromJson(json: string, mode: 'merge' | 'replace' = 'merge'): ImportFavoritesResult {
function importFromJson(json: string, mode: 'merge' | 'replace' = 'merge'): ImportResult {
let parsed: unknown
try {
parsed = JSON.parse(json)

View File

@ -16,10 +16,10 @@ import type { SearchHit } from '~/types'
* - El identificador de colección no es un set cerrado funciona con
* cualquier colección que se sume en el futuro.
*/
export type HistoryCollection = string
export type Collection = string
export interface HistoryItem {
collection: HistoryCollection
collection: Collection
_id: string | number
/** Snapshot del documento para poder mostrarlo en el historial sin volver a
* consultar al backend (igual que hacemos con favoritos). */
@ -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

View File

@ -1,87 +0,0 @@
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
export type PaginationType = 'infinite_scroll' | 'numbered'
export const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50] as const
export type PageSizeOption = typeof PAGE_SIZE_OPTIONS[number]
const STORAGE_KEY = 'lgcc:settings:v1'
interface SettingsData {
pageSize: PageSizeOption
paginationType: PaginationType
showParagraphNumbers: boolean
}
const DEFAULTS: SettingsData = {
pageSize: 10,
paginationType: 'infinite_scroll',
showParagraphNumbers: true
}
function readStorage(): SettingsData {
if (typeof window === 'undefined') return { ...DEFAULTS }
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return { ...DEFAULTS }
const parsed = JSON.parse(raw) as Partial<SettingsData>
return {
pageSize: (PAGE_SIZE_OPTIONS as readonly number[]).includes(parsed.pageSize as number)
? (parsed.pageSize as PageSizeOption)
: DEFAULTS.pageSize,
paginationType:
parsed.paginationType === 'numbered' || parsed.paginationType === 'infinite_scroll'
? parsed.paginationType
: DEFAULTS.paginationType,
showParagraphNumbers: typeof parsed.showParagraphNumbers === 'boolean'
? parsed.showParagraphNumbers
: DEFAULTS.showParagraphNumbers
}
} catch {
return { ...DEFAULTS }
}
}
function writeStorage(data: SettingsData) {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch (e) {
console.warn('No se pudieron guardar los ajustes', e)
}
}
export const useSettingsStore = defineStore('settings', () => {
const pageSize = ref<PageSizeOption>(DEFAULTS.pageSize)
const paginationType = ref<PaginationType>(DEFAULTS.paginationType)
const showParagraphNumbers = ref<boolean>(DEFAULTS.showParagraphNumbers)
const ready = ref(false)
let hydrated = false
function hydrate() {
if (typeof window === 'undefined') return
if (hydrated) return
const data = readStorage()
pageSize.value = data.pageSize
paginationType.value = data.paginationType
showParagraphNumbers.value = data.showParagraphNumbers
ready.value = true
hydrated = true
}
if (typeof window !== 'undefined') {
watch([pageSize, paginationType, showParagraphNumbers], () => {
if (!hydrated) return
writeStorage({ pageSize: pageSize.value, paginationType: paginationType.value, showParagraphNumbers: showParagraphNumbers.value })
})
}
return {
pageSize,
paginationType,
showParagraphNumbers,
ready,
hydrate
}
})

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,220 +1,171 @@
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
export function formatFiles( files:FilesObject ){
const { $i18n } = useNuxtApp();
const t = $i18n.t;
const items = []
if (files) {
if (files.youtube) {
items.push({
to: files.youtube,
target: '_blank',
label: 'Youtube',
icon: 'ph:youtube-logo-thin',
labelClass: 'text-xs'
})
let items = []
if (files) {
if (files.youtube) {
items.push({
to: files.youtube,
target: '_blank',
label: 'Youtube',
icon: 'ph:youtube-logo-thin',
labelClass: 'text-xs',
})
}
if (files.video) {
items.push({
to: files.video,
target: '_blank',
label: t('Activities.video'),
icon: 'ph:file-video-thin',
labelClass: 'text-xs'
})
}
if (files.audio) {
items.push({
to: files.audio,
target: '_blank',
label: t('Activities.audio'),
icon: 'ph:file-audio-thin',
labelClass: 'text-xs'
})
}
if (files.booklet) {
items.push({
to: files.booklet,
target: '_blank',
label: t('Activities.book'),
icon: 'ph:notebook-thin',
labelClass: 'text-xs'
})
}
if (files.simple) {
items.push({
to: files.simple,
target: '_blank',
label: t('Activities.simple'),
icon: 'ph:note-thin',
labelClass: 'text-xs'
})
}
}
if (files.video) {
items.push({
to: files.video,
target: '_blank',
label: t('downloads.video'),
icon: 'ph:file-video-thin',
labelClass: 'text-xs'
})
return items
}
export function formatDate( d:number ){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000)
let dateString = date.toLocaleString(locale.value, { year: 'numeric', month:'long', day: 'numeric', weekday:'long', timeZone:'UTC' })
return capitalizeFirstLetter( dateString );
}
export function formatLocation( i: ItemObject){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
const regionNames = new Intl.DisplayNames(
[ locale.value], {type: 'region'}
);
var locationParts = [];
locationParts.push(i?.place);
locationParts.push(i?.city);
locationParts.push(i?.state);
if( i.country ){
locationParts.push(regionNames.of(i.country));
}
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,
target: '_blank',
label: t('downloads.audio'),
icon: 'ph:file-audio-thin',
labelClass: 'text-xs'
})
return locationParts.filter(Boolean).join(', ');
}
export function getDay( d:number ){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
return date.toLocaleString(locale.value, { weekday: 'long', timeZone: 'utc' });
}
export function getDayDate( d:number ){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
return date.toLocaleString(locale.value, { day: 'numeric', timeZone: 'utc' });
}
export function getMonth( d:number ){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
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 month = (date.month()+1).toString()
const slug = (item.slug || item.slug === 'undefined' ? item.slug : item.id);
if( isSearch ){
return false;
} else {
return `/${locale.value}/${item.type}/${date.year()}/${month.padStart(2, '0')}/${slug}`;
}
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,
target: '_blank',
label: t('downloads.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,
target: '_blank',
label: t('downloads.simple'),
icon: 'ph:note-thin',
labelClass: 'text-xs'
})
}
}
return items
}
export function formatDate(d: number) {
if (!d) {
return
}
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
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)
}
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 locationParts = []
locationParts.push(i?.place)
locationParts.push(i?.city)
locationParts.push(i?.state)
if (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
}
export function getDay(d: number) {
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const date = new Date(d * 1000)
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)
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)
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 month = (date.month() + 1).toString()
const slug = item.slug || item.slug === 'undefined' ? item.slug : item.id
if (isSearch) {
return false
} else {
return `/${locale.value}/${item.type}/${date.year()}/${month.padStart(2, '0')}/${slug}`
}
}
export function getThumbnail(item: ItemObject) {
const path = item.thumbnail
export function getThumbnail( item:ItemObject ) {
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`
}
}
export function getAuthor(type: string) {
let author = ''
switch (type) {
case 'activities':
author = 'Dr. José Benjamín Pérez Matos'
break
case 'conferences':
author = 'Dr. William Soto Santiago'
break
case 'sermons':
author = 'William Marrion Branham'
break
}
return author
export function getAuthor( type:string ){
let author = ''
switch (type){
case "activities":
author = 'Dr. José Benjamín Pérez Matos'
break
case "conferences":
author = 'Dr. William Soto Santiago'
break
case "sermons":
author = 'William Marrion Branham';
break
}
return author
}
// removed: openItem relied on a Pinia store that is not installed in this app.
// Detail navigation is now handled directly inside the search pages.
// Detail navigation is now handled directly inside the search pages.

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,16 @@
"name": "English",
"nav": {
"home": "Home",
"bible_studies": "Studies",
"bible_studies_ts": "Studies",
"conferences_ts": "Conferences",
"bible_studies": "Bible Studies",
"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"
"history": "History"
},
"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",
"search": {
"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",
@ -45,56 +28,5 @@
"tab2": {
"tab_title": "Conferences"
}
},
"settings": {
"page_size_title": "Results per search",
"page_size_desc": "How many results to load per page or request.",
"results": "results",
"pagination_title": "Pagination type",
"pagination_desc": "How you want to navigate through results.",
"infinite_scroll": "Infinite scroll",
"infinite_scroll_desc": "Results load automatically when you reach the end of the list.",
"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"
}
}
}
}

View File

@ -1,50 +1,16 @@
{
"nav": {
"home": "Inicio",
"bible_studies": "Estudios",
"bible_studies_ts": "Estudios",
"conferences_ts": "Conferencias",
"bible_studies": "Estudios Bíblicos",
"conferences": "Conferencias",
"between_the_lines": "Entrelíneas",
"between_the_lines": "Entre Lí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."
"history": "Historial"
},
"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,81 +22,17 @@
"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"
},
"tab2": {
"tab_title": "Conferencias"
}
},
"settings": {
"page_size_title": "Resultados por búsqueda",
"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?",
"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."
},
"tip": "Tip: envuelve en \"comillas\" para frase exacta en ese orden."
}
}
}

View File

@ -2,87 +2,19 @@
"name": "Français",
"nav": {
"home": "Commencer",
"bible_studies": "Études",
"bible_studies_ts": "Études",
"conferences_ts": "Conférences",
"bible_studies": "Études Bibliques",
"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"
"history": "Historique"
},
"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",
"es": "Español",
"fr": "Français",
"pt": "Português"
},
"settings": {
"page_size_title": "Résultats par recherche",
"page_size_desc": "Combien de résultats charger par page ou requête.",
"results": "résultats",
"pagination_title": "Type de pagination",
"pagination_desc": "Comment naviguer entre les résultats.",
"infinite_scroll": "Défilement infini",
"infinite_scroll_desc": "Les résultats se chargent automatiquement en fin de liste.",
"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"
}
}
}

View File

@ -2,31 +2,14 @@
"name": "Português",
"nav": {
"home": "Inicio",
"bible_studies": "Estudios",
"bible_studies_ts": "Estudos",
"conferences_ts": "Conferências Typesense",
"bible_studies": "Estudios Bíblicos",
"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"
"history": "Registro"
},
"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",
@ -43,54 +26,5 @@
"country": "País",
"hits_per_page": "Resultados por página",
"hits_retrieved_in": "Resultados recuperados em"
},
"settings": {
"page_size_title": "Resultados por pesquisa",
"page_size_desc": "Quantos resultados carregar por página ou requisição.",
"results": "resultados",
"pagination_title": "Tipo de paginação",
"pagination_desc": "Como navegar pelos resultados.",
"infinite_scroll": "Rolagem infinita",
"infinite_scroll_desc": "Os resultados carregam automaticamente ao chegar ao final da lista.",
"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"
}
}
}

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
@ -76,7 +54,7 @@ export default defineNuxtConfig({
{
code: 'pt',
name: 'Portugues',
language: 'pt',
language: 'pt-BR',
file: 'pt.json',
icon: 'i-circle-flags:br'
}],
@ -84,9 +62,24 @@ export default defineNuxtConfig({
langDir: '../lang/',
strategy: 'prefix',
defaultLocale: 'es',
detectBrowserLanguage: false,
bundle: {
optimizeTranslationDirective: false,
},
detectBrowserLanguage: false
// skipSettingLocaleOnNavigate: true
// vueI18n: "./i18n.config.ts",
},
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: 'wXyOg0Vrhm8LXpaXbMaF7k24Bnf2rDAv', // Your Typesense API key
// Habilita los composables auto-importados en cliente
// (useTypesenseDocuments, useTypesenseApi, etc.).
// ⚠️ Solo usa una clave de búsqueda (search-only) aquí: queda expuesta al navegador.
clientMode: true
}
})

773
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,34 @@
}
}
},
"node_modules/@nuxt/cli/node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/@nuxt/cli/node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
},
"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 +2686,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 +2802,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 +2824,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 +2858,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 +3062,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 +4165,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 +7393,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 +7417,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 +7473,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 +8241,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 +8746,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 +8780,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 +10842,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 +11817,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 +12642,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 +12872,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 +13298,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 +13830,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 +14044,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 +14088,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 +14231,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 +14978,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 +15226,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 +15307,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 +16384,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 +16600,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 +16679,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 +17133,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 +17441,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 +19209,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)
}
})