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,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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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'
|
||||
};
|
||||
116
assets/vue/app/domain/setting/domain/models/ContentSource.js
Normal file
116
assets/vue/app/domain/setting/domain/models/ContentSource.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,10 +1,12 @@
|
||||
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 MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
||||
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
||||
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
|
||||
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
|
||||
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
|
||||
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
|
||||
import Layout from '../shared/components/layout/Layout.vue';
|
||||
|
||||
// Placeholder component for new routes
|
||||
const PlaceholderComponent = {
|
||||
@@ -104,9 +106,18 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/settings/scrappers',
|
||||
name: 'settings-scrappers',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Configuration des scrappers' }
|
||||
name: 'scrapper-configurations',
|
||||
component: ScrapperConfigurations
|
||||
},
|
||||
{
|
||||
path: '/settings/scrappers/new',
|
||||
name: 'scrapper-new',
|
||||
component: ScrapperEdit
|
||||
},
|
||||
{
|
||||
path: '/settings/scrappers/edit/:id',
|
||||
name: 'scrapper-edit',
|
||||
component: ScrapperEdit
|
||||
},
|
||||
{
|
||||
path: '/settings/ui',
|
||||
|
||||
@@ -7,7 +7,7 @@ use ApiPlatform\Metadata\Get;
|
||||
use App\Domain\Setting\Infrastructure\ApiPlatform\State\Provider\ExportContentSourceStateProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'ContentSourceExport',
|
||||
shortName: 'ContentSource',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/content-sources/export',
|
||||
|
||||
@@ -8,7 +8,7 @@ use App\Domain\Setting\Infrastructure\ApiPlatform\State\Processor\ImportContentS
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'ContentSourceImport',
|
||||
shortName: 'ContentSource',
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/content-sources/import',
|
||||
|
||||
@@ -6,6 +6,7 @@ use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
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;
|
||||
|
||||
#[ApiResource(
|
||||
@@ -20,6 +21,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/content-sources/{id}',
|
||||
provider: GetContentSourceStateProvider::class,
|
||||
processor: UpsertContentSourceStateProcessor::class,
|
||||
input: UpsertContentSourceResource::class,
|
||||
description: 'Met à jour une source de contenu existante'
|
||||
|
||||
@@ -33,4 +33,16 @@ readonly class ContentSourceMapper
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,14 +34,24 @@ readonly class DoctrineContentSourceRepository implements ContentSourceRepositor
|
||||
|
||||
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);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Met à jour l'ID du modèle du domaine si nécessaire
|
||||
if ($entity->getId() && $contentSource->getId() === null) {
|
||||
$contentSource->updateId($entity->getId());
|
||||
// Met à jour l'ID du modèle du domaine
|
||||
if ($entity->getId()) {
|
||||
$contentSource->updateId($entity->getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user