Compare commits

...

3 Commits

Author SHA1 Message Date
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
10 changed files with 213 additions and 122 deletions

View File

@ -592,6 +592,21 @@ function clearLocalQuery() {
const localQuery = ref('')
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) => {
// Cualquier cambio en el input activa el Estado 2
@ -791,17 +806,7 @@ function onSelectionChange() {
if (!sel || sel.toString().trim().length === 0) clearSelectionTooltip()
}
const links = computed(() => {
return [
{
label: 'Ver en sitio',
icon: 'ph-arrow-square-out',
to: matchUrl,
target: '_blank'
},
...formatFiles(props.document?.files)
]
})
const links = computed(() => [])
const items = computed(() => {
return [
@ -830,6 +835,15 @@ const items = computed(() => {
</template>
<template #right>
<UTooltip :text="showInternalSearch ? 'Cerrar buscador' : 'Buscar en documento'">
<UButton
icon="i-lucide-search"
:color="showInternalSearch || !!localQuery ? 'primary' : 'neutral'"
:variant="showInternalSearch || !!localQuery ? 'soft' : 'ghost'"
:disabled="!paragraphs.length"
@click="toggleInternalSearch"
/>
</UTooltip>
<UTooltip text="Como funciona?">
<UButton
icon="ph-student"
@ -853,8 +867,17 @@ const items = computed(() => {
</template>
</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 -->
<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-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">
@ -887,24 +910,12 @@ const items = computed(() => {
/>
</div>
<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>
<!-- file links hidden -->
<!-- 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
ref="internalSearchRef"
v-model="localQuery"
icon="i-lucide-search"
:placeholder="props.query || 'Buscar en este documento...'"
@ -1021,17 +1032,6 @@ const items = computed(() => {
</div>
</UPageBody>
<template #right>
<UPageAside>
<UPageAnchors :links="links" v-if="paragraphs.length" />
<UAccordion :items="items">
<template #body="{ item }">
<pre>{{ item.content }}</pre>
</template>
</UAccordion>
</UPageAside>
</template>
</UPage>
</div>

View File

@ -22,6 +22,7 @@ interface EntrelineaDoc {
page?: number | string
text?: string
html?: string
draft?: boolean
studies?: Study[]
[key: string]: unknown
}
@ -39,6 +40,8 @@ const showStudies = ref(false)
// Volver al detalle cuando cambia el documento
watch(() => props.document?.id, () => { showStudies.value = false })
const { unlocked } = useDevMode()
const imageUrl = computed<string | null>(() =>
getEntrelineaImageUrl(props.document?.image, 'detail')
)
@ -268,8 +271,6 @@ function onLightboxTouchStart(e: TouchEvent) {
}
function onLightboxTouchMove(e: TouchEvent) {
// Prevent page scroll while interacting inside the lightbox
e.preventDefault()
if (e.touches.length === 1 && lightboxScale.value > 1) {
const dx = e.touches[0].clientX - lbLastTouchX
const dy = e.touches[0].clientY - lbLastTouchY
@ -391,13 +392,13 @@ const lightboxImgStyle = computed(() => ({
<div class="p-4 sm:p-6 pb-10 flex flex-col gap-6">
<!-- Imagen: mobile toca para abrir lightbox, desktop zoom al hover -->
<template v-if="imageUrl">
<template v-if="imageUrl && (!document.draft || unlocked)">
<!-- Mobile: imagen con indicador de zoom táctil -->
<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"
>
<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">
<UIcon name="i-lucide-zoom-in" class="size-4 text-white" />
</div>
@ -406,7 +407,7 @@ const lightboxImgStyle = computed(() => ({
<!-- Desktop: hover zoom con lente -->
<div
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"
@mouseleave="onZoomLeave"
@mousemove="onZoomMove"
@ -430,13 +431,13 @@ const lightboxImgStyle = computed(() => ({
</div>
</template>
<!-- Sin imagen -->
<!-- Sin imagen / Borrador -->
<div
v-else
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" />
<p>Sin imagen disponible</p>
<p>Imagen no disponible</p>
</div>
<!-- Texto -->
@ -519,31 +520,47 @@ const lightboxImgStyle = computed(() => ({
/>
</Teleport>
<!-- Lightbox mobile con pinch-to-zoom -->
<Teleport to="body">
<div
v-if="lightboxOpen"
class="lg:hidden fixed inset-0 bg-black z-[9999] flex items-center justify-center"
@touchstart.passive="onLightboxTouchStart"
@touchmove.prevent="onLightboxTouchMove"
@touchend="onLightboxTouchEnd"
@click.self="closeLightbox"
<!-- Popup móvil: imagen con pinch-to-zoom -->
<UModal
v-model:open="lightboxOpen"
:ui="{
overlay: 'bg-black/90',
content: 'bg-black text-white max-w-[95vw] sm:max-w-lg',
header: 'py-2 px-3 border-b-0',
body: 'p-0',
}"
>
<button
class="absolute top-4 right-4 z-10 text-white bg-black/60 rounded-full p-2"
<template #header>
<div class="w-full flex justify-end">
<UButton
icon="i-lucide-x"
size="sm"
color="neutral"
variant="ghost"
class="text-white hover:bg-white/20"
@click="closeLightbox"
/>
</div>
</template>
<template #body>
<div
class="touch-none flex items-center justify-center overflow-hidden"
style="height: 70dvh"
@touchstart.passive="onLightboxTouchStart"
@touchmove.passive="onLightboxTouchMove"
@touchend.passive="onLightboxTouchEnd"
>
<UIcon name="i-lucide-x" class="size-6" />
</button>
<img
:src="imageUrl!"
v-if="imageUrl"
:src="imageUrl"
:alt="title"
class="max-w-full max-h-full object-contain select-none"
class="w-full h-full object-contain select-none"
:style="lightboxImgStyle"
draggable="false"
/>
</div>
</Teleport>
</template>
</UModal>
</UDashboardPanel>
</template>

View File

@ -434,21 +434,7 @@ function onInputKey(e: KeyboardEvent) {
/>
</div>
<!-- Fila 2: archivos adjuntos -->
<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>
<!-- file links hidden -->
<!-- Fila 3: búsqueda local en el documento -->
<div

View File

@ -139,6 +139,8 @@ const colors = computed(() => {
// ---- State ----------------------------------------------------------------
const exactSearch = ref(false)
const groupedHits = ref<SearchGroup[]>([])
const total = ref(0)
const currentPage = ref(1)
@ -257,7 +259,7 @@ async function runSearch(q: string, page = 1, append = false) {
multiSearchSearchesParameter: {
searches: [{
collection: props.paragraphsCollection,
q: q || '*',
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY,
filterBy: filterBy.value,
perPage: settings.pageSize,
@ -422,6 +424,10 @@ watch(debouncedQuery, (q) => {
}
})
watch(exactSearch, () => {
if (query.value.trim()) runSearch(query.value, 1, false)
})
// ---- Selección y carga del detalle ----------------------------------------
const selectedDocId = ref<string | null>(null)
@ -576,15 +582,30 @@ function metaLocation(meta: DocMeta | undefined): string {
</template>
</UDashboardNavbar>
<div class="px-4 sm:px-6 py-3 border-b border-default" id="inputField">
<div class="px-4 sm:px-6 py-3 border-b border-default flex items-center gap-2" id="inputField">
<UInput
v-model="query"
icon="i-lucide-search"
:placeholder="t('search.placeholder')"
:loading="loading"
size="md"
class="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>
<UAlert

View File

@ -35,6 +35,7 @@ const debouncedQuery = useDebounce(query, 150)
const loading = ref(false)
const loadingMore = ref(false)
const errorMsg = ref<string | null>(null)
const exactSearch = ref(false)
interface Study {
id?: number
@ -53,6 +54,7 @@ interface EntrelineaDoc {
page?: number | string
text?: string
html?: string
draft?: boolean
studies?: Study[]
[key: string]: unknown
}
@ -120,7 +122,7 @@ async function runSearch(q: string, page = 1, append = false) {
multiSearchSearchesParameter: {
searches: [{
collection: COLLECTION,
q: q || '*',
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY,
includeFields: INCLUDE_FIELDS,
filterBy: filterBy.value,
@ -188,6 +190,10 @@ watch(debouncedQuery, (q) => {
runSearch(q, 1, false)
})
watch(exactSearch, () => {
if (query.value.trim()) runSearch(query.value, 1, false)
})
const selected = ref<EntrelineaDoc | null>(null)
const isPanelOpen = computed({
@ -311,14 +317,31 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
<!-- Funcional: se muestra cuando la clave de desarrollador es correcta -->
<template v-else>
<div class="px-4 sm:px-6 py-3 border-b border-default">
<div class="flex items-center gap-2">
<UInput
v-model="query"
icon="i-lucide-search"
:placeholder="t('Search.placeholder')"
:loading="loading"
size="md"
class="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">
<UIcon name="i-lucide-database" class="size-3" />
<span>
@ -387,6 +410,14 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
color="neutral"
class="mb-1 uppercase"
/>
<UBadge
v-if="hit.document?.draft"
label="Borrador"
size="sm"
variant="subtle"
color="warning"
class="mb-1"
/>
</div>
<UTooltip :text="isFav(hit.document) ? 'Quitar de mi lista' : 'Guardar en mi lista'">
<UButton

View File

@ -33,7 +33,6 @@ const links = ref<ButtonProps[]>([
</script>
<template>
<UDashboardPanel id="changelog-panel" grow>
<UDashboardNavbar :title="t('nav.home')">
<template #leading>
@ -41,33 +40,62 @@ const links = ref<ButtonProps[]>([
</template>
</UDashboardNavbar>
<UPage>
<UPageHero title="Buscador Carpa"
:description="$t('home.instructions')"
:headline="`${release?.date}`" orientation="horizontal"
:links="links"
>
<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">
<div id="index_changelog">
<UPageCard variant="soft" icon="ph-git-branch" :title="release?.title" :description="release?.description">
<!-- 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>
<!-- 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>
<span class="text-default leading-snug">{{ change.text }}</span>
</li>
</ul>
</UPageCard>
</UCard>
</div>
</UPageHero>
</UPage>
</div>
</div>
</div>
</UDashboardPanel>
</template>

View File

@ -13,6 +13,8 @@
"changelog": "What's New"
},
"search": {
"word": "Word",
"phrase": "Phrase",
"placeholder": "Search for...",
"searching": "Searching...",
"tip": "Tip: wrap in \"quotes\" for exact phrase in that order.",

View File

@ -49,6 +49,8 @@
"hits_per_page": "aciertos por página",
"hits_retrieved_in": "aciertos logrados en",
"for": "Buscando",
"word": "Palabra",
"phrase": "Frase",
"words": "palabras",
"phrases": "frases",
"words_tooltip": "Buscar por palabras",

View File

@ -13,6 +13,8 @@
"changelog": "Nouveautés"
},
"search": {
"word": "Mot",
"phrase": "Phrase",
"placeholder": "Rechercher des activités",
"publication": "Publicación",
"draft": ""

View File

@ -13,6 +13,8 @@
"changelog": "Novidades"
},
"search": {
"word": "Palavra",
"phrase": "Frase",
"placeholder": "Digite para pesquisar...",
"publication": "Publicação",
"draft": "Borrador",