search/app/pages/favoritos.vue

639 lines
20 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { breakpointsTailwind } from '@vueuse/core'
import type { DropdownMenuItem } from '@nuxt/ui'
import type { SearchHit } from '~/types'
import { useFavoritesStore, type FavoriteItem } from '~/stores/favorites'
import InboxActivity from '~/components/inbox/InboxActivity.vue'
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
const favorites = useFavoritesStore()
// Refs reactivos para usar en watchers / template (Pinia setup store).
const { items: favItems, total: favTotal, collections: favCollections } = storeToRefs(favorites)
const toast = useToast()
/** Identificador de la colección de entrelíneas en el store de favoritos.
* Debe coincidir con `FAVORITES_COLLECTION` en `pages/entrelineas.vue`. */
const ENTRELINEAS_COLLECTION = 'entrelineas'
// Nota: la URL de ImageKit y los presets de transformación viven en
// `~/utils/entrelineaImage.ts` (auto-importado). EntrelineaDetail los usa
// internamente, así que aquí no hay que pasar nada relacionado a ImageKit.
const COLLECTION_LABELS: Record<string, string> = {
activities: 'Actividades',
conferences: 'Conferencias',
entrelineas: 'Entre Líneas'
}
function labelFor(c: string): string {
return COLLECTION_LABELS[c] || c.charAt(0).toUpperCase() + c.slice(1)
}
// Filtros: pestaña por colección o "todos".
const activeCollection = ref<string>('all')
// Reset del filtro si la colección activa se queda sin elementos.
watch(favCollections, (cols) => {
if (activeCollection.value !== 'all' && !cols.includes(activeCollection.value)) {
activeCollection.value = 'all'
}
})
const tabs = computed(() => {
const items = [{ value: 'all', label: `Todos (${favTotal.value})`, icon: 'i-lucide-bookmark' }]
for (const c of favCollections.value) {
const count = favItems.value.filter(it => it.collection === c).length
items.push({
value: c,
label: `${labelFor(c)} (${count})`,
icon: c === 'activities'
? 'i-lucide-calendar-days'
: c === 'conferences'
? 'i-lucide-mic'
: c === 'entrelineas'
? 'i-lucide-book-open'
: 'i-lucide-folder'
})
}
return items
})
const localQuery = ref('')
const filteredItems = computed<FavoriteItem[]>(() => {
let list = favItems.value
if (activeCollection.value !== 'all') {
list = list.filter(it => it.collection === activeCollection.value)
}
const q = localQuery.value.trim().toLowerCase()
if (q) {
list = list.filter((it) => {
const t = (it.hit?.title || '').toLowerCase()
return t.includes(q)
})
}
return list
})
// ---- Selección y panel de detalle --------------------------------------
const selected = ref<FavoriteItem | null>(null)
const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null)
const selectedCollection = computed<string | undefined>(() => selected.value?.collection)
/** Las entrelíneas tienen un detalle distinto al de actividades/conferencias
* (imagen de ImageKit + texto), así que cambiamos el componente según la
* colección del item seleccionado. */
const isEntrelinea = computed(() => selectedCollection.value === ENTRELINEAS_COLLECTION)
/** El SearchHit guardado de una entrelínea conserva todos los campos del
* documento original (`id`, `image`, `link`, `locale`, `page`, `text`)
* porque al guardar hicimos `{ ...doc, _id, title, body }`. Lo casteamos a
* un objeto plano para pasárselo a `EntrelineaDetail`. */
const selectedEntrelineaDoc = computed<Record<string, unknown> | null>(() => {
if (!isEntrelinea.value || !selectedHit.value) return null
return selectedHit.value as unknown as Record<string, unknown>
})
const isPanelOpen = computed({
get() { return !!selected.value },
set(v: boolean) { if (!v) selected.value = null }
})
// Si el item seleccionado se quita de favoritos desde otra pestaña o desde el
// botón del propio detalle, cerrar el panel.
watch(favItems, (items) => {
if (!selected.value) return
const stillThere = items.find(it =>
it.collection === selected.value!.collection
&& String(it._id) === String(selected.value!._id))
if (!stillThere) selected.value = null
})
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
// ---- Helpers de fila ---------------------------------------------------
function safeDate(hit: SearchHit) {
const d = hit.date ?? hit.isodate
if (!d) return ''
const ts = typeof d === 'string' ? new Date(d).getTime() : (d as number) * 1000
if (!Number.isFinite(ts)) return ''
return formatDate(ts / 1000)
}
function hasDate(hit: SearchHit) {
return hit.date != null || hit.isodate != null
}
function removeItem(it: FavoriteItem, ev: Event) {
ev.stopPropagation()
favorites.remove(it.collection, it._id)
toast.add({
title: 'Eliminado de tu lista',
description: it.hit?.title,
icon: 'i-lucide-bookmark-x',
color: 'neutral',
duration: 1500
})
}
// ---- Exportar / Compartir ----------------------------------------------
async function copyList() {
if (!favTotal.value) return
const ok = await favorites.copyJsonToClipboard()
toast.add({
title: ok ? 'Lista copiada al portapapeles' : 'No se pudo copiar la lista',
description: ok ? 'Pégala en otro lugar para compartirla.' : 'Tu navegador bloqueó el acceso al portapapeles.',
icon: ok ? 'i-lucide-clipboard-check' : 'i-lucide-triangle-alert',
color: ok ? 'primary' : 'error',
duration: 2400
})
}
function downloadList() {
if (!favTotal.value) return
favorites.downloadJson()
toast.add({
title: 'Descargando tu lista',
icon: 'i-lucide-download',
color: 'primary',
duration: 1500
})
}
const showClearConfirm = ref(false)
function confirmClear() {
favorites.clear()
showClearConfirm.value = false
toast.add({
title: 'Lista vacía',
icon: 'i-lucide-trash-2',
color: 'neutral',
duration: 1500
})
}
// ---- Importar ----------------------------------------------------------
const showImport = ref(false)
const importMode = ref<'merge' | 'replace'>('merge')
const importText = ref('')
const importFileInput = ref<HTMLInputElement | null>(null)
const importing = ref(false)
function openImport() {
importText.value = ''
importMode.value = 'merge'
showImport.value = true
}
function pickImportFile() {
importFileInput.value?.click()
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
importing.value = true
try {
const result = await favorites.importFromFile(file, importMode.value)
toast.add({
title: `Importados ${result.added}`,
description: result.skipped
? `${result.skipped} ya estaban en tu lista${result.invalid ? `, ${result.invalid} inválidos` : ''}.`
: result.invalid
? `${result.invalid} inválidos.`
: 'Tu lista se actualizó.',
icon: 'i-lucide-bookmark-plus',
color: 'primary',
duration: 2800
})
showImport.value = false
} catch (e) {
toast.add({
title: 'No se pudo importar',
description: (e as Error)?.message || 'Archivo inválido.',
icon: 'i-lucide-triangle-alert',
color: 'error',
duration: 3200
})
} finally {
importing.value = false
input.value = ''
}
}
function applyTextImport() {
if (!importText.value.trim()) return
importing.value = true
try {
const result = favorites.importFromJson(importText.value, importMode.value)
toast.add({
title: `Importados ${result.added}`,
description: result.skipped
? `${result.skipped} ya estaban en tu lista${result.invalid ? `, ${result.invalid} inválidos` : ''}.`
: result.invalid
? `${result.invalid} inválidos.`
: 'Tu lista se actualizó.',
icon: 'i-lucide-bookmark-plus',
color: 'primary',
duration: 2800
})
showImport.value = false
} catch (e) {
toast.add({
title: 'No se pudo importar',
description: (e as Error)?.message || 'JSON inválido.',
icon: 'i-lucide-triangle-alert',
color: 'error',
duration: 3200
})
} finally {
importing.value = false
}
}
// Menú compacto para móvil — los 4 iconos sueltos del navbar son ambiguos sin
// tooltip (el tooltip no aparece en touch), así que en pantallas chicas los
// agrupamos en un dropdown con etiquetas explícitas.
const mobileActions = computed<DropdownMenuItem[][]>(() => [[
{
label: 'Importar lista',
icon: 'i-lucide-upload',
onSelect: () => openImport()
},
{
label: 'Copiar al portapapeles',
icon: 'i-lucide-clipboard-copy',
disabled: !favTotal.value,
onSelect: () => copyList()
},
{
label: 'Descargar (.json)',
icon: 'i-lucide-download',
disabled: !favTotal.value,
onSelect: () => downloadList()
}
], [
{
label: 'Vaciar mi lista',
icon: 'i-lucide-trash-2',
color: 'error',
disabled: !favTotal.value,
onSelect: () => { showClearConfirm.value = true }
}
]])
</script>
<template>
<UDashboardPanel
id="favorites-list"
:default-size="32"
:min-size="24"
:max-size="44"
resizable
>
<UDashboardNavbar title="Mi lista">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #trailing>
<UBadge v-if="favTotal" :label="favTotal" variant="subtle" />
</template>
<template #right>
<!-- Desktop: iconos individuales (rápidos y con tooltip) -->
<div class="hidden sm:flex items-center gap-0.5">
<UTooltip text="Importar lista">
<UButton
icon="i-lucide-upload"
color="neutral"
variant="ghost"
size="sm"
aria-label="Importar lista"
@click="openImport"
/>
</UTooltip>
<UTooltip text="Copiar lista al portapapeles">
<UButton
icon="i-lucide-clipboard-copy"
color="neutral"
variant="ghost"
size="sm"
:disabled="!favTotal"
aria-label="Copiar lista"
@click="copyList"
/>
</UTooltip>
<UTooltip text="Descargar lista (.json)">
<UButton
icon="i-lucide-download"
color="neutral"
variant="ghost"
size="sm"
:disabled="!favTotal"
aria-label="Descargar lista"
@click="downloadList"
/>
</UTooltip>
<UTooltip text="Vaciar la lista">
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="sm"
:disabled="!favTotal"
aria-label="Vaciar lista"
@click="showClearConfirm = true"
/>
</UTooltip>
</div>
<!-- Móvil: un único botón con menú etiquetado -->
<UDropdownMenu :items="mobileActions" :content="{ align: 'end' }" class="sm:hidden">
<UButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
size="sm"
aria-label="Más acciones"
/>
</UDropdownMenu>
</template>
</UDashboardNavbar>
<div class="px-4 sm:px-6 py-3 border-b border-default flex flex-col gap-2.5">
<UInput
v-model="localQuery"
icon="i-lucide-search"
placeholder="Filtrar mi lista por título..."
size="md"
class="w-full"
/>
<!-- Filtros por colección. En móvil van con scroll horizontal para que
los botones siempre aparezcan completos en una sola línea. -->
<div class="-mx-4 sm:mx-0 px-4 sm:px-0 flex gap-1.5 overflow-x-auto pb-0.5 scrollbar-thin">
<UButton
v-for="t in tabs"
:key="t.value"
:icon="t.icon"
:label="t.label"
size="xs"
class="shrink-0"
:color="activeCollection === t.value ? 'primary' : 'neutral'"
:variant="activeCollection === t.value ? 'soft' : 'ghost'"
@click="activeCollection = t.value"
/>
</div>
</div>
<div class="overflow-y-auto divide-y divide-default flex-1">
<div
v-if="!filteredItems.length"
class="flex flex-col items-center justify-center gap-3 py-12 sm:py-16 text-dimmed text-sm px-6 text-center"
>
<UIcon name="i-lucide-bookmark" class="size-10" />
<template v-if="!favTotal">
<p class="font-medium text-toned">
Tu lista está vacía
</p>
<p class="max-w-xs">
Toca el icono
<UIcon name="i-lucide-bookmark-plus" class="size-4 inline-block align-text-bottom mx-0.5" />
que aparece junto a cada resultado para guardarlo aquí.
</p>
<UButton
icon="i-lucide-upload"
label="Importar una lista compartida"
variant="soft"
size="sm"
class="mt-1"
@click="openImport"
/>
</template>
<p v-else>
No hay coincidencias en tu lista para este filtro.
</p>
</div>
<div
v-for="(it, index) in filteredItems"
:key="`${it.collection}-${it._id}-${index}`"
>
<div
class="p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors text-toned"
:class="selected && selected.collection === it.collection && String(selected._id) === String(it._id)
? 'border-primary bg-primary/10'
: 'border-transparent hover:border-primary hover:bg-primary/5'"
@click="selected = it"
>
<div class="flex items-start justify-between gap-2 mb-1">
<div class="min-w-0 flex-1">
<UBadge
:label="labelFor(it.collection)"
size="sm"
variant="subtle"
class="mb-1 uppercase font-semibold"
:color="it.collection=='activities'?'success':'info'"
/>
<div class="text-sm font-semibold line-clamp-2">
{{ it.hit?.title || 'Sin título' }}
</div>
</div>
<UButton
icon="i-lucide-bookmark-x"
color="neutral"
variant="ghost"
size="sm"
class="-mt-1 -me-1 shrink-0"
aria-label="Quitar de mi lista"
@click="(ev: MouseEvent) => removeItem(it, ev)"
/>
</div>
<p class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted">
<span v-if="hasDate(it.hit)">{{ safeDate(it.hit) }}</span>
<USeparator v-if="hasDate(it.hit) && formatLocation(it.hit)" orientation="vertical" class="h-3 hidden sm:block" />
<span class="truncate">{{ formatLocation(it.hit) }}</span>
</p>
</div>
</div>
</div>
</UDashboardPanel>
<!-- Entrelíneas → componente propio (imagen ImageKit + texto). -->
<EntrelineaDetail
v-if="selected && !isMobile && isEntrelinea"
:document="selectedEntrelineaDoc!"
:collection="selectedCollection"
@close="selected = null"
/>
<!-- Resto (actividades, conferencias) → InboxActivity. -->
<InboxActivity
v-else-if="selected && !isMobile"
:activity="selectedHit!"
:collection="selectedCollection"
@close="selected = null"
/>
<div v-else-if="!selected" 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-bookmark" class="size-16" />
<p class="text-sm">
Selecciona un elemento para ver el detalle
</p>
</div>
</div>
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
<template #content>
<EntrelineaDetail
v-if="selected && isEntrelinea"
:document="selectedEntrelineaDoc!"
:collection="selectedCollection"
@close="selected = null"
/>
<InboxActivity
v-else-if="selected"
:activity="selectedHit!"
:collection="selectedCollection"
@close="selected = null"
/>
</template>
</USlideover>
</ClientOnly>
<!-- Confirmación de vaciado ------------------------------------------- -->
<UModal v-model:open="showClearConfirm" title="Vaciar mi lista">
<template #body>
<p class="text-sm text-toned">
Esto eliminará los <strong>{{ favTotal }}</strong> elementos guardados en este dispositivo.
La acción no se puede deshacer (a menos que tengas un export previo).
</p>
</template>
<template #footer>
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 w-full">
<UButton
color="neutral"
variant="ghost"
label="Cancelar"
block
class="sm:w-auto"
@click="showClearConfirm = false"
/>
<UButton
color="error"
label="Vaciar lista"
icon="i-lucide-trash-2"
block
class="sm:w-auto"
@click="confirmClear"
/>
</div>
</template>
</UModal>
<!-- Importar ---------------------------------------------------------- -->
<UModal v-model:open="showImport" title="Importar lista" :ui="{ footer: 'justify-end' }">
<template #body>
<div class="flex flex-col gap-4">
<p class="text-sm text-muted">
Carga una lista compartida desde un archivo
<code class="px-1 rounded bg-elevated text-toned font-mono text-xs">.json</code>
o pega su contenido. Funciona para cualquier tipo de búsqueda
(actividades, conferencias y futuras secciones).
</p>
<UButton
color="primary"
variant="soft"
icon="i-lucide-file-up"
label="Subir archivo .json"
size="md"
block
@click="pickImportFile"
/>
<USeparator label="o pega el contenido" />
<div>
<p class="text-xs font-medium text-toned mb-1.5">
¿Qué hacer si ya tienes favoritos guardados?
</p>
<div class="grid grid-cols-2 gap-1.5">
<UButton
label="Combinar"
icon="i-lucide-merge"
size="sm"
block
:color="importMode === 'merge' ? 'primary' : 'neutral'"
:variant="importMode === 'merge' ? 'soft' : 'outline'"
@click="importMode = 'merge'"
/>
<UButton
label="Reemplazar"
icon="i-lucide-replace"
size="sm"
block
:color="importMode === 'replace' ? 'primary' : 'neutral'"
:variant="importMode === 'replace' ? 'soft' : 'outline'"
@click="importMode = 'replace'"
/>
</div>
<p class="text-xs text-dimmed mt-1.5">
{{ importMode === 'merge'
? 'Conserva los favoritos actuales y añade los nuevos.'
: 'Borra tu lista actual y la sustituye por la importada.' }}
</p>
</div>
<UTextarea
v-model="importText"
:rows="6"
placeholder='{"version":1,"items":[ ... ]}'
class="w-full font-mono text-xs"
/>
<input
ref="importFileInput"
type="file"
accept="application/json,.json"
class="hidden"
@change="onImportFile"
>
</div>
</template>
<template #footer>
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 w-full">
<UButton
color="neutral"
variant="ghost"
label="Cancelar"
block
class="sm:w-auto"
@click="showImport = false"
/>
<UButton
color="primary"
icon="i-lucide-bookmark-plus"
label="Importar desde texto"
:loading="importing"
:disabled="!importText.trim() || importing"
block
class="sm:w-auto"
@click="applyTextImport"
/>
</div>
</template>
</UModal>
</template>