Compare commits

..

1 Commits

52 changed files with 2373 additions and 5095 deletions

12
.gitignore vendored
View File

@ -22,15 +22,3 @@ logs
.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

@ -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,230 +84,44 @@ 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'">
<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'"
@ -352,216 +131,84 @@ const lightboxImgStyle = computed(() => ({
/>
</UTooltip>
</template>
</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="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
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"
>
<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"
v-if="imageUrl"
class="rounded-lg overflow-hidden border border-default bg-elevated flex items-center justify-center mb-2"
>
<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"
/>
class="max-w-full h-auto"
>
</div>
</template>
<!-- Sin imagen / Borrador -->
<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">
<!-- Entrelínea (text) usa la versión resaltada si Typesense la
devolvió, si no el HTML crudo del documento. -->
<article
class="prose prose-sm max-w-none dark:prose-invert leading-relaxed"
v-if="bodyHtml"
>
<div
v-html="bodyHtml"
class="prose prose-sm max-w-none"
/>
</div>
<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>
</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" />
<!--
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>
<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,13 +388,10 @@ 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>
<!-- 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'"
@ -404,48 +401,32 @@ 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" />
<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 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 class="text-sm truncate flex items-center gap-1">
<UIcon name="ph:map-pin" class="size-5 text-green-600" />
{{ formatLocation(activity) }}
</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>
<!-- file links hidden -->
<!-- Fila 3: búsqueda local en el documento -->
<div
v-if="activity?.body"
class="flex items-center gap-2"
>
<UFieldGroup>
<UInput
v-model="localQuery"
icon="i-lucide-search"
placeholder="Buscar en este documento..."
class="flex-1 min-w-0"
placeholder="Buscar frase exacta en este documento..."
class="w-full"
:ui="{ trailing: 'pe-1' }"
@keydown="onInputKey"
>
@ -460,23 +441,24 @@ function onInputKey(e: KeyboardEvent) {
</template>
</UInput>
<template v-if="localQuery">
<UBadge v-if="localQuery" class="bg-gray-400 text-white">
<span
class="text-xs tabular-nums whitespace-nowrap shrink-0 px-2 py-1 rounded-md bg-elevated border border-default"
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>
<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"
class="bg-gray-400"
color="neutral"
variant="ghost"
size="sm"
@click="prevMatch"
/>
</UTooltip>
@ -485,15 +467,30 @@ function onInputKey(e: KeyboardEvent) {
icon="i-lucide-chevron-down"
:disabled="!totalMatches"
aria-label="Coincidencia siguiente"
class="bg-gray-400"
color="neutral"
variant="ghost"
size="sm"
@click="nextMatch"
/>
</UTooltip>
</UFieldGroup>
</div>
</template>
</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,794 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
import PublicationDetail from '~/components/PublicationDetail.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
}
const props = withDefaults(defineProps<Props>(), {
showDraft: false,
author: ''
})
const QUERY_BY = 'text'
const { $i18n } = useNuxtApp()
const t = $i18n.t
const { locale } = useI18n()
const filterBy = computed(() => `locale:=${locale.value}`)
const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore()
// Restaurar estado desde URL antes de crear los refs
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
const query = ref(q0)
const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
// ---- Types ----------------------------------------------------------------
interface ParagraphDoc {
id?: string
document_id: string
text: string
number: number
locale: string
type: string
}
interface DocMeta {
id: string
title: string
date?: string
timestamp?: number
place?: string
city?: string
state?: string
country?: string
type?: string
slug?: string
draft?: string
}
interface DocumentDoc extends DocMeta {
code: string
locale: string
files?: {
youtube?: string
video?: string
audio?: string
booklet?: string
simple?: string
}
body?: string
[key: string]: unknown
}
interface TypesenseHighlight {
field?: string
snippet?: string
value?: string
matched_tokens?: string[]
}
interface TypesenseParagraphHit {
document: ParagraphDoc
highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number
}
interface TypesenseGroupedHit {
groupKey: string[]
hits: TypesenseParagraphHit[]
}
interface TypesenseSearchResponse {
found: number
groupedHits?: TypesenseGroupedHit[]
}
interface SearchGroup {
docId: string
firstHit: TypesenseParagraphHit
allHits: TypesenseParagraphHit[]
}
interface BrowseItem {
docId: string
meta: DocMeta
}
interface DisplayGroup {
docId: string
meta: DocMeta | undefined
firstHit: TypesenseParagraphHit | null
}
// ---- 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',
}
})
// ---- State ----------------------------------------------------------------
const exactSearch = ref(false)
const sortMode = ref<'relevance' | 'date'>('relevance')
const groupedHits = ref<SearchGroup[]>([])
const total = ref(0)
const currentPage = ref(1)
const hasMore = computed(() =>
settings.paginationType === 'infinite_scroll' ? groupedHits.value.length < total.value : false
)
const visibleGroupCount = ref(10)
const visibleGroups = computed(() =>
settings.paginationType === 'infinite_scroll'
? groupedHits.value.slice(0, visibleGroupCount.value)
: groupedHits.value
)
const hasMoreVisible = computed(() =>
settings.paginationType === 'infinite_scroll' &&
visibleGroupCount.value < groupedHits.value.length
)
const browseItems = ref<BrowseItem[]>([])
const browseTotal = ref(0)
const browsePage = ref(1)
const hasMoreBrowse = computed(() =>
settings.paginationType === 'infinite_scroll'
? browseItems.value.length < browseTotal.value
: false
)
const displayGroups = computed((): DisplayGroup[] => {
if (!debouncedQuery.value.trim()) {
return browseItems.value.map(item => ({
docId: item.docId,
meta: item.meta,
firstHit: null
}))
}
return visibleGroups.value.map(g => ({
docId: g.docId,
meta: docCache.value[g.docId],
firstHit: g.firstHit
}))
})
const activePage = ref(p0)
const displayTotal = computed(() =>
debouncedQuery.value.trim() ? total.value : browseTotal.value
)
const totalPages = computed(() =>
Math.max(1, Math.ceil(displayTotal.value / settings.pageSize))
)
const docCache = ref<Record<string, DocMeta>>({})
const { documentsApi } = useTypesenseApi()
// ---- Batch fetch de metadatos ---------------------------------------------
async function fetchDocumentMeta(docIds: string[]) {
const unique = docIds.filter(id => id && !(id in docCache.value))
if (!unique.length) return
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.mainCollection,
q: '*',
queryBy: 'title',
filterBy: `id:=[${unique.join(',')}]`,
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft',
perPage: unique.length,
page: 1
}]
}
})
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocMeta }> })?.hits ?? []
for (const hit of docHits) {
if (hit.document.id) docCache.value[hit.document.id] = hit.document
}
} catch (err) {
console.error('Error fetching document metadata', err)
}
}
// ---- Búsqueda de párrafos (con query) -------------------------------------
let searchSeq = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
async function runSearch(q: string, page = 1, append = false) {
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (seq === searchSeq) {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}
}, REQUEST_TIMEOUT_MS)
const isInfinite = settings.paginationType === 'infinite_scroll'
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
try {
const shouldSortByDate = sortMode.value === 'date' && q.trim()
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.paragraphsCollection,
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY,
filterBy: filterBy.value,
...(shouldSortByDate ? { sortBy: `$${props.mainCollection}(timestamp:desc)` } : {}),
perPage: settings.pageSize,
page: typePage,
highlightFullFields: QUERY_BY,
highlightFields: QUERY_BY,
highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>',
highlightAffixNumTokens: 30,
groupBy: props.groupByField
}]
}
})
if (seq !== searchSeq) return
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
const rawGroups = res?.groupedHits ?? []
const newGroups: SearchGroup[] = rawGroups.map(g => ({
docId: g.groupKey[0]!,
firstHit: g.hits[0]!,
allHits: g.hits
}))
if (!append) docCache.value = {}
await fetchDocumentMeta(newGroups.map(g => g.docId).filter(Boolean))
if (seq !== searchSeq) 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 (seq !== searchSeq) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { groupedHits.value = []; total.value = 0 }
} finally {
if (seq === searchSeq) {
if (timeoutId) clearTimeout(timeoutId)
loading.value = false
loadingMore.value = false
}
}
}
// ---- Exploración por fecha (sin query) ------------------------------------
async function runBrowse(page = 1, append = false) {
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (seq === searchSeq) {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}
}, REQUEST_TIMEOUT_MS)
const isInfinite = settings.paginationType === 'infinite_scroll'
const typePage = isInfinite ? (append ? browsePage.value + 1 : 1) : page
try {
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.paragraphsCollection,
q: '*',
queryBy: QUERY_BY,
filterBy: `${filterBy.value} && $${props.mainCollection}(locale:=${locale.value})`,
sortBy: `$${props.mainCollection}(timestamp:desc)`,
groupBy: props.groupByField,
perPage: settings.pageSize,
page: typePage,
includeFields: `$${props.mainCollection}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
}]
}
})
if (seq !== searchSeq) return
const result = (multi?.results?.[0] as TypesenseSearchResponse | undefined)
const rawGroups = result?.groupedHits ?? []
const newItems = rawGroups.map(g => {
const docId = g.groupKey[0]!
const parentMeta = (g.hits[0]?.document as unknown as Record<string, unknown>)[props.mainCollection] as Partial<DocMeta> | undefined
return { docId, meta: { id: docId, ...parentMeta } as DocMeta }
})
browseItems.value = append ? browseItems.value.concat(newItems) : newItems
browseTotal.value = result?.found ?? browseItems.value.length
browsePage.value = typePage
if (!append) activePage.value = page
} catch (err: unknown) {
if (seq !== searchSeq) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { browseItems.value = []; browseTotal.value = 0 }
} finally {
if (seq === searchSeq) {
if (timeoutId) clearTimeout(timeoutId)
loading.value = false
loadingMore.value = false
}
}
}
function loadMore() {
if (settings.paginationType !== 'infinite_scroll') return
if (loadingMore.value || loading.value || !hasMore.value) return
runSearch(query.value, currentPage.value, true)
}
function goToPage(p: number) {
activePage.value = p
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(p, false)
} else {
groupedHits.value = []
runSearch(query.value, p, false)
}
}
const listContainer = ref<HTMLElement | null>(null)
function onListScroll() {
if (settings.paginationType !== 'infinite_scroll') return
const el = listContainer.value
if (!el) return
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
if (!debouncedQuery.value.trim()) {
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) runBrowse(browsePage.value, true)
} else {
if (hasMoreVisible.value) visibleGroupCount.value += 10
else if (hasMore.value && !loadingMore.value && !loading.value) loadMore()
}
}
}
function retry() {
if (!query.value.trim()) runBrowse(activePage.value, false)
else runSearch(query.value, activePage.value, false)
}
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
watch(debouncedQuery, (q) => {
activePage.value = 1
if (!q.trim()) {
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)
}
})
// ---- Selección y carga del detalle ----------------------------------------
const selectedDocId = ref<string | null>(null)
const selectedDocument = ref<DocumentDoc | null>(null)
const documentLoading = ref(false)
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
const paragraphsLoading = ref(false)
const selectedHit = ref<TypesenseParagraphHit | null>(null)
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
async function fetchDocumentWithParagraphs(docId: string) {
documentLoading.value = true
paragraphsLoading.value = true
selectedDocument.value = null
selectedParagraphs.value = []
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.mainCollection,
q: '*',
queryBy: 'title',
filterBy: `id:=${docId} && $${props.paragraphsCollection}(id: *)`,
includeFields: `*, $${props.paragraphsCollection}(*)`
}]
}
})
const hit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
if (hit) {
const docRaw = { ...hit.document }
const raw = docRaw[props.paragraphsCollection]
const rawParagraphs = Array.isArray(raw) ? raw : (raw ? [raw] : []) as ParagraphDoc[]
delete docRaw[props.paragraphsCollection]
selectedDocument.value = docRaw as unknown as DocumentDoc
selectedParagraphs.value = [...rawParagraphs]
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
.map(p => ({ document: p }))
}
} catch (err) {
console.error('Error fetching document with paragraphs', err)
selectedDocument.value = null
selectedParagraphs.value = []
} finally {
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: TypesenseParagraphHit, field: string): string | null {
const fromArr = hit.highlights?.find(h => h.field === field)
if (fromArr?.snippet) return fromArr.snippet
if (fromArr?.value) return fromArr.value
const fromObj = hit.highlight?.[field]
if (fromObj?.snippet) return fromObj.snippet
if (fromObj?.value) return fromObj.value
return null
}
function metaDate(meta: DocMeta | undefined): string {
if (!meta) return ''
const ts = meta.timestamp || (meta.date ? Math.floor(new Date(meta.date).getTime() / 1000) : null)
if (!ts) return meta.date || ''
return formatDate(ts)
}
function metaLocation(meta: DocMeta | undefined): string {
if (!meta) return ''
return formatLocation({
id: meta.id, date: meta.timestamp ?? 0, slug: meta.slug ?? '',
type: meta.type ?? '', place: meta.place ?? '', city: meta.city ?? '',
state: meta.state ?? '', country: meta.country ?? '', thumbnail: ''
})
}
</script>
<template>
<UDashboardPanel
:id="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>
<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>
<div class="px-4 sm:px-6 py-3">
<USelect
v-if="query.trim()"
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>
<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

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

View File

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

View File

@ -1,113 +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 { documentsApi } = useTypesenseApi()
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
}
detailDocumentLoading.value = true
detailParagraphsLoading.value = true
detailDocument.value = null
detailParagraphs.value = []
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: config.main,
q: '*',
queryBy: 'title',
filterBy: `id:=${docId} && $${config.paragraphs}(id: *)`,
includeFields: `*, $${config.paragraphs}(*)`
}]
}
})
const docHit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
if (docHit) {
const docRaw = { ...docHit.document }
const raw = docRaw[config.paragraphs]
const rawParagraphs = Array.isArray(raw) ? raw : (raw ? [raw] : []) as ParagraphDoc[]
delete docRaw[config.paragraphs]
detailDocument.value = docRaw as unknown as DocumentDoc
detailParagraphs.value = [...rawParagraphs]
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
.map(p => ({ document: p }))
}
} catch (err) {
console.error('[usePublicationFetch] Error fetching publication detail', err)
detailDocument.value = null
detailParagraphs.value = []
} finally {
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

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

@ -3,21 +3,52 @@ 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
@ -25,37 +56,20 @@ const filterBy = computed(() => {
const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore()
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
const query = ref(q0)
const query = ref('')
const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
const exactSearch = ref(false)
interface Study {
id?: number
title?: string
date?: string
place?: string
link?: string
}
interface EntrelineaDoc {
id?: string
image?: string
link?: string
locale?: string
description?: string
page?: number | string
text?: string
html?: string
draft?: boolean
studies?: Study[]
[key: string]: unknown
}
@ -83,26 +97,29 @@ interface TypesenseSearchResponse {
const hits = ref<TypesenseHit[]>([])
const total = ref(0)
const currentPage = ref(1)
const activePage = ref(p0)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
const hasMore = computed(() => hits.value.length < total.value)
const hasMore = computed(() =>
settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false
)
/* -------------------------------------------------------------------------- */
/* 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, page = 1, append = false) {
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
@ -111,23 +128,29 @@ async function runSearch(q: string, page = 1, append = false) {
}
}, REQUEST_TIMEOUT_MS)
const isInfinite = settings.paginationType === 'infinite_scroll'
const typePage = isInfinite
? (append ? currentPage.value + 1 : 1)
: page
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: exactSearch.value && q ? `"${q}"` : q || '*',
q: q || '*',
queryBy: QUERY_BY,
includeFields: INCLUDE_FIELDS,
filterBy: filterBy.value,
perPage: settings.pageSize,
page: typePage,
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">',
@ -136,15 +159,14 @@ async function runSearch(q: string, page = 1, append = false) {
}
})
// 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 = typePage
if (!append) activePage.value = page
currentPage.value = page
} catch (err: unknown) {
if (seq !== searchSeq) return
console.error('Typesense error', err)
@ -163,21 +185,16 @@ async function runSearch(q: string, page = 1, append = false) {
}
function loadMore() {
if (settings.paginationType !== 'infinite_scroll') return
if (loadingMore.value || loading.value || !hasMore.value) return
runSearch(query.value, currentPage.value, true)
}
function goToPage(p: number) {
activePage.value = p
hits.value = []
runSearch(query.value, p, false)
runSearch(query.value, true)
}
function retry() {
runSearch(query.value, activePage.value, false)
runSearch(query.value, false)
}
onMounted(() => runSearch(''))
onBeforeUnmount(() => {
if (timeoutId) clearTimeout(timeoutId)
})
@ -186,13 +203,12 @@ watch(debouncedQuery, (q) => {
hits.value = []
total.value = 0
currentPage.value = 1
activePage.value = 1
runSearch(q, 1, false)
runSearch(q, false)
})
watch(exactSearch, () => {
if (query.value.trim()) runSearch(query.value, 1, false)
})
/* -------------------------------------------------------------------------- */
/* Selección / panel de detalle */
/* -------------------------------------------------------------------------- */
const selected = ref<EntrelineaDoc | null>(null)
@ -201,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
}
@ -249,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
})
}
/* -------------------------------------------------------------------------- */
/* 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
@ -285,68 +298,21 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #trailing>
<UBadge :label="total" variant="subtle" :ui="{
base: 'total-results'
}" />
<UBadge :label="total" variant="subtle" />
</template>
</UDashboardNavbar>
<!-- Banner: se muestra cuando NO hay clave de desarrollador -->
<template v-if="!unlocked">
<div class="flex-1 flex items-center justify-center p-8 bg-gradient-to-br from-amber-50 via-white to-amber-100/60">
<div class="flex flex-col items-center gap-6 text-center max-w-sm">
<div class="size-28 rounded-full bg-gradient-to-br from-amber-300 to-amber-500 flex items-center justify-center shadow-lg shadow-amber-200/50 ring-4 ring-white">
<UIcon name="i-lucide-flask-conical" class="size-14 text-white" />
</div>
<div class="space-y-2">
<h2 class="text-2xl font-bold text-highlighted">
{{ t('entrelineas.development_banner') }}
</h2>
<p class="text-sm text-toned leading-relaxed">
{{ t('entrelineas.development_subtitle_line1') }}<br>
{{ t('entrelineas.development_subtitle_line2') }}
</p>
</div>
<div class="flex items-center gap-2 pt-2">
<span class="size-2 rounded-full bg-amber-300 animate-pulse" />
<span class="size-2 rounded-full bg-amber-400 animate-pulse" style="animation-delay: 0.2s" />
<span class="size-2 rounded-full bg-amber-500 animate-pulse" style="animation-delay: 0.4s" />
</div>
</div>
</div>
</template>
<!-- Funcional: se muestra cuando la clave de desarrollador es correcta -->
<template v-else>
<div class="px-4 sm:px-6 py-3 border-b border-default">
<div class="flex items-center gap-2">
<UInput
v-model="query"
icon="i-lucide-search"
:placeholder="t('Search.placeholder')"
:loading="loading"
size="md"
class="flex-1 min-w-0"
class="w-full"
/>
<div
class="flex rounded-full p-0.5 shrink-0 transition-colors duration-200"
:class="exactSearch ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'"
>
<button
class="px-2.5 py-0.5 rounded-full text-xs transition-all duration-200 whitespace-nowrap"
:class="!exactSearch ? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white font-semibold shadow-sm' : 'text-white/40 font-normal'"
@click.stop="exactSearch = false"
>{{ t('search.word') }}</button>
<button
class="px-2.5 py-0.5 rounded-full text-xs transition-all duration-200 whitespace-nowrap"
:class="exactSearch ? 'bg-white text-primary font-semibold shadow-sm' : 'text-gray-400 dark:text-gray-400 font-normal'"
@click.stop="exactSearch = true"
>{{ t('search.phrase') }}</button>
</div>
</div>
<p class="mt-1.5 flex items-center gap-1 text-[11px] text-dimmed">
<UIcon name="i-lucide-database" class="size-3" />
<span>
@ -354,7 +320,7 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
·
<code class="text-toned">{{ QUERY_BY }}</code>
·
<code class="text-toned">{{ filterBy }}</code>
<code class="text-toned">{{ FILTER_BY }}</code>
</span>
</p>
</div>
@ -369,7 +335,7 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
/>
<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 && !hits.length"
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
@ -398,7 +364,25 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
@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"
@ -415,14 +399,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
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
@ -436,17 +412,28 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
</UTooltip>
</div>
<div class="text-sm font-semibold tracking-wide truncate mb-2">
{{ (hit.document?.studies?.[0]?.title as string) || hit.document.id || `entrelinea_${index}` }}
</div>
<!-- La entrelínea (text). Si Typesense devolvió un snippet con
highlight, lo pintamos con v-html para que aparezca el <mark>;
si no, mostramos el HTML crudo de `text`. -->
<div
v-if="highlightedFor(hit, 'text') || hit.document.text"
class="snippet-html text-sm text-toned"
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 === 'infinite_scroll' && hasMore && !loading"
v-if="hasMore && !loading"
class="p-4 flex justify-center"
>
<UButton
@ -461,29 +448,15 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
</div>
<div
v-else-if="settings.paginationType === 'infinite_scroll' && hits.length && !hasMore && !loading"
v-else-if="hits.length && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados
</div>
</div>
<div
v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading"
class="px-4 py-3 border-t border-default flex justify-center shrink-0"
>
<UPagination
:page="activePage"
:total="total"
:items-per-page="settings.pageSize"
size="sm"
@update:page="goToPage"
/>
</div>
</template>
</UDashboardPanel>
<template v-if="unlocked">
<!-- Panel de detalle (escritorio) -->
<EntrelineaDetail
v-if="selected && !isMobile"
:document="selected"
@ -500,6 +473,7 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
</div>
</div>
<!-- Panel de detalle (móvil) -->
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
<template #content>
@ -513,10 +487,12 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
</template>
</USlideover>
</ClientOnly>
</template>
</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,14 +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"
/>
</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-biblicos`,
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">
Buscador Carpa
</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

@ -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). */

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,27 +3,15 @@
"nav": {
"home": "Home",
"bible_studies": "Bible Studies",
"bible_studies_ts": "Bible Studies",
"conferences_ts": "Conferences",
"conferences": "Conferences",
"between_the_lines": "Between the Lines",
"my_list": "My List",
"history": "History",
"settings": "Settings",
"changelog": "What's New"
"history": "History"
},
"search": {
"sort": {
"relevance": "Normal",
"date": "Most recent"
},
"word": "Word",
"phrase": "Phrase",
"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",
@ -40,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 Bible Study to see the detail",
"empty_conferences": "Choose a Conference to see the detail"
}
}

View File

@ -2,48 +2,15 @@
"nav": {
"home": "Inicio",
"bible_studies": "Estudios Bíblicos",
"bible_studies_ts": "Estudios Bíblicos",
"conferences_ts": "Conferencias",
"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"
},
"tour": {
"progress": "{current} de {total}",
"next": "Siguiente",
"prev": "Anterior",
"done": "Finalizar",
"bible_studies_description": "Realiza búsquedas en los Estudios Bíblicos 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 Bíblicos, 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",
@ -55,77 +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",
"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 Bíblico 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

@ -3,81 +3,18 @@
"nav": {
"home": "Commencer",
"bible_studies": "Études Bibliques",
"bible_studies_ts": "Études Bibliques Typesense",
"conferences_ts": "Conférences Typesense",
"conferences": "Conférences",
"between_the_lines": "Entre les lignes",
"my_list": "Ma liste",
"history": "Historique",
"settings": "Paramètres",
"changelog": "Nouveautés"
"history": "Historique"
},
"search": {
"sort": {
"relevance": "Normal",
"date": "Plus récents"
},
"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

@ -3,25 +3,13 @@
"nav": {
"home": "Inicio",
"bible_studies": "Estudios Bíblicos",
"bible_studies_ts": "Estudos Bíblicos Typesense",
"conferences_ts": "Conferências Typesense",
"conferences": "Conferências",
"between_the_lines": "Entre as linhas",
"my_list": "Minha lista",
"history": "Registro",
"settings": "Configurações",
"changelog": "Novidades"
"history": "Registro"
},
"search": {
"sort": {
"relevance": "Normal",
"date": "Mais recentes"
},
"word": "Palavra",
"phrase": "Frase",
"placeholder": "Digite para pesquisar...",
"publication": "Publicação",
"draft": "Borrador",
"for": "Para",
"city": "Cidade",
"page": "Página",
@ -38,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', '@sfxcode/nuxt-typesense','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,19 +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 || ''
}
},
routeRules: {
'/api/**': {
cors: true
@ -74,7 +54,7 @@ export default defineNuxtConfig({
{
code: 'pt',
name: 'Portugues',
language: 'pt',
language: 'pt-BR',
file: 'pt.json',
icon: 'i-circle-flags:br'
}],
@ -82,15 +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: process.env.NUXT_PUBLIC_TYPESENSE_URL || 'https://searchts.carpa.com',
apiKey: process.env.NUXT_PUBLIC_TYPESENSE_API_KEY || '',
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

@ -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,7 +22,7 @@ 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))
@ -56,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))
@ -96,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'}
@ -1046,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:
@ -1140,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'}
@ -1234,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}
@ -2539,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==}
@ -2551,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==}
@ -2569,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==}
@ -2789,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}
@ -2974,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}
@ -3001,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==}
@ -3692,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'}
@ -4017,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'}
@ -4282,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==}
@ -4356,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==}
@ -4492,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==}
@ -4700,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'}
@ -4715,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==}
@ -4774,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==}
@ -4863,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}
@ -4883,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==}
@ -5263,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'}
@ -5328,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==}
@ -5344,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==}
@ -5485,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
@ -6157,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:
@ -6321,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':
@ -7150,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)
@ -7437,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)
@ -7549,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))
@ -7599,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))
@ -7767,6 +8103,8 @@ snapshots:
- supports-color
- vue
'@opentelemetry/api@1.9.0': {}
'@oxc-minify/binding-android-arm-eabi@0.128.0':
optional: true
@ -8742,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': {}
@ -8750,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':
@ -8768,6 +9112,8 @@ snapshots:
'@types/pbf@3.0.5': {}
'@types/qs@6.15.1': {}
'@types/resolve@1.20.2': {}
'@types/supercluster@5.0.3':
@ -9099,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
@ -9335,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:
@ -9353,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
@ -9360,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: {}
@ -10032,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
@ -10455,6 +10833,8 @@ snapshots:
events@3.3.0: {}
eventsource-parser@3.0.8: {}
execa@8.0.1:
dependencies:
cross-spawn: 7.0.6
@ -10605,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: {}
@ -10742,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:
@ -10806,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: {}
@ -10916,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: {}
@ -11132,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: {}
@ -11140,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: {}
@ -11185,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
@ -11202,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
@ -11352,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
@ -11371,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:
@ -11928,6 +12375,8 @@ snapshots:
powershell-utils@0.1.0: {}
preact@10.29.1: {}
prelude-ls@1.2.1: {}
pretty-bytes@7.1.0: {}
@ -12017,6 +12466,8 @@ snapshots:
punycode@2.3.1: {}
qs@6.9.9: {}
quansync@0.2.11: {}
queue-microtask@1.2.3: {}
@ -12027,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
@ -12190,6 +12648,8 @@ snapshots:
scule@1.3.0: {}
search-insights@2.17.3: {}
semver@6.3.1: {}
semver@7.8.0: {}
@ -12871,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
@ -13032,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,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 })
}
})