From fb2fa8bb0b10bbc71ab491bcaa55093065623ce6 Mon Sep 17 00:00:00 2001 From: David Ascanio Date: Tue, 12 May 2026 12:28:42 -0300 Subject: [PATCH] feat historial --- .../entrelineas/EntrelineaDetail.vue | 202 +++++ app/components/inbox/InboxActivity.vue | 17 + app/composables/useDashboard.ts | 3 +- app/composables/useHistory.ts | 12 + app/layouts/default.vue | 10 + app/pages/entrelineas.vue | 403 ++++++++-- app/pages/favoritos.vue | 52 +- app/pages/historial.vue | 689 ++++++++++++++++++ app/plugins/history.client.ts | 19 + app/stores/history.ts | 303 ++++++++ app/utils/entrelineaImage.ts | 55 ++ nuxt.config.ts | 6 +- 12 files changed, 1694 insertions(+), 77 deletions(-) create mode 100644 app/components/entrelineas/EntrelineaDetail.vue create mode 100644 app/composables/useHistory.ts create mode 100644 app/pages/historial.vue create mode 100644 app/plugins/history.client.ts create mode 100644 app/stores/history.ts create mode 100644 app/utils/entrelineaImage.ts diff --git a/app/components/entrelineas/EntrelineaDetail.vue b/app/components/entrelineas/EntrelineaDetail.vue new file mode 100644 index 0000000..f9bf7e8 --- /dev/null +++ b/app/components/entrelineas/EntrelineaDetail.vue @@ -0,0 +1,202 @@ + + + diff --git a/app/components/inbox/InboxActivity.vue b/app/components/inbox/InboxActivity.vue index 90eb03c..dc936fd 100755 --- a/app/components/inbox/InboxActivity.vue +++ b/app/components/inbox/InboxActivity.vue @@ -3,6 +3,7 @@ import dayjs from 'dayjs' import { useDebounce } from '@vueuse/core' import type { SearchHit } from '~/types' import { useFavoritesStore } from '~/stores/favorites' +import { useHistoryStore } from '~/stores/history' const props = defineProps<{ activity: SearchHit @@ -17,8 +18,24 @@ const emits = defineEmits(['close']) const { locale } = useI18n() const favorites = useFavoritesStore() +const history = useHistoryStore() const toast = useToast() +// Registrar la visita en el historial cada vez que el componente recibe una +// actividad distinta (o se monta con la primera). El store deduplica por +// (collection, _id) moviendo la entrada al inicio y actualizando la fecha, +// así que disparar esto de más es seguro. No registramos si no hay +// `collection` (uso fuera de un buscador concreto) ni si el hit aún no +// tiene `_id` resoluble. +watch( + () => [props.collection, props.activity?._id] as const, + ([collection, id]) => { + if (!collection || id == null || id === '') return + history.visit(collection, props.activity) + }, + { immediate: true } +) + const isFav = computed(() => { if (!props.collection || !props.activity?._id) return false return favorites.isFavorite(props.collection, props.activity._id) diff --git a/app/composables/useDashboard.ts b/app/composables/useDashboard.ts index 2d965b4..4f352a9 100755 --- a/app/composables/useDashboard.ts +++ b/app/composables/useDashboard.ts @@ -6,7 +6,8 @@ const _useDashboard = () => { defineShortcuts({ 'g-a': () => router.push('/actividades'), 'g-c': () => router.push('/conferencias'), - 'g-f': () => router.push('/favoritos') + 'g-f': () => router.push('/favoritos'), + 'g-h': () => router.push('/historial') }) return {} diff --git a/app/composables/useHistory.ts b/app/composables/useHistory.ts new file mode 100644 index 0000000..5cf5141 --- /dev/null +++ b/app/composables/useHistory.ts @@ -0,0 +1,12 @@ +/** + * Re-export del store de Pinia como composable, paralelo a `useFavorites`. + * + * La fuente única de verdad es `app/stores/history.ts`. + */ +export { useHistoryStore as useHistory } from '~/stores/history' +export type { + HistoryItem, + HistoryFile, + ImportResult, + Collection +} from '~/stores/history' diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 25d3ea3..af23854 100755 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -3,6 +3,7 @@ import { computed } from 'vue' import { storeToRefs } from 'pinia' import type { NavigationMenuItem } from '@nuxt/ui' import { useFavoritesStore } from '~/stores/favorites' +import { useHistoryStore } from '~/stores/history' const { locale, locales, setLocale } = useI18n() @@ -14,6 +15,9 @@ const open = ref(false) const favorites = useFavoritesStore() const { total: favTotal } = storeToRefs(favorites) +const history = useHistoryStore() +const { total: histTotal } = storeToRefs(history) + const links = [[{ label: t('nav.bible_studies'), icon: 'ph:books', @@ -51,6 +55,12 @@ const links = [[{ to: '/favoritos', badge: favTotal.value > 0 ? String(favTotal.value) : undefined, onSelect: () => { open.value = false } +}, { + label: 'Historial', + icon: 'i-lucide-history', + to: '/historial', + badge: histTotal.value > 0 ? String(histTotal.value) : undefined, + onSelect: () => { open.value = false } }]] satisfies NavigationMenuItem[][] diff --git a/app/pages/entrelineas.vue b/app/pages/entrelineas.vue index 2483da7..a142f10 100644 --- a/app/pages/entrelineas.vue +++ b/app/pages/entrelineas.vue @@ -1,74 +1,183 @@ + + diff --git a/app/pages/favoritos.vue b/app/pages/favoritos.vue index a079ad1..c868d9d 100644 --- a/app/pages/favoritos.vue +++ b/app/pages/favoritos.vue @@ -6,15 +6,25 @@ 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 = { activities: 'Actividades', - conferences: 'Conferencias' + conferences: 'Conferencias', + entrelineas: 'Entre Líneas' } function labelFor(c: string): string { @@ -38,7 +48,13 @@ const tabs = computed(() => { items.push({ value: c, label: `${labelFor(c)} (${count})`, - icon: c === 'activities' ? 'i-lucide-calendar-days' : c === 'conferences' ? 'i-lucide-mic' : 'i-lucide-folder' + icon: c === 'activities' + ? 'i-lucide-calendar-days' + : c === 'conferences' + ? 'i-lucide-mic' + : c === 'entrelineas' + ? 'i-lucide-book-open' + : 'i-lucide-folder' }) } return items @@ -68,6 +84,20 @@ const selected = ref(null) const selectedHit = computed(() => selected.value?.hit ?? null) const selectedCollection = computed(() => 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 | null>(() => { + if (!isEntrelinea.value || !selectedHit.value) return null + return selectedHit.value as unknown as Record +}) + const isPanelOpen = computed({ get() { return !!selected.value }, set(v: boolean) { if (!v) selected.value = null } @@ -439,8 +469,16 @@ const mobileActions = computed(() => [[ + + + (() => [[