add landing and subscription plans
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
545
web/dashboard/src/views/Articles.vue
Normal file
545
web/dashboard/src/views/Articles.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Artículos Notificados</h1>
|
||||
<p v-if="currentUser" class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span v-if="isAdmin">Todos los artículos</span>
|
||||
<span v-else>Tus artículos</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 ml-1">({{ currentUser }})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Búsqueda y Filtros -->
|
||||
<div class="card p-4 mb-4">
|
||||
<!-- Campo de búsqueda -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<MagnifyingGlassIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
Búsqueda
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Buscar artículos por título, descripción, precio, localidad..."
|
||||
class="input pr-10 pl-10"
|
||||
@input="searchQuery = $event.target.value"
|
||||
/>
|
||||
<MagnifyingGlassIcon class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<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>
|
||||
<button
|
||||
v-else-if="searchQuery"
|
||||
@click="searchQuery = ''"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
title="Limpiar búsqueda"
|
||||
>
|
||||
<XMarkIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Modo:</label>
|
||||
<select
|
||||
v-model="searchMode"
|
||||
class="input text-sm w-24"
|
||||
title="AND: todas las palabras deben estar presentes | OR: al menos una palabra debe estar presente"
|
||||
>
|
||||
<option value="AND">AND</option>
|
||||
<option value="OR">OR</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span v-if="searchMode === 'AND'">Todas las palabras deben estar presentes</span>
|
||||
<span v-else>Al menos una palabra debe estar presente</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Separador -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<FunnelIcon class="w-5 h-5 text-primary-600" />
|
||||
Filtros
|
||||
<span v-if="hasActiveFilters" class="ml-2 px-2 py-0.5 bg-primary-600 text-white rounded-full text-xs font-semibold">
|
||||
{{ activeFiltersCount }}
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
@click="clearAllFilters"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4 mr-1" />
|
||||
Limpiar todo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<ServerIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
Plataforma
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedPlatform"
|
||||
@change="loadArticles"
|
||||
class="input text-sm w-full"
|
||||
>
|
||||
<option value="">Todas las plataformas</option>
|
||||
<option v-for="platform in facets.platforms" :key="platform" :value="platform">
|
||||
{{ platform === 'wallapop' ? 'Wallapop' : platform === 'vinted' ? 'Vinted' : platform }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="isAdmin" class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<UserIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
Usuario
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedUsername"
|
||||
@change="loadArticles"
|
||||
class="input text-sm w-full"
|
||||
>
|
||||
<option value="">Todos los usuarios</option>
|
||||
<option v-for="username in availableUsernames" :key="username" :value="username">
|
||||
{{ username }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<BriefcaseIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
Worker
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedWorker"
|
||||
@change="loadArticles"
|
||||
class="input text-sm w-full"
|
||||
>
|
||||
<option value="">Todos los workers</option>
|
||||
<option v-for="worker in availableWorkers" :key="worker" :value="worker">
|
||||
{{ worker }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<button @click="loadArticles" class="btn btn-primary whitespace-nowrap">
|
||||
<ArrowPathIcon class="w-4 h-4 mr-1" />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros activos (chips) -->
|
||||
<div v-if="hasActiveFilters" class="flex flex-wrap gap-2 mt-3 mb-4">
|
||||
<span
|
||||
v-if="selectedPlatform"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-sm font-medium"
|
||||
>
|
||||
<ServerIcon class="w-3.5 h-3.5" />
|
||||
{{ selectedPlatform === 'wallapop' ? 'Wallapop' : 'Vinted' }}
|
||||
<button
|
||||
@click="selectedPlatform = ''; loadArticles()"
|
||||
class="ml-1 hover:text-primary-900 dark:hover:text-primary-100"
|
||||
title="Eliminar filtro"
|
||||
>
|
||||
<XMarkIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedUsername"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-sm font-medium"
|
||||
>
|
||||
<UserIcon class="w-3.5 h-3.5" />
|
||||
{{ selectedUsername }}
|
||||
<button
|
||||
@click="selectedUsername = ''; loadArticles()"
|
||||
class="ml-1 hover:text-primary-900 dark:hover:text-primary-100"
|
||||
title="Eliminar filtro"
|
||||
>
|
||||
<XMarkIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedWorker"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-sm font-medium"
|
||||
>
|
||||
<BriefcaseIcon class="w-3.5 h-3.5" />
|
||||
{{ selectedWorker }}
|
||||
<button
|
||||
@click="selectedWorker = ''; loadArticles()"
|
||||
class="ml-1 hover:text-primary-900 dark:hover:text-primary-100"
|
||||
title="Eliminar filtro"
|
||||
>
|
||||
<XMarkIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="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 dark:text-gray-400">Cargando artículos...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredArticles.length === 0 && !searchQuery" class="card text-center py-12">
|
||||
<p class="text-gray-600 dark:text-gray-400">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 dark:text-gray-400">Buscando artículos...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredArticles.length === 0 && searchQuery && !searching" class="card text-center py-12">
|
||||
<p class="text-gray-600 dark:text-gray-400">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">
|
||||
<ArticleCard
|
||||
v-for="article in filteredArticles"
|
||||
:key="`${article.platform}-${article.id}`"
|
||||
:article="article"
|
||||
:is-new="newArticleIds.has(`${article.platform}-${article.id}`)"
|
||||
/>
|
||||
|
||||
<div v-if="!searchQuery" class="flex justify-center space-x-2 mt-6">
|
||||
<button
|
||||
@click="loadMore"
|
||||
:disabled="allArticles.length >= total"
|
||||
class="btn btn-secondary"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': allArticles.length >= total }"
|
||||
>
|
||||
Cargar más
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs sm:text-sm text-gray-500 mt-4 px-2">
|
||||
<span v-if="searchQuery">
|
||||
Mostrando {{ filteredArticles.length }} resultados de búsqueda
|
||||
<span class="block sm:inline sm: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="block sm:inline sm:ml-2 text-xs text-gray-400">(Actualización automática cada 30s)</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import ArticleCard from '../components/ArticleCard.vue';
|
||||
import {
|
||||
FunnelIcon,
|
||||
XMarkIcon,
|
||||
ServerIcon,
|
||||
UserIcon,
|
||||
BriefcaseIcon,
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
|
||||
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 selectedUsername = ref('');
|
||||
const selectedWorker = ref('');
|
||||
const searchQuery = ref('');
|
||||
const searchMode = ref('AND'); // 'AND' o 'OR'
|
||||
const autoPollInterval = ref(null);
|
||||
const searchTimeout = ref(null);
|
||||
const POLL_INTERVAL = 30000; // 30 segundos
|
||||
const SEARCH_DEBOUNCE = 500; // 500ms de debounce para búsqueda
|
||||
|
||||
// Rastreo de artículos nuevos para efectos visuales
|
||||
const existingArticleIds = ref(new Set());
|
||||
const newArticleIds = ref(new Set());
|
||||
|
||||
// Facets obtenidos del backend
|
||||
const facets = ref({
|
||||
platforms: [],
|
||||
usernames: [],
|
||||
workers: []
|
||||
});
|
||||
|
||||
// Usar facets del backend en lugar de calcular desde artículos cargados
|
||||
const availableUsernames = computed(() => {
|
||||
return facets.value.usernames || [];
|
||||
});
|
||||
|
||||
const availableWorkers = computed(() => {
|
||||
return facets.value.workers || [];
|
||||
});
|
||||
|
||||
// Artículos que se muestran (búsqueda o lista normal)
|
||||
const filteredArticles = computed(() => {
|
||||
if (searchQuery.value.trim()) {
|
||||
return searchResults.value;
|
||||
}
|
||||
return allArticles.value;
|
||||
});
|
||||
|
||||
// Computed para filtros activos
|
||||
const hasActiveFilters = computed(() => {
|
||||
return !!(selectedPlatform.value || selectedUsername.value || selectedWorker.value);
|
||||
});
|
||||
|
||||
const activeFiltersCount = computed(() => {
|
||||
let count = 0;
|
||||
if (selectedPlatform.value) count++;
|
||||
if (selectedUsername.value) count++;
|
||||
if (selectedWorker.value) count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
function clearAllFilters() {
|
||||
selectedPlatform.value = '';
|
||||
selectedUsername.value = '';
|
||||
selectedWorker.value = '';
|
||||
loadArticles();
|
||||
}
|
||||
|
||||
async function loadFacets() {
|
||||
try {
|
||||
const data = await api.getArticleFacets();
|
||||
facets.value = {
|
||||
platforms: data.platforms || [],
|
||||
usernames: data.usernames || [],
|
||||
workers: data.workers || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error cargando facets:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function loadArticles(reset = true, silent = false) {
|
||||
// Guardar IDs existentes antes de cargar (para detectar artículos nuevos en polling)
|
||||
let previousIds = new Set();
|
||||
if (silent && reset) {
|
||||
// Para polling automático, guardar los IDs de los artículos actuales antes de resetear
|
||||
allArticles.value.forEach(article => {
|
||||
previousIds.add(`${article.platform}-${article.id}`);
|
||||
});
|
||||
} else if (!reset) {
|
||||
// Para cargar más, usar los IDs ya existentes
|
||||
previousIds = new Set(existingArticleIds.value);
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
offset.value = 0;
|
||||
if (!silent) {
|
||||
// Solo limpiar si no es polling silencioso
|
||||
newArticleIds.value.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Construir query params para filtros
|
||||
const params = { limit, offset: offset.value };
|
||||
if (selectedPlatform.value) params.platform = selectedPlatform.value;
|
||||
// Solo permitir filtrar por username si es admin
|
||||
if (selectedUsername.value && isAdmin.value) {
|
||||
params.username = selectedUsername.value;
|
||||
}
|
||||
if (selectedWorker.value) params.worker_name = selectedWorker.value;
|
||||
|
||||
const data = await api.getArticles(limit, offset.value, params);
|
||||
|
||||
let filtered = data.articles;
|
||||
// El filtro de plataforma se aplica en el backend ahora, pero mantenemos compatibilidad
|
||||
if (selectedPlatform.value) {
|
||||
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
|
||||
}
|
||||
|
||||
// Si es polling silencioso, detectar artículos nuevos comparando con los anteriores
|
||||
if (silent && reset && previousIds.size > 0) {
|
||||
newArticleIds.value.clear(); // Limpiar IDs anteriores
|
||||
filtered.forEach(article => {
|
||||
const articleId = `${article.platform}-${article.id}`;
|
||||
if (!previousIds.has(articleId)) {
|
||||
newArticleIds.value.add(articleId);
|
||||
}
|
||||
});
|
||||
// Limpiar IDs de artículos nuevos después de 5 segundos
|
||||
if (newArticleIds.value.size > 0) {
|
||||
setTimeout(() => {
|
||||
newArticleIds.value.clear();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar el Set de IDs existentes
|
||||
if (reset) {
|
||||
existingArticleIds.value.clear();
|
||||
}
|
||||
filtered.forEach(article => {
|
||||
existingArticleIds.value.add(`${article.platform}-${article.id}`);
|
||||
});
|
||||
|
||||
if (reset) {
|
||||
allArticles.value = filtered;
|
||||
offset.value = limit;
|
||||
} else {
|
||||
allArticles.value.push(...filtered);
|
||||
offset.value += limit;
|
||||
}
|
||||
|
||||
total.value = data.total;
|
||||
} catch (error) {
|
||||
console.error('Error cargando artículos:', error);
|
||||
} finally {
|
||||
if (!silent) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
// Si no es admin, no permitir filtrar por username
|
||||
if (!isAdmin.value && selectedUsername.value) {
|
||||
selectedUsername.value = '';
|
||||
}
|
||||
if (currentUser.value) {
|
||||
loadFacets(); // Recargar facets cuando cambie el usuario
|
||||
loadArticles();
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
loadArticles(false);
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'articles_updated') {
|
||||
loadFacets(); // Actualizar facets cuando se actualicen los artículos
|
||||
loadArticles();
|
||||
}
|
||||
}
|
||||
|
||||
async function searchArticles(query) {
|
||||
if (!query.trim()) {
|
||||
searchResults.value = [];
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
searching.value = true;
|
||||
|
||||
try {
|
||||
const data = await api.searchArticles(query, searchMode.value);
|
||||
|
||||
let filtered = data.articles || [];
|
||||
|
||||
// Aplicar filtros si están seleccionados
|
||||
if (selectedPlatform.value) {
|
||||
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
|
||||
}
|
||||
if (selectedUsername.value) {
|
||||
filtered = filtered.filter(a => a.username === selectedUsername.value);
|
||||
}
|
||||
if (selectedWorker.value) {
|
||||
filtered = filtered.filter(a => a.worker_name === selectedWorker.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 o searchMode (con debounce)
|
||||
watch([searchQuery, searchMode], ([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);
|
||||
});
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
// Si no es admin, no permitir filtrar por username
|
||||
if (!isAdmin.value && selectedUsername.value) {
|
||||
selectedUsername.value = '';
|
||||
}
|
||||
loadFacets(); // Cargar facets primero
|
||||
loadArticles();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
|
||||
// Iniciar autopoll para actualizar automáticamente
|
||||
autoPollInterval.value = setInterval(() => {
|
||||
loadArticles(true, true); // Reset silencioso cada 30 segundos
|
||||
loadFacets(); // Actualizar facets también
|
||||
}, POLL_INTERVAL);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
|
||||
// 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