Added:
- turbo + code adaptation - cover & thumbnails download
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,3 +34,4 @@ yarn-error.log
|
||||
/public/manga-export/
|
||||
/public/manga-images/
|
||||
/public/cbz/
|
||||
/public/images/
|
||||
|
||||
@@ -31,6 +31,7 @@ RUN set -eux; \
|
||||
intl \
|
||||
opcache \
|
||||
zip \
|
||||
gd \
|
||||
;
|
||||
|
||||
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
|
||||
|
||||
5
Makefile
5
Makefile
@@ -143,7 +143,7 @@ consume: ## Consume messages
|
||||
|
||||
## —— Webpack Encore —————————————————————————————————————————————————————————————
|
||||
npm-install: ## Install npm dependencies
|
||||
@$(DOCKER_COMP) exec node npm install
|
||||
@$(DOCKER_COMP) exec node npm install --force
|
||||
|
||||
npm-run: ## Run the dev server
|
||||
@$(DOCKER_COMP) exec node npm run dev
|
||||
@@ -156,3 +156,6 @@ npm-add: ## Add a package as a dependency make npm-add p=package-name
|
||||
|
||||
npm-add-dev: ## Add a package as a dev dependency make npm-add-dev p=package-name
|
||||
@$(DOCKER_COMP) exec node npm install $(p) --save-dev
|
||||
|
||||
npm-remove: ## Remove a package make npm-remove p=package-name
|
||||
@$(DOCKER_COMP) exec node npm uninstall $(p)
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
"@symfony/ux-live-component/dist/live.min.css": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@symfony/ux-turbo": {
|
||||
"turbo-core": {
|
||||
"enabled": true,
|
||||
"fetch": "eager"
|
||||
},
|
||||
"mercure-turbo-stream": {
|
||||
"enabled": false,
|
||||
"fetch": "eager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoints": []
|
||||
|
||||
@@ -35,11 +35,14 @@ export default class extends Controller {
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
50
assets/controllers/chapter_progress_controller.js
Normal file
50
assets/controllers/chapter_progress_controller.js
Normal file
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
this.progressBarElement = this.progressBarTarget.querySelector('.bg-blue-600');
|
||||
|
||||
const mercureHubUrl = 'https://localhost/.well-known/mercure';
|
||||
this.eventSource = new EventSource(`${mercureHubUrl}?topic=activity`);
|
||||
|
||||
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 === "Page Scrapping progress" && data.chapterId === this.chapterIdValue) {
|
||||
this.handleProgressUpdate(data);
|
||||
}
|
||||
}
|
||||
|
||||
handleProgressUpdate(data) {
|
||||
this.currentPage = data.pageIndex + 1;
|
||||
this.totalPages = data.totalPages;
|
||||
|
||||
if (this.currentPage > 1) {
|
||||
this.progressBarTarget.classList.remove('hidden');
|
||||
}
|
||||
|
||||
this.updateProgressBar();
|
||||
}
|
||||
|
||||
updateProgressBar() {
|
||||
const progress = (this.currentPage / this.totalPages) * 100;
|
||||
this.progressBarElement.style.width = `${progress}%`;
|
||||
this.progressTextTarget.textContent = `${this.currentPage} / ${this.totalPages}`;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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 = ['icon']
|
||||
|
||||
connect() {
|
||||
this.defaultIconClass = this.iconTarget.classList.value;
|
||||
}
|
||||
|
||||
async handleClick(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const button = event.currentTarget;
|
||||
const url = button.dataset.url;
|
||||
|
||||
// Change the icon to a loader
|
||||
this.iconTarget.classList.remove("fa-search");
|
||||
this.iconTarget.classList.add("fa-spinner");
|
||||
this.iconTarget.classList.add("fa-spin");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${url}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// Handle the response data as needed
|
||||
if(data.error){
|
||||
this.dispatchAlert(data.error, 'error');
|
||||
}else if(data.success) {
|
||||
this.dispatchAlert(data.success, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"doctrine/doctrine-migrations-bundle": "^3.3",
|
||||
"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",
|
||||
@@ -42,6 +43,7 @@
|
||||
"symfony/stimulus-bundle": "^2.17",
|
||||
"symfony/twig-bundle": "7.0.*",
|
||||
"symfony/ux-live-component": "^2.17",
|
||||
"symfony/ux-turbo": "^2.18",
|
||||
"symfony/validator": "7.0.*",
|
||||
"symfony/webpack-encore-bundle": "^2.1",
|
||||
"symfony/yaml": "7.0.*",
|
||||
|
||||
235
composer.lock
generated
235
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d52c83bad4e4c116ba33e0f33b9cfd7b",
|
||||
"content-hash": "821989b8a12af699869b3885cb6c3660",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/core",
|
||||
@@ -1820,6 +1820,142 @@
|
||||
],
|
||||
"time": "2023-12-03T20:05:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/gif",
|
||||
"version": "4.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/gif.git",
|
||||
"reference": "3a2b5f8a8856e8877cdab5c47e51aab2d4cb23a3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/gif/zipball/3a2b5f8a8856e8877cdab5c47e51aab2d4cb23a3",
|
||||
"reference": "3a2b5f8a8856e8877cdab5c47e51aab2d4cb23a3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"slevomat/coding-standard": "~8.0",
|
||||
"squizlabs/php_codesniffer": "^3.8"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Gif\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "oliver@intervention.io",
|
||||
"homepage": "https://intervention.io/"
|
||||
}
|
||||
],
|
||||
"description": "Native PHP GIF Encoder/Decoder",
|
||||
"homepage": "https://github.com/intervention/gif",
|
||||
"keywords": [
|
||||
"animation",
|
||||
"gd",
|
||||
"gif",
|
||||
"image"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/gif/issues",
|
||||
"source": "https://github.com/Intervention/gif/tree/4.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-03-26T17:23:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
"version": "3.7.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image.git",
|
||||
"reference": "5451ff9f909c2fc836722e5ed6831b9f9a6db68c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/5451ff9f909c2fc836722e5ed6831b9f9a6db68c",
|
||||
"reference": "5451ff9f909c2fc836722e5ed6831b9f9a6db68c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"intervention/gif": "^4.1",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"phpstan/phpstan": "^1",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"slevomat/coding-standard": "~8.0",
|
||||
"squizlabs/php_codesniffer": "^3.8"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-exif": "Recommended to be able to read EXIF data properly."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Image\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "oliver@intervention.io",
|
||||
"homepage": "https://intervention.io/"
|
||||
}
|
||||
],
|
||||
"description": "PHP image manipulation",
|
||||
"homepage": "https://image.intervention.io/",
|
||||
"keywords": [
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"resize",
|
||||
"thumbnail",
|
||||
"watermark"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image/issues",
|
||||
"source": "https://github.com/Intervention/image/tree/3.7.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-07-05T13:35:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
"version": "5.3.0",
|
||||
@@ -7358,6 +7494,103 @@
|
||||
],
|
||||
"time": "2024-04-22T18:53:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/ux-turbo",
|
||||
"version": "v2.18.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/ux-turbo.git",
|
||||
"reference": "e447231ddcc09ab68d29047f47d31a524837dc7a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/ux-turbo/zipball/e447231ddcc09ab68d29047f47d31a524837dc7a",
|
||||
"reference": "e447231ddcc09ab68d29047f47d31a524837dc7a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"symfony/stimulus-bundle": "^2.9.1"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/flex": "<1.13"
|
||||
},
|
||||
"require-dev": {
|
||||
"dbrekelmans/bdi": "dev-main",
|
||||
"doctrine/doctrine-bundle": "^2.4.3",
|
||||
"doctrine/orm": "^2.8 | 3.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"symfony/debug-bundle": "^5.4|^6.0|^7.0",
|
||||
"symfony/expression-language": "^5.4|^6.0|^7.0",
|
||||
"symfony/form": "^5.4|^6.0|^7.0",
|
||||
"symfony/framework-bundle": "^5.4|^6.0|^7.0",
|
||||
"symfony/mercure-bundle": "^0.3.7",
|
||||
"symfony/messenger": "^5.4|^6.0|^7.0",
|
||||
"symfony/panther": "^1.0|^2.0",
|
||||
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0",
|
||||
"symfony/process": "^5.4|6.3.*|^7.0",
|
||||
"symfony/property-access": "^5.4|^6.0|^7.0",
|
||||
"symfony/security-core": "^5.4|^6.0|^7.0",
|
||||
"symfony/stopwatch": "^5.4|^6.0|^7.0",
|
||||
"symfony/twig-bundle": "^5.4|^6.0|^7.0",
|
||||
"symfony/web-profiler-bundle": "^5.4|^6.0|^7.0",
|
||||
"symfony/webpack-encore-bundle": "^2.1.1"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"name": "symfony/ux",
|
||||
"url": "https://github.com/symfony/ux"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\UX\\Turbo\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Kévin Dunglas",
|
||||
"email": "kevin@dunglas.fr"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Hotwire Turbo integration for Symfony",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"hotwire",
|
||||
"javascript",
|
||||
"mercure",
|
||||
"symfony-ux",
|
||||
"turbo",
|
||||
"turbo-stream"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/ux-turbo/tree/v2.18.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-06-01T17:56:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/ux-twig-component",
|
||||
"version": "v2.17.0",
|
||||
|
||||
@@ -19,4 +19,5 @@ return [
|
||||
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
|
||||
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
|
||||
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -9,7 +9,7 @@ framework:
|
||||
cookie_secure: true
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
fragments: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
|
||||
@@ -52,9 +52,13 @@ services:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\EventListener\MangaScrapedListener:
|
||||
tags:
|
||||
- { name: kernel.event_listener, event: 'manga.scraped', method: 'onMangaScraped' }
|
||||
App\Controller\TestController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Controller\MangaController:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\EventSubscriber\QueueStatusSubscriber:
|
||||
tags:
|
||||
|
||||
32
migrations/Version20240706171902.php
Normal file
32
migrations/Version20240706171902.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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 Version20240706171902 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 manga ADD thumbnail_url VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
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 manga DROP thumbnail_url');
|
||||
}
|
||||
}
|
||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -18,8 +18,10 @@
|
||||
"@babel/core": "^7.17.0",
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@hotwired/stimulus": "^3.0.0",
|
||||
"@hotwired/turbo": "^7.1.1 || ^8.0",
|
||||
"@symfony/stimulus-bridge": "^3.2.0",
|
||||
"@symfony/ux-live-component": "file:vendor/symfony/ux-live-component/assets",
|
||||
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
|
||||
"@symfony/webpack-encore": "^4.0.0",
|
||||
"core-js": "^3.23.0",
|
||||
"daisyui": "^4.4.2",
|
||||
@@ -1781,6 +1783,15 @@
|
||||
"@hotwired/stimulus": ">= 3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hotwired/turbo": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.4.tgz",
|
||||
"integrity": "sha512-mlZEFUZrJnpfj+g/XeCWWuokvQyN68WvM78JM+0jfSFc98wegm259vCbC1zSllcspRwbgXK31ibehCy5PA78/Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -2171,6 +2182,10 @@
|
||||
"resolved": "vendor/symfony/ux-live-component/assets",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@symfony/ux-turbo": {
|
||||
"resolved": "vendor/symfony/ux-turbo/assets",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@symfony/webpack-encore": {
|
||||
"version": "4.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@symfony/webpack-encore/-/webpack-encore-4.6.1.tgz",
|
||||
@@ -10644,6 +10659,19 @@
|
||||
"peerDependencies": {
|
||||
"@hotwired/stimulus": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"vendor/symfony/ux-turbo/assets": {
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@hotwired/stimulus": "^3.0.0",
|
||||
"@hotwired/turbo": "^7.1.0 || ^8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hotwired/stimulus": "^3.0.0",
|
||||
"@hotwired/turbo": "^7.1.1 || ^8.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
"@babel/core": "^7.17.0",
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@hotwired/stimulus": "^3.0.0",
|
||||
"@hotwired/turbo": "^7.1.1 || ^8.0",
|
||||
"@symfony/stimulus-bridge": "^3.2.0",
|
||||
"@symfony/ux-live-component": "file:vendor/symfony/ux-live-component/assets",
|
||||
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
|
||||
"@symfony/webpack-encore": "^4.0.0",
|
||||
"core-js": "^3.23.0",
|
||||
"daisyui": "^4.4.2",
|
||||
|
||||
@@ -89,6 +89,7 @@ class ActivityController extends AbstractController
|
||||
'manga' => $manga->getTitle(),
|
||||
'volume' => $chapter->getVolume(),
|
||||
'chapter' => $chapter->getNumber(),
|
||||
'chapterId' => $chapter->getId(),
|
||||
'title' => $chapter->getTitle(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,31 +15,42 @@ 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\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
class MangaController extends AbstractController
|
||||
{
|
||||
private ImageManager $imageManager;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $projectDir,
|
||||
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 EntityManagerInterface $entityManager,
|
||||
private readonly NotificationService $notificationService,
|
||||
private readonly SluggerInterface $slugger
|
||||
)
|
||||
{
|
||||
$this->imageManager = new ImageManager(new Driver());
|
||||
}
|
||||
|
||||
#[Route('/manga', name: 'app_manga')]
|
||||
#[Route('/', name: 'app_manga')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$sort = $request->query->get('sort', 'title');
|
||||
@@ -57,11 +68,8 @@ class MangaController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NonUniqueResultException
|
||||
*/
|
||||
#[Route('/manga/chapters/{mangaSlug}', name: 'app_manga_show')]
|
||||
public function showChapters(string $mangaSlug): Response
|
||||
public function showChapters(string $mangaSlug, Request $request): Response
|
||||
{
|
||||
// $manga = $this->mangaRepository->findOneWithChapterBy(['slug' => $mangaSlug]);
|
||||
$manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]);
|
||||
@@ -70,6 +78,16 @@ class MangaController extends AbstractController
|
||||
throw new NotFoundHttpException("Le manga demandé n'existe pas.");
|
||||
}
|
||||
|
||||
return $this->render('manga/show_chapters.html.twig', [
|
||||
'manga' => $manga,
|
||||
'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId()])->getGroups(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function _chaptersByManga(int $id): Response
|
||||
{
|
||||
$manga = $this->mangaRepository->find($id);
|
||||
$chaptersByVolume = [];
|
||||
foreach ($manga->getChapters() as $chapter) {
|
||||
$volume = $chapter->getVolume() ?? 'Not Found';
|
||||
@@ -92,10 +110,10 @@ class MangaController extends AbstractController
|
||||
}
|
||||
return $b <=> $a;
|
||||
});
|
||||
return $this->render('manga/show_chapters.html.twig', [
|
||||
'chapters_by_volume' => $chaptersByVolume,
|
||||
|
||||
return $this->render('manga/_chapter_list.html.twig', [
|
||||
'manga' => $manga,
|
||||
'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId()])->getGroups(),
|
||||
'chapters_by_volume' => $chaptersByVolume
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -154,11 +172,15 @@ class MangaController extends AbstractController
|
||||
#[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'))
|
||||
->setImageUrl($request->request->get('imageUrl'))
|
||||
->setStatus($request->request->get('status'))
|
||||
->setGenres(explode(',', $request->request->get('genres')))
|
||||
->setAuthor($request->request->get('author'))
|
||||
@@ -166,6 +188,16 @@ class MangaController extends AbstractController
|
||||
->setRating($request->request->get('rating'))
|
||||
->setExternalId($request->request->get('externalId'));
|
||||
|
||||
// Traitement de l'image
|
||||
$imageUrl = $request->request->get('imageUrl');
|
||||
try {
|
||||
$imageUrls = $this->processAndSaveImage($imageUrl);
|
||||
$manga->setImageUrl($imageUrls['full']);
|
||||
$manga->setThumbnailUrl($imageUrls['thumbnail']);
|
||||
} catch (\Exception $e) {
|
||||
// Gérer l'exception (par exemple, logger l'erreur)
|
||||
}
|
||||
|
||||
$mergedChapters = $this->mangadexProvider->addAllChaptersToManga($manga);
|
||||
|
||||
if (empty($mergedChapters)) {
|
||||
@@ -189,7 +221,48 @@ class MangaController extends AbstractController
|
||||
return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $manga->getSlug()]);
|
||||
}
|
||||
|
||||
#[Route('/addChapter/{id}', name: 'add_chapter')]
|
||||
/**
|
||||
* @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);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/searchChapter/{id}', name: 'search_chapter')]
|
||||
public function addChapterMessenger(int $id): JsonResponse
|
||||
{
|
||||
$chapter = $this->chapterRepository->find($id);
|
||||
@@ -204,37 +277,86 @@ class MangaController extends AbstractController
|
||||
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
|
||||
]);
|
||||
|
||||
if (empty($volumeChapters)) {
|
||||
$this->notificationService->sendUpdate(['error' => '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
|
||||
public function downloadChapter(int $chapterId): BinaryFileResponse|JsonResponse
|
||||
{
|
||||
$chapter = $this->chapterRepository->find($chapterId);
|
||||
if (!$chapter) {
|
||||
throw $this->createNotFoundException("Le chapitre demandé n'existe pas.");
|
||||
$this->notificationService->sendUpdate(['error' => 'Chapitre non trouvé.']);
|
||||
return new JsonResponse(['error' => 'Chapitre non trouvé.'], 200);
|
||||
}
|
||||
|
||||
$cbzPath = $chapter->getCbzPath();
|
||||
if (!$cbzPath || !file_exists($cbzPath)) {
|
||||
throw $this->createNotFoundException("Le fichier CBZ n'existe pas.");
|
||||
$this->notificationService->sendUpdate(['error' => 'Le fichier CBZ n\'existe pas.']);
|
||||
return new JsonResponse(['error' => 'Le fichier CBZ n\'existe pas.'], 200);
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($cbzPath);
|
||||
|
||||
// Vérifier si c'est un volume complet ou un chapitre individuel
|
||||
$isFullVolume = $this->isFullVolume($chapter);
|
||||
$fileName = $isFullVolume
|
||||
? $this->cbzService->generateFileName($chapter->getManga(), $chapter->getVolume())
|
||||
: $this->cbzService->generateFileName($chapter->getManga(), null, $chapter->getNumber());
|
||||
|
||||
if ($isFullVolume) {
|
||||
$fileName = sprintf("%s_volume_%02d.cbz", $chapter->getManga()->getSlug(), $chapter->getVolume());
|
||||
} else {
|
||||
$fileName = sprintf("%s_chapter_%s.cbz", $chapter->getManga()->getSlug(), number_format($chapter->getNumber(), 2));
|
||||
return $this->cbzService->createBinaryFileResponse($cbzPath, $fileName);
|
||||
}
|
||||
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
$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
|
||||
]);
|
||||
|
||||
if (empty($volumeChapters)) {
|
||||
$this->notificationService->sendUpdate(['error' => 'Aucun chapitre trouvé pour ce volume.']);
|
||||
}
|
||||
|
||||
if (!$this->cbzService->doAllChaptersHaveCbz($volumeChapters)) {
|
||||
$this->notificationService->sendUpdate(['error' => '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
|
||||
|
||||
@@ -8,83 +8,94 @@ 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 App\Service\SushiScanProviderService;
|
||||
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 MangadexProvider $mangadexProvider,
|
||||
private MangaRepository $mangaRepository,
|
||||
private MessageBusInterface $bus,
|
||||
private Connection $connection,
|
||||
private SerializerInterface $serializer,
|
||||
private readonly ChapterRepository $chapterRepository
|
||||
private string $projectDir,
|
||||
private SluggerInterface $slugger,
|
||||
private MangaRepository $mangaRepository
|
||||
)
|
||||
{
|
||||
$this->imageManager = new ImageManager(new Driver());
|
||||
}
|
||||
|
||||
#[Route('/test', name: 'test')]
|
||||
public function test(): Response
|
||||
{
|
||||
$sqlPending = 'SELECT * FROM messenger_messages WHERE queue_name = :queue';
|
||||
$pending = $this->connection->fetchAllAssociative($sqlPending, ['queue' => 'default']);
|
||||
$mangas = $this->mangaRepository->findAll();
|
||||
|
||||
// // 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']);
|
||||
|
||||
// dd($pending);
|
||||
$decoded = $this->decodeMessages($pending);
|
||||
|
||||
$status = [];
|
||||
foreach($decoded as $message) {
|
||||
$message = $message['body'];
|
||||
if($message instanceof Envelope) {
|
||||
$chapter = $this->chapterRepository->find($message->getMessage()->getChapterId());
|
||||
$manga = $chapter->getManga();
|
||||
$status[] = [
|
||||
'manga' => $manga->getTitle(),
|
||||
'volume' => $chapter->getVolume(),
|
||||
'chapter' => $chapter->getNumber(),
|
||||
'title' => $chapter->getTitle(),
|
||||
];
|
||||
$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++;
|
||||
}
|
||||
}
|
||||
|
||||
// $this->bus->dispatch(new DownloadChapter(1));
|
||||
|
||||
dd($status);
|
||||
return new JsonResponse(['changed' => $changed]);
|
||||
}
|
||||
|
||||
private function decodeMessages(array $messages): array
|
||||
/**
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
private function processAndSaveImage(string $imageUrl): array
|
||||
{
|
||||
$decodedMessages = [];
|
||||
$image = file_get_contents($this->projectDir . '/public' .$imageUrl);
|
||||
$tempImage = tmpfile();
|
||||
fwrite($tempImage, $image);
|
||||
$tempImagePath = stream_get_meta_data($tempImage)['uri'];
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$decodedMessages[] = [
|
||||
'id' => $message['id'],
|
||||
'body' => $this->decodeMessageBody($message['body']),
|
||||
'headers' => json_decode($message['headers'], true),
|
||||
// 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
|
||||
];
|
||||
}
|
||||
|
||||
return $decodedMessages;
|
||||
} catch (FileException $e) {
|
||||
// Fermer le fichier temporaire en cas d'erreur
|
||||
fclose($tempImage);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
private function decodeMessageBody(string $body)
|
||||
{
|
||||
return unserialize(stripcslashes($body));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ class ContentSource
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $baseUrl = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $imageSelector = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $NextPageSelector = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
|
||||
@@ -52,6 +52,9 @@ class Manga
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $status = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $thumbnailUrl = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->chapters = new ArrayCollection();
|
||||
@@ -234,4 +237,16 @@ class Manga
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getThumbnailUrl(): ?string
|
||||
{
|
||||
return $this->thumbnailUrl;
|
||||
}
|
||||
|
||||
public function setThumbnailUrl(?string $thumbnailUrl): static
|
||||
{
|
||||
$this->thumbnailUrl = $thumbnailUrl;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\Chapter;
|
||||
use App\Entity\Manga;
|
||||
use App\Entity\Page;
|
||||
use App\EventSubscriber\MangaScrapedEvent;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class MangaScrapedListener
|
||||
{
|
||||
private EntityManagerInterface $entityManager;
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
public function onMangaScraped(MangaScrapedEvent $event): void
|
||||
{
|
||||
$mangaData = $event->getMangaData();
|
||||
$manga = $this->entityManager->getRepository(Manga::class)->findOneBy(['title' => $mangaData['title']]);
|
||||
if (!$manga) {
|
||||
$manga = new Manga();
|
||||
$manga->setTitle($mangaData['title']);
|
||||
$this->entityManager->persist($manga);
|
||||
}
|
||||
|
||||
$chapter = $manga->getChapterByNumber($mangaData['chapter']);
|
||||
if (!$chapter) {
|
||||
$chapter = (new Chapter())
|
||||
->setNumber($mangaData['chapter']);
|
||||
$manga->addChapter($chapter);
|
||||
$this->entityManager->persist($chapter);
|
||||
$this->entityManager->persist($manga);
|
||||
}
|
||||
|
||||
$chapter->setLocalPath($mangaData['directory']);
|
||||
|
||||
foreach ($mangaData['pages'] as $pageData) {
|
||||
$page = $chapter->getPageByNumber($pageData['page_number']);
|
||||
if (!$page) {
|
||||
$page = (new Page())
|
||||
->setNumber($pageData['page_number'])
|
||||
->setImageUrl($pageData['image_url'])
|
||||
->setImageLocalUrl($pageData['local_image_url']);
|
||||
|
||||
$chapter->addPagesLink($page);
|
||||
$this->entityManager->persist($chapter);
|
||||
$this->entityManager->persist($page);
|
||||
}
|
||||
}
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
class MangaScrapedEvent extends Event
|
||||
{
|
||||
public const NAME = 'manga.scraped';
|
||||
|
||||
private string $mangaTitle;
|
||||
private float $chapterNumber;
|
||||
private array $pagesData;
|
||||
private string $chapterDirectory;
|
||||
|
||||
public function __construct(string $mangaTitle, float $chapterNumber, array $pagesData, string $chapterDirectory)
|
||||
{
|
||||
$this->mangaTitle = $mangaTitle;
|
||||
$this->chapterNumber = $chapterNumber;
|
||||
$this->pagesData = $pagesData;
|
||||
$this->chapterDirectory = $chapterDirectory;
|
||||
}
|
||||
|
||||
public function getMangaData(): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->mangaTitle,
|
||||
'chapter' => $this->chapterNumber,
|
||||
'pages' => $this->pagesData,
|
||||
'directory' => $this->chapterDirectory
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Manga;
|
||||
use Exception;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
use ZipArchive;
|
||||
|
||||
@@ -133,4 +136,68 @@ class CbzService
|
||||
sort($images);
|
||||
return $images;
|
||||
}
|
||||
|
||||
public function createVolumeArchive(array $chapters): string
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'volume_cbz_');
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($tempFile, ZipArchive::CREATE) !== TRUE) {
|
||||
throw new \RuntimeException("Impossible de créer le fichier ZIP temporaire.");
|
||||
}
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$chapterZip = new ZipArchive();
|
||||
if ($chapterZip->open($chapter->getCbzPath()) === TRUE) {
|
||||
for ($i = 0; $i < $chapterZip->numFiles; $i++) {
|
||||
$filename = $chapterZip->getNameIndex($i);
|
||||
$fileContent = $chapterZip->getFromIndex($i);
|
||||
$zip->addFromString("Chapter " . $chapter->getNumber() . "/" . $filename, $fileContent);
|
||||
}
|
||||
$chapterZip->close();
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
return $tempFile;
|
||||
}
|
||||
|
||||
public function generateFileName(Manga $manga, ?int $volume = null, ?float $chapterNumber = null): string
|
||||
{
|
||||
$sluggedTitle = $this->slugger->slug($manga->getTitle())->lower();
|
||||
if ($volume !== null) {
|
||||
return sprintf("%s_volume_%02d.cbz", $sluggedTitle, $volume);
|
||||
} elseif ($chapterNumber !== null) {
|
||||
return sprintf("%s_chapter_%s.cbz", $sluggedTitle, number_format($chapterNumber, 2));
|
||||
} else {
|
||||
throw new \InvalidArgumentException("Either volume or chapter number must be provided");
|
||||
}
|
||||
}
|
||||
|
||||
public function createBinaryFileResponse(string $filePath, string $fileName): BinaryFileResponse
|
||||
{
|
||||
$response = new BinaryFileResponse($filePath);
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
$fileName
|
||||
);
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function areAllChaptersCbzIdentical(array $chapters): bool
|
||||
{
|
||||
if (empty($chapters)) {
|
||||
return false;
|
||||
}
|
||||
$firstCbzPath = $chapters[0]->getCbzPath();
|
||||
return array_reduce($chapters, function ($carry, $chapter) use ($firstCbzPath) {
|
||||
return $carry && $chapter->getCbzPath() === $firstCbzPath;
|
||||
}, true);
|
||||
}
|
||||
|
||||
public function doAllChaptersHaveCbz(array $chapters): bool
|
||||
{
|
||||
return array_reduce($chapters, function ($carry, $chapter) {
|
||||
return $carry && $chapter->getCbzPath() !== null;
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Entity\Chapter;
|
||||
use App\Entity\Manga;
|
||||
use App\Entity\ContentSource;
|
||||
use App\Event\PageScrappingProgressEvent;
|
||||
use App\EventSubscriber\MangaScrapedEvent;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use GuzzleHttp\Client;
|
||||
|
||||
@@ -258,6 +258,9 @@
|
||||
"config/routes/ux_live_component.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/ux-turbo": {
|
||||
"version": "v2.18.0"
|
||||
},
|
||||
"symfony/ux-twig-component": {
|
||||
"version": "2.17",
|
||||
"recipe": {
|
||||
|
||||
@@ -23,13 +23,24 @@
|
||||
</thead>
|
||||
<tbody class="text-gray-700">
|
||||
{% for manga in status %}
|
||||
<tr class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out">
|
||||
<tr class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out"
|
||||
data-controller="chapter-progress"
|
||||
data-chapter-progress-chapter-id-value="{{ manga.chapterId }}">
|
||||
<td class="py-4 px-4 text-center">
|
||||
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600">
|
||||
</td>
|
||||
<td class="py-4 px-4 font-medium">{{ manga.manga }}</td>
|
||||
<td class="py-4 px-4">{{ manga.volume }}</td>
|
||||
<td class="py-4 px-4">{{ manga.chapter }}</td>
|
||||
<td class="py-4 px-4">
|
||||
{{ manga.chapter }}
|
||||
<div class="mt-2 hidden" data-chapter-progress-target="progressBar">
|
||||
<div class="bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||
<div class="bg-green-600 h-2.5 rounded-full" style="width: 0"></div>
|
||||
</div>
|
||||
<div class="text-xs mt-1 text-center" data-chapter-progress-target="progressText">
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 px-4">{{ manga.title }}</td>
|
||||
<td class="py-4 px-4">
|
||||
<button class="text-red-500 hover:text-red-700 transition duration-150 ease-in-out">
|
||||
@@ -46,9 +57,5 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# <div class="mt-6 ml-4 flex justify-between items-center"> #}
|
||||
{# <span class="text-sm text-gray-600">Total des enregistrements: {{ status|length }}</span> #}
|
||||
{# </div> #}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
{% endblock %}
|
||||
{% block javascripts %}
|
||||
{{ encore_entry_script_tags('app') }}
|
||||
{# {{ encore_entry_script_tags('turbo') }} #}
|
||||
{% endblock %}
|
||||
<meta name="turbo-refresh-scroll" content="preserve">
|
||||
</head>
|
||||
<body class="bg-gray-50 h-full overflow-hidden" data-controller="menu">
|
||||
<div data-controller="mercure" data-mercure-topic="notification"></div>
|
||||
@@ -51,19 +53,21 @@
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="ml-4 w-60 relative">
|
||||
<twig:Search/> <!-- Search component -->
|
||||
<twig:Search/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex h-full pt-16">
|
||||
<!-- Sidebar -->
|
||||
<nav data-menu-target="sidebar" class="w-60 bg-white h-full overflow-y-auto fixed left-0 transform -translate-x-full transition-transform duration-200 ease-in-out md:translate-x-0 z-40">
|
||||
{% include 'menu/menu.html.twig' %} <!-- Menu component -->
|
||||
<nav data-menu-target="sidebar"
|
||||
class="w-60 bg-white h-full overflow-y-auto fixed left-0 transform -translate-x-full transition-transform duration-200 ease-in-out md:translate-x-0 z-40">
|
||||
{% include 'menu/menu.html.twig' %}
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden md:ml-60 w-full">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="bg-white shadow z-20 w-full">
|
||||
{% block toolbar %}
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<twig:ToolBarButton
|
||||
icon="{{ icon }}"
|
||||
text="{{ text }}"
|
||||
action="toggle"
|
||||
action="dropdown#toggle"
|
||||
data-dropdown-target="button"
|
||||
controller="dropdown"
|
||||
/>
|
||||
<div class="absolute left-0 mt-2 w-max z-10 bg-gray-800 rounded-sm shadow-lg hidden"
|
||||
data-dropdown-target="menu" data-controller="toolbar">
|
||||
<div class="py-1">
|
||||
data-dropdown-target="menu">
|
||||
<div class="py-1" data-controller="toolbar">
|
||||
{% for item in items %}
|
||||
<a href="#"
|
||||
class="block px-4 py-2 text-sm text-white hover:text-green-500"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div {{ attributes }}>
|
||||
<button class="flex flex-col justify-around min-h-14 w-min ml-4 items-center text-white group"
|
||||
{% if action %}
|
||||
{{ stimulus_action('toolbar', action) }}
|
||||
data-action="{{ action }}"
|
||||
{% endif %}
|
||||
{{ buttonAttributes|join(' ') }}
|
||||
>
|
||||
|
||||
152
templates/manga/_chapter_list.html.twig
Normal file
152
templates/manga/_chapter_list.html.twig
Normal file
@@ -0,0 +1,152 @@
|
||||
<turbo-frame id="chapter_list">
|
||||
<div class="p-4">
|
||||
{% for volume, chapters in chapters_by_volume %}
|
||||
{% set is_first = loop.first %}
|
||||
{% set volume_cbz_path = chapters|first.cbzPath %}
|
||||
{% set all_chapters_same_cbz = chapters|reduce((carry, chapter) => carry and chapter.cbzPath == volume_cbz_path, true) %}
|
||||
{% set available_chapters = chapters|filter(chapter => chapter.cbzPath is not null) %}
|
||||
{% set total_chapters = chapters|length %}
|
||||
|
||||
<div data-controller="table" data-table-open-value="{{ is_first ? 'true' : 'false' }}">
|
||||
<div class="bg-white rounded-sm shadow mb-4">
|
||||
<div class="relative flex items-center justify-between bg-white p-4 rounded-t-sm">
|
||||
<!-- Partie gauche -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<i class="fas fa-bookmark text-gray-500 text-3xl w-8"></i>
|
||||
<h2 class="text-xl font-semibold w-28">Volume {{ '%02d'|format(volume) }}</h2>
|
||||
<div class="flex items-center w-16">
|
||||
<span class="px-2 py-1 text-sm rounded w-full text-center
|
||||
{% if available_chapters|length == 0 %}
|
||||
bg-red-500 text-white
|
||||
{% elseif available_chapters|length < total_chapters %}
|
||||
bg-yellow-500 text-white
|
||||
{% else %}
|
||||
bg-green-500 text-white
|
||||
{% endif %}">
|
||||
{{ available_chapters|length }} / {{ total_chapters }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
<i data-table-target="toggleIcon" data-action="click->table#toggle"
|
||||
class="bg-gray-400 rounded-full font-bold text-sm text-white p-1 hover:bg-green-500 fas fa-chevron-{{ is_first ? 'up' : 'down' }} cursor-pointer"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 text-xl text-bold">
|
||||
<a href="#"
|
||||
data-controller="download"
|
||||
data-action="download#download"
|
||||
data-download-url-value="{{ path('search_volume', {'mangaSlug': manga.slug, 'volume': volume}) }}"
|
||||
class="w-8 text-center">
|
||||
<i data-download-target="icon"
|
||||
class="fas fa-search text-gray-500 hover:text-green-500"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
data-controller="download"
|
||||
data-action="download#download"
|
||||
data-download-url-value="{{ path('download_volume', {'mangaSlug': manga.slug, 'volume': volume}) }}"
|
||||
class="w-8 text-center">
|
||||
<i data-download-target="icon"
|
||||
class="fas fa-download text-gray-500 hover:text-green-500"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-table-target="body"
|
||||
class="p-4 border-t" {{ not is_first ? 'style="display: none;"' : '' }}>
|
||||
<table class="min-w-full table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left">#</th>
|
||||
<th class="px-4 py-2 text-left">Title</th>
|
||||
<th class="px-4 py-2 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if all_chapters_same_cbz and volume_cbz_path is not null %}
|
||||
<tr class="border-t hover:bg-green-100">
|
||||
<td class="px-4 py-2 text-green-500">
|
||||
<a data-turbo-frame="_top" href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}">
|
||||
{{ '%02d'|format(volume) }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 w-full text-left">
|
||||
<a data-turbo-frame="_top" href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}">
|
||||
Volume {{ '%02d'|format(volume) }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 flex justify-end gap-2">
|
||||
<a href="{{ path('download_cbz', {chapterId: chapters|first.id}) }}"
|
||||
class="text-gray-500 hover:text-green-500">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for chapter in chapters %}
|
||||
<tr class="border-t hover:bg-green-100">
|
||||
{% if chapter.cbzPath is not null %}
|
||||
<td class="px-4 py-2 text-green-500">
|
||||
<a data-turbo-frame="_top" href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}">
|
||||
{{ '%02d'|format(chapter.number) }}
|
||||
</a>
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="px-4 py-2">{{ '%02d'|format(chapter.number) }}</td>
|
||||
{% endif %}
|
||||
|
||||
<td class="px-4 py-2 w-full text-left">
|
||||
{% if chapter.cbzPath is not null %}
|
||||
<a data-turbo-frame="_top" href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}">
|
||||
{{ chapter.title ?? 'No title' }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ chapter.title ?? 'No title' }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 flex justify-end gap-2">
|
||||
{% if chapter.cbzPath is null %}
|
||||
<button
|
||||
data-controller="download"
|
||||
data-action="download#download"
|
||||
data-download-url-value="{{ path('search_chapter', {id: chapter.id}) }}"
|
||||
>
|
||||
<span class="text-gray-500 hover:text-green-500">
|
||||
<i data-download-target="icon" class="fas fa-search"></i>
|
||||
</span>
|
||||
</button>
|
||||
{% else %}
|
||||
<button disabled>
|
||||
<span class="text-gray-500">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="#"
|
||||
data-controller="download"
|
||||
data-action="download#download"
|
||||
data-download-url-value="{{ path('download_cbz', {chapterId: chapter.id}) }}"
|
||||
class="w-8 text-center">
|
||||
<i data-download-target="icon"
|
||||
class="fas fa-download text-gray-500 hover:text-green-500"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="relative flex items-center justify-between bg-white mt-4 mb-2 rounded-t-sm">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
<i data-table-target="toggleIcon" data-action="click->table#toggle"
|
||||
class="bg-gray-400 rounded-full font-bold text-sm text-white p-1 hover:bg-green-500 fas fa-chevron-up cursor-pointer"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</turbo-frame>
|
||||
94
templates/manga/_list.html.twig
Normal file
94
templates/manga/_list.html.twig
Normal file
@@ -0,0 +1,94 @@
|
||||
{% block body %}
|
||||
{% if currentView == 'poster' %}
|
||||
{# Vue poster actuelle #}
|
||||
<div
|
||||
class="w-full p-4 grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8 2xl:grid-cols-12 gap-4">
|
||||
{% for manga in mangas %}
|
||||
<div
|
||||
class="bg-white overflow-hidden border border-gray-200 hover:shadow-2xl hover:border-gray-400 transition-all duration-300 flex flex-col">
|
||||
<a href="{{ path('app_manga_show', { 'mangaSlug': manga.slug }) }}"
|
||||
class="block relative w-full pb-[150%] overflow-hidden">
|
||||
<img src="{{ manga.thumbnailUrl ?? 'https://placehold.co/150x220' }}"
|
||||
alt="{{ manga.title }}"
|
||||
class="absolute top-0 left-0 w-full h-full object-cover">
|
||||
</a>
|
||||
<div class="p-2 flex flex-col justify-between flex-grow">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold truncate">{{ manga.title }}</h3>
|
||||
<p class="text-xs text-gray-500">{{ manga.publicationYear }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">Added: {{ manga.createdAt|date('M d, Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="col-span-full text-center text-gray-500">Aucun manga trouvé.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif currentView == 'resume' %}
|
||||
{# Vue résumé #}
|
||||
<div class="w-full p-4 space-y-4">
|
||||
{% for manga in mangas %}
|
||||
<div
|
||||
class="bg-white overflow-hidden border border-gray-200 hover:shadow-lg hover:border-gray-400 transition-all duration-300 flex">
|
||||
<a class="flex flex-row" href="{{ path('app_manga_show', {'mangaSlug': manga.slug}) }}">
|
||||
<img src="{{ manga.imageUrl ?? 'https://placehold.co/150x220' }}"
|
||||
alt="{{ manga.title }}"
|
||||
class="w-32 h-48 object-cover">
|
||||
<div class="p-4 flex flex-col justify-between flex-grow">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">{{ manga.title }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ manga.publicationYear }}</p>
|
||||
<p class="text-sm text-gray-600 mt-2">{{ manga.description|truncate(350) }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-2">
|
||||
Added: {{ manga.createdAt|date('M d, Y') }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-gray-500">Aucun manga trouvé.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif currentView == 'table' %}
|
||||
<div class="p-4">
|
||||
<table class="min-w-full bg-white">
|
||||
<thead>
|
||||
<tr class="bg-gray-100 text-gray-600 uppercase text-sm leading-normal">
|
||||
<th class="py-3 px-6 text-left">Manga Title</th>
|
||||
{# <th class="py-3 px-6 text-center">Volumes</th> #}
|
||||
<th class="py-3 px-6 text-center">Chapters</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-600 text-sm">
|
||||
{% for manga in mangas %}
|
||||
<tr class="border-b border-gray-200 hover:bg-gray-100">
|
||||
<td class="py-3 px-6 text-left whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<span class="font-medium">{{ manga.title }}</span>
|
||||
</div>
|
||||
</td>
|
||||
{# <td class="py-3 px-6 text-center"> #}
|
||||
{# {{ manga.volumes|length }} #}
|
||||
{# </td> #}
|
||||
<td class="py-3 px-6 text-center">
|
||||
{% set total_chapters = manga.chapters|length %}
|
||||
{% set available_chapters = manga.chapters|filter(chapter => chapter.cbzPath is not null)|length %}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-48 bg-gray-200 rounded-full h-2.5">
|
||||
<div class="bg-blue-600 h-2.5 rounded-full"
|
||||
style="width: {{ (available_chapters / total_chapters * 100)|round }}%"></div>
|
||||
</div>
|
||||
<span class="ml-2">{{ available_chapters }} / {{ total_chapters }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="py-3 px-6 text-center">Aucun manga trouvé.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
112
templates/manga/_manga_details.html.twig
Normal file
112
templates/manga/_manga_details.html.twig
Normal file
@@ -0,0 +1,112 @@
|
||||
{% block body %}
|
||||
<div class="relative">
|
||||
<div class="shadow-lg text-white">
|
||||
<div class="relative h-96 bg-cover bg-center" style="background-image: url('{{ manga.imageUrl }}')">
|
||||
<div class="absolute inset-0 bg-black opacity-50"></div>
|
||||
<div class="absolute inset-0 flex flex-row justify-center p-4">
|
||||
<div class="hidden mr-12 xl:block 2xl:block">
|
||||
<img src="{{ manga.thumbnailUrl }}" alt="{{ manga.title }}" class="max-w-72 max-h-72 ml-4">
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-bookmark text-white text-3xl"></i>
|
||||
<h1 class="text-3xl font-bold ml-4">{{ manga.title }}</h1>
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<span class="mr-4">{{ manga.publicationYear }}</span>
|
||||
<span>Chapters: {{ manga.chapters.count }}</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-folder text-gray-400 mr-2"></i>
|
||||
<span
|
||||
class="truncate">/media/mangas/{{ manga.title }} ({{ manga.publicationYear }})</span>
|
||||
<span
|
||||
class="ml-auto bg-green-600 py-1 px-2 rounded">{{ manga.status ?? 'Terminé' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
{% set genre_count = 0 %}
|
||||
{% for genre in manga.genres %}
|
||||
{% if genre_count < 5 %}
|
||||
<span class="bg-gray-700 py-1 px-2 rounded-sm mr-2">{{ genre }}</span>
|
||||
{% set genre_count = genre_count + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if genre_count == 5 and manga.genres|length > 5 %}
|
||||
<span class="bg-gray-700 py-1 px-2 rounded-sm mr-2">...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fas fa-heart text-red-500 mr-2"></i>
|
||||
<span>{{ manga.rating|round(2) }}</span>
|
||||
</div>
|
||||
<p>{{ manga.description|truncate(500) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<turbo-frame id="chapter_list" src="{{ fragment_uri(controller('App\\Controller\\MangaController::_chaptersByManga', {'id': manga.id})) }}"></turbo-frame>
|
||||
{# Modal d'édition #}
|
||||
<twig:Modal
|
||||
openTrigger="openEditModal"
|
||||
closeTrigger="closeEditModal"
|
||||
title="Edit Manga"
|
||||
>
|
||||
{% block content %}
|
||||
<form id="editForm" method="post" action="">
|
||||
<div class="mb-4">
|
||||
<label for="title" class="block text-gray-700 text-sm font-bold mb-2">Title:</label>
|
||||
<input type="text" id="title" name="title" value="{{ manga.title }}"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="description"
|
||||
class="block text-gray-700 text-sm font-bold mb-2">Description:</label>
|
||||
<textarea id="description" name="description"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
rows="3">{{ manga.description }}</textarea>
|
||||
</div>
|
||||
{# Ajoutez d'autres champs selon vos besoins #}
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% block footer %}
|
||||
<button type="submit" form="editForm"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" data-action="modal#close"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
{% endblock %}
|
||||
</twig:Modal>
|
||||
|
||||
{# Modal de confirmation de suppression #}
|
||||
<twig:Modal
|
||||
openTrigger="openDeleteModal"
|
||||
closeTrigger="closeDeleteModal"
|
||||
title="Delete Manga"
|
||||
>
|
||||
<twig:block name="content">
|
||||
<p class="text-sm text-gray-500">
|
||||
Are you sure you want to delete this manga? This action cannot be undone.
|
||||
</p>
|
||||
</twig:block>
|
||||
<twig:block name="footer">
|
||||
<form id="deleteForm" method="post" action="">
|
||||
<button type="submit"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" data-action="modal#close"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</twig:block>
|
||||
</twig:Modal>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,96 +1,9 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block toolbar %}
|
||||
{% if toolbar %}
|
||||
{% if toolbar is defined %}
|
||||
<twig:Toolbar toolbar="{{ toolbar }}"/>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
{% if currentView == 'poster' %}
|
||||
{# Vue poster actuelle #}
|
||||
<div class="w-full p-4 grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8 2xl:grid-cols-12 gap-4">
|
||||
{% for manga in mangas %}
|
||||
<div
|
||||
class="bg-white overflow-hidden border border-gray-200 hover:shadow-2xl hover:border-gray-400 transition-all duration-300 flex flex-col">
|
||||
<a href="{{ path('app_manga_show', { 'mangaSlug': manga.slug }) }}"
|
||||
class="block relative w-full pb-[150%] overflow-hidden">
|
||||
<img src="{{ manga.imageUrl ?? 'https://placehold.co/150x220' }}" alt="{{ manga.title }}"
|
||||
class="absolute top-0 left-0 w-full h-full object-cover">
|
||||
</a>
|
||||
<div class="p-2 flex flex-col justify-between flex-grow">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold truncate">{{ manga.title }}</h3>
|
||||
<p class="text-xs text-gray-500">{{ manga.publicationYear }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">Added: {{ manga.createdAt|date('M d, Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="col-span-full text-center text-gray-500">Aucun manga trouvé.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif currentView == 'resume' %}
|
||||
{# Vue résumé #}
|
||||
<div class="w-full p-4 space-y-4">
|
||||
{% for manga in mangas %}
|
||||
<div
|
||||
class="bg-white overflow-hidden border border-gray-200 hover:shadow-lg hover:border-gray-400 transition-all duration-300 flex">
|
||||
<a class="flex flex-row" href="{{ path('app_manga_show', {'mangaSlug': manga.slug}) }}">
|
||||
<img src="{{ manga.imageUrl ?? 'https://placehold.co/150x220' }}" alt="{{ manga.title }}"
|
||||
class="w-32 h-48 object-cover">
|
||||
<div class="p-4 flex flex-col justify-between flex-grow">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">{{ manga.title }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ manga.publicationYear }}</p>
|
||||
<p class="text-sm text-gray-600 mt-2">{{ manga.description|truncate(350) }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-2">Added: {{ manga.createdAt|date('M d, Y') }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-gray-500">Aucun manga trouvé.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif currentView == 'table' %}
|
||||
<div class="p-4">
|
||||
<table class="min-w-full bg-white">
|
||||
<thead>
|
||||
<tr class="bg-gray-100 text-gray-600 uppercase text-sm leading-normal">
|
||||
<th class="py-3 px-6 text-left">Manga Title</th>
|
||||
{# <th class="py-3 px-6 text-center">Volumes</th> #}
|
||||
<th class="py-3 px-6 text-center">Chapters</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-600 text-sm">
|
||||
{% for manga in mangas %}
|
||||
<tr class="border-b border-gray-200 hover:bg-gray-100">
|
||||
<td class="py-3 px-6 text-left whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<span class="font-medium">{{ manga.title }}</span>
|
||||
</div>
|
||||
</td>
|
||||
{# <td class="py-3 px-6 text-center"> #}
|
||||
{# {{ manga.volumes|length }} #}
|
||||
{# </td> #}
|
||||
<td class="py-3 px-6 text-center">
|
||||
{% set total_chapters = manga.chapters|length %}
|
||||
{% set available_chapters = manga.chapters|filter(chapter => chapter.cbzPath is not null)|length %}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-48 bg-gray-200 rounded-full h-2.5">
|
||||
<div class="bg-blue-600 h-2.5 rounded-full"
|
||||
style="width: {{ (available_chapters / total_chapters * 100)|round }}%"></div>
|
||||
</div>
|
||||
<span class="ml-2">{{ available_chapters }} / {{ total_chapters }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="py-3 px-6 text-center">Aucun manga trouvé.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'manga/_list.html.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,248 +5,6 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="relative">
|
||||
<div class="shadow-lg text-white">
|
||||
<div class="relative h-96 bg-cover bg-center" style="background-image: url('{{ manga.imageUrl }}')">
|
||||
<div class="absolute inset-0 bg-black opacity-50"></div>
|
||||
<div class="absolute inset-0 flex flex-row justify-center p-4">
|
||||
<div class="hidden mr-12 xl:block 2xl:block">
|
||||
<img src="{{ manga.imageUrl }}" alt="{{ manga.title }}" class="max-w-72 max-h-72 ml-4">
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-bookmark text-white text-3xl"></i>
|
||||
<h1 class="text-3xl font-bold ml-4">{{ manga.title }}</h1>
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<span class="mr-4">{{ manga.publicationYear }}</span>
|
||||
<span>Chapters: {{ manga.chapters.count }}</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-folder text-gray-400 mr-2"></i>
|
||||
<span class="truncate">/media/mangas/{{ manga.title }} ({{ manga.publicationYear }})</span>
|
||||
<span class="ml-auto bg-green-600 py-1 px-2 rounded">{{ manga.status ?? 'Terminé' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
{% set genre_count = 0 %}
|
||||
{% for genre in manga.genres %}
|
||||
{% if genre_count < 5 %}
|
||||
<span class="bg-gray-700 py-1 px-2 rounded-sm mr-2">{{ genre }}</span>
|
||||
{% set genre_count = genre_count + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if genre_count == 5 and manga.genres|length > 5 %}
|
||||
<span class="bg-gray-700 py-1 px-2 rounded-sm mr-2">...</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fas fa-heart text-red-500 mr-2"></i>
|
||||
<span>{{ manga.rating|round(2) }}</span>
|
||||
</div>
|
||||
<p>{{ manga.description|truncate(500) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
{% for volume, chapters in chapters_by_volume %}
|
||||
{% set is_first = loop.first %}
|
||||
{% set volume_cbz_path = chapters|first.cbzPath %}
|
||||
{% set all_chapters_same_cbz = chapters|reduce((carry, chapter) => carry and chapter.cbzPath == volume_cbz_path, true) %}
|
||||
{% set available_chapters = chapters|filter(chapter => chapter.cbzPath is not null) %}
|
||||
{% set total_chapters = chapters|length %}
|
||||
|
||||
<div data-controller="table" data-table-open-value="{{ is_first ? 'true' : 'false' }}">
|
||||
<div class="bg-white rounded-sm shadow mb-4">
|
||||
<div class="relative flex items-center justify-between bg-white p-4 rounded-t-sm">
|
||||
<!-- Partie gauche -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<i class="fas fa-bookmark text-gray-500 text-3xl w-8"></i>
|
||||
<h2 class="text-xl font-semibold w-28">Volume {{ '%02d'|format(volume) }}</h2>
|
||||
<div class="flex items-center w-16">
|
||||
<span class="px-2 py-1 text-sm rounded w-full text-center
|
||||
{% if available_chapters|length == 0 %}
|
||||
bg-red-500 text-white
|
||||
{% elseif available_chapters|length < total_chapters %}
|
||||
bg-yellow-500 text-white
|
||||
{% else %}
|
||||
bg-green-500 text-white
|
||||
{% endif %}">
|
||||
{{ available_chapters|length }} / {{ total_chapters }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chevron centré -->
|
||||
<div class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
<i data-table-target="toggleIcon" data-action="click->table#toggle"
|
||||
class="bg-gray-400 rounded-full font-bold text-sm text-white p-1 hover:bg-green-500 fas fa-chevron-{{ is_first ? 'up' : 'down' }} cursor-pointer"></i>
|
||||
</div>
|
||||
|
||||
<!-- Partie droite -->
|
||||
<div class="flex space-x-2 text-xl text-bold">
|
||||
<a href="#" class="w-8 text-center">
|
||||
<i class="fas fa-search text-gray-500 hover:text-green-500"></i>
|
||||
</a>
|
||||
<a href="#" class="w-8 text-center">
|
||||
<i class="fas fa-download text-gray-500 hover:text-green-500"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-table-target="body"
|
||||
class="p-4 border-t" {{ not is_first ? 'style="display: none;"' : '' }}>
|
||||
<table class="min-w-full table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left">#</th>
|
||||
<th class="px-4 py-2 text-left">Title</th>
|
||||
<th class="px-4 py-2 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if all_chapters_same_cbz and volume_cbz_path is not null %}
|
||||
<tr class="border-t hover:bg-green-100">
|
||||
<td class="px-4 py-2 text-green-500">
|
||||
<a href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}">
|
||||
{{ '%02d'|format(volume) }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 w-full text-left">
|
||||
<a href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}">
|
||||
Volume {{ '%02d'|format(volume) }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 flex justify-end gap-2">
|
||||
<a href="{{ path('download_cbz', {chapterId: chapters|first.id}) }}"
|
||||
class="text-gray-500 hover:text-green-500">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for chapter in chapters %}
|
||||
<tr class="border-t hover:bg-green-100">
|
||||
{% if chapter.cbzPath is not null %}
|
||||
<td class="px-4 py-2 text-green-500">
|
||||
<a href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}">
|
||||
{{ '%02d'|format(chapter.number) }}
|
||||
</a>
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="px-4 py-2">{{ '%02d'|format(chapter.number) }}</td>
|
||||
{% endif %}
|
||||
|
||||
<td class="px-4 py-2 w-full text-left">
|
||||
{% if chapter.cbzPath is not null %}
|
||||
<a href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}">
|
||||
{{ chapter.title ?? 'No title' }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ chapter.title ?? 'No title' }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 flex justify-end gap-2">
|
||||
{% if chapter.cbzPath is null %}
|
||||
<button
|
||||
data-controller="download-chapter"
|
||||
data-action="click->download-chapter#handleClick"
|
||||
data-url="{{ path('add_chapter', {id: chapter.id}) }}"
|
||||
>
|
||||
<span class="text-gray-500 hover:text-green-500">
|
||||
<i data-download-chapter-target="icon"
|
||||
class="fas fa-search"></i>
|
||||
</span>
|
||||
</button>
|
||||
{% else %}
|
||||
<button disabled>
|
||||
<span class="text-gray-500">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ path('download_cbz', {chapterId: chapter.id}) }}"
|
||||
class="text-gray-500 hover:text-green-500">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="relative flex items-center justify-between bg-white mt-4 mb-2 rounded-t-sm">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
<i data-table-target="toggleIcon" data-action="click->table#toggle"
|
||||
class="bg-gray-400 rounded-full font-bold text-sm text-white p-1 hover:bg-green-500 fas fa-chevron-up cursor-pointer"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{# Modal d'édition #}
|
||||
<twig:Modal
|
||||
openTrigger="openEditModal"
|
||||
closeTrigger="closeEditModal"
|
||||
title="Edit Manga"
|
||||
>
|
||||
{% block content %}
|
||||
<form id="editForm" method="post" action="">
|
||||
<div class="mb-4">
|
||||
<label for="title" class="block text-gray-700 text-sm font-bold mb-2">Title:</label>
|
||||
<input type="text" id="title" name="title" value="{{ manga.title }}"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="description" class="block text-gray-700 text-sm font-bold mb-2">Description:</label>
|
||||
<textarea id="description" name="description"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
rows="3">{{ manga.description }}</textarea>
|
||||
</div>
|
||||
{# Ajoutez d'autres champs selon vos besoins #}
|
||||
</form>
|
||||
{% include 'manga/_manga_details.html.twig' %}
|
||||
{% endblock %}
|
||||
{% block footer %}
|
||||
<button type="submit" form="editForm"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" data-action="modal#close"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
{% endblock %}
|
||||
</twig:Modal>
|
||||
|
||||
{# Modal de confirmation de suppression #}
|
||||
<twig:Modal
|
||||
openTrigger="openDeleteModal"
|
||||
closeTrigger="closeDeleteModal"
|
||||
title="Delete Manga"
|
||||
>
|
||||
<twig:block name="content">
|
||||
<p class="text-sm text-gray-500">
|
||||
Are you sure you want to delete this manga? This action cannot be undone.
|
||||
</p>
|
||||
</twig:block>
|
||||
<twig:block name="footer">
|
||||
<form id="deleteForm" method="post" action="">
|
||||
<button type="submit"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" data-action="modal#close"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</twig:block>
|
||||
</twig:Modal>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user