Compare commits
1 Commits
cde701986e
...
feat/disco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420c3922c2 |
@@ -1,7 +1,7 @@
|
||||
#syntax=docker/dockerfile:1.4
|
||||
|
||||
# Versions
|
||||
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
||||
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream
|
||||
|
||||
# The different stages of this Dockerfile are meant to be built into separate images
|
||||
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
|
||||
|
||||
17
assets/app.js
Normal file
17
assets/app.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import './bootstrap.js';
|
||||
|
||||
import '@fortawesome/fontawesome-free/js/all.js';
|
||||
/*
|
||||
* Welcome to your app's main JavaScript file!
|
||||
*
|
||||
* We recommend including the built version of this JavaScript file
|
||||
* (and its CSS file) in your base layout (base.html.twig).
|
||||
*/
|
||||
|
||||
// any CSS you import will output into a single css file (app.css in this case)
|
||||
import './styles/app.scss';
|
||||
|
||||
// start the Stimulus application
|
||||
import './bootstrap';
|
||||
|
||||
// La ligne registerReactControllerComponents a déjà été commentée
|
||||
35
assets/bootstrap.js
vendored
Normal file
35
assets/bootstrap.js
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
import { startStimulusApp } from '@symfony/stimulus-bridge';
|
||||
|
||||
// Registers Stimulus controllers from controllers.json and in the controllers/ directory
|
||||
export const app = startStimulusApp(require.context(
|
||||
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
|
||||
true,
|
||||
/\.[jt]sx?$/
|
||||
));
|
||||
|
||||
// register any custom, 3rd party controllers here
|
||||
// app.register('some_controller_name', SomeImportedController);
|
||||
|
||||
//DEBUG TURBO
|
||||
// import * as Turbo from "@hotwired/turbo"
|
||||
//
|
||||
// Turbo.session.drive = false
|
||||
// Turbo.start()
|
||||
//
|
||||
// // Écouteurs existants
|
||||
// document.addEventListener("turbo:before-stream-render", (event) => {
|
||||
// console.log("Before stream render", event.target);
|
||||
// });
|
||||
//
|
||||
// document.addEventListener("turbo:stream-render", (event) => {
|
||||
// console.log("Stream rendered", event.target);
|
||||
// });
|
||||
//
|
||||
// // Nouvel écouteur pour les événements de création
|
||||
// document.addEventListener("turbo:before-fetch-request", (event) => {
|
||||
// console.log("Before fetch request", event.detail.fetchOptions);
|
||||
// });
|
||||
//
|
||||
// document.addEventListener("turbo:before-fetch-response", (event) => {
|
||||
// console.log("Before fetch response", event.detail.fetchResponse);
|
||||
// });
|
||||
30
assets/controllers.json
Normal file
30
assets/controllers.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"controllers": {
|
||||
"@symfony/ux-live-component": {
|
||||
"live": {
|
||||
"enabled": true,
|
||||
"fetch": "eager",
|
||||
"autoimport": {
|
||||
"@symfony/ux-live-component/dist/live.min.css": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@symfony/ux-react": {
|
||||
"react": {
|
||||
"enabled": true,
|
||||
"fetch": "eager"
|
||||
}
|
||||
},
|
||||
"@symfony/ux-turbo": {
|
||||
"turbo-core": {
|
||||
"enabled": true,
|
||||
"fetch": "eager"
|
||||
},
|
||||
"mercure-turbo-stream": {
|
||||
"enabled": true,
|
||||
"fetch": "eager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoints": []
|
||||
}
|
||||
54
assets/controllers/activity_controller.js
Normal file
54
assets/controllers/activity_controller.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
||||
*/
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ['activity']
|
||||
|
||||
// ...
|
||||
async connect() {
|
||||
try {
|
||||
const response = await fetch(`/activity/status`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// Handle the response data as needed
|
||||
this.activityTarget.innerHTML = data.length;
|
||||
if (data.length > 0) {
|
||||
this.activityTarget.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
|
||||
|
||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
||||
const eventSource = new EventSource(`${mercureHubUrl}?topic=activity`, {withCredentials: true});
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.processing !== undefined && data.pending !== undefined) {
|
||||
let totalActivities = data.processing.length + data.pending.length;
|
||||
this.activityTarget.innerHTML = totalActivities;
|
||||
if (totalActivities > 0) {
|
||||
this.activityTarget.classList.remove('hidden');
|
||||
}else if (totalActivities === 0) {
|
||||
this.activityTarget.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
eventSource
|
||||
.onerror = (event) => {
|
||||
console.error('EventSource failed:', event);
|
||||
};
|
||||
}
|
||||
}
|
||||
14
assets/controllers/addmanga_controller.js
Normal file
14
assets/controllers/addmanga_controller.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// assets/controllers/addmanga_controller.js
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
index: Number
|
||||
}
|
||||
|
||||
openModal(event) {
|
||||
event.preventDefault()
|
||||
const openEvent = new CustomEvent(`openAddMangaModal${this.indexValue}`)
|
||||
document.dispatchEvent(openEvent)
|
||||
}
|
||||
}
|
||||
60
assets/controllers/alert_controller.js
Normal file
60
assets/controllers/alert_controller.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
||||
*/
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ['alert', 'icon', 'message']
|
||||
|
||||
connect() {
|
||||
window.addEventListener('alert:show', this.showAlert.bind(this));
|
||||
}
|
||||
|
||||
// ...
|
||||
showAlert(event) {
|
||||
const detail = event.detail;
|
||||
const message = detail.message;
|
||||
const level = detail.level;
|
||||
|
||||
let alertClass = "";
|
||||
let iconClass = "";
|
||||
switch (level) {
|
||||
case 'success':
|
||||
alertClass = "bg-green-500";
|
||||
iconClass = "fa-circle-check";
|
||||
break;
|
||||
case 'warning':
|
||||
alertClass = "bg-yellow-500";
|
||||
iconClass = "fa-circle-exclamation";
|
||||
break;
|
||||
case 'error':
|
||||
alertClass = "bg-red-500";
|
||||
iconClass = "fa-circle-xmark";
|
||||
break;
|
||||
case 'info':
|
||||
default:
|
||||
alertClass = "bg-blue-500";
|
||||
iconClass = "fa-circle-info";
|
||||
break;
|
||||
}
|
||||
|
||||
this.messageTarget.innerHTML = message;
|
||||
this.alertTarget.classList.add(alertClass);
|
||||
this.iconTarget.classList.add(iconClass);
|
||||
this.alertTarget.style.display = "block";
|
||||
|
||||
setTimeout(() => {
|
||||
this.alertTarget.style.opacity = 0;
|
||||
|
||||
setTimeout(() => {
|
||||
this.alertTarget.style.display = 'none';
|
||||
this.alertTarget.classList.remove(alertClass);
|
||||
this.alertTarget.style.opacity = 1;
|
||||
this.iconTarget.classList.remove(iconClass);
|
||||
this.messageTarget.innerHTML = message;
|
||||
}, 1000);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
45
assets/controllers/chapter_progress_controller.js
Normal file
45
assets/controllers/chapter_progress_controller.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ['progressBar', 'progressText']
|
||||
static values = {
|
||||
chapterId: Number
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.currentPage = 0;
|
||||
this.totalPages = 0;
|
||||
|
||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
||||
this.eventSource = new EventSource(`${mercureHubUrl}?topic=activity`, {withCredentials: true});
|
||||
|
||||
this.eventSource.onmessage = this.handleMessage.bind(this);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === "scrapping.progress" && data.chapterId === this.chapterIdValue) {
|
||||
this.handleProgressUpdate(data);
|
||||
}
|
||||
}
|
||||
|
||||
handleProgressUpdate(data) {
|
||||
this.currentPage = data.pageIndex;
|
||||
this.totalPages = data.totalPages;
|
||||
|
||||
this.updateProgressBar();
|
||||
}
|
||||
|
||||
updateProgressBar() {
|
||||
const progress = (this.currentPage / this.totalPages) * 100;
|
||||
this.progressBarTarget.style.width = `${progress}%`;
|
||||
this.progressTextTarget.textContent = `${this.currentPage} / ${this.totalPages}`;
|
||||
}
|
||||
}
|
||||
26
assets/controllers/collection_controller.js
Normal file
26
assets/controllers/collection_controller.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
||||
*/
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ['container', 'template', 'item'];
|
||||
|
||||
connect() {
|
||||
this.index = this.itemTargets.length;
|
||||
}
|
||||
|
||||
add(event) {
|
||||
event.preventDefault();
|
||||
const template = this.templateTarget.innerHTML.replace(/__name__/g, this.index);
|
||||
this.containerTarget.insertAdjacentHTML('beforeend', template);
|
||||
this.index++;
|
||||
}
|
||||
|
||||
remove(event) {
|
||||
event.preventDefault();
|
||||
event.target.closest('.collection-item').remove();
|
||||
}
|
||||
}
|
||||
69
assets/controllers/download_controller.js
Normal file
69
assets/controllers/download_controller.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ['icon']
|
||||
static values = {
|
||||
url: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.defaultIconClass = this.iconTarget.classList.value;
|
||||
}
|
||||
|
||||
async download(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Change the icon to a loader
|
||||
this.iconTarget.classList.remove("fa-download", "fa-search");
|
||||
this.iconTarget.classList.add("fa-spinner", "fa-spin");
|
||||
|
||||
try {
|
||||
const response = await fetch(this.urlValue, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
this.dispatchAlert(data.error, 'error');
|
||||
} else if (data.success) {
|
||||
this.dispatchAlert(data.success, 'success');
|
||||
}
|
||||
} else {
|
||||
// C'est un fichier à télécharger
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
|
||||
const matches = filenameRegex.exec(contentDisposition);
|
||||
let filename = 'download';
|
||||
if (matches != null && matches[1]) {
|
||||
filename = matches[1].replace(/['"]/g, '');
|
||||
}
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
} finally {
|
||||
// Revert the icon back to the original one
|
||||
this.iconTarget.classList.value = this.defaultIconClass;
|
||||
}
|
||||
}
|
||||
|
||||
dispatchAlert(message, level) {
|
||||
const event = new CustomEvent('alert:show', {
|
||||
detail: { message: message, level: level }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
45
assets/controllers/dropdown_controller.js
Normal file
45
assets/controllers/dropdown_controller.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// assets/controllers/dropdown_controller.js
|
||||
import {Controller} from "@hotwired/stimulus"
|
||||
import {useClickOutside} from "stimulus-use"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["button", "menu"]
|
||||
|
||||
connect() {
|
||||
useClickOutside(this)
|
||||
}
|
||||
|
||||
toggle(event) {
|
||||
this.menuTarget.classList.toggle('hidden')
|
||||
if (!this.menuTarget.classList.contains('hidden')) {
|
||||
this.positionMenu()
|
||||
}
|
||||
}
|
||||
|
||||
clickOutside(event) {
|
||||
this.menuTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
positionMenu() {
|
||||
const buttonRect = this.buttonTarget.getBoundingClientRect()
|
||||
const menuRect = this.menuTarget.getBoundingClientRect()
|
||||
const spaceRight = window.innerWidth - buttonRect.right
|
||||
const spaceBottom = window.innerHeight - buttonRect.bottom
|
||||
|
||||
if (spaceRight < menuRect.width && buttonRect.left > menuRect.width) {
|
||||
this.menuTarget.style.left = 'auto'
|
||||
this.menuTarget.style.right = '0'
|
||||
} else {
|
||||
this.menuTarget.style.left = '0'
|
||||
this.menuTarget.style.right = 'auto'
|
||||
}
|
||||
|
||||
if (spaceBottom < menuRect.height && buttonRect.top > menuRect.height) {
|
||||
this.menuTarget.style.top = 'auto'
|
||||
this.menuTarget.style.bottom = '100%'
|
||||
} else {
|
||||
this.menuTarget.style.top = '100%'
|
||||
this.menuTarget.style.bottom = 'auto'
|
||||
}
|
||||
}
|
||||
}
|
||||
16
assets/controllers/hello_controller.js
Normal file
16
assets/controllers/hello_controller.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* This is an example Stimulus controller!
|
||||
*
|
||||
* Any element with a data-controller="hello" attribute will cause
|
||||
* this controller to be executed. The name "hello" comes from the filename:
|
||||
* hello_controller.js -> "hello"
|
||||
*
|
||||
* Delete this file or adapt it for your use!
|
||||
*/
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
|
||||
}
|
||||
}
|
||||
51
assets/controllers/import_match_controller.js
Normal file
51
assets/controllers/import_match_controller.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["checkbox", "modal", "modalContent"]
|
||||
|
||||
toggleAllCheckboxes(event) {
|
||||
this.checkboxTargets.forEach(checkbox => {
|
||||
checkbox.checked = event.target.checked;
|
||||
});
|
||||
}
|
||||
|
||||
updateMangaInfo(event) {
|
||||
const select = event.target;
|
||||
const selectedOption = select.options[select.selectedIndex];
|
||||
const mangaInfo = JSON.parse(selectedOption.dataset.mangaInfo);
|
||||
}
|
||||
|
||||
showDetails(event) {
|
||||
const fileId = event.currentTarget.dataset.fileId;
|
||||
const select = document.querySelector(`select[name="manga_slug[${fileId}]"]`);
|
||||
const mangaInfo = JSON.parse(select.options[select.selectedIndex].dataset.mangaInfo);
|
||||
|
||||
this.modalContentTarget.innerHTML = `
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">${mangaInfo.title}</h3>
|
||||
<div class="mt-2">
|
||||
<p><strong>Author:</strong> ${mangaInfo.author || 'N/A'}</p>
|
||||
<p><strong>Publication Year:</strong> ${mangaInfo.publicationYear || 'N/A'}</p>
|
||||
<p><strong>Genres:</strong> ${mangaInfo.genres ? mangaInfo.genres.join(', ') : 'N/A'}</p>
|
||||
<p><strong>Description:</strong> ${this.truncate(mangaInfo.description || 'N/A', 200)}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.modalTarget.classList.remove('hidden');
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.modalTarget.classList.add('hidden');
|
||||
}
|
||||
|
||||
confirmSelected(event) {
|
||||
const selectedFiles = this.checkboxTargets.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value);
|
||||
if (selectedFiles.length === 0) {
|
||||
event.preventDefault();
|
||||
alert('Veuillez sélectionner au moins un fichier à importer.');
|
||||
}
|
||||
}
|
||||
|
||||
truncate(str, length) {
|
||||
return str.length > length ? str.substring(0, length) + '...' : str;
|
||||
}
|
||||
}
|
||||
21
assets/controllers/loading_button_controller.js
Normal file
21
assets/controllers/loading_button_controller.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// assets/controllers/loading_button_controller.js
|
||||
import {Controller} from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["text", "loader"];
|
||||
static values = {form: String};
|
||||
|
||||
startLoading(event) {
|
||||
event.preventDefault();
|
||||
this.textTarget.classList.add("hidden");
|
||||
this.loaderTarget.classList.remove("hidden");
|
||||
this.element.disabled = true;
|
||||
|
||||
if (this.hasFormValue) {
|
||||
const form = document.getElementById(this.formValue);
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
assets/controllers/menu_controller.js
Normal file
10
assets/controllers/menu_controller.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// assets/controllers/menu_controller.js
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["sidebar"]
|
||||
|
||||
toggleMenu() {
|
||||
this.sidebarTarget.classList.toggle('-translate-x-full')
|
||||
}
|
||||
}
|
||||
33
assets/controllers/mercure_controller.js
Normal file
33
assets/controllers/mercure_controller.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
||||
*/
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
// ...
|
||||
connect() {
|
||||
const topic = this.data.get('topic');
|
||||
const mercureHubUrl = 'https://mangarr.test.nestor-server.fr/.well-known/mercure';
|
||||
const eventSource = new EventSource(`${mercureHubUrl}?topic=${topic}`, {withCredentials: true});
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Received Mercure update:', data);
|
||||
|
||||
this.dispatchAlert(data.message, data.status);
|
||||
};
|
||||
|
||||
eventSource.onerror = (event) => {
|
||||
console.error('EventSource failed:', event);
|
||||
};
|
||||
}
|
||||
|
||||
dispatchAlert(message, level) {
|
||||
const event = new CustomEvent('alert:show', {
|
||||
detail: { message: message, level: level }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
37
assets/controllers/modal_controller.js
Normal file
37
assets/controllers/modal_controller.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// assets/controllers/modal_controller.js
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal"]
|
||||
static values = {
|
||||
openTrigger: String,
|
||||
closeTrigger: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.hasOpenTriggerValue) {
|
||||
document.addEventListener(this.openTriggerValue, this.open.bind(this))
|
||||
}
|
||||
if (this.hasCloseTriggerValue) {
|
||||
document.addEventListener(this.closeTriggerValue, this.close.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.hasOpenTriggerValue) {
|
||||
document.removeEventListener(this.openTriggerValue, this.open.bind(this))
|
||||
}
|
||||
if (this.hasCloseTriggerValue) {
|
||||
document.removeEventListener(this.closeTriggerValue, this.close.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
console.log("Opening modal...")
|
||||
this.modalTarget.classList.remove('hidden')
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modalTarget.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
101
assets/controllers/preferred_sources_controller.js
Normal file
101
assets/controllers/preferred_sources_controller.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// assets/controllers/preferred-sources_controller.js
|
||||
|
||||
import {Controller} from "@hotwired/stimulus"
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["preferredList", "availableList"]
|
||||
static values = {
|
||||
mangaId: Number,
|
||||
preferredSources: Array,
|
||||
allSources: Array
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.initSortable()
|
||||
}
|
||||
|
||||
initSortable() {
|
||||
new Sortable(this.preferredListTarget, {
|
||||
animation: 150,
|
||||
ghostClass: 'bg-gray-300',
|
||||
onEnd: this.handleDragEnd.bind(this)
|
||||
})
|
||||
}
|
||||
|
||||
handleDragEnd() {
|
||||
this.updatePreferredSources()
|
||||
}
|
||||
|
||||
addSource(event) {
|
||||
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
|
||||
if (!this.preferredSourcesValue.includes(sourceId)) {
|
||||
this.preferredSourcesValue = [...this.preferredSourcesValue, sourceId]
|
||||
this.updateLists()
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
removeSource(event) {
|
||||
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
|
||||
this.preferredSourcesValue = this.preferredSourcesValue.filter(id => id !== sourceId)
|
||||
this.updateLists()
|
||||
this.save()
|
||||
}
|
||||
|
||||
updatePreferredSources() {
|
||||
this.preferredSourcesValue = Array.from(this.preferredListTarget.children).map(li => parseInt(li.dataset.id))
|
||||
this.save()
|
||||
}
|
||||
|
||||
updateLists() {
|
||||
this.preferredListTarget.innerHTML = this.preferredSourcesValue
|
||||
.map(id => this.allSourcesValue.find(s => s.id === id))
|
||||
.map(source => this.sourceTemplate(source, true))
|
||||
.join('')
|
||||
|
||||
this.availableListTarget.innerHTML = this.allSourcesValue
|
||||
.filter(source => !this.preferredSourcesValue.includes(source.id))
|
||||
.map(source => this.sourceTemplate(source, false))
|
||||
.join('')
|
||||
|
||||
this.initSortable()
|
||||
}
|
||||
|
||||
sourceTemplate(source, isPreferred) {
|
||||
return `
|
||||
<li data-id="${source.id}" draggable="true" class="flex items-center justify-between p-2 bg-gray-100 rounded ${isPreferred ? 'cursor-move' : ''}">
|
||||
<span>${source.name}</span>
|
||||
<button type="button" data-action="preferred-sources#${isPreferred ? 'removeSource' : 'addSource'}" data-source-id="${source.id}" class="text-${isPreferred ? 'red' : 'green'}-500 hover:text-${isPreferred ? 'red' : 'green'}-700">
|
||||
<i class="fas fa-${isPreferred ? 'times' : 'plus'}"></i>
|
||||
</button>
|
||||
</li>
|
||||
`
|
||||
}
|
||||
|
||||
async save() {
|
||||
try {
|
||||
const response = await fetch(`/manga/${this.mangaIdValue}/preferred-sources`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
preferredSources: this.preferredSourcesValue
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
console.log('Preferred sources saved successfully')
|
||||
// Optionally show a success message
|
||||
} else {
|
||||
console.error('Error saving preferred sources')
|
||||
// Optionally show an error message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
// Optionally show an error message
|
||||
}
|
||||
}
|
||||
}
|
||||
127
assets/controllers/reader_controller.js
Normal file
127
assets/controllers/reader_controller.js
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['pageContainer', 'currentPage', 'chapterSelect', 'readingModeButton']
|
||||
static values = {
|
||||
mangaSlug: String,
|
||||
chapterNumber: Number,
|
||||
totalPages: Number,
|
||||
currentPage: { type: Number, default: 1 },
|
||||
readingMode: { type: String, default: 'horizontal' }
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.loadChapters();
|
||||
this.loadPages();
|
||||
}
|
||||
|
||||
async loadChapters() {
|
||||
try {
|
||||
const response = await fetch(`/api/chapters/${this.mangaSlugValue}`);
|
||||
const chapters = await response.json();
|
||||
|
||||
this.chapterSelectTarget.innerHTML = chapters.map(chapter =>
|
||||
`<option value="${chapter.number}" ${chapter.number === this.chapterNumberValue ? 'selected' : ''}>
|
||||
Chapitre ${chapter.number}
|
||||
</option>`
|
||||
).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading chapters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadPages() {
|
||||
this.pageContainerTarget.innerHTML = '';
|
||||
if (this.readingModeValue === 'horizontal') {
|
||||
await this.loadPage(this.currentPageValue);
|
||||
} else {
|
||||
for (let i = 1; i <= this.totalPagesValue; i++) {
|
||||
await this.loadPage(i, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadPage(pageNumber, isVertical = false) {
|
||||
const response = await fetch(`/api/read/${this.mangaSlugValue}/${this.chapterNumberValue}/${pageNumber}`);
|
||||
const pageContent = await response.text();
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `data:image/jpeg;base64,${pageContent}`;
|
||||
img.alt = `Page ${pageNumber}`;
|
||||
img.classList.add('shadow-lg', 'w-full', 'h-auto');
|
||||
|
||||
if (this.readingModeValue === 'horizontal') {
|
||||
img.classList.add('cursor-pointer');
|
||||
img.dataset.action = 'click->reader#pageClick';
|
||||
this.pageContainerTarget.innerHTML = '';
|
||||
}
|
||||
|
||||
if (isVertical) {
|
||||
img.loading = 'lazy';
|
||||
img.classList.add('mb-4');
|
||||
}
|
||||
|
||||
this.pageContainerTarget.appendChild(img);
|
||||
|
||||
if (!isVertical) {
|
||||
this.currentPageTarget.textContent = pageNumber;
|
||||
this.currentPageValue = pageNumber;
|
||||
}
|
||||
}
|
||||
|
||||
pageClick(event) {
|
||||
if (this.readingModeValue === 'horizontal') {
|
||||
const pageWidth = event.target.offsetWidth;
|
||||
const clickX = event.offsetX;
|
||||
|
||||
if (clickX < pageWidth / 2) {
|
||||
this.previousPage();
|
||||
} else {
|
||||
this.nextPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousPage() {
|
||||
if (this.currentPageValue > 1) {
|
||||
this.loadPage(this.currentPageValue - 1);
|
||||
} else {
|
||||
this.previousChapter();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPageValue < this.totalPagesValue) {
|
||||
this.loadPage(this.currentPageValue + 1);
|
||||
} else {
|
||||
this.nextChapter();
|
||||
}
|
||||
}
|
||||
|
||||
async previousChapter() {
|
||||
const response = await fetch(`/api/previous-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`);
|
||||
const previousChapter = await response.json();
|
||||
if (previousChapter) {
|
||||
window.location.href = `/read/${this.mangaSlugValue}/${previousChapter.number}`;
|
||||
}
|
||||
}
|
||||
|
||||
async nextChapter() {
|
||||
const response = await fetch(`/api/next-chapter/${this.mangaSlugValue}/${this.chapterNumberValue}`);
|
||||
const nextChapter = await response.json();
|
||||
if (nextChapter) {
|
||||
window.location.href = `/read/${this.mangaSlugValue}/${nextChapter.number}`;
|
||||
}
|
||||
}
|
||||
|
||||
changeChapter(event) {
|
||||
const selectedChapterNumber = event.target.value;
|
||||
window.location.href = `/read/${this.mangaSlugValue}/${selectedChapterNumber}`;
|
||||
}
|
||||
|
||||
toggleReadingMode() {
|
||||
this.readingModeValue = this.readingModeValue === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
this.readingModeButtonTarget.textContent = this.readingModeValue === 'horizontal' ? 'Passer en mode vertical' : 'Passer en mode horizontal';
|
||||
this.loadPages();
|
||||
}
|
||||
}
|
||||
76
assets/controllers/scrapper_configure_controller.js
Normal file
76
assets/controllers/scrapper_configure_controller.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['form', 'testForm', 'imageSelector', 'nextPageSelector', 'testResults', 'scrapingType']
|
||||
|
||||
connect() {
|
||||
}
|
||||
|
||||
async saveConfiguration(event) {
|
||||
event.preventDefault();
|
||||
this.formTarget.submit();
|
||||
}
|
||||
|
||||
async testConfiguration(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(this.formTarget);
|
||||
const testFormData = new FormData(this.testFormTarget);
|
||||
|
||||
for (let [key, value] of formData.entries()) {
|
||||
const cleanKey = key.replace(/^content_source\[(.+)]$/, '$1');
|
||||
testFormData.append(`content_source[${cleanKey}]`, value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.testFormTarget.action, {
|
||||
method: 'POST',
|
||||
body: testFormData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.displayTestResults(result.data);
|
||||
} else {
|
||||
this.displayError(result.message, result.errors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
this.displayError('An error occurred while testing the configuration');
|
||||
}
|
||||
}
|
||||
|
||||
displayTestResults(data) {
|
||||
let html = '<h3 class="text-xl font-semibold mb-4">Test Results</h3>';
|
||||
html += '<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">';
|
||||
data.forEach(page => {
|
||||
html += `
|
||||
<div class="border rounded-lg p-2 flex flex-col items-center">
|
||||
<img src="${page.image_url}" alt="Page ${page.page_number}" class="w-full h-48 object-cover mb-2">
|
||||
<p class="text-sm font-medium">Page ${page.page_number}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
this.testResultsTarget.innerHTML = html;
|
||||
}
|
||||
|
||||
displayError(message, errors = []) {
|
||||
let errorHtml = `
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||
<strong class="font-bold">Error:</strong>
|
||||
<span class="block sm:inline">${message}</span>
|
||||
`;
|
||||
|
||||
if (errors.length > 0) {
|
||||
errorHtml += '<ul class="list-disc list-inside mt-2">';
|
||||
errors.forEach(error => {
|
||||
errorHtml += `<li>${error}</li>`;
|
||||
});
|
||||
errorHtml += '</ul>';
|
||||
}
|
||||
|
||||
errorHtml += '</div>';
|
||||
this.testResultsTarget.innerHTML = errorHtml;
|
||||
}
|
||||
}
|
||||
81
assets/controllers/scrapper_import_controller.js
Normal file
81
assets/controllers/scrapper_import_controller.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
||||
*/
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
// ...
|
||||
static targets = ["textarea", "submitButton"]
|
||||
|
||||
connect() {
|
||||
document.addEventListener('openImportModal', this.prepareImportModal.bind(this));
|
||||
document.addEventListener('openExportModal', this.prepareExportModal.bind(this));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener('openImportModal', this.prepareImportModal.bind(this));
|
||||
document.removeEventListener('openExportModal', this.prepareExportModal.bind(this));
|
||||
}
|
||||
|
||||
async prepareExportModal() {
|
||||
try {
|
||||
const response = await fetch('/settings/export_scrappers');
|
||||
const data = await response.json();
|
||||
this.textareaTarget.value = JSON.stringify(data, null, 2);
|
||||
this.submitButtonTarget.textContent = 'Copy to Clipboard';
|
||||
this.submitButtonTarget.dataset.action = 'scrapper-import#copyToClipboard';
|
||||
this.openModal('Export Scrapper Configurations');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
prepareImportModal() {
|
||||
this.textareaTarget.value = '';
|
||||
this.submitButtonTarget.textContent = 'Import';
|
||||
this.submitButtonTarget.dataset.action = 'scrapper-import#submitImport';
|
||||
this.openModal('Import Scrapper Configurations');
|
||||
}
|
||||
|
||||
openModal(title) {
|
||||
const event = new CustomEvent('openScrapperModal', { detail: { title: title } });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
async submitImport() {
|
||||
const jsonData = this.textareaTarget.value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/import_scrappers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
console.log(result.message);
|
||||
document.dispatchEvent(new CustomEvent('closeScrapperModal'));
|
||||
window.location.reload();
|
||||
} else {
|
||||
console.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
copyToClipboard() {
|
||||
navigator.clipboard.writeText(this.textareaTarget.value).then(() => {
|
||||
console.log('Copied to clipboard');
|
||||
document.dispatchEvent(new CustomEvent('closeScrapperModal'));
|
||||
}, (err) => {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
15
assets/controllers/search_controller.js
Normal file
15
assets/controllers/search_controller.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
||||
*/
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ['input']
|
||||
|
||||
clearSearch() {
|
||||
this.inputTarget.value = '';
|
||||
this.inputTarget.focus();
|
||||
}
|
||||
}
|
||||
35
assets/controllers/table_controller.js
Normal file
35
assets/controllers/table_controller.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
||||
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
|
||||
*/
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ["body", "toggleIcon"]
|
||||
static values = { open: Boolean }
|
||||
|
||||
connect() {
|
||||
if (!this.openValue) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.bodyTarget.style.display === "none") {
|
||||
this.open()
|
||||
} else {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.bodyTarget.style.display = "block"
|
||||
this.toggleIconTarget.classList.replace("fa-chevron-down", "fa-chevron-up")
|
||||
}
|
||||
|
||||
close() {
|
||||
this.bodyTarget.style.display = "none"
|
||||
this.toggleIconTarget.classList.replace("fa-chevron-up", "fa-chevron-down")
|
||||
}
|
||||
}
|
||||
198
assets/controllers/toolbar_controller.js
Normal file
198
assets/controllers/toolbar_controller.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// assets/controllers/toolbar_controller.js
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { visit } from "@hotwired/turbo"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["dropdown", "icon", "text"]
|
||||
static values = {
|
||||
currentSort: String,
|
||||
currentOrder: String,
|
||||
currentStatus: String,
|
||||
mangaId: Number
|
||||
}
|
||||
|
||||
connect() {
|
||||
window.addEventListener('alert:show', this.stopLoading.bind(this));
|
||||
}
|
||||
|
||||
stopLoading(event) {
|
||||
if(event.currentTarget.dataset !== undefined){
|
||||
this.iconTarget.classList.remove('fa-spin');
|
||||
}
|
||||
}
|
||||
|
||||
refreshMetadata(event) {
|
||||
const mangaId = event.currentTarget.dataset.mangaid;
|
||||
const url = `/refresh_metadata`;
|
||||
|
||||
this.iconTarget.classList.add('fa-spin');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ mangaId: mangaId })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
searchLastChapter() {
|
||||
console.log("Searching last chapter...");
|
||||
}
|
||||
|
||||
import() {
|
||||
console.log("Importing...");
|
||||
}
|
||||
|
||||
monitoring(event){
|
||||
const mangaId = event.currentTarget.dataset.mangaid;
|
||||
const currentTarget = event.currentTarget;
|
||||
|
||||
const url = `/toggle_monitored`;
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ mangaId: mangaId })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
}).then(data => {
|
||||
if(data.isMonitored === true){
|
||||
currentTarget.classList.remove('text-white');
|
||||
currentTarget.classList.add('text-green-500');
|
||||
this.textTarget.innerHTML = "Monitored";
|
||||
}else if(data.isMonitored === false){
|
||||
currentTarget.classList.remove('text-green-500');
|
||||
currentTarget.classList.add('text-white');
|
||||
this.textTarget.innerHTML = "Monitoring";
|
||||
}
|
||||
// console.log(data.isMonitored);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
editMangas() {
|
||||
console.log("Editing mangas...");
|
||||
}
|
||||
|
||||
editManga() {
|
||||
const event = new CustomEvent('openEditModal');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
editPreferredSources() {
|
||||
const event = new CustomEvent('openPreferredSourcesModal');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
openImportModal() {
|
||||
const importEvent = new CustomEvent('openImportModal');
|
||||
document.dispatchEvent(importEvent);
|
||||
}
|
||||
|
||||
openExportModal() {
|
||||
const exportEvent = new CustomEvent('openExportModal');
|
||||
document.dispatchEvent(exportEvent);
|
||||
}
|
||||
|
||||
deleteMangas() {
|
||||
console.log("Deleting mangas...");
|
||||
}
|
||||
|
||||
deleteManga() {
|
||||
const event = new CustomEvent('openDeleteModal');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
confirmDelete(event) {
|
||||
event.preventDefault();
|
||||
const url = `/manga/delete/${this.mangaIdValue}`;
|
||||
|
||||
fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
visit('/', {});
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
// Show error message to user
|
||||
});
|
||||
}
|
||||
|
||||
showOptions() {
|
||||
console.log("Showing options...");
|
||||
}
|
||||
|
||||
expandAll() {
|
||||
console.log("Expanding all...");
|
||||
}
|
||||
|
||||
changeView(event) {
|
||||
event.preventDefault();
|
||||
const viewOption = event.currentTarget.dataset.view;
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('view', viewOption);
|
||||
|
||||
window.location = url.toString();
|
||||
}
|
||||
|
||||
sort(event) {
|
||||
event.preventDefault()
|
||||
const sortOption = event.currentTarget.dataset.sort;
|
||||
let order = 'asc';
|
||||
|
||||
if (sortOption === this.currentSortValue && this.currentOrderValue === 'asc') {
|
||||
order = 'desc';
|
||||
}
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('sort', sortOption);
|
||||
url.searchParams.set('order', order);
|
||||
|
||||
window.location = url.toString();
|
||||
}
|
||||
|
||||
filter(event) {
|
||||
event.preventDefault();
|
||||
const filterOption = event.currentTarget.dataset.filter;
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('status', filterOption);
|
||||
|
||||
// Réinitialiser la page à 1 si on utilise la pagination
|
||||
// url.searchParams.set('page', '1');
|
||||
|
||||
window.location = url.toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { Job } from '../../domain/entities/job';
|
||||
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
|
||||
|
||||
const jobRepository = new ApiJobRepository();
|
||||
|
||||
const ACTIVE_STATUSES = ['pending', 'in_progress'];
|
||||
|
||||
export const useActivityStore = defineStore('activity', {
|
||||
state: () => ({
|
||||
jobs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
mercureEventSource: null,
|
||||
// Pagination
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
@@ -19,15 +15,21 @@ export const useActivityStore = defineStore('activity', {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
// Tri
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
// Filtres
|
||||
filter: {
|
||||
status: ['pending', 'in_progress'], // Par défaut, ne montrer que les 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,
|
||||
// Getters pour la pagination
|
||||
paginationInfo: state => ({
|
||||
currentPage: state.currentPage,
|
||||
totalPages: state.totalPages,
|
||||
@@ -39,25 +41,44 @@ export const useActivityStore = defineStore('activity', {
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Charge la liste des jobs selon les filtres actuels
|
||||
* @param {number} page - Numéro de page optionnel
|
||||
*/
|
||||
async loadJobs(page = null) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const jobCollection = await jobRepository.getJobs({
|
||||
const options = {
|
||||
page: page || this.currentPage,
|
||||
limit: this.limit,
|
||||
sortBy: this.sortBy,
|
||||
sortOrder: this.sortOrder,
|
||||
status: ACTIVE_STATUSES,
|
||||
});
|
||||
sortBy: this.filter.sortBy,
|
||||
sortOrder: this.filter.sortOrder,
|
||||
status: this.filter.status
|
||||
};
|
||||
|
||||
const jobCollection = await jobRepository.getJobs(options);
|
||||
|
||||
// Mettre à jour les données
|
||||
this.jobs = jobCollection.items;
|
||||
this.currentPage = jobCollection.page;
|
||||
this.total = jobCollection.total;
|
||||
this.hasNextPage = jobCollection.hasNextPage;
|
||||
this.hasPreviousPage = jobCollection.hasPreviousPage;
|
||||
|
||||
// Calculer le nombre total de pages
|
||||
this.totalPages = Math.ceil(this.total / this.limit);
|
||||
|
||||
console.log('Store updated with:', {
|
||||
jobs: this.jobs.length,
|
||||
currentPage: this.currentPage,
|
||||
total: this.total,
|
||||
limit: this.limit,
|
||||
totalPages: this.totalPages,
|
||||
hasNextPage: this.hasNextPage,
|
||||
hasPreviousPage: this.hasPreviousPage
|
||||
});
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
console.error('Error loading jobs:', error);
|
||||
@@ -66,6 +87,10 @@ export const useActivityStore = defineStore('activity', {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Va à une page spécifique
|
||||
* @param {number} page
|
||||
*/
|
||||
async goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
@@ -73,26 +98,39 @@ export const useActivityStore = defineStore('activity', {
|
||||
}
|
||||
},
|
||||
|
||||
async updateSort(sortBy, sortOrder) {
|
||||
this.sortBy = sortBy;
|
||||
this.sortOrder = sortOrder;
|
||||
this.currentPage = 1;
|
||||
/**
|
||||
* Met à jour les filtres et recharge la liste
|
||||
* @param {Object} filter
|
||||
*/
|
||||
async updateFilter(filter) {
|
||||
this.filter = { ...this.filter, ...filter };
|
||||
this.currentPage = 1; // Retourner à la première page lors du changement de filtre
|
||||
await this.loadJobs(1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Met à jour la limite par page
|
||||
* @param {number} limit
|
||||
*/
|
||||
async updateLimit(limit) {
|
||||
this.limit = limit;
|
||||
this.currentPage = 1;
|
||||
this.currentPage = 1; // Retourner à la première page
|
||||
await this.loadJobs(1);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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);
|
||||
// Recharger la page courante pour avoir les bons totaux
|
||||
await this.loadJobs(this.currentPage);
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
@@ -102,75 +140,17 @@ export const useActivityStore = defineStore('activity', {
|
||||
}
|
||||
},
|
||||
|
||||
updateJobProgress(jobId, progress) {
|
||||
const job = this.jobs.find(j => j.id === jobId);
|
||||
if (job) job.progress = progress;
|
||||
},
|
||||
|
||||
handleJobCreated(data) {
|
||||
const alreadyExists = this.jobs.some(j => j.id === data.id);
|
||||
if (alreadyExists) return;
|
||||
|
||||
const job = Job.create({
|
||||
id: data.id,
|
||||
type: data.type_job,
|
||||
status: data.status,
|
||||
createdAt: data.createdAt,
|
||||
context: data.context,
|
||||
attempts: data.attempts,
|
||||
maxAttempts: data.maxAttempts,
|
||||
});
|
||||
|
||||
this.jobs.unshift(job);
|
||||
this.total += 1;
|
||||
this.totalPages = Math.ceil(this.total / this.limit);
|
||||
},
|
||||
|
||||
handleJobStatusChange(jobId, newStatus) {
|
||||
const job = this.jobs.find(j => j.id === jobId);
|
||||
if (!job) return;
|
||||
|
||||
if (newStatus === 'in_progress') {
|
||||
job.status = 'in_progress';
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.jobs = this.jobs.filter(j => j.id !== jobId);
|
||||
this.total = Math.max(0, this.total - 1);
|
||||
this.totalPages = Math.ceil(this.total / this.limit);
|
||||
}, 1500);
|
||||
}
|
||||
},
|
||||
|
||||
subscribeMercure() {
|
||||
if (this.mercureEventSource) return;
|
||||
const url = new URL('/.well-known/mercure', window.location.origin);
|
||||
url.searchParams.append('topic', 'jobs/activity');
|
||||
this.mercureEventSource = new EventSource(url.toString());
|
||||
this.mercureEventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'job.created') {
|
||||
this.handleJobCreated(data);
|
||||
} else if (data.type === 'job.progress_updated') {
|
||||
this.updateJobProgress(data.jobId, data.progress);
|
||||
} else if (data.type === 'job.status_changed') {
|
||||
this.handleJobStatusChange(data.jobId, data.status);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
unsubscribeMercure() {
|
||||
if (this.mercureEventSource) {
|
||||
this.mercureEventSource.close();
|
||||
this.mercureEventSource = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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(this.currentPage);
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
@@ -180,5 +160,26 @@ export const useActivityStore = defineStore('activity', {
|
||||
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({});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,8 +10,6 @@ export class Job {
|
||||
failureReason = null,
|
||||
createdAt = new Date().toISOString(),
|
||||
updatedAt = new Date().toISOString(),
|
||||
startedAt = null,
|
||||
completedAt = null,
|
||||
attempts = 0,
|
||||
maxAttempts = 1,
|
||||
context = {}
|
||||
@@ -25,8 +23,6 @@ export class Job {
|
||||
this.error = failureReason ?? error;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
this.startedAt = startedAt;
|
||||
this.completedAt = completedAt;
|
||||
this.attempts = attempts;
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.context = context;
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
* @returns {Promise<JobCollection>} Collection de jobs
|
||||
*/
|
||||
async getJobs(options = {}) {
|
||||
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [], type = null } = 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}`;
|
||||
@@ -23,11 +23,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
|
||||
url += `&status=${status.join(',')}`;
|
||||
}
|
||||
|
||||
// Ajouter le filtre de type si fourni
|
||||
if (type) {
|
||||
url += `&type=${type}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,153 +1,169 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
<div class="overflow-y-auto h-full">
|
||||
<Toolbar :config="toolbarConfig" class="mb-6" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<div class="animate-spin h-10 w-10 border-b-2 border-indigo-500 rounded-full"></div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="activityStore.error" class="px-6 py-8">
|
||||
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4">
|
||||
<p class="text-red-800 dark:text-red-200">{{ activityStore.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="activityStore.error" class="bg-red-100 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-400 p-4 mb-6">
|
||||
<p>{{ activityStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<section v-else class="border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Empty -->
|
||||
<div v-if="activityStore.jobs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
|
||||
<ClockIcon class="w-12 h-12 mb-3" />
|
||||
<p class="text-base">Aucun job en cours ou en attente.</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<div v-else class="container mx-auto p-2">
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white dark:bg-gray-800">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700 text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
<th class="w-2/11 py-3 px-6 text-left">Type</th>
|
||||
<th class="w-2/11 py-3 px-4 text-left">Statut</th>
|
||||
<th class="w-3/11 py-3 px-4 text-left">Informations</th>
|
||||
<th class="w-3/11 py-3 px-4 text-left">Progression</th>
|
||||
<th class="w-1/11 py-3 px-4 text-left">Actions</th>
|
||||
<tr class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
|
||||
<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="divide-y divide-gray-100 dark:divide-gray-700/50 text-gray-700 dark:text-gray-300">
|
||||
<JobItem
|
||||
v-for="job in activityStore.jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
@delete="deleteJob" />
|
||||
<tbody class="text-gray-700 dark:text-gray-300">
|
||||
<template v-if="activityStore.jobs.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="py-8 px-4 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<ClockIcon class="h-12 w-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p class="text-lg font-medium dark:text-gray-300">Aucune activité trouvée</p>
|
||||
<p class="text-sm dark:text-gray-400">Aucune activité ne correspond aux filtres actuels.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<JobItem
|
||||
v-for="job in activityStore.jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
@delete="deleteJob" />
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="total > activityStore.limit"
|
||||
v-if="activityStore.total > activityStore.limit"
|
||||
:current-page="activityStore.currentPage"
|
||||
:total-pages="activityStore.totalPages"
|
||||
:total="total"
|
||||
:total="activityStore.total"
|
||||
:limit="activityStore.limit"
|
||||
:has-next-page="activityStore.hasNextPage"
|
||||
:has-previous-page="activityStore.hasPreviousPage"
|
||||
@page-change="changePage" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowPathIcon, BarsArrowDownIcon, ClockIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import { ArrowPathIcon, ClockIcon, FunnelIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useActivityStore } from '../../application/store/activityStore';
|
||||
import JobItem from '../components/JobItem.vue';
|
||||
|
||||
const activityStore = useActivityStore();
|
||||
const activityStore = useActivityStore();
|
||||
const selectedAll = ref(false);
|
||||
|
||||
const { sortBy, sortOrder, total, loading } = storeToRefs(activityStore);
|
||||
// Statuts disponibles pour le filtre
|
||||
const statusOptions = [
|
||||
{ value: ['pending', 'in_progress'], label: 'Actifs' },
|
||||
{ value: ['pending', 'in_progress', 'completed', 'failed'], label: 'Tous' },
|
||||
{ value: ['completed'], label: 'Terminés' },
|
||||
{ value: ['failed'], label: 'En erreur' },
|
||||
{ value: ['pending'], label: 'En attente' },
|
||||
{ value: ['in_progress'], label: 'En cours' }
|
||||
];
|
||||
|
||||
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
|
||||
// Index du statut actif (par défaut "Actifs")
|
||||
const activeStatusIndex = ref(0);
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Activité', class: 'text-sm font-medium' },
|
||||
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'dropdown',
|
||||
icon: BarsArrowDownIcon,
|
||||
label: 'Trier',
|
||||
items: [
|
||||
{
|
||||
label: 'Plus récent',
|
||||
isSelected: isSortSelected('createdAt', 'DESC'),
|
||||
onClick: () => activityStore.updateSort('createdAt', 'DESC'),
|
||||
},
|
||||
{
|
||||
label: 'Plus ancien',
|
||||
isSelected: isSortSelected('createdAt', 'ASC'),
|
||||
onClick: () => activityStore.updateSort('createdAt', 'ASC'),
|
||||
},
|
||||
{
|
||||
label: 'Par type',
|
||||
isSelected: isSortSelected('type', 'ASC'),
|
||||
onClick: () => activityStore.updateSort('type', 'ASC'),
|
||||
},
|
||||
{
|
||||
label: 'Par statut',
|
||||
isSelected: isSortSelected('status', 'ASC'),
|
||||
onClick: () => activityStore.updateSort('status', 'ASC'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: ArrowPathIcon,
|
||||
label: 'Rafraîchir',
|
||||
disabled: loading.value,
|
||||
onClick: () => activityStore.loadJobs(),
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: TrashIcon,
|
||||
label: 'Supprimer visibles',
|
||||
disabled: loading.value || total.value === 0,
|
||||
onClick: deleteVisibleJobs,
|
||||
},
|
||||
],
|
||||
}));
|
||||
// Configuration de la toolbar réactive
|
||||
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 visibles',
|
||||
onClick: deleteVisibleJobs
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
activityStore.loadJobs();
|
||||
activityStore.subscribeMercure();
|
||||
});
|
||||
onMounted(() => {
|
||||
loadJobs();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
activityStore.unsubscribeMercure();
|
||||
});
|
||||
|
||||
function changePage(page) {
|
||||
activityStore.goToPage(page);
|
||||
}
|
||||
|
||||
function deleteJob(id) {
|
||||
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
|
||||
activityStore.deleteJob(id);
|
||||
function loadJobs() {
|
||||
activityStore.loadJobs();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteVisibleJobs() {
|
||||
if (activityStore.jobs.length === 0) return;
|
||||
if (confirm('Voulez-vous vraiment supprimer tous les jobs visibles ?')) {
|
||||
activityStore.deleteJobs({ status: ['pending', 'in_progress'] });
|
||||
function refreshJobs() {
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
activityStore.goToPage(page);
|
||||
}
|
||||
|
||||
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 (activityStore.jobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusLabel = statusOptions[activeStatusIndex.value].label.toLowerCase();
|
||||
if (confirm(`Voulez-vous vraiment supprimer tous les jobs ${statusLabel} ?`)) {
|
||||
activityStore.deleteJobs({ status: activityStore.filter.status });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20">
|
||||
<td class="px-4 py-2 text-gray-900 dark:text-gray-100" :class="{ 'text-green-500 dark:text-green-400': chapter.isAvailable }">
|
||||
<template v-if="chapter.isVolumeGroup">{{ chapter.volumeChaptersRange }}</template>
|
||||
<template v-if="chapter.isVolumeGroup">Vol. {{ chapter.volume }}</template>
|
||||
<template v-else>{{ String(chapter.number).padStart(2, '0') }}</template>
|
||||
</td>
|
||||
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">
|
||||
|
||||
@@ -94,14 +94,14 @@ import ReaderPage from './ReaderPage.vue';
|
||||
});
|
||||
};
|
||||
|
||||
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage, zoom inclus
|
||||
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
|
||||
const getPlaceholderHeight = (page) => {
|
||||
const dims = page?.dimensions;
|
||||
if (!dims?.width || !dims?.height) return Math.round(800 * props.zoom);
|
||||
if (!dims?.width || !dims?.height) return 800;
|
||||
const displayWidth = windowWidth.value < 1200
|
||||
? Math.min(dims.width, windowWidth.value * 0.95)
|
||||
: Math.min(dims.width, 1200);
|
||||
return Math.round((dims.height / dims.width) * displayWidth * props.zoom);
|
||||
return Math.round((dims.height / dims.width) * displayWidth);
|
||||
};
|
||||
|
||||
const setupObservers = () => {
|
||||
@@ -109,7 +109,7 @@ import ReaderPage from './ReaderPage.vue';
|
||||
visibilityObserver.value?.disconnect();
|
||||
|
||||
observer.value = new IntersectionObserver(observeIntersection, {
|
||||
root: containerRef.value,
|
||||
root: null,
|
||||
threshold: 0.5
|
||||
});
|
||||
|
||||
@@ -124,7 +124,7 @@ import ReaderPage from './ReaderPage.vue';
|
||||
}
|
||||
});
|
||||
},
|
||||
{ root: containerRef.value, rootMargin: '1000px 0px', threshold: 0 }
|
||||
{ root: null, rootMargin: '1000px 0px', threshold: 0 }
|
||||
);
|
||||
|
||||
nextTick(() => {
|
||||
@@ -328,6 +328,7 @@ import ReaderPage from './ReaderPage.vue';
|
||||
@apply flex-1 flex flex-col items-center overflow-y-auto relative min-h-0;
|
||||
/* Réduction du padding sur mobile */
|
||||
@apply py-2 sm:py-8;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
|
||||
@@ -87,9 +87,13 @@ import { useReaderStore } from '../../application/store/readerStore';
|
||||
|
||||
const store = useReaderStore();
|
||||
|
||||
// zoom via la propriété CSS `zoom` dans les deux modes (affecte le layout → pas de chevauchement en mode scroll)
|
||||
// En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles)
|
||||
// En mode infinite : zoom via transform: scale (pas d'impact layout souhaité)
|
||||
const containerStyle = computed(() => {
|
||||
return { zoom: props.zoom };
|
||||
if (store.readingMode === 'single') {
|
||||
return { zoom: props.zoom };
|
||||
}
|
||||
return { transform: `scale(${props.zoom})` };
|
||||
});
|
||||
|
||||
const imageRef = ref(null);
|
||||
|
||||
@@ -23,15 +23,7 @@ export const useContentSourceStore = defineStore('contentSource', {
|
||||
importing: false,
|
||||
exporting: false,
|
||||
importError: null,
|
||||
exportError: null,
|
||||
|
||||
// Health check state
|
||||
checkingHealth: false,
|
||||
checkHealthError: null,
|
||||
|
||||
// Delete state
|
||||
deleting: false,
|
||||
deleteError: null,
|
||||
exportError: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -176,64 +168,12 @@ export const useContentSourceStore = defineStore('contentSource', {
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a source
|
||||
async deleteSource(id) {
|
||||
if (this.deleting) return;
|
||||
|
||||
this.deleting = true;
|
||||
this.deleteError = null;
|
||||
|
||||
try {
|
||||
await contentSourceRepository.delete(id);
|
||||
this.sources = this.sources.filter(source => source.id !== id);
|
||||
if (this.currentSource && this.currentSource.id === id) {
|
||||
this.currentSource = null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.deleteError = error.message;
|
||||
console.error('Erreur lors de la suppression de la source:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.deleting = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear current source
|
||||
clearCurrentSource() {
|
||||
this.currentSource = null;
|
||||
this.currentSourceError = null;
|
||||
},
|
||||
|
||||
// Check all scrapers health
|
||||
async checkAllHealth() {
|
||||
if (this.checkingHealth) return;
|
||||
|
||||
this.checkingHealth = true;
|
||||
this.checkHealthError = null;
|
||||
|
||||
try {
|
||||
await contentSourceRepository.checkAllHealth();
|
||||
} catch (error) {
|
||||
this.checkHealthError = error.message;
|
||||
console.error('Erreur lors du health check:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.checkingHealth = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Update health status of a single source (called from Mercure)
|
||||
updateSourceHealth(sourceId, status, error = null) {
|
||||
const index = this.sources.findIndex(s => s.id === sourceId);
|
||||
if (index !== -1) {
|
||||
this.sources[index] = {
|
||||
...this.sources[index],
|
||||
healthStatus: status,
|
||||
healthLastError: error,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Clear errors
|
||||
clearErrors() {
|
||||
this.sourcesError = null;
|
||||
@@ -241,7 +181,6 @@ export const useContentSourceStore = defineStore('contentSource', {
|
||||
this.saveError = null;
|
||||
this.importError = null;
|
||||
this.exportError = null;
|
||||
this.checkHealthError = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export const ScraperHealthStatus = {
|
||||
UNKNOWN: 'unknown',
|
||||
OK: 'ok',
|
||||
KO: 'ko',
|
||||
TESTING: 'testing',
|
||||
};
|
||||
@@ -82,28 +82,6 @@ export class ApiContentSourceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenche le test de santé de tous les scrapers
|
||||
*/
|
||||
async checkAllHealth() {
|
||||
try {
|
||||
await this.apiClient.post('/scraping/check-all-health', {});
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erreur lors du lancement du health check');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une source de contenu
|
||||
*/
|
||||
async delete(id) {
|
||||
try {
|
||||
await this.apiClient.delete(`/content-sources/${id}`);
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erreur lors de la suppression de la source');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste une configuration de scraper
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
@click="$emit('edit', source)"
|
||||
class="bg-white dark:bg-gray-800 shadow-md border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow duration-200 cursor-pointer">
|
||||
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">
|
||||
@@ -20,24 +20,16 @@
|
||||
<!-- Badge type de scraping -->
|
||||
<span
|
||||
:class="getScrapingTypeBadgeClass(source.scrapingType)"
|
||||
class="px-2 py-1 text-xs font-medium">
|
||||
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">
|
||||
class="px-2 py-1 text-xs font-medium rounded-md">
|
||||
{{ getOrientation(source) }}
|
||||
</span>
|
||||
|
||||
<!-- Badge health status -->
|
||||
<span
|
||||
:class="getHealthBadgeClass(source.healthStatus)"
|
||||
class="px-2 py-1 text-xs font-medium"
|
||||
:title="source.healthLastError || ''">
|
||||
{{ getHealthLabel(source.healthStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,7 +39,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
|
||||
import { ScraperHealthStatus } from '../../domain/model/ScraperHealthStatus';
|
||||
|
||||
defineProps({
|
||||
source: {
|
||||
@@ -95,26 +86,4 @@ const getOrientationBadgeClass = (source) => {
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getHealthLabel = (status) => {
|
||||
switch (status) {
|
||||
case ScraperHealthStatus.OK: return '✓ ok';
|
||||
case ScraperHealthStatus.KO: return '✗ ko';
|
||||
case ScraperHealthStatus.TESTING: return '⟳ test';
|
||||
default: return '? unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getHealthBadgeClass = (status) => {
|
||||
switch (status) {
|
||||
case ScraperHealthStatus.OK:
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||
case ScraperHealthStatus.KO:
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||
case ScraperHealthStatus.TESTING:
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<TransitionRoot as="template" :show="isOpen">
|
||||
<Dialog as="div" class="relative z-50" @close="closeModal">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-out duration-300"
|
||||
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enter-to="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leave-from="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div class="mb-6">
|
||||
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
|
||||
Supprimer la source de contenu
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-6 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Warning message -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">Action irréversible</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Êtes-vous sûr de vouloir supprimer la source <strong>{{ source?.baseUrl }}</strong> ?
|
||||
</p>
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-md p-4">
|
||||
<div class="flex">
|
||||
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-300">
|
||||
Attention
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<p>Cette source ne pourra plus être utilisée pour le scraping des chapitres.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="closeModal"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="confirmDelete"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<ArrowPathIcon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
|
||||
{{ isLoading ? 'Suppression...' : 'Supprimer définitivement' }}
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
|
||||
import { ArrowPathIcon, ExclamationTriangleIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
source: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'confirm']);
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
</script>
|
||||
@@ -1,7 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<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="space-y-6">
|
||||
<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">
|
||||
@@ -12,12 +22,25 @@
|
||||
v-model="form.baseUrl"
|
||||
type="url"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
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 class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<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>
|
||||
@@ -26,132 +49,132 @@
|
||||
v-model="form.chapterUrlFormat"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
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>
|
||||
|
||||
<!-- Selectors -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
|
||||
<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 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder=".reading-content .page-break img" />
|
||||
</div>
|
||||
|
||||
<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">(laisser vide si lecteur vertical)</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 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder=".next-page" />
|
||||
</div>
|
||||
|
||||
<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">(requis pour le scraping Javascript)</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 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder=".chapter-selector" />
|
||||
</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>
|
||||
|
||||
<!-- Scraping Type + Token -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
|
||||
<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 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>
|
||||
<!-- 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>
|
||||
|
||||
<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 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Optional authentication token" />
|
||||
</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="border-t border-gray-200 dark:border-gray-700 pt-6 text-red-600 dark:text-red-400 text-sm">
|
||||
<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-700 pt-6 mt-6">
|
||||
<div class="flex items-center space-x-2 mb-6">
|
||||
<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-sm font-medium text-gray-900 dark:text-white">Configuration de test (health check)</h3>
|
||||
<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="testSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Manga Slug <span class="text-gray-500">(enregistré)</span>
|
||||
<label for="testMangaSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Manga Slug
|
||||
</label>
|
||||
<input
|
||||
id="testSlug"
|
||||
v-model="form.testSlug"
|
||||
id="testMangaSlug"
|
||||
v-model="testData.mangaSlug"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
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">
|
||||
Numéro de chapitre <span class="text-gray-500">(enregistré)</span>
|
||||
Chapter Number
|
||||
</label>
|
||||
<input
|
||||
id="testChapterNumber"
|
||||
v-model="form.testChapterNumber"
|
||||
v-model="testData.chapterNumber"
|
||||
type="number"
|
||||
step="0.1"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
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>
|
||||
|
||||
<!-- Preview URL -->
|
||||
<div v-if="generatedTestUrl" class="mb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">URL qui sera testée</p>
|
||||
<code class="text-xs text-gray-700 dark:text-gray-300 break-all">{{ generatedTestUrl }}</code>
|
||||
<!-- Preview de l'URL qui sera testée -->
|
||||
<div v-if="generatedTestUrl" class="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md">
|
||||
<div class="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>URL qui sera testée :</strong>
|
||||
<div class="mt-1 font-mono text-xs break-all">{{ generatedTestUrl }}</div>
|
||||
</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 transition-colors duration-200 flex items-center justify-center space-x-2">
|
||||
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>Tester maintenant</span>
|
||||
<span>Test Configuration</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,6 +183,8 @@
|
||||
<script setup>
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
Cog6ToothIcon,
|
||||
PencilSquareIcon,
|
||||
PlayIcon,
|
||||
WrenchScrewdriverIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
@@ -191,9 +216,12 @@ const form = ref({
|
||||
nextPageSelector: '',
|
||||
chapterSelector: '',
|
||||
scrapingType: 'html',
|
||||
token: '',
|
||||
testSlug: '',
|
||||
testChapterNumber: '',
|
||||
token: ''
|
||||
});
|
||||
|
||||
const testData = ref({
|
||||
mangaSlug: '',
|
||||
chapterNumber: ''
|
||||
});
|
||||
|
||||
const testing = ref(false);
|
||||
@@ -201,19 +229,20 @@ const testing = ref(false);
|
||||
const canTest = computed(() => {
|
||||
return form.value.baseUrl &&
|
||||
form.value.chapterUrlFormat &&
|
||||
form.value.testSlug &&
|
||||
form.value.testChapterNumber;
|
||||
testData.value.mangaSlug &&
|
||||
testData.value.chapterNumber;
|
||||
});
|
||||
|
||||
const generatedTestUrl = computed(() => {
|
||||
if (!form.value.chapterUrlFormat || !form.value.testSlug || !form.value.testChapterNumber) {
|
||||
if (!form.value.chapterUrlFormat || !testData.value.mangaSlug || !testData.value.chapterNumber) {
|
||||
return '';
|
||||
}
|
||||
return form.value.chapterUrlFormat
|
||||
.replace('{slug}', form.value.testSlug)
|
||||
.replace('{chapterNumber}', form.value.testChapterNumber);
|
||||
.replace('{slug}', testData.value.mangaSlug)
|
||||
.replace('{chapterNumber}', testData.value.chapterNumber);
|
||||
});
|
||||
|
||||
// Initialize form with source data if editing, clear if creating new
|
||||
watch(() => props.source, (newSource) => {
|
||||
if (newSource) {
|
||||
form.value = {
|
||||
@@ -223,11 +252,10 @@ watch(() => props.source, (newSource) => {
|
||||
nextPageSelector: newSource.nextPageSelector || '',
|
||||
chapterSelector: newSource.chapterSelector || '',
|
||||
scrapingType: (newSource.scrapingType || 'html').toLowerCase(),
|
||||
token: newSource.token || '',
|
||||
testSlug: newSource.testSlug || '',
|
||||
testChapterNumber: newSource.testChapterNumber ?? '',
|
||||
token: newSource.token || ''
|
||||
};
|
||||
} else {
|
||||
// Reset form when no source (creating new)
|
||||
form.value = {
|
||||
baseUrl: '',
|
||||
imageSelector: '',
|
||||
@@ -235,37 +263,23 @@ watch(() => props.source, (newSource) => {
|
||||
nextPageSelector: '',
|
||||
chapterSelector: '',
|
||||
scrapingType: 'html',
|
||||
token: '',
|
||||
testSlug: '',
|
||||
testChapterNumber: '',
|
||||
token: ''
|
||||
};
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const buildPayload = (formData) => {
|
||||
const data = { ...formData };
|
||||
const raw = data.testChapterNumber;
|
||||
data.testChapterNumber = (raw === '' || raw === null || raw === undefined)
|
||||
? null
|
||||
: parseFloat(raw);
|
||||
return data;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit', buildPayload(form.value));
|
||||
emit('submit', { ...form.value });
|
||||
};
|
||||
|
||||
defineExpose({ submitForm: handleSubmit });
|
||||
|
||||
const testConfiguration = async () => {
|
||||
testing.value = true;
|
||||
try {
|
||||
await emit('test', {
|
||||
configuration: buildPayload(form.value),
|
||||
configuration: { ...form.value },
|
||||
testData: {
|
||||
mangaSlug: form.value.testSlug,
|
||||
chapterNumber: form.value.testChapterNumber,
|
||||
testUrl: generatedTestUrl.value,
|
||||
...testData.value,
|
||||
testUrl: generatedTestUrl.value
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -3,54 +3,72 @@
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
<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 h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
<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 p-4 mb-6">
|
||||
<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 hover:bg-red-700">
|
||||
class="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sources Grid -->
|
||||
<section v-else class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div 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" />
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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 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>
|
||||
<!-- 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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Import/Export Success Messages -->
|
||||
<div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
|
||||
<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 shadow-lg">
|
||||
<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>
|
||||
@@ -58,12 +76,12 @@
|
||||
|
||||
<!-- 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 shadow-xl w-full max-w-md">
|
||||
<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 dark:bg-gray-700 dark:text-white"
|
||||
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">
|
||||
@@ -75,7 +93,7 @@
|
||||
<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">
|
||||
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>
|
||||
@@ -91,11 +109,10 @@ import {
|
||||
ArrowPathIcon,
|
||||
ArrowUpTrayIcon,
|
||||
ExclamationTriangleIcon,
|
||||
HeartIcon,
|
||||
PlusIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
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';
|
||||
@@ -109,13 +126,9 @@ const {
|
||||
loadingSources,
|
||||
sourcesError,
|
||||
importing,
|
||||
exporting,
|
||||
checkingHealth,
|
||||
exporting
|
||||
} = storeToRefs(contentSourceStore);
|
||||
|
||||
// Mercure — écoute des mises à jour health
|
||||
let mercureEventSource = null;
|
||||
|
||||
// Local state
|
||||
const showImportModal = ref(false);
|
||||
const showExportSuccess = ref(false);
|
||||
@@ -125,45 +138,40 @@ const importData = ref('');
|
||||
// Load sources on mount and clear current source
|
||||
onMounted(async () => {
|
||||
try {
|
||||
contentSourceStore.clearCurrentSource();
|
||||
contentSourceStore.clearErrors();
|
||||
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);
|
||||
}
|
||||
|
||||
// Écoute Mercure pour les mises à jour de health status
|
||||
const url = new URL('/.well-known/mercure', window.location.href);
|
||||
sources.value.forEach(source => {
|
||||
url.searchParams.append('topic', `scrapers/health/${source.id}`);
|
||||
});
|
||||
|
||||
mercureEventSource = new EventSource(url.toString());
|
||||
mercureEventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
contentSourceStore.updateSourceHealth(data.sourceId, data.status, data.error);
|
||||
} catch (e) {
|
||||
console.error('Erreur parsing Mercure event:', e);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mercureEventSource?.close();
|
||||
});
|
||||
|
||||
// Toolbar configuration
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Scrapers', class: 'text-sm font-medium' },
|
||||
{
|
||||
icon: ArrowPathIcon,
|
||||
label: 'Actualiser',
|
||||
type: 'button',
|
||||
onClick: () => contentSourceStore.loadSources(),
|
||||
active: loadingSources.value
|
||||
}
|
||||
],
|
||||
rightSection: [
|
||||
{ type: 'button', icon: ArrowPathIcon, label: 'Actualiser', onClick: () => contentSourceStore.loadSources(), disabled: loadingSources.value },
|
||||
{ type: 'button', icon: HeartIcon, label: 'Tester tous', onClick: handleCheckAllHealth, disabled: checkingHealth.value },
|
||||
{ type: 'button', icon: ArrowDownTrayIcon, label: 'Exporter', onClick: handleExport, disabled: exporting.value },
|
||||
{ type: 'button', icon: ArrowUpTrayIcon, label: 'Importer', onClick: () => showImportModal.value = true },
|
||||
],
|
||||
{
|
||||
icon: ArrowDownTrayIcon,
|
||||
label: 'Exporter',
|
||||
type: 'button',
|
||||
onClick: handleExport,
|
||||
disabled: exporting.value
|
||||
},
|
||||
{
|
||||
icon: ArrowUpTrayIcon,
|
||||
label: 'Importer',
|
||||
type: 'button',
|
||||
onClick: () => showImportModal.value = true
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
// Actions
|
||||
@@ -182,14 +190,6 @@ const openSourceLink = (url) => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
async function handleCheckAllHealth() {
|
||||
try {
|
||||
await contentSourceStore.checkAllHealth();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du health check:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const exportData = await contentSourceStore.exportSources();
|
||||
|
||||
@@ -3,36 +3,43 @@
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div class="px-6 py-8">
|
||||
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
|
||||
<div class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Form -->
|
||||
<div v-else>
|
||||
<ContentSourceForm
|
||||
ref="formRef"
|
||||
:source="currentSource"
|
||||
:saving="saving"
|
||||
:error="saveError"
|
||||
@submit="handleSubmit"
|
||||
@test="handleTest" />
|
||||
<!-- 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>
|
||||
</section>
|
||||
</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 shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl 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>
|
||||
@@ -47,7 +54,7 @@
|
||||
<div class="p-6 overflow-y-auto">
|
||||
<!-- Loading state during test -->
|
||||
<div v-if="testingConfiguration" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
|
||||
<span class="text-gray-600">Test en cours...</span>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +65,7 @@
|
||||
<span class="font-medium">Test réussi !</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 p-4">
|
||||
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
|
||||
@@ -85,11 +92,10 @@
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="`Image ${index + 1}`"
|
||||
class="w-full h-32 object-cover border border-gray-200 dark:border-gray-600"
|
||||
referrerpolicy="no-referrer"
|
||||
class="w-full h-32 object-cover rounded border border-gray-200 dark:border-gray-600"
|
||||
@error="handleImageError"
|
||||
@load="handleImageLoad" />
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity rounded flex items-center justify-center">
|
||||
<span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium">
|
||||
Page {{ index + 1 }}
|
||||
</span>
|
||||
@@ -101,7 +107,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 p-4">
|
||||
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<ExclamationTriangleIcon class="w-5 h-5 text-yellow-400 mr-2" />
|
||||
<p class="text-yellow-800 dark:text-yellow-200">
|
||||
@@ -119,7 +125,7 @@
|
||||
<span class="font-medium">Test échoué</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-4">
|
||||
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-4">
|
||||
<div class="text-sm text-red-800 dark:text-red-200">
|
||||
<div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div>
|
||||
<div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div>
|
||||
@@ -132,14 +138,14 @@
|
||||
<div
|
||||
v-for="(error, index) in testResults.errors"
|
||||
:key="index"
|
||||
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4">
|
||||
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4 rounded">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<ExclamationTriangleIcon class="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<div class="flex items-center mb-1">
|
||||
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
|
||||
{{ formatErrorType(error.type) }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
@@ -149,7 +155,7 @@
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mb-2">
|
||||
{{ error.message }}
|
||||
</p>
|
||||
<div class="bg-red-50 dark:bg-red-900 p-2">
|
||||
<div class="bg-red-50 dark:bg-red-900 rounded p-2">
|
||||
<p class="text-xs text-red-600 dark:text-red-400">
|
||||
<strong>Suggestion :</strong> {{ error.suggestion }}
|
||||
</p>
|
||||
@@ -160,7 +166,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Generic Error -->
|
||||
<div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-3">
|
||||
<div v-else-if="testResults.error" 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>
|
||||
@@ -171,20 +177,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
|
||||
<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>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<ContentSourceDeleteModal
|
||||
:is-open="isDeleteModalOpen"
|
||||
:source="currentSource"
|
||||
:is-loading="isDeleting"
|
||||
:error="deleteError"
|
||||
@close="isDeleteModalOpen = false"
|
||||
@confirm="confirmDeleteSource" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -193,8 +190,6 @@ import {
|
||||
ArrowLeftIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
XCircleIcon,
|
||||
XMarkIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
@@ -204,7 +199,6 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useContentSourceStore } from '../../application/store/contentSourceStore';
|
||||
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
|
||||
import ContentSourceDeleteModal from '../components/ContentSourceDeleteModal.vue';
|
||||
import ContentSourceForm from '../components/ContentSourceForm.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -220,17 +214,11 @@ const {
|
||||
saveError
|
||||
} = storeToRefs(contentSourceStore);
|
||||
|
||||
// Form ref
|
||||
const formRef = ref(null);
|
||||
|
||||
// Local state
|
||||
const showTestResults = ref(false);
|
||||
const showSuccessMessage = ref(false);
|
||||
const testResults = ref({});
|
||||
const testingConfiguration = ref(false);
|
||||
const isDeleteModalOpen = ref(false);
|
||||
const isDeleting = ref(false);
|
||||
const deleteError = ref(null);
|
||||
|
||||
const isEditing = computed(() => !!route.params.id);
|
||||
|
||||
@@ -245,19 +233,16 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
// Toolbar configuration
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'button', icon: ArrowLeftIcon, label: 'Retour', onClick: () => router.push({ name: 'scrapper-configurations' }) },
|
||||
{ type: 'divider' },
|
||||
{ type: 'label', text: isEditing.value ? 'Modifier la configuration' : 'Nouvelle configuration', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
...(isEditing.value ? [{ type: 'button', icon: TrashIcon, label: 'Supprimer', onClick: () => { isDeleteModalOpen.value = true; }, class: 'text-red-600 hover:text-red-700' }, { type: 'divider' }] : []),
|
||||
{ type: 'button', icon: PencilSquareIcon, label: isEditing.value ? 'Mettre à jour' : 'Créer', onClick: () => formRef.value?.submitForm(), disabled: saving.value },
|
||||
],
|
||||
}));
|
||||
const toolbarConfig = {
|
||||
leftSection: [],
|
||||
rightSection: []
|
||||
};
|
||||
|
||||
// Actions
|
||||
const goBack = () => {
|
||||
router.push({ name: 'scrapper-configurations' });
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData) => {
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
@@ -294,11 +279,6 @@ const handleTest = async ({ configuration, testData }) => {
|
||||
testResults.value = {};
|
||||
|
||||
try {
|
||||
// Persister testSlug + testChapterNumber avant de lancer le test
|
||||
if (isEditing.value) {
|
||||
await contentSourceStore.updateSource(route.params.id, configuration);
|
||||
}
|
||||
|
||||
// Préparer les données selon le format de l'API
|
||||
const testConfiguration = {
|
||||
baseUrl: configuration.baseUrl,
|
||||
@@ -343,21 +323,6 @@ const handleImageLoad = (event) => {
|
||||
event.target.style.display = 'block';
|
||||
};
|
||||
|
||||
const confirmDeleteSource = async () => {
|
||||
isDeleting.value = true;
|
||||
deleteError.value = null;
|
||||
|
||||
try {
|
||||
await contentSourceStore.deleteSource(route.params.id);
|
||||
isDeleteModalOpen.value = false;
|
||||
await router.push({ name: 'scrapper-configurations' });
|
||||
} catch (error) {
|
||||
deleteError.value = error.message;
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatErrorType = (type) => {
|
||||
const typeMap = {
|
||||
'selector_error': 'Erreur sélecteur',
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ApiJobRepository } from '../../../activity/infrastructure/api/ApiJobRepository';
|
||||
|
||||
const jobRepository = new ApiJobRepository();
|
||||
|
||||
// Statuts disponibles par filtre
|
||||
const STATUS_MAP = {
|
||||
failed: ['failed'],
|
||||
completed: ['completed'],
|
||||
all: ['failed', 'completed'],
|
||||
};
|
||||
|
||||
export const useLogsStore = defineStore('logs', {
|
||||
state: () => ({
|
||||
logs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
total: 0,
|
||||
limit: 50,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
statusFilter: 'failed', // 'failed' | 'completed' | 'all'
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isLoading: state => state.loading,
|
||||
hasError: state => !!state.error,
|
||||
},
|
||||
|
||||
actions: {
|
||||
async loadLogs(page = null) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const collection = await jobRepository.getJobs({
|
||||
page: page || this.currentPage,
|
||||
limit: this.limit,
|
||||
sortBy: this.sortBy,
|
||||
sortOrder: this.sortOrder,
|
||||
status: STATUS_MAP[this.statusFilter],
|
||||
type: 'scraping_job',
|
||||
});
|
||||
|
||||
this.logs = collection.items;
|
||||
this.currentPage = collection.page;
|
||||
this.total = collection.total;
|
||||
this.hasNextPage = collection.hasNextPage;
|
||||
this.hasPreviousPage = collection.hasPreviousPage;
|
||||
this.totalPages = Math.ceil(this.total / this.limit);
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
await this.loadLogs(page);
|
||||
}
|
||||
},
|
||||
|
||||
async updateSort(sortBy, sortOrder) {
|
||||
this.sortBy = sortBy;
|
||||
this.sortOrder = sortOrder;
|
||||
this.currentPage = 1;
|
||||
await this.loadLogs(1);
|
||||
},
|
||||
|
||||
async setStatusFilter(filter) {
|
||||
this.statusFilter = filter;
|
||||
this.currentPage = 1;
|
||||
await this.loadLogs(1);
|
||||
},
|
||||
|
||||
async deleteLog(id) {
|
||||
try {
|
||||
await jobRepository.deleteJob(id);
|
||||
this.logs = this.logs.filter(log => log.id !== id);
|
||||
this.total = Math.max(0, this.total - 1);
|
||||
this.totalPages = Math.ceil(this.total / this.limit);
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAllLogs() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
await jobRepository.deleteJobs({
|
||||
status: STATUS_MAP[this.statusFilter].join(','),
|
||||
type: 'scraping_job',
|
||||
});
|
||||
await this.loadLogs(1);
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ApiStatusRepository } from '../../infrastructure/api/ApiStatusRepository';
|
||||
|
||||
const statusRepository = new ApiStatusRepository();
|
||||
|
||||
export const useStatusStore = defineStore('system-status', {
|
||||
state: () => ({
|
||||
status: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async loadStatus() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
this.status = await statusRepository.getStatus();
|
||||
} catch (e) {
|
||||
this.error = e.message ?? 'Erreur lors du chargement du statut système';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
export class ApiStatusRepository {
|
||||
async getStatus() {
|
||||
const response = await fetch('/api/system/status', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<StatusCard title="Chapitres" :icon="DocumentTextIcon">
|
||||
<div class="flex items-baseline gap-2 mb-3">
|
||||
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalChapters }}</span>
|
||||
<span class="text-sm text-gray-500">total</span>
|
||||
</div>
|
||||
<div class="mb-1 flex justify-between text-xs text-gray-500">
|
||||
<span>{{ status.downloadedChapters }} téléchargés</span>
|
||||
<span>{{ downloadedPercent }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-green-500 rounded-full transition-all"
|
||||
:style="{ width: downloadedPercent + '%' }" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">{{ status.pendingChapters }} en attente</p>
|
||||
</StatusCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { DocumentTextIcon } from '@heroicons/vue/24/outline';
|
||||
import StatusCard from './StatusCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const downloadedPercent = computed(() => {
|
||||
if (!props.status.totalChapters) return 0;
|
||||
return Math.round((props.status.downloadedChapters / props.status.totalChapters) * 100);
|
||||
});
|
||||
</script>
|
||||
@@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<StatusCard title="Jobs" :icon="CpuChipIcon">
|
||||
<!-- Onglets -->
|
||||
<div class="flex gap-1 mb-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'">
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<template v-if="activeTab === 'global'">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Metric label="Total" :value="status.totalJobs" />
|
||||
<Metric label="En cours" :value="status.inProgressJobs" color="blue" />
|
||||
<Metric label="Terminés" :value="status.completedJobs" color="green" />
|
||||
<Metric label="En attente" :value="status.pendingJobs" color="yellow" />
|
||||
<Metric label="Échoués" :value="status.failedJobs" color="red" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === '24h'">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Metric label="Total" :value="status.totalJobsLast24h" />
|
||||
<Metric label="Terminés" :value="status.completedJobsLast24h" color="green" />
|
||||
<Metric label="Échoués" :value="status.failedJobsLast24h" color="red" />
|
||||
<div class="col-span-2">
|
||||
<p class="text-xs text-gray-500 mb-1">Taux de succès</p>
|
||||
<span class="text-xl font-bold" :class="rateColor(status.successRateLast24h)">
|
||||
{{ status.successRateLast24h }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Metric label="Total" :value="status.totalJobsLast7d" />
|
||||
<Metric label="Terminés" :value="status.completedJobsLast7d" color="green" />
|
||||
<Metric label="Échoués" :value="status.failedJobsLast7d" color="red" />
|
||||
<div class="col-span-2">
|
||||
<p class="text-xs text-gray-500 mb-1">Taux de succès</p>
|
||||
<span class="text-xl font-bold" :class="rateColor(status.successRateLast7d)">
|
||||
{{ status.successRateLast7d }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</StatusCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { CpuChipIcon } from '@heroicons/vue/24/outline';
|
||||
import StatusCard from './StatusCard.vue';
|
||||
|
||||
defineProps({
|
||||
status: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const activeTab = ref('global');
|
||||
|
||||
const tabs = [
|
||||
{ key: 'global', label: 'Global' },
|
||||
{ key: '24h', label: '24h' },
|
||||
{ key: '7j', label: '7 jours' },
|
||||
];
|
||||
|
||||
function rateColor(rate) {
|
||||
if (rate >= 80) return 'text-green-600 dark:text-green-400';
|
||||
if (rate >= 50) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
}
|
||||
|
||||
const Metric = {
|
||||
props: {
|
||||
label: String,
|
||||
value: Number,
|
||||
color: { type: String, default: 'gray' },
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">{{ label }}</p>
|
||||
<p class="text-lg font-semibold"
|
||||
:class="{
|
||||
'text-gray-900 dark:text-white': color === 'gray',
|
||||
'text-green-600 dark:text-green-400': color === 'green',
|
||||
'text-red-600 dark:text-red-400': color === 'red',
|
||||
'text-yellow-600 dark:text-yellow-400': color === 'yellow',
|
||||
'text-blue-600 dark:text-blue-400': color === 'blue',
|
||||
}">{{ value }}</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
</script>
|
||||
@@ -1,131 +0,0 @@
|
||||
<template>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 py-4 px-6">
|
||||
<!-- Ligne 1 : Titre manga + chapitre + badge statut + date + bouton supprimer -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-baseline gap-2 min-w-0">
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ log.context?.mangaTitle ?? 'Manga inconnu' }}
|
||||
</span>
|
||||
<span class="text-gray-400 dark:text-gray-500 text-sm shrink-0">•</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 shrink-0">
|
||||
Chapitre {{ log.context?.chapterNumber ?? '?' }}
|
||||
</span>
|
||||
<span
|
||||
:class="[
|
||||
'px-1.5 py-0.5 text-xs font-medium shrink-0',
|
||||
log.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
]">
|
||||
{{ log.status === 'completed' ? 'Terminé' : 'Échec' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ formattedDate }}</span>
|
||||
<button
|
||||
@click="$emit('delete', log.id)"
|
||||
class="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors"
|
||||
title="Supprimer ce log">
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 : Source + slug + durée -->
|
||||
<div class="flex items-center justify-between mt-1 gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400">
|
||||
<!-- Domaine de la source (lien vers la page d'édition) -->
|
||||
<RouterLink
|
||||
v-if="source"
|
||||
:to="{ name: 'scrapper-edit', params: { id: source.id } }"
|
||||
class="flex items-center gap-1 hover:text-blue-500 dark:hover:text-blue-400 transition-colors shrink-0">
|
||||
<GlobeAltIcon class="w-3.5 h-3.5" />
|
||||
<span class="font-mono">{{ cleanDomain }}</span>
|
||||
</RouterLink>
|
||||
<span v-else class="font-mono shrink-0">
|
||||
ID {{ log.context?.sourceId ?? '-' }}
|
||||
</span>
|
||||
|
||||
<!-- Badge type de scraping -->
|
||||
<span
|
||||
v-if="source?.scrapingType"
|
||||
:class="[
|
||||
'px-1.5 py-0.5 text-xs font-medium shrink-0',
|
||||
source.scrapingType === 'Javascript'
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
]">
|
||||
{{ source.scrapingType }}
|
||||
</span>
|
||||
|
||||
<!-- Slug utilisé -->
|
||||
<span v-if="log.context?.slug" class="truncate text-gray-400 dark:text-gray-500">
|
||||
slug : <span class="font-mono">{{ log.context.slug }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span v-if="duration !== null" class="text-xs text-gray-400 dark:text-gray-500 shrink-0">
|
||||
{{ duration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 3 : Message d'erreur -->
|
||||
<div v-if="log.error" class="mt-2">
|
||||
<p
|
||||
:class="[
|
||||
'text-sm font-mono text-red-600 dark:text-red-400',
|
||||
!expanded && isLong ? 'line-clamp-1' : ''
|
||||
]">
|
||||
↳ {{ log.error }}
|
||||
</p>
|
||||
<button
|
||||
v-if="isLong"
|
||||
@click="expanded = !expanded"
|
||||
class="mt-1 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||
{{ expanded ? 'voir moins' : 'voir plus' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { GlobeAltIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { computed, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
log: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
source: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['delete']);
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
const isLong = computed(() => props.log.error && props.log.error.length > 120);
|
||||
|
||||
const cleanDomain = computed(() => {
|
||||
if (!props.source?.baseUrl) return null;
|
||||
return props.source.baseUrl.replace(/^(https?:\/\/)?(www\.)?/, '').replace(/\/+$/, '');
|
||||
});
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.log.createdAt) return '';
|
||||
const d = new Date(props.log.createdAt);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
});
|
||||
|
||||
const duration = computed(() => {
|
||||
if (!props.log.startedAt || !props.log.completedAt) return null;
|
||||
const ms = new Date(props.log.completedAt) - new Date(props.log.startedAt);
|
||||
if (ms < 0) return null;
|
||||
return `${(ms / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 })}s`;
|
||||
});
|
||||
</script>
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<StatusCard title="Mangas" :icon="BookOpenIcon">
|
||||
<div class="flex items-baseline gap-2 mb-3">
|
||||
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalMangas }}</span>
|
||||
<span class="text-sm text-gray-500">total</span>
|
||||
<span class="ml-auto text-sm text-blue-600 dark:text-blue-400">{{ status.monitoredMangas }} suivis</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(count, label) in status.mangasByStatus"
|
||||
:key="label"
|
||||
class="px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400">
|
||||
{{ label }}: {{ count }}
|
||||
</span>
|
||||
<span v-if="!hasStatuses" class="text-xs text-gray-400">Aucun statut disponible</span>
|
||||
</div>
|
||||
</StatusCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { BookOpenIcon } from '@heroicons/vue/24/outline';
|
||||
import StatusCard from './StatusCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hasStatuses = computed(() => Object.keys(props.status.mangasByStatus ?? {}).length > 0);
|
||||
</script>
|
||||
@@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<StatusCard title="Sources" :icon="GlobeAltIcon">
|
||||
<div class="flex items-baseline gap-2 mb-3">
|
||||
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.totalSources }}</span>
|
||||
<span class="text-sm text-gray-500">sources configurées</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(count, health) in status.sourcesByHealth"
|
||||
:key="health"
|
||||
class="px-2 py-0.5 text-xs rounded-full"
|
||||
:class="healthBadgeClass(health)">
|
||||
{{ health }}: {{ count }}
|
||||
</span>
|
||||
<span v-if="!hasSources" class="text-xs text-gray-400">Aucune source</span>
|
||||
</div>
|
||||
</StatusCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { GlobeAltIcon } from '@heroicons/vue/24/outline';
|
||||
import StatusCard from './StatusCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hasSources = computed(() => Object.keys(props.status.sourcesByHealth ?? {}).length > 0);
|
||||
|
||||
function healthBadgeClass(health) {
|
||||
switch (health) {
|
||||
case 'healthy': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
case 'unhealthy': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<component :is="icon" v-if="icon" class="w-5 h-5 text-blue-500 shrink-0" />
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ title }}</h3>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: [Object, Function],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,40 +0,0 @@
|
||||
<template>
|
||||
<StatusCard title="Stockage" :icon="CircleStackIcon">
|
||||
<div class="flex items-baseline gap-2 mb-3">
|
||||
<span class="text-3xl font-bold text-gray-900 dark:text-white">{{ status.storageUsedHuman }}</span>
|
||||
<span class="text-sm text-gray-500">utilisés</span>
|
||||
</div>
|
||||
<div class="mb-1 flex justify-between text-xs text-gray-500">
|
||||
<span>{{ status.storageFreeHuman }} libres</span>
|
||||
<span>{{ usedPercent }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="usedPercent > 90 ? 'bg-red-500' : 'bg-blue-500'"
|
||||
:style="{ width: usedPercent + '%' }" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">Total : {{ status.storageTotalHuman }}</p>
|
||||
<p class="mt-1 text-xs text-gray-400 truncate" :title="status.storagePath">{{ status.storagePath }}</p>
|
||||
</StatusCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { CircleStackIcon } from '@heroicons/vue/24/outline';
|
||||
import StatusCard from './StatusCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const diskUsedBytes = computed(() => props.status.storageTotalBytes - props.status.storageFreeBytes);
|
||||
|
||||
const usedPercent = computed(() => {
|
||||
if (!props.status.storageTotalBytes) return 0;
|
||||
return Math.round((diskUsedBytes.value / props.status.storageTotalBytes) * 100);
|
||||
});
|
||||
</script>
|
||||
@@ -1,32 +0,0 @@
|
||||
<template>
|
||||
<StatusCard title="Informations système" :icon="ServerIcon">
|
||||
<dl class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500">Version PHP</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ status.phpVersion }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<dt class="text-gray-500">Généré le</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{{ formattedDate }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</StatusCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ServerIcon } from '@heroicons/vue/24/outline';
|
||||
import StatusCard from './StatusCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.status.generatedAt) return '';
|
||||
return new Date(props.status.generatedAt).toLocaleString('fr-FR');
|
||||
});
|
||||
</script>
|
||||
@@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<section class="border-t border-gray-200 dark:border-gray-700">
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="flex justify-center py-12">
|
||||
<div class="animate-spin h-10 w-10 border-b-2 border-blue-500 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="hasError" class="px-6 py-8">
|
||||
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4">
|
||||
<div class="flex items-center">
|
||||
<ExclamationCircleIcon class="w-5 h-5 text-red-400 mr-2 shrink-0" />
|
||||
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="logsStore.loadLogs()"
|
||||
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!isLoading && logs.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-500">
|
||||
<ExclamationCircleIcon class="w-12 h-12 mb-3" />
|
||||
<p class="text-base">Aucune erreur de scraping</p>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<template v-else>
|
||||
<LogItem
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
:log="log"
|
||||
:source="getSource(log)"
|
||||
@delete="handleDelete" />
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
:total="total"
|
||||
:limit="limit"
|
||||
:has-next-page="hasNextPage"
|
||||
:has-previous-page="hasPreviousPage"
|
||||
@page-change="logsStore.goToPage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowPathIcon, ExclamationCircleIcon, TrashIcon } from '@heroicons/vue/24/outline';
|
||||
import { BarsArrowDownIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import Pagination from '../../../../shared/components/ui/Pagination.vue';
|
||||
import { useContentSourceStore } from '../../../setting/application/store/contentSourceStore';
|
||||
import { useLogsStore } from '../../application/store/logsStore';
|
||||
import LogItem from '../components/LogItem.vue';
|
||||
|
||||
const logsStore = useLogsStore();
|
||||
const contentSourceStore = useContentSourceStore();
|
||||
const { sources } = storeToRefs(contentSourceStore);
|
||||
|
||||
const {
|
||||
logs,
|
||||
loading: isLoading,
|
||||
error,
|
||||
currentPage,
|
||||
totalPages,
|
||||
total,
|
||||
limit,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
statusFilter,
|
||||
} = storeToRefs(logsStore);
|
||||
|
||||
const hasError = computed(() => !!error.value);
|
||||
|
||||
onMounted(() => {
|
||||
logsStore.loadLogs();
|
||||
contentSourceStore.loadSources();
|
||||
});
|
||||
|
||||
function getSource(log) {
|
||||
const sourceId = log.context?.sourceId;
|
||||
if (!sourceId) return null;
|
||||
// eslint-disable-next-line eqeqeq
|
||||
return sources.value.find(s => s.id == sourceId) ?? null;
|
||||
}
|
||||
|
||||
const isSortSelected = (by, order) => sortBy.value === by && sortOrder.value === order;
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ key: 'failed', label: 'Échecs' },
|
||||
{ key: 'completed', label: 'Terminés' },
|
||||
{ key: 'all', label: 'Tous' },
|
||||
];
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Logs', class: 'text-sm font-medium' },
|
||||
{ type: 'label', text: `(${total.value})`, class: 'text-sm text-gray-400' },
|
||||
],
|
||||
rightSection: [
|
||||
...STATUS_FILTERS.map(f => ({
|
||||
type: 'button',
|
||||
label: f.label,
|
||||
active: statusFilter.value === f.key,
|
||||
onClick: () => logsStore.setStatusFilter(f.key),
|
||||
})),
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'dropdown',
|
||||
icon: BarsArrowDownIcon,
|
||||
label: 'Trier',
|
||||
items: [
|
||||
{
|
||||
label: 'Plus récent',
|
||||
isSelected: isSortSelected('createdAt', 'DESC'),
|
||||
onClick: () => logsStore.updateSort('createdAt', 'DESC'),
|
||||
},
|
||||
{
|
||||
label: 'Plus ancien',
|
||||
isSelected: isSortSelected('createdAt', 'ASC'),
|
||||
onClick: () => logsStore.updateSort('createdAt', 'ASC'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: ArrowPathIcon,
|
||||
label: 'Rafraîchir',
|
||||
disabled: isLoading.value,
|
||||
onClick: () => logsStore.loadLogs(),
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
icon: TrashIcon,
|
||||
label: 'Tout supprimer',
|
||||
disabled: isLoading.value || total.value === 0,
|
||||
onClick: handleDeleteAll,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
async function handleDelete(id) {
|
||||
await logsStore.deleteLog(id);
|
||||
}
|
||||
|
||||
async function handleDeleteAll() {
|
||||
if (!confirm('Supprimer tous les logs d\'erreur ? Cette action est irréversible.')) return;
|
||||
await logsStore.deleteAllLogs();
|
||||
}
|
||||
</script>
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Toolbar :config="toolbarConfig" />
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<div class="animate-spin h-10 w-10 border-b-2 border-blue-500 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="px-6 py-8">
|
||||
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 rounded">
|
||||
<div class="flex items-center">
|
||||
<ExclamationCircleIcon class="w-5 h-5 text-red-400 mr-2 shrink-0" />
|
||||
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="statusStore.loadStatus()"
|
||||
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Données -->
|
||||
<div v-else-if="status" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4">
|
||||
<MangasStatusCard :status="status" />
|
||||
<ChaptersStatusCard :status="status" />
|
||||
<JobsStatusCard :status="status" />
|
||||
<StorageStatusCard :status="status" />
|
||||
<SourcesStatusCard :status="status" />
|
||||
<SystemInfoCard :status="status" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowPathIcon, ExclamationCircleIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useStatusStore } from '../../application/store/statusStore';
|
||||
import ChaptersStatusCard from '../components/ChaptersStatusCard.vue';
|
||||
import JobsStatusCard from '../components/JobsStatusCard.vue';
|
||||
import MangasStatusCard from '../components/MangasStatusCard.vue';
|
||||
import SourcesStatusCard from '../components/SourcesStatusCard.vue';
|
||||
import StorageStatusCard from '../components/StorageStatusCard.vue';
|
||||
import SystemInfoCard from '../components/SystemInfoCard.vue';
|
||||
|
||||
const statusStore = useStatusStore();
|
||||
const { status, loading, error } = storeToRefs(statusStore);
|
||||
|
||||
onMounted(() => statusStore.loadStatus());
|
||||
|
||||
const toolbarConfig = computed(() => ({
|
||||
leftSection: [
|
||||
{ type: 'label', text: 'Statut système', class: 'text-sm font-medium' },
|
||||
],
|
||||
rightSection: [
|
||||
{
|
||||
type: 'button',
|
||||
icon: ArrowPathIcon,
|
||||
label: 'Rafraîchir',
|
||||
disabled: loading.value,
|
||||
onClick: () => statusStore.loadStatus(),
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
@@ -10,10 +10,24 @@ import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
|
||||
import ScrapperConfigurations from '../domain/setting/presentation/pages/ScrapperConfigurations.vue';
|
||||
import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue';
|
||||
import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue';
|
||||
import LogsPage from '../domain/system/presentation/pages/LogsPage.vue';
|
||||
import StatusPage from '../domain/system/presentation/pages/StatusPage.vue';
|
||||
import Layout from '../shared/components/layout/Layout.vue';
|
||||
|
||||
// Placeholder component for new routes
|
||||
const PlaceholderComponent = {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold mb-4">{{ title }}</h1>
|
||||
<p class="text-gray-600">Cette fonctionnalité sera bientôt disponible.</p>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
@@ -51,6 +65,13 @@ const routes = [
|
||||
name: 'import',
|
||||
component: NewImportPage
|
||||
},
|
||||
// Pages placeholder avec chargement différé
|
||||
{
|
||||
path: '/manga/import',
|
||||
name: 'manga-import',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Import de bibliothèque' }
|
||||
},
|
||||
{
|
||||
path: '/manga/discover',
|
||||
name: 'discover',
|
||||
@@ -69,7 +90,21 @@ const routes = [
|
||||
// Paramètres
|
||||
{
|
||||
path: '/settings',
|
||||
redirect: '/settings/scrappers',
|
||||
name: 'settings',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Paramètres' }
|
||||
},
|
||||
{
|
||||
path: '/settings/general',
|
||||
name: 'settings-general',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Paramètres généraux' }
|
||||
},
|
||||
{
|
||||
path: '/settings/folders',
|
||||
name: 'settings-folders',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Gestion des dossiers' }
|
||||
},
|
||||
{
|
||||
path: '/settings/scrappers',
|
||||
@@ -94,18 +129,34 @@ const routes = [
|
||||
// Système
|
||||
{
|
||||
path: '/system',
|
||||
redirect: '/system/status',
|
||||
name: 'system',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Système' }
|
||||
},
|
||||
{
|
||||
path: '/system/status',
|
||||
name: 'system-status',
|
||||
component: StatusPage,
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Status du système' }
|
||||
},
|
||||
{
|
||||
path: '/system/backup',
|
||||
name: 'system-backup',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Sauvegarde' }
|
||||
},
|
||||
{
|
||||
path: '/system/logs',
|
||||
name: 'system-logs',
|
||||
component: LogsPage,
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Journaux système' }
|
||||
},
|
||||
{
|
||||
path: '/system/updates',
|
||||
name: 'system-updates',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Mises à jour' }
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="h-[100dvh] overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
|
||||
<div class="h-screen overflow-hidden bg-gray-50 dark:bg-gray-900 flex">
|
||||
<Header
|
||||
:show-menu-button="isReaderMode"
|
||||
@menu-click="toggleSidebar"
|
||||
@@ -16,7 +16,7 @@
|
||||
headerStore.shouldShowHeader ? 'mt-16' : 'mt-0',
|
||||
isReaderMode ? '' : 'md:ml-60'
|
||||
]" style="transition: margin-top 300ms ease-in-out;">
|
||||
<RouterView class="flex-1 min-h-0"></RouterView>
|
||||
<RouterView></RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -78,9 +78,11 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
||||
{
|
||||
icon: Cog6ToothIcon,
|
||||
text: 'Paramètres',
|
||||
to: '/settings/scrappers',
|
||||
to: '/settings',
|
||||
id: 'settings',
|
||||
subItems: [
|
||||
{ icon: null, text: 'Général', to: '/settings/general' },
|
||||
{ icon: null, text: 'Dossiers', to: '/settings/folders' },
|
||||
{ icon: null, text: 'Scrappers', to: '/settings/scrappers' },
|
||||
{ icon: null, text: 'UI', to: '/settings/ui' }
|
||||
]
|
||||
@@ -88,11 +90,13 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
||||
{
|
||||
icon: ComputerDesktopIcon,
|
||||
text: 'Système',
|
||||
to: '/system/status',
|
||||
to: '/system',
|
||||
id: 'system',
|
||||
subItems: [
|
||||
{ icon: null, text: 'Status', to: '/system/status' },
|
||||
{ icon: null, text: 'Backup', to: '/system/backup' },
|
||||
{ icon: null, text: 'Logs', to: '/system/logs' },
|
||||
{ icon: null, text: 'Updates', to: '/system/updates' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -3,55 +3,56 @@
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"description": "A minimal Symfony project recommended to create bare bones applications",
|
||||
"minimum-stability": "dev",
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.4.0",
|
||||
"php": ">=8.3.1",
|
||||
"ext-ctype": "*",
|
||||
"ext-curl": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-zip": "*",
|
||||
"api-platform/core": "^4.0",
|
||||
"doctrine/dbal": "^4",
|
||||
"doctrine/doctrine-bundle": "^3.0",
|
||||
"api-platform/core": "^3.2",
|
||||
"doctrine/dbal": "^3",
|
||||
"doctrine/doctrine-bundle": "^2.11",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||
"doctrine/orm": "^3.0",
|
||||
"doctrine/orm": "^2.17",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"intervention/image": "^3.7",
|
||||
"nelmio/cors-bundle": "^2.4",
|
||||
"phpdocumentor/reflection-docblock": "^5.3",
|
||||
"phpstan/phpdoc-parser": "^1.25",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
"symfony/css-selector": "8.0.*",
|
||||
"symfony/doctrine-messenger": "8.0.*",
|
||||
"symfony/dotenv": "8.0.*",
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"runtime/frankenphp-symfony": "^0.2.0",
|
||||
"symfony/asset": "7.0.*",
|
||||
"symfony/console": "7.0.*",
|
||||
"symfony/css-selector": "7.0.*",
|
||||
"symfony/doctrine-messenger": "7.0.*",
|
||||
"symfony/dotenv": "7.0.*",
|
||||
"symfony/expression-language": "7.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/form": "8.0.*",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/mercure-bundle": "^0.4",
|
||||
"symfony/messenger": "8.0.*",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/form": "7.0.*",
|
||||
"symfony/framework-bundle": "7.0.*",
|
||||
"symfony/http-client": "7.0.*",
|
||||
"symfony/mercure-bundle": "^0.3.9",
|
||||
"symfony/messenger": "7.0.*",
|
||||
"symfony/mime": "7.0.*",
|
||||
"symfony/monolog-bundle": "^3.10",
|
||||
"symfony/panther": "^2.1",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
"symfony/scheduler": "8.0.*",
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
"symfony/property-access": "7.0.*",
|
||||
"symfony/property-info": "7.0.*",
|
||||
"symfony/runtime": "7.0.*",
|
||||
"symfony/scheduler": "7.0.*",
|
||||
"symfony/security-bundle": "7.0.*",
|
||||
"symfony/serializer": "7.0.*",
|
||||
"symfony/stimulus-bundle": "^2.17",
|
||||
"symfony/twig-bundle": "8.0.*",
|
||||
"symfony/twig-bundle": "7.0.*",
|
||||
"symfony/ux-live-component": "^2.17",
|
||||
"symfony/ux-react": "^2.23",
|
||||
"symfony/ux-turbo": "^2.18",
|
||||
"symfony/validator": "8.0.*",
|
||||
"symfony/validator": "7.0.*",
|
||||
"symfony/webpack-encore-bundle": "^2.1",
|
||||
"symfony/yaml": "8.0.*",
|
||||
"symfony/yaml": "7.0.*",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/twig": "^2.12|^3.0",
|
||||
"vich/uploader-bundle": "^2.7"
|
||||
@@ -102,7 +103,7 @@
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "8.0.*",
|
||||
"require": "7.0.*",
|
||||
"docker": true
|
||||
}
|
||||
},
|
||||
@@ -110,18 +111,18 @@
|
||||
"dama/doctrine-test-bundle": "^8.2",
|
||||
"dbrekelmans/bdi": "^1.3",
|
||||
"deployer/deployer": "^7.5",
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.0",
|
||||
"doctrine/doctrine-fixtures-bundle": "^3.5",
|
||||
"friendsofphp/php-cs-fixer": "^3.48",
|
||||
"mtdowling/jmespath.php": "^2.7",
|
||||
"phparkitect/phparkitect": "^0.8",
|
||||
"phpmd/phpmd": "3.x-dev",
|
||||
"phparkitect/phparkitect": "^0.3.33",
|
||||
"phpmd/phpmd": "^2.15",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/browser-kit": "7.0.*",
|
||||
"symfony/maker-bundle": "^1.52",
|
||||
"symfony/phpunit-bridge": "^8.0",
|
||||
"symfony/stopwatch": "8.0.*",
|
||||
"symfony/web-profiler-bundle": "8.0.*",
|
||||
"symfony/phpunit-bridge": "^7.0",
|
||||
"symfony/stopwatch": "7.0.*",
|
||||
"symfony/web-profiler-bundle": "7.0.*",
|
||||
"zenstruck/browser": "^1.8",
|
||||
"zenstruck/foundry": "^2.0"
|
||||
"zenstruck/foundry": "^1.36"
|
||||
}
|
||||
}
|
||||
|
||||
3794
composer.lock
generated
3794
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ api_platform:
|
||||
extra_properties:
|
||||
standard_put: true
|
||||
rfc_7807_compliant_errors: true
|
||||
event_listeners_backward_compatibility_layer: false
|
||||
keep_legacy_inflector: false
|
||||
mapping:
|
||||
paths:
|
||||
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
|
||||
@@ -32,6 +34,5 @@ api_platform:
|
||||
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
|
||||
- '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource'
|
||||
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
|
||||
- '%kernel.project_dir%/src/Domain/System/Infrastructure/ApiPlatform/Resource'
|
||||
patch_formats:
|
||||
json: ['application/merge-patch+json']
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Enable stateless CSRF protection for forms and logins/logouts
|
||||
framework:
|
||||
form:
|
||||
csrf_protection:
|
||||
token_id: submit
|
||||
|
||||
csrf_protection:
|
||||
stateless_token_ids:
|
||||
- submit
|
||||
- authenticate
|
||||
- logout
|
||||
@@ -3,6 +3,7 @@ doctrine:
|
||||
connections:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
use_savepoints: true
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
@@ -10,6 +11,9 @@ doctrine:
|
||||
#server_version: '16'
|
||||
|
||||
orm:
|
||||
auto_generate_proxy_classes: true
|
||||
enable_lazy_ghost_objects: true
|
||||
report_fields_where_declared: true
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
auto_mapping: true
|
||||
@@ -36,12 +40,15 @@ when@test:
|
||||
dbal:
|
||||
connections:
|
||||
default:
|
||||
use_savepoints: true
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
auto_generate_proxy_classes: false
|
||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||
query_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.system_cache_pool
|
||||
|
||||
@@ -17,6 +17,7 @@ framework:
|
||||
command.bus:
|
||||
middleware:
|
||||
- validation
|
||||
- doctrine_transaction
|
||||
event.bus:
|
||||
default_middleware: allow_no_handlers
|
||||
|
||||
@@ -37,6 +38,10 @@ framework:
|
||||
'App\Domain\Shared\Domain\Event\VolumeImported': events
|
||||
'App\Domain\Shared\Domain\Event\ChapterScraped': events
|
||||
|
||||
# Legacy messages (à garder si nécessaire)
|
||||
'App\Message\DownloadChapter': commands
|
||||
'App\Message\RefreshMetadata': commands
|
||||
'App\Message\RefreshAndDownloadChapters': commands
|
||||
|
||||
# when@test:
|
||||
# framework:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
2106
config/reference.php
2106
config/reference.php
File diff suppressed because it is too large
Load Diff
@@ -7,3 +7,8 @@ vue_app:
|
||||
requirements:
|
||||
req: "^(?!api/|legacy).*"
|
||||
|
||||
controllers:
|
||||
resource:
|
||||
path: ../src/Controller/
|
||||
namespace: App\Controller
|
||||
type: attribute
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
when@dev:
|
||||
_errors:
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
|
||||
prefix: /_error
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
when@dev:
|
||||
web_profiler_wdt:
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
|
||||
prefix: /_wdt
|
||||
|
||||
web_profiler_profiler:
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
|
||||
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
|
||||
prefix: /_profiler
|
||||
|
||||
@@ -26,6 +26,10 @@ services:
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
App\EventListener\ExceptionListener:
|
||||
tags:
|
||||
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }
|
||||
|
||||
GuzzleHttp\Client:
|
||||
class: GuzzleHttp\Client
|
||||
arguments:
|
||||
@@ -39,11 +43,63 @@ services:
|
||||
protocols: [ 'http', 'https' ]
|
||||
track_redirects: true
|
||||
|
||||
App\Service\MangaScraperService:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Controller\TestController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Domain\Conversion\Infrastructure\Service\ConversionService:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
# Scrapers Factory for Domain Layer
|
||||
App\Service\CbrToCbzConverter:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Manager\FileSystemManager:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\EventSubscriber\QueueStatusSubscriber:
|
||||
tags:
|
||||
- { name: kernel.event_subscriber }
|
||||
|
||||
App\Client\MangadexClient:
|
||||
arguments:
|
||||
$httpClient: '@GuzzleHttp\Client'
|
||||
$clientId: '%env(MANGADEX_CLIENT_ID)%'
|
||||
$clientSecret: '%env(MANGADEX_CLIENT_SECRET)%'
|
||||
$username: '%env(MANGADEX_USERNAME)%'
|
||||
$password: '%env(MANGADEX_PASSWORD)%'
|
||||
|
||||
App\Service\MangadexProvider:
|
||||
arguments:
|
||||
$client: '@App\Client\MangadexClient'
|
||||
|
||||
# Scraper Service
|
||||
App\Service\Scraper\HtmlScraper:
|
||||
tags: [ 'app.scraper' ]
|
||||
|
||||
App\Service\Scraper\JavascriptScraper:
|
||||
tags: [ 'app.scraper' ]
|
||||
|
||||
App\Service\Scraper\MangadexScraper:
|
||||
tags: [ 'app.scraper' ]
|
||||
|
||||
# Scraper Factory
|
||||
App\Service\Scraper\ScraperFactory:
|
||||
arguments:
|
||||
$scrapers: !tagged_iterator app.scraper
|
||||
|
||||
# Manga Scraper Service
|
||||
App\Service\Scraper\MangaScraperService:
|
||||
arguments:
|
||||
$scraperFactory: '@App\Service\Scraper\ScraperFactory'
|
||||
|
||||
# New Scrapers Factory for Domain Layer
|
||||
App\Domain\Scraping\Infrastructure\Service\ScraperFactory:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
@@ -124,18 +180,16 @@ services:
|
||||
tags:
|
||||
- { name: messenger.message_handler, bus: command.bus }
|
||||
|
||||
# Scraper Health Check
|
||||
App\Domain\Scraping\Domain\Contract\Repository\ContentSourceForHealthCheckInterface:
|
||||
alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
|
||||
# Import Domain Services
|
||||
App\Domain\Import\Infrastructure\Service\FilenameAnalyzer: ~
|
||||
|
||||
App\Domain\Import\Domain\Service\FilenameAnalyzerInterface:
|
||||
alias: App\Domain\Import\Infrastructure\Service\FilenameAnalyzer
|
||||
|
||||
App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface:
|
||||
alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
|
||||
# Import Domain Query/Command Handlers
|
||||
App\Domain\Import\Application\QueryHandler\AnalyzeFilenameQueryHandler: ~
|
||||
App\Domain\Import\Application\CommandHandler\ImportFileCommandHandler: ~
|
||||
|
||||
# System Domain
|
||||
App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface:
|
||||
alias: App\Domain\System\Infrastructure\Persistence\Repository\DoctrineSystemStatusRepository
|
||||
|
||||
App\Domain\System\Application\QueryHandler\GetSystemStatusQueryHandler:
|
||||
arguments:
|
||||
$mangaDataPath: '%env(resolve:MANGA_DATA_PATH)%'
|
||||
$imagesStoragePath: '%kernel.project_dir%/public/images'
|
||||
# Import Domain API Platform Services
|
||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
|
||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~
|
||||
|
||||
27
deploy.php
27
deploy.php
@@ -1,5 +1,4 @@
|
||||
<?php
|
||||
|
||||
namespace Deployer;
|
||||
|
||||
require 'recipe/symfony.php';
|
||||
@@ -34,16 +33,15 @@ task('deploy:prepare_dirs', function () {
|
||||
// --user assure que vendor/ appartient au user deploy et non root
|
||||
// Skip si composer.lock inchangé et vendor/ déjà populé (hard-linké depuis la release précédente)
|
||||
task('deploy:vendors', function () {
|
||||
$releaseDir = get('release_path');
|
||||
$releaseDir = get('release_path');
|
||||
$previousDir = get('previous_release');
|
||||
|
||||
if (null !== $previousDir) {
|
||||
$lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1");
|
||||
if ($previousDir !== null) {
|
||||
$lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1");
|
||||
$vendorPopulated = test("[ -d $releaseDir/vendor/composer ]");
|
||||
|
||||
if ($lockUnchanged && $vendorPopulated) {
|
||||
writeln('<info>deploy:vendors skipped — composer.lock unchanged</info>');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -58,23 +56,23 @@ task('deploy:vendors', function () {
|
||||
// 3. Cache npm et webpack persistants entre les releases
|
||||
desc('Build Webpack Encore assets');
|
||||
task('webpack_encore:build', function () {
|
||||
$sharedDir = '/srv/mangarr/shared';
|
||||
$sharedDir = '/srv/mangarr/shared';
|
||||
$sharedWebpackCache = "$sharedDir/webpack_cache";
|
||||
$sharedNodeModules = "$sharedDir/node_modules";
|
||||
$sharedNpmCache = "$sharedDir/npm_cache";
|
||||
$sharedNodeModules = "$sharedDir/node_modules";
|
||||
$sharedNpmCache = "$sharedDir/npm_cache";
|
||||
|
||||
run("mkdir -p $sharedWebpackCache $sharedNodeModules $sharedNpmCache");
|
||||
|
||||
$releaseDir = get('release_path');
|
||||
$releaseDir = get('release_path');
|
||||
$previousDir = get('previous_release'); // null au 1er déploiement
|
||||
|
||||
// --- COUCHE 1 : skip total si aucun fichier front-end n'a changé ---
|
||||
if (null !== $previousDir) {
|
||||
if ($previousDir !== null) {
|
||||
$watchList = ['assets', 'templates', 'package.json', 'package-lock.json',
|
||||
'webpack.config.js', 'postcss.config.js', 'tailwind.config.js'];
|
||||
'webpack.config.js', 'postcss.config.js', 'tailwind.config.js'];
|
||||
|
||||
$diffChecks = implode(' && ', array_map(
|
||||
fn ($p) => "diff -rq --no-dereference $previousDir/$p $releaseDir/$p > /dev/null 2>&1",
|
||||
fn($p) => "diff -rq --no-dereference $previousDir/$p $releaseDir/$p > /dev/null 2>&1",
|
||||
$watchList
|
||||
));
|
||||
|
||||
@@ -83,16 +81,15 @@ task('webpack_encore:build', function () {
|
||||
if ($hasPreviousBuild && test("($diffChecks)")) {
|
||||
run("cp -al $previousDir/public/build $releaseDir/public/build");
|
||||
writeln('<info>webpack_encore:build skipped — no front-end files changed</info>');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- COUCHE 2 : skip npm install si package-lock.json inchangé ---
|
||||
$needsNpmInstall = true;
|
||||
if (null !== $previousDir) {
|
||||
if ($previousDir !== null) {
|
||||
$lockUnchanged = test("diff -q $previousDir/package-lock.json $releaseDir/package-lock.json > /dev/null 2>&1");
|
||||
$nmPopulated = test("[ -d $sharedNodeModules/.bin ]");
|
||||
$nmPopulated = test("[ -d $sharedNodeModules/.bin ]");
|
||||
if ($lockUnchanged && $nmPopulated) {
|
||||
$needsNpmInstall = false;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
worker {
|
||||
file ./public/index.php
|
||||
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260315221706 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE content_source ADD test_slug VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE content_source ADD test_chapter_number DOUBLE PRECISION DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE content_source ADD health_status VARCHAR(20) DEFAULT \'unknown\' NOT NULL');
|
||||
$this->addSql('ALTER TABLE content_source ADD health_last_tested_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE content_source ADD health_last_error TEXT DEFAULT NULL');
|
||||
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
$this->addSql('ALTER TABLE content_source DROP test_slug');
|
||||
$this->addSql('ALTER TABLE content_source DROP test_chapter_number');
|
||||
$this->addSql('ALTER TABLE content_source DROP health_status');
|
||||
$this->addSql('ALTER TABLE content_source DROP health_last_tested_at');
|
||||
$this->addSql('ALTER TABLE content_source DROP health_last_error');
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260326165659 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Migrate manga.genres column from PHP-serialized array to JSON';
|
||||
}
|
||||
|
||||
public function preUp(Schema $schema): void
|
||||
{
|
||||
// Convert existing PHP-serialized data to JSON before changing the column type
|
||||
$rows = $this->connection->fetchAllAssociative('SELECT id, genres FROM manga WHERE genres IS NOT NULL');
|
||||
foreach ($rows as $row) {
|
||||
$raw = $row['genres'];
|
||||
// Skip if already valid JSON
|
||||
json_decode($raw);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
continue;
|
||||
}
|
||||
// Unserialize PHP format and re-encode as JSON
|
||||
$value = @unserialize($raw);
|
||||
if ($value === false && $raw !== 'b:0;') {
|
||||
$value = [];
|
||||
}
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE manga SET genres = :json WHERE id = :id',
|
||||
['json' => json_encode($value), 'id' => $row['id']]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN job.created_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN job.started_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'\'');
|
||||
$this->addSql('ALTER TABLE manga ALTER genres TYPE JSON USING genres::json');
|
||||
$this->addSql('COMMENT ON COLUMN manga.genres IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN manga.created_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.created_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.updated_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN source.created_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'\'');
|
||||
$this->addSql('DROP INDEX idx_75ea56e0e3bd61ce');
|
||||
$this->addSql('DROP INDEX idx_75ea56e0fb7336f0');
|
||||
$this->addSql('DROP INDEX idx_75ea56e016ba31db');
|
||||
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE messenger_messages ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
|
||||
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'\'');
|
||||
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'\'');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN job.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN job.started_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('ALTER TABLE manga ALTER genres TYPE TEXT');
|
||||
$this->addSql('COMMENT ON COLUMN manga.genres IS \'(DC2Type:array)\'');
|
||||
$this->addSql('COMMENT ON COLUMN manga.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750');
|
||||
$this->addSql('ALTER TABLE messenger_messages ALTER id SET DEFAULT nextval(\'messenger_messages_id_seq\'::regclass)');
|
||||
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP IDENTITY');
|
||||
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('CREATE INDEX idx_75ea56e0e3bd61ce ON messenger_messages (available_at)');
|
||||
$this->addSql('CREATE INDEX idx_75ea56e0fb7336f0 ON messenger_messages (queue_name)');
|
||||
$this->addSql('CREATE INDEX idx_75ea56e016ba31db ON messenger_messages (delivered_at)');
|
||||
$this->addSql('COMMENT ON COLUMN source.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;
|
||||
use Arkitect\Rules\Rule;
|
||||
|
||||
return static function (Config $config): void {
|
||||
$domainClassSet = ClassSet::fromDir(__DIR__.'/src/Domain');
|
||||
$domainClassSet = ClassSet::fromDir(__DIR__ . '/src/Domain');
|
||||
$businessDomains = ['Manga', 'Reader', 'Scraping', 'Conversion'];
|
||||
|
||||
// Classes PHP standards et utilitaires
|
||||
@@ -29,7 +29,7 @@ return static function (Config $config): void {
|
||||
// Dépendances externes autorisées
|
||||
$externalDependencies = [
|
||||
'Symfony\Component\Messenger',
|
||||
'Ramsey\Uuid',
|
||||
'Ramsey\Uuid'
|
||||
];
|
||||
|
||||
// Règle pour le namespace cohérent
|
||||
@@ -72,7 +72,7 @@ return static function (Config $config): void {
|
||||
// Interdiction explicite pour l'Application d'accéder à l'Infrastructure
|
||||
$rules[] = Rule::allClasses()
|
||||
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
|
||||
->should(new NotDependsOnTheseNamespaces(["App\Domain\\$domain\Infrastructure"]))
|
||||
->should(new NotDependsOnTheseNamespaces("App\Domain\\$domain\Infrastructure"))
|
||||
->because("la couche Application de $domain ne doit jamais dépendre de l'Infrastructure, même au sein de son propre domaine");
|
||||
}
|
||||
|
||||
|
||||
0
src/ApiResource/.gitignore
vendored
Normal file
0
src/ApiResource/.gitignore
vendored
Normal file
86
src/Client/MangadexClient.php
Normal file
86
src/Client/MangadexClient.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Client;
|
||||
|
||||
use App\Interface\ClientInterface;
|
||||
use GuzzleHttp\ClientInterface as GuzzleInterface;
|
||||
|
||||
class MangadexClient implements ClientInterface
|
||||
{
|
||||
private const AUTHENTICATION_URL = 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token';
|
||||
private const API_URL = 'https://api.mangadex.org';
|
||||
private GuzzleInterface $httpClient;
|
||||
private string $clientId;
|
||||
private string $clientSecret;
|
||||
private string $username;
|
||||
private string $password;
|
||||
private ?string $accessToken = null;
|
||||
private ?string $refreshToken = null;
|
||||
|
||||
public function __construct(GuzzleInterface $httpClient, string $clientId, string $clientSecret, string $username, string $password)
|
||||
{
|
||||
$this->httpClient = $httpClient;
|
||||
$this->clientId = $clientId;
|
||||
$this->clientSecret = $clientSecret;
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
$this->authenticate();
|
||||
}
|
||||
|
||||
public function authenticate(): void
|
||||
{
|
||||
$response = $this->httpClient->request('POST', self::AUTHENTICATION_URL, [
|
||||
'form_params' => [
|
||||
'grant_type' => 'password',
|
||||
'username' => $this->username,
|
||||
'password' => $this->password,
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
$this->accessToken = $data['access_token'];
|
||||
$this->refreshToken = $data['refresh_token'];
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$response = $this->httpClient->request('POST', self::AUTHENTICATION_URL, [
|
||||
'form_params' => [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $this->refreshToken,
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
$this->accessToken = $data['access_token'];
|
||||
}
|
||||
|
||||
private function request(string $method, string $endpoint, array $options = []): array
|
||||
{
|
||||
$options['headers']['Authorization'] = 'Bearer ' . $this->accessToken;
|
||||
|
||||
$response = $this->httpClient->request($method, self::API_URL . $endpoint, $options);
|
||||
|
||||
if ($response->getStatusCode() === 429) {
|
||||
$this->refresh();
|
||||
$options['headers']['Authorization'] = 'Bearer ' . $this->accessToken;
|
||||
$response = $this->httpClient->request($method, self::API_URL . $endpoint, $options);
|
||||
}
|
||||
|
||||
return json_decode($response->getBody()->getContents(), true);
|
||||
}
|
||||
|
||||
public function get(string $endpoint, array $params = []): array
|
||||
{
|
||||
return $this->request('GET', $endpoint, ['query' => $params]);
|
||||
}
|
||||
|
||||
public function post(string $endpoint, array $data): array
|
||||
{
|
||||
return $this->request('POST', $endpoint, ['json' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
class SendTestNotificationCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationInterface $notification,
|
||||
private readonly NotificationInterface $notification
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -38,15 +38,14 @@ class SendTestNotificationCommand extends Command
|
||||
$allowed = ['info', 'success', 'error', 'warning'];
|
||||
if (!in_array($type, $allowed, true)) {
|
||||
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
match ($type) {
|
||||
'success' => $this->notification->sendSuccess($message),
|
||||
'error' => $this->notification->sendError($message),
|
||||
'error' => $this->notification->sendError($message),
|
||||
'warning' => $this->notification->sendWarning($message),
|
||||
default => $this->notification->sendInfo($message),
|
||||
default => $this->notification->sendInfo($message),
|
||||
};
|
||||
|
||||
$output->writeln(sprintf('<info>[%s] Notification envoyée : %s</info>', strtoupper($type), $message));
|
||||
|
||||
120
src/Controller/ActivityController.php
Normal file
120
src/Controller/ActivityController.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Manager\Toolbar\Factory\ToolbarFactory;
|
||||
use App\Manager\ToolbarManager;
|
||||
use App\Message\DownloadChapter;
|
||||
use App\Repository\ChapterRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class ActivityController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly ChapterRepository $chapterRepository,
|
||||
private readonly ToolbarFactory $toolbarFactory
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
#[Route('/activity', name: 'app_activity')]
|
||||
public function index(): Response
|
||||
{
|
||||
$queueStatus = $this->getQueueStatus();
|
||||
$decodedPending = $this->decodeMessages($queueStatus['pending']);
|
||||
$decodedProcessing = $this->decodeMessages($queueStatus['processing']);
|
||||
|
||||
$status = array_merge(
|
||||
$this->buildStatusActivity($decodedPending),
|
||||
$this->buildStatusActivity($decodedProcessing)
|
||||
);
|
||||
|
||||
return $this->render('activity/index.html.twig', [
|
||||
'controller_name' => 'ActivityController',
|
||||
'status' => $status,
|
||||
'toolbar' => $this->toolbarFactory->createToolbar('activity')->getGroups(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/activity/status', name: 'app_activity_status', methods: ['GET'])]
|
||||
public function getStatus(): JsonResponse
|
||||
{
|
||||
$queueStatus = $this->getQueueStatus();
|
||||
$decodedPending = $this->decodeMessages($queueStatus['pending']);
|
||||
$decodedProcessing = $this->decodeMessages($queueStatus['processing']);
|
||||
$status = array_merge(
|
||||
$this->buildStatusActivity($decodedPending),
|
||||
$this->buildStatusActivity($decodedProcessing)
|
||||
);
|
||||
|
||||
return new JsonResponse($status);
|
||||
}
|
||||
|
||||
// TODO refactorer ce code avec celui du QueueStatusSubscriber
|
||||
private function getQueueStatus(): array
|
||||
{
|
||||
// Requête pour récupérer les messages en attente
|
||||
$sqlPending = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NULL';
|
||||
$pending = $this->connection->fetchAllAssociative($sqlPending, ['queue' => 'default']);
|
||||
|
||||
// Requête pour récupérer les messages en cours de traitement
|
||||
$sqlProcessing = 'SELECT * FROM messenger_messages WHERE queue_name = :queue AND available_at IS NOT NULL';
|
||||
$processing = $this->connection->fetchAllAssociative($sqlProcessing, ['queue' => 'default']);
|
||||
|
||||
return [
|
||||
'pending' => $pending,
|
||||
'processing' => $processing
|
||||
];
|
||||
}
|
||||
|
||||
private function buildStatusActivity(array $activity): array
|
||||
{
|
||||
$status = [];
|
||||
foreach ($activity as $envelope) {
|
||||
$envelope = $envelope['body'];
|
||||
if ($envelope instanceof Envelope) {
|
||||
if (!$envelope->getMessage() instanceof DownloadChapter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$chapter = $this->chapterRepository->find($envelope->getMessage()->getChapterId());
|
||||
$manga = $chapter->getManga();
|
||||
$status[] = [
|
||||
'manga' => $manga->getTitle(),
|
||||
'volume' => $chapter->getVolume(),
|
||||
'chapter' => $chapter->getNumber(),
|
||||
'chapterId' => $chapter->getId(),
|
||||
'title' => $chapter->getTitle(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function decodeMessages(array $messages): array
|
||||
{
|
||||
$decodedMessages = [];
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$decodedMessages[] = [
|
||||
'id' => $message['id'],
|
||||
'body' => $this->decodeMessageBody($message['body']),
|
||||
'headers' => json_decode($message['headers'], true),
|
||||
];
|
||||
}
|
||||
|
||||
return $decodedMessages;
|
||||
}
|
||||
|
||||
private function decodeMessageBody(string $body)
|
||||
{
|
||||
return unserialize(stripcslashes($body));
|
||||
}
|
||||
}
|
||||
18
src/Controller/CalendarController.php
Normal file
18
src/Controller/CalendarController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class CalendarController extends AbstractController
|
||||
{
|
||||
#[Route('/calendar', name: 'app_calendar')]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('calendar/index.html.twig', [
|
||||
'controller_name' => 'CalendarController',
|
||||
]);
|
||||
}
|
||||
}
|
||||
64
src/Controller/ConversionController.php
Normal file
64
src/Controller/ConversionController.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\CbrToCbzConverter;
|
||||
use App\Service\NotificationService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class ConversionController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CbrToCbzConverter $cbrToCbzConverter,
|
||||
private readonly NotificationService $notificationService
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/convert', name: 'app_convert')]
|
||||
public function convert(Request $request): Response
|
||||
{
|
||||
if ($request->isMethod('POST')) {
|
||||
/** @var UploadedFile $file */
|
||||
$file = $request->files->get('file');
|
||||
|
||||
if ($file && $file->getClientOriginalExtension() === 'cbr') {
|
||||
$originalFileName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$tempFilePath = $file->getPathname();
|
||||
|
||||
try {
|
||||
$cbzPath = $this->cbrToCbzConverter->convert($tempFilePath);
|
||||
|
||||
$response = new BinaryFileResponse($cbzPath);
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
$originalFileName . '.cbz'
|
||||
);
|
||||
$response->headers->set('Content-Type', 'application/x-cbz');
|
||||
$response->headers->set('Turbo-Visit-Control', 'reload');
|
||||
|
||||
$response->deleteFileAfterSend(true);
|
||||
|
||||
return $response;
|
||||
} catch (\Exception $e) {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => 'Une erreur est survenue lors de la conversion : ' . $e->getMessage()
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => 'Veuillez sélectionner un fichier CBR valide.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('conversion/index.html.twig');
|
||||
}
|
||||
}
|
||||
220
src/Controller/ImportController.php
Normal file
220
src/Controller/ImportController.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Manager\FileSystemManager;
|
||||
use App\Repository\ChapterRepository;
|
||||
use App\Repository\MangaRepository;
|
||||
use App\Service\CbrToCbzConverter;
|
||||
use App\Service\CbzService;
|
||||
use App\Service\MangaImportService;
|
||||
use App\Service\NotificationService;
|
||||
use Exception;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class ImportController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileSystemManager $fileSystemManager,
|
||||
private readonly CbzService $cbzService,
|
||||
private readonly MangaImportService $mangaImportService,
|
||||
private readonly NotificationService $notificationService,
|
||||
private readonly MangaRepository $mangaRepository,
|
||||
private readonly CbrToCbzConverter $cbrToCbzConverter
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
#[Route('/manga/import', name: 'app_manga_import')]
|
||||
public function index(Request $request, SessionInterface $session): Response
|
||||
{
|
||||
if ($request->isMethod('POST')) {
|
||||
$files = $request->files->get('files');
|
||||
if ($files) {
|
||||
$importFiles = [];
|
||||
foreach ($files as $file) {
|
||||
if ($file && in_array($file->getClientOriginalExtension(), ['cbz', 'cbr'])) {
|
||||
$originalFileName = $file->getClientOriginalName();
|
||||
|
||||
try {
|
||||
$tmpPath = $this->fileSystemManager->moveUploadedFile(
|
||||
$file->getPathname(),
|
||||
$this->fileSystemManager->getUploadsDirectory(),
|
||||
$file->getClientOriginalName()
|
||||
);
|
||||
$importFiles[] = [
|
||||
'id' => uniqid(),
|
||||
'path' => $tmpPath,
|
||||
'original_name' => $originalFileName,
|
||||
];
|
||||
} catch (FileException $e) {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => 'Une erreur est survenue lors de l\'import du fichier ' . $originalFileName,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => 'Le fichier ' . $file->getClientOriginalName() . ' doit être au format CBZ ou CBR.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($importFiles)) {
|
||||
$session->set('import_files', $importFiles);
|
||||
return $this->redirectToRoute('import_match');
|
||||
}
|
||||
} else {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => 'Aucun fichier n\'a été sélectionné.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('import/index.html.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
#[Route('/import/match', name: 'import_match')]
|
||||
public function match(SessionInterface $session): Response
|
||||
{
|
||||
$files = $session->get('import_files', []);
|
||||
if (empty($files)) {
|
||||
return $this->redirectToRoute('app_manga_import');
|
||||
}
|
||||
|
||||
$processedFiles = [];
|
||||
foreach ($files as $fileId => $fileInfo) {
|
||||
$filePath = $fileInfo['path'];
|
||||
$originalFileName = $fileInfo['original_name'];
|
||||
|
||||
$fileExtension = pathinfo($filePath, PATHINFO_EXTENSION);
|
||||
if (strtolower($fileExtension) === 'cbr') {
|
||||
$cbzPath = $this->cbrToCbzConverter->convert($filePath);
|
||||
$filePath = $cbzPath;
|
||||
$originalFileName = pathinfo($originalFileName, PATHINFO_FILENAME) . '.cbz';
|
||||
$files[$fileId]['path'] = $filePath;
|
||||
$files[$fileId]['original_name'] = $originalFileName;
|
||||
}
|
||||
|
||||
$metadata = $this->cbzService->extractMetadata($filePath, $originalFileName);
|
||||
$mangas = $this->mangaRepository->findBySlug($metadata['title']);
|
||||
|
||||
$mangaOptions = [];
|
||||
foreach ($mangas as $manga) {
|
||||
$mangaOptions[] = [
|
||||
'slug' => $manga->getSlug(),
|
||||
'title' => $manga->getTitle(),
|
||||
'author' => $manga->getAuthor(),
|
||||
'publicationYear' => $manga->getPublicationYear(),
|
||||
'genres' => $manga->getGenres(),
|
||||
'description' => $manga->getDescription()
|
||||
];
|
||||
}
|
||||
|
||||
$processedFiles[] = [
|
||||
'id' => $fileId,
|
||||
'originalFileName' => $originalFileName,
|
||||
'fileSize' => $this->formatBytes(filesize($filePath)),
|
||||
'metadata' => $metadata,
|
||||
'mangaOptions' => $mangaOptions
|
||||
];
|
||||
}
|
||||
|
||||
$session->set('import_files', $files);
|
||||
|
||||
return $this->render('import/match.html.twig', [
|
||||
'files' => $processedFiles
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatBytes($bytes, $precision = 2)
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
#[Route('/import/confirm', name: 'import_confirm', methods: ['POST'])]
|
||||
public function confirm(Request $request, SessionInterface $session): Response
|
||||
{
|
||||
$files = $session->get('import_files', []);
|
||||
$selectedFiles = $request->request->all('selected');
|
||||
$mangaSlugs = $request->request->all('manga_slug');
|
||||
$volumes = $request->request->all('volume');
|
||||
$chapters = $request->request->all('chapter');
|
||||
|
||||
$importedFiles = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($selectedFiles as $fileId) {
|
||||
if (!isset($files[$fileId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $files[$fileId];
|
||||
$mangaSlug = $mangaSlugs[$fileId] ?? null;
|
||||
$volume = $volumes[$fileId] ?? null;
|
||||
$chapter = $chapters[$fileId] ?? null;
|
||||
|
||||
try {
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw new \Exception('Manga non trouvé.');
|
||||
}
|
||||
|
||||
if (!is_null($chapter)) {
|
||||
$chapter = $manga->getChapterByNumber($chapter);
|
||||
if (!$chapter) {
|
||||
throw new \Exception('Chapitre non trouvé.');
|
||||
}
|
||||
}
|
||||
|
||||
$importedFiles[] = $file['original_name'];
|
||||
$this->mangaImportService->importFile($manga, $volume, $chapter, $file['path']);
|
||||
} catch (\Exception $e) {
|
||||
$errors[] = "Erreur lors de l'import de {$file['original_name']} : " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Nettoyer les fichiers temporaires non importés
|
||||
foreach ($files as $file) {
|
||||
$this->fileSystemManager->deleteFile($file['path']);
|
||||
}
|
||||
|
||||
// Nettoyer la session
|
||||
$session->remove('import_files');
|
||||
|
||||
// Préparer le message de notification
|
||||
if (!empty($importedFiles)) {
|
||||
$successMessage = 'Fichiers importés avec succès : ' . implode(', ', $importedFiles);
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'success',
|
||||
'message' => $successMessage
|
||||
]);
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$errorMessage = implode("\n", $errors);
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => $errorMessage
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_manga');
|
||||
}
|
||||
}
|
||||
475
src/Controller/MangaController.php
Normal file
475
src/Controller/MangaController.php
Normal file
@@ -0,0 +1,475 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\Manga;
|
||||
use App\Form\MangaEditType;
|
||||
use App\Manager\FileSystemManager;
|
||||
use App\Manager\Toolbar\Factory\ToolbarFactory;
|
||||
use App\Message\DownloadChapter;
|
||||
use App\Message\RefreshMetadata;
|
||||
use App\Repository\ChapterRepository;
|
||||
use App\Repository\ContentSourceRepository;
|
||||
use App\Repository\MangaRepository;
|
||||
use App\Service\CbzService;
|
||||
use App\Service\MangadexProvider;
|
||||
use App\Service\NotificationService;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\NonUniqueResultException;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class MangaController extends AbstractController
|
||||
{
|
||||
private ImageManager $imageManager;
|
||||
|
||||
public function __construct(
|
||||
private readonly FileSystemManager $fileSystemManager,
|
||||
private readonly MangaRepository $mangaRepository,
|
||||
private readonly ChapterRepository $chapterRepository,
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly CbzService $cbzService,
|
||||
private readonly ToolbarFactory $toolbarFactory,
|
||||
private readonly MangadexProvider $mangadexProvider,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly NotificationService $notificationService,
|
||||
private readonly ContentSourceRepository $contentSourceRepository
|
||||
) {
|
||||
$this->imageManager = new ImageManager(new Driver());
|
||||
}
|
||||
|
||||
#[Route('/legacy', name: 'app_legacy')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$sort = $request->query->get('sort', 'title');
|
||||
$order = $request->query->get('order', 'asc');
|
||||
$status = $request->query->get('status', 'all');
|
||||
$view = $request->query->get('view', 'poster');
|
||||
|
||||
$mangas = $this->mangaRepository->findAllSortedAndFiltered($sort, $order, $status);
|
||||
|
||||
return $this->render('manga/index.html.twig', [
|
||||
'mangas' => $mangas,
|
||||
'toolbar' => $this->toolbarFactory->createToolbar('manga_list')->getGroups(),
|
||||
'currentStatus' => $status,
|
||||
'currentView' => $view,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/manga/chapters/{mangaSlug}', name: 'app_manga_show')]
|
||||
public function showChapters(string $mangaSlug, Request $request): Response
|
||||
{
|
||||
// $manga = $this->mangaRepository->findOneWithChapterBy(['slug' => $mangaSlug]);
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
|
||||
if (!$manga) {
|
||||
throw new NotFoundHttpException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$form = $this->createForm(MangaEditType::class, $manga);
|
||||
$contentSources = $this->contentSourceRepository->findAll();
|
||||
|
||||
return $this->render('manga/show_chapters.html.twig', [
|
||||
'manga' => $manga,
|
||||
'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId(), 'isMonitored' => (int)$manga->isMonitored()])->getGroups(),
|
||||
'form' => $form->createView(),
|
||||
'contentSources' => $contentSources,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/manga/delete/{id}', name: 'app_manga_delete', methods: ['DELETE'])]
|
||||
public function deleteManga(Manga $manga): JsonResponse
|
||||
{
|
||||
try {
|
||||
foreach ($manga->getChapters() as $chapter) {
|
||||
file_exists($chapter->getCbzPath()) ?? unlink($chapter->getCbzPath());
|
||||
$this->entityManager->remove($chapter);
|
||||
}
|
||||
$this->entityManager->remove($manga);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['success' => true]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResponse(['success' => false, 'error' => 'Unable to delete manga.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/manga/{id}/edit', name: 'app_manga_edit', methods: ['POST'])]
|
||||
public function edit(Request $request, Manga $manga, EntityManagerInterface $entityManager): JsonResponse|Response
|
||||
{
|
||||
$form = $this->createForm(MangaEditType::class, $manga);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$entityManager->flush();
|
||||
|
||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
foreach ($form->getErrors(true) as $error) {
|
||||
$errors[] = $error->getMessage();
|
||||
}
|
||||
|
||||
return new JsonResponse(['errors' => $errors], 400);
|
||||
}
|
||||
|
||||
#[Route('/manga/{id}/preferred-sources', name: 'manga_preferred_sources', methods: ['POST'])]
|
||||
public function updatePreferredSources(
|
||||
Request $request,
|
||||
Manga $manga,
|
||||
ContentSourceRepository $contentSourceRepository
|
||||
): JsonResponse {
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$preferredSourceIds = $data['preferredSources'] ?? [];
|
||||
|
||||
$preferredSources = $contentSourceRepository->findBy(['id' => $preferredSourceIds]);
|
||||
|
||||
// This will maintain the order of the sources as they were sent in the request
|
||||
$orderedPreferredSources = array_map(
|
||||
fn ($id) => current(array_filter($preferredSources, fn ($s) => $s->getId() == $id)),
|
||||
$preferredSourceIds
|
||||
);
|
||||
|
||||
$manga->setPreferredSources(array_filter($orderedPreferredSources));
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['success' => true]);
|
||||
}
|
||||
|
||||
public function _chaptersByManga(int $id): Response
|
||||
{
|
||||
$manga = $this->mangaRepository->find($id);
|
||||
$chaptersByVolume = [];
|
||||
foreach ($manga->getChapters() as $chapter) {
|
||||
$volume = $chapter->getVolume() ?? 'Not Found';
|
||||
$chaptersByVolume[$volume][] = $chapter;
|
||||
}
|
||||
|
||||
foreach ($chaptersByVolume as $volume => &$chapters) {
|
||||
usort($chapters, function ($a, $b) {
|
||||
return $b->getNumber() <=> $a->getNumber();
|
||||
});
|
||||
}
|
||||
unset($chapters);
|
||||
|
||||
uksort($chaptersByVolume, function ($a, $b) {
|
||||
if ($a == 0) {
|
||||
return -1;
|
||||
}
|
||||
if ($b == 0) {
|
||||
return 1;
|
||||
}
|
||||
return $b <=> $a;
|
||||
});
|
||||
|
||||
return $this->render('manga/_chapter_list.html.twig', [
|
||||
'manga' => $manga,
|
||||
'chapters_by_volume' => $chaptersByVolume
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/delete_cbz/{id}', name: 'app_delete_cbz')]
|
||||
public function deleteChapterCbz(Chapter $chapter): JsonResponse
|
||||
{
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath) {
|
||||
return new JsonResponse(['error' => 'No CBZ path for this chapter.'], 400);
|
||||
}
|
||||
|
||||
file_exists($cbzPath) ?? unlink($cbzPath);
|
||||
|
||||
$chapter->setCbzPath(null);
|
||||
$this->entityManager->persist($chapter);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['success' => 'CBZ file deleted.'], 200);
|
||||
}
|
||||
|
||||
#[Route('/chapter/{id}/edit', name: 'app_chapter_edit', methods: ['POST'])]
|
||||
public function editChapter(Request $request, Chapter $chapter): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
$chapter->setNumber($data['number']);
|
||||
$chapter->setTitle($data['title']);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['success' => true, 'message' => 'Chapter updated successfully']);
|
||||
}
|
||||
|
||||
#[Route('/hide_chapter/{id}', name: 'app_hide_chapter')]
|
||||
public function hideChapter(Chapter $chapter): JsonResponse
|
||||
{
|
||||
$chapter->setVisible(false);
|
||||
$this->entityManager->persist($chapter);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['success' => 'Chapter hidden.'], 200);
|
||||
}
|
||||
|
||||
#[Route('/manga/search/{query}', name: 'app_manga_search')]
|
||||
public function search(string $query = ''): Response
|
||||
{
|
||||
return $this->render('manga/add_new.html.twig', [
|
||||
'query' => $query,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
#[Route('/addManga', name: 'app_manga_add')]
|
||||
public function addManga(Request $request): Response
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $request->request->get('slug')]);
|
||||
if ($manga) {
|
||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
|
||||
}
|
||||
|
||||
$manga = new Manga();
|
||||
$manga->setTitle($request->request->get('title'))
|
||||
->setSlug($request->request->get('slug'))
|
||||
->setDescription($request->request->get('description'))
|
||||
->setStatus($request->request->get('status'))
|
||||
->setGenres(explode(',', $request->request->get('genres')))
|
||||
->setAuthor($request->request->get('author'))
|
||||
->setPublicationYear($request->request->get('publicationYear'))
|
||||
->setRating($request->request->get('rating'))
|
||||
->setExternalId($request->request->get('externalId'))
|
||||
->setMonitored(false);
|
||||
|
||||
// Traitement de l'image
|
||||
$imageUrl = $request->request->get('imageUrl');
|
||||
try {
|
||||
$imageUrls = $this->processAndSaveImage($imageUrl);
|
||||
$manga->setImageUrl($imageUrls['full']);
|
||||
$manga->setThumbnailUrl($imageUrls['thumbnail']);
|
||||
} catch (\Exception|GuzzleException $e) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$mergedChapters = $this->mangadexProvider->addAllChaptersToManga($manga);
|
||||
|
||||
if (empty($mergedChapters)) {
|
||||
return $this->redirectToRoute('app_manga_search', ['query' => $manga->getTitle()]);
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($manga->getChapters() as $chapter) {
|
||||
$this->entityManager->persist($chapter);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($manga);
|
||||
$this->entityManager->flush();
|
||||
} catch (\Exception $e) {
|
||||
if ($e instanceof UniqueConstraintViolationException) {
|
||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function processAndSaveImage(string $imageUrl): array
|
||||
{
|
||||
$client = new Client();
|
||||
$response = $client->get($imageUrl);
|
||||
$tempImage = tmpfile();
|
||||
fwrite($tempImage, $response->getBody()->getContents());
|
||||
$tempImagePath = stream_get_meta_data($tempImage)['uri'];
|
||||
|
||||
// Générer un nom de fichier unique
|
||||
$originalFilename = pathinfo($imageUrl, PATHINFO_FILENAME);
|
||||
$newFilename = $this->fileSystemManager->generateUniqueImageFilename($imageUrl);
|
||||
|
||||
try {
|
||||
// Créer et sauvegarder la miniature
|
||||
$thumbnail = $this->imageManager->read($tempImagePath);
|
||||
$thumbnail->cover(300, 440);
|
||||
$thumbnail->save($this->fileSystemManager->getImagePath('thumbnails') . '/' . $newFilename, quality: 85);
|
||||
|
||||
// Sauvegarder l'image en taille réelle
|
||||
$fullImage = $this->imageManager->read($tempImagePath);
|
||||
$fullImage->save($this->fileSystemManager->getImagePath('full') . '/' . $newFilename, quality: 90);
|
||||
|
||||
// Fermer et supprimer le fichier temporaire
|
||||
fclose($tempImage);
|
||||
|
||||
return [
|
||||
'full' => '/images/full/' . $newFilename,
|
||||
'thumbnail' => '/images/thumbnails/' . $newFilename
|
||||
];
|
||||
|
||||
} catch (FileException $e) {
|
||||
// Fermer le fichier temporaire en cas d'erreur
|
||||
fclose($tempImage);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/searchChapter/{id}', name: 'search_chapter')]
|
||||
public function addChapterMessenger(int $id): JsonResponse
|
||||
{
|
||||
$chapter = $this->chapterRepository->find($id);
|
||||
if (!$chapter) {
|
||||
return new JsonResponse(['error' => 'Chapter Not Found.'], 400);
|
||||
} elseif ($chapter->getCbzPath() !== null) {
|
||||
return new JsonResponse(['error' => 'Chapter already scraped.'], 400);
|
||||
}
|
||||
|
||||
$this->bus->dispatch(new DownloadChapter($id));
|
||||
|
||||
return new JsonResponse(['success' => 'Scrapping started...'], 200);
|
||||
}
|
||||
|
||||
#[Route('/searchVolume/{mangaSlug}/{volume}', name: 'search_volume')]
|
||||
public function searchVolume(string $mangaSlug, int $volume): JsonResponse
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
|
||||
}
|
||||
|
||||
$volumeChapters = $this->chapterRepository->findBy([
|
||||
'manga' => $manga,
|
||||
'volume' => $volume,
|
||||
'visible' => true
|
||||
]);
|
||||
|
||||
if (empty($volumeChapters)) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'No chapters found for this volume.']);
|
||||
return new JsonResponse(['error' => 'No chapters found for this volume.'], 200);
|
||||
}
|
||||
|
||||
foreach ($volumeChapters as $chapter) {
|
||||
if ($chapter->getCbzPath() === null) {
|
||||
$this->bus->dispatch(new DownloadChapter($chapter->getId()));
|
||||
}
|
||||
}
|
||||
|
||||
return new JsonResponse(['success' => 'Scrapping started...'], 200);
|
||||
}
|
||||
|
||||
#[Route('/download-cbz/{chapterId}', name: 'download_cbz')]
|
||||
public function downloadChapter(int $chapterId): BinaryFileResponse|JsonResponse
|
||||
{
|
||||
$chapter = $this->chapterRepository->find($chapterId);
|
||||
if (!$chapter) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapitre non trouvé.']);
|
||||
return new JsonResponse(['error' => 'Chapitre non trouvé.'], 200);
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath || !file_exists($cbzPath)) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Le fichier CBZ n\'existe pas.']);
|
||||
return new JsonResponse(['error' => 'Le fichier CBZ n\'existe pas.'], 200);
|
||||
}
|
||||
|
||||
$isFullVolume = $this->isFullVolume($chapter);
|
||||
$fileName = $isFullVolume
|
||||
? $this->cbzService->generateFileName($chapter->getManga(), $chapter->getVolume())
|
||||
: $this->cbzService->generateFileName($chapter->getManga(), null, $chapter->getNumber());
|
||||
|
||||
return $this->cbzService->createBinaryFileResponse($cbzPath, $fileName);
|
||||
}
|
||||
|
||||
#[Route('/download-volume/{mangaSlug}/{volume}', name: 'download_volume')]
|
||||
public function downloadVolume(string $mangaSlug, int $volume): BinaryFileResponse|JsonResponse
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
|
||||
$volumeChapters = $this->chapterRepository->findBy([
|
||||
'manga' => $manga,
|
||||
'volume' => $volume,
|
||||
'visible' => true
|
||||
], ['number' => 'ASC']);
|
||||
|
||||
if (empty($volumeChapters)) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Aucun chapitre trouvé pour ce volume.']);
|
||||
}
|
||||
|
||||
if (!$this->cbzService->doAllChaptersHaveCbz($volumeChapters)) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Tous les chapitres du volume ne sont pas scrapés.']);
|
||||
return new JsonResponse(['error' => 'Tous les chapitres du volume ne sont pas scrapés.'], 200);
|
||||
}
|
||||
|
||||
$fileName = $this->cbzService->generateFileName($manga, $volume);
|
||||
|
||||
if ($this->cbzService->areAllChaptersCbzIdentical($volumeChapters)) {
|
||||
return $this->cbzService->createBinaryFileResponse($volumeChapters[0]->getCbzPath(), $fileName);
|
||||
} else {
|
||||
$tempFile = $this->cbzService->createVolumeArchive($volumeChapters);
|
||||
$response = $this->cbzService->createBinaryFileResponse($tempFile, $fileName);
|
||||
$response->deleteFileAfterSend(true);
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/refresh_metadata', name: 'refresh_metadata')]
|
||||
public function refreshMetadata(Request $request): JsonResponse
|
||||
{
|
||||
$mangaId = json_decode($request->getContent(), true)['mangaId'];
|
||||
$manga = $this->mangaRepository->find($mangaId);
|
||||
if (!$manga) {
|
||||
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
|
||||
}
|
||||
$this->bus->dispatch(new RefreshMetadata($mangaId));
|
||||
|
||||
return new JsonResponse(['success' => 'Metadata refresh started...'], 200);
|
||||
}
|
||||
|
||||
#[Route('/toggle_monitored', name: 'toggle_monitored')]
|
||||
public function toogleMonitored(Request $request): JsonResponse
|
||||
{
|
||||
$id = json_decode($request->getContent(), true)['mangaId'];
|
||||
$manga = $this->mangaRepository->find($id);
|
||||
if (!$manga) {
|
||||
return new JsonResponse(['error' => 'Manga Not Found.'], 400);
|
||||
}
|
||||
|
||||
$manga->setMonitored(!$manga->isMonitored());
|
||||
$this->entityManager->persist($manga);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['success' => 'Monitored status updated.', 'isMonitored' => $manga->isMonitored()], 200);
|
||||
}
|
||||
|
||||
private function isFullVolume(Chapter $chapter): bool
|
||||
{
|
||||
$volumeChapters = $this->chapterRepository->findBy([
|
||||
'manga' => $chapter->getManga(),
|
||||
'volume' => $chapter->getVolume()
|
||||
]);
|
||||
|
||||
$firstChapterPath = $volumeChapters[0]->getCbzPath();
|
||||
foreach ($volumeChapters as $volumeChapter) {
|
||||
if ($volumeChapter->getCbzPath() !== $firstChapterPath) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
138
src/Controller/ReaderController.php
Normal file
138
src/Controller/ReaderController.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\MangaRepository;
|
||||
use App\Service\CbzService;
|
||||
use App\Service\NotificationService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class ReaderController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MangaRepository $mangaRepository,
|
||||
private readonly CbzService $cbzService,
|
||||
private readonly NotificationService $notificationService,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/read/{mangaSlug}/{chapterNumber}', name: 'app_reader')]
|
||||
public function read(string $mangaSlug, float $chapterNumber): Response
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$chapter = $manga->getChapterByNumber($chapterNumber);
|
||||
if (!$chapter) {
|
||||
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
|
||||
}
|
||||
|
||||
if (is_null($chapter->getCbzPath())) {
|
||||
$this->notificationService->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => 'Le chapitre demandé n\'est pas encore disponible.',
|
||||
]);
|
||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $mangaSlug]);
|
||||
}
|
||||
|
||||
$totalPages = $this->cbzService->getPageCount($chapter->getCbzPath());
|
||||
|
||||
return $this->render('reader/index.html.twig', [
|
||||
'manga' => $manga,
|
||||
'chapter' => $chapter,
|
||||
'totalPages' => $totalPages,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_reader_page')]
|
||||
public function getPage(string $mangaSlug, float $chapterNumber, int $pageNumber): Response
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$chapter = $manga->getChapterByNumber($chapterNumber);
|
||||
if (!$chapter) {
|
||||
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$pageContent = $this->cbzService->getPageContent($chapter->getCbzPath(), $pageNumber);
|
||||
if (!$pageContent) {
|
||||
throw $this->createNotFoundException("La page demandée n'existe pas.");
|
||||
}
|
||||
|
||||
return new Response(base64_encode($pageContent), 200, ['Content-Type' => 'text/plain']);
|
||||
}
|
||||
|
||||
#[Route('/api/chapters/{mangaSlug}', name: 'app_reader_chapters')]
|
||||
public function getChapters(string $mangaSlug): JsonResponse
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$chapters = $manga->getChapters()
|
||||
->filter(fn ($chapter) => $chapter->isVisible() && !is_null($chapter->getCbzPath()))
|
||||
->toArray();
|
||||
|
||||
usort($chapters, fn ($a, $b) => $b->getNumber() <=> $a->getNumber());
|
||||
|
||||
$chapters = array_values(array_map(fn ($chapter) => [
|
||||
'number' => $chapter->getNumber(),
|
||||
'title' => $chapter->getTitle(),
|
||||
], $chapters));
|
||||
|
||||
return $this->json($chapters);
|
||||
}
|
||||
|
||||
#[Route('/api/previous-chapter/{mangaSlug}/{currentChapterNumber}', name: 'app_reader_previous_chapter')]
|
||||
public function getPreviousChapter(string $mangaSlug, float $currentChapterNumber): JsonResponse
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$chapters = $manga->getChapters()
|
||||
->filter(fn ($chapter) => $chapter->isVisible() && $chapter->getNumber() < $currentChapterNumber)
|
||||
->toArray();
|
||||
|
||||
usort($chapters, fn ($a, $b) => $b->getNumber() <=> $a->getNumber());
|
||||
|
||||
$previousChapter = reset($chapters) ?: null;
|
||||
|
||||
return $this->json($previousChapter ? [
|
||||
'number' => $previousChapter->getNumber(),
|
||||
'title' => $previousChapter->getTitle(),
|
||||
] : null);
|
||||
}
|
||||
|
||||
#[Route('/api/next-chapter/{mangaSlug}/{currentChapterNumber}', name: 'app_reader_next_chapter')]
|
||||
public function getNextChapter(string $mangaSlug, float $currentChapterNumber): JsonResponse
|
||||
{
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
if (!$manga) {
|
||||
throw $this->createNotFoundException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
$nextChapter = $manga->getChapters()
|
||||
->filter(fn ($chapter) => $chapter->isVisible() && $chapter->getNumber() > $currentChapterNumber)
|
||||
->toArray();
|
||||
|
||||
usort($nextChapter, fn ($a, $b) => $a->getNumber() <=> $b->getNumber());
|
||||
|
||||
$nextChapter = reset($nextChapter) ?: null;
|
||||
|
||||
return $this->json($nextChapter ? [
|
||||
'number' => $nextChapter->getNumber(),
|
||||
'title' => $nextChapter->getTitle(),
|
||||
] : null);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use ApiPlatform\Api\IriConverterInterface;
|
||||
use App\Entity\User;
|
||||
use Exception;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
@@ -12,11 +13,11 @@ use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
class SecurityController extends AbstractController
|
||||
{
|
||||
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
|
||||
public function login(IriConverterInterface $iriConverter, #[CurrentUser] ?User $user = null): Response
|
||||
public function login(IriConverterInterface $iriConverter, #[CurrentUser] User $user = null): Response
|
||||
{
|
||||
if (!$user) {
|
||||
return $this->json([
|
||||
'error' => 'Invalid credentials',
|
||||
'error' => 'Invalid credentials'
|
||||
], 401);
|
||||
}
|
||||
|
||||
@@ -26,11 +27,11 @@ class SecurityController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
* @throws Exception
|
||||
*/
|
||||
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
|
||||
public function logout(): void
|
||||
{
|
||||
throw new \Exception('This method can be blank.');
|
||||
throw new Exception('This method can be blank.');
|
||||
}
|
||||
}
|
||||
|
||||
203
src/Controller/SettingsController.php
Normal file
203
src/Controller/SettingsController.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ContentSource;
|
||||
use App\Form\AppSettingsType;
|
||||
use App\Form\ContentSourceType;
|
||||
use App\Manager\AppSettingsManager;
|
||||
use App\Manager\Toolbar\Factory\ToolbarFactory;
|
||||
use App\Repository\ContentSourceRepository;
|
||||
use App\Service\NotificationService;
|
||||
use App\Service\Scraper\MangaScraperService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private MangaScraperService $mangaScraperService,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private NotificationService $notificationService,
|
||||
private ContentSourceRepository $contentSourceRepository
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
#[Route('/settings', name: 'app_settings')]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('settings/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings/general', name: 'app_settings_general')]
|
||||
public function general(): Response
|
||||
{
|
||||
return $this->render('settings/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings/folders', name: 'app_settings_folders')]
|
||||
public function folders(Request $request, AppSettingsManager $settingsManager): Response
|
||||
{
|
||||
$currentSettings = $settingsManager->getSettings();
|
||||
|
||||
$form = $this->createForm(AppSettingsType::class, $currentSettings);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$newSettings = $form->getData();
|
||||
$settingsManager->updateSettings($newSettings);
|
||||
|
||||
$this->notificationService->sendUpdate(['status' => 'success', 'message' => 'Settings updated successfully.']);
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
return $this->render('settings/folders.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings/scrappers/list', name: 'app_settings_scrappers_list')]
|
||||
public function list(ContentSourceRepository $repository, ToolbarFactory $toolbarFactory): Response
|
||||
{
|
||||
$contentSources = $repository->findAll();
|
||||
|
||||
return $this->render('settings/scrapper_list.html.twig', [
|
||||
'contentSources' => $contentSources,
|
||||
'toolbar' => $toolbarFactory->createToolbar('scraper_list')->getGroups(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings/scrappers/{id}', name: 'app_settings_scrappers', defaults: ['id' => null])]
|
||||
public function scrappers(Request $request, ?ContentSource $contentSource): Response
|
||||
{
|
||||
$isNew = $contentSource === null;
|
||||
$contentSource = $contentSource ?? new ContentSource();
|
||||
|
||||
$form = $this->createForm(ContentSourceType::class, $contentSource);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->persist($contentSource);
|
||||
$this->entityManager->flush();
|
||||
$this->notificationService->sendUpdate(['status' => 'success', 'message' => ($isNew ? 'New scrapper configuration saved' : 'Scrapper configuration updated') . ' successfully.']);
|
||||
return $this->redirectToRoute('app_settings_scrappers_list');
|
||||
}
|
||||
|
||||
return $this->render('settings/scrappers.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'isNew' => $isNew,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
#[Route('/settings/scrappers_test', name: 'app_settings_scrappers_test', methods: ['POST'])]
|
||||
public function scrapperTest(Request $request): JsonResponse
|
||||
{
|
||||
$contentSource = new ContentSource();
|
||||
$form = $this->createForm(ContentSourceType::class, $contentSource);
|
||||
$form->submit($request->request->all()['content_source']);
|
||||
|
||||
if ($form->isValid()) {
|
||||
$mangaSlug = $request->request->get('mangaSlug');
|
||||
$chapterNumber = $request->request->get('chapterNumber');
|
||||
|
||||
try {
|
||||
$scrapedData = $this->mangaScraperService->testScraping($mangaSlug, $chapterNumber, $contentSource);
|
||||
} catch (\Exception $e) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => $e->getMessage()]);
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Test successful',
|
||||
'data' => $scrapedData
|
||||
]);
|
||||
} else {
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Invalid form submission',
|
||||
'errors' => $this->getFormErrors($form)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getFormErrors($form): array
|
||||
{
|
||||
$errors = [];
|
||||
foreach ($form->getErrors(true) as $error) {
|
||||
$errors[] = $error->getMessage();
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
#[Route('/settings/ui', name: 'app_settings_ui')]
|
||||
public function ui(): Response
|
||||
{
|
||||
return $this->render('settings/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/settings/export_scrappers', name: 'app_settings_scrappers_export', methods: ['GET'])]
|
||||
public function exportScrappers(): JsonResponse
|
||||
{
|
||||
$contentSources = $this->contentSourceRepository->findAll();
|
||||
$data = [];
|
||||
|
||||
foreach ($contentSources as $source) {
|
||||
$data[] = [
|
||||
'baseUrl' => $source->getBaseUrl(),
|
||||
'imageSelector' => $source->getImageSelector(),
|
||||
'nextPageSelector' => $source->getNextPageSelector(),
|
||||
'chapterUrlFormat' => $source->getChapterUrlFormat(),
|
||||
'scrapingType' => $source->getScrapingType(),
|
||||
'chapterSelector' => $source->getChapterSelector(), //TODO à renommer en chapterListSelector
|
||||
];
|
||||
}
|
||||
|
||||
return new JsonResponse($data);
|
||||
}
|
||||
|
||||
#[Route('/settings/import_scrappers', name: 'app_settings_scrappers_import', methods: ['POST'])]
|
||||
public function importScrappers(Request $request): JsonResponse
|
||||
{
|
||||
$content = $request->getContent();
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Invalid JSON data']);
|
||||
return new JsonResponse(['error' => 'Invalid JSON data'], 400);
|
||||
}
|
||||
|
||||
foreach ($data as $sourceData) {
|
||||
$contentSource = new ContentSource();
|
||||
$contentSource->setBaseUrl($sourceData['baseUrl']);
|
||||
$contentSource->setImageSelector($sourceData['imageSelector']);
|
||||
$contentSource->setNextPageSelector($sourceData['nextPageSelector']);
|
||||
$contentSource->setChapterUrlFormat($sourceData['chapterUrlFormat']);
|
||||
$contentSource->setScrapingType($sourceData['scrapingType']);
|
||||
|
||||
$this->entityManager->persist($contentSource);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['message' => 'Content sources imported successfully']);
|
||||
}
|
||||
}
|
||||
50
src/Controller/SystemController.php
Normal file
50
src/Controller/SystemController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class SystemController extends AbstractController
|
||||
{
|
||||
#[Route('/system', name: 'app_system')]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('system/index.html.twig', [
|
||||
'controller_name' => 'SystemController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/system/status', name: 'app_system_status')]
|
||||
public function status(): Response
|
||||
{
|
||||
return $this->render('system/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/system/backup', name: 'app_system_backup')]
|
||||
public function backup(): Response
|
||||
{
|
||||
return $this->render('system/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/system/logs', name: 'app_system_logs')]
|
||||
public function logs(): Response
|
||||
{
|
||||
return $this->render('system/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/system/updates', name: 'app_system_updates')]
|
||||
public function update(): Response
|
||||
{
|
||||
return $this->render('system/index.html.twig', [
|
||||
'controller_name' => 'SettingsController',
|
||||
]);
|
||||
}
|
||||
}
|
||||
100
src/Controller/TestController.php
Normal file
100
src/Controller/TestController.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\ContentSource;
|
||||
use App\Entity\Manga;
|
||||
use App\Message\DownloadChapter;
|
||||
use App\Repository\ChapterRepository;
|
||||
use App\Repository\MangaRepository;
|
||||
use App\Service\ActivityService;
|
||||
use App\Service\MangadexProvider;
|
||||
use App\Service\MangaScraperService;
|
||||
use App\Service\MangaUpdatesMetadataProvider;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class TestController extends AbstractController
|
||||
{
|
||||
private ImageManager $imageManager;
|
||||
public function __construct(
|
||||
private string $projectDir,
|
||||
private SluggerInterface $slugger,
|
||||
private MangaRepository $mangaRepository
|
||||
) {
|
||||
$this->imageManager = new ImageManager(new Driver());
|
||||
}
|
||||
|
||||
#[Route('/test', name: 'test')]
|
||||
public function test(): Response
|
||||
{
|
||||
$mangas = $this->mangaRepository->findAll();
|
||||
|
||||
$changed = 0;
|
||||
foreach ($mangas as $manga) {
|
||||
//si getImageUrl() retourne un lien sous la forme d'une URL (https ou http)
|
||||
if ($manga->getImageUrl()) {
|
||||
$imageUrls = $this->processAndSaveImage($manga->getImageUrl());
|
||||
$manga->setThumbnailUrl($imageUrls['thumbnail']);
|
||||
$this->mangaRepository->save($manga, true);
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return new JsonResponse(['changed' => $changed]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function processAndSaveImage(string $imageUrl): array
|
||||
{
|
||||
$image = file_get_contents($this->projectDir . '/public' .$imageUrl);
|
||||
$tempImage = tmpfile();
|
||||
fwrite($tempImage, $image);
|
||||
$tempImagePath = stream_get_meta_data($tempImage)['uri'];
|
||||
|
||||
// Générer un nom de fichier unique
|
||||
$originalFilename = pathinfo($imageUrl, PATHINFO_FILENAME);
|
||||
$safeFilename = $this->slugger->slug($originalFilename);
|
||||
$newFilename = $safeFilename . '-' . uniqid() . '.' . 'jpg';
|
||||
|
||||
try {
|
||||
// Créer et sauvegarder la miniature
|
||||
$thumbnail = $this->imageManager->read($tempImagePath);
|
||||
$thumbnail->cover(300, 440);
|
||||
$thumbnail->save($this->projectDir . '/public/images/thumbnails/' . $newFilename, quality: 85);
|
||||
|
||||
// Sauvegarder l'image en taille réelle
|
||||
// $fullImage = $this->imageManager->read($tempImagePath);
|
||||
// $fullImage->save($this->projectDir . '/public/images/full/' . $newFilename, quality: 90);
|
||||
|
||||
// Fermer et supprimer le fichier temporaire
|
||||
fclose($tempImage);
|
||||
|
||||
return [
|
||||
'full' => '/images/full/' . $newFilename,
|
||||
'thumbnail' => '/images/thumbnails/' . $newFilename
|
||||
];
|
||||
|
||||
} catch (FileException $e) {
|
||||
// Fermer le fichier temporaire en cas d'erreur
|
||||
fclose($tempImage);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/DataFixtures/AppFixtures.php
Normal file
45
src/DataFixtures/AppFixtures.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Factory\ApiTokenFactory;
|
||||
use App\Factory\ChapterFactory;
|
||||
use App\Factory\MangaFactory;
|
||||
use App\Factory\PageFactory;
|
||||
use App\Factory\UserFactory;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
class AppFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
UserFactory::createMany(20);
|
||||
ApiTokenFactory::createMany(60, function () {
|
||||
return [
|
||||
'ownedBy' => UserFactory::random()
|
||||
];
|
||||
});
|
||||
|
||||
$mangas = MangaFactory::createMany(25);
|
||||
|
||||
foreach ($mangas as $manga) {
|
||||
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$manga->addChapter(ChapterFactory::createOne([
|
||||
'manga' => $manga,
|
||||
'number' => $i
|
||||
])->object());
|
||||
}
|
||||
|
||||
foreach ($manga->getChapters() as $chapter) {
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$chapter->addPagesLink(PageFactory::createOne([
|
||||
'chapter' => $chapter,
|
||||
'number' => $i
|
||||
])->object());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ final readonly class ConvertFileCommand
|
||||
public function __construct(
|
||||
public string $filePath,
|
||||
public string $originalFilename,
|
||||
public int $fileSize,
|
||||
public int $fileSize
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ final readonly class ConvertFileCommandHandler
|
||||
private const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MB
|
||||
|
||||
public function __construct(
|
||||
private ConversionServiceInterface $conversionService,
|
||||
private ConversionServiceInterface $conversionService
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ final readonly class ConversionResponse
|
||||
public string $convertedFilePath,
|
||||
public string $outputFilename,
|
||||
public int $originalFileSize,
|
||||
public int $convertedFileSize,
|
||||
public int $convertedFileSize
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Domain\Conversion\Domain\Exception;
|
||||
|
||||
class ConversionException extends \RuntimeException
|
||||
use RuntimeException;
|
||||
|
||||
class ConversionException extends RuntimeException
|
||||
{
|
||||
public static function fileNotFound(string $filePath): self
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ final readonly class ConversionRequest
|
||||
public function __construct(
|
||||
private string $filePath,
|
||||
private string $originalFilename,
|
||||
private int $fileSize,
|
||||
private int $fileSize
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ final readonly class ConversionRequest
|
||||
public function getOutputFilename(): string
|
||||
{
|
||||
$pathInfo = pathinfo($this->originalFilename, PATHINFO_FILENAME);
|
||||
|
||||
return $pathInfo.'.cbz';
|
||||
return $pathInfo . '.cbz';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ final readonly class ConversionResult
|
||||
private string $convertedFilePath,
|
||||
private string $outputFilename,
|
||||
private int $originalFileSize,
|
||||
private int $convertedFileSize,
|
||||
private int $convertedFileSize
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,18 @@ namespace App\Domain\Conversion\Infrastructure\ApiPlatform\Controller;
|
||||
use App\Domain\Conversion\Application\Command\ConvertFileCommand;
|
||||
use App\Domain\Conversion\Application\CommandHandler\ConvertFileCommandHandler;
|
||||
use App\Domain\Conversion\Domain\Exception\ConversionException;
|
||||
use App\Domain\Conversion\Infrastructure\ApiPlatform\Resource\ConvertFileResource;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
#[AsController]
|
||||
final class ConvertFileController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConvertFileCommandHandler $commandHandler,
|
||||
private readonly ConvertFileCommandHandler $commandHandler
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -23,7 +25,7 @@ final class ConvertFileController extends AbstractController
|
||||
$uploadedFile = $request->files->get('file');
|
||||
if (!$uploadedFile) {
|
||||
return $this->json([
|
||||
['propertyPath' => 'file', 'message' => 'Please upload a file'],
|
||||
['propertyPath' => 'file', 'message' => 'Please upload a file']
|
||||
], 422);
|
||||
}
|
||||
|
||||
@@ -56,6 +58,7 @@ final class ConvertFileController extends AbstractController
|
||||
'Content-Disposition' => sprintf('attachment; filename=%s', $response->outputFilename),
|
||||
]
|
||||
);
|
||||
|
||||
} catch (ConversionException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], 400);
|
||||
}
|
||||
@@ -69,9 +72,8 @@ final class ConvertFileController extends AbstractController
|
||||
if (!$uploadedFile->isValid()) {
|
||||
$errors[] = [
|
||||
'propertyPath' => 'file',
|
||||
'message' => 'The uploaded file is not valid: '.$uploadedFile->getErrorMessage(),
|
||||
'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage()
|
||||
];
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
@@ -80,7 +82,7 @@ final class ConvertFileController extends AbstractController
|
||||
if ($uploadedFile->getSize() > $maxSize) {
|
||||
$errors[] = [
|
||||
'propertyPath' => 'file',
|
||||
'message' => 'The uploaded file is too large. Allowed size is 150MB.',
|
||||
'message' => 'The uploaded file is too large. Allowed size is 150MB.'
|
||||
];
|
||||
}
|
||||
|
||||
@@ -91,7 +93,7 @@ final class ConvertFileController extends AbstractController
|
||||
if (!in_array($extension, $allowedExtensions)) {
|
||||
$errors[] = [
|
||||
'propertyPath' => 'file',
|
||||
'message' => 'Please upload a valid CBR or CBZ file',
|
||||
'message' => 'Please upload a valid CBR or CBZ file'
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ namespace App\Domain\Conversion\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\OpenApi\Model\RequestBody;
|
||||
use ApiPlatform\OpenApi\Model;
|
||||
use App\Domain\Conversion\Infrastructure\ApiPlatform\Controller\ConvertFileController;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'Conversion',
|
||||
@@ -16,11 +16,11 @@ use Symfony\Component\HttpFoundation\File\File;
|
||||
uriTemplate: '/conversions/convert',
|
||||
controller: ConvertFileController::class,
|
||||
deserialize: false,
|
||||
openapi: new Operation(
|
||||
summary: 'Convert comic book file to CBZ',
|
||||
description: 'Converts a CBR or CBZ file to CBZ format and returns the converted file for download',
|
||||
requestBody: new RequestBody(
|
||||
content: new \ArrayObject([
|
||||
openapiContext: [
|
||||
'summary' => 'Convert comic book file to CBZ',
|
||||
'description' => 'Converts a CBR or CBZ file to CBZ format and returns the converted file for download',
|
||||
'requestBody' => [
|
||||
'content' => [
|
||||
'multipart/form-data' => [
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
@@ -29,28 +29,28 @@ use Symfony\Component\HttpFoundation\File\File;
|
||||
'file' => [
|
||||
'type' => 'string',
|
||||
'format' => 'binary',
|
||||
'description' => 'Comic book file to convert (CBR, CBZ, max 150MB)',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
),
|
||||
responses: [
|
||||
'description' => 'Comic book file to convert (CBR, CBZ, max 150MB)'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'responses' => [
|
||||
'200' => [
|
||||
'description' => 'File converted successfully',
|
||||
'content' => [
|
||||
'application/x-cbz' => [
|
||||
'schema' => [
|
||||
'type' => 'string',
|
||||
'format' => 'binary',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'format' => 'binary'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
]
|
||||
)]
|
||||
class ConvertFileResource
|
||||
|
||||
@@ -16,7 +16,7 @@ final class ConversionService implements ConversionServiceInterface
|
||||
|
||||
public function __construct(string $projectDir)
|
||||
{
|
||||
$this->tempDir = $projectDir.'/public/tmp';
|
||||
$this->tempDir = $projectDir . '/public/tmp';
|
||||
$this->filesystem = new Filesystem();
|
||||
}
|
||||
|
||||
@@ -40,10 +40,10 @@ final class ConversionService implements ConversionServiceInterface
|
||||
|
||||
private function convertCbrToCbz(string $cbrPath): string
|
||||
{
|
||||
$tempDir = $this->tempDir.'/'.uniqid('cbr_conversion_');
|
||||
$tempDir = $this->tempDir . '/' . uniqid('cbr_conversion_');
|
||||
$this->filesystem->mkdir($tempDir);
|
||||
|
||||
$extractDir = $tempDir.'/extract';
|
||||
$extractDir = $tempDir . '/extract';
|
||||
$this->filesystem->mkdir($extractDir);
|
||||
|
||||
// Essayer d'extraire avec unrar-free
|
||||
@@ -56,16 +56,16 @@ final class ConversionService implements ConversionServiceInterface
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new \RuntimeException('Extraction failed: '.$process->getErrorOutput());
|
||||
throw new \RuntimeException("Extraction failed: " . $process->getErrorOutput());
|
||||
}
|
||||
}
|
||||
|
||||
// Créer le CBZ
|
||||
$cbzFileName = pathinfo($cbrPath, PATHINFO_FILENAME).'.cbz';
|
||||
$cbzPath = $this->tempDir.'/'.$cbzFileName;
|
||||
$cbzFileName = pathinfo($cbrPath, PATHINFO_FILENAME) . '.cbz';
|
||||
$cbzPath = $this->tempDir . '/' . $cbzFileName;
|
||||
$zip = new \ZipArchive();
|
||||
if (true !== $zip->open($cbzPath, \ZipArchive::CREATE)) {
|
||||
throw new \RuntimeException('Cannot create ZIP file');
|
||||
if ($zip->open($cbzPath, \ZipArchive::CREATE) !== true) {
|
||||
throw new \RuntimeException("Cannot create ZIP file");
|
||||
}
|
||||
|
||||
$files = new \RecursiveIteratorIterator(
|
||||
|
||||
@@ -7,7 +7,7 @@ readonly class ChapterEditData
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public ?string $title = null,
|
||||
public ?int $volume = null,
|
||||
public ?int $volume = null
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Domain\Manga\Application\Command;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
readonly class CheckMonitoredMangas
|
||||
{
|
||||
public function __construct(
|
||||
public ?\DateTimeImmutable $since = null,
|
||||
public ?DateTimeImmutable $since = null
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ readonly class CreateManga
|
||||
public string $status,
|
||||
public ?string $externalId,
|
||||
public ?string $imageUrl,
|
||||
public ?float $rating,
|
||||
public ?float $rating
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Domain\Manga\Application\Command;
|
||||
readonly class CreateMangaFromMangadex
|
||||
{
|
||||
public function __construct(
|
||||
public string $externalId,
|
||||
public string $externalId
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
|
||||
readonly class DeleteCbz implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
public string $chapterId,
|
||||
public string $chapterId
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user