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,186 @@
import { defineStore } from 'pinia';
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
const contentSourceRepository = new ApiContentSourceRepository();
export const useContentSourceStore = defineStore('contentSource', {
state: () => ({
// Collection state
sources: [],
loadingSources: false,
sourcesError: null,
// Current source state
currentSource: null,
loadingCurrentSource: false,
currentSourceError: null,
// Create/Update state
saving: false,
saveError: null,
// Import/Export state
importing: false,
exporting: false,
importError: null,
exportError: null
}),
getters: {
getSourceById: (state) => (id) => {
return state.sources.find(source => source.id === id);
},
getSourcesByType: (state) => (scrapingType) => {
return state.sources.filter(source => source.scrapingType === scrapingType);
},
htmlSources: (state) => {
return state.sources.filter(source => source.scrapingType === 'HTML');
},
javascriptSources: (state) => {
return state.sources.filter(source => source.scrapingType === 'Javascript');
}
},
actions: {
// Load all sources
async loadSources() {
if (this.loadingSources) return;
this.loadingSources = true;
this.sourcesError = null;
try {
this.sources = await contentSourceRepository.getAll();
} catch (error) {
this.sourcesError = error.message;
console.error('Erreur lors du chargement des sources:', error);
} finally {
this.loadingSources = false;
}
},
// Load specific source by ID
async loadSource(id) {
if (this.loadingCurrentSource) return;
this.loadingCurrentSource = true;
this.currentSourceError = null;
try {
this.currentSource = await contentSourceRepository.getById(id);
} catch (error) {
this.currentSourceError = error.message;
console.error('Erreur lors du chargement de la source:', error);
} finally {
this.loadingCurrentSource = false;
}
},
// Create new source
async createSource(sourceData) {
if (this.saving) return;
this.saving = true;
this.saveError = null;
try {
const newSource = await contentSourceRepository.create(sourceData);
this.sources.push(newSource);
return newSource;
} catch (error) {
this.saveError = error.message;
console.error('Erreur lors de la création de la source:', error);
throw error;
} finally {
this.saving = false;
}
},
// Update existing source
async updateSource(id, sourceData) {
if (this.saving) return;
this.saving = true;
this.saveError = null;
try {
const updatedSource = await contentSourceRepository.update(id, sourceData);
// Update in sources array
const index = this.sources.findIndex(source => source.id === id);
if (index !== -1) {
this.sources[index] = updatedSource;
}
// Update current source if it's the same
if (this.currentSource && this.currentSource.id === id) {
this.currentSource = updatedSource;
}
return updatedSource;
} catch (error) {
this.saveError = error.message;
console.error('Erreur lors de la mise à jour de la source:', error);
throw error;
} finally {
this.saving = false;
}
},
// Export sources
async exportSources() {
if (this.exporting) return;
this.exporting = true;
this.exportError = null;
try {
return await contentSourceRepository.export();
} catch (error) {
this.exportError = error.message;
console.error('Erreur lors de l\'export:', error);
throw error;
} finally {
this.exporting = false;
}
},
// Import sources
async importSources(sourcesData) {
if (this.importing) return;
this.importing = true;
this.importError = null;
try {
await contentSourceRepository.import(sourcesData);
// Reload sources after import
await this.loadSources();
} catch (error) {
this.importError = error.message;
console.error('Erreur lors de l\'import:', error);
throw error;
} finally {
this.importing = false;
}
},
// Clear current source
clearCurrentSource() {
this.currentSource = null;
this.currentSourceError = null;
},
// Clear errors
clearErrors() {
this.sourcesError = null;
this.currentSourceError = null;
this.saveError = null;
this.importError = null;
this.exportError = null;
}
}
});

View File

@@ -0,0 +1,55 @@
/**
* Types de scraping disponibles
*/
export const SCRAPING_TYPES = {
HTML: 'html',
JAVASCRIPT: 'javascript'
};
/**
* Orientations de lecture
*/
export const READING_ORIENTATIONS = {
VERTICAL: 'vertical',
HORIZONTAL: 'horizontal'
};
/**
* États des sources
*/
export const SOURCE_STATUS = {
ACTIVE: 'active',
INACTIVE: 'inactive',
ERROR: 'error'
};
/**
* Configuration par défaut pour une nouvelle source
*/
export const DEFAULT_SOURCE_CONFIG = {
baseUrl: '',
chapterUrlFormat: '',
scrapingType: SCRAPING_TYPES.HTML,
imageSelector: '',
nextPageSelector: '',
chapterSelector: '',
token: ''
};
/**
* Validateurs pour les champs
*/
export const VALIDATORS = {
URL_PATTERN: /^https?:\/\/.+/,
SELECTOR_PATTERN: /^[.#]?[\w\-\s.#\[\]=\"':()>+~,]+$/
};
/**
* Messages d'erreur
*/
export const ERROR_MESSAGES = {
INVALID_URL: 'L\'URL doit commencer par http:// ou https://',
INVALID_SELECTOR: 'Le sélecteur CSS n\'est pas valide',
REQUIRED_FIELD: 'Ce champ est obligatoire',
CHAPTER_URL_FORMAT_REQUIRED: 'Le format d\'URL des chapitres est obligatoire'
};

View File

@@ -0,0 +1,116 @@
/**
* Modèle représentant une source de contenu pour le scraping
*/
export class ContentSource {
constructor({
id = null,
baseUrl = '',
chapterUrlFormat = '',
scrapingType = 'HTML',
imageSelector = null,
nextPageSelector = null,
chapterSelector = null,
cleanBaseUrl = '',
token = null
} = {}) {
this.id = id;
this.baseUrl = baseUrl;
this.chapterUrlFormat = chapterUrlFormat;
this.scrapingType = scrapingType;
this.imageSelector = imageSelector;
this.nextPageSelector = nextPageSelector;
this.chapterSelector = chapterSelector;
this.cleanBaseUrl = cleanBaseUrl || this.extractCleanBaseUrl(baseUrl);
this.token = token;
}
/**
* Extrait une URL propre à partir de l'URL de base
*/
extractCleanBaseUrl(url) {
if (!url) return '';
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch (error) {
return url;
}
}
/**
* Vérifie si la source est valide
*/
isValid() {
return !!(this.baseUrl && this.chapterUrlFormat);
}
/**
* Vérifie si la source est de type JavaScript
*/
isJavascriptSource() {
return this.scrapingType === 'Javascript';
}
/**
* Vérifie si la source est de type HTML
*/
isHtmlSource() {
return this.scrapingType === 'HTML';
}
/**
* Détermine l'orientation basée sur les sélecteurs
*/
getOrientation() {
return this.nextPageSelector ? 'vertical' : 'horizontal';
}
/**
* Génère une URL de chapitre basée sur le format
*/
generateChapterUrl(slug, chapterNumber) {
return this.chapterUrlFormat
.replace('{slug}', slug)
.replace('{chapterNumber}', chapterNumber);
}
/**
* Convertit l'objet en format API
*/
toApiFormat() {
return {
baseUrl: this.baseUrl,
chapterUrlFormat: this.chapterUrlFormat,
scrapingType: this.scrapingType,
imageSelector: this.imageSelector,
nextPageSelector: this.nextPageSelector,
chapterSelector: this.chapterSelector,
token: this.token
};
}
/**
* Crée une instance depuis les données API
*/
static fromApiData(data) {
return new ContentSource(data);
}
/**
* Clone l'instance
*/
clone() {
return new ContentSource({
id: this.id,
baseUrl: this.baseUrl,
chapterUrlFormat: this.chapterUrlFormat,
scrapingType: this.scrapingType,
imageSelector: this.imageSelector,
nextPageSelector: this.nextPageSelector,
chapterSelector: this.chapterSelector,
cleanBaseUrl: this.cleanBaseUrl,
token: this.token
});
}
}

View File

@@ -0,0 +1,86 @@
import axios from 'axios';
export class ApiContentSourceRepository {
constructor() {
this.apiClient = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
}
/**
* Récupère toutes les sources de contenu
*/
async getAll() {
try {
const response = await this.apiClient.get('/content-sources');
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erreur lors de la récupération des sources');
}
}
/**
* Récupère une source de contenu par son ID
*/
async getById(id) {
try {
const response = await this.apiClient.get(`/content-sources/${id}`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erreur lors de la récupération de la source');
}
}
/**
* Crée une nouvelle source de contenu
*/
async create(contentSource) {
try {
const response = await this.apiClient.post('/content-sources', contentSource);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erreur lors de la création de la source');
}
}
/**
* Met à jour une source de contenu
*/
async update(id, contentSource) {
try {
const response = await this.apiClient.put(`/content-sources/${id}`, contentSource);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erreur lors de la mise à jour de la source');
}
}
/**
* Exporte toutes les sources de contenu
*/
async export() {
try {
const response = await this.apiClient.get('/content-sources/export');
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erreur lors de l\'export des sources');
}
}
/**
* Importe des sources de contenu
*/
async import(contentSources) {
try {
const response = await this.apiClient.post('/content-sources/import', {
contentSources
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Erreur lors de l\'import des sources');
}
}
}

View File

@@ -0,0 +1,89 @@
<template>
<div
@click="$emit('edit', source)"
class="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow duration-200 cursor-pointer">
<!-- Header avec URL et icône externe -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate" :title="source.cleanBaseUrl">
{{ truncateUrl(source.cleanBaseUrl) }}
</h3>
<button
@click.stop="$emit('openLink', source.baseUrl)"
class="p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
title="Ouvrir le site">
<ArrowTopRightOnSquareIcon class="w-4 h-4" />
</button>
</div>
<!-- Badges -->
<div class="flex flex-wrap gap-2 mb-4">
<!-- Badge type de scraping -->
<span
:class="getScrapingTypeBadgeClass(source.scrapingType)"
class="px-2 py-1 text-xs font-medium rounded-md">
{{ source.scrapingType?.toLowerCase() || 'N/A' }}
</span>
<!-- Badge orientation basé sur les sélecteurs -->
<span
:class="getOrientationBadgeClass(source)"
class="px-2 py-1 text-xs font-medium rounded-md">
{{ getOrientation(source) }}
</span>
</div>
</div>
</template>
<script setup>
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
defineProps({
source: {
type: Object,
required: true
}
});
defineEmits(['edit', 'openLink']);
// Fonction pour tronquer l'URL si elle est trop longue
const truncateUrl = (url) => {
if (!url) return '';
const maxLength = 25; // Ajustez selon vos besoins
return url.length > maxLength ? url.substring(0, maxLength) + '...' : url;
};
const getScrapingTypeBadgeClass = (type) => {
switch (type?.toLowerCase()) {
case 'html':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
case 'javascript':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
const getOrientation = (source) => {
// Logic pour déterminer l'orientation basée sur les sélecteurs ou autre logique métier
if (source.nextPageSelector) {
return 'vertical';
}
return 'horizontal';
};
const getOrientationBadgeClass = (source) => {
const orientation = getOrientation(source);
switch (orientation) {
case 'vertical':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
case 'horizontal':
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
</script>

View File

@@ -0,0 +1,268 @@
<template>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600 rounded-t-lg">
<div class="flex items-center space-x-2">
<Cog6ToothIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ isEditing ? 'Edit Scrapper Configuration' : 'New Scrapper Configuration' }}
</h2>
</div>
</div>
<!-- Form -->
<form @submit.prevent="handleSubmit" class="p-6 space-y-6">
<!-- Base URL -->
<div>
<label for="baseUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Base URL
</label>
<input
id="baseUrl"
v-model="form.baseUrl"
type="url"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="https://example.com" />
</div>
<!-- Image Selector -->
<div>
<label for="imageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Image Selector
</label>
<input
id="imageSelector"
v-model="form.imageSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".reading-content .page-break img" />
</div>
<!-- Chapter URL Format -->
<div>
<label for="chapterUrlFormat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Chapter URL Format <span class="text-gray-500">({slug}, {chapterNumber})</span>
</label>
<input
id="chapterUrlFormat"
v-model="form.chapterUrlFormat"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="https://example.com/manga/{slug}-{chapterNumber}/" />
</div>
<!-- Next Page Selector -->
<div>
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Next Page Selector <span class="text-gray-500">(let empty if vertical reader)</span>
</label>
<input
id="nextPageSelector"
v-model="form.nextPageSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".next-page" />
</div>
<!-- Chapter Selector -->
<div>
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Chapter Selector <span class="text-gray-500">(required for Javascript scraping)</span>
</label>
<input
id="chapterSelector"
v-model="form.chapterSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".chapter-selector" />
</div>
<!-- Scraping Type -->
<div>
<label for="scrapingType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Scraping Type
</label>
<select
id="scrapingType"
v-model="form.scrapingType"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white">
<option value="html">HTML</option>
<option value="javascript">Javascript</option>
</select>
</div>
<!-- Token (optionnel) -->
<div>
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Token
</label>
<input
id="token"
v-model="form.token"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Optional authentication token" />
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button
type="submit"
:disabled="saving"
class="px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center space-x-2">
<ArrowPathIcon v-if="saving" class="w-4 h-4 animate-spin" />
<span>{{ isEditing ? 'Update Configuration' : 'Create Configuration' }}</span>
<PencilSquareIcon v-if="!saving" class="w-4 h-4" />
</button>
</div>
<!-- Error message -->
<div v-if="error" class="text-red-600 dark:text-red-400 text-sm">
{{ error }}
</div>
</form>
<!-- Test Configuration Section -->
<div class="border-t border-gray-200 dark:border-gray-600 p-6 bg-gray-50 dark:bg-gray-700 rounded-b-lg">
<div class="flex items-center space-x-2 mb-4">
<WrenchScrewdriverIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Test Configuration</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="testMangaSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Manga Slug
</label>
<input
id="testMangaSlug"
v-model="testData.mangaSlug"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="manga-slug" />
</div>
<div>
<label for="testChapterNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Chapter Number
</label>
<input
id="testChapterNumber"
v-model="testData.chapterNumber"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="1" />
</div>
</div>
<button
type="button"
@click="testConfiguration"
:disabled="testing || !canTest"
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center justify-center space-x-2">
<ArrowPathIcon v-if="testing" class="w-4 h-4 animate-spin" />
<PlayIcon v-else class="w-4 h-4" />
<span>Test Configuration</span>
</button>
</div>
</div>
</template>
<script setup>
import {
ArrowPathIcon,
Cog6ToothIcon,
PencilSquareIcon,
PlayIcon,
WrenchScrewdriverIcon
} from '@heroicons/vue/24/outline';
import { computed, ref, watch } from 'vue';
const props = defineProps({
source: {
type: Object,
default: null
},
saving: {
type: Boolean,
default: false
},
error: {
type: String,
default: null
}
});
const emit = defineEmits(['submit', 'test']);
const isEditing = computed(() => !!props.source);
const form = ref({
baseUrl: '',
imageSelector: '',
chapterUrlFormat: '',
nextPageSelector: '',
chapterSelector: '',
scrapingType: 'HTML',
token: ''
});
const testData = ref({
mangaSlug: '',
chapterNumber: ''
});
const testing = ref(false);
const canTest = computed(() => {
return form.value.baseUrl &&
form.value.chapterUrlFormat &&
testData.value.mangaSlug &&
testData.value.chapterNumber;
});
// Initialize form with source data if editing, clear if creating new
watch(() => props.source, (newSource) => {
if (newSource) {
form.value = {
baseUrl: newSource.baseUrl || '',
imageSelector: newSource.imageSelector || '',
chapterUrlFormat: newSource.chapterUrlFormat || '',
nextPageSelector: newSource.nextPageSelector || '',
chapterSelector: newSource.chapterSelector || '',
scrapingType: newSource.scrapingType || 'HTML',
token: newSource.token || ''
};
} else {
// Reset form when no source (creating new)
form.value = {
baseUrl: '',
imageSelector: '',
chapterUrlFormat: '',
nextPageSelector: '',
chapterSelector: '',
scrapingType: 'HTML',
token: ''
};
}
}, { immediate: true });
const handleSubmit = () => {
emit('submit', { ...form.value });
};
const testConfiguration = async () => {
testing.value = true;
try {
await emit('test', {
configuration: { ...form.value },
testData: { ...testData.value }
});
} finally {
testing.value = false;
}
};
</script>

View File

@@ -0,0 +1,56 @@
import { computed } from 'vue';
import { useContentSourceStore } from '../../application/store/contentSourceStore';
export function useContentSources() {
const store = useContentSourceStore();
// Computed properties pour un accès facile aux données
const sources = computed(() => store.sources);
const currentSource = computed(() => store.currentSource);
const isLoading = computed(() => store.loadingSources || store.loadingCurrentSource);
const isSaving = computed(() => store.saving);
const hasError = computed(() => store.sourcesError || store.currentSourceError || store.saveError);
// Getters
const getSourceById = (id) => store.getSourceById(id);
const getSourcesByType = (type) => store.getSourcesByType(type);
const htmlSources = computed(() => store.htmlSources);
const javascriptSources = computed(() => store.javascriptSources);
// Actions
const loadSources = () => store.loadSources();
const loadSource = (id) => store.loadSource(id);
const createSource = (data) => store.createSource(data);
const updateSource = (id, data) => store.updateSource(id, data);
const exportSources = () => store.exportSources();
const importSources = (data) => store.importSources(data);
const clearCurrentSource = () => store.clearCurrentSource();
const clearErrors = () => store.clearErrors();
return {
// Data
sources,
currentSource,
// States
isLoading,
isSaving,
hasError,
// Getters
getSourceById,
getSourcesByType,
htmlSources,
javascriptSources,
// Actions
loadSources,
loadSource,
createSource,
updateSource,
exportSources,
importSources,
clearCurrentSource,
clearErrors
};
}

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>

View File

@@ -1,10 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import Layout from '../shared/components/layout/Layout.vue'; import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
import HomePage from '../domain/manga/presentation/pages/HomePage.vue'; import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue'; import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue'; import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue'; import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
import AddManga from '../domain/manga/presentation/pages/AddManga.vue'; import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
import Layout from '../shared/components/layout/Layout.vue';
// Placeholder component for new routes // Placeholder component for new routes
const PlaceholderComponent = { const PlaceholderComponent = {
@@ -104,9 +106,18 @@ const routes = [
}, },
{ {
path: '/settings/scrappers', path: '/settings/scrappers',
name: 'settings-scrappers', name: 'scrapper-configurations',
component: PlaceholderComponent, component: ScrapperConfigurations
props: { title: 'Configuration des scrappers' } },
{
path: '/settings/scrappers/new',
name: 'scrapper-new',
component: ScrapperEdit
},
{
path: '/settings/scrappers/edit/:id',
name: 'scrapper-edit',
component: ScrapperEdit
}, },
{ {
path: '/settings/ui', path: '/settings/ui',

View File

@@ -7,7 +7,7 @@ use ApiPlatform\Metadata\Get;
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\ExportContentSourceStateProvider; use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\ExportContentSourceStateProvider;
#[ApiResource( #[ApiResource(
shortName: 'ContentSourceExport', shortName: 'ContentSource',
operations: [ operations: [
new Get( new Get(
uriTemplate: '/content-sources/export', uriTemplate: '/content-sources/export',

View File

@@ -8,7 +8,7 @@ use App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor\ImportContentS
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource( #[ApiResource(
shortName: 'ContentSourceImport', shortName: 'ContentSource',
operations: [ operations: [
new Post( new Post(
uriTemplate: '/content-sources/import', uriTemplate: '/content-sources/import',

View File

@@ -6,6 +6,7 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor\UpsertContentSourceStateProcessor; use App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor\UpsertContentSourceStateProcessor;
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\GetContentSourceStateProvider;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource( #[ApiResource(
@@ -20,6 +21,7 @@ use Symfony\Component\Validator\Constraints as Assert;
), ),
new Put( new Put(
uriTemplate: '/content-sources/{id}', uriTemplate: '/content-sources/{id}',
provider: GetContentSourceStateProvider::class,
processor: UpsertContentSourceStateProcessor::class, processor: UpsertContentSourceStateProcessor::class,
input: UpsertContentSourceResource::class, input: UpsertContentSourceResource::class,
description: 'Met à jour une source de contenu existante' description: 'Met à jour une source de contenu existante'

View File

@@ -33,4 +33,16 @@ readonly class ContentSourceMapper
return $entity; return $entity;
} }
public function updateEntity(ContentSourceEntity $entity, ContentSource $contentSource): ContentSourceEntity
{
$entity->setBaseUrl($contentSource->getBaseUrl())
->setChapterUrlFormat($contentSource->getChapterUrlFormat())
->setScrapingType($contentSource->getScrapingType())
->setImageSelector($contentSource->getImageSelector())
->setNextPageSelector($contentSource->getNextPageSelector())
->setChapterSelector($contentSource->getChapterSelector());
return $entity;
}
} }

View File

@@ -34,14 +34,24 @@ readonly class DoctrineContentSourceRepository implements ContentSourceRepositor
public function save(ContentSource $contentSource): void public function save(ContentSource $contentSource): void
{ {
$entity = $this->mapper->toEntity($contentSource); if ($contentSource->getId()) {
// Update existing entity
$entity = $this->entityManager->find(ContentSourceEntity::class, $contentSource->getId());
if ($entity) {
// Update existing entity with new values
$this->mapper->updateEntity($entity, $contentSource);
$this->entityManager->flush();
}
} else {
// Create new entity
$entity = $this->mapper->toEntity($contentSource);
$this->entityManager->persist($entity);
$this->entityManager->flush();
$this->entityManager->persist($entity); // Met à jour l'ID du modèle du domaine
$this->entityManager->flush(); if ($entity->getId()) {
$contentSource->updateId($entity->getId());
// Met à jour l'ID du modèle du domaine si nécessaire }
if ($entity->getId() && $contentSource->getId() === null) {
$contentSource->updateId($entity->getId());
} }
} }