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