diff --git a/app/components/inbox/InboxActivity.vue b/app/components/inbox/InboxActivity.vue index f44cf72..90eb03c 100755 --- a/app/components/inbox/InboxActivity.vue +++ b/app/components/inbox/InboxActivity.vue @@ -2,10 +2,13 @@ import dayjs from 'dayjs' import { useDebounce } from '@vueuse/core' import type { SearchHit } from '~/types' +import { useFavoritesStore } from '~/stores/favorites' const props = defineProps<{ activity: SearchHit - collection?: 'activities' | 'conferences' + /** Permitir cualquier nombre de colección para que el detalle siga siendo + * reusable cuando se añadan otros buscadores en el futuro. */ + collection?: string query?: string }>() @@ -13,6 +16,27 @@ const emits = defineEmits(['close']) const { locale } = useI18n() +const favorites = useFavoritesStore() +const toast = useToast() + +const isFav = computed(() => { + if (!props.collection || !props.activity?._id) return false + return favorites.isFavorite(props.collection, props.activity._id) +}) + +function onToggleFavorite() { + if (!props.collection || !props.activity) return + const wasFav = isFav.value + favorites.toggle(props.collection, props.activity) + toast.add({ + title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista', + description: props.activity.title, + icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check', + color: wasFav ? 'neutral' : 'primary', + duration: 1800 + }) +} + // ---- Match URL & metadata ---------------------------------------------- function getTimestamp(i: SearchHit): number | null { @@ -45,7 +69,11 @@ function safeDate(i: SearchHit) { const bodyContainer = ref(null) const scrollContainer = ref(null) -const localQuery = ref(props.query || '') +function stripOuterQuotes(s: string): string { + return s.trim().replace(/^"+|"+$/g, '').trim() +} + +const localQuery = ref(stripOuterQuotes(props.query || '')) const debouncedLocalQuery = useDebounce(localQuery, 200) const currentIdx = ref(0) @@ -55,7 +83,7 @@ const totalMatches = ref(0) watch( () => props.activity?._id, () => { - localQuery.value = props.query || '' + localQuery.value = stripOuterQuotes(props.query || '') currentIdx.value = 0 totalMatches.value = 0 } @@ -64,7 +92,8 @@ watch( // Keep local query in sync if the parent search changes while the same // activity stays open. watch(() => props.query, (q) => { - if (q !== undefined && q !== localQuery.value) localQuery.value = q || '' + const stripped = stripOuterQuotes(q || '') + if (stripped !== localQuery.value) localQuery.value = stripped }) function escapeRegex(s: string) { @@ -107,11 +136,36 @@ function findMatchesInText(text: string, terms: string[]): Array<{ start: number return out } -/** In the detail view, the entire query is treated as ONE exact phrase — no - * word splitting. Surrounding double quotes are tolerated and stripped. */ +// "los paises" -> ['los paises'] | los paises -> ['los', 'paises'] +// Palabras de 1 caracter se descartan (ruido); usa comillas si las necesitas. function parseTerms(query: string): string[] { - const trimmed = (query || '').trim().replace(/^"+|"+$/g, '').trim() - return trimmed.length > 0 ? [trimmed] : [] + if (!query) return [] + const tokens: string[] = [] + const tokenRe = /"([^"]+)"|(\S+)/g + for (const match of query.matchAll(tokenRe)) { + const [, phrase, word] = match + if (phrase) tokens.push(phrase.trim()) + else if (word && word.length > 1) tokens.push(word) + } + return tokens.filter(Boolean) +} + +/** Decide cómo trocear la query antes de buscarla en el cuerpo del detalle. + * + * - Si la query coincide con la que viene del buscador padre (`props.query`), + * se tokeniza igual que en el listado: cada palabra se resalta por separado, + * para que el usuario vea TODOS los términos del search global. + * - Si el usuario ha modificado el input local (escribió algo distinto), + * tratamos toda la cadena como una FRASE EXACTA — sin tokenizar — porque + * cuando alguien escribe "ley de extranjeria" dentro del detalle quiere + * encontrar exactamente esa secuencia, no las tres palabras sueltas. */ +function termsFor(query: string): string[] { + if (!query) return [] + if (query !== (props.query || '')) { + const stripped = stripOuterQuotes(query) + return stripped ? [stripped] : [] + } + return parseTerms(query) } function termToRegexSource(term: string): string { @@ -239,10 +293,16 @@ async function renderBody() { if (scrollContainer.value) scrollContainer.value.scrollTop = 0 - applyHighlights(false) + // Importante: usamos `localQuery.value` directamente (no la versión + // debounced) para que el primer scroll-a-coincidencia tras cambiar de + // detalle refleje la query recién reseteada. Si leyéramos del debounce, + // tendría aún el valor antiguo del input local (200ms de retraso) y + // buscaríamos el término viejo en el cuerpo nuevo → sin coincidencias → + // sin scroll. Bug clásico de carrera entre el reset síncrono y el debounce. + applyHighlights({ query: localQuery.value, scroll: true }) } -function applyHighlights(preserveScrollIfSameQuery = false) { +function applyHighlights({ query, scroll }: { query: string, scroll: boolean }) { const el = bodyContainer.value if (!el) return @@ -255,24 +315,25 @@ function applyHighlights(preserveScrollIfSameQuery = false) { // Merge adjacent text nodes so the next regex pass works cleanly. el.normalize() - const terms = parseTerms(debouncedLocalQuery.value || '') + const terms = termsFor(query) const count = terms.length ? highlightTextNodes(el, terms) : 0 totalMatches.value = count currentIdx.value = 0 if (count > 0) { - nextTick(() => applyCurrent(!preserveScrollIfSameQuery)) + nextTick(() => applyCurrent(scroll)) } } // Re-render entire body when the activity changes. watch(() => props.activity?._id, renderBody, { immediate: true }) -// Re-apply highlights when the local search query changes. -watch(debouncedLocalQuery, () => { - // Don't auto-scroll on every keystroke; jump to first match only on activity change. - applyHighlights(true) +// Re-apply highlights when the user edits the local search query. +// `scroll: false` porque mientras teclea no queremos saltar en cada pulsación; +// el salto inicial a la primera coincidencia ya lo hace `renderBody`. +watch(debouncedLocalQuery, (q) => { + applyHighlights({ query: q, scroll: false }) }) onMounted(renderBody) @@ -303,6 +364,9 @@ function onInputKey(e: KeyboardEvent) { diff --git a/app/components/inbox/InboxList.vue b/app/components/inbox/InboxList.vue index 7081633..e70fa35 100755 --- a/app/components/inbox/InboxList.vue +++ b/app/components/inbox/InboxList.vue @@ -1,6 +1,7 @@