Compare commits

..

65 Commits

Author SHA1 Message Date
David Ascanio 2ac1077a1b fix error cuando solo hay un párrafo devuelve como objeto 2026-06-19 17:45:02 -03:00
David Ascanio 7430e17a52 fix exclude cross-locale activities from browser results 2026-06-05 21:12:49 -03:00
Esteban baeef1323f add text to most recent changelog 2026-06-05 14:12:15 -05:00
David Ascanio f60b2960c5 show author attribution for Bible Studies and Conferences
- Display author name (Dr. José Benjamín Pérez Matos / Dr. William Soto Santiago)
  in the list panel and publication detail for each respective section
- History entries now show the author inline and pass it through to the detail view
- Fixed internal collection IDs (bible-studies-ts, conferences-ts) showing as raw
  strings in history; they now resolve to proper translated labels via i18n
- Fixed English nav labels that incorrectly included "Typesense" in the display name
- Added changelog entry for v0.8
2026-06-05 01:10:01 -03:00
David Ascanio 69030d2001 link to Carpa 2026-06-05 00:32:12 -03:00
David Ascanio 1409763544 total items entrelineas 2026-06-05 00:22:31 -03:00
Esteban 07bddf5eb9 add most resent code 2026-06-04 18:42:56 -05:00
David Ascanio b356a0bea3 Merge branch 'main' of https://gitea.carpa.com/LGCC/search 2026-06-04 00:32:15 -03:00
David Ascanio 6dc4c8e9ec fix texts error 2026-06-04 00:29:15 -03:00
Esteban Paz 9ca87503a9 fix the es.json semantic 2026-06-03 22:26:27 -05:00
David Ascanio 763a77d4f5 fix bug historial 2026-06-04 00:10:30 -03:00
Esteban Paz 1cf37c0184 Merge pull request 'new-collections' (#3) from new-collections into main
Reviewed-on: #3
2026-06-04 02:15:11 +00:00
Esteban Paz 65437c1a60 add favicon 2026-06-03 20:43:20 -05:00
Esteban Paz ecdf8430d6 Fix tour index in index.vue home 2026-06-03 19:59:28 -05:00
Esteban Paz 5738c8d949 Update UI components for the tour and Spanish translations for EB and conferences v2 2026-06-03 19:53:48 -05:00
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 572cf9031c Removing copy bug 2026-05-25 21:16:09 -05:00
Julio Ruiz 995111612c Fixed copy functionality 2026-05-25 21:12:42 -05:00
Julio Ruiz e2deaa8066 Adding new clipboard funtionality and panel 2026-05-25 21:04:05 -05:00
Julio Ruiz 09b99985eb Adding basic hover toolbar for all paragraphs 2026-05-25 18:41:45 -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
Julio Ruiz f963c48e75 Added base64 encoding / decoding code 2026-05-24 06:04:50 -05:00
David Ascanio e882a41510 apikey estudios bíblicos 2026-05-24 06:07:30 -03:00
David Ascanio b12c31099c Fix bug favorites detail 2026-05-23 17:31:15 -03:00
Julio Ruiz 3424b22fcf Adding draft to bible studies display in list and detail.
Adding draft to translation files.
2026-05-22 08:24:11 -05:00
Julio Ruiz c883975e33 Removed old meili pages
Renamed typesense pages for correct routes
Updated initial route
Fixed copy to clipboard dependency error in nuxt
2026-05-21 15:33:30 -05:00
Julio Ruiz a3f5bd3512 Fixing paragraphs in detail view for mobile 2026-05-21 15:04:31 -05:00
Julio Ruiz 1cbb31c09d Fixing duplicated array definitions 2026-05-21 14:48:27 -05:00
Julio Ruiz daa0240cb7 Visual style tweaks 2026-05-21 14:47:03 -05:00
Julio Ruiz 7d62d40642 Adding publication to typesense details
Made paragraph numbers more subdued
Fixed publication translation in all languages
2026-05-21 13:08:15 -05:00
Julio Ruiz d632846e24 Separated copy to clipboard function into a new utilities class
Added corrected colors for old content
Added paragraph click to copy function
Fixed visual styling for paragraph display
2026-05-21 12:34:19 -05:00
Julio Ruiz 97cf386f96 Fixing non working search results in conferences 2026-05-21 08:27:09 -05:00
David Ascanio efd752e410 groups typesense 2026-05-20 19:54:24 -03:00
Julio Ruiz f2521b9ff3 Merge pull request 'Optimization' (#2) from jr-branch-1 into main
Reviewed-on: #2
2026-05-19 04:32:05 +00:00
44 changed files with 2717 additions and 3375 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,143 @@ 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 isMobile = window.innerWidth < 768;
if (!isMobile) {
const { driver } = useDriver("onboarding");
const instance = driver(tourConfig)
instance.drive(startIndex)
}
})
</script> </script>
<template> <template>

View File

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

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
@ -50,6 +52,7 @@ interface DocumentDoc {
} }
slug?: string slug?: string
body?: string body?: string
draft?: boolean
[key: string]: unknown [key: string]: unknown
} }
@ -62,12 +65,19 @@ const props = defineProps<{
query?: string query?: string
selectedHit?: TypesenseParagraphHit | null selectedHit?: TypesenseParagraphHit | null
selectedMatchingHits?: TypesenseParagraphHit[] | null selectedMatchingHits?: TypesenseParagraphHit[] | null
accentColor?: 'green' | 'blue'
noTrackVisit?: boolean
author?: string
}>() }>()
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()
@ -95,7 +105,7 @@ watch(
() => [props.collection, props.document?.id] as const, () => [props.collection, props.document?.id] as const,
([collection, id]) => { ([collection, id]) => {
if (!collection || !id || !props.document) return if (!collection || !id || !props.document) return
history.visit(collection, toSearchHit(props.document)) if (!props.noTrackVisit) history.visit(collection, toSearchHit(props.document))
}, },
{ immediate: true } { immediate: true }
) )
@ -326,6 +336,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'
@ -338,7 +349,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)
@ -480,9 +491,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
@ -581,6 +594,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
@ -626,6 +654,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 --------------------------------------------
@ -745,13 +779,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>
@ -761,60 +837,145 @@ 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="¿Cómo funciona?">
<UButton
icon="ph-student"
color="neutral"
variant="ghost"
:disabled="!document"
@click="nuxtApp.callHook('tour',12)"
/>
</UTooltip>
<UTooltip :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"> <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">
<UIcon name="ph:file-dashed" class="size-4 text-carpared" />
{{ $t('search.draft') }}
</p>
<p v-if="author" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
<UIcon name="ph:user-circle" :class="['size-4', iconColor]" />
<span class="italic">{{ author }}</span>
</p>
<p v-if="safeDate()" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0"> <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">
<UIcon name="ph:hash" :class="['size-4 shrink-0', iconColor]" />
{{ $t('search.publication') }} {{ document.activity }}
</p>
<p v-if="safeLocation()" class="text-sm flex items-center gap-1.5 text-muted min-w-0"> <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>
@ -822,25 +983,43 @@ 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">
<UPageBody class="bg-white p-8 rounded-xl shadow-lg">
<UPageHeader
:title="document?.title"
:description="formatSignature(document)"
class="py-0 pb-2"
>
<template #headline>
<div class="flex w-full justify-between" v-if="paragraphs.length>0">
<UBadge variant="outline" color="neutral" :class="document?.type=='activities' ? 'bg-carpagreen/20' : 'bg-carpablue/20'">{{ document?.locale.toUpperCase() }}-{{ document?.code }}</UBadge>
<div class="text-carpared font-semibold" v-if="document?.draft"><UIcon name="ph-file-dashed" class="mr-1" />{{ $t('ui.draft') }}</div>
</div>
</template>
</UPageHeader>
<div v-if="paragraphsLoading" class="flex items-center justify-center gap-2 py-16 text-sm text-muted"> <div v-if="paragraphsLoading" class="flex items-center justify-center gap-2 py-16 text-sm text-muted">
<UIcon name="i-lucide-loader-circle" class="size-5 animate-spin" /> <UIcon name="i-lucide-loader-circle" class="size-5 animate-spin" />
Cargando párrafos... Cargando párrafos...
</div> </div>
<div v-else-if="paragraphs.length" ref="paragraphsContainer">
<!-- Todos los párrafos --> <div class="">
<div v-else-if="paragraphs.length" ref="paragraphsContainer" class="p-1 sm:p-4 max-w-4xl m-4 sm:mx-auto sm:my-6">
<div class="bg-white rounded-lg shadow-md p-4 mb-4 last:mb-0">
<div v-for="(hit, idx) in paragraphs" :key="idx" :data-paragraph-number="hit.document.number"> <div v-for="(hit, idx) in paragraphs" :key="idx" :data-paragraph-number="hit.document.number">
<div class="flex items-start gap-2 mb-2"> <div class="grid grid-cols-1fr items-start gap-2 mb-2" :class="(showParagraphNumbers && 'grid-cols-[20px_1fr]')">
<div v-if="showParagraphNumbers" class="w-4 mr-2"> <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="xs" variant="outline" <UBadge
color="info" /> 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>
<div class=""> <div class="">
<div <div
class="paragraph-html text-sm leading-relaxed text-gray-800 dark:text-gray-200" class="paragraph-html text-sm leading-relaxed text-gray-800 dark:text-gray-200"
v-html="hit.document.raw" v-html="hit.document.html || hit.document.text"
/> />
</div> </div>
</div> </div>
@ -857,7 +1036,57 @@ function highlightTextNodes(root: HTMLElement, terms: string[]): number {
<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>
@ -869,4 +1098,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
} }
@ -28,6 +31,7 @@ const props = defineProps<{
document: EntrelineaDoc document: EntrelineaDoc
collection?: string collection?: string
highlightedText?: string | null highlightedText?: string | null
noTrackVisit?: boolean
}>() }>()
const emits = defineEmits<{ close: [] }>() const emits = defineEmits<{ close: [] }>()
@ -37,6 +41,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')
) )
@ -47,8 +53,39 @@ const title = computed(() =>
origin.value || (props.document?.id as string) || 'Entrelínea' origin.value || (props.document?.id as string) || 'Entrelínea'
) )
function formatEntrelineaText(html: string): string {
return html;
if (!html) return ''
const sPrefix = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000"
const sBracket = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000"
const sInner = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000;text-decoration:underline"
const wrapLine = (content: string, isFirst: boolean): string =>
(isFirst ? `<span style="${sPrefix}">[WSS] </span>` : '') +
`<span style="${sBracket}">«</span>` +
`<span style="${sInner}">${content}</span>` +
`<span style="${sBracket}">»</span>`
if (/<p[\s>]/i.test(html)) {
const lines = html
.split(/<p(?:[^>]*)>/i)
.map(part => part.replace(/<\/p>/gi, '').trim())
.filter(Boolean)
if (lines.length) return lines.map((l, i) => wrapLine(l, i === 0)).join('<br>')
return html
}
if (/<br/i.test(html)) {
const parts = html.split(/<br\s*\/?>/i).map(p => p.trim()).filter(Boolean)
if (parts.length) return parts.map((l, i) => wrapLine(l, i === 0)).join('<br>')
}
return wrapLine(html.trim(), true)
}
const bodyHtml = computed<string>(() => const bodyHtml = computed<string>(() =>
props.highlightedText || props.document?.text || '' formatEntrelineaText(props.document?.html || props.document?.text || '')
) )
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -61,14 +98,14 @@ 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(
() => [props.collection, props.document?.id] as const, () => [props.collection, props.document?.id] as const,
([collection, id]) => { ([collection, id]) => {
if (!collection || !id) return if (!collection || !id) return
history.visit(collection, toSearchHit(props.document)) if (!props.noTrackVisit) history.visit(collection, toSearchHit(props.document))
}, },
{ immediate: true } { immediate: true }
) )
@ -82,9 +119,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
@ -234,8 +272,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
@ -304,44 +340,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 [overflow-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'"
@ -368,36 +368,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>
<!-- ------------------------------------------------------------------ --> <!-- ------------------------------------------------------------------ -->
@ -407,13 +393,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>
@ -422,7 +408,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"
@ -446,13 +432,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 -->
@ -535,31 +521,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">
<UButton
icon="i-lucide-x"
size="sm"
color="neutral"
variant="ghost"
class="text-white hover:bg-white/20"
@click="closeLightbox" @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" />
</button>
<img <img
:src="imageUrl!" v-if="imageUrl"
:src="imageUrl"
:alt="title" :alt="title"
class="max-w-full max-h-full object-contain select-none" class="w-full h-full object-contain select-none"
:style="lightboxImgStyle" :style="lightboxImgStyle"
draggable="false" draggable="false"
/> />
</div> </div>
</Teleport> </template>
</UModal>
</UDashboardPanel> </UDashboardPanel>
</template> </template>

View File

@ -305,7 +305,15 @@ async function renderBody() {
const el = bodyContainer.value const el = bodyContainer.value
if (!el) return if (!el) return
const raw = props.activity?.body // const raw = props.activity?.body
let raw = props.activity?.body || ''
if (raw) {
raw = raw
.replaceAll('#00ccff', '#0070C0')
.replaceAll('#FF0000', '#C00000')
.replaceAll('#333399', '#7030A0')
}
el.innerHTML = raw ? ((fixLink(raw) as string) || '') : '' el.innerHTML = raw ? ((fixLink(raw) as string) || '') : ''
if (scrollContainer.value) scrollContainer.value.scrollTop = 0 if (scrollContainer.value) scrollContainer.value.scrollTop = 0
@ -426,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

@ -1,19 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind, useDebounce } from '@vueuse/core' import { breakpointsTailwind, useDebounce } from '@vueuse/core'
import EstudiosTypensenseDetail from '~/components/estudiosTypensense/EstudiosTypensenseDetail.vue' import PublicationDetail from '~/components/PublicationDetail.vue'
import { useSettingsStore } from '~/stores/settings' import { useSettingsStore } from '~/stores/settings'
const PARAGRAPHS_COLLECTION = 'paragraphs' interface Props {
const DOCUMENTS_COLLECTION = 'documents' paragraphsCollection: string
mainCollection: string
groupByField: string
favoritesCollection: string
panelId: string
navTitleKey: string
accentColor: 'green' | 'blue'
emptyDetailText: string
showDraft?: boolean
author?: string
}
const props = withDefaults(defineProps<Props>(), {
showDraft: false,
author: ''
})
const QUERY_BY = 'text' const QUERY_BY = 'text'
const FAVORITES_COLLECTION = 'conferences-ts'
const { $i18n } = useNuxtApp() const { $i18n } = useNuxtApp()
const t = $i18n.t const t = $i18n.t
const { locale } = useI18n() const { locale } = useI18n()
const filterBy = computed(() => `locale:=${locale.value} && type:=conferences`) const filterBy = computed(() => `locale:=${locale.value}`)
const REQUEST_TIMEOUT_MS = 15000 const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore() const settings = useSettingsStore()
@ -49,9 +64,10 @@ interface DocMeta {
country?: string country?: string
type?: string type?: string
slug?: string slug?: string
draft?: string
} }
export interface DocumentDoc extends DocMeta { interface DocumentDoc extends DocMeta {
code: string code: string
locale: string locale: string
files?: { files?: {
@ -72,16 +88,27 @@ interface TypesenseHighlight {
matched_tokens?: string[] matched_tokens?: string[]
} }
export interface TypesenseParagraphHit { interface TypesenseParagraphHit {
document: ParagraphDoc document: ParagraphDoc
highlights?: TypesenseHighlight[] highlights?: TypesenseHighlight[]
highlight?: Record<string, { snippet?: string, value?: string }> highlight?: Record<string, { snippet?: string, value?: string }>
text_match?: number text_match?: number
} }
interface TypesenseGroupedHit {
groupKey: string[]
hits: TypesenseParagraphHit[]
}
interface TypesenseSearchResponse { interface TypesenseSearchResponse {
found: number found: number
hits?: TypesenseParagraphHit[] groupedHits?: TypesenseGroupedHit[]
}
interface SearchGroup {
docId: string
firstHit: TypesenseParagraphHit
allHits: TypesenseParagraphHit[]
} }
interface BrowseItem { interface BrowseItem {
@ -95,33 +122,38 @@ interface DisplayGroup {
firstHit: TypesenseParagraphHit | null 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 ---------------------------------------------------------------- // ---- State ----------------------------------------------------------------
// Modo búsqueda (con query): hits de párrafos agrupados por documento const exactSearch = ref(false)
const hits = ref<TypesenseParagraphHit[]>([]) const sortMode = ref<'relevance' | 'date'>('relevance')
const groupedHits = ref<SearchGroup[]>([])
const total = ref(0) const total = ref(0)
const currentPage = ref(1) const currentPage = ref(1)
const hasMore = computed(() => const hasMore = computed(() =>
settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false settings.paginationType === 'infinite_scroll' ? groupedHits.value.length < total.value : false
) )
// Progressive display (sólo en scroll infinito)
const visibleGroupCount = ref(10) const visibleGroupCount = ref(10)
const groupedHits = computed(() => {
const map = new Map<string, TypesenseParagraphHit[]>()
for (const hit of hits.value) {
const id = hit.document.document_id
if (!map.has(id)) map.set(id, [])
map.get(id)!.push(hit)
}
return [...map.entries()].map(([docId, docHits]) => ({
docId,
firstHit: docHits[0]!
}))
})
const visibleGroups = computed(() => const visibleGroups = computed(() =>
settings.paginationType === 'infinite_scroll' settings.paginationType === 'infinite_scroll'
? groupedHits.value.slice(0, visibleGroupCount.value) ? groupedHits.value.slice(0, visibleGroupCount.value)
@ -133,7 +165,6 @@ const hasMoreVisible = computed(() =>
visibleGroupCount.value < groupedHits.value.length visibleGroupCount.value < groupedHits.value.length
) )
// Modo exploración (sin query): documentos ordenados por fecha
const browseItems = ref<BrowseItem[]>([]) const browseItems = ref<BrowseItem[]>([])
const browseTotal = ref(0) const browseTotal = ref(0)
const browsePage = ref(1) const browsePage = ref(1)
@ -144,7 +175,6 @@ const hasMoreBrowse = computed(() =>
: false : false
) )
// Grupo unificado para el template
const displayGroups = computed((): DisplayGroup[] => { const displayGroups = computed((): DisplayGroup[] => {
if (!debouncedQuery.value.trim()) { if (!debouncedQuery.value.trim()) {
return browseItems.value.map(item => ({ return browseItems.value.map(item => ({
@ -160,7 +190,6 @@ const displayGroups = computed((): DisplayGroup[] => {
})) }))
}) })
// Paginación activa (compartida entre browse y search)
const activePage = ref(p0) const activePage = ref(p0)
const displayTotal = computed(() => const displayTotal = computed(() =>
@ -171,26 +200,25 @@ const totalPages = computed(() =>
Math.max(1, Math.ceil(displayTotal.value / settings.pageSize)) 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 docCache = ref<Record<string, DocMeta>>({})
const { documentsApi } = useTypesenseApi() const { documentsApi } = useTypesenseApi()
// ---- Batch fetch de metadatos de documentos ------------------------------- // ---- Batch fetch de metadatos ---------------------------------------------
async function fetchDocumentMeta(docIds: string[]) { async function fetchDocumentMeta(docIds: string[]) {
const unique = docIds.filter(id => id && !(id in docCache.value)) const unique = docIds.filter(id => id && !(id in docCache.value))
console.log('Fetching metadata for documents', unique) if (!unique.length) return
try { try {
const res = await documentsApi.multiSearch({ const res = await documentsApi.multiSearch({
multiSearchParameters: {}, multiSearchParameters: {},
multiSearchSearchesParameter: { multiSearchSearchesParameter: {
searches: [{ searches: [{
collection: DOCUMENTS_COLLECTION, collection: props.mainCollection,
q: '*', q: '*',
queryBy: 'title', queryBy: 'title',
filterBy: `id:=[${unique.join(',')}]`, filterBy: `id:=[${unique.join(',')}]`,
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug', includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft',
perPage: unique.length, perPage: unique.length,
page: 1 page: 1
}] }]
@ -198,9 +226,7 @@ async function fetchDocumentMeta(docIds: string[]) {
}) })
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocMeta }> })?.hits ?? [] const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocMeta }> })?.hits ?? []
for (const hit of docHits) { for (const hit of docHits) {
if (hit.document.id) { if (hit.document.id) docCache.value[hit.document.id] = hit.document
docCache.value[hit.document.id] = hit.document
}
} }
} catch (err) { } catch (err) {
console.error('Error fetching document metadata', err) console.error('Error fetching document metadata', err)
@ -231,48 +257,52 @@ async function runSearch(q: string, page = 1, append = false) {
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
try { try {
const shouldSortByDate = sortMode.value === 'date' && q.trim()
const multi = await documentsApi.multiSearch({ const multi = await documentsApi.multiSearch({
multiSearchParameters: {}, multiSearchParameters: {},
multiSearchSearchesParameter: { multiSearchSearchesParameter: {
searches: [{ searches: [{
collection: PARAGRAPHS_COLLECTION, collection: props.paragraphsCollection,
q: q || '*', q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY, queryBy: QUERY_BY,
filterBy: filterBy.value, filterBy: filterBy.value,
...(shouldSortByDate ? { sortBy: `$${props.mainCollection}(timestamp:desc)` } : {}),
perPage: settings.pageSize, perPage: settings.pageSize,
page: typePage, page: typePage,
highlightFullFields: QUERY_BY, highlightFullFields: QUERY_BY,
highlightFields: QUERY_BY, highlightFields: QUERY_BY,
highlightStartTag: '<mark class="search-match">', highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>', highlightEndTag: '</mark>',
snippetThreshold: 100 highlightAffixNumTokens: 30,
groupBy: props.groupByField
}] }]
} }
}) })
if (seq !== searchSeq) return if (seq !== searchSeq) return
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
const newHits = res?.hits ?? [] 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 = {} if (!append) docCache.value = {}
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))] await fetchDocumentMeta(newGroups.map(g => g.docId).filter(Boolean))
await fetchDocumentMeta(newDocIds)
if (seq !== searchSeq) return if (seq !== searchSeq) return
hits.value = append ? hits.value.concat(newHits) : newHits groupedHits.value = append ? groupedHits.value.concat(newGroups) : newGroups
total.value = res?.found ?? hits.value.length total.value = res?.found ?? groupedHits.value.length
currentPage.value = typePage currentPage.value = typePage
if (!append) activePage.value = page if (!append) activePage.value = page
} catch (err: unknown) { } catch (err: unknown) {
if (seq !== searchSeq) return if (seq !== searchSeq) return
console.error('Typesense error', err) console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.' errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { if (!append) { groupedHits.value = []; total.value = 0 }
hits.value = []
total.value = 0
}
} finally { } finally {
if (seq === searchSeq) { if (seq === searchSeq) {
if (timeoutId) clearTimeout(timeoutId) if (timeoutId) clearTimeout(timeoutId)
@ -307,25 +337,26 @@ async function runBrowse(page = 1, append = false) {
multiSearchParameters: {}, multiSearchParameters: {},
multiSearchSearchesParameter: { multiSearchSearchesParameter: {
searches: [{ searches: [{
collection: DOCUMENTS_COLLECTION, collection: props.paragraphsCollection,
q: '*', q: '*',
queryBy: 'title', queryBy: QUERY_BY,
filterBy: filterBy.value, filterBy: `${filterBy.value} && $${props.mainCollection}(locale:=${locale.value})`,
sortBy: 'timestamp:desc', sortBy: `$${props.mainCollection}(timestamp:desc)`,
groupBy: props.groupByField,
perPage: settings.pageSize, perPage: settings.pageSize,
page: typePage, page: typePage,
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug' includeFields: `$${props.mainCollection}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
}] }]
} }
}) })
if (seq !== searchSeq) return if (seq !== searchSeq) return
const result = (multi?.results?.[0] as TypesenseSearchResponse | undefined)
const result = (multi?.results?.[0] as { found?: number, hits?: Array<{ document: DocMeta }> } | undefined) const rawGroups = result?.groupedHits ?? []
const newItems = (result?.hits ?? []).map(h => ({ const newItems = rawGroups.map(g => {
docId: h.document.id!, const docId = g.groupKey[0]!
meta: h.document 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 browseItems.value = append ? browseItems.value.concat(newItems) : newItems
browseTotal.value = result?.found ?? browseItems.value.length browseTotal.value = result?.found ?? browseItems.value.length
@ -335,10 +366,7 @@ async function runBrowse(page = 1, append = false) {
if (seq !== searchSeq) return if (seq !== searchSeq) return
console.error('Typesense error', err) console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.' errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { if (!append) { browseItems.value = []; browseTotal.value = 0 }
browseItems.value = []
browseTotal.value = 0
}
} finally { } finally {
if (seq === searchSeq) { if (seq === searchSeq) {
if (timeoutId) clearTimeout(timeoutId) if (timeoutId) clearTimeout(timeoutId)
@ -360,7 +388,7 @@ function goToPage(p: number) {
browseItems.value = [] browseItems.value = []
runBrowse(p, false) runBrowse(p, false)
} else { } else {
hits.value = [] groupedHits.value = []
runSearch(query.value, p, false) runSearch(query.value, p, false)
} }
} }
@ -373,25 +401,17 @@ function onListScroll() {
if (!el) return if (!el) return
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) { if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
if (!debouncedQuery.value.trim()) { if (!debouncedQuery.value.trim()) {
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) { if (hasMoreBrowse.value && !loadingMore.value && !loading.value) runBrowse(browsePage.value, true)
runBrowse(browsePage.value, true)
}
} else { } else {
if (hasMoreVisible.value) { if (hasMoreVisible.value) visibleGroupCount.value += 10
visibleGroupCount.value += 10 else if (hasMore.value && !loadingMore.value && !loading.value) loadMore()
} else if (hasMore.value && !loadingMore.value && !loading.value) {
loadMore()
}
} }
} }
} }
function retry() { function retry() {
if (!query.value.trim()) { if (!query.value.trim()) runBrowse(activePage.value, false)
runBrowse(activePage.value, false) else runSearch(query.value, activePage.value, false)
} else {
runSearch(query.value, activePage.value, false)
}
} }
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) }) onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
@ -399,23 +419,26 @@ onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
watch(debouncedQuery, (q) => { watch(debouncedQuery, (q) => {
activePage.value = 1 activePage.value = 1
if (!q.trim()) { if (!q.trim()) {
hits.value = [] groupedHits.value = []; total.value = 0; currentPage.value = 1; visibleGroupCount.value = 10
total.value = 0 browseItems.value = []; browseTotal.value = 0; browsePage.value = 1
currentPage.value = 1
visibleGroupCount.value = 10
browseItems.value = []
browseTotal.value = 0
browsePage.value = 1
runBrowse(1, false) runBrowse(1, false)
} else { } else {
browseItems.value = [] browseItems.value = []; browseTotal.value = 0; browsePage.value = 1
browseTotal.value = 0 groupedHits.value = []; total.value = 0; currentPage.value = 1; visibleGroupCount.value = 10
browsePage.value = 1 runSearch(q, 1, false)
hits.value = [] }
})
watch(exactSearch, () => {
if (query.value.trim()) runSearch(query.value, 1, false)
})
watch(sortMode, () => {
if (query.value.trim()) {
groupedHits.value = []
total.value = 0 total.value = 0
currentPage.value = 1 currentPage.value = 1
visibleGroupCount.value = 10 runSearch(query.value, 1, false)
runSearch(q, 1, false)
} }
}) })
@ -426,71 +449,44 @@ const selectedDocument = ref<DocumentDoc | null>(null)
const documentLoading = ref(false) const documentLoading = ref(false)
const selectedParagraphs = ref<TypesenseParagraphHit[]>([]) const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
const paragraphsLoading = ref(false) const paragraphsLoading = ref(false)
const selectedHit = ref<TypesenseParagraphHit | null>(null) const selectedHit = ref<TypesenseParagraphHit | null>(null)
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([]) const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
async function fetchFullDocument(docId: string) { async function fetchDocumentWithParagraphs(docId: string) {
documentLoading.value = true documentLoading.value = true
paragraphsLoading.value = true
selectedDocument.value = null selectedDocument.value = null
selectedParagraphs.value = []
try { try {
const res = await documentsApi.multiSearch({ const res = await documentsApi.multiSearch({
multiSearchParameters: {}, multiSearchParameters: {},
multiSearchSearchesParameter: { multiSearchSearchesParameter: {
searches: [{ searches: [{
collection: DOCUMENTS_COLLECTION, collection: props.mainCollection,
q: '*', q: '*',
queryBy: 'title', queryBy: 'title',
filterBy: `id:=${docId}`, filterBy: `id:=${docId} && $${props.paragraphsCollection}(id: *)`,
perPage: 1, includeFields: `*, $${props.paragraphsCollection}(*)`
page: 1
}] }]
} }
}) })
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocumentDoc }> })?.hits ?? [] const hit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
selectedDocument.value = docHits[0]?.document ?? null if (hit) {
const docRaw = { ...hit.document }
const raw = docRaw[props.paragraphsCollection]
const rawParagraphs = Array.isArray(raw) ? raw : (raw ? [raw] : []) as ParagraphDoc[]
delete docRaw[props.paragraphsCollection]
selectedDocument.value = docRaw as unknown as DocumentDoc
selectedParagraphs.value = [...rawParagraphs]
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
.map(p => ({ document: p }))
}
} catch (err) { } catch (err) {
console.error('Error fetching document', err) console.error('Error fetching document with paragraphs', err)
selectedDocument.value = null selectedDocument.value = null
selectedParagraphs.value = []
} finally { } finally {
documentLoading.value = false 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 paragraphsLoading.value = false
} }
} }
@ -498,11 +494,8 @@ async function fetchDocumentParagraphs(docId: string) {
async function selectGroup(group: DisplayGroup) { async function selectGroup(group: DisplayGroup) {
selectedDocId.value = group.docId selectedDocId.value = group.docId
selectedHit.value = group.firstHit selectedHit.value = group.firstHit
selectedMatchingHits.value = group.firstHit selectedMatchingHits.value = groupedHits.value.find(g => g.docId === group.docId)?.allHits ?? []
? hits.value.filter(h => h.document.document_id === group.docId) fetchDocumentWithParagraphs(group.docId)
: []
fetchFullDocument(group.docId)
fetchDocumentParagraphs(group.docId)
} }
const isPanelOpen = computed({ const isPanelOpen = computed({
@ -520,8 +513,7 @@ const isPanelOpen = computed({
watch(groupedHits, () => { watch(groupedHits, () => {
if (!selectedDocId.value || !debouncedQuery.value.trim()) return if (!selectedDocId.value || !debouncedQuery.value.trim()) return
const still = groupedHits.value.find(g => g.docId === selectedDocId.value) if (!groupedHits.value.find(g => g.docId === selectedDocId.value)) {
if (!still) {
selectedDocId.value = null selectedDocId.value = null
selectedDocument.value = null selectedDocument.value = null
selectedParagraphs.value = [] selectedParagraphs.value = []
@ -530,33 +522,22 @@ watch(groupedHits, () => {
} }
}) })
// ID del documento seleccionado para sincronizar con la URL
const selectedId = computed(() => selectedDocId.value) const selectedId = computed(() => selectedDocId.value)
// Sincronización con URL
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listContainer }) useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listContainer })
// Ciclo de vida
onMounted(async () => { onMounted(async () => {
// Carga inicial: browse si no hay query, search si hay query if (q0.trim()) await runSearch(q0, p0, false)
if (q0.trim()) { else await runBrowse(p0, false)
await runSearch(q0, p0, false)
} else {
await runBrowse(p0, false)
}
// Restaurar scroll
restoreScrollPosition(listContainer.value, s0) restoreScrollPosition(listContainer.value, s0)
// Restaurar documento seleccionado
if (sid0) { if (sid0) {
const group = displayGroups.value.find(g => g.docId === sid0) const group = displayGroups.value.find(g => g.docId === sid0)
if (group) { if (group) selectGroup(group)
selectGroup(group) else {
} else {
selectedDocId.value = sid0 selectedDocId.value = sid0
fetchFullDocument(sid0) fetchDocumentWithParagraphs(sid0)
fetchDocumentParagraphs(sid0)
} }
} }
}) })
@ -588,44 +569,77 @@ function metaDate(meta: DocMeta | undefined): string {
function metaLocation(meta: DocMeta | undefined): string { function metaLocation(meta: DocMeta | undefined): string {
if (!meta) return '' if (!meta) return ''
return formatLocation({ return formatLocation({
id: meta.id, id: meta.id, date: meta.timestamp ?? 0, slug: meta.slug ?? '',
date: meta.timestamp ?? 0, type: meta.type ?? '', place: meta.place ?? '', city: meta.city ?? '',
slug: meta.slug ?? '', state: meta.state ?? '', country: meta.country ?? '', thumbnail: ''
type: meta.type ?? '',
place: meta.place ?? '',
city: meta.city ?? '',
state: meta.state ?? '',
country: meta.country ?? '',
thumbnail: ''
}) })
} }
</script> </script>
<template> <template>
<UDashboardPanel <UDashboardPanel
id="conferencias-ts-list" :id="panelId"
:default-size="32" :default-size="32"
:min-size="24" :min-size="24"
:max-size="45" :max-size="45"
resizable resizable
> >
<UDashboardNavbar :title="t('nav.conferences_ts')"> <UDashboardNavbar :title="t(navTitleKey)">
<template #leading> <template #leading>
<UDashboardSidebarCollapse /> <UDashboardSidebarCollapse :ui="{
base: 'collapse-sidebar-icon'
}" />
</template> </template>
<template #trailing> <template #trailing>
<UBadge :label="displayTotal" variant="subtle" /> <UBadge :label="displayTotal" variant="subtle" :ui="{
base: 'total-results'
}" />
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
<div class="px-4 sm:px-6 py-3 border-b border-default"> <div
v-if="author"
class="px-4 sm:px-6 py-2 border-b border-default flex items-center gap-1.5 text-xs text-muted"
>
<UIcon name="ph:user-circle" :class="['size-3.5 shrink-0', colors.icon]" />
<span class="italic">{{ author }}</span>
</div>
<div class="px-4 sm:px-6 py-3 border-b border-default flex items-center gap-2" id="inputField">
<UInput <UInput
v-model="query" v-model="query"
icon="i-lucide-search" icon="i-lucide-search"
:placeholder="t('search.placeholder')" :placeholder="t('search.placeholder')"
:loading="loading" :loading="loading"
size="md" size="md"
class="w-full" class="flex-1 min-w-0"
/>
<div
class="flex rounded-full p-0.5 shrink-0 transition-colors duration-200"
:class="exactSearch ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'"
>
<button
class="px-2.5 py-0.5 rounded-full text-xs transition-all duration-200 whitespace-nowrap"
:class="!exactSearch ? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white font-semibold shadow-sm' : 'text-white/40 font-normal'"
@click.stop="exactSearch = false"
>{{ t('search.word') }}</button>
<button
class="px-2.5 py-0.5 rounded-full text-xs transition-all duration-200 whitespace-nowrap"
:class="exactSearch ? 'bg-white text-primary font-semibold shadow-sm' : 'text-gray-400 dark:text-gray-400 font-normal'"
@click.stop="exactSearch = true"
>{{ t('search.phrase') }}</button>
</div>
</div>
<div class="px-4 sm:px-6 py-3">
<USelect
v-if="query.trim()"
v-model="sortMode"
:items="[
{ label: t('search.sort.relevance'), value: 'relevance' },
{ label: t('search.sort.date'), value: 'date' }
]"
size="sm"
class="shrink-0 min-w-[130px]"
/> />
</div> </div>
@ -659,38 +673,34 @@ function metaLocation(meta: DocMeta | undefined): string {
<div <div
v-for="group in displayGroups" v-for="group in displayGroups"
:key="group.docId" :key="group.docId"
class="bg-white p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors" class="p-4 sm:px-6 text-sm cursor-pointer border-b-2 transition-colors"
:class="[ :class="selectedDocId === group.docId ? colors.selectedItem : colors.hoverItem"
selectedDocId === group.docId
? 'border-primary bg-primary/10'
: 'border-transparent hover:border-primary hover:bg-primary/5'
]"
@click="selectGroup(group)" @click="selectGroup(group)"
> >
<div class="mb-1"> <div class="mb-1">
<p class="text-sm font-semibold line-clamp-2 text-highlighted"> <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 }} {{ group.meta?.title || group.docId }}
</p> </p>
</div> </div>
<p class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-xs mb-2 text-muted"> <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 <span v-if="metaDate(group.meta)" class="flex items-center gap-1">
v-if="metaDate(group.meta)" <UIcon name="ph:calendar" :class="['size-4', colors.icon]" />
class="flex items-center gap-1"
>
<UIcon name="ph:calendar" class="size-4 text-green-600" />
{{ metaDate(group.meta) }} {{ metaDate(group.meta) }}
</span> </span>
<span <span v-if="metaLocation(group.meta)" class="flex items-center gap-1 truncate">
v-if="metaLocation(group.meta)" <UIcon name="ph:map-pin" :class="['size-4 shrink-0', colors.icon]" />
class="flex items-center gap-1 truncate"
>
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
<span class="truncate">{{ metaLocation(group.meta) }}</span> <span class="truncate">{{ metaLocation(group.meta) }}</span>
</span> </span>
</p> </p>
<!-- Snippet con highlight: solo en modo búsqueda -->
<div <div
v-if="group.firstHit" v-if="group.firstHit"
class="snippet-html text-sm text-dimmed" class="snippet-html text-sm text-dimmed"
@ -698,7 +708,6 @@ function metaLocation(meta: DocMeta | undefined): string {
/> />
</div> </div>
<!-- Infinite scroll: cargando más -->
<div <div
v-if="settings.paginationType === 'infinite_scroll' && loadingMore" v-if="settings.paginationType === 'infinite_scroll' && loadingMore"
class="flex items-center justify-center gap-2 py-4 text-sm text-muted" class="flex items-center justify-center gap-2 py-4 text-sm text-muted"
@ -715,7 +724,6 @@ function metaLocation(meta: DocMeta | undefined): string {
</div> </div>
</div> </div>
<!-- Paginación numerada -->
<div <div
v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading" v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading"
class="px-4 py-3 border-t border-default flex justify-center shrink-0" class="px-4 py-3 border-t border-default flex justify-center shrink-0"
@ -731,22 +739,24 @@ function metaLocation(meta: DocMeta | undefined): string {
</UDashboardPanel> </UDashboardPanel>
<!-- Panel de detalle (escritorio) --> <!-- Panel de detalle (escritorio) -->
<EstudiosTypensenseDetail <PublicationDetail
v-if="selectedDocId && !isMobile" v-if="selectedDocId && !isMobile"
:document="selectedDocument" :document="selectedDocument"
:document-loading="documentLoading" :document-loading="documentLoading"
:paragraphs="selectedParagraphs" :paragraphs="selectedParagraphs"
:paragraphs-loading="paragraphsLoading" :paragraphs-loading="paragraphsLoading"
:collection="FAVORITES_COLLECTION" :collection="favoritesCollection"
:query="debouncedQuery" :query="debouncedQuery"
:selected-hit="selectedHit" :selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits" :selected-matching-hits="selectedMatchingHits"
:accent-color="accentColor"
:author="author"
@close="isPanelOpen = false" @close="isPanelOpen = false"
/> />
<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-search" class="size-16" /> <UIcon name="i-lucide-search" class="size-16" />
<p class="text-sm">Selecciona una conferencia para ver el detalle</p> <p class="text-sm">{{ emptyDetailText }}</p>
</div> </div>
</div> </div>
@ -754,16 +764,18 @@ function metaLocation(meta: DocMeta | undefined): string {
<ClientOnly> <ClientOnly>
<USlideover v-if="isMobile" v-model:open="isPanelOpen"> <USlideover v-if="isMobile" v-model:open="isPanelOpen">
<template #content> <template #content>
<EstudiosTypensenseDetail <PublicationDetail
v-if="selectedDocId" v-if="selectedDocId"
:document="selectedDocument" :document="selectedDocument"
:document-loading="documentLoading" :document-loading="documentLoading"
:paragraphs="selectedParagraphs" :paragraphs="selectedParagraphs"
:paragraphs-loading="paragraphsLoading" :paragraphs-loading="paragraphsLoading"
:collection="FAVORITES_COLLECTION" :collection="favoritesCollection"
:query="debouncedQuery" :query="debouncedQuery"
:selected-hit="selectedHit" :selected-hit="selectedHit"
:selected-matching-hits="selectedMatchingHits" :selected-matching-hits="selectedMatchingHits"
:accent-color="accentColor"
:author="author"
@close="isPanelOpen = false" @close="isPanelOpen = false"
/> />
</template> </template>

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,113 @@
import { ref } from 'vue'
import type { SearchHit } from '~/types'
interface ParagraphDoc {
id?: string
document_id: string
text: string
raw?: string
number: number
locale: string
type: string
}
export interface TypesenseParagraphHit {
document: ParagraphDoc
highlights?: unknown[]
highlight?: Record<string, unknown>
}
export interface DocumentDoc {
id?: string
code: string
locale: string
type: string
title: string
timestamp: number
date: string
place?: string
city?: string
state?: string
country?: string
thumbnail?: string
files?: { youtube?: string; video?: string; audio?: string; booklet?: string; simple?: string }
slug?: string
body?: string
draft?: boolean
[key: string]: unknown
}
// Maps the favorites/history collection name → Typesense main + paragraphs collections.
const COLLECTION_CONFIG: Record<string, { main: string; paragraphs: string }> = {
'bible-studies-ts': { main: 'activities', paragraphs: 'activities_paragraphs' },
'conferences-ts': { main: 'conferences', paragraphs: 'conferences_paragraphs' },
}
export function usePublicationFetch() {
const detailDocument = ref<DocumentDoc | null>(null)
const detailDocumentLoading = ref(false)
const detailParagraphs = ref<TypesenseParagraphHit[]>([])
const detailParagraphsLoading = ref(false)
const { documentsApi } = useTypesenseApi()
async function fetchDetail(hit: SearchHit, favoritesCollection: string) {
const config = COLLECTION_CONFIG[favoritesCollection]
const docId = String(hit._id || hit.id || '')
if (!config || !docId) {
detailDocument.value = null
detailParagraphs.value = []
return
}
detailDocumentLoading.value = true
detailParagraphsLoading.value = true
detailDocument.value = null
detailParagraphs.value = []
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: config.main,
q: '*',
queryBy: 'title',
filterBy: `id:=${docId} && $${config.paragraphs}(id: *)`,
includeFields: `*, $${config.paragraphs}(*)`
}]
}
})
const docHit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
if (docHit) {
const docRaw = { ...docHit.document }
const raw = docRaw[config.paragraphs]
const rawParagraphs = Array.isArray(raw) ? raw : (raw ? [raw] : []) as ParagraphDoc[]
delete docRaw[config.paragraphs]
detailDocument.value = docRaw as unknown as DocumentDoc
detailParagraphs.value = [...rawParagraphs]
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
.map(p => ({ document: p }))
}
} catch (err) {
console.error('[usePublicationFetch] Error fetching publication detail', err)
detailDocument.value = null
detailParagraphs.value = []
} finally {
detailDocumentLoading.value = false
detailParagraphsLoading.value = false
}
}
function clearDetail() {
detailDocument.value = null
detailParagraphs.value = []
}
return {
detailDocument,
detailDocumentLoading,
detailParagraphs,
detailParagraphsLoading,
fetchDetail,
clearDetail,
}
}

View File

@ -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;
@ -18,99 +22,133 @@ const { total: favTotal } = storeToRefs(favorites)
const history = useHistoryStore() const history = useHistoryStore()
const { total: histTotal } = storeToRefs(history) const { total: histTotal } = storeToRefs(history)
const toCarpa = () => {
window.location.href = `https://carpa.com/${$i18n.locale.value}`;
}
const links = computed(() => { const links = computed(() => {
return [ const links = [
{ {
label: $i18n.t('nav.bible_studies'), id: 'bible-studies',
icon: 'ph:books', label: t('nav.bible_studies'),
to: '/actividades', icon: 'ph-books',
locale: locale.value, to: '/estudios-biblicos',
onSelect: () => { open.value = false } onSelect: () => { open.value = false },
}, },
{ {
label: t("nav.conferences"), id: 'conferences',
icon: 'ph:books', label: t('nav.conferences'),
icon: 'ph-books',
to: '/conferencias', to: '/conferencias',
onSelect: () => { open.value = false } onSelect: () => { open.value = false }
}, },
{ {
label: t("nav.bible_studies_ts"), id: 'betweenthelines',
icon: 'i-lucide-database', label: t('nav.between_the_lines'),
to: '/estudios-typensense', icon: 'ph-list-magnifying-glass',
onSelect: () => { open.value = false }
},
{
label: t("nav.conferences_ts"),
icon: 'i-lucide-database',
to: '/conferencias-typensense',
onSelect: () => { open.value = false }
},
{
label: t("nav.between_the_lines"),
icon: 'ph:list-magnifying-glass',
to: '/entrelineas', to: '/entrelineas',
onSelect: () => { open.value = false } onSelect: () => { open.value = false }
}, },
{ {
label: t("nav.my_list"), id: 'favorites',
label: t('nav.my_list'),
icon: 'i-lucide-bookmark', icon: 'i-lucide-bookmark',
to: '/favoritos', to: '/favoritos',
badge: favTotal.value > 0 ? String(favTotal.value) : undefined, badge: favTotal.value > 0 ? String(favTotal.value) : undefined,
onSelect: () => { open.value = false } onSelect: () => { open.value = false }
}, },
{ {
label: t("nav.history"), id: 'history',
label: t('nav.history'),
icon: 'i-lucide-history', icon: 'i-lucide-history',
to: '/historial', to: '/historial',
badge: histTotal.value > 0 ? String(histTotal.value) : undefined, badge: histTotal.value > 0 ? String(histTotal.value) : undefined,
onSelect: () => { open.value = false } onSelect: () => { open.value = false }
}, },
{ {
label: t("nav.settings"), id: 'changelog',
label: t('nav.changelog'),
icon: 'i-lucide-megaphone',
to: '/changelog',
onSelect: () => { open.value = false }
},
{
id: 'settings',
label: t('nav.settings'),
icon: 'i-lucide-settings', 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: 'hidden sm:flex mt-4 border-t-2 border-gray-300 pt-4',
label: t('nav.tour'),
icon: 'ph-student',
onSelect: () => { doTour() },
chip: {
color: 'error'
}
} }
] satisfies NavigationMenuItem[]
})
] 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 id="default" v-model:open="open" collapsible resizable
<UDashboardSidebar class="bg-elevated/25 bg-gradient-to-tr from-blue-100 to-white" :ui="{ footer: 'lg:border-t lg:border-default' }">
id="default"
v-model:open="open"
collapsible
resizable
class="bg-elevated/25 bg-gradient-to-tr from-blue-100 to-white"
:ui="{ footer: 'lg:border-t lg:border-default' }"
>
<template #header="{ collapsed }"> <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 v-on:click="toCarpa" src="/logo.svg" class="w-full cursor-pointer" alt="Buscador - La Gran Carpa Catedral" />
</div> </div>
<img v-if="collapsed" v-on:click="toCarpa" src="/logo_round.svg" class="w-full cursor-pointer" alt="Buscador - La Gran Carpa Catedral" />
</template> </template>
<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">
/> <UChip :color="item.chip?.color" :show="item.chip?.color ? true : false" inset>
<UIcon :name="item.icon" class="text-2xl" />
</UChip>
<div :class="!collapsed ? 'flex justify-between w-full' : 'hidden'">{{ item.label }}
<UBadge v-if="item.badge" variant="outline" color="neutral" size="sm" :label="item.badge" />
</div>
</div>
</template>
</UNavigationMenu>
</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>

View File

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

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>

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

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

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,21 +285,68 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
<template #leading> <template #leading>
<UDashboardSidebarCollapse /> <UDashboardSidebarCollapse />
</template> </template>
<template #trailing> <template #trailing>
<UBadge :label="total" variant="subtle" /> <UBadge :label="total" variant="subtle" :ui="{
base: 'total-results'
}" />
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
<!-- Banner: se muestra cuando NO hay clave de desarrollador -->
<template v-if="!unlocked">
<div class="flex-1 flex items-center justify-center p-8 bg-gradient-to-br from-amber-50 via-white to-amber-100/60">
<div class="flex flex-col items-center gap-6 text-center max-w-sm">
<div class="size-28 rounded-full bg-gradient-to-br from-amber-300 to-amber-500 flex items-center justify-center shadow-lg shadow-amber-200/50 ring-4 ring-white">
<UIcon name="i-lucide-flask-conical" class="size-14 text-white" />
</div>
<div class="space-y-2">
<h2 class="text-2xl font-bold text-highlighted">
{{ t('entrelineas.development_banner') }}
</h2>
<p class="text-sm text-toned leading-relaxed">
{{ t('entrelineas.development_subtitle_line1') }}<br>
{{ t('entrelineas.development_subtitle_line2') }}
</p>
</div>
<div class="flex items-center gap-2 pt-2">
<span class="size-2 rounded-full bg-amber-300 animate-pulse" />
<span class="size-2 rounded-full bg-amber-400 animate-pulse" style="animation-delay: 0.2s" />
<span class="size-2 rounded-full bg-amber-500 animate-pulse" style="animation-delay: 0.4s" />
</div>
</div>
</div>
</template>
<!-- Funcional: se muestra cuando la clave de desarrollador es correcta -->
<template v-else>
<div class="px-4 sm:px-6 py-3 border-b border-default"> <div class="px-4 sm:px-6 py-3 border-b border-default">
<div class="flex items-center gap-2">
<UInput <UInput
v-model="query" v-model="query"
icon="i-lucide-search" icon="i-lucide-search"
:placeholder="t('Search.placeholder')" :placeholder="t('Search.placeholder')"
:loading="loading" :loading="loading"
size="md" size="md"
class="w-full" 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"> <p class="mt-1.5 flex items-center gap-1 text-[11px] text-dimmed">
<UIcon name="i-lucide-database" class="size-3" /> <UIcon name="i-lucide-database" class="size-3" />
<span> <span>
@ -374,22 +399,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
> >
<div class="flex items-start justify-between gap-2 mb-1"> <div class="flex items-start justify-between gap-2 mb-1">
<div class="min-w-0 flex-1 flex gap-2"> <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 <UBadge
v-if="hit.document?.filter" v-if="hit.document?.filter"
:label="hit.document?.filter" :label="hit.document?.filter"
@ -406,6 +415,14 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
color="neutral" color="neutral"
class="mb-1 uppercase" class="mb-1 uppercase"
/> />
<UBadge
v-if="hit.document?.draft"
label="Borrador"
size="sm"
variant="subtle"
color="warning"
class="mb-1"
/>
</div> </div>
<UTooltip :text="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'"> <UTooltip :text="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'">
<UButton <UButton
@ -419,10 +436,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
</UTooltip> </UTooltip>
</div> </div>
<div class="text-sm font-semibold tracking-wide truncate mb-2">
{{ (hit.document?.studies?.[0]?.title as string) || hit.document.id || `entrelinea_${index}` }}
</div>
<div <div
v-if="highlightedFor(hit, 'text') || hit.document.text" v-if="highlightedFor(hit, 'text') || hit.document.text"
class="snippet-html text-sm text-toned" class="snippet-html text-sm text-toned"
@ -430,13 +443,8 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
/> />
<USeparator class="my-2"/> <USeparator class="my-2"/>
<div class="text-xs text-dimmed">
{{ hit.document?.origin }}
</div>
</div> </div>
<!-- Infinite scroll: cargando más -->
<div <div
v-if="settings.paginationType === 'infinite_scroll' && hasMore && !loading" v-if="settings.paginationType === 'infinite_scroll' && hasMore && !loading"
class="p-4 flex justify-center" class="p-4 flex justify-center"
@ -460,7 +468,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
</div> </div>
</div> </div>
<!-- Paginación numerada -->
<div <div
v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading" v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading"
class="px-4 py-3 border-t border-default flex justify-center shrink-0" class="px-4 py-3 border-t border-default flex justify-center shrink-0"
@ -473,9 +480,10 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
@update:page="goToPage" @update:page="goToPage"
/> />
</div> </div>
</template>
</UDashboardPanel> </UDashboardPanel>
<!-- Panel de detalle (escritorio) --> <template v-if="unlocked">
<EntrelineaDetail <EntrelineaDetail
v-if="selected && !isMobile" v-if="selected && !isMobile"
:document="selected" :document="selected"
@ -492,7 +500,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
</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>
@ -507,6 +514,7 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
</USlideover> </USlideover>
</ClientOnly> </ClientOnly>
</template> </template>
</template>
<style scoped> <style scoped>
.snippet-html :deep(p) { .snippet-html :deep(p) {

View File

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

View File

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

View File

@ -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 { useFavoritesStore, type FavoriteItem } from '~/stores/favorites' import { useFavoritesStore, type FavoriteItem } from '~/stores/favorites'
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'
const favorites = useFavoritesStore() const favorites = useFavoritesStore()
@ -17,6 +17,15 @@ const toast = useToast()
* 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'
const {
detailDocument,
detailDocumentLoading,
detailParagraphs,
detailParagraphsLoading,
fetchDetail,
clearDetail,
} = usePublicationFetch()
// 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
// internamente, así que aquí no hay que pasar nada relacionado a ImageKit. // internamente, así que aquí no hay que pasar nada relacionado a ImageKit.
@ -116,6 +125,14 @@ watch(favItems, (items) => {
const breakpoints = useBreakpoints(breakpointsTailwind) const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg') const isMobile = breakpoints.smaller('lg')
watch(selected, (item) => {
if (!item || item.collection === ENTRELINEAS_COLLECTION) {
clearDetail()
return
}
fetchDetail(item.hit, item.collection)
})
// ---- Helpers de fila --------------------------------------------------- // ---- Helpers de fila ---------------------------------------------------
function safeDate(hit: SearchHit) { function safeDate(hit: SearchHit) {
@ -477,11 +494,14 @@ const mobileActions = computed<DropdownMenuItem[][]>(() => [[
:collection="selectedCollection" :collection="selectedCollection"
@close="selected = null" @close="selected = null"
/> />
<!-- Resto (actividades, conferencias) InboxActivity. --> <!-- Resto (actividades, conferencias) detalle con párrafos de Typesense. -->
<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">
@ -502,10 +522,13 @@ const mobileActions = computed<DropdownMenuItem[][]>(() => [[
: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>

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(...)`).
*/ */
@ -30,14 +30,28 @@ const toast = useToast()
const ENTRELINEAS_COLLECTION = 'entrelineas' const ENTRELINEAS_COLLECTION = 'entrelineas'
const COLLECTION_LABELS: Record<string, string> = { const { t } = useI18n()
activities: 'Actividades',
conferences: 'Conferencias',
entrelineas: 'Entre Líneas'
}
function labelFor(c: string): string { function labelFor(c: string): string {
return COLLECTION_LABELS[c] || c.charAt(0).toUpperCase() + c.slice(1) const labels: Record<string, string> = {
activities: t('nav.bible_studies_ts'),
'bible-studies-ts': t('nav.bible_studies_ts'),
conferences: t('nav.conferences_ts'),
'conferences-ts': t('nav.conferences_ts'),
entrelineas: t('nav.between_the_lines'),
}
return labels[c] || c.charAt(0).toUpperCase() + c.slice(1)
}
const COLLECTION_AUTHORS: Record<string, string> = {
'bible-studies-ts': 'Dr. José Benjamín Pérez Matos',
activities: 'Dr. José Benjamín Pérez Matos',
'conferences-ts': 'Dr. William Soto Santiago',
conferences: 'Dr. William Soto Santiago',
}
function authorFor(c: string): string {
return COLLECTION_AUTHORS[c] || ''
} }
// Filtros: pestaña por colección o "todos". // Filtros: pestaña por colección o "todos".
@ -57,9 +71,9 @@ const tabs = computed(() => {
items.push({ items.push({
value: c, value: c,
label: `${labelFor(c)} (${count})`, label: `${labelFor(c)} (${count})`,
icon: c === 'activities' icon: (c === 'activities' || c === 'bible-studies-ts')
? 'i-lucide-calendar-days' ? 'i-lucide-calendar-days'
: c === 'conferences' : (c === 'conferences' || c === 'conferences-ts')
? 'i-lucide-mic' ? 'i-lucide-mic'
: c === 'entrelineas' : c === 'entrelineas'
? 'i-lucide-book-open' ? 'i-lucide-book-open'
@ -92,6 +106,7 @@ const selected = ref<HistoryItem | null>(null)
const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null) const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null)
const selectedCollection = computed<string | undefined>(() => selected.value?.collection) const selectedCollection = computed<string | undefined>(() => selected.value?.collection)
const selectedAuthor = computed(() => authorFor(selectedCollection.value ?? ''))
const isEntrelinea = computed(() => selectedCollection.value === ENTRELINEAS_COLLECTION) const isEntrelinea = computed(() => selectedCollection.value === ENTRELINEAS_COLLECTION)
@ -107,6 +122,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 +542,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
@ -533,6 +567,13 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
<span v-if="hasDate(it.hit)">{{ safeDate(it.hit) }}</span> <span v-if="hasDate(it.hit)">{{ safeDate(it.hit) }}</span>
<USeparator v-if="formatLocation(it.hit)" orientation="vertical" class="h-3 hidden sm:block" /> <USeparator v-if="formatLocation(it.hit)" orientation="vertical" class="h-3 hidden sm:block" />
<span class="truncate">{{ formatLocation(it.hit) }}</span> <span class="truncate">{{ formatLocation(it.hit) }}</span>
<template v-if="authorFor(it.collection)">
<USeparator orientation="vertical" class="h-3 hidden sm:block" />
<span class="inline-flex items-center gap-1 italic truncate">
<UIcon name="ph:user-circle" class="size-3 shrink-0" />
{{ authorFor(it.collection) }}
</span>
</template>
</p> </p>
</div> </div>
</div> </div>
@ -544,13 +585,19 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
v-if="selected && !isMobile && isEntrelinea" v-if="selected && !isMobile && isEntrelinea"
:document="selectedEntrelineaDoc!" :document="selectedEntrelineaDoc!"
:collection="selectedCollection" :collection="selectedCollection"
no-track-visit
@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!"
:author="selectedAuthor"
no-track-visit
@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">
@ -569,12 +616,18 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
v-if="selected && isEntrelinea" v-if="selected && isEntrelinea"
:document="selectedEntrelineaDoc!" :document="selectedEntrelineaDoc!"
:collection="selectedCollection" :collection="selectedCollection"
no-track-visit
@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!"
:author="selectedAuthor"
no-track-visit
@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('/actividades', { 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',0)
},
{
label: t('nav.bible_studies'),
to: `/${$i18n.locale.value}/estudios-biblicos`,
icon: 'ph-books',
color: 'primary'
},
{
label: t('nav.conferences'),
to: `/${$i18n.locale.value}/conferencias`,
icon: 'ph-books',
color: 'secondary'
},
])
</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

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

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

View File

@ -1,30 +1,31 @@
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({
@ -32,41 +33,62 @@ export function formatFiles( files:FilesObject ){
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) { if (files.video) {
items.push({ items.push({
to: files.video, to: files.video,
target: '_blank', target: '_blank',
label: t('Activities.video'), label: t('downloads.video'),
icon: 'ph:file-video-thin', icon: 'ph:file-video-thin',
labelClass: 'text-xs' labelClass: 'text-xs'
}) })
} }
if (files.audio) { if (files.audio) {
let fileUrl = ''
if (files.audio.startsWith('http')) {
fileUrl = files.audio
} else {
fileUrl = `https://actividadeswp.carpa.com/wp-content/uploads/${files.audio}`
}
items.push({ items.push({
to: files.audio, to: fileUrl,
target: '_blank', target: '_blank',
label: t('Activities.audio'), label: t('downloads.audio'),
icon: 'ph:file-audio-thin', icon: 'ph:file-audio-thin',
labelClass: 'text-xs' labelClass: 'text-xs'
}) })
} }
if (files.booklet) { 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({ items.push({
to: files.booklet, to: fileUrl,
target: '_blank', target: '_blank',
label: t('Activities.book'), label: t('downloads.book'),
icon: 'ph:notebook-thin', icon: 'ph:notebook-thin',
labelClass: 'text-xs' labelClass: 'text-xs'
}) })
} }
if (files.simple) { 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({ items.push({
to: files.simple, to: fileUrl,
target: '_blank', target: '_blank',
label: t('Activities.simple'), label: t('downloads.simple'),
icon: 'ph:note-thin', icon: 'ph:note-thin',
labelClass: 'text-xs' labelClass: 'text-xs'
}) })
@ -76,76 +98,103 @@ export function formatFiles( files:FilesObject ){
} }
export function formatDate(d: number) { export function formatDate(d: number) {
const { $i18n } = useNuxtApp(); if (!d) {
const locale = $i18n.locale; return
}
let date = new Date(d * 1000) const { $i18n } = useNuxtApp()
const locale = $i18n.locale
let dateString = date.toLocaleString(locale.value, { year: 'numeric', month:'long', day: 'numeric', weekday:'long', timeZone:'UTC' }) const date = new Date(d * 1000)
return capitalizeFirstLetter( dateString );
const dateString = date.toLocaleString(locale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
timeZone: 'UTC'
})
return capitalizeFirstLetter(dateString)
} }
export function formatLocation(i: ItemObject) { export function formatLocation(i: ItemObject) {
const { $i18n } = useNuxtApp(); if (!i) {
const locale = $i18n.locale; return
const regionNames = new Intl.DisplayNames( }
[ locale.value], {type: 'region'} const { $i18n } = useNuxtApp()
); const locale = $i18n.locale
const regionNames = new Intl.DisplayNames([locale.value], { type: 'region' })
var locationParts = []; const locationParts = []
locationParts.push(i?.place); locationParts.push(i?.place)
locationParts.push(i?.city); locationParts.push(i?.city)
locationParts.push(i?.state); locationParts.push(i?.state)
if (i.country) { if (i.country) {
locationParts.push(regionNames.of(i.country)); locationParts.push(regionNames.of(i.country))
} }
return locationParts.filter(Boolean).join(', '); 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) { export function getDay(d: number) {
const { $i18n } = useNuxtApp(); const { $i18n } = useNuxtApp()
const locale = $i18n.locale; const locale = $i18n.locale
let date = new Date(d * 1000); const date = new Date(d * 1000)
return date.toLocaleString(locale.value, { weekday: 'long', timeZone: 'utc' }); return date.toLocaleString(locale.value, {
weekday: 'long',
timeZone: 'utc'
})
} }
export function getDayDate(d: number) { export function getDayDate(d: number) {
const { $i18n } = useNuxtApp(); const { $i18n } = useNuxtApp()
const locale = $i18n.locale; const locale = $i18n.locale
let date = new Date(d * 1000); const date = new Date(d * 1000)
return date.toLocaleString(locale.value, { day: 'numeric', timeZone: 'utc' }); return date.toLocaleString(locale.value, { day: 'numeric', timeZone: 'utc' })
} }
export function getMonth(d: number) { export function getMonth(d: number) {
const { $i18n } = useNuxtApp(); const { $i18n } = useNuxtApp()
const locale = $i18n.locale; const locale = $i18n.locale
let date = new Date(d * 1000); const date = new Date(d * 1000)
return date.toLocaleString(locale.value, { month: 'long', timeZone: 'utc' }); return date.toLocaleString(locale.value, { month: 'long', timeZone: 'utc' })
} }
export function setUrl(item: ItemObject, isSearch: boolean) { export function setUrl(item: ItemObject, isSearch: boolean) {
const { $i18n } = useNuxtApp(); const { $i18n } = useNuxtApp()
const locale = $i18n.locale; const locale = $i18n.locale
const date = dayjs(item.date*1000); const date = dayjs(item.date * 1000)
const month = (date.month() + 1).toString() const month = (date.month() + 1).toString()
const slug = (item.slug || item.slug === 'undefined' ? item.slug : item.id); const slug = item.slug || item.slug === 'undefined' ? item.slug : item.id
if (isSearch) { if (isSearch) {
return false; return false
} else { } else {
return `/${locale.value}/${item.type}/${date.year()}/${month.padStart(2, '0')}/${slug}`; return `/${locale.value}/${item.type}/${date.year()}/${month.padStart(2, '0')}/${slug}`
} }
} }
export function getThumbnail(item: ItemObject) { export function getThumbnail(item: ItemObject) {
console.log("ITEM",item); const path = item.thumbnail
let 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`
} }
@ -154,14 +203,14 @@ export function getThumbnail( item:ItemObject ) {
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

View File

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

View File

@ -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

@ -3,18 +3,27 @@
"nav": { "nav": {
"home": "Home", "home": "Home",
"bible_studies": "Bible Studies", "bible_studies": "Bible Studies",
"bible_studies_ts": "Bible Studies Typesense", "bible_studies_ts": "Bible Studies",
"conferences_ts": "Conferences Typesense", "conferences_ts": "Conferences",
"conferences": "Conferences", "conferences": "Conferences",
"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": {
"sort": {
"relevance": "Normal",
"date": "Most recent"
},
"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.",
"publication": "Publication",
"draft": "Draft",
"country": "Country", "country": "Country",
"state": "State", "state": "State",
"city": "City", "city": "City",
@ -43,6 +52,44 @@
"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

@ -2,18 +2,48 @@
"nav": { "nav": {
"home": "Inicio", "home": "Inicio",
"bible_studies": "Estudios Bíblicos", "bible_studies": "Estudios Bíblicos",
"bible_studies_ts": "Estudios Bíblicos Typesense", "bible_studies_ts": "Estudios Bíblicos",
"conferences_ts": "Conferencias Typesense", "conferences_ts": "Conferencias",
"conferences": "Conferencias", "conferences": "Conferencias",
"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",
"localeselector": "Selector de idioma"
},
"tour": {
"progress": "{current} de {total}",
"next": "Siguiente",
"prev": "Anterior",
"done": "Finalizar",
"bible_studies_description": "Realiza búsquedas en los Estudios Bíblicos predicados por el Dr. José Benjamín Pérez Matos",
"conferences_description": "Realiza búsquedas en las conferencias predicadas por el Dr. William Soto Santiago",
"betweenthelines_description": "Realiza búsquedas en las imágenes de entrelíneas de los estudios del Dr. José Benjamín Pérez Matos",
"favorites_description": "Listado de resultados guardados como favoritos para fácil acceso futuro",
"history_description": "Historial de los resultados de búsqueda que has visto",
"settings_description": "Configuración, cantidad de resultados por página, tipo de paginación, entre otros",
"changelog_description": "Bitácora de cambios realizados al sitio en orden cronológico",
"feedback_description": "¿Tienes alguna sugerencia o queja? Realízala aquí.",
"localeselector_description": "Cambia fácilmente el idioma de la página",
"index_changelog": "Panel de últimos cambios subidos",
"favorites_toggle": "Botón para guardar / quitar este documento de mis favoritos",
"collapse_sidebar_description": "Oculta o muestra la barra de navegación lateral para tener más espacio en pantalla",
"total_results_description": "Muestra la cantidad total de resultados encontrados para tu búsqueda",
"favorites_button": "Botón de favoritos"
},
"home": {
"instructions": "Bienvenidos, aquí podrán buscar, entre los Estudios Bíblicos, las conferencias y las entrelíneas que están disponibles en el material de archivo de La Gran Carpa Catedral."
}, },
"search": { "search": {
"placeholder": "Buscar...", "placeholder": "Buscar...",
"searching": "Buscando...", "searching": "Buscando...",
"tip": "Consejo: envuelve en \"comillas\" para frase exacta en ese orden.", "tip": "Consejo: envuelve en \"comillas\" para frase exacta en ese orden.",
"collapse": "Colapsar menú lateral",
"total_results": "Total de resultados",
"publication": "Publicación",
"draft": "Borrador",
"country": "País", "country": "País",
"state": "Estado", "state": "Estado",
"city": "Ciudad", "city": "Ciudad",
@ -25,11 +55,17 @@
"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",
"sort": {
"relevance": "Normal",
"date": "Más recientes"
},
"word": "Palabra",
"phrase": "Frase",
"words": "palabras", "words": "palabras",
"phrases": "frases", "phrases": "frases",
"words_tooltip": "Buscar por palabras", "words_tooltip": "Buscar por palabras",
"phrases_tooltip": "Buscar por frases", "phrases_tooltip": "Buscar por frases",
"instructions": "Selecciona un resultado de busqueda...", "instructions": "Selecciona un resultado de búsqueda...",
"tab1": { "tab1": {
"tab_title": "Actividades" "tab_title": "Actividades"
}, },
@ -42,12 +78,54 @@
"page_size_desc": "Cuántos resultados cargar en cada página o petición.", "page_size_desc": "Cuántos resultados cargar en cada página o petición.",
"results": "resultados", "results": "resultados",
"pagination_title": "Tipo de paginación", "pagination_title": "Tipo de paginación",
"pagination_desc": "Cómo quieres navegar entre los resultados.", "pagination_desc": "¿Cómo quieres navegar entre los resultados?",
"infinite_scroll": "Scroll infinito", "infinite_scroll": "Scroll infinito",
"infinite_scroll_desc": "Los resultados se cargan automáticamente al llegar al final de la lista.", "infinite_scroll_desc": "Los resultados se cargan automáticamente al llegar al final de la lista.",
"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,10 +9,19 @@
"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": {
"placeholder": "Rechercher des activités" "sort": {
"relevance": "Normal",
"date": "Plus récents"
},
"word": "Mot",
"phrase": "Phrase",
"placeholder": "Rechercher des activités",
"publication": "Publicación",
"draft": ""
}, },
"locale": { "locale": {
"en": "English", "en": "English",
@ -31,6 +40,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,10 +9,19 @@
"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": {
"sort": {
"relevance": "Normal",
"date": "Mais recentes"
},
"word": "Palavra",
"phrase": "Frase",
"placeholder": "Digite para pesquisar...", "placeholder": "Digite para pesquisar...",
"publication": "Publicação",
"draft": "Borrador",
"for": "Para", "for": "Para",
"city": "Cidade", "city": "Cidade",
"page": "Página", "page": "Página",
@ -41,6 +50,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,9 @@ 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: nuxt-driver.js:
specifier: ^1.4.17 specifier: ^0.1.1
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)) version: 0.1.1(magicast@0.5.2)
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 +96,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 +1046,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 +1140,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 +1234,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 +2539,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 +2551,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 +2569,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 +2789,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 +2974,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 +3001,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==}
@ -3812,6 +3692,9 @@ packages:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'} engines: {node: '>=12'}
driver.js@1.4.0:
resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -4134,10 +4017,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 +4282,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 +4356,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 +4492,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 +4700,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 +4715,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 +4774,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 +4863,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,8 +4883,8 @@ 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: nuxt-driver.js@0.1.1:
resolution: {integrity: sha512-9O0dUIwuu00YAGVqQ0BVp/7kgLf3a6ONeOy6gDYtgj8sw/VI1o5tYIwg9hMjesOD1Jw/2WGL8dORkNRN2Mwedg==} resolution: {integrity: sha512-K8SJBzLD8oyu3mhp2vv6F6/SUEaXSp067iGLCPDiMGh4w86VdDArapPmyylH9ZvYq/aeltVPbS6S/uOuVtAXHw==}
nuxt@4.4.5: nuxt@4.4.5:
resolution: {integrity: sha512-MwTf3wyaEIm1U9/T1VKpqg7rGhhrn5Cx2ZS40lwo8GxsiY9xE7UOj5Cg0eAI0fSbJzyXlzdxspytgqWsgL+nIA==} resolution: {integrity: sha512-MwTf3wyaEIm1U9/T1VKpqg7rGhhrn5Cx2ZS40lwo8GxsiY9xE7UOj5Cg0eAI0fSbJzyXlzdxspytgqWsgL+nIA==}
@ -5431,9 +5263,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 +5328,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 +5344,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 +5485,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 +6157,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 +6321,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 +7150,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 +7437,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 +7549,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 +7599,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 +7767,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 +8742,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 +8750,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 +8768,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 +9099,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 +9335,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 +9353,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 +9360,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: {}
@ -10412,6 +10032,8 @@ snapshots:
dotenv@17.4.2: {} dotenv@17.4.2: {}
driver.js@1.4.0: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@ -10833,8 +10455,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 +10605,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 +10742,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 +10806,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 +10916,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 +11132,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 +11140,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 +11185,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 +11202,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 +11352,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,20 +11371,12 @@ 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)): nuxt-driver.js@0.1.1(magicast@0.5.2):
dependencies: dependencies:
'@meilisearch/instant-meilisearch': 0.28.0 '@nuxt/kit': 4.4.5(magicast@0.5.2)
'@nuxt/kit': 4.2.0(magicast@0.5.2) driver.js: 1.4.0
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: transitivePeerDependencies:
- '@vue/server-renderer'
- algoliasearch
- magicast - 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:
@ -12375,8 +11928,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 +12017,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 +12027,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 +12190,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 +12871,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 +13032,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

BIN
public/favicon.ico Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

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 })
}
})