Refactor favorites management to use Redis

- Removed local favorites.json file and related file handling in the code.
- Implemented Redis caching for managing favorite articles, including methods to set, get, and check favorites.
- Updated TelegramManager and server API to interact with Redis for favorite operations.
- Added search functionality for articles in Redis, enhancing user experience.
- Adjusted frontend components to support searching and displaying articles from Redis.
This commit is contained in:
Omar Sánchez Pizarro
2026-01-19 20:42:11 +01:00
parent 9939c4d9ed
commit a316844576
7 changed files with 524 additions and 169 deletions

View File

@@ -49,6 +49,13 @@ export default {
return response.data;
},
async searchArticles(query) {
const response = await api.get('/articles/search', {
params: { q: query },
});
return response.data;
},
// Logs
async getLogs(limit = 100) {
const response = await api.get('/logs', {

View File

@@ -1,21 +1,40 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Artículos Notificados</h1>
<div class="flex items-center space-x-4">
<select
v-model="selectedPlatform"
@change="loadArticles"
class="input"
style="width: auto;"
>
<option value="">Todas las plataformas</option>
<option value="wallapop">Wallapop</option>
<option value="vinted">Vinted</option>
</select>
<button @click="loadArticles" class="btn btn-primary">
Actualizar
</button>
<div class="mb-6">
<div class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold text-gray-900">Artículos Notificados</h1>
<div class="flex items-center space-x-4">
<select
v-model="selectedPlatform"
@change="loadArticles"
class="input"
style="width: auto;"
>
<option value="">Todas las plataformas</option>
<option value="wallapop">Wallapop</option>
<option value="vinted">Vinted</option>
</select>
<button @click="loadArticles" class="btn btn-primary">
Actualizar
</button>
</div>
</div>
<!-- Campo de búsqueda -->
<div class="relative">
<input
v-model="searchQuery"
type="text"
placeholder="Buscar artículos en Redis por título, descripción, precio, localidad..."
class="input pr-10"
@input="searchQuery = $event.target.value"
/>
<span v-if="searching" class="absolute right-3 top-1/2 transform -translate-y-1/2">
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
</span>
<span v-else-if="searchQuery" class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 cursor-pointer hover:text-gray-600" @click="searchQuery = ''">
</span>
</div>
</div>
@@ -24,13 +43,23 @@
<p class="mt-2 text-gray-600">Cargando artículos...</p>
</div>
<div v-else-if="articles.length === 0" class="card text-center py-12">
<div v-else-if="filteredArticles.length === 0 && !searchQuery" class="card text-center py-12">
<p class="text-gray-600">No hay artículos para mostrar</p>
</div>
<div v-else-if="searching" class="card text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-gray-600">Buscando artículos en Redis...</p>
</div>
<div v-else-if="filteredArticles.length === 0 && searchQuery && !searching" class="card text-center py-12">
<p class="text-gray-600">No se encontraron artículos que coincidan con "{{ searchQuery }}"</p>
<button @click="searchQuery = ''" class="btn btn-secondary mt-4">Limpiar búsqueda</button>
</div>
<div v-else class="space-y-4">
<div
v-for="article in articles"
v-for="article in filteredArticles"
:key="`${article.platform}-${article.id}`"
class="card hover:shadow-lg transition-shadow"
>
@@ -119,47 +148,72 @@
</div>
</div>
<div class="flex justify-center space-x-2 mt-6">
<div v-if="!searchQuery" class="flex justify-center space-x-2 mt-6">
<button
@click="loadMore"
:disabled="articles.length >= total"
:disabled="allArticles.length >= total"
class="btn btn-secondary"
:class="{ 'opacity-50 cursor-not-allowed': articles.length >= total }"
:class="{ 'opacity-50 cursor-not-allowed': allArticles.length >= total }"
>
Cargar más
</button>
</div>
<p class="text-center text-sm text-gray-500 mt-4">
Mostrando {{ articles.length }} de {{ total }} artículos
<span v-if="searchQuery">
Mostrando {{ filteredArticles.length }} resultados de búsqueda en Redis
<span class="ml-2 text-xs text-primary-600">(de {{ total }} artículos totales)</span>
</span>
<span v-else>
Mostrando {{ filteredArticles.length }} de {{ total }} artículos
<span class="ml-2 text-xs text-gray-400">(Actualización automática cada 30s)</span>
</span>
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import api from '../services/api';
const articles = ref([]);
const allArticles = ref([]);
const searchResults = ref([]);
const loading = ref(true);
const searching = ref(false);
const total = ref(0);
const offset = ref(0);
const limit = 50;
const selectedPlatform = ref('');
const searchQuery = ref('');
const autoPollInterval = ref(null);
const searchTimeout = ref(null);
const POLL_INTERVAL = 30000; // 30 segundos
const SEARCH_DEBOUNCE = 500; // 500ms de debounce para búsqueda
// Artículos que se muestran (búsqueda o lista normal)
const filteredArticles = computed(() => {
if (searchQuery.value.trim()) {
return searchResults.value;
}
return allArticles.value;
});
function formatDate(timestamp) {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleString('es-ES');
}
async function loadArticles(reset = true) {
async function loadArticles(reset = true, silent = false) {
if (reset) {
offset.value = 0;
articles.value = [];
allArticles.value = [];
}
if (!silent) {
loading.value = true;
}
loading.value = true;
try {
const data = await api.getArticles(limit, offset.value);
@@ -169,17 +223,20 @@ async function loadArticles(reset = true) {
}
if (reset) {
articles.value = filtered;
allArticles.value = filtered;
offset.value = limit;
} else {
articles.value.push(...filtered);
allArticles.value.push(...filtered);
offset.value += limit;
}
total.value = data.total;
offset.value += limit;
} catch (error) {
console.error('Error cargando artículos:', error);
} finally {
loading.value = false;
if (!silent) {
loading.value = false;
}
}
}
@@ -194,6 +251,53 @@ function handleWSMessage(event) {
}
}
async function searchArticles(query) {
if (!query.trim()) {
searchResults.value = [];
searching.value = false;
return;
}
searching.value = true;
try {
const data = await api.searchArticles(query);
let filtered = data.articles || [];
// Aplicar filtro de plataforma si está seleccionado
if (selectedPlatform.value) {
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
}
searchResults.value = filtered;
} catch (error) {
console.error('Error buscando artículos:', error);
searchResults.value = [];
} finally {
searching.value = false;
}
}
// Watch para buscar cuando cambie searchQuery (con debounce)
watch(searchQuery, (newQuery) => {
// Limpiar timeout anterior
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
// Si está vacío, limpiar resultados
if (!newQuery.trim()) {
searchResults.value = [];
return;
}
// Añadir debounce antes de buscar
searchTimeout.value = setTimeout(() => {
searchArticles(newQuery);
}, SEARCH_DEBOUNCE);
});
function handleImageError(event) {
// Si la imagen falla al cargar, reemplazar con placeholder
event.target.onerror = null; // Prevenir bucle infinito
@@ -203,10 +307,27 @@ function handleImageError(event) {
onMounted(() => {
loadArticles();
window.addEventListener('ws-message', handleWSMessage);
// Iniciar autopoll para actualizar automáticamente
autoPollInterval.value = setInterval(() => {
loadArticles(true, true); // Reset silencioso cada 30 segundos
}, POLL_INTERVAL);
});
onUnmounted(() => {
window.removeEventListener('ws-message', handleWSMessage);
// Limpiar el intervalo cuando el componente se desmonte
if (autoPollInterval.value) {
clearInterval(autoPollInterval.value);
autoPollInterval.value = null;
}
// Limpiar el timeout de búsqueda
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
searchTimeout.value = null;
}
});
</script>