mongodb
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
225
web/frontend/src/components/ArticleCard.vue
Normal file
225
web/frontend/src/components/ArticleCard.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="card hover:shadow-lg transition-shadow">
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
<!-- Imagen del artículo -->
|
||||
<div class="flex-shrink-0 self-center sm:self-start">
|
||||
<div v-if="article.images && article.images.length > 0" class="w-24 h-24 sm:w-32 sm:h-32 relative">
|
||||
<img
|
||||
:src="article.images[0]"
|
||||
:alt="article.title || 'Sin título'"
|
||||
class="w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-lg"
|
||||
@error="($event) => handleImageError($event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="w-24 h-24 sm:w-32 sm:h-32 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<span class="text-gray-400 dark:text-gray-500 text-xs">Sin imagen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información del artículo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-semibold rounded flex-shrink-0"
|
||||
:class="
|
||||
article.platform === 'wallapop'
|
||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300'
|
||||
: 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300'
|
||||
"
|
||||
>
|
||||
{{ article.platform?.toUpperCase() || 'N/A' }}
|
||||
</span>
|
||||
<span v-if="article.username" class="px-2 py-1 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300 flex-shrink-0" title="Usuario">
|
||||
👤 {{ article.username }}
|
||||
</span>
|
||||
<span v-if="article.worker_name" class="px-2 py-1 text-xs font-medium rounded bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-300 flex-shrink-0" title="Worker">
|
||||
⚙️ {{ article.worker_name }}
|
||||
</span>
|
||||
<span v-if="article.notifiedAt" class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatDate(article.notifiedAt) }}
|
||||
</span>
|
||||
<span v-if="article.addedAt && !article.notifiedAt" class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
|
||||
Añadido: {{ formatDate(article.addedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-2" :title="article.title">
|
||||
{{ article.title || 'Sin título' }}
|
||||
</h3>
|
||||
|
||||
<div v-if="article.price !== null && article.price !== undefined" class="mb-2">
|
||||
<span class="text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ article.price }} {{ article.currency || '€' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-xs sm:text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<div v-if="article.location" class="flex flex-wrap items-center">
|
||||
<span class="font-medium">📍 Localidad:</span>
|
||||
<span class="ml-2">{{ article.location }}</span>
|
||||
</div>
|
||||
<div v-if="article.allows_shipping !== null && article.allows_shipping !== undefined" class="flex flex-wrap items-center">
|
||||
<span class="font-medium">🚚 Envío:</span>
|
||||
<span class="ml-2">{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}</span>
|
||||
</div>
|
||||
<div v-if="article.modified_at" class="flex flex-wrap items-center">
|
||||
<span class="font-medium">🕒 Modificado:</span>
|
||||
<span class="ml-2 break-all">{{ article.modified_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="article.description" class="text-xs sm:text-sm text-gray-700 dark:text-gray-300 mb-2 overflow-hidden line-clamp-2">
|
||||
{{ article.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-4 mt-3">
|
||||
<a
|
||||
v-if="article.url"
|
||||
:href="article.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 text-xs sm:text-sm font-medium break-all"
|
||||
>
|
||||
🔗 Ver anuncio
|
||||
</a>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 break-all">
|
||||
ID: {{ article.id }}
|
||||
</span>
|
||||
<button
|
||||
v-if="showRemoveButton"
|
||||
@click="$emit('remove', article.platform, article.id)"
|
||||
class="btn btn-danger text-xs sm:text-sm"
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
<button
|
||||
v-if="!showRemoveButton && isAuthenticated && !isAdding"
|
||||
@click="handleAddFavorite"
|
||||
class="btn text-xs sm:text-sm flex items-center gap-1"
|
||||
:class="favoriteStatus ? 'btn-secondary' : 'bg-pink-500 hover:bg-pink-600 text-white border-pink-600'"
|
||||
:disabled="favoriteStatus"
|
||||
:title="favoriteStatus ? 'Ya está en favoritos' : 'Añadir a favoritos'"
|
||||
>
|
||||
<HeartIconSolid v-if="favoriteStatus" class="w-4 h-4" />
|
||||
<HeartIcon v-else class="w-4 h-4" />
|
||||
{{ favoriteStatus ? 'En favoritos' : 'Añadir a favoritos' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!showRemoveButton && isAuthenticated && isAdding"
|
||||
disabled
|
||||
class="btn btn-secondary text-xs sm:text-sm opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<span class="inline-block animate-spin mr-1">⏳</span>
|
||||
Añadiendo...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { HeartIcon } from '@heroicons/vue/24/outline';
|
||||
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
|
||||
import authService from '../services/auth';
|
||||
import api from '../services/api';
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showRemoveButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isFavorite: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove', 'added']);
|
||||
|
||||
const isAdding = ref(false);
|
||||
const isAuthenticated = ref(false);
|
||||
const favoriteStatus = ref(props.isFavorite);
|
||||
|
||||
// Verificar autenticación al montar y cuando cambie
|
||||
function checkAuth() {
|
||||
isAuthenticated.value = authService.hasCredentials();
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return 'N/A';
|
||||
return new Date(timestamp).toLocaleString('es-ES');
|
||||
}
|
||||
|
||||
function handleImageError(event) {
|
||||
// Si la imagen falla al cargar, reemplazar con placeholder
|
||||
event.target.onerror = null; // Prevenir bucle infinito
|
||||
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
|
||||
}
|
||||
|
||||
async function handleAddFavorite() {
|
||||
if (!isAuthenticated.value || favoriteStatus.value || isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.article.platform || !props.article.id) {
|
||||
alert('Error: El artículo no tiene platform o id válidos');
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
// El backend solo necesita platform e id
|
||||
const favorite = {
|
||||
platform: props.article.platform,
|
||||
id: String(props.article.id), // Asegurar que sea string
|
||||
};
|
||||
|
||||
await api.addFavorite(favorite);
|
||||
favoriteStatus.value = true;
|
||||
|
||||
// Emitir evento para que el componente padre pueda actualizar si es necesario
|
||||
emit('added', props.article.platform, props.article.id);
|
||||
} catch (error) {
|
||||
console.error('Error añadiendo a favoritos:', error);
|
||||
// El interceptor de API ya maneja el error 401 mostrando el modal de login
|
||||
if (error.response?.status === 404) {
|
||||
alert('El artículo no se encontró en la base de datos. Asegúrate de que el artículo esté en la lista de notificados.');
|
||||
} else if (error.response?.status === 400) {
|
||||
alert('Error: ' + (error.response?.data?.error || 'Datos inválidos'));
|
||||
} else if (error.response?.status !== 401) {
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Error desconocido';
|
||||
alert('Error al añadir a favoritos: ' + errorMessage);
|
||||
}
|
||||
} finally {
|
||||
isAdding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
checkAuth();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth();
|
||||
// Escuchar cambios en la autenticación
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user