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:
ext.jeremy.guillot@maxicoffee.domains
2025-06-27 16:40:48 +02:00
parent 32b4e4fbb2
commit dac2f91998
15 changed files with 1364 additions and 15 deletions

View File

@@ -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>

View File

@@ -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>