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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user