feat: ajout de la gestion des sources de contenu avec création de composants, formulaires et API pour l'importation, l'exportation et la configuration des sources de scraping.
This commit is contained in:
parent
32b4e4fbb2
commit
dac2f91998
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Scrapper Configurations
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Gérez les configurations de scraping pour les différentes sources de manga
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loadingSources" class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="sourcesError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center">
|
||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
||||
<p class="text-red-800 dark:text-red-200">{{ sourcesError }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="contentSourceStore.loadSources()"
|
||||
class="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Debug Info (temporary) -->
|
||||
<div v-if="!loadingSources && !sourcesError && sources.length === 0" class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6">
|
||||
<p class="text-blue-800 dark:text-blue-200">Aucune source trouvée. Rechargement en cours...</p>
|
||||
<button
|
||||
@click="contentSourceStore.loadSources()"
|
||||
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sources Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Existing Sources -->
|
||||
<ContentSourceCard
|
||||
v-for="source in sources"
|
||||
:key="source.id"
|
||||
:source="source"
|
||||
@edit="editSource"
|
||||
@open-link="openSourceLink" />
|
||||
|
||||
<!-- Add New Configuration Card -->
|
||||
<div
|
||||
@click="addNewSource"
|
||||
class="bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer flex flex-col items-center justify-center h-full">
|
||||
<PlusIcon class="w-8 h-8 text-gray-400 dark:text-gray-500 mb-3" />
|
||||
<span class="text-lg font-medium text-gray-600 dark:text-gray-400 mb-2">
|
||||
Add New Configuration
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import/Export Success Messages -->
|
||||
<div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Configuration importée avec succès !
|
||||
</div>
|
||||
|
||||
<div v-if="showExportSuccess" class="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Configuration exportée !
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Importer des configurations</h3>
|
||||
<textarea
|
||||
v-model="importData"
|
||||
class="w-full h-40 p-3 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Collez ici le JSON des configurations à importer..."></textarea>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-4">
|
||||
<button
|
||||
@click="showImportModal = false"
|
||||
class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="handleImport"
|
||||
:disabled="importing || !importData.trim()"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md">
|
||||
{{ importing ? 'Import...' : 'Importer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowPathIcon,
|
||||
ArrowUpTrayIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PlusIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
||||
import ContentSourceCard from '../components/ContentSourceCard.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const contentSourceStore = useContentSourceStore();
|
||||
|
||||
const {
|
||||
sources,
|
||||
loadingSources,
|
||||
sourcesError,
|
||||
importing,
|
||||
exporting
|
||||
} = storeToRefs(contentSourceStore);
|
||||
|
||||
// Local state
|
||||
const showImportModal = ref(false);
|
||||
const showExportSuccess = ref(false);
|
||||
const showImportSuccess = ref(false);
|
||||
const importData = ref('');
|
||||
|
||||
// Load sources on mount and clear current source
|
||||
onMounted(async () => {
|
||||
try {
|
||||
contentSourceStore.clearCurrentSource(); // Clear any previously loaded source
|
||||
contentSourceStore.clearErrors(); // Clear any previous errors
|
||||
await contentSourceStore.loadSources();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des sources:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar configuration
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{
|
||||
icon: ArrowPathIcon,
|
||||
label: 'Actualiser',
|
||||
type: 'button',
|
||||
onClick: () => contentSourceStore.loadSources(),
|
||||
active: loadingSources.value
|
||||
}
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
icon: ArrowDownTrayIcon,
|
||||
label: 'Exporter',
|
||||
type: 'button',
|
||||
onClick: handleExport,
|
||||
disabled: exporting.value
|
||||
},
|
||||
{
|
||||
icon: ArrowUpTrayIcon,
|
||||
label: 'Importer',
|
||||
type: 'button',
|
||||
onClick: () => showImportModal.value = true
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
// Actions
|
||||
const editSource = (source) => {
|
||||
router.push({
|
||||
name: 'scrapper-edit',
|
||||
params: { id: source.id }
|
||||
});
|
||||
};
|
||||
|
||||
const addNewSource = () => {
|
||||
router.push({ name: 'scrapper-new' });
|
||||
};
|
||||
|
||||
const openSourceLink = (url) => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const exportData = await contentSourceStore.exportSources();
|
||||
|
||||
// Create and download file
|
||||
const dataStr = JSON.stringify(exportData, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `scrapper-configurations-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showExportSuccess.value = true;
|
||||
setTimeout(() => showExportSuccess.value = false, 3000);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'export:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
try {
|
||||
const data = JSON.parse(importData.value);
|
||||
await contentSourceStore.importSources(data);
|
||||
|
||||
showImportModal.value = false;
|
||||
importData.value = '';
|
||||
showImportSuccess.value = true;
|
||||
setTimeout(() => showImportSuccess.value = false, 3000);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'import:', error);
|
||||
alert('Erreur: Format JSON invalide ou erreur serveur');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Back Navigation -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
|
||||
<ArrowLeftIcon class="w-5 h-5" />
|
||||
<span>Retour aux configurations</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-center">
|
||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
|
||||
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div v-else class="max-w-4xl mx-auto">
|
||||
<ContentSourceForm
|
||||
:source="currentSource"
|
||||
:saving="saving"
|
||||
:error="saveError"
|
||||
@submit="handleSubmit"
|
||||
@test="handleTest" />
|
||||
</div>
|
||||
|
||||
<!-- Test Results Modal -->
|
||||
<div v-if="showTestResults" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-600">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">Résultats du test</h3>
|
||||
<button
|
||||
@click="showTestResults = false"
|
||||
class="text-gray-400 hover:text-gray-600">
|
||||
<XMarkIcon class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto">
|
||||
<div v-if="testResults.success" class="space-y-4">
|
||||
<div class="flex items-center text-green-600 mb-4">
|
||||
<CheckCircleIcon class="w-5 h-5 mr-2" />
|
||||
<span class="font-medium">Test réussi !</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium mb-2">URL testée:</h4>
|
||||
<code class="block bg-gray-100 dark:bg-gray-700 p-2 rounded text-sm">
|
||||
{{ testResults.testedUrl }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div v-if="testResults.images && testResults.images.length > 0">
|
||||
<h4 class="font-medium mb-2">Images trouvées ({{ testResults.images.length }}):</h4>
|
||||
<div class="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
|
||||
<img
|
||||
v-for="(image, index) in testResults.images.slice(0, 6)"
|
||||
:key="index"
|
||||
:src="image"
|
||||
:alt="`Image ${index + 1}`"
|
||||
class="w-full h-32 object-cover rounded border"
|
||||
@error="handleImageError" />
|
||||
</div>
|
||||
<p v-if="testResults.images.length > 6" class="text-sm text-gray-500 mt-2">
|
||||
Et {{ testResults.images.length - 6 }} autres images...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="flex items-center text-red-600 mb-4">
|
||||
<XCircleIcon class="w-5 h-5 mr-2" />
|
||||
<span class="font-medium">Test échoué</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium mb-2">Erreur:</h4>
|
||||
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded p-3">
|
||||
<code class="text-sm text-red-800 dark:text-red-200">
|
||||
{{ testResults.error }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
XMarkIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
||||
import ContentSourceForm from '../components/ContentSourceForm.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const contentSourceStore = useContentSourceStore();
|
||||
|
||||
const {
|
||||
currentSource,
|
||||
loadingCurrentSource,
|
||||
currentSourceError,
|
||||
saving,
|
||||
saveError
|
||||
} = storeToRefs(contentSourceStore);
|
||||
|
||||
// Local state
|
||||
const showTestResults = ref(false);
|
||||
const showSuccessMessage = ref(false);
|
||||
const testResults = ref({});
|
||||
|
||||
const isEditing = computed(() => !!route.params.id);
|
||||
|
||||
// Load source if editing, clear if creating new
|
||||
onMounted(async () => {
|
||||
if (isEditing.value) {
|
||||
await contentSourceStore.loadSource(route.params.id);
|
||||
} else {
|
||||
// Clear current source immediately when creating new
|
||||
contentSourceStore.clearCurrentSource();
|
||||
}
|
||||
});
|
||||
|
||||
// Toolbar configuration
|
||||
const toolbarConfig = {
|
||||
leftSection: [],
|
||||
rightSection: []
|
||||
};
|
||||
|
||||
// Actions
|
||||
const goBack = () => {
|
||||
router.push({ name: 'scrapper-configurations' });
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData) => {
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await contentSourceStore.updateSource(route.params.id, formData);
|
||||
} else {
|
||||
await contentSourceStore.createSource(formData);
|
||||
}
|
||||
|
||||
// Clear current source and errors before redirecting
|
||||
contentSourceStore.clearCurrentSource();
|
||||
contentSourceStore.clearErrors();
|
||||
|
||||
// Show success message briefly then redirect
|
||||
showSuccessMessage.value = true;
|
||||
|
||||
// Use nextTick to ensure the DOM is updated before redirecting
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Navigate back to list
|
||||
await router.push({ name: 'scrapper-configurations' });
|
||||
|
||||
// Hide success message after navigation
|
||||
showSuccessMessage.value = false;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde:', error);
|
||||
// Don't redirect if there's an error
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async ({ configuration, testData }) => {
|
||||
try {
|
||||
// Simulate test API call - You'll need to implement this endpoint
|
||||
const testUrl = configuration.chapterUrlFormat
|
||||
.replace('{slug}', testData.mangaSlug)
|
||||
.replace('{chapterNumber}', testData.chapterNumber);
|
||||
|
||||
// Mock test results for now
|
||||
testResults.value = {
|
||||
success: true,
|
||||
testedUrl: testUrl,
|
||||
images: [
|
||||
'https://via.placeholder.com/400x600/008000/FFFFFF?text=Page+1',
|
||||
'https://via.placeholder.com/400x600/FF0000/FFFFFF?text=Page+2',
|
||||
'https://via.placeholder.com/400x600/0000FF/FFFFFF?text=Page+3',
|
||||
'https://via.placeholder.com/400x600/FFA500/FFFFFF?text=Page+4'
|
||||
]
|
||||
};
|
||||
|
||||
showTestResults.value = true;
|
||||
} catch (error) {
|
||||
testResults.value = {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
showTestResults.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageError = (event) => {
|
||||
event.target.style.display = 'none';
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user