feat: ajout de la gestion des jobs avec création, récupération, suppression et filtrage via l'API, incluant des entités, des composants Vue.js et des mises à jour de la documentation API
This commit is contained in:
parent
4d1d5b9f21
commit
fd2d3cd640
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs: *.vue,*.js,assets/vue/app/*
|
||||
globs: *.vue,*.js,assets/vue/app/*,assets/vue/app/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
# Architecture Frontend Vue.js
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
|
||||
|
||||
const jobRepository = new ApiJobRepository();
|
||||
|
||||
export const useActivityStore = defineStore('activity', {
|
||||
state: () => ({
|
||||
jobs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
filter: {
|
||||
status: ['pending', 'in_progress', 'failed'], // Par défaut, afficher les jobs actifs
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC'
|
||||
}
|
||||
}),
|
||||
|
||||
getters: {
|
||||
activeJobs: state => state.jobs.filter(job => job.isActive()),
|
||||
completedJobs: state => state.jobs.filter(job => job.isCompleted()),
|
||||
failedJobs: state => state.jobs.filter(job => job.hasError()),
|
||||
isLoading: state => state.loading,
|
||||
hasError: state => !!state.error
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Charge la liste des jobs selon les filtres actuels
|
||||
*/
|
||||
async loadJobs() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const options = {
|
||||
page: 1, // Page fixée à 1
|
||||
limit: 100, // Limite augmentée pour récupérer plus de jobs
|
||||
sortBy: this.filter.sortBy,
|
||||
sortOrder: this.filter.sortOrder,
|
||||
status: this.filter.status
|
||||
};
|
||||
|
||||
const jobCollection = await jobRepository.getJobs(options);
|
||||
this.jobs = jobCollection.items;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
console.error('Error loading jobs:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Met à jour les filtres et recharge la liste
|
||||
* @param {Object} filter
|
||||
*/
|
||||
async updateFilter(filter) {
|
||||
this.filter = { ...this.filter, ...filter };
|
||||
await this.loadJobs();
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime un job par son ID
|
||||
* @param {string} id
|
||||
*/
|
||||
async deleteJob(id) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
await jobRepository.deleteJob(id);
|
||||
// Supprimer le job de la liste locale
|
||||
this.jobs = this.jobs.filter(job => job.id !== id);
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
console.error('Error deleting job:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs correspondant aux critères
|
||||
* @param {Object} criteria
|
||||
*/
|
||||
async deleteJobs(criteria = {}) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const deleted = await jobRepository.deleteJobs(criteria);
|
||||
// Recharger la liste après suppression
|
||||
await this.loadJobs();
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
console.error('Error deleting jobs:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs terminés
|
||||
*/
|
||||
async deleteCompletedJobs() {
|
||||
return this.deleteJobs({ status: ['COMPLETED'] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs en erreur
|
||||
*/
|
||||
async deleteFailedJobs() {
|
||||
return this.deleteJobs({ status: ['ERROR'] });
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs
|
||||
*/
|
||||
async deleteAllJobs() {
|
||||
return this.deleteJobs({});
|
||||
}
|
||||
}
|
||||
});
|
||||
50
assets/vue/app/domain/activity/domain/entities/job.js
Normal file
50
assets/vue/app/domain/activity/domain/entities/job.js
Normal file
@@ -0,0 +1,50 @@
|
||||
export class Job {
|
||||
constructor({
|
||||
id,
|
||||
type,
|
||||
status,
|
||||
progress = 0,
|
||||
payload = {},
|
||||
result = null,
|
||||
error = null,
|
||||
createdAt = new Date().toISOString(),
|
||||
updatedAt = new Date().toISOString()
|
||||
}) {
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.status = status;
|
||||
this.progress = progress;
|
||||
this.payload = payload;
|
||||
this.result = result;
|
||||
this.error = error;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
static create(data) {
|
||||
return new Job(data);
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return ['PENDING', 'IN_PROGRESS'].includes(this.status);
|
||||
}
|
||||
|
||||
hasError() {
|
||||
return this.status === 'ERROR';
|
||||
}
|
||||
|
||||
isCompleted() {
|
||||
return this.status === 'COMPLETED';
|
||||
}
|
||||
}
|
||||
|
||||
export class JobCollection {
|
||||
constructor(items, total, page, limit, hasNextPage, hasPreviousPage) {
|
||||
this.items = items.map(item => Job.create(item));
|
||||
this.total = total;
|
||||
this.page = page;
|
||||
this.limit = limit;
|
||||
this.hasNextPage = hasNextPage;
|
||||
this.hasPreviousPage = hasPreviousPage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export class JobRepositoryInterface {
|
||||
/**
|
||||
* Récupère la liste des jobs
|
||||
* @param {Object} options Les options de filtrage et pagination
|
||||
* @param {number} options.page Numéro de la page
|
||||
* @param {number} options.limit Nombre d'éléments par page
|
||||
* @param {string} options.sortBy Champ pour le tri
|
||||
* @param {string} options.sortOrder Direction du tri ('ASC' ou 'DESC')
|
||||
* @param {Array<string>} options.status Liste des statuts à filtrer
|
||||
* @returns {Promise<JobCollection>} Collection de jobs
|
||||
*/
|
||||
async getJobs(options) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un job par son ID
|
||||
* @param {string} id Identifiant du job
|
||||
* @returns {Promise<Job>} Job
|
||||
*/
|
||||
async getJobById(id) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un job
|
||||
* @param {string} id Identifiant du job
|
||||
* @returns {Promise<boolean>} Succès de l'opération
|
||||
*/
|
||||
async deleteJob(id) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs correspondant aux critères
|
||||
* @param {Object} criteria Critères de suppression
|
||||
* @returns {Promise<number>} Nombre de jobs supprimés
|
||||
*/
|
||||
async deleteJobs(criteria) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Job, JobCollection } from '../../domain/entities/job';
|
||||
import { JobRepositoryInterface } from '../../domain/repository/JobRepositoryInterface';
|
||||
|
||||
export class ApiJobRepository extends JobRepositoryInterface {
|
||||
/**
|
||||
* Récupère la liste des jobs
|
||||
* @param {Object} options Les options de filtrage et pagination
|
||||
* @param {number} options.page Numéro de la page
|
||||
* @param {number} options.limit Nombre d'éléments par page
|
||||
* @param {string} options.sortBy Champ pour le tri
|
||||
* @param {string} options.sortOrder Direction du tri ('ASC' ou 'DESC')
|
||||
* @param {Array<string>} options.status Liste des statuts à filtrer
|
||||
* @returns {Promise<JobCollection>} Collection de jobs
|
||||
*/
|
||||
async getJobs(options = {}) {
|
||||
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [] } = options;
|
||||
|
||||
try {
|
||||
let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
||||
|
||||
// Ajouter les filtres de statut s'ils sont fournis
|
||||
if (status && status.length > 0) {
|
||||
url += `&status=${status.join(',')}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch jobs');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return new JobCollection(
|
||||
data.items || [],
|
||||
data.total || 0,
|
||||
data.page || 1,
|
||||
data.limit || limit,
|
||||
!!data.hasNextPage,
|
||||
!!data.hasPreviousPage
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un job par son ID
|
||||
* @param {string} id Identifiant du job
|
||||
* @returns {Promise<Job>} Job
|
||||
*/
|
||||
async getJobById(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/jobs/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch job');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return Job.create(data);
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un job
|
||||
* @param {string} id Identifiant du job
|
||||
* @returns {Promise<boolean>} Succès de l'opération
|
||||
*/
|
||||
async deleteJob(id) {
|
||||
try {
|
||||
const response = await fetch(`/api/jobs/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete job');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime tous les jobs correspondant aux critères
|
||||
* @param {Object} criteria Critères de suppression
|
||||
* @returns {Promise<number>} Nombre de jobs supprimés
|
||||
*/
|
||||
async deleteJobs(criteria = {}) {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
// Ajouter les critères à l'URL
|
||||
Object.entries(criteria).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
queryParams.append(key, value.join(','));
|
||||
} else {
|
||||
queryParams.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/jobs?${queryParams.toString()}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete jobs');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.deleted || 0;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out"
|
||||
:class="{
|
||||
'bg-yellow-50': job.status === 'PENDING',
|
||||
'bg-blue-50': job.status === 'IN_PROGRESS',
|
||||
'bg-green-50': job.status === 'COMPLETED',
|
||||
'bg-red-50': job.status === 'ERROR'
|
||||
}">
|
||||
<td class="py-4 px-4 text-center">
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" />
|
||||
</td>
|
||||
<td class="py-4 px-4 font-medium">{{ job.type }}</td>
|
||||
<td class="py-4 px-4">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-800': job.status === 'pending',
|
||||
'bg-blue-100 text-blue-800': job.status === 'in_progress',
|
||||
'bg-green-100 text-green-800': job.status === 'completed',
|
||||
'bg-red-100 text-red-800': job.status === 'failed'
|
||||
}">
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div v-if="job.error" class="text-sm text-red-600">
|
||||
{{ job.error }}
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-600">
|
||||
{{ formatDate(job.createdAt) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<div v-if="job.status === 'IN_PROGRESS'" class="mt-2">
|
||||
<div class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||
:style="{ width: `${job.progress}%` }"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
|
||||
{{ job.progress }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'COMPLETED'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
|
||||
style="width: 100%"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
|
||||
100%
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="job.status === 'ERROR'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-red-400 transition-all duration-300 ease-out"
|
||||
style="width: 100%"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
|
||||
Erreur
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-yellow-400 transition-all duration-300 ease-out"
|
||||
style="width: 0%"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600">
|
||||
En attente
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<button
|
||||
@click="onDelete"
|
||||
class="text-red-500 hover:text-red-700 transition duration-150 ease-in-out"
|
||||
title="Supprimer">
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { TrashIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const props = defineProps({
|
||||
job: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
emit('delete', props.job.id);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="mb-6" />
|
||||
|
||||
<div v-if="activityStore.loading" class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activityStore.error" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
|
||||
<p>{{ activityStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="container mx-auto p-2">
|
||||
<div class="bg-white overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white">
|
||||
<thead>
|
||||
<tr class="bg-gray-200 text-gray-800">
|
||||
<th class="w-1/12 py-3 px-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox h-5 w-5 text-green-600"
|
||||
@change="toggleSelectAll" />
|
||||
</th>
|
||||
<th class="w-2/12 py-3 px-4 text-left">Type</th>
|
||||
<th class="w-2/12 py-3 px-4 text-left">Statut</th>
|
||||
<th class="w-3/12 py-3 px-4 text-left">Informations</th>
|
||||
<th class="w-3/12 py-3 px-4 text-left">Progression</th>
|
||||
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-700">
|
||||
<template v-if="activityStore.jobs.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="py-4 px-4 text-center text-gray-500"
|
||||
>Aucune activité trouvée avec les filtres actuels.</td
|
||||
>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<JobItem
|
||||
v-for="job in activityStore.jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
@delete="deleteJob" />
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { useActivityStore } from '../../application/store/activityStore';
|
||||
import JobItem from '../components/JobItem.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { ArrowPathIcon, TrashIcon, FunnelIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const activityStore = useActivityStore();
|
||||
const selectedAll = ref(false);
|
||||
|
||||
// Statuts disponibles pour le filtre
|
||||
const statusOptions = [
|
||||
{ value: ['PENDING', 'IN_PROGRESS'], label: 'Actifs' },
|
||||
{ value: ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'ERROR'], label: 'Tous' },
|
||||
{ value: ['COMPLETED'], label: 'Terminés' },
|
||||
{ value: ['ERROR'], label: 'En erreur' }
|
||||
];
|
||||
|
||||
// Index du statut actif
|
||||
const activeStatusIndex = ref(0);
|
||||
|
||||
// Rendre toolbarConfig computed pour que le label du dropdown soit réactif
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{
|
||||
icon: FunnelIcon,
|
||||
type: 'dropdown',
|
||||
label: statusOptions[activeStatusIndex.value].label,
|
||||
active: false,
|
||||
items: statusOptions.map((option, index) => ({
|
||||
label: option.label,
|
||||
isSelected: index === activeStatusIndex.value,
|
||||
onClick: () => setStatusFilter(index)
|
||||
}))
|
||||
}
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
icon: ArrowPathIcon,
|
||||
type: 'button',
|
||||
label: 'Rafraîchir',
|
||||
onClick: refreshJobs
|
||||
},
|
||||
{
|
||||
icon: TrashIcon,
|
||||
type: 'button',
|
||||
label: 'Supprimer',
|
||||
onClick: deleteVisibleJobs
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
loadJobs();
|
||||
});
|
||||
|
||||
function loadJobs() {
|
||||
activityStore.loadJobs();
|
||||
}
|
||||
|
||||
function refreshJobs() {
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
selectedAll.value = !selectedAll.value;
|
||||
// La logique pour sélectionner tous les jobs serait ajoutée ici
|
||||
}
|
||||
|
||||
function setStatusFilter(index) {
|
||||
if (index >= 0 && index < statusOptions.length) {
|
||||
activeStatusIndex.value = index;
|
||||
activityStore.updateFilter({ status: statusOptions[index].value });
|
||||
}
|
||||
}
|
||||
|
||||
function deleteJob(id) {
|
||||
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
||||
activityStore.deleteJob(id);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteVisibleJobs() {
|
||||
if (confirm('Voulez-vous vraiment supprimer tous les jobs affichés ?')) {
|
||||
activityStore.deleteJobs({ status: activityStore.filter.status });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,60 +1,85 @@
|
||||
<template>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="container mx-auto px-4">
|
||||
<MangaGrid :mangas="collection?.items || []" />
|
||||
<div v-if="isBackgroundLoading" class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Mise à jour en cours...
|
||||
</div>
|
||||
<div>
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
<div class="container mx-auto px-4">
|
||||
<MangaGrid :mangas="collection?.items || []" />
|
||||
<div
|
||||
v-if="isBackgroundLoading"
|
||||
class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Mise à jour en cours...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaGrid from '../components/MangaGrid.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
Cog6ToothIcon,
|
||||
EyeIcon,
|
||||
ArrowsUpDownIcon,
|
||||
FunnelIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaGrid from '../components/MangaGrid.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
Cog6ToothIcon,
|
||||
EyeIcon,
|
||||
ArrowsUpDownIcon,
|
||||
FunnelIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
|
||||
const {
|
||||
collection,
|
||||
loading,
|
||||
error,
|
||||
isBackgroundLoading
|
||||
} = storeToRefs(mangaStore);
|
||||
const { collection, loading, error, isBackgroundLoading } = storeToRefs(mangaStore);
|
||||
|
||||
onMounted(() => {
|
||||
mangaStore.loadCollection();
|
||||
});
|
||||
onMounted(() => {
|
||||
mangaStore.loadCollection();
|
||||
});
|
||||
|
||||
const toolbarConfig = {
|
||||
leftSection: [
|
||||
{
|
||||
icon: ArrowPathIcon,
|
||||
label: 'Refresh',
|
||||
onClick: () => mangaStore.refreshCollectionInBackground(),
|
||||
active: isBackgroundLoading
|
||||
},
|
||||
{ icon: MagnifyingGlassIcon, label: 'Search', onClick: () => {} }
|
||||
],
|
||||
rightSection: [
|
||||
{ icon: Cog6ToothIcon, onClick: () => {} },
|
||||
{ icon: EyeIcon, onClick: () => {} },
|
||||
{ icon: ArrowsUpDownIcon, onClick: () => {} },
|
||||
{ icon: FunnelIcon, onClick: () => {} }
|
||||
]
|
||||
};
|
||||
const toolbarConfig = {
|
||||
leftSection: [
|
||||
{
|
||||
icon: ArrowPathIcon,
|
||||
label: 'Refresh',
|
||||
type: 'button',
|
||||
onClick: () => mangaStore.refreshCollectionInBackground(),
|
||||
active: isBackgroundLoading
|
||||
},
|
||||
{ icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} }
|
||||
],
|
||||
rightSection: [
|
||||
{ icon: Cog6ToothIcon, type: 'button', onClick: () => {} },
|
||||
{
|
||||
icon: EyeIcon,
|
||||
type: 'dropdown',
|
||||
label: 'View',
|
||||
items: [
|
||||
{ label: 'List', onClick: () => {} },
|
||||
{ label: 'Grid', onClick: () => {} }
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: ArrowsUpDownIcon,
|
||||
type: 'dropdown',
|
||||
label: 'Sort',
|
||||
items: [
|
||||
{ label: 'Title', onClick: () => {} },
|
||||
{ label: 'Author', onClick: () => {} },
|
||||
{ label: 'Status', onClick: () => {} },
|
||||
{ label: 'Year', onClick: () => {} }
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: FunnelIcon,
|
||||
type: 'dropdown',
|
||||
label: 'Filter',
|
||||
items: [
|
||||
{ label: 'All', onClick: () => {} },
|
||||
{ label: 'Completed', onClick: () => {} },
|
||||
{ label: 'In Progress', onClick: () => {} }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Layout from '../shared/components/layout/Layout.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';
|
||||
|
||||
// Placeholder component for new routes
|
||||
const PlaceholderComponent = {
|
||||
@@ -80,8 +81,7 @@ const routes = [
|
||||
{
|
||||
path: '/activity',
|
||||
name: 'activity',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Activité' }
|
||||
component: ActivityPage
|
||||
},
|
||||
// Paramètres
|
||||
{
|
||||
|
||||
7
assets/vue/app/shared/components/ui/Divider.vue
Normal file
7
assets/vue/app/shared/components/ui/Divider.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-14 mx-4 border-r opacity-50 border-green-500"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Pas de script nécessaire pour ce composant purement visuel
|
||||
</script>
|
||||
@@ -1,47 +1,26 @@
|
||||
<template>
|
||||
<div :class="['bg-white shadow-sm px-4 py-2', $attrs.class]">
|
||||
<div class="container mx-auto flex justify-between items-center">
|
||||
<!-- Left section -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
v-for="(item, index) in config.leftSection"
|
||||
:key="index"
|
||||
@click="item.onClick"
|
||||
:class="[
|
||||
'p-2 rounded-lg transition-colors',
|
||||
item.active
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'hover:bg-gray-100'
|
||||
]"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5" />
|
||||
<span v-if="item.label" class="ml-2">{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="['bg-gray-800 p-3 min-h-14', $attrs.class]">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<!-- Left section -->
|
||||
<ToolbarSection :items="config.leftSection" />
|
||||
|
||||
<!-- Right section -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
v-for="(item, index) in config.rightSection"
|
||||
:key="index"
|
||||
@click="item.onClick"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Right section -->
|
||||
<ToolbarSection :items="config.rightSection" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: (value) => {
|
||||
return value.leftSection && value.rightSection;
|
||||
}
|
||||
}
|
||||
});
|
||||
import ToolbarSection from './ToolbarSection.vue';
|
||||
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator: value => {
|
||||
// Vérifie que leftSection et rightSection sont des tableaux
|
||||
return Array.isArray(value.leftSection) && Array.isArray(value.rightSection);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
37
assets/vue/app/shared/components/ui/ToolbarButton.vue
Normal file
37
assets/vue/app/shared/components/ui/ToolbarButton.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<button
|
||||
@click="$emit('click')"
|
||||
:class="[
|
||||
'flex flex-col items-center justify-center p-1 rounded group text-white min-w-[50px]',
|
||||
active
|
||||
? 'text-green-500' // Style actif
|
||||
: 'hover:text-green-500' // Effet de survol
|
||||
]"
|
||||
:aria-label="label || 'Toolbar button'">
|
||||
<component v-if="icon" :is="icon" class="h-6 w-6 mb-1" />
|
||||
<span v-if="label" class="text-xs">{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: [Object, Function], // Heroicons sont souvent importés comme des objets/fonctions
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['click']);
|
||||
</script>
|
||||
67
assets/vue/app/shared/components/ui/ToolbarDropdown.vue
Normal file
67
assets/vue/app/shared/components/ui/ToolbarDropdown.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<Menu as="div" class="relative inline-block">
|
||||
<div>
|
||||
<MenuButton
|
||||
:class="[
|
||||
'flex flex-col items-center justify-center p-1 rounded group text-white w-20 hover:text-green-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75',
|
||||
active ? 'text-green-500' : ''
|
||||
]"
|
||||
:aria-label="label || 'Options'">
|
||||
<component v-if="icon" :is="icon" class="h-6 w-6 mb-1" aria-hidden="true" />
|
||||
<span v-if="label" class="text-xs truncate w-full px-1">{{ label }}</span>
|
||||
</MenuButton>
|
||||
</div>
|
||||
|
||||
<MenuItems
|
||||
class="absolute left-0 mt-2 w-max origin-top-left rounded-sm bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
|
||||
<div class="px-1 py-1">
|
||||
<MenuItem v-for="(item, index) in items" :key="index" v-slot="{ active }" :disabled="item.disabled">
|
||||
<button
|
||||
:class="[
|
||||
item.isSelected ? 'text-green-500' : active ? 'text-green-500' : 'text-white',
|
||||
'group flex w-full items-center rounded-sm px-3 py-2 text-sm',
|
||||
item.disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
]"
|
||||
@click="item.onClick">
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue';
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: [Object, Function],
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
validator: items => {
|
||||
return items.every(
|
||||
item =>
|
||||
item &&
|
||||
item.label &&
|
||||
typeof item.onClick === 'function' &&
|
||||
(item.isSelected === undefined || typeof item.isSelected === 'boolean')
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
44
assets/vue/app/shared/components/ui/ToolbarSection.vue
Normal file
44
assets/vue/app/shared/components/ui/ToolbarSection.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<ToolbarButton
|
||||
v-if="item.type === 'button'"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:active="item.active"
|
||||
@click="item.onClick" />
|
||||
<ToolbarDropdown
|
||||
v-else-if="item.type === 'dropdown'"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:active="item.active"
|
||||
:items="item.items" />
|
||||
<Divider v-else-if="item.type === 'divider'" />
|
||||
<!-- Ajoutez d'autres types d'éléments ici si nécessaire -->
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ToolbarButton from './ToolbarButton.vue';
|
||||
import ToolbarDropdown from './ToolbarDropdown.vue';
|
||||
import Divider from './Divider.vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
validator: items => {
|
||||
return items.every(
|
||||
item =>
|
||||
item &&
|
||||
item.type &&
|
||||
(item.type === 'button' ||
|
||||
item.type === 'divider' ||
|
||||
(item.type === 'dropdown' && Array.isArray(item.items)))
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -12,6 +12,347 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/jobs": {
|
||||
"get": {
|
||||
"operationId": "api_jobs_get_collection",
|
||||
"tags": [
|
||||
"Job"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Job collection",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Job"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/ld+json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hydra:member": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Job.jsonld"
|
||||
}
|
||||
},
|
||||
"hydra:totalItems": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"hydra:view": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"@id": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
},
|
||||
"@type": {
|
||||
"type": "string"
|
||||
},
|
||||
"hydra:first": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
},
|
||||
"hydra:last": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
},
|
||||
"hydra:previous": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
},
|
||||
"hydra:next": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"@id": "string",
|
||||
"type": "string",
|
||||
"hydra:first": "string",
|
||||
"hydra:last": "string",
|
||||
"hydra:previous": "string",
|
||||
"hydra:next": "string"
|
||||
}
|
||||
},
|
||||
"hydra:search": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"@type": {
|
||||
"type": "string"
|
||||
},
|
||||
"hydra:template": {
|
||||
"type": "string"
|
||||
},
|
||||
"hydra:variableRepresentation": {
|
||||
"type": "string"
|
||||
},
|
||||
"hydra:mapping": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"@type": {
|
||||
"type": "string"
|
||||
},
|
||||
"variable": {
|
||||
"type": "string"
|
||||
},
|
||||
"property": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hydra:member"
|
||||
]
|
||||
}
|
||||
},
|
||||
"text/html": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Job"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/hal+json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_embedded": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Job.jsonhal"
|
||||
}
|
||||
},
|
||||
"totalItems": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"_links": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"self": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"href": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
}
|
||||
}
|
||||
},
|
||||
"first": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"href": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
}
|
||||
}
|
||||
},
|
||||
"last": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"href": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
}
|
||||
}
|
||||
},
|
||||
"next": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"href": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
}
|
||||
}
|
||||
},
|
||||
"previous": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"href": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"_links",
|
||||
"_embedded"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "Retrieves the collection of Job resources.",
|
||||
"description": "Retrieves the collection of Job resources.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "status",
|
||||
"in": "query",
|
||||
"description": "Filtrer par status",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"allowEmptyValue": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"failed"
|
||||
],
|
||||
"example": "pending"
|
||||
},
|
||||
"style": "form",
|
||||
"explode": false,
|
||||
"allowReserved": false
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"description": "Filtrer par type de job (ex: scraping_job)",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"allowEmptyValue": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"style": "form",
|
||||
"explode": false,
|
||||
"allowReserved": false
|
||||
},
|
||||
{
|
||||
"name": "createdAfter",
|
||||
"in": "query",
|
||||
"description": "Date de cr\u00e9ation minimum (format ISO8601)",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"allowEmptyValue": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"style": "form",
|
||||
"explode": false,
|
||||
"allowReserved": false
|
||||
},
|
||||
{
|
||||
"name": "createdBefore",
|
||||
"in": "query",
|
||||
"description": "Date de cr\u00e9ation maximum (format ISO8601)",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"allowEmptyValue": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"style": "form",
|
||||
"explode": false,
|
||||
"allowReserved": false
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "Num\u00e9ro de la page",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"allowEmptyValue": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"minimum": 1
|
||||
},
|
||||
"style": "form",
|
||||
"explode": false,
|
||||
"allowReserved": false
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "Nombre d'\u00e9l\u00e9ments par page",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"allowEmptyValue": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"minimum": 1
|
||||
},
|
||||
"style": "form",
|
||||
"explode": false,
|
||||
"allowReserved": false
|
||||
},
|
||||
{
|
||||
"name": "sortBy",
|
||||
"in": "query",
|
||||
"description": "Champ de tri",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"allowEmptyValue": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"createdAt",
|
||||
"type",
|
||||
"status"
|
||||
],
|
||||
"default": "createdAt"
|
||||
},
|
||||
"style": "form",
|
||||
"explode": false,
|
||||
"allowReserved": false
|
||||
},
|
||||
{
|
||||
"name": "sortOrder",
|
||||
"in": "query",
|
||||
"description": "Ordre de tri",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"allowEmptyValue": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ASC",
|
||||
"DESC"
|
||||
],
|
||||
"default": "DESC"
|
||||
},
|
||||
"style": "form",
|
||||
"explode": false,
|
||||
"allowReserved": false
|
||||
}
|
||||
],
|
||||
"deprecated": false
|
||||
},
|
||||
"parameters": []
|
||||
},
|
||||
"/api/manga/chapters/fetch": {
|
||||
"post": {
|
||||
"operationId": "api_mangachaptersfetch_post",
|
||||
@@ -596,30 +937,24 @@
|
||||
"description": "Creates a new manga by fetching its data from Mangadex using an external ID",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"description": "The new Manga resource",
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Manga"
|
||||
}
|
||||
},
|
||||
"application/ld+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Manga.jsonld"
|
||||
}
|
||||
},
|
||||
"text/html": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Manga"
|
||||
}
|
||||
},
|
||||
"application/hal+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Manga.jsonhal"
|
||||
"type": "object",
|
||||
"required": [
|
||||
"externalId"
|
||||
],
|
||||
"properties": {
|
||||
"externalId": {
|
||||
"type": "string",
|
||||
"description": "The Mangadex ID of the manga"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
"required": false
|
||||
},
|
||||
"deprecated": false
|
||||
},
|
||||
@@ -1177,64 +1512,6 @@
|
||||
"deprecated": false
|
||||
},
|
||||
"parameters": []
|
||||
},
|
||||
"/api/scraping/jobs/{jobId}/status": {
|
||||
"get": {
|
||||
"operationId": "api_scrapingjobs_jobIdstatus_get",
|
||||
"tags": [
|
||||
"Scraping"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Scraping resource",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Scraping"
|
||||
}
|
||||
},
|
||||
"application/ld+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Scraping.jsonld"
|
||||
}
|
||||
},
|
||||
"text/html": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Scraping"
|
||||
}
|
||||
},
|
||||
"application/hal+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Scraping.jsonhal"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Resource not found"
|
||||
}
|
||||
},
|
||||
"summary": "Retrieves a Scraping resource.",
|
||||
"description": "Retrieves a Scraping resource.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "jobId",
|
||||
"in": "path",
|
||||
"description": "ScrapingStatusResponse identifier",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"allowEmptyValue": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"style": "simple",
|
||||
"explode": false,
|
||||
"allowReserved": false
|
||||
}
|
||||
],
|
||||
"deprecated": false
|
||||
},
|
||||
"parameters": []
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -1265,6 +1542,9 @@
|
||||
"isVisible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isAvailable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1296,6 +1576,9 @@
|
||||
"isVisible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isAvailable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1362,11 +1645,264 @@
|
||||
"isVisible": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isAvailable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Job": {
|
||||
"type": "object",
|
||||
"description": "Liste des jobs",
|
||||
"deprecated": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Identifiant unique du job",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type du job (ex: scraping_job)",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "Status du job",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled"
|
||||
]
|
||||
},
|
||||
"createdAt": {
|
||||
"description": "Date de cr\u00e9ation du job",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"startedAt": {
|
||||
"description": "Date de d\u00e9but d'ex\u00e9cution du job",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"completedAt": {
|
||||
"description": "Date de fin d'ex\u00e9cution du job",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"failureReason": {
|
||||
"description": "Raison de l'\u00e9chec si le job a \u00e9chou\u00e9",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"attempts": {
|
||||
"minimum": 0,
|
||||
"description": "Nombre de tentatives effectu\u00e9es",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxAttempts": {
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Nombre maximum de tentatives autoris\u00e9es",
|
||||
"type": "integer"
|
||||
},
|
||||
"context": {
|
||||
"description": "Donn\u00e9es contextuelles du job",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"status",
|
||||
"createdAt"
|
||||
]
|
||||
},
|
||||
"Job.jsonhal": {
|
||||
"type": "object",
|
||||
"description": "Liste des jobs",
|
||||
"deprecated": false,
|
||||
"properties": {
|
||||
"_links": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"self": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"href": {
|
||||
"type": "string",
|
||||
"format": "iri-reference"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"description": "Identifiant unique du job",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type du job (ex: scraping_job)",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "Status du job",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled"
|
||||
]
|
||||
},
|
||||
"createdAt": {
|
||||
"description": "Date de cr\u00e9ation du job",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"startedAt": {
|
||||
"description": "Date de d\u00e9but d'ex\u00e9cution du job",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"completedAt": {
|
||||
"description": "Date de fin d'ex\u00e9cution du job",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"failureReason": {
|
||||
"description": "Raison de l'\u00e9chec si le job a \u00e9chou\u00e9",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"attempts": {
|
||||
"minimum": 0,
|
||||
"description": "Nombre de tentatives effectu\u00e9es",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxAttempts": {
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Nombre maximum de tentatives autoris\u00e9es",
|
||||
"type": "integer"
|
||||
},
|
||||
"context": {
|
||||
"description": "Donn\u00e9es contextuelles du job",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"status",
|
||||
"createdAt"
|
||||
]
|
||||
},
|
||||
"Job.jsonld": {
|
||||
"type": "object",
|
||||
"description": "Liste des jobs",
|
||||
"deprecated": false,
|
||||
"properties": {
|
||||
"@id": {
|
||||
"readOnly": true,
|
||||
"type": "string"
|
||||
},
|
||||
"@type": {
|
||||
"readOnly": true,
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Identifiant unique du job",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "Type du job (ex: scraping_job)",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "Status du job",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled"
|
||||
]
|
||||
},
|
||||
"createdAt": {
|
||||
"description": "Date de cr\u00e9ation du job",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"startedAt": {
|
||||
"description": "Date de d\u00e9but d'ex\u00e9cution du job",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"completedAt": {
|
||||
"description": "Date de fin d'ex\u00e9cution du job",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"failureReason": {
|
||||
"description": "Raison de l'\u00e9chec si le job a \u00e9chou\u00e9",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"attempts": {
|
||||
"minimum": 0,
|
||||
"description": "Nombre de tentatives effectu\u00e9es",
|
||||
"type": "integer"
|
||||
},
|
||||
"maxAttempts": {
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Nombre maximum de tentatives autoris\u00e9es",
|
||||
"type": "integer"
|
||||
},
|
||||
"context": {
|
||||
"description": "Donn\u00e9es contextuelles du job",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"status",
|
||||
"createdAt"
|
||||
]
|
||||
},
|
||||
"Manga": {
|
||||
"type": "object",
|
||||
"description": "",
|
||||
@@ -2284,6 +2820,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"thumbnailUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2334,6 +2876,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"thumbnailUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2419,6 +2967,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"thumbnailUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -9,8 +9,11 @@ use App\Domain\Shared\Domain\Model\JobStatus;
|
||||
|
||||
readonly class ListJobsQuery implements QueryInterface
|
||||
{
|
||||
/**
|
||||
* @param JobStatus[] $statuses
|
||||
*/
|
||||
public function __construct(
|
||||
public ?JobStatus $status = null,
|
||||
public ?array $statuses = null,
|
||||
public ?string $type = null,
|
||||
public ?\DateTimeImmutable $createdAfter = null,
|
||||
public ?\DateTimeImmutable $createdBefore = null,
|
||||
|
||||
@@ -28,7 +28,7 @@ readonly class ListJobsQueryHandler implements QueryHandlerInterface
|
||||
}
|
||||
|
||||
$criteria = [
|
||||
'status' => $query->status,
|
||||
'statuses' => $query->statuses,
|
||||
'type' => $query->type,
|
||||
'createdAfter' => $query->createdAfter,
|
||||
'createdBefore' => $query->createdBefore,
|
||||
|
||||
@@ -18,6 +18,7 @@ interface JobRepositoryInterface
|
||||
/**
|
||||
* @param array{
|
||||
* status?: ?JobStatus,
|
||||
* statuses?: ?array<JobStatus>,
|
||||
* type?: ?string,
|
||||
* createdAfter?: ?\DateTimeImmutable,
|
||||
* createdBefore?: ?\DateTimeImmutable,
|
||||
@@ -33,6 +34,7 @@ interface JobRepositoryInterface
|
||||
/**
|
||||
* @param array{
|
||||
* status?: ?JobStatus,
|
||||
* statuses?: ?array<JobStatus>,
|
||||
* type?: ?string,
|
||||
* createdAfter?: ?\DateTimeImmutable,
|
||||
* createdBefore?: ?\DateTimeImmutable
|
||||
|
||||
@@ -25,13 +25,26 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
[
|
||||
'name' => 'status',
|
||||
'in' => 'query',
|
||||
'description' => 'Filtrer par status',
|
||||
'description' => 'Filtrer par status (séparés par des virgules pour plusieurs statuts, ex: "pending,in_progress" ou sous forme de tableau avec "status[]=")',
|
||||
'required' => false,
|
||||
'schema' => [
|
||||
'type' => 'string',
|
||||
'enum' => ['pending', 'in_progress', 'completed', 'failed'],
|
||||
'example' => 'pending'
|
||||
]
|
||||
'oneOf' => [
|
||||
[
|
||||
'type' => 'string',
|
||||
'example' => 'pending,in_progress'
|
||||
],
|
||||
[
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'string',
|
||||
'enum' => ['pending', 'in_progress', 'completed', 'failed', 'cancelled']
|
||||
],
|
||||
'example' => ['pending', 'in_progress']
|
||||
]
|
||||
]
|
||||
],
|
||||
'style' => 'form',
|
||||
'explode' => false
|
||||
],
|
||||
[
|
||||
'name' => 'type',
|
||||
@@ -108,10 +121,7 @@ class GetJobListResource
|
||||
#[Assert\NotBlank]
|
||||
public readonly string $type,
|
||||
|
||||
#[ApiProperty(
|
||||
description: 'Status du job',
|
||||
openapiContext: ['enum' => ['pending', 'in_progress', 'completed', 'failed', 'cancelled']]
|
||||
)]
|
||||
#[ApiProperty(description: 'Status du job')]
|
||||
#[Assert\NotBlank]
|
||||
public readonly string $status,
|
||||
|
||||
|
||||
@@ -21,8 +21,34 @@ readonly class GetJobListStateProvider implements ProviderInterface
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
$statuses = null;
|
||||
if (isset($filters['status'])) {
|
||||
if (is_array($filters['status'])) {
|
||||
// Cas où status est passé en format tableau: status[]=pending&status[]=in_progress
|
||||
$statusValues = $filters['status'];
|
||||
} else {
|
||||
// Cas où status est passé comme une chaîne séparée par des virgules: status=pending,in_progress
|
||||
$statusValues = explode(',', $filters['status']);
|
||||
}
|
||||
|
||||
$statuses = [];
|
||||
foreach ($statusValues as $statusValue) {
|
||||
try {
|
||||
$statuses[] = JobStatus::from($statusValue);
|
||||
} catch (\ValueError $e) {
|
||||
// Ignorer les valeurs invalides
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Si aucun statut valide n'est trouvé, on met $statuses à null
|
||||
if (empty($statuses)) {
|
||||
$statuses = null;
|
||||
}
|
||||
}
|
||||
|
||||
$query = new ListJobsQuery(
|
||||
status: isset($filters['status']) ? JobStatus::from($filters['status']) : null,
|
||||
statuses: $statuses,
|
||||
type: $filters['type'] ?? null,
|
||||
createdAfter: isset($filters['createdAfter']) ? new \DateTimeImmutable($filters['createdAfter']) : null,
|
||||
createdBefore: isset($filters['createdBefore']) ? new \DateTimeImmutable($filters['createdBefore']) : null,
|
||||
|
||||
@@ -112,7 +112,15 @@ readonly class DoctrineJobRepository implements JobRepositoryInterface
|
||||
->select('j')
|
||||
->from(JobEntity::class, 'j');
|
||||
|
||||
if (isset($criteria['status'])) {
|
||||
if (isset($criteria['statuses']) && is_array($criteria['statuses']) && !empty($criteria['statuses'])) {
|
||||
$expr = $qb->expr()->orX();
|
||||
foreach ($criteria['statuses'] as $key => $status) {
|
||||
$paramName = 'status' . $key;
|
||||
$expr->add($qb->expr()->eq('j.status', ':' . $paramName));
|
||||
$qb->setParameter($paramName, $status->value);
|
||||
}
|
||||
$qb->andWhere($expr);
|
||||
} elseif (isset($criteria['status'])) {
|
||||
$qb->andWhere('j.status = :status')
|
||||
->setParameter('status', $criteria['status']->value);
|
||||
}
|
||||
@@ -155,7 +163,15 @@ readonly class DoctrineJobRepository implements JobRepositoryInterface
|
||||
->select('COUNT(j.id)')
|
||||
->from(JobEntity::class, 'j');
|
||||
|
||||
if (isset($criteria['status'])) {
|
||||
if (isset($criteria['statuses']) && is_array($criteria['statuses']) && !empty($criteria['statuses'])) {
|
||||
$expr = $qb->expr()->orX();
|
||||
foreach ($criteria['statuses'] as $key => $status) {
|
||||
$paramName = 'status' . $key;
|
||||
$expr->add($qb->expr()->eq('j.status', ':' . $paramName));
|
||||
$qb->setParameter($paramName, $status->value);
|
||||
}
|
||||
$qb->andWhere($expr);
|
||||
} elseif (isset($criteria['status'])) {
|
||||
$qb->andWhere('j.status = :status')
|
||||
->setParameter('status', $criteria['status']->value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user