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
This commit is contained in:
David Ascanio 2026-06-03 19:40:35 -03:00
parent 0ea3bd0859
commit 87f4f04d2c
6 changed files with 205 additions and 122 deletions

View File

@ -592,6 +592,21 @@ function clearLocalQuery() {
const localQuery = ref('') const localQuery = ref('')
const debouncedLocalQuery = useDebounce(localQuery, 200) const debouncedLocalQuery = useDebounce(localQuery, 200)
const showInternalSearch = ref(false)
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 })
watch(debouncedLocalQuery, (q) => { watch(debouncedLocalQuery, (q) => {
// Cualquier cambio en el input activa el Estado 2 // Cualquier cambio en el input activa el Estado 2
@ -791,17 +806,7 @@ function onSelectionChange() {
if (!sel || sel.toString().trim().length === 0) clearSelectionTooltip() if (!sel || sel.toString().trim().length === 0) clearSelectionTooltip()
} }
const links = computed(() => { const links = computed(() => [])
return [
{
label: 'Ver en sitio',
icon: 'ph-arrow-square-out',
to: matchUrl,
target: '_blank'
},
...formatFiles(props.document?.files)
]
})
const items = computed(() => { const items = computed(() => {
return [ return [
@ -830,6 +835,15 @@ const items = computed(() => {
</template> </template>
<template #right> <template #right>
<UTooltip :text="showInternalSearch ? 'Cerrar buscador' : 'Buscar en documento'">
<UButton
icon="i-lucide-search"
:color="showInternalSearch || !!localQuery ? 'primary' : 'neutral'"
:variant="showInternalSearch || !!localQuery ? 'soft' : 'ghost'"
:disabled="!paragraphs.length"
@click="toggleInternalSearch"
/>
</UTooltip>
<UTooltip text="Como funciona?"> <UTooltip text="Como funciona?">
<UButton <UButton
icon="ph-student" icon="ph-student"
@ -853,8 +867,17 @@ const items = computed(() => {
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
<!-- Strip siempre visible para expandir/colapsar metadatos -->
<button
class="w-full flex items-center justify-center gap-1.5 py-1 border-b border-default bg-elevated/60 hover:bg-elevated transition-colors text-xs text-muted"
@click="metaExpanded = !metaExpanded"
>
<UIcon :name="metaExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="size-3.5" />
<span>{{ metaExpanded ? 'Ocultar detalles' : 'Ver detalles' }}</span>
</button>
<!-- Metadata --> <!-- Metadata -->
<div class="flex flex-col gap-3 p-4 sm:px-6 border-b border-default shadow-sm z-10"> <div v-show="metaExpanded" class="flex flex-col gap-3 p-4 sm:px-6 border-b border-default shadow-sm z-10">
<div class="flex flex-wrap items-start justify-between gap-2"> <div class="flex flex-wrap items-start justify-between gap-2">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0"> <div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
<p v-if="document?.draft" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0"> <p v-if="document?.draft" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
@ -887,24 +910,12 @@ const items = computed(() => {
/> />
</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 <UInput
ref="internalSearchRef"
v-model="localQuery" v-model="localQuery"
icon="i-lucide-search" icon="i-lucide-search"
:placeholder="props.query || 'Buscar en este documento...'" :placeholder="props.query || 'Buscar en este documento...'"
@ -1021,17 +1032,6 @@ const items = computed(() => {
</div> </div>
</UPageBody> </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> </UPage>
</div> </div>

View File

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

View File

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

View File

@ -139,6 +139,8 @@ const colors = computed(() => {
// ---- State ---------------------------------------------------------------- // ---- State ----------------------------------------------------------------
const exactSearch = ref(false)
const groupedHits = ref<SearchGroup[]>([]) const groupedHits = ref<SearchGroup[]>([])
const total = ref(0) const total = ref(0)
const currentPage = ref(1) const currentPage = ref(1)
@ -257,7 +259,7 @@ async function runSearch(q: string, page = 1, append = false) {
multiSearchSearchesParameter: { multiSearchSearchesParameter: {
searches: [{ searches: [{
collection: props.paragraphsCollection, collection: props.paragraphsCollection,
q: q || '*', q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY, queryBy: QUERY_BY,
filterBy: filterBy.value, filterBy: filterBy.value,
perPage: settings.pageSize, 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 ---------------------------------------- // ---- Selección y carga del detalle ----------------------------------------
const selectedDocId = ref<string | null>(null) const selectedDocId = ref<string | null>(null)
@ -576,15 +582,30 @@ function metaLocation(meta: DocMeta | undefined): string {
</template> </template>
</UDashboardNavbar> </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 <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"
>Palabra</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"
>Frase</button>
</div>
</div> </div>
<UAlert <UAlert

View File

@ -35,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
@ -53,6 +54,7 @@ interface EntrelineaDoc {
page?: number | string page?: number | string
text?: string text?: string
html?: string html?: string
draft?: boolean
studies?: Study[] studies?: Study[]
[key: string]: unknown [key: string]: unknown
} }
@ -120,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,
@ -188,6 +190,10 @@ watch(debouncedQuery, (q) => {
runSearch(q, 1, false) runSearch(q, 1, false)
}) })
watch(exactSearch, () => {
if (query.value.trim()) runSearch(query.value, 1, false)
})
const selected = ref<EntrelineaDoc | null>(null) const selected = ref<EntrelineaDoc | null>(null)
const isPanelOpen = computed({ 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 --> <!-- Funcional: se muestra cuando la clave de desarrollador es correcta -->
<template v-else> <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">
<UInput <div class="flex items-center gap-2">
v-model="query" <UInput
icon="i-lucide-search" v-model="query"
:placeholder="t('Search.placeholder')" icon="i-lucide-search"
:loading="loading" :placeholder="t('Search.placeholder')"
size="md" :loading="loading"
class="w-full" size="md"
/> class="flex-1 min-w-0"
/>
<div
class="flex rounded-full p-0.5 shrink-0 transition-colors duration-200"
:class="exactSearch ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700'"
>
<button
class="px-2.5 py-0.5 rounded-full text-xs transition-all duration-200 whitespace-nowrap"
:class="!exactSearch ? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white font-semibold shadow-sm' : 'text-white/40 font-normal'"
@click.stop="exactSearch = false"
>Palabra</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"
>Frase</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>
@ -387,6 +410,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

View File

@ -33,7 +33,6 @@ const links = ref<ButtonProps[]>([
</script> </script>
<template> <template>
<UDashboardPanel id="changelog-panel" grow> <UDashboardPanel id="changelog-panel" grow>
<UDashboardNavbar :title="t('nav.home')"> <UDashboardNavbar :title="t('nav.home')">
<template #leading> <template #leading>
@ -41,33 +40,62 @@ const links = ref<ButtonProps[]>([
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
<UPage> <div class="flex-1 overflow-y-auto">
<UPageHero title="Buscador Carpa" <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
:description="$t('home.instructions')" <div class="flex flex-col lg:flex-row lg:items-start gap-8 lg:gap-12">
:headline="`${release?.date}`" orientation="horizontal"
:links="links"
>
<div id="index_changelog"> <!-- Hero: título, descripción y accesos -->
<UPageCard variant="soft" icon="ph-git-branch" :title="release?.title" :description="release?.description"> <div class="flex-1 flex flex-col gap-6">
<UBadge variant="subtle" color="neutral" size="sm" class="w-fit">
{{ release?.date }}
</UBadge>
<template #header> <div>
v{{ release?.version }} <h1 class="text-3xl sm:text-4xl font-bold text-highlighted tracking-tight">
</template> 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>
<!-- 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>
</UPageCard>
</div> </div>
</UPageHero> </div>
</div>
</UPage>
</UDashboardPanel> </UDashboardPanel>
</template> </template>