Compare commits

...

35 Commits

Author SHA1 Message Date
David Ascanio 161ab1dab4 Merge branch 'main' of https://gitea.carpa.com/LGCC/search — keep local (new-collections) changes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 20:56:57 -03:00
David Ascanio 7f92da2607 add items to git ignore 2026-06-03 20:52:51 -03:00
David Ascanio 73cd1ead70 translations 2026-06-03 19:45:21 -03:00
David Ascanio 50215fc657 default internal search true 2026-06-03 19:41:00 -03:00
David Ascanio 87f4f04d2c Add exact phrase search toggle, collapsible detail metadata, entrelineas draft support, and home page responsive layout
- Add word/phrase search toggle to SearchPanel and entrelineas search inputs
- Add collapsible metadata section with chevron toggle in PublicationDetail
- Add internal document search toggle button to detail navbar, open by default
- Add draft field support to entrelineas: hide image for draft documents unless dev mode is enabled, show draft badge in results list
- Replace mobile lightbox teleport hacks with UModal for image popup in EntrelineaDetail
- Fix image display in EntrelineaDetail: fixed height container with object-contain
- Remove file link buttons from PublicationDetail and InboxActivity
- Remove right aside column from PublicationDetail so content fills full width
- Rewrite home page with explicit responsive two-column layout replacing UPageHero
2026-06-03 19:40:35 -03:00
Julio Ruiz 0ea3bd0859 Tour example
Added tour example in a subroute of the app.
2026-06-01 11:35:50 -05:00
Julio Ruiz 47c88c22a6 Updated tour functionality.
Set main functionality inside app.vue with nuxt hooks to be able to call it from any point in the app.
2026-06-01 11:19:56 -05:00
Julio Ruiz 6b7f9bdd78 Reverting tour change
Nuxt conflict
2026-05-31 23:12:21 -05:00
Julio Ruiz 0a991082f5 Tour and optimizations
Added driver js for virual tours.
Added conidtional homepage rendering.
Added localization for homepage and nav buttons
Added dynamic tour button to main navigation.
Separated changelog into a separate ts file.
Separated tour to ts file.
2026-05-31 23:01:45 -05:00
Julio Ruiz c827b3e711 Fixing env variables.
Removing variables from PUBLIC GIT source
2026-05-31 17:41:29 -05:00
Esteban 95f29d2fe2 fix to feedback.post.ts 2026-05-31 13:51:06 -05:00
Esteban 9082b46717 fix nuxt config for .env read 2026-05-31 13:40:15 -05:00
Esteban 2dd21ffe45 feat: feedback i18n, entrelineas dev-lock, and developer access system 2026-05-31 12:16:52 -05:00
David Ascanio ce8efe5a8a pnpm update 2026-05-31 10:33:15 -03:00
David Ascanio f817d20e5b feat: add changelog page with realese history 2026-05-30 08:40:37 -03:00
David Ascanio 46205886ca text & html entrelineas 2026-05-30 08:30:53 -03:00
David Ascanio d71334089f fix error sintaxis 2026-05-29 19:31:31 -03:00
David Ascanio 7d3a97d8f0 no translate html 2026-05-29 18:50:18 -03:00
David Ascanio 1355c93974 remove: delete features not planned for current phase 2026-05-27 22:44:13 -03:00
Julio Ruiz 1c5a3e510f Fixing french translations 2026-05-26 17:39:07 -05:00
Julio Ruiz 0ed5906f1b Fixed minor ui issues - added document code 2026-05-26 17:28:35 -05:00
Julio Ruiz 898aa04071 Added conditional rendering of word draft.
Fixed positioning of aside in shorter docs.
2026-05-26 17:10:46 -05:00
Julio Ruiz 409ef2cae7 Fixing ui issues
Adding neater copy code
2026-05-26 17:04:24 -05:00
Julio Ruiz 9f7d868d2b Merge branch 'new-collections' of https://gitea.carpa.com/LGCC/search into new-collections 2026-05-26 16:34:18 -05:00
Julio Ruiz b7b12184db Adding ui fixes
Fixed translations strings
2026-05-26 16:34:01 -05:00
David Ascanio 364fbe0ad7 filter browse results to only show items with paragraphs 2026-05-26 18:31:49 -03:00
David Ascanio 3d54899b3e tooltip copy 2026-05-26 16:30:33 -03:00
Julio Ruiz 1756aadaff UI Fixes
Adding page component to document results
Adding aside to handle notes and highlights in future
2026-05-26 12:48:06 -05:00
Julio Ruiz e2deaa8066 Adding new clipboard funtionality and panel 2026-05-25 21:04:05 -05:00
David Ascanio d7f5612954 fix bug favoritos 2026-05-25 19:45:28 -03:00
David Ascanio 506f50181a fix bug references 2026-05-25 13:06:30 -03:00
David Ascanio f5ba0531c5 fix bug reference publication detail 2026-05-25 12:53:47 -03:00
David Ascanio 1ce67d7e42 Generic component 2026-05-25 12:20:06 -03:00
David Ascanio a09280f915 Conference y activities documents 2026-05-24 11:23:57 -03:00
David Ascanio e882a41510 apikey estudios bíblicos 2026-05-24 06:07:30 -03:00
41 changed files with 3015 additions and 3629 deletions

12
.gitignore vendored
View File

@ -22,3 +22,15 @@ logs
.env .env
.env.* .env.*
!.env.example !.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,4 +1,11 @@
<script setup lang="ts"> <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({ useHead({
meta: [ meta: [
{ charset: 'utf-8' }, { charset: 'utf-8' },
@ -8,19 +15,141 @@ useHead({
{ rel: 'icon', href: '/favicon.ico' } { rel: 'icon', href: '/favicon.ico' }
], ],
htmlAttrs: { htmlAttrs: {
lang: 'es' lang: $i18n.locale
} }
}) })
const title = 'La Gran Carpa Catedral - Buscador'
const description = 'Buscador de actividades y conferencias.'
useSeoMeta({ useSeoMeta({
title, title,
description, description,
ogTitle: title, ogTitle: title,
ogDescription: description 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 { driver } = useDriver("onboarding");
const instance = driver( tourConfig )
instance.drive(startIndex)
})
</script> </script>
<template> <template>

View File

@ -19,6 +19,9 @@
--color-carpablue: #2C4EA2; --color-carpablue: #2C4EA2;
--color-carpagreen: #6B8E23; --color-carpagreen: #6B8E23;
--color-carpared: #ff0000; --color-carpared: #ff0000;
--color-primary: #6B8E23;
--color-secondary: #2C4EA2;
} }
/* Colors for Typesense rich text */ /* Colors for Typesense rich text */

View File

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

View File

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

View File

@ -18,8 +18,11 @@ interface EntrelineaDoc {
link?: string link?: string
locale?: string locale?: string
origin?: string origin?: string
filter?: string
page?: number | string page?: number | string
text?: string text?: string
html?: string
draft?: boolean
studies?: Study[] studies?: Study[]
[key: string]: unknown [key: string]: unknown
} }
@ -37,6 +40,8 @@ const showStudies = ref(false)
// Volver al detalle cuando cambia el documento // Volver al detalle cuando cambia el documento
watch(() => props.document?.id, () => { showStudies.value = false }) watch(() => props.document?.id, () => { showStudies.value = false })
const { unlocked } = useDevMode()
const imageUrl = computed<string | null>(() => const imageUrl = computed<string | null>(() =>
getEntrelineaImageUrl(props.document?.image, 'detail') getEntrelineaImageUrl(props.document?.image, 'detail')
) )
@ -48,6 +53,7 @@ const title = computed(() =>
) )
function formatEntrelineaText(html: string): string { function formatEntrelineaText(html: string): string {
return html;
if (!html) return '' if (!html) return ''
const sPrefix = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000" const sPrefix = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000"
@ -78,7 +84,7 @@ function formatEntrelineaText(html: string): string {
} }
const bodyHtml = computed<string>(() => const bodyHtml = computed<string>(() =>
formatEntrelineaText(props.highlightedText || props.document?.text || '') formatEntrelineaText(props.document?.html || props.document?.text || '')
) )
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -91,7 +97,7 @@ const toast = useToast()
function toSearchHit(doc: EntrelineaDoc): SearchHit { function toSearchHit(doc: EntrelineaDoc): SearchHit {
const id = doc.id || '' const id = doc.id || ''
return { _id: id, id, title: id || 'Entrelínea', body: doc.text, ...doc } return { _id: id, id, title: doc.text?.slice(0, 100) || 'Entrelínea', body: doc.text, ...doc }
} }
watch( watch(
@ -112,9 +118,10 @@ function onToggleFavorite() {
if (!props.collection || !props.document?.id) return if (!props.collection || !props.document?.id) return
const wasFav = isFav.value const wasFav = isFav.value
favorites.toggle(props.collection, toSearchHit(props.document)) favorites.toggle(props.collection, toSearchHit(props.document))
console.log("text slice", props.document.text?.slice(0, 100))
toast.add({ toast.add({
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista', title: 'Entrelínea',
description: props.document.id, description: props.document.text?.slice(0, 100) || props.document.id,
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check', icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
color: wasFav ? 'neutral' : 'primary', color: wasFav ? 'neutral' : 'primary',
duration: 1800 duration: 1800
@ -264,8 +271,6 @@ function onLightboxTouchStart(e: TouchEvent) {
} }
function onLightboxTouchMove(e: TouchEvent) { function onLightboxTouchMove(e: TouchEvent) {
// Prevent page scroll while interacting inside the lightbox
e.preventDefault()
if (e.touches.length === 1 && lightboxScale.value > 1) { if (e.touches.length === 1 && lightboxScale.value > 1) {
const dx = e.touches[0].clientX - lbLastTouchX const dx = e.touches[0].clientX - lbLastTouchX
const dy = e.touches[0].clientY - lbLastTouchY const dy = e.touches[0].clientY - lbLastTouchY
@ -334,44 +339,8 @@ const lightboxImgStyle = computed(() => ({
/> />
</template> </template>
<template #title>
<span
v-if="showStudies"
class="font-semibold text-sm block"
>
Estudios relacionados
<span class="text-muted font-normal">({{ document.studies?.length }})</span>
</span>
<span
v-else
class="font-semibold text-sm min-w-0 max-w-full block whitespace-normal leading-snug pr-1 wrap-anywhere"
:title="title"
>
{{ title }}
</span>
</template>
<template #right> <template #right>
<template v-if="!showStudies"> <template v-if="!showStudies">
<UTooltip
v-if="document.studies?.length"
:text="`Estudios relacionados (${document.studies.length})`"
>
<UChip
:text="document.studies.length"
color="primary"
size="sm"
:show="!!document.studies?.length"
>
<UButton
icon="i-lucide-book-open"
color="neutral"
variant="ghost"
aria-label="Estudios relacionados"
@click="showStudies = true"
/>
</UChip>
</UTooltip>
<UTooltip v-if="collection" :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"> <UTooltip v-if="collection" :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'">
<UButton <UButton
:icon="isFav ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'" :icon="isFav ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
@ -398,36 +367,22 @@ const lightboxImgStyle = computed(() => ({
size="xs" size="xs"
class="shrink-0" class="shrink-0"
/> />
<UBadge
v-if="document.filter"
:label="String(document.filter)"
color="neutral"
variant="subtle"
size="xs"
class="shrink-0 uppercase"
/>
<span <span
v-if="origin && origin !== document.id" 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]" 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" :title="origin"
> >
{{ origin }} {{ origin }}
</span> </span>
<span v-if="document.page != null" class="text-xs text-muted flex items-center gap-1 shrink-0">
<UIcon name="i-lucide-file-text" class="size-3" />
Pág. {{ document.page }}
</span>
<span
v-if="document.id && document.id !== origin"
class="text-xs text-dimmed font-mono truncate max-w-[160px] shrink-0"
:title="String(document.id)"
>
{{ document.id }}
</span>
</div> </div>
<UButton
v-if="document.link"
:to="document.link"
target="_blank"
icon="i-lucide-external-link"
label="Ver en sitio"
color="primary"
variant="soft"
size="xs"
class="shrink-0"
/>
</div> </div>
<!-- ------------------------------------------------------------------ --> <!-- ------------------------------------------------------------------ -->
@ -437,13 +392,13 @@ const lightboxImgStyle = computed(() => ({
<div class="p-4 sm:p-6 pb-10 flex flex-col gap-6"> <div class="p-4 sm:p-6 pb-10 flex flex-col gap-6">
<!-- Imagen: mobile toca para abrir lightbox, desktop zoom al hover --> <!-- Imagen: mobile toca para abrir lightbox, desktop zoom al hover -->
<template v-if="imageUrl"> <template v-if="imageUrl && (!document.draft || unlocked)">
<!-- Mobile: imagen con indicador de zoom táctil --> <!-- Mobile: imagen con indicador de zoom táctil -->
<div <div
class="lg:hidden rounded-xl overflow-hidden border border-default shadow-sm bg-elevated relative cursor-pointer" 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" @click="openLightbox"
> >
<img :src="imageUrl" :alt="title" loading="lazy" class="w-full h-auto select-none"> <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"> <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" /> <UIcon name="i-lucide-zoom-in" class="size-4 text-white" />
</div> </div>
@ -452,7 +407,7 @@ const lightboxImgStyle = computed(() => ({
<!-- Desktop: hover zoom con lente --> <!-- Desktop: hover zoom con lente -->
<div <div
ref="zoomBoxRef" ref="zoomBoxRef"
class="hidden lg:flex rounded-xl bg-elevated border border-default shadow-sm p-4 items-center justify-center relative cursor-zoom-in select-none min-h-48" 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" @mouseenter="onZoomEnter"
@mouseleave="onZoomLeave" @mouseleave="onZoomLeave"
@mousemove="onZoomMove" @mousemove="onZoomMove"
@ -476,13 +431,13 @@ const lightboxImgStyle = computed(() => ({
</div> </div>
</template> </template>
<!-- Sin imagen --> <!-- Sin imagen / Borrador -->
<div <div
v-else v-else
class="rounded-xl border border-dashed border-default p-8 text-center text-xs text-dimmed" class="rounded-xl border border-dashed border-default p-8 text-center text-xs text-dimmed"
> >
<UIcon name="i-lucide-image-off" class="size-6 mb-1 mx-auto" /> <UIcon name="i-lucide-image-off" class="size-6 mb-1 mx-auto" />
<p>Sin imagen disponible</p> <p>Imagen no disponible</p>
</div> </div>
<!-- Texto --> <!-- Texto -->
@ -565,31 +520,47 @@ const lightboxImgStyle = computed(() => ({
/> />
</Teleport> </Teleport>
<!-- Lightbox mobile con pinch-to-zoom --> <!-- Popup móvil: imagen con pinch-to-zoom -->
<Teleport to="body"> <UModal
<div v-model:open="lightboxOpen"
v-if="lightboxOpen" :ui="{
class="lg:hidden fixed inset-0 bg-black z-[9999] flex items-center justify-center" overlay: 'bg-black/90',
@touchstart.passive="onLightboxTouchStart" content: 'bg-black text-white max-w-[95vw] sm:max-w-lg',
@touchmove.prevent="onLightboxTouchMove" header: 'py-2 px-3 border-b-0',
@touchend="onLightboxTouchEnd" body: 'p-0',
@click.self="closeLightbox" }"
> >
<button <template #header>
class="absolute top-4 right-4 z-10 text-white bg-black/60 rounded-full p-2" <div class="w-full flex justify-end">
@click="closeLightbox" <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"
> >
<UIcon name="i-lucide-x" class="size-6" /> <img
</button> v-if="imageUrl"
<img :src="imageUrl"
:src="imageUrl!" :alt="title"
:alt="title" class="w-full h-full object-contain select-none"
class="max-w-full max-h-full object-contain select-none" :style="lightboxImgStyle"
:style="lightboxImgStyle" draggable="false"
draggable="false" />
/> </div>
</div> </template>
</Teleport> </UModal>
</UDashboardPanel> </UDashboardPanel>
</template> </template>

View File

@ -434,21 +434,7 @@ function onInputKey(e: KeyboardEvent) {
/> />
</div> </div>
<!-- Fila 2: archivos adjuntos --> <!-- file links hidden -->
<div v-if="fileLinks.length" class="flex flex-wrap gap-2">
<UButton
v-for="(f, idx) in fileLinks"
:key="idx"
:to="f.to"
:target="f.target"
:icon="f.icon!"
:label="f.label"
color="neutral"
variant="subtle"
size="xs"
external
/>
</div>
<!-- Fila 3: búsqueda local en el documento --> <!-- Fila 3: búsqueda local en el documento -->
<div <div

View File

@ -0,0 +1,757 @@
<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
}
const props = withDefaults(defineProps<Props>(), {
showDraft: false
})
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 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 multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.paragraphsCollection,
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY,
filterBy: filterBy.value,
perPage: settings.pageSize,
page: typePage,
highlightFullFields: QUERY_BY,
highlightFields: QUERY_BY,
highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>',
highlightAffixNumTokens: 30,
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,
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)`
}]
}
})
console.log('Browse result', multi)
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)
})
// ---- 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 rawParagraphs = (docRaw[props.paragraphsCollection] as ParagraphDoc[] | undefined) ?? []
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 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>
<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"
@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"
@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

@ -0,0 +1,33 @@
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

@ -0,0 +1,112 @@
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 rawParagraphs = (docRaw[config.paragraphs] as ParagraphDoc[] | undefined) ?? []
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

@ -0,0 +1,45 @@
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

@ -4,9 +4,13 @@ import { storeToRefs } from 'pinia'
import type { NavigationMenuItem } from '@nuxt/ui' import type { NavigationMenuItem } from '@nuxt/ui'
import { useFavoritesStore } from '~/stores/favorites' import { useFavoritesStore } from '~/stores/favorites'
import { useHistoryStore } from '~/stores/history' import { useHistoryStore } from '~/stores/history'
import { chatPalette } from '#build/ui'
const unlocked = ref(useDevMode())
const { locale, locales, setLocale } = useI18n() const { locale, locales, setLocale } = useI18n()
const nuxtApp = useNuxtApp()
const { $i18n } = useNuxtApp(); const { $i18n } = useNuxtApp();
const t = $i18n.t; const t = $i18n.t;
@ -20,26 +24,30 @@ const { total: histTotal } = storeToRefs(history)
const links = computed(() => { const links = computed(() => {
return [ const links = [
{ {
id: 'bible-studies',
label: t('nav.bible_studies'), label: t('nav.bible_studies'),
icon: 'ph:books', icon: 'ph-books',
to: '/estudios-biblicos', to: '/estudios-biblicos',
onSelect: () => { open.value = false } onSelect: () => { open.value = false },
}, },
{ {
id: 'conferences',
label: t('nav.conferences'), label: t('nav.conferences'),
icon: 'ph:books', icon: 'ph-books',
to: '/conferencias', to: '/conferencias',
onSelect: () => { open.value = false } onSelect: () => { open.value = false }
}, },
{ {
id: 'betweenthelines',
label: t('nav.between_the_lines'), label: t('nav.between_the_lines'),
icon: 'ph:list-magnifying-glass', icon: 'ph-list-magnifying-glass',
to: '/entrelineas', to: '/entrelineas',
onSelect: () => { open.value = false } onSelect: () => { open.value = false }
}, },
{ {
id: 'favorites',
label: t('nav.my_list'), label: t('nav.my_list'),
icon: 'i-lucide-bookmark', icon: 'i-lucide-bookmark',
to: '/favoritos', to: '/favoritos',
@ -47,6 +55,7 @@ const links = computed(() => {
onSelect: () => { open.value = false } onSelect: () => { open.value = false }
}, },
{ {
id: 'history',
label: t('nav.history'), label: t('nav.history'),
icon: 'i-lucide-history', icon: 'i-lucide-history',
to: '/historial', to: '/historial',
@ -54,50 +63,88 @@ const links = computed(() => {
onSelect: () => { open.value = false } onSelect: () => { open.value = false }
}, },
{ {
id: 'changelog',
label: t('nav.changelog'),
icon: 'i-lucide-megaphone',
to: '/changelog',
onSelect: () => { open.value = false }
},
{
id: 'settings',
label: t('nav.settings'), label: t('nav.settings'),
icon: 'i-lucide-settings', icon: 'i-lucide-settings',
to: '/configuracion', to: '/configuracion',
onSelect: () => { open.value = false } onSelect: () => { open.value = false }
},
{
id: 'feedback',
label: t('feedback.title'),
icon: 'i-lucide-bug',
to: '/feedback',
onSelect: () => { open.value = false }
},
{
id: 'wizard',
class: 'mt-4 border-t-2 border-gray-300 pt-4',
label: t('nav.tour'),
icon: 'ph-student',
onSelect: () => { doTour() },
chip: {
color: 'error'
}
} }
] satisfies NavigationMenuItem[]
})
] satisfies NavigationMenuItem[]
function doTour(){
nuxtApp.callHook('tour',0)
}
const homeLink = {
id: 'home',
label: t('nav.home'),
icon: 'ph-house',
to: '/'
}
return [homeLink, ...links]
})
</script> </script>
<template> <template>
<UDashboardGroup unit="rem"> <UDashboardGroup unit="rem">
<UDashboardSidebar <UDashboardSidebar id="default" v-model:open="open" collapsible resizable
id="default" class="bg-elevated/25 bg-gradient-to-tr from-blue-100 to-white" :ui="{ footer: 'lg:border-t lg:border-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 }"> <template #header="{ collapsed }">
<div v-if="!collapsed" class="mt-2 flex justify-center"> <div v-if="!collapsed" class="mt-2 flex justify-center">
<img src="/logo.svg" class="w-full" alt="Buscador - La Gran Carpa Catedral" /> <img src="/logo.svg" class="w-full" alt="Buscador - La Gran Carpa Catedral" />
</div> </div>
<img v-if="collapsed" src="/logo_round.svg" class="w-full" alt="Buscador - La Gran Carpa Catedral" />
</template> </template>
<template #default="{ collapsed }"> <template #default="{ collapsed }">
<UNavigationMenu <UNavigationMenu :collapsed="collapsed" :items="links" orientation="vertical" tooltip popover color="neutral"
:collapsed="collapsed" variant="link">
:items="links"
orientation="vertical" <template #item="{ item }">
tooltip <div :id="item.id"
popover 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">
color="neutral" <UChip :color="item.chip?.color" :show="item.chip?.color ? true : false" inset>
variant="link" <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>
</template> </template>
<template #footer="{ collapsed }"> <template #footer="{ collapsed }">
<ULocaleSelect <div id="localeSelector">
:model-value="$i18n.locale.value" <ULocaleSelect :model-value="$i18n.locale.value" :locales="Object.values(locales)"
:locales="Object.values(locales)" @update:model-value="setLocale($event)" />
@update:model-value="setLocale($event)" </div>
/>
</template> </template>
</UDashboardSidebar> </UDashboardSidebar>

58
app/pages/changelog.vue Normal file
View File

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

View File

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

View File

@ -25,6 +25,19 @@ const paginationItems = [
description: t('settings.numbered_desc') 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> </script>
<template> <template>
@ -69,6 +82,37 @@ const paginationItems = [
</div> </div>
<USwitch v-model="showParagraphNumbers" /> <USwitch v-model="showParagraphNumbers" />
</div> </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>
</div> </div>
</UDashboardPanel> </UDashboardPanel>

View File

@ -6,24 +6,18 @@ import { useFavoritesStore } from '~/stores/favorites'
import { useSettingsStore } from '~/stores/settings' import { useSettingsStore } from '~/stores/settings'
import type { SearchHit } from '~/types' import type { SearchHit } from '~/types'
/* -------------------------------------------------------------------------- */
/* CONFIGURACIÓN — lo único que necesitas tocar */
/* -------------------------------------------------------------------------- */
const COLLECTION = 'entrelineas' const COLLECTION = 'entrelineas'
const QUERY_BY = 'text' const QUERY_BY = 'text'
const INCLUDE_FIELDS = '*' const INCLUDE_FIELDS = '*'
const EXTRA_FILTER_BY = '' const EXTRA_FILTER_BY = ''
const FAVORITES_COLLECTION = 'entrelineas' const FAVORITES_COLLECTION = 'entrelineas'
/* -------------------------------------------------------------------------- */
/* Estado */
/* -------------------------------------------------------------------------- */
const { $i18n } = useNuxtApp() const { $i18n } = useNuxtApp()
const t = $i18n.t const t = $i18n.t
const { locale } = useI18n() const { locale } = useI18n()
const { unlocked } = useDevMode()
const filterBy = computed(() => { const filterBy = computed(() => {
const localeFilter = `locale:=${locale.value}` const localeFilter = `locale:=${locale.value}`
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
@ -33,7 +27,6 @@ const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore() const settings = useSettingsStore()
// Restaurar estado desde URL antes de crear los refs
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState() const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
const query = ref(q0) const query = ref(q0)
@ -42,6 +35,7 @@ const debouncedQuery = useDebounce(query, 150)
const loading = ref(false) const loading = ref(false)
const loadingMore = ref(false) const loadingMore = ref(false)
const errorMsg = ref<string | null>(null) const errorMsg = ref<string | null>(null)
const exactSearch = ref(false)
interface Study { interface Study {
id?: number id?: number
@ -56,9 +50,11 @@ interface EntrelineaDoc {
image?: string image?: string
link?: string link?: string
locale?: string locale?: string
origin?: string description?: string
page?: number | string page?: number | string
text?: string text?: string
html?: string
draft?: boolean
studies?: Study[] studies?: Study[]
[key: string]: unknown [key: string]: unknown
} }
@ -95,10 +91,6 @@ const hasMore = computed(() =>
settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false
) )
/* -------------------------------------------------------------------------- */
/* Búsqueda */
/* -------------------------------------------------------------------------- */
const { documentsApi } = useTypesenseApi() const { documentsApi } = useTypesenseApi()
let searchSeq = 0 let searchSeq = 0
@ -130,7 +122,7 @@ async function runSearch(q: string, page = 1, append = false) {
multiSearchSearchesParameter: { multiSearchSearchesParameter: {
searches: [{ searches: [{
collection: COLLECTION, collection: COLLECTION,
q: q || '*', q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY, queryBy: QUERY_BY,
includeFields: INCLUDE_FIELDS, includeFields: INCLUDE_FIELDS,
filterBy: filterBy.value, filterBy: filterBy.value,
@ -198,9 +190,9 @@ watch(debouncedQuery, (q) => {
runSearch(q, 1, false) runSearch(q, 1, false)
}) })
/* -------------------------------------------------------------------------- */ watch(exactSearch, () => {
/* Selección / panel de detalle */ if (query.value.trim()) runSearch(query.value, 1, false)
/* -------------------------------------------------------------------------- */ })
const selected = ref<EntrelineaDoc | null>(null) const selected = ref<EntrelineaDoc | null>(null)
@ -215,23 +207,17 @@ watch(hits, () => {
if (!stillThere) selected.value = null if (!stillThere) selected.value = null
}) })
// ID del item seleccionado para sincronizar con la URL
const selectedId = computed(() => selected.value?.id?.toString() ?? null) const selectedId = computed(() => selected.value?.id?.toString() ?? null)
// Contenedor scrollable de la lista
const listEl = ref<HTMLElement | null>(null) const listEl = ref<HTMLElement | null>(null)
// Sincronización con URL
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listEl }) useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listEl })
// Ciclo de vida
onMounted(async () => { onMounted(async () => {
await runSearch(q0, p0, false) await runSearch(q0, p0, false)
// Restaurar scroll
restoreScrollPosition(listEl.value, s0) restoreScrollPosition(listEl.value, s0)
// Restaurar item seleccionado
if (sid0) { if (sid0) {
const found = hits.value.find(h => h.document.id === sid0) const found = hits.value.find(h => h.document.id === sid0)
if (found) selected.value = found.document if (found) selected.value = found.document
@ -243,10 +229,6 @@ const isMobile = breakpoints.smaller('lg')
useDetailHistory(isPanelOpen, isMobile) useDetailHistory(isPanelOpen, isMobile)
/* -------------------------------------------------------------------------- */
/* Favoritos */
/* -------------------------------------------------------------------------- */
const favorites = useFavoritesStore() const favorites = useFavoritesStore()
const toast = useToast() const toast = useToast()
@ -255,7 +237,7 @@ function toSearchHit(doc: EntrelineaDoc): SearchHit {
return { return {
_id: id, _id: id,
id, id,
title: id || 'Entrelínea', title: doc.text?.slice(0, 100) || 'Entrelínea',
body: doc.text, body: doc.text,
...doc ...doc
} }
@ -273,17 +255,13 @@ function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
favorites.toggle(FAVORITES_COLLECTION, toSearchHit(doc)) favorites.toggle(FAVORITES_COLLECTION, toSearchHit(doc))
toast.add({ toast.add({
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista', title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
description: doc.id, description: doc.text?.slice(0, 100),
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check', icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
color: wasFav ? 'neutral' : 'primary', color: wasFav ? 'neutral' : 'primary',
duration: 1800 duration: 1800
}) })
} }
/* -------------------------------------------------------------------------- */
/* Helpers de presentación */
/* -------------------------------------------------------------------------- */
function highlightedFor(hit: TypesenseHit, field: string): string | null { function highlightedFor(hit: TypesenseHit, field: string): string | null {
const fromArr = hit.highlights?.find(h => h.field === field) const fromArr = hit.highlights?.find(h => h.field === field)
if (fromArr?.snippet) return fromArr.snippet if (fromArr?.snippet) return fromArr.snippet
@ -307,205 +285,230 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
<template #leading> <template #leading>
<UDashboardSidebarCollapse /> <UDashboardSidebarCollapse />
</template> </template>
<template #trailing>
<UBadge :label="total" variant="subtle" />
</template>
</UDashboardNavbar> </UDashboardNavbar>
<div class="px-4 sm:px-6 py-3 border-b border-default"> <!-- Banner: se muestra cuando NO hay clave de desarrollador -->
<UInput <template v-if="!unlocked">
v-model="query" <div class="flex-1 flex items-center justify-center p-8 bg-gradient-to-br from-amber-50 via-white to-amber-100/60">
icon="i-lucide-search" <div class="flex flex-col items-center gap-6 text-center max-w-sm">
:placeholder="t('Search.placeholder')" <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">
:loading="loading" <UIcon name="i-lucide-flask-conical" class="size-14 text-white" />
size="md"
class="w-full"
/>
<p class="mt-1.5 flex items-center gap-1 text-[11px] text-dimmed">
<UIcon name="i-lucide-database" class="size-3" />
<span>
<code class="text-toned">{{ COLLECTION }}</code>
·
<code class="text-toned">{{ QUERY_BY }}</code>
·
<code class="text-toned">{{ filterBy }}</code>
</span>
</p>
</div>
<UAlert
v-if="errorMsg"
:title="errorMsg"
color="error"
variant="subtle"
icon="i-lucide-triangle-alert"
class="mx-4 my-2"
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
/>
<div ref="listEl" class="overflow-y-auto divide-y divide-default flex-1">
<div
v-if="loading && !hits.length"
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
Buscando...
</div>
<div
v-else-if="!hits.length"
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
>
<UIcon name="i-lucide-inbox" class="size-10" />
<p>{{ query ? `Sin coincidencias para "${query}"` : 'Sin resultados' }}</p>
</div>
<div
v-for="(hit, index) in hits"
:key="hit.document.id ?? index"
class="p-4 sm:px-6 text-sm cursor-pointer transition-colors border-b-2"
:class="[
selected && selected.id === hit.document.id
? 'bg-primary/10 text-highlighted'
: 'border-gray-200 text-toned hover:bg-primary/5'
]"
@click="selected = hit.document"
>
<div class="flex items-start justify-between gap-2 mb-1">
<div class="min-w-0 flex-1 flex gap-2">
<UBadge
v-if="hit.document?.studies?.[0]?.date"
:label="hit.document?.studies?.[0]?.date"
size="sm"
variant="subtle"
color="info"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.page"
:label="`Página ${hit.document?.page}`"
size="sm"
variant="subtle"
color="error"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.filter"
:label="hit.document?.filter"
size="sm"
variant="subtle"
color="neutral"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.type"
:label="hit.document?.type"
size="sm"
variant="subtle"
color="neutral"
class="mb-1 uppercase"
/>
</div> </div>
<UTooltip :text="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'">
<UButton
:icon="isFav(hit.document) ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
:color="isFav(hit.document) ? 'primary' : 'neutral'"
variant="ghost"
size="xs"
:aria-label="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
@click="(ev: MouseEvent) => toggleFavorite(hit.document, ev)"
/>
</UTooltip>
</div>
<div class="text-sm font-semibold tracking-wide truncate mb-2"> <div class="space-y-2">
{{ (hit.document?.studies?.[0]?.title as string) || hit.document.id || `entrelinea_${index}` }} <h2 class="text-2xl font-bold text-highlighted">
{{ t('entrelineas.development_banner') }}
</h2>
<p class="text-sm text-toned leading-relaxed">
{{ t('entrelineas.development_subtitle_line1') }}<br>
{{ t('entrelineas.development_subtitle_line2') }}
</p>
</div>
<div class="flex items-center gap-2 pt-2">
<span class="size-2 rounded-full bg-amber-300 animate-pulse" />
<span class="size-2 rounded-full bg-amber-400 animate-pulse" style="animation-delay: 0.2s" />
<span class="size-2 rounded-full bg-amber-500 animate-pulse" style="animation-delay: 0.4s" />
</div>
</div>
</div>
</template>
<!-- Funcional: se muestra cuando la clave de desarrollador es correcta -->
<template v-else>
<div class="px-4 sm:px-6 py-3 border-b border-default">
<div class="flex items-center gap-2">
<UInput
v-model="query"
icon="i-lucide-search"
:placeholder="t('Search.placeholder')"
:loading="loading"
size="md"
class="flex-1 min-w-0"
/>
<div
class="flex rounded-full p-0.5 shrink-0 transition-colors duration-200"
:class="exactSearch ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'"
>
<button
class="px-2.5 py-0.5 rounded-full text-xs transition-all duration-200 whitespace-nowrap"
:class="!exactSearch ? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white font-semibold shadow-sm' : 'text-white/40 font-normal'"
@click.stop="exactSearch = false"
>{{ t('search.word') }}</button>
<button
class="px-2.5 py-0.5 rounded-full text-xs transition-all duration-200 whitespace-nowrap"
:class="exactSearch ? 'bg-white text-primary font-semibold shadow-sm' : 'text-gray-400 dark:text-gray-400 font-normal'"
@click.stop="exactSearch = true"
>{{ t('search.phrase') }}</button>
</div>
</div>
<p class="mt-1.5 flex items-center gap-1 text-[11px] text-dimmed">
<UIcon name="i-lucide-database" class="size-3" />
<span>
<code class="text-toned">{{ COLLECTION }}</code>
·
<code class="text-toned">{{ QUERY_BY }}</code>
·
<code class="text-toned">{{ filterBy }}</code>
</span>
</p>
</div>
<UAlert
v-if="errorMsg"
:title="errorMsg"
color="error"
variant="subtle"
icon="i-lucide-triangle-alert"
class="mx-4 my-2"
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
/>
<div ref="listEl" class="overflow-y-auto divide-y divide-default flex-1">
<div
v-if="loading && !hits.length"
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
Buscando...
</div> </div>
<div <div
v-if="highlightedFor(hit, 'text') || hit.document.text" v-else-if="!hits.length"
class="snippet-html text-sm text-toned" class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
v-html="highlightedFor(hit, 'text') || hit.document.text" >
/> <UIcon name="i-lucide-inbox" class="size-10" />
<p>{{ query ? `Sin coincidencias para "${query}"` : 'Sin resultados' }}</p>
</div>
<USeparator class="my-2"/> <div
v-for="(hit, index) in hits"
:key="hit.document.id ?? index"
class="p-4 sm:px-6 text-sm cursor-pointer transition-colors border-b-2"
:class="[
selected && selected.id === hit.document.id
? 'bg-primary/10 text-highlighted'
: 'border-gray-200 text-toned hover:bg-primary/5'
]"
@click="selected = hit.document"
>
<div class="flex items-start justify-between gap-2 mb-1">
<div class="min-w-0 flex-1 flex gap-2">
<UBadge
v-if="hit.document?.filter"
:label="hit.document?.filter"
size="sm"
variant="subtle"
color="neutral"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.type"
:label="hit.document?.type"
size="sm"
variant="subtle"
color="neutral"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.draft"
label="Borrador"
size="sm"
variant="subtle"
color="warning"
class="mb-1"
/>
</div>
<UTooltip :text="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'">
<UButton
:icon="isFav(hit.document) ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
:color="isFav(hit.document) ? 'primary' : 'neutral'"
variant="ghost"
size="xs"
:aria-label="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
@click="(ev: MouseEvent) => toggleFavorite(hit.document, ev)"
/>
</UTooltip>
</div>
<div class="text-xs text-dimmed"> <div
{{ hit.document?.origin }} v-if="highlightedFor(hit, 'text') || hit.document.text"
class="snippet-html text-sm text-toned"
v-html="highlightedFor(hit, 'text') || hit.document.text"
/>
<USeparator class="my-2"/>
</div>
<div
v-if="settings.paginationType === 'infinite_scroll' && hasMore && !loading"
class="p-4 flex justify-center"
>
<UButton
variant="outline"
color="neutral"
size="sm"
:loading="loadingMore"
@click="loadMore"
>
Cargar más
</UButton>
</div>
<div
v-else-if="settings.paginationType === 'infinite_scroll' && hits.length && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados
</div> </div>
</div> </div>
<!-- Infinite scroll: cargando más -->
<div <div
v-if="settings.paginationType === 'infinite_scroll' && hasMore && !loading" v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading"
class="p-4 flex justify-center" class="px-4 py-3 border-t border-default flex justify-center shrink-0"
> >
<UButton <UPagination
variant="outline" :page="activePage"
color="neutral" :total="total"
:items-per-page="settings.pageSize"
size="sm" size="sm"
:loading="loadingMore" @update:page="goToPage"
@click="loadMore" />
>
Cargar más
</UButton>
</div> </div>
</template>
<div
v-else-if="settings.paginationType === 'infinite_scroll' && hits.length && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados
</div>
</div>
<!-- Paginación numerada -->
<div
v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading"
class="px-4 py-3 border-t border-default flex justify-center shrink-0"
>
<UPagination
:page="activePage"
:total="total"
:items-per-page="settings.pageSize"
size="sm"
@update:page="goToPage"
/>
</div>
</UDashboardPanel> </UDashboardPanel>
<!-- Panel de detalle (escritorio) --> <template v-if="unlocked">
<EntrelineaDetail <EntrelineaDetail
v-if="selected && !isMobile" v-if="selected && !isMobile"
:document="selected" :document="selected"
:collection="FAVORITES_COLLECTION" :collection="FAVORITES_COLLECTION"
:highlighted-text="selected.id ? (hits.find(h => h.document.id === selected!.id)?.highlights?.find(hl => hl.field === 'text')?.value ?? null) : null" :highlighted-text="selected.id ? (hits.find(h => h.document.id === selected!.id)?.highlights?.find(hl => hl.field === 'text')?.value ?? null) : null"
@close="selected = null" @close="selected = null"
/> />
<div v-else-if="!isMobile" class="hidden lg:flex flex-1 items-center justify-center"> <div v-else-if="!isMobile" class="hidden lg:flex flex-1 items-center justify-center">
<div class="flex flex-col items-center gap-2 text-dimmed"> <div class="flex flex-col items-center gap-2 text-dimmed">
<UIcon name="i-lucide-mouse-pointer-click" class="size-16" /> <UIcon name="i-lucide-mouse-pointer-click" class="size-16" />
<p class="text-sm"> <p class="text-sm">
Selecciona una entrelínea para ver el detalle Selecciona una entrelínea para ver el detalle
</p> </p>
</div>
</div> </div>
</div>
<!-- Panel de detalle (móvil) --> <ClientOnly>
<ClientOnly> <USlideover v-if="isMobile" v-model:open="isPanelOpen">
<USlideover v-if="isMobile" v-model:open="isPanelOpen"> <template #content>
<template #content> <EntrelineaDetail
<EntrelineaDetail v-if="selected"
v-if="selected" :document="selected"
:document="selected" :collection="FAVORITES_COLLECTION"
:collection="FAVORITES_COLLECTION" :highlighted-text="selected.id ? (hits.find(h => h.document.id === selected!.id)?.highlights?.find(hl => hl.field === 'text')?.value ?? null) : null"
:highlighted-text="selected.id ? (hits.find(h => h.document.id === selected!.id)?.highlights?.find(hl => hl.field === 'text')?.value ?? null) : null" @close="selected = null"
@close="selected = null" />
/> </template>
</template> </USlideover>
</USlideover> </ClientOnly>
</ClientOnly> </template>
</template> </template>
<style scoped> <style scoped>

View File

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

View File

@ -5,125 +5,26 @@ import { breakpointsTailwind } from '@vueuse/core'
import type { DropdownMenuItem } from '@nuxt/ui' import type { DropdownMenuItem } from '@nuxt/ui'
import type { SearchHit } from '~/types' import type { SearchHit } from '~/types'
import { useFavoritesStore, type FavoriteItem } from '~/stores/favorites' import { useFavoritesStore, type FavoriteItem } from '~/stores/favorites'
import EstudiosTypensenseDetail from '~/components/estudiosTypensense/EstudiosTypensenseDetail.vue' import PublicationDetail from '~/components/PublicationDetail.vue'
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue' import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
const favorites = useFavoritesStore() const favorites = useFavoritesStore()
// Refs reactivos para usar en watchers / template (Pinia setup store). // Refs reactivos para usar en watchers / template (Pinia setup store).
const { items: favItems, total: favTotal, collections: favCollections } = storeToRefs(favorites) const { items: favItems, total: favTotal, collections: favCollections } = storeToRefs(favorites)
const toast = useToast() const toast = useToast()
const { locale } = useI18n()
const { documentsApi } = useTypesenseApi()
const TYPESENSE_DOCUMENTS = 'documents'
const TYPESENSE_PARAGRAPHS = 'paragraphs'
/** Identificador de la colección de entrelíneas en el store de favoritos. /** Identificador de la colección de entrelíneas en el store de favoritos.
* Debe coincidir con `FAVORITES_COLLECTION` en `pages/entrelineas.vue`. */ * Debe coincidir con `FAVORITES_COLLECTION` en `pages/entrelineas.vue`. */
const ENTRELINEAS_COLLECTION = 'entrelineas' const ENTRELINEAS_COLLECTION = 'entrelineas'
interface ParagraphDoc { const {
id?: string detailDocument,
document_id: string detailDocumentLoading,
text: string detailParagraphs,
number: number detailParagraphsLoading,
locale: string fetchDetail,
type: string clearDetail,
} } = usePublicationFetch()
interface TypesenseParagraphHit {
document: ParagraphDoc
highlights?: unknown[]
highlight?: Record<string, unknown>
}
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
}
const detailDocument = ref<DocumentDoc | null>(null)
const detailDocumentLoading = ref(false)
const detailParagraphs = ref<TypesenseParagraphHit[]>([])
const detailParagraphsLoading = ref(false)
async function fetchDetailDocument(hit: SearchHit) {
const docId = String(hit._id || hit.id || '')
if (!docId) return
detailDocumentLoading.value = true
detailDocument.value = null
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{ collection: TYPESENSE_DOCUMENTS, q: '*', queryBy: 'title', filterBy: `id:=${docId}`, perPage: 1, page: 1 }]
}
})
const hits = (res?.results?.[0] as { hits?: Array<{ document: DocumentDoc }> })?.hits ?? []
detailDocument.value = hits[0]?.document ?? null
} catch (err) {
console.error('Error fetching document', err)
detailDocument.value = null
} finally {
detailDocumentLoading.value = false
}
}
async function fetchDetailParagraphs(hit: SearchHit) {
const docId = String(hit._id || hit.id || '')
const type = hit.type as string
if (!docId || !type) return
detailParagraphsLoading.value = true
detailParagraphs.value = []
const PER_PAGE = 250
let page = 1
let totalFound = 0
const all: Array<{ document: ParagraphDoc }> = []
try {
do {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: TYPESENSE_PARAGRAPHS,
q: '*',
queryBy: '',
filterBy: `document_id:=${docId} && locale:=${locale.value} && type:=${type}`,
perPage: PER_PAGE,
page,
sortBy: 'number:asc'
}]
}
})
const result = (res?.results?.[0] as { found?: number; hits?: Array<{ document: ParagraphDoc }> } | undefined)
if (!result) break
if (page === 1) totalFound = result.found ?? 0
all.push(...(result.hits ?? []))
page++
} while (all.length < totalFound)
detailParagraphs.value = all.map(h => ({ document: h.document }))
} catch (err) {
console.error('Error fetching paragraphs', err)
detailParagraphs.value = []
} finally {
detailParagraphsLoading.value = false
}
}
// Nota: la URL de ImageKit y los presets de transformación viven en // Nota: la URL de ImageKit y los presets de transformación viven en
// `~/utils/entrelineaImage.ts` (auto-importado). EntrelineaDetail los usa // `~/utils/entrelineaImage.ts` (auto-importado). EntrelineaDetail los usa
@ -226,12 +127,10 @@ const isMobile = breakpoints.smaller('lg')
watch(selected, (item) => { watch(selected, (item) => {
if (!item || item.collection === ENTRELINEAS_COLLECTION) { if (!item || item.collection === ENTRELINEAS_COLLECTION) {
detailDocument.value = null clearDetail()
detailParagraphs.value = []
return return
} }
fetchDetailDocument(item.hit) fetchDetail(item.hit, item.collection)
fetchDetailParagraphs(item.hit)
}) })
// ---- Helpers de fila --------------------------------------------------- // ---- Helpers de fila ---------------------------------------------------
@ -596,7 +495,7 @@ const mobileActions = computed<DropdownMenuItem[][]>(() => [[
@close="selected = null" @close="selected = null"
/> />
<!-- Resto (actividades, conferencias) detalle con párrafos de Typesense. --> <!-- Resto (actividades, conferencias) detalle con párrafos de Typesense. -->
<EstudiosTypensenseDetail <PublicationDetail
v-else-if="selected && !isMobile" v-else-if="selected && !isMobile"
:document="detailDocument" :document="detailDocument"
:document-loading="detailDocumentLoading" :document-loading="detailDocumentLoading"
@ -623,7 +522,7 @@ const mobileActions = computed<DropdownMenuItem[][]>(() => [[
:collection="selectedCollection" :collection="selectedCollection"
@close="selected = null" @close="selected = null"
/> />
<EstudiosTypensenseDetail <PublicationDetail
v-else-if="selected" v-else-if="selected"
:document="detailDocument" :document="detailDocument"
:document-loading="detailDocumentLoading" :document-loading="detailDocumentLoading"

196
app/pages/feedback.vue Normal file
View File

@ -0,0 +1,196 @@
<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 { DropdownMenuItem } from '@nuxt/ui'
import type { SearchHit } from '~/types' import type { SearchHit } from '~/types'
import { useHistoryStore, type HistoryItem, HISTORY_LIMIT } from '~/stores/history' import { useHistoryStore, type HistoryItem, HISTORY_LIMIT } from '~/stores/history'
import InboxActivity from '~/components/inbox/InboxActivity.vue' import PublicationDetail from '~/components/PublicationDetail.vue'
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.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`). * - El store es `useHistoryStore` y los items tienen `visitedAt` (no `addedAt`).
* - Cada fila muestra "Visto: <fecha>" para que se entienda que el orden * - 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. * de la lista es por última visita, no por fecha de creación.
* - El registro lo hace el detalle al abrirse (ver `InboxActivity.vue` y * - El registro lo hace el detalle al abrirse (`PublicationDetail.vue` y
* `EntrelineaDetail.vue` ambos llaman a `history.visit(...)`). * `EntrelineaDetail.vue` ambos llaman a `history.visit(...)`).
*/ */
@ -107,6 +107,23 @@ const isPanelOpen = computed({
set(v: boolean) { if (!v) selected.value = null } 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 // Si el item seleccionado desaparece del historial (eliminado desde otra
// pestaña o desde aquí mismo), cerramos el panel de detalle. // pestaña o desde aquí mismo), cerramos el panel de detalle.
watch(histItems, (items) => { watch(histItems, (items) => {
@ -510,7 +527,9 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
/> />
</div> </div>
<div class="text-sm font-semibold line-clamp-2"> <div class="text-sm font-semibold line-clamp-2">
{{ it.hit?.origin || it.hit?.title || 'Sin título' }} {{ it.collection === ENTRELINEAS_COLLECTION
? (it.hit?.title || 'Sin título')
: (it.hit?.origin || it.hit?.title || 'Sin título') }}
</div> </div>
</div> </div>
<UButton <UButton
@ -546,11 +565,14 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
:collection="selectedCollection" :collection="selectedCollection"
@close="selected = null" @close="selected = null"
/> />
<!-- Resto (actividades, conferencias) InboxActivity. --> <!-- Resto (actividades, conferencias) detalle completo con párrafos. -->
<InboxActivity <PublicationDetail
v-else-if="selected && !isMobile" v-else-if="selected && !isMobile"
:activity="selectedHit!" :document="detailDocument"
:collection="selectedCollection" :document-loading="detailDocumentLoading"
:paragraphs="detailParagraphs"
:paragraphs-loading="detailParagraphsLoading"
:collection="selectedCollection!"
@close="selected = null" @close="selected = null"
/> />
<div v-else-if="!selected" class="hidden lg:flex flex-1 items-center justify-center"> <div v-else-if="!selected" class="hidden lg:flex flex-1 items-center justify-center">
@ -571,10 +593,13 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
:collection="selectedCollection" :collection="selectedCollection"
@close="selected = null" @close="selected = null"
/> />
<InboxActivity <PublicationDetail
v-else-if="selected" v-else-if="selected"
:activity="selectedHit!" :document="detailDocument"
:collection="selectedCollection" :document-loading="detailDocumentLoading"
:paragraphs="detailParagraphs"
:paragraphs-loading="detailParagraphsLoading"
:collection="selectedCollection!"
@close="selected = null" @close="selected = null"
/> />
</template> </template>

View File

@ -1,10 +1,101 @@
<script setup lang="ts"> <script setup lang="ts">
// Home redirects straight to the search experience. // Home redirects straight to the search experience.
definePageMeta({ import type { ButtonProps } from '@nuxt/ui'
middleware: () => navigateTo('/estudios-biblicos', { redirectCode: 302 })
}) 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',11)
},
{
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'
},
])
</script> </script>
<template> <template>
<div /> <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>
</template> </template>

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

@ -55,6 +55,16 @@ export interface Member {
avatar: AvatarProps 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 { export interface Notification {
id: number id: number
unread?: boolean unread?: boolean

131
app/utils/changelog.ts Normal file
View File

@ -0,0 +1,131 @@
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.7',
date: '31 de mayo, 2026 11:50PM',
title: 'Tour y optimizaciones',
changes: [
{ type: 'nuevo', text: 'Agregado tour virtual con localizacion para explicar funcionamiento del buscador'},
{ type: 'nuevo', text: 'Agregada pagina de inicio que muestra la version mas reciente del changelog' },
{ type: 'mejora', text: 'Separacion de changelog a un TS aparte para utilizar en changelog y en el home sin duplicacion de codigo'},
{ 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,171 +1,220 @@
import dayjs from "dayjs"; import dayjs from 'dayjs'
export interface ItemObject { export interface ItemObject {
id: string; id: string
date: number; date: number
slug: string; timestamp: number
type: string; slug: string
place: string; type: string
city: string; place: string
state: string; city: string
country: string; state: string
thumbnail: string; country: string
thumbnail: string
} }
export interface FilesObject { export interface FilesObject {
youtube?: string; youtube?: string
video?: string; video?: string
audio?: string; audio?: string
booklet?: string; booklet?: string
simple?: string; simple?: string
} }
export function formatFiles( files:FilesObject ){ export function formatFiles(files: FilesObject) {
const { $i18n } = useNuxtApp(); const { $i18n } = useNuxtApp()
const t = $i18n.t; const t = $i18n.t
let items = [] const items = []
if (files) { if (files) {
if (files.youtube) { if (files.youtube) {
items.push({ items.push({
to: files.youtube, to: files.youtube,
target: '_blank', target: '_blank',
label: 'Youtube', label: 'Youtube',
icon: 'ph:youtube-logo-thin', icon: 'ph:youtube-logo-thin',
labelClass: 'text-xs', labelClass: 'text-xs'
}) })
}
if (files.video) {
items.push({
to: files.video,
target: '_blank',
label: t('Activities.video'),
icon: 'ph:file-video-thin',
labelClass: 'text-xs'
})
}
if (files.audio) {
items.push({
to: `https://actividadeswp.carpa.com/wp-content/uploads/${files.audio}`,
target: '_blank',
label: t('Activities.audio'),
icon: 'ph:file-audio-thin',
labelClass: 'text-xs'
})
}
if (files.booklet) {
items.push({
to: files.booklet,
target: '_blank',
label: t('Activities.book'),
icon: 'ph:notebook-thin',
labelClass: 'text-xs'
})
}
if (files.simple) {
items.push({
to: files.simple,
target: '_blank',
label: t('Activities.simple'),
icon: 'ph:note-thin',
labelClass: 'text-xs'
})
}
} }
return items if (files.video) {
} items.push({
to: files.video,
export function formatDate( d:number ){ target: '_blank',
const { $i18n } = useNuxtApp(); label: t('downloads.video'),
const locale = $i18n.locale; icon: 'ph:file-video-thin',
labelClass: 'text-xs'
let date = new Date(d * 1000) })
let dateString = date.toLocaleString(locale.value, { year: 'numeric', month:'long', day: 'numeric', weekday:'long', timeZone:'UTC' })
return capitalizeFirstLetter( dateString );
}
export function formatLocation( i: ItemObject){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
const regionNames = new Intl.DisplayNames(
[ locale.value], {type: 'region'}
);
var locationParts = [];
locationParts.push(i?.place);
locationParts.push(i?.city);
locationParts.push(i?.state);
if( i.country ){
locationParts.push(regionNames.of(i.country));
} }
if (files.audio) {
let fileUrl = ''
if (files.audio.startsWith('http')) {
fileUrl = files.audio
} else {
fileUrl = `https://actividadeswp.carpa.com/wp-content/uploads/${files.audio}`
}
return locationParts.filter(Boolean).join(', '); items.push({
} to: fileUrl,
target: '_blank',
export function getDay( d:number ){ label: t('downloads.audio'),
const { $i18n } = useNuxtApp(); icon: 'ph:file-audio-thin',
const locale = $i18n.locale; labelClass: 'text-xs'
let date = new Date(d * 1000); })
return date.toLocaleString(locale.value, { weekday: 'long', timeZone: 'utc' });
}
export function getDayDate( d:number ){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
return date.toLocaleString(locale.value, { day: 'numeric', timeZone: 'utc' });
}
export function getMonth( d:number ){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
return date.toLocaleString(locale.value, { month: 'long', timeZone: 'utc' });
}
export function setUrl( item:ItemObject, isSearch:boolean ) {
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
const date = dayjs(item.date*1000);
const month = (date.month()+1).toString()
const slug = (item.slug || item.slug === 'undefined' ? item.slug : item.id);
if( isSearch ){
return false;
} else {
return `/${locale.value}/${item.type}/${date.year()}/${month.padStart(2, '0')}/${slug}`;
} }
if (files.booklet) {
let fileUrl = ''
if (files.booklet.startsWith('http')) {
fileUrl = files.booklet
} else {
fileUrl = `https://actividadeswp.carpa.com/wp-content/uploads/${files.booklet}`
}
items.push({
to: fileUrl,
target: '_blank',
label: t('downloads.book'),
icon: 'ph:notebook-thin',
labelClass: 'text-xs'
})
}
if (files.simple) {
let fileUrl = ''
if (files.simple.startsWith('http')) {
fileUrl = files.simple
} else {
fileUrl = `https://actividadeswp.carpa.com/wp-content/uploads/${files.simple}`
}
items.push({
to: fileUrl,
target: '_blank',
label: t('downloads.simple'),
icon: 'ph:note-thin',
labelClass: 'text-xs'
})
}
}
return items
} }
export function getThumbnail( item:ItemObject ) { export function formatDate(d: number) {
console.log("ITEM",item); if (!d) {
let path = item.thumbnail return
}
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const date = new Date(d * 1000)
const dateString = date.toLocaleString(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
timeZone: 'UTC'
})
return capitalizeFirstLetter(dateString)
}
export function formatLocation(i: ItemObject) {
if (!i) {
return
}
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const regionNames = new Intl.DisplayNames([locale.value], { type: 'region' })
const locationParts = []
locationParts.push(i?.place)
locationParts.push(i?.city)
locationParts.push(i?.state)
if (i.country) {
locationParts.push(regionNames.of(i.country))
}
return locationParts.filter(Boolean).join(', ')
}
export function formatSignature(i: ItemObject) {
const date = formatDate(i?.timestamp)
const location = formatLocation(i)
if (date === undefined || location === undefined) {
return
}
const val = `${date} - ${location}`
if (!val) {
return
}
return val
}
export function getDay(d: number) {
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const date = new Date(d * 1000)
return date.toLocaleString(locale.value, {
weekday: 'long',
timeZone: 'utc'
})
}
export function getDayDate(d: number) {
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const date = new Date(d * 1000)
return date.toLocaleString(locale.value, { day: 'numeric', timeZone: 'utc' })
}
export function getMonth(d: number) {
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const date = new Date(d * 1000)
return date.toLocaleString(locale.value, { month: 'long', timeZone: 'utc' })
}
export function setUrl(item: ItemObject, isSearch: boolean) {
const { $i18n } = useNuxtApp()
const locale = $i18n.locale
const date = dayjs(item.date * 1000)
const month = (date.month() + 1).toString()
const slug = item.slug || item.slug === 'undefined' ? item.slug : item.id
if (isSearch) {
return false
} else {
return `/${locale.value}/${item.type}/${date.year()}/${month.padStart(2, '0')}/${slug}`
}
}
export function getThumbnail(item: ItemObject) {
const path = item.thumbnail
if (!path) { 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 { } else {
return `https://images.carpa.com/${item.type}/${path}?tr=w-900` return `https://images.carpa.com/${item.type}/${path}?tr=w-900`
} }
} }
export function getAuthor( type:string ){ export function getAuthor(type: string) {
let author = '' let author = ''
switch (type){ switch (type) {
case "activities": case 'activities':
author = 'Dr. José Benjamín Pérez Matos' author = 'Dr. José Benjamín Pérez Matos'
break break
case "conferences": case 'conferences':
author = 'Dr. William Soto Santiago' author = 'Dr. William Soto Santiago'
break break
case "sermons": case 'sermons':
author = 'William Marrion Branham'; author = 'William Marrion Branham'
break break
} }
return author return author
} }
// removed: openItem relied on a Pinia store that is not installed in this app. // removed: openItem relied on a Pinia store that is not installed in this app.
// Detail navigation is now handled directly inside the search pages. // Detail navigation is now handled directly inside the search pages.

View File

@ -1,5 +1,5 @@
/* Copy to Clipboard */ /* Copy to Clipboard */
export async function copyToClipboard() { export async function copyToClipboard(doc: ItemObject) {
const toast = useToast() const toast = useToast()
const selection = window.getSelection() const selection = window.getSelection()
@ -8,8 +8,8 @@ export async function copyToClipboard() {
const range = selection?.getRangeAt(0) const range = selection?.getRangeAt(0)
container.appendChild(range.cloneContents()) container.appendChild(range.cloneContents())
const htmlOutput = container.innerHTML const htmlOutput = container.innerHTML + '<br/>' + doc.title + '<br/>' + formatSignature(doc)
const textOutput = selection?.toString() const textOutput = selection?.toString() + '\n\n' + doc.title + '\n' + formatSignature(doc)
try { try {
// Modern Clipboard API requires ClipboardItem // Modern Clipboard API requires ClipboardItem
@ -19,63 +19,34 @@ export async function copyToClipboard() {
}) })
await navigator.clipboard.write([clipboardItem]) await navigator.clipboard.write([clipboardItem])
window.getSelection()?.removeAllRanges()
toast.add({ toast.add({
title: 'Texto copiado!', title: 'Texto copiado!',
description: `${textOutput}`, description: `${truncateAtWord(textOutput, 150)}`,
icon: 'ph-clipboard-text', icon: 'ph-clipboard-text',
color: 'success' color: doc.type == 'activities' ? 'success' : 'info'
}) })
} catch (err) { } catch (err) {
console.error('Failed to copy: ', err) console.error('Failed to copy: ', err)
} }
} }
/* Function to copy url with search parameters into clipboard */ const truncateAtWord = (str: string, limit: number) => {
export function copyParagraphToUrl(id: string) { if (str.length <= limit) return str
console.log('Encoding id', id) const subString = str.slice(0, limit)
return subString.slice(0, subString.lastIndexOf('')) + '...'
}
const highlights = { export function debugDocument(json: []) {
highlights: [ let data = ''
{ // Iterate through keys and values
id: id, for (const key in json) {
color: '#f90' if (json.hasOwnProperty(key)) {
} data += `${key}: ${json[key]}\n\r`
] }
} }
return data
const blob = encodeObjectToBase64Url(highlights)
console.log('Encoded highlights', blob)
console.log('Decoded highlights object', base64UrlDecode(blob))
} }
/* Encode JS object to base64Url compatible string */ export function addNote() {
function encodeObjectToBase64Url(obj: object) { return 'yay'
// 1. Convert object to JSON string
const jsonString = JSON.stringify(obj)
// 2. Base64 encode the JSON string
const base64 = btoa(jsonString)
// 3. Make it URL-safe and remove padding
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
/* Decode base64URL String to json object */
function base64UrlDecode(base64Url: string) {
// Replace URL-safe characters and add padding
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4 !== 0) {
base64 += '='
}
return decodeURIComponent(atob(base64).split('').map(function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
}).join(''))
} }

View File

@ -0,0 +1,15 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,15 @@
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

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

6
bruno/bruno.json Normal file
View File

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

View File

@ -9,9 +9,12 @@
"between_the_lines": "Between the Lines", "between_the_lines": "Between the Lines",
"my_list": "My List", "my_list": "My List",
"history": "History", "history": "History",
"settings": "Settings" "settings": "Settings",
"changelog": "What's New"
}, },
"search": { "search": {
"word": "Word",
"phrase": "Phrase",
"placeholder": "Search for...", "placeholder": "Search for...",
"searching": "Searching...", "searching": "Searching...",
"tip": "Tip: wrap in \"quotes\" for exact phrase in that order.", "tip": "Tip: wrap in \"quotes\" for exact phrase in that order.",
@ -44,7 +47,45 @@
"infinite_scroll_desc": "Results load automatically when you reach the end of the list.", "infinite_scroll_desc": "Results load automatically when you reach the end of the list.",
"numbered": "Numbered pages", "numbered": "Numbered pages",
"numbered_desc": "Navigate between pages with pagination controls.", "numbered_desc": "Navigate between pages with pagination controls.",
"paragraph_numbers_title": "Paragraph numbers", "paragraph_numbers_title": "Paragraph numbers",
"paragraph_numbers_desc": "Show the paragraph number next to each paragraph in the document detail." "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

@ -8,7 +8,29 @@
"between_the_lines": "Entrelíneas", "between_the_lines": "Entrelíneas",
"my_list": "Mi Listado", "my_list": "Mi Listado",
"history": "Historial", "history": "Historial",
"settings": "Configuración" "settings": "Configuración",
"changelog": "Novedades",
"tour": "Toma el tour"
},
"tour": {
"progress": "{current} de {total}",
"next": "Siguiente",
"prev": "Anterior",
"done": "Finalizar",
"bible_studies_description": "Realiza busquedas en los estudios biblicos predicados por el Dr. José Benjamín Pérez",
"conferences_description": "Realiza busquedas en las conferencias predicadas por el Dr. William Soto Santiago",
"betweenthelines_description": "Realiza busquedas en las imagenes de entrelineas de los estudios del Dr. José Benjamín Perez",
"favorites_description": "Listado de resultados guardados como favoritos para facil acceso futuro",
"history_description": "Historial de los resultados de busqueda que has visto",
"settings_description": "Configuracion, cantidad de resultados por pagina, tipo de paginacion, entre otros",
"changelog_description": "Bitacora de cambios realizados al sitio en orden cronologico",
"feedback_description": "Tienes alguna sugerencia o queja? realizala aqui",
"localeselector_description": "Cambia facilmente el idioma de la pagina",
"index_changelog": "Panel de ultimos cambios subidos",
"favorites_toggle": "Boton para guardar / quitar este documento de mis favoritos"
},
"home": {
"instructions": "Bienvenidos, aqui podran buscar entre los estudios biblicos, las conferencias y las entrelineas que estan disponibles en el material de archivo de La Gran Carpa Catedral."
}, },
"search": { "search": {
"placeholder": "Buscar...", "placeholder": "Buscar...",
@ -27,6 +49,8 @@
"hits_per_page": "aciertos por página", "hits_per_page": "aciertos por página",
"hits_retrieved_in": "aciertos logrados en", "hits_retrieved_in": "aciertos logrados en",
"for": "Buscando", "for": "Buscando",
"word": "Palabra",
"phrase": "Frase",
"words": "palabras", "words": "palabras",
"phrases": "frases", "phrases": "frases",
"words_tooltip": "Buscar por palabras", "words_tooltip": "Buscar por palabras",
@ -50,6 +74,48 @@
"numbered": "Páginas numeradas", "numbered": "Páginas numeradas",
"numbered_desc": "Navega entre páginas con controles de paginación.", "numbered_desc": "Navega entre páginas con controles de paginación.",
"paragraph_numbers_title": "Números de párrafo", "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." "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."
} }
} }

View File

@ -9,9 +9,12 @@
"between_the_lines": "Entre les lignes", "between_the_lines": "Entre les lignes",
"my_list": "Ma liste", "my_list": "Ma liste",
"history": "Historique", "history": "Historique",
"settings": "Paramètres" "settings": "Paramètres",
"changelog": "Nouveautés"
}, },
"search": { "search": {
"word": "Mot",
"phrase": "Phrase",
"placeholder": "Rechercher des activités", "placeholder": "Rechercher des activités",
"publication": "Publicación", "publication": "Publicación",
"draft": "" "draft": ""
@ -33,6 +36,44 @@
"numbered": "Pages numérotées", "numbered": "Pages numérotées",
"numbered_desc": "Naviguez entre les pages avec des contrôles de pagination.", "numbered_desc": "Naviguez entre les pages avec des contrôles de pagination.",
"paragraph_numbers_title": "Numéros de paragraphe", "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." "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

@ -9,9 +9,12 @@
"between_the_lines": "Entre as linhas", "between_the_lines": "Entre as linhas",
"my_list": "Minha lista", "my_list": "Minha lista",
"history": "Registro", "history": "Registro",
"settings": "Configurações" "settings": "Configurações",
"changelog": "Novidades"
}, },
"search": { "search": {
"word": "Palavra",
"phrase": "Frase",
"placeholder": "Digite para pesquisar...", "placeholder": "Digite para pesquisar...",
"publication": "Publicação", "publication": "Publicação",
"draft": "Borrador", "draft": "Borrador",
@ -43,6 +46,42 @@
"numbered": "Páginas numeradas", "numbered": "Páginas numeradas",
"numbered_desc": "Navegue entre páginas com controles de paginação.", "numbered_desc": "Navegue entre páginas com controles de paginação.",
"paragraph_numbers_title": "Números de parágrafo", "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." "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,6 +1,13 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
modules: ['@nuxt/eslint', '@nuxt/ui', '@vueuse/nuxt', 'nuxt-meilisearch', '@nuxtjs/i18n', '@pinia/nuxt', '@sfxcode/nuxt-typesense'], 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' }]
}
},
devtools: { devtools: {
enabled: true enabled: true
@ -12,6 +19,19 @@ export default defineNuxtConfig({
colorMode: false 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: { routeRules: {
'/api/**': { '/api/**': {
cors: true cors: true
@ -68,18 +88,9 @@ export default defineNuxtConfig({
}, },
}, },
meilisearch: {
hostUrl: 'https://search.carpa.com', // required
searchApiKey: '04be59c1f633e2bb434082fc1a6fcc6ce97e3630e3fcf9e814e1f03a386c03e1', // required
serverSideUsage: true // default: false
},
typesense: { typesense: {
url: 'https://searchts.carpa.com', // Your Typesense server URL url: process.env.NUXT_PUBLIC_TYPESENSE_URL || 'https://searchts.carpa.com',
apiKey: 'a2lbIMTxh48KVteLLndpBfo4tuOIGiwD', // Your Typesense API key apiKey: process.env.NUXT_PUBLIC_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 clientMode: true
} }
}) })

751
package-lock.json generated
View File

@ -23,7 +23,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"nuxt": "^4.4.2", "nuxt": "^4.4.2",
"nuxt-meilisearch": "^1.4.17", "nuxt-driver.js": "^0.1.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"scule": "^1.3.0", "scule": "^1.3.0",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
@ -39,267 +39,6 @@
"vue-tsc": "^3.2.7" "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": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@ -2273,21 +2012,6 @@
"node": ">=6.0.0" "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": { "node_modules/@miyaneee/rollup-plugin-json5": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@miyaneee/rollup-plugin-json5/-/rollup-plugin-json5-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@miyaneee/rollup-plugin-json5/-/rollup-plugin-json5-1.2.0.tgz",
@ -2432,12 +2156,6 @@
} }
} }
}, },
"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": { "node_modules/@nuxt/devalue": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@nuxt/devalue/-/devalue-2.0.2.tgz",
@ -2664,25 +2382,25 @@
} }
}, },
"node_modules/@nuxt/icon": { "node_modules/@nuxt/icon": {
"version": "2.2.1", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz", "resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.2.tgz",
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==", "integrity": "sha512-K9wINW21M9x5GcKF5JEXzPKAT/Kfxl/vdnEyppw54hh5qoLcdi5HmsYoTfDP9gbJ6Z1T6IdH5JxBWk72HMe1Zg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@iconify/collections": "^1.0.641", "@iconify/collections": "^1.0.679",
"@iconify/types": "^2.0.0", "@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.0", "@iconify/utils": "^3.1.1",
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@nuxt/devtools-kit": "^3.1.1", "@nuxt/devtools-kit": "^3.2.4",
"@nuxt/kit": "^4.2.2", "@nuxt/kit": "^4.4.4",
"consola": "^3.4.2", "consola": "^3.4.2",
"local-pkg": "^1.1.2", "local-pkg": "^1.1.2",
"mlly": "^1.8.0", "mlly": "^1.8.2",
"ohash": "^2.0.11", "ohash": "^2.0.11",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"picomatch": "^4.0.3", "picomatch": "^4.0.4",
"std-env": "^3.10.0", "std-env": "^4.1.0",
"tinyglobby": "^0.2.15" "tinyglobby": "^0.2.16"
} }
}, },
"node_modules/@nuxt/kit": { "node_modules/@nuxt/kit": {
@ -2780,12 +2498,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@nuxt/schema": {
"version": "4.4.4", "version": "4.4.4",
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.4.tgz", "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.4.tgz",
@ -2802,12 +2514,6 @@
"node": "^14.18.0 || >=16.10.0" "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": { "node_modules/@nuxt/telemetry": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/@nuxt/telemetry/-/telemetry-2.8.0.tgz", "resolved": "https://registry.npmjs.org/@nuxt/telemetry/-/telemetry-2.8.0.tgz",
@ -2836,12 +2542,6 @@
"integrity": "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==", "integrity": "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==",
"license": "MIT" "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": { "node_modules/@nuxt/ui": {
"version": "4.7.1", "version": "4.7.1",
"resolved": "https://registry.npmjs.org/@nuxt/ui/-/ui-4.7.1.tgz", "resolved": "https://registry.npmjs.org/@nuxt/ui/-/ui-4.7.1.tgz",
@ -3040,12 +2740,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@nuxtjs/color-mode": {
"version": "3.5.2", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/@nuxtjs/color-mode/-/color-mode-3.5.2.tgz", "resolved": "https://registry.npmjs.org/@nuxtjs/color-mode/-/color-mode-3.5.2.tgz",
@ -4143,15 +3837,6 @@
"vue": "^3.5.0" "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": { "node_modules/@oxc-minify/binding-android-arm-eabi": {
"version": "0.128.0", "version": "0.128.0",
"resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.128.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.128.0.tgz",
@ -7371,12 +7056,6 @@
"integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
"license": "MIT" "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": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@ -7395,18 +7074,6 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -7451,12 +7118,6 @@
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
"license": "MIT" "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": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -8219,15 +7880,6 @@
"node": ">=8" "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": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.6", "version": "6.0.6",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
@ -8724,24 +8376,6 @@
"node": ">= 14" "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": { "node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@ -8758,44 +8392,6 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/alien-signals": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
@ -10820,6 +10416,12 @@
"url": "https://dotenvx.com" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -11795,15 +11397,6 @@
"bare-events": "^2.7.0" "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": { "node_modules/execa": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
@ -12620,51 +12213,12 @@
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==", "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==",
"license": "MIT" "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": { "node_modules/hookable": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz",
"integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==", "integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==",
"license": "MIT" "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": { "node_modules/html-entities": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
@ -12850,88 +12404,6 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC" "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": { "node_modules/internmap": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@ -13276,12 +12748,6 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT" "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": { "node_modules/json-schema-to-typescript-lite": {
"version": "15.0.0", "version": "15.0.0",
"resolved": "https://registry.npmjs.org/json-schema-to-typescript-lite/-/json-schema-to-typescript-lite-15.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-to-typescript-lite/-/json-schema-to-typescript-lite-15.0.0.tgz",
@ -13808,12 +13274,6 @@
"listhen": "bin/listhen.mjs" "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": { "node_modules/load-tsconfig": {
"version": "0.2.5", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz",
@ -14022,23 +13482,6 @@
"vt-pbf": "^3.1.3" "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": { "node_modules/marked": {
"version": "17.0.6", "version": "17.0.6",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz",
@ -14066,12 +13509,6 @@
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"license": "CC0-1.0" "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": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -14209,22 +13646,6 @@
"node": ">= 18" "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": { "node_modules/mlly": {
"version": "1.8.2", "version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@ -14956,12 +14377,6 @@
"node": ">= 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": { "node_modules/nitropack/node_modules/unimport": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/unimport/-/unimport-6.2.0.tgz", "resolved": "https://registry.npmjs.org/unimport/-/unimport-6.2.0.tgz",
@ -15204,58 +14619,14 @@
} }
} }
}, },
"node_modules/nuxt-meilisearch": { "node_modules/nuxt-driver.js": {
"version": "1.4.17", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/nuxt-meilisearch/-/nuxt-meilisearch-1.4.17.tgz", "resolved": "https://registry.npmjs.org/nuxt-driver.js/-/nuxt-driver.js-0.1.1.tgz",
"integrity": "sha512-9O0dUIwuu00YAGVqQ0BVp/7kgLf3a6ONeOy6gDYtgj8sw/VI1o5tYIwg9hMjesOD1Jw/2WGL8dORkNRN2Mwedg==", "integrity": "sha512-K8SJBzLD8oyu3mhp2vv6F6/SUEaXSp067iGLCPDiMGh4w86VdDArapPmyylH9ZvYq/aeltVPbS6S/uOuVtAXHw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@meilisearch/instant-meilisearch": "0.28.0", "@nuxt/kit": "^4.4.4",
"@nuxt/kit": "4.2.0", "driver.js": "^1.4.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": { "node_modules/nuxt/node_modules/cookie-es": {
@ -15285,12 +14656,6 @@
"@types/estree": "^1.0.0" "@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": { "node_modules/nuxt/node_modules/unimport": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/unimport/-/unimport-6.2.0.tgz", "resolved": "https://registry.npmjs.org/unimport/-/unimport-6.2.0.tgz",
@ -16362,16 +15727,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -16578,18 +15933,6 @@
"node": ">=6" "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": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -16657,15 +16000,6 @@
"destr": "^2.0.5" "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": { "node_modules/readable-stream": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
@ -17111,12 +16445,6 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT" "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": { "node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -17419,9 +16747,9 @@
} }
}, },
"node_modules/std-env": { "node_modules/std-env": {
"version": "3.10.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/streamx": { "node_modules/streamx": {
@ -19187,31 +18515,6 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT" "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": { "node_modules/vue-router": {
"version": "5.0.6", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.6.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.6.tgz",

View File

@ -27,7 +27,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"nuxt": "^4.4.2", "nuxt": "^4.4.2",
"nuxt-meilisearch": "^1.4.17", "nuxt-driver.js": "^0.1.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"scule": "^1.3.0", "scule": "^1.3.0",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
@ -42,5 +42,5 @@
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vue-tsc": "^3.2.7" "vue-tsc": "^3.2.7"
}, },
"packageManager": "pnpm@10.33.2" "packageManager": "pnpm@11.5.0"
} }

View File

@ -22,7 +22,7 @@ importers:
version: 3.12.1 version: 3.12.1
'@nuxt/ui': '@nuxt/ui':
specifier: ^4.7.0 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)(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) 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)
'@nuxtjs/i18n': '@nuxtjs/i18n':
specifier: ^9.5.6 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)) 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,6 @@ importers:
nuxt: nuxt:
specifier: ^4.4.2 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) 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-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: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) version: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
@ -96,81 +93,6 @@ importers:
packages: 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': '@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1121,9 +1043,6 @@ packages:
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
'@meilisearch/instant-meilisearch@0.28.0':
resolution: {integrity: sha512-QeY8w0PsoZUwzrKJyrmmi3lGRXDhzPr38t1Ns9iezoA5CD9L3YARjJJKgExybQF6KIReFw1zfJaKbPRA1xTDCA==}
'@miyaneee/rollup-plugin-json5@1.2.0': '@miyaneee/rollup-plugin-json5@1.2.0':
resolution: {integrity: sha512-JjTIaXZp9WzhUHpElrqPnl1AzBi/rvRs065F71+aTmlqvTMVkdbjZ8vfFl4nRlgJy+TPBw69ZK4pwFdmOAt4aA==} resolution: {integrity: sha512-JjTIaXZp9WzhUHpElrqPnl1AzBi/rvRs065F71+aTmlqvTMVkdbjZ8vfFl4nRlgJy+TPBw69ZK4pwFdmOAt4aA==}
peerDependencies: peerDependencies:
@ -1218,10 +1137,6 @@ packages:
resolution: {integrity: sha512-eGo9DjJ9NzKMbJpFU/UTd4c5iOSYuivghKD8W/jVGHs7kew+hdSMvUy401IfQB7EObKPvt/WXEutAIaTg9OsyA==} resolution: {integrity: sha512-eGo9DjJ9NzKMbJpFU/UTd4c5iOSYuivghKD8W/jVGHs7kew+hdSMvUy401IfQB7EObKPvt/WXEutAIaTg9OsyA==}
engines: {node: '>=18.12.0'} 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': '@nuxt/kit@4.4.5':
resolution: {integrity: sha512-J0BpoOomzd3iVZozYlZJ7AwAVliXRgeChZnAkQLfg8d0h/Q+aMK9kkHuhwFULASaRn5idiD4BIhOUz7/uoLbSw==} resolution: {integrity: sha512-J0BpoOomzd3iVZozYlZJ7AwAVliXRgeChZnAkQLfg8d0h/Q+aMK9kkHuhwFULASaRn5idiD4BIhOUz7/uoLbSw==}
engines: {node: '>=18.12.0'} engines: {node: '>=18.12.0'}
@ -1316,10 +1231,6 @@ packages:
resolution: {integrity: sha512-PhrQtJT6Di9uoslL5BTrBFqntFlfCaUKlO3T9ORJwmWFdowPqQeFjQ9OjVbKA6TNWr3kQhDqLbIcGlhbuG1USQ==} resolution: {integrity: sha512-PhrQtJT6Di9uoslL5BTrBFqntFlfCaUKlO3T9ORJwmWFdowPqQeFjQ9OjVbKA6TNWr3kQhDqLbIcGlhbuG1USQ==}
engines: {node: '>=18.12.0'} 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': '@oxc-minify/binding-android-arm-eabi@0.128.0':
resolution: {integrity: sha512-EwdDhZLRmXxSnfy0v9gdOru7TutM8ItRg1Xv8e2B4boWMnHlFCIH38JfwgQnenbkF8SVTwVJtDCkmwEzN4q3xA==} resolution: {integrity: sha512-EwdDhZLRmXxSnfy0v9gdOru7TutM8ItRg1Xv8e2B4boWMnHlFCIH38JfwgQnenbkF8SVTwVJtDCkmwEzN4q3xA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -2625,9 +2536,6 @@ packages:
'@types/dagre@0.7.54': '@types/dagre@0.7.54':
resolution: {integrity: sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==} 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': '@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@ -2640,12 +2548,6 @@ packages:
'@types/geojson@7946.0.16': '@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} 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': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -2664,9 +2566,6 @@ packages:
'@types/pbf@3.0.5': '@types/pbf@3.0.5':
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@ -2887,10 +2786,6 @@ packages:
engines: {node: '>=20'} engines: {node: '>=20'}
hasBin: true 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': '@vitejs/plugin-vue-jsx@5.1.5':
resolution: {integrity: sha512-jIAsvHOEtWpslLOI2MeElGFxH7M8pM83BU/Tor4RLyiwH0FM4nUW3xdvbw20EeU9wc5IspQwMq225K3CMnJEpA==} resolution: {integrity: sha512-jIAsvHOEtWpslLOI2MeElGFxH7M8pM83BU/Tor4RLyiwH0FM4nUW3xdvbw20EeU9wc5IspQwMq225K3CMnJEpA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -3076,9 +2971,6 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.5.0 vue: ^3.5.0
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
abbrev@3.0.1: abbrev@3.0.1:
resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==}
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
@ -3106,24 +2998,9 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'} 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: ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} 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: alien-signals@3.1.2:
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
@ -4134,10 +4011,6 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'} engines: {node: '>=0.8.x'}
eventsource-parser@3.0.8:
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
engines: {node: '>=18.0.0'}
execa@8.0.1: execa@8.0.1:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'} engines: {node: '>=16.17'}
@ -4403,19 +4276,12 @@ packages:
hey-listen@1.0.8: hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
hogan.js@3.0.2:
resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==}
hasBin: true
hookable@5.5.3: hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
hookable@6.1.1: hookable@6.1.1:
resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==}
htm@3.1.1:
resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==}
html-entities@2.6.0: html-entities@2.6.0:
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
@ -4484,17 +4350,6 @@ packages:
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} 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: internmap@1.0.1:
resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
@ -4631,9 +4486,6 @@ packages:
json-schema-traverse@0.4.1: json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} 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: json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@ -4842,15 +4694,6 @@ packages:
maplibre-gl@2.4.0: maplibre-gl@2.4.0:
resolution: {integrity: sha512-csNFylzntPmHWidczfgCZpvbTSmhaWvLRj9e1ezUDBEPizGgshgm3ea1T5TCNEEBq0roauu7BPuRZjA3wO4KqA==} 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: marked@17.0.6:
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
@ -4866,12 +4709,6 @@ packages:
mdn-data@2.27.1: mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} 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: merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@ -4931,16 +4768,9 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
mitt@2.1.0:
resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==}
mitt@3.0.1: mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} 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: mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
@ -5027,10 +4857,6 @@ packages:
node-releases@2.0.38: node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
nopt@1.0.10:
resolution: {integrity: sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==}
hasBin: true
nopt@8.1.0: nopt@8.1.0:
resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==}
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
@ -5051,9 +4877,6 @@ packages:
nth-check@2.1.1: nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
nuxt-meilisearch@1.4.17:
resolution: {integrity: sha512-9O0dUIwuu00YAGVqQ0BVp/7kgLf3a6ONeOy6gDYtgj8sw/VI1o5tYIwg9hMjesOD1Jw/2WGL8dORkNRN2Mwedg==}
nuxt@4.4.5: nuxt@4.4.5:
resolution: {integrity: sha512-MwTf3wyaEIm1U9/T1VKpqg7rGhhrn5Cx2ZS40lwo8GxsiY9xE7UOj5Cg0eAI0fSbJzyXlzdxspytgqWsgL+nIA==} resolution: {integrity: sha512-MwTf3wyaEIm1U9/T1VKpqg7rGhhrn5Cx2ZS40lwo8GxsiY9xE7UOj5Cg0eAI0fSbJzyXlzdxspytgqWsgL+nIA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -5431,9 +5254,6 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'} engines: {node: '>=20'}
preact@10.29.1:
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -5499,10 +5319,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
qs@6.9.9:
resolution: {integrity: sha512-4mp+ySf3n7scxSqU6LIO9jjYCJWIdbXh3EQ8uvEc1uXiZm8sLI7moRTOvZb66c8zEHelBGEVRkSW6e9JCaayTw==}
engines: {node: '>=0.6'}
quansync@0.2.11: quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@ -5519,16 +5335,9 @@ packages:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
rc9@3.0.1: rc9@3.0.1:
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} 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: readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
@ -5667,9 +5476,6 @@ packages:
scule@1.3.0: scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
search-insights@2.17.3:
resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==}
semver@6.3.1: semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
@ -6342,19 +6148,6 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.0.0 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: vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies: peerDependencies:
@ -6519,120 +6312,11 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'} 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: zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots: 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': {} '@alloc/quick-lru@5.2.0': {}
'@antfu/install-pkg@1.1.0': '@antfu/install-pkg@1.1.0':
@ -7457,10 +7141,6 @@ snapshots:
'@mapbox/whoots-js@3.1.0': {} '@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)': '@miyaneee/rollup-plugin-json5@1.2.0(rollup@4.60.3)':
dependencies: dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.3) '@rollup/pluginutils': 5.3.0(rollup@4.60.3)
@ -7748,31 +7428,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - 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)': '@nuxt/kit@4.4.5(magicast@0.5.2)':
dependencies: dependencies:
c12: 3.3.4(magicast@0.5.2) c12: 3.3.4(magicast@0.5.2)
@ -7885,7 +7540,7 @@ snapshots:
rc9: 3.0.1 rc9: 3.0.1
std-env: 4.1.0 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)(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)': '@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)':
dependencies: dependencies:
'@floating-ui/dom': 1.7.6 '@floating-ui/dom': 1.7.6
'@iconify/vue': 5.0.1(vue@3.5.34(typescript@6.0.3)) '@iconify/vue': 5.0.1(vue@3.5.34(typescript@6.0.3))
@ -7935,7 +7590,7 @@ snapshots:
knitwork: 1.3.0 knitwork: 1.3.0
magic-string: 0.30.21 magic-string: 0.30.21
mlly: 1.8.2 mlly: 1.8.2
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)) 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))
ohash: 2.0.11 ohash: 2.0.11
pathe: 2.0.3 pathe: 2.0.3
reka-ui: 2.9.6(vue@3.5.34(typescript@6.0.3)) reka-ui: 2.9.6(vue@3.5.34(typescript@6.0.3))
@ -8103,8 +7758,6 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@opentelemetry/api@1.9.0': {}
'@oxc-minify/binding-android-arm-eabi@0.128.0': '@oxc-minify/binding-android-arm-eabi@0.128.0':
optional: true optional: true
@ -9080,8 +8733,6 @@ snapshots:
'@types/dagre@0.7.54': {} '@types/dagre@0.7.54': {}
'@types/dom-speech-recognition@0.0.1': {}
'@types/esrecurse@4.3.1': {} '@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
@ -9090,10 +8741,6 @@ snapshots:
'@types/geojson@7946.0.16': {} '@types/geojson@7946.0.16': {}
'@types/google.maps@3.64.0': {}
'@types/hogan.js@3.0.5': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/leaflet@1.7.6': '@types/leaflet@1.7.6':
@ -9112,8 +8759,6 @@ snapshots:
'@types/pbf@3.0.5': {} '@types/pbf@3.0.5': {}
'@types/qs@6.15.1': {}
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@types/supercluster@5.0.3': '@types/supercluster@5.0.3':
@ -9445,8 +9090,6 @@ snapshots:
- rollup - rollup
- supports-color - 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))': '@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: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
@ -9683,8 +9326,6 @@ snapshots:
dependencies: dependencies:
vue: 3.5.34(typescript@6.0.3) vue: 3.5.34(typescript@6.0.3)
abbrev@1.1.1: {}
abbrev@3.0.1: {} abbrev@3.0.1: {}
abort-controller@3.0.0: abort-controller@3.0.0:
@ -9703,14 +9344,6 @@ snapshots:
agent-base@7.1.4: {} 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: ajv@6.15.0:
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
@ -9718,28 +9351,6 @@ snapshots:
json-schema-traverse: 0.4.1 json-schema-traverse: 0.4.1
uri-js: 4.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: {} alien-signals@3.1.2: {}
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
@ -10833,8 +10444,6 @@ snapshots:
events@3.3.0: {} events@3.3.0: {}
eventsource-parser@3.0.8: {}
execa@8.0.1: execa@8.0.1:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@ -10985,13 +10594,11 @@ snapshots:
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
framer-motion@12.38.0(react@19.2.6): framer-motion@12.38.0:
dependencies: dependencies:
motion-dom: 12.38.0 motion-dom: 12.38.0
motion-utils: 12.36.0 motion-utils: 12.36.0
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies:
react: 19.2.6
fresh@2.0.0: {} fresh@2.0.0: {}
@ -11124,17 +10731,10 @@ snapshots:
hey-listen@1.0.8: {} hey-listen@1.0.8: {}
hogan.js@3.0.2:
dependencies:
mkdirp: 0.3.0
nopt: 1.0.10
hookable@5.5.3: {} hookable@5.5.3: {}
hookable@6.1.1: {} hookable@6.1.1: {}
htm@3.1.1: {}
html-entities@2.6.0: {} html-entities@2.6.0: {}
http-errors@2.0.1: http-errors@2.0.1:
@ -11195,38 +10795,6 @@ snapshots:
ini@4.1.1: {} 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@1.0.1: {}
internmap@2.0.3: {} internmap@2.0.3: {}
@ -11337,8 +10905,6 @@ snapshots:
json-schema-traverse@0.4.1: {} json-schema-traverse@0.4.1: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify-without-jsonify@1.0.1: {}
json5@2.2.3: {} json5@2.2.3: {}
@ -11555,10 +11121,6 @@ snapshots:
tinyqueue: 2.0.3 tinyqueue: 2.0.3
vt-pbf: 3.1.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: {} marked@17.0.6: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
@ -11567,10 +11129,6 @@ snapshots:
mdn-data@2.27.1: {} mdn-data@2.27.1: {}
meilisearch@0.53.0: {}
meilisearch@0.54.0: {}
merge-stream@2.0.0: {} merge-stream@2.0.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
@ -11616,12 +11174,8 @@ snapshots:
dependencies: dependencies:
minipass: 7.1.3 minipass: 7.1.3
mitt@2.1.0: {}
mitt@3.0.1: {} mitt@3.0.1: {}
mkdirp@0.3.0: {}
mlly@1.8.2: mlly@1.8.2:
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
@ -11637,10 +11191,10 @@ snapshots:
motion-utils@12.36.0: {} motion-utils@12.36.0: {}
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)): 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)):
dependencies: dependencies:
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@6.0.3)) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@6.0.3))
framer-motion: 12.38.0(react@19.2.6) framer-motion: 12.38.0
hey-listen: 1.0.8 hey-listen: 1.0.8
motion-dom: 12.38.0 motion-dom: 12.38.0
motion-utils: 12.36.0 motion-utils: 12.36.0
@ -11787,10 +11341,6 @@ snapshots:
node-releases@2.0.38: {} node-releases@2.0.38: {}
nopt@1.0.10:
dependencies:
abbrev: 1.1.1
nopt@8.1.0: nopt@8.1.0:
dependencies: dependencies:
abbrev: 3.0.1 abbrev: 3.0.1
@ -11810,21 +11360,6 @@ snapshots:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0
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:
'@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): 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: dependencies:
'@dxup/nuxt': 0.4.1(magicast@0.5.2)(typescript@6.0.3) '@dxup/nuxt': 0.4.1(magicast@0.5.2)(typescript@6.0.3)
@ -12375,8 +11910,6 @@ snapshots:
powershell-utils@0.1.0: {} powershell-utils@0.1.0: {}
preact@10.29.1: {}
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
pretty-bytes@7.1.0: {} pretty-bytes@7.1.0: {}
@ -12466,8 +11999,6 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
qs@6.9.9: {}
quansync@0.2.11: {} quansync@0.2.11: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
@ -12478,18 +12009,11 @@ snapshots:
range-parser@1.2.1: {} range-parser@1.2.1: {}
rc9@2.1.2:
dependencies:
defu: 6.1.7
destr: 2.0.5
rc9@3.0.1: rc9@3.0.1:
dependencies: dependencies:
defu: 6.1.7 defu: 6.1.7
destr: 2.0.5 destr: 2.0.5
react@19.2.6: {}
readable-stream@2.3.8: readable-stream@2.3.8:
dependencies: dependencies:
core-util-is: 1.0.3 core-util-is: 1.0.3
@ -12648,8 +12172,6 @@ snapshots:
scule@1.3.0: {} scule@1.3.0: {}
search-insights@2.17.3: {}
semver@6.3.1: {} semver@6.3.1: {}
semver@7.8.0: {} semver@7.8.0: {}
@ -13331,18 +12853,6 @@ snapshots:
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
vue: 3.5.34(typescript@6.0.3) 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)): vue-router@4.6.4(vue@3.5.34(typescript@6.0.3)):
dependencies: dependencies:
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
@ -13504,8 +13014,4 @@ snapshots:
compress-commons: 6.0.2 compress-commons: 6.0.2
readable-stream: 4.7.0 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: {} zod@4.4.3: {}

6
pnpm-workspace.yaml Normal file
View File

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

20
public/logo_round.svg Normal file
View File

@ -0,0 +1,20 @@
<?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>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,47 @@
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 })
}
})