426 lines
13 KiB
Vue
Executable File
426 lines
13 KiB
Vue
Executable File
<script setup lang="ts">
|
|
import dayjs from 'dayjs'
|
|
import { useDebounce } from '@vueuse/core'
|
|
import type { SearchHit } from '~/types'
|
|
|
|
const props = defineProps<{
|
|
activity: SearchHit
|
|
collection?: 'activities' | 'conferences'
|
|
query?: string
|
|
}>()
|
|
|
|
const emits = defineEmits(['close'])
|
|
|
|
const { locale } = useI18n()
|
|
|
|
// ---- Match URL & metadata ----------------------------------------------
|
|
|
|
function getTimestamp(i: SearchHit): number | null {
|
|
const d = i.date ?? i.isodate
|
|
if (d == null) return null
|
|
const ts = typeof d === 'string' ? new Date(d).getTime() : (d as number) * 1000
|
|
return Number.isFinite(ts) ? ts : null
|
|
}
|
|
|
|
const matchUrl = computed(() => {
|
|
const i = props.activity
|
|
if (!i?.type) return null
|
|
const ts = getTimestamp(i)
|
|
if (ts == null) return null
|
|
const d = dayjs(ts)
|
|
const month = (d.month() + 1).toString().padStart(2, '0')
|
|
const slug = i.slug && i.slug !== 'undefined' ? i.slug : i.id ?? i._id
|
|
return `https://www.carpa.com/${locale.value}/${i.type}/${d.year()}/${month}/${slug}`
|
|
})
|
|
|
|
const fileLinks = computed(() => formatFiles(props.activity.files || {}))
|
|
|
|
function safeDate(i: SearchHit) {
|
|
const ts = getTimestamp(i)
|
|
return ts == null ? '' : formatDate(ts / 1000)
|
|
}
|
|
|
|
// ---- Find-in-document --------------------------------------------------
|
|
|
|
const bodyContainer = ref<HTMLElement | null>(null)
|
|
const scrollContainer = ref<HTMLElement | null>(null)
|
|
|
|
const localQuery = ref(props.query || '')
|
|
const debouncedLocalQuery = useDebounce(localQuery, 200)
|
|
|
|
const currentIdx = ref(0)
|
|
const totalMatches = ref(0)
|
|
|
|
// Reset local query and counters when the user opens a different activity.
|
|
watch(
|
|
() => props.activity?._id,
|
|
() => {
|
|
localQuery.value = props.query || ''
|
|
currentIdx.value = 0
|
|
totalMatches.value = 0
|
|
}
|
|
)
|
|
|
|
// 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 || ''
|
|
})
|
|
|
|
function escapeRegex(s: string) {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
function normalize(s: string): string {
|
|
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
}
|
|
|
|
function normalizeWithMap(text: string): { normalized: string, map: number[] } {
|
|
let normalized = ''
|
|
const map: number[] = []
|
|
for (let i = 0; i < text.length; i++) {
|
|
const norm = text[i].normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
for (let j = 0; j < norm.length; j++) {
|
|
normalized += norm[j]
|
|
map.push(i)
|
|
}
|
|
}
|
|
return { normalized, map }
|
|
}
|
|
|
|
function findMatchesInText(text: string, terms: string[]): Array<{ start: number, end: number }> {
|
|
if (!text || !terms.length) return []
|
|
const sources = terms.map(t => termToRegexSource(normalize(t))).filter(s => s.length > 0)
|
|
if (!sources.length) return []
|
|
|
|
const { normalized, map } = normalizeWithMap(text)
|
|
const re = new RegExp(`(${sources.join('|')})`, 'g')
|
|
const out: Array<{ start: number, end: number }> = []
|
|
let m: RegExpExecArray | null
|
|
while ((m = re.exec(normalized)) !== null) {
|
|
if (m[0].length === 0) { re.lastIndex++; continue }
|
|
const start = map[m.index] ?? text.length
|
|
const endNormIdx = m.index + m[0].length
|
|
const end = endNormIdx < map.length ? map[endNormIdx] : text.length
|
|
out.push({ start, end })
|
|
}
|
|
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. */
|
|
function parseTerms(query: string): string[] {
|
|
const trimmed = (query || '').trim().replace(/^"+|"+$/g, '').trim()
|
|
return trimmed.length > 0 ? [trimmed] : []
|
|
}
|
|
|
|
function termToRegexSource(term: string): string {
|
|
const parts = term.split(/\s+/).filter(Boolean)
|
|
if (parts.length === 0) return ''
|
|
if (parts.length === 1) return escapeRegex(parts[0])
|
|
return parts.map(escapeRegex).join('\\s+')
|
|
}
|
|
|
|
/** Walk text nodes, build the FLAT text content of `root`, find phrase
|
|
* positions in that flat text, then mark whichever portion of each match
|
|
* falls inside each text node. Cross-node phrases (split by HTML tags) get
|
|
* highlighted as several adjacent <mark>s.
|
|
* Returns the number of distinct matches (not <mark> elements) so the
|
|
* navigation counter ("3 / N") reflects unique phrase occurrences. */
|
|
function highlightTextNodes(root: HTMLElement, terms: string[]): number {
|
|
if (!terms.length) return 0
|
|
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
|
|
const infos: { node: Text, start: number, end: number }[] = []
|
|
let flat = ''
|
|
let n: Node | null
|
|
while ((n = walker.nextNode())) {
|
|
const node = n as Text
|
|
const text = node.nodeValue || ''
|
|
infos.push({ node, start: flat.length, end: flat.length + text.length })
|
|
flat += text
|
|
}
|
|
if (!infos.length) return 0
|
|
|
|
const matches = findMatchesInText(flat, terms)
|
|
if (!matches.length) return 0
|
|
|
|
// Tag the first <mark> of each logical match so the navigator can step
|
|
// between distinct occurrences (not between fragments of one phrase).
|
|
const matchStartByPos = new Set(matches.map(m => m.start))
|
|
|
|
for (let i = infos.length - 1; i >= 0; i--) {
|
|
const info = infos[i]
|
|
const nodeText = info.node.nodeValue || ''
|
|
|
|
const segments: { start: number, end: number, matchStart: number }[] = []
|
|
for (const match of matches) {
|
|
const segStart = Math.max(match.start, info.start) - info.start
|
|
const segEnd = Math.min(match.end, info.end) - info.start
|
|
if (segStart < segEnd) segments.push({ start: segStart, end: segEnd, matchStart: match.start })
|
|
}
|
|
if (!segments.length) continue
|
|
segments.sort((a, b) => a.start - b.start)
|
|
|
|
const frag = document.createDocumentFragment()
|
|
let cursor = 0
|
|
for (const seg of segments) {
|
|
if (seg.start > cursor) frag.appendChild(document.createTextNode(nodeText.slice(cursor, seg.start)))
|
|
const mark = document.createElement('mark')
|
|
mark.className = 'search-match'
|
|
// First fragment of a logical match gets a marker class so we can find
|
|
// distinct occurrences for the up/down navigation buttons.
|
|
if (matchStartByPos.has(seg.matchStart) && info.start + seg.start === seg.matchStart) {
|
|
mark.classList.add('match-start')
|
|
}
|
|
mark.textContent = nodeText.slice(seg.start, seg.end)
|
|
frag.appendChild(mark)
|
|
cursor = seg.end
|
|
}
|
|
if (cursor < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(cursor)))
|
|
info.node.parentNode?.replaceChild(frag, info.node)
|
|
}
|
|
|
|
return matches.length
|
|
}
|
|
|
|
function getMarks(): HTMLElement[] {
|
|
const el = bodyContainer.value
|
|
if (!el) return []
|
|
return Array.from(el.querySelectorAll('mark.search-match')) as HTMLElement[]
|
|
}
|
|
|
|
/** Distinct match starts — for cross-node phrases, only the FIRST <mark>
|
|
* fragment of each phrase carries the .match-start class. */
|
|
function getMatchStarts(): HTMLElement[] {
|
|
const el = bodyContainer.value
|
|
if (!el) return []
|
|
return Array.from(el.querySelectorAll('mark.search-match.match-start')) as HTMLElement[]
|
|
}
|
|
|
|
function applyCurrent(scroll = true) {
|
|
const allMarks = getMarks()
|
|
allMarks.forEach(m => m.classList.remove('is-current'))
|
|
const starts = getMatchStarts()
|
|
if (!starts.length) return
|
|
const idx = Math.min(Math.max(currentIdx.value, 0), starts.length - 1)
|
|
const target = starts[idx]
|
|
if (!target) return
|
|
target.classList.remove('is-current')
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
void target.offsetWidth
|
|
target.classList.add('is-current')
|
|
if (scroll) target.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
}
|
|
|
|
function nextMatch() {
|
|
if (!totalMatches.value) return
|
|
currentIdx.value = (currentIdx.value + 1) % totalMatches.value
|
|
applyCurrent()
|
|
}
|
|
|
|
function prevMatch() {
|
|
if (!totalMatches.value) return
|
|
currentIdx.value = (currentIdx.value - 1 + totalMatches.value) % totalMatches.value
|
|
applyCurrent()
|
|
}
|
|
|
|
function clearLocalQuery() {
|
|
localQuery.value = ''
|
|
}
|
|
|
|
async function renderBody() {
|
|
await nextTick()
|
|
const el = bodyContainer.value
|
|
if (!el) return
|
|
|
|
const raw = props.activity?.body
|
|
el.innerHTML = raw ? ((fixLink(raw) as string) || '') : ''
|
|
|
|
if (scrollContainer.value) scrollContainer.value.scrollTop = 0
|
|
|
|
applyHighlights(false)
|
|
}
|
|
|
|
function applyHighlights(preserveScrollIfSameQuery = false) {
|
|
const el = bodyContainer.value
|
|
if (!el) return
|
|
|
|
// Wipe any previous <mark> wrappers by replacing them with text nodes.
|
|
const previous = el.querySelectorAll('mark.search-match')
|
|
previous.forEach((m) => {
|
|
const text = document.createTextNode(m.textContent || '')
|
|
m.parentNode?.replaceChild(text, m)
|
|
})
|
|
// Merge adjacent text nodes so the next regex pass works cleanly.
|
|
el.normalize()
|
|
|
|
const terms = parseTerms(debouncedLocalQuery.value || '')
|
|
|
|
const count = terms.length ? highlightTextNodes(el, terms) : 0
|
|
totalMatches.value = count
|
|
currentIdx.value = 0
|
|
|
|
if (count > 0) {
|
|
nextTick(() => applyCurrent(!preserveScrollIfSameQuery))
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
|
|
onMounted(renderBody)
|
|
|
|
// Keyboard helpers when the input is focused.
|
|
function onInputKey(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
if (e.shiftKey) prevMatch()
|
|
else nextMatch()
|
|
} else if (e.key === 'Escape') {
|
|
clearLocalQuery()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel id="activity-detail">
|
|
<UDashboardNavbar :title="activity.title" :toggle="false">
|
|
<template #leading>
|
|
<UButton
|
|
icon="i-lucide-x"
|
|
color="neutral"
|
|
variant="ghost"
|
|
class="-ms-1.5"
|
|
@click="emits('close')"
|
|
/>
|
|
</template>
|
|
|
|
<template #right>
|
|
<UButton
|
|
v-if="matchUrl"
|
|
:to="matchUrl"
|
|
target="_blank"
|
|
icon="i-lucide-external-link"
|
|
label="Ver en sitio"
|
|
color="primary"
|
|
variant="solid"
|
|
size="sm"
|
|
/>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<div class="flex flex-col sm:flex-row justify-between gap-2 p-4 sm:px-6 border-b border-default">
|
|
<div class="min-w-0">
|
|
<p class="font-semibold text-sm text-highlighted">
|
|
{{ safeDate(activity) }}
|
|
</p>
|
|
<p class="text-muted text-sm truncate">
|
|
{{ formatLocation(activity) }}
|
|
</p>
|
|
</div>
|
|
|
|
<ul v-if="fileLinks.length" class="flex flex-wrap gap-3 text-xs text-muted">
|
|
<li
|
|
v-for="(f, idx) in fileLinks"
|
|
:key="idx"
|
|
class="flex items-center"
|
|
>
|
|
<ULink
|
|
:to="f.to"
|
|
:target="f.target"
|
|
class="flex items-center gap-1 hover:text-primary"
|
|
>
|
|
<UIcon :name="f.icon!" class="size-4" />
|
|
<span>{{ f.label }}</span>
|
|
</ULink>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative">
|
|
<!-- Find-in-document toolbar: sticky at the top of the scroll area so
|
|
the user can always reach it while reading. -->
|
|
<div
|
|
v-if="activity?.body"
|
|
class="sticky top-0 z-10 bg-default/95 backdrop-blur-sm border-b border-default px-4 sm:px-6 py-2 flex items-center gap-2"
|
|
>
|
|
<div class="relative flex-1 min-w-0 max-w-md">
|
|
<UInput
|
|
v-model="localQuery"
|
|
icon="i-lucide-search"
|
|
placeholder="Buscar frase exacta en este documento..."
|
|
size="sm"
|
|
class="w-full"
|
|
:ui="{ trailing: 'pe-1' }"
|
|
@keydown="onInputKey"
|
|
>
|
|
<template #trailing>
|
|
<UButton
|
|
v-if="localQuery"
|
|
icon="i-lucide-x"
|
|
color="neutral"
|
|
variant="link"
|
|
size="xs"
|
|
aria-label="Limpiar"
|
|
@click="clearLocalQuery"
|
|
/>
|
|
</template>
|
|
</UInput>
|
|
</div>
|
|
|
|
<span
|
|
class="text-xs tabular-nums whitespace-nowrap min-w-[3.5rem] text-center"
|
|
:class="totalMatches ? 'text-toned font-medium' : 'text-dimmed'"
|
|
>
|
|
<template v-if="localQuery">
|
|
{{ totalMatches ? `${currentIdx + 1} / ${totalMatches}` : '0 / 0' }}
|
|
</template>
|
|
</span>
|
|
|
|
<UTooltip text="Anterior (Shift+Enter)">
|
|
<UButton
|
|
icon="i-lucide-chevron-up"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
:disabled="!totalMatches"
|
|
aria-label="Coincidencia anterior"
|
|
@click="prevMatch"
|
|
/>
|
|
</UTooltip>
|
|
<UTooltip text="Siguiente (Enter)">
|
|
<UButton
|
|
icon="i-lucide-chevron-down"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
:disabled="!totalMatches"
|
|
aria-label="Coincidencia siguiente"
|
|
@click="nextMatch"
|
|
/>
|
|
</UTooltip>
|
|
</div>
|
|
|
|
<article
|
|
v-if="activity?.body"
|
|
ref="bodyContainer"
|
|
class="prose prose-sm max-w-none dark:prose-invert p-4 sm:p-6"
|
|
/>
|
|
<p v-else class="p-4 sm:p-6 text-sm text-muted">
|
|
No hay contenido disponible para esta coincidencia.
|
|
<span v-if="matchUrl">
|
|
Puedes
|
|
<ULink :to="matchUrl" target="_blank" class="text-primary">verla en el sitio</ULink>.
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</UDashboardPanel>
|
|
</template>
|