From 115e4336abf21fe0aa8b3489678fd5fb864ac480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Guillot?= Date: Thu, 27 Jun 2024 11:28:45 +0200 Subject: [PATCH] Added: - manga import - read from cbz - save cbz from scrapping - menu interactions --- assets/controllers/menu_controller.js | 12 - assets/controllers/table_controller.js | 33 ++- config/services.yaml | 8 + frankenphp/conf.d/app.ini | 4 + migrations/Version20240626162522.php | 32 +++ src/Controller/CalendarController.php | 18 ++ src/Controller/ImportController.php | 197 ++++++++++++++++ src/Controller/MangaController.php | 56 +++-- src/Controller/SettingsController.php | 50 ++++ src/Controller/SystemController.php | 50 ++++ src/Entity/Chapter.php | 15 ++ src/MessageHandler/DownloadChapterHandler.php | 2 +- src/Repository/MangaRepository.php | 43 ++++ src/Service/CbzService.php | 136 +++++++++++ src/Service/MangaImportService.php | 110 +++++++++ src/Service/MangaScraperService.php | 222 +++++++++--------- templates/activity/index.html.twig | 78 +++--- templates/base.html.twig | 9 +- templates/calendar/index.html.twig | 20 ++ templates/import/confirm.html.twig | 13 + templates/import/index.html.twig | 43 ++++ templates/import/match.html.twig | 81 +++++++ templates/manga/index.html.twig | 20 +- templates/manga/manga_reader.html.twig | 47 ++-- templates/manga/show_chapters.html.twig | 118 ++++++---- templates/menu/menu.html.twig | 84 ++++--- templates/settings/index.html.twig | 20 ++ templates/system/index.html.twig | 20 ++ 28 files changed, 1239 insertions(+), 302 deletions(-) delete mode 100644 assets/controllers/menu_controller.js create mode 100644 migrations/Version20240626162522.php create mode 100644 src/Controller/CalendarController.php create mode 100644 src/Controller/ImportController.php create mode 100644 src/Controller/SettingsController.php create mode 100644 src/Controller/SystemController.php create mode 100644 src/Service/CbzService.php create mode 100644 src/Service/MangaImportService.php create mode 100644 templates/calendar/index.html.twig create mode 100644 templates/import/confirm.html.twig create mode 100644 templates/import/index.html.twig create mode 100644 templates/import/match.html.twig create mode 100644 templates/settings/index.html.twig create mode 100644 templates/system/index.html.twig diff --git a/assets/controllers/menu_controller.js b/assets/controllers/menu_controller.js deleted file mode 100644 index 638efe9..0000000 --- a/assets/controllers/menu_controller.js +++ /dev/null @@ -1,12 +0,0 @@ -// assets/controllers/menu_controller.js -import { Controller } from '@hotwired/stimulus'; - -export default class extends Controller { - static targets = ['menu']; - - toggleMenu(event) { - this.menuTargets.forEach(menu => { - menu.classList.toggle('hidden', menu !== event.currentTarget); - }); - } -} diff --git a/assets/controllers/table_controller.js b/assets/controllers/table_controller.js index ae125bc..534bdd5 100644 --- a/assets/controllers/table_controller.js +++ b/assets/controllers/table_controller.js @@ -6,19 +6,30 @@ import {Controller} from '@hotwired/stimulus'; */ /* stimulusFetch: 'lazy' */ export default class extends Controller { - static targets = ['body'] + static targets = ["body", "toggleIcon"] + static values = { open: Boolean } - // ... - collapse(event) { - if (this.bodyTarget.style.display === "none") { - this.bodyTarget.style.display = "block"; - event.currentTarget.classList.remove('fa-chevron-up'); - event.currentTarget.classList.add('fa-chevron-down'); - } else { - this.bodyTarget.style.display = "none"; - event.currentTarget.classList.remove('fa-chevron-down'); - event.currentTarget.classList.add('fa-chevron-up'); + 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") } } diff --git a/config/services.yaml b/config/services.yaml index cf6e775..68e7b42 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -52,6 +52,14 @@ services: App\Service\MangaExportService: arguments: $projectDir: '%kernel.project_dir%' + + App\Service\MangaImportService: + arguments: + $projectDir: '%kernel.project_dir%' + + App\Controller\ImportController: + arguments: + $projectDir: '%kernel.project_dir%' App\EventListener\MangaScrapedListener: tags: diff --git a/frankenphp/conf.d/app.ini b/frankenphp/conf.d/app.ini index 044c070..8fc8459 100644 --- a/frankenphp/conf.d/app.ini +++ b/frankenphp/conf.d/app.ini @@ -1,3 +1,7 @@ +upload_max_filesize = 512M +post_max_size = 512M +memory_limit = 512M + expose_php = 0 date.timezone = UTC apc.enable_cli = 1 diff --git a/migrations/Version20240626162522.php b/migrations/Version20240626162522.php new file mode 100644 index 0000000..2c99eb0 --- /dev/null +++ b/migrations/Version20240626162522.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE chapter ADD cbz_path 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 chapter DROP cbz_path'); + } +} diff --git a/src/Controller/CalendarController.php b/src/Controller/CalendarController.php new file mode 100644 index 0000000..2e2ca99 --- /dev/null +++ b/src/Controller/CalendarController.php @@ -0,0 +1,18 @@ +render('calendar/index.html.twig', [ + 'controller_name' => 'CalendarController', + ]); + } +} diff --git a/src/Controller/ImportController.php b/src/Controller/ImportController.php new file mode 100644 index 0000000..6996d4d --- /dev/null +++ b/src/Controller/ImportController.php @@ -0,0 +1,197 @@ +isMethod('post')) { + $file = $request->files->get('file'); + if ($file && $file->getClientOriginalExtension() === 'cbz') { + $originalFileName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); + $filename = uniqid() . '.' . $file->getClientOriginalExtension(); + + try { + $file->move($this->projectDir . '/' . self::UPLOADS_DIRECTORY, $filename); + $session->set('import_file_path', $this->projectDir . '/' .self::UPLOADS_DIRECTORY . '/' . $filename); + $session->set('import_original_file_name', $originalFileName); + return $this->redirectToRoute('import_match'); + } catch (FileException $e) { + $this->notificationService->sendUpdate([ + 'type' => 'error', + 'message' => 'Une erreur est survenue lors de l\'import du fichier.' + ]); + } + } else { + $this->notificationService->sendUpdate([ + 'type' => 'error', + 'message' => 'Le fichier doit être au format CBZ.' + ]); + } + } + + return $this->render('import/index.html.twig'); + } + + /** + * @throws Exception + */ + #[Route('/import/match', name: 'import_match')] + public function match(Request $request, SessionInterface $session): Response + { + $filePath = $session->get('import_file_path'); + $originalFileName = $session->get('import_original_file_name'); + if (!$filePath || !$originalFileName) { + return $this->redirectToRoute('app_import'); + } + + $metadata = $this->cbzService->extractMetadata($filePath, $originalFileName); + if($metadata['title'] === '' || is_null($metadata['title'])){ + $this->notificationService->sendUpdate([ + 'type' => 'error', + 'message' => 'Impossible de détecter le titre du manga.' + ]); + return $this->redirectToRoute('app_import'); + } + + $mangas = $this->mangaRepository->findBySlug($metadata['title']); + + $mangasChapters = []; + foreach ($mangas as $manga) { + if(!is_null($metadata['chapter'])){ + $chapters = $this->chapterRepository->findBy([ + 'manga' => $manga, + 'number' => $metadata['chapter'] + ]); + $chapters = [$chapters[0]->getVolume() => $chapters]; + }else{ + $chapters = $this->chapterRepository->findBy([ + 'manga' => $manga, + 'volume' => (int) $metadata['volume'] + ]); + + $chapters = [$metadata['volume'] => $chapters]; + } + $mangasChapters[$manga->getSlug()] = $chapters; + } + + if(empty($mangas)) { + $this->notificationService->sendUpdate([ + 'type' => 'error', + 'message' => 'Aucun manga trouvé avec ce titre.' + ]); + return $this->redirectToRoute('app_manga_new', ['query' => $metadata['title']]); + } + + if ($request->isMethod('post')) { + $session->set('import_metadata', $request->request->all()); + return $this->redirectToRoute('import_confirm'); + } + + return $this->render('import/match.html.twig', [ + 'mangas' => $mangas, + 'volume' => $metadata['volume'], + 'chapters' => $mangasChapters + ]); + } + + #[Route('/import/confirm', name: 'import_confirm')] + public function confirm(Request $request, SessionInterface $session): Response + { + if (!$request->isMethod('POST')) { + return $this->redirectToRoute('app_import'); + } + + $action = $request->request->get('action'); + $mangaSlug = $request->request->get('manga_slug'); + $volume = $request->request->get('volume'); + + if ($action === 'confirm') { + // Logique de confirmation + $manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]); + if (!$manga) { + $this->notificationService->sendUpdate([ + 'type' => 'error', + 'message' => 'Manga non trouvé.' + ]); + return $this->redirectToRoute('app_import'); + } + + $filePath = $session->get('import_file_path'); + if (!$filePath) { + $this->notificationService->sendUpdate([ + 'type' => 'error', + 'message' => 'Fichier d\'import non trouvé.' + ]); + return $this->redirectToRoute('app_import'); + } + $originalFileName = $session->get('import_original_file_name'); + + // Ici, vous pouvez ajouter la logique pour importer effectivement le fichier + // Par exemple : + // $this->mangaImportService->importVolume($manga, $volume, $filePath); + + try { + $this->mangaImportService->importVolume($manga, (int)$volume, $filePath, $originalFileName); + } catch (\Exception $e) { + $this->notificationService->sendUpdate([ + 'type' => 'error', + 'message' => 'Erreur lors de l\'import : ' . $e->getMessage() + ]); + } + + $this->notificationService->sendUpdate([ + 'type' => 'success', + 'message' => 'Import confirmé avec succès.' + ]); + + return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $mangaSlug]); + } elseif ($action === 'refuse') { + // Logique de refus + $filePath = $session->get('import_file_path'); + if ($filePath && file_exists($filePath)) { + unlink($filePath); // Supprime le fichier temporaire + } + $session->remove('import_file_path'); + $session->remove('import_original_file_name'); + + $this->notificationService->sendUpdate([ + 'type' => 'info', + 'message' => 'Import refusé. Le fichier a été supprimé.' + ]); + } + + return $this->redirectToRoute('app_import'); + } +} diff --git a/src/Controller/MangaController.php b/src/Controller/MangaController.php index e5c6d05..94b4cbd 100644 --- a/src/Controller/MangaController.php +++ b/src/Controller/MangaController.php @@ -6,6 +6,7 @@ use App\Entity\Manga; use App\Message\DownloadChapter; use App\Repository\ChapterRepository; use App\Repository\MangaRepository; +use App\Service\CbzService; use App\Service\MangaExportService; use App\Service\LelScansProviderService; use App\Service\MangaScraperServiceOld; @@ -19,19 +20,20 @@ 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\Annotation\Route; +use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\String\Slugger\AsciiSlugger; class MangaController extends AbstractController { public function __construct( - private readonly MangaScraperServiceOld $mangaScraperService, - private readonly MangaExportService $mangaExportService, - private readonly LelScansProviderService $mangaProviderService, - private readonly MangaRepository $mangaRepository, - private ChapterRepository $chapterRepository, - private MangaUpdatesMetadataProvider $mangaUpdatesDbProvider, - private MessageBusInterface $bus + private readonly MangaScraperServiceOld $mangaScraperService, + private readonly MangaExportService $mangaExportService, + private readonly LelScansProviderService $mangaProviderService, + private readonly MangaRepository $mangaRepository, + private readonly ChapterRepository $chapterRepository, + private readonly MangaUpdatesMetadataProvider $mangaUpdatesDbProvider, + private readonly MessageBusInterface $bus, + private readonly CbzService $cbzService ) { } @@ -39,6 +41,7 @@ class MangaController extends AbstractController #[Route('/manga', name: 'app_manga')] public function index(): Response { +// phpinfo(); $mangas = $this->mangaRepository->findAll(); return $this->render('manga/index.html.twig', [ 'controller_name' => 'MangaController', @@ -49,7 +52,7 @@ class MangaController extends AbstractController /** * @throws NonUniqueResultException */ - #[Route('/manga/{mangaSlug}', name: 'manga_show')] + #[Route('/manga/chapters/{mangaSlug}', name: 'app_manga_show')] public function showChapters(string $mangaSlug): Response { // $manga = $this->mangaRepository->findOneWithChapterBy(['slug' => $mangaSlug]); @@ -88,8 +91,8 @@ class MangaController extends AbstractController ]); } - #[Route('/manga/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'read_chapter_page')] - public function readChapterPage(string $mangaSlug, float $chapterNumber, int $pageNumber = 0): Response + #[Route('/manga/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_manga_read')] + public function readChapterPage(string $mangaSlug, float $chapterNumber, int $pageNumber = 1): Response { $manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]); if (!$manga) { @@ -101,20 +104,37 @@ class MangaController extends AbstractController throw $this->createNotFoundException("Le chapitre demandé n'existe pas."); } - $currentPage = $chapter->getPageByNumber($pageNumber); - if (!$currentPage) { + if (is_null($chapter->getCbzPath())) { + $currentPage = $chapter->getPageByNumber($pageNumber); + if (!$currentPage) { + throw $this->createNotFoundException("La page demandée n'existe pas."); + } + + return $this->render('manga/manga_reader.html.twig', [ + 'manga' => $manga, + 'chapter' => $chapter, + 'pages' => $chapter->getPagesLink(), + 'currentPage' => $currentPage, + ]); + } + + $pageContent = $this->cbzService->getPageContent($chapter->getCbzPath(), $pageNumber); + if (!$pageContent) { throw $this->createNotFoundException("La page demandée n'existe pas."); } + $totalPages = $this->cbzService->getPageCount($chapter->getCbzPath()); + return $this->render('manga/manga_reader.html.twig', [ 'manga' => $manga, 'chapter' => $chapter, - 'pages' => $chapter->getPagesLink(), - 'currentPage' => $currentPage, + 'currentPage' => $pageNumber, + 'totalPages' => $totalPages, + 'pageContent' => base64_encode($pageContent), ]); } - #[Route('/addNew/{query}', name: 'add_new_manga')] + #[Route('/manga/new/{query}', name: 'app_manga_new')] public function addNew(string $query = ''): Response { return $this->render('manga/add_new.html.twig', [ @@ -137,7 +157,7 @@ class MangaController extends AbstractController $chapter = $this->chapterRepository->find($id); if (!$chapter) { return new JsonResponse(['error' => 'Chapter Not Found.'], 400); - } elseif ($chapter->getLocalPath() !== null) { + } elseif ($chapter->getCbzPath() !== null) { return new JsonResponse(['error' => 'Chapter already scraped.'], 400); } @@ -198,7 +218,7 @@ class MangaController extends AbstractController $availableChapters = $this->mangaProviderService->getChapterList($mangaSlug); - return $this->redirectToRoute('manga_show', ['mangaSlug' => $mangaSlug, 'availableChapters' => $availableChapters]); + return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $mangaSlug, 'availableChapters' => $availableChapters]); } #[Route('/manga/exportFrom/{mangaSlug}/{chapterNumber}', name: 'manga_export')] diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php new file mode 100644 index 0000000..e8b058e --- /dev/null +++ b/src/Controller/SettingsController.php @@ -0,0 +1,50 @@ +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(): Response + { + return $this->render('settings/index.html.twig', [ + 'controller_name' => 'SettingsController', + ]); + } + + #[Route('/settings/scrappers', name: 'app_settings_scrappers')] + public function scrappers(): Response + { + return $this->render('settings/index.html.twig', [ + 'controller_name' => 'SettingsController', + ]); + } + + #[Route('/settings/ui', name: 'app_settings_ui')] + public function ui(): Response + { + return $this->render('settings/index.html.twig', [ + 'controller_name' => 'SettingsController', + ]); + } +} diff --git a/src/Controller/SystemController.php b/src/Controller/SystemController.php new file mode 100644 index 0000000..c2b4fa8 --- /dev/null +++ b/src/Controller/SystemController.php @@ -0,0 +1,50 @@ +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', + ]); + } +} diff --git a/src/Entity/Chapter.php b/src/Entity/Chapter.php index 1117bca..0045d18 100644 --- a/src/Entity/Chapter.php +++ b/src/Entity/Chapter.php @@ -40,6 +40,9 @@ class Chapter #[ORM\Column(length: 255, nullable: true)] private ?string $externalId = null; + #[ORM\Column(length: 255, nullable: true)] + private ?string $cbzPath = null; + public function __construct() { $this->pagesLink = new ArrayCollection(); @@ -177,4 +180,16 @@ class Chapter return $this; } + + public function getCbzPath(): ?string + { + return $this->cbzPath; + } + + public function setCbzPath(?string $cbzPath): static + { + $this->cbzPath = $cbzPath; + + return $this; + } } diff --git a/src/MessageHandler/DownloadChapterHandler.php b/src/MessageHandler/DownloadChapterHandler.php index 895dee6..69995f9 100644 --- a/src/MessageHandler/DownloadChapterHandler.php +++ b/src/MessageHandler/DownloadChapterHandler.php @@ -35,7 +35,7 @@ readonly class DownloadChapterHandler if (!$chapter) { $this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapter not found.']); throw new BadRequestHttpException('Chapter not found'); - } elseif ($chapter->getLocalPath() !== null) { + } elseif ($chapter->getCbzPath() !== null) { $this->notificationService->sendUpdate(['status' => 'error', 'message' => 'Chapter already scraped.']); throw new BadRequestHttpException('Chapter already downloaded'); } diff --git a/src/Repository/MangaRepository.php b/src/Repository/MangaRepository.php index 5647bfd..2499f77 100644 --- a/src/Repository/MangaRepository.php +++ b/src/Repository/MangaRepository.php @@ -49,6 +49,49 @@ class MangaRepository extends ServiceEntityRepository ->getResult(); } + public function findBySlug(string $slug): array + { + $this->getEntityManager()->getConnection()->executeStatement('CREATE EXTENSION IF NOT EXISTS fuzzystrmatch'); + + $conn = $this->getEntityManager()->getConnection(); + + $sql = ' + SELECT m.*, levenshtein(LOWER(m.slug), LOWER(:slug)) as distance + FROM manga m + WHERE levenshtein(LOWER(m.slug), LOWER(:slug)) <= :max_distance + ORDER BY distance + LIMIT 10 + '; + + $stmt = $conn->prepare($sql); + $resultSet = $stmt->executeQuery([ + 'slug' => $slug, + 'max_distance' => strlen($slug) / 3 + ]); + + $results = $resultSet->fetchAllAssociative(); + + $ids = array_column($results, 'id'); + $entities = $this->findBy(['id' => $ids]); + + $sortedEntities = []; + foreach ($results as $result) { + foreach ($entities as $entity) { + if ($entity->getId() == $result['id']) { + $sortedEntities[] = $entity; + break; + } + } + } + + return $sortedEntities; + } + + private function normalizeSlug(string $slug): string + { + return strtolower(preg_replace('/[^a-z0-9]+/i', '', $slug)); + } + /** * @throws NonUniqueResultException */ diff --git a/src/Service/CbzService.php b/src/Service/CbzService.php new file mode 100644 index 0000000..29eb9f6 --- /dev/null +++ b/src/Service/CbzService.php @@ -0,0 +1,136 @@ +extractInfoFromFileName($originalFileName); + + $metadata['title'] = $fileInfo['title']; + $metadata['volume'] = $fileInfo['volume'] !== null ? (int)$fileInfo['volume'] : null; + $metadata['chapter'] = $fileInfo['chapter'] !== null ? (int)$fileInfo['chapter'] : null; + + if (is_null($metadata['chapter'])) { + try { + $zip->open($filePath); + $chapterNumbers = []; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $fileName = $stat['name']; + + $chapterNumbers[] = $this->extractChapter($fileName); + } + + $chapterNumbers = array_unique($chapterNumbers); + + if (count($chapterNumbers) === 1) { + $metadata['chapter'] = array_values($chapterNumbers)[0] === '' ? null : (int)array_values($chapterNumbers)[0]; + } elseif (count($chapterNumbers) > 1) { + $metadata['chapter'] = min($chapterNumbers); + } + + $zip->close(); + } catch (Exception $e) { + throw new Exception("Impossible d'ouvrir le fichier CBZ. " . $e->getMessage()); + } + } + + return $metadata; + } + + public function getPageContent(string $cbzPath, int $pageNumber): ?string + { + $zip = new ZipArchive(); + if ($zip->open($cbzPath) === TRUE) { + $images = $this->getImageList($zip); + if (isset($images[$pageNumber - 1])) { + $content = $zip->getFromName($images[$pageNumber - 1]); + $zip->close(); + return $content; + } + $zip->close(); + } + return null; + } + + public function getPageCount(string $cbzPath): int + { + $zip = new ZipArchive(); + if ($zip->open($cbzPath) === TRUE) { + $count = count($this->getImageList($zip)); + $zip->close(); + return $count; + } + return 0; + } + + private function extractInfoFromFileName(string $fileName): array + { + $title = $this->extractTitle($fileName); + $volume = $this->extractVolume($fileName); + $chapter = $this->extractChapter($fileName); + + return [ + 'title' => $title === '' ? null : $title, + 'volume' => $volume === '' ? null : $volume, + 'chapter' => $chapter === '' ? null : $chapter, + ]; + } + + private function extractTitle(string $fileName): string + { + $titlePattern = '/^(?P.+?)(?:\s*-\s*|\s+)?(?:(?:[Tt]ome|[Vv]ol\.?|[Tt]|[Cc]hap(?:itre|ter)?)\s*\d+)/'; + if (preg_match($titlePattern, $fileName, $matches)) { + return $this->slugger->slug(trim($matches['title']), '-')->lower()->toString(); + } + return ''; + } + + private function extractVolume(string $fileName): string + { + $volumePattern = '/(?:[Tt]ome|[Vv]ol\.?|[Tt])\s*(?P<volume>\d+)/'; + if (preg_match($volumePattern, $fileName, $matches)) { + return str_pad($matches['volume'], 2, '0', STR_PAD_LEFT); + } + return ''; + } + + private function extractChapter(string $fileName): string + { + $chapterPattern = '/[Cc]hap(?:itre|ter)?\s*(?P<chapter>\d+)/'; + if (preg_match($chapterPattern, $fileName, $matches)) { + return $matches['chapter']; + } + return ''; + } + + private function getImageList(ZipArchive $zip): array + { + $images = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $filename = $zip->getNameIndex($i); + if (preg_match('/\.(jpg|jpeg|png|gif)$/i', $filename)) { + $images[] = $filename; + } + } + sort($images); + return $images; + } +} diff --git a/src/Service/MangaImportService.php b/src/Service/MangaImportService.php new file mode 100644 index 0000000..bcf63b9 --- /dev/null +++ b/src/Service/MangaImportService.php @@ -0,0 +1,110 @@ +<?php + +namespace App\Service; + +use App\Entity\Manga; +use App\Repository\ChapterRepository; +use Doctrine\ORM\EntityManagerInterface; +use Exception; +use JetBrains\PhpStorm\NoReturn; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\String\Slugger\SluggerInterface; + +class MangaImportService +{ + private const string CBZ_DIRECTORY = 'public/cbz'; + + public function __construct( + private readonly string $projectDir, + private readonly EntityManagerInterface $entityManager, + private readonly ChapterRepository $chapterRepository, + private readonly CbzService $cbzService, + private readonly Filesystem $filesystem, + private readonly SluggerInterface $slugger + ) + { + } + + /** + * @throws Exception + */ + #[NoReturn] public function importVolume(Manga $manga, int $volume, string $tempFilePath, string $originalFileName): void + { + // Extraire les métadonnées du fichier CBZ + $metadata = $this->cbzService->extractMetadata($tempFilePath, $originalFileName); + + // Créer le nom de fichier et le chemin pour le stockage permanent + $permanentFileName = $this->createPermanentFileName($manga, $volume, $metadata); + $mangaDirectory = $this->createMangaDirectory($manga); + $permanentFilePath = $this->projectDir . '/' . $mangaDirectory .'/volume_' . sprintf('%02d', $volume) . '/' . $permanentFileName; + + // Vérifier si le fichier existe déjà + if ($this->filesystem->exists($permanentFilePath)) { + throw new \RuntimeException("Un fichier pour ce volume/chapitre existe déjà."); + } + + // Déplacer le fichier vers l'emplacement permanent + $this->filesystem->mkdir(dirname($permanentFilePath), 0755); + $this->filesystem->rename($tempFilePath, $permanentFilePath, true); + + // Mettre à jour ou créer les entités Chapter + if (isset($metadata['chapter'])) { + // Si c'est un chapitre spécifique + $this->updateChapter($manga, $volume, $metadata['chapter'], $permanentFilePath); + } else { + // Si c'est un volume entier, mettre à jour tous les chapitres du volume + $this->updateVolumeChapters($manga, $volume, $permanentFilePath); + } + + $this->entityManager->flush(); + } + + private function createPermanentFileName(Manga $manga, int $volume, array $metadata): string + { + $baseFileName = $this->slugger->slug($manga->getTitle()) . '_vol' . sprintf('%02d', $volume); + if (isset($metadata['chapter'])) { + $baseFileName .= '_ch' . $metadata['chapter']; + } + return $baseFileName . '.cbz'; + } + + private function createMangaDirectory(Manga $manga): string + { + $mangaYear = $manga->getPublicationYear() ?? 'unknown'; + $directoryPath = self::CBZ_DIRECTORY . '/' . ucfirst($manga->getSlug()) . ' (' . $mangaYear . ')'; + + $this->filesystem->mkdir($directoryPath, 0755); + return $directoryPath; + } + + private function updateChapter(Manga $manga, int $volume, float $chapterNumber, string $cbzPath): void + { + $chapter = $this->chapterRepository->findOneBy([ + 'manga' => $manga, + 'volume' => $volume, + 'number' => $chapterNumber + ]); + + if (!$chapter) { + throw new \RuntimeException("Le chapitre $chapterNumber du volume $volume n'existe pas en base de données."); + } + + $chapter->setCbzPath($cbzPath); + } + + private function updateVolumeChapters(Manga $manga, int $volume, string $cbzPath): void + { + $chapters = $this->chapterRepository->findBy([ + 'manga' => $manga, + 'volume' => $volume + ]); + + if (empty($chapters)) { + throw new \RuntimeException("Aucun chapitre trouvé pour le volume $volume en base de données."); + } + + foreach ($chapters as $chapter) { + $chapter->setCbzPath($cbzPath); + } + } +} diff --git a/src/Service/MangaScraperService.php b/src/Service/MangaScraperService.php index e33fc18..4f77858 100644 --- a/src/Service/MangaScraperService.php +++ b/src/Service/MangaScraperService.php @@ -7,6 +7,7 @@ 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; use GuzzleHttp\Exception\GuzzleException; @@ -23,15 +24,14 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class MangaScraperService { - const string IMG_BASE_DIR = '/public/manga-images'; - private string $projectDir; - private EventDispatcherInterface $eventDispatcher; - private string $scrapingType = ''; + const string PUBLIC_CBZ = '/public/cbz'; - public function __construct($projectDir, EventDispatcherInterface $eventDispatcher) + public function __construct( + private readonly string $projectDir, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly EntityManagerInterface $entityManager + ) { - $this->projectDir = $projectDir; - $this->eventDispatcher = $eventDispatcher; } private function extractMangaPageData(string $html, ContentSource $mangaSource): array @@ -94,71 +94,54 @@ class MangaScraperService }; } -// private function scrapeChapterHtml(Manga $manga, Chapter $chapter, MangaSource $mangaSource): array|bool -// { -// $chapterUrl = $mangaSource->getChapterUrl($manga->getTitle(), $chapter->getChapterNumber()); -// $html = $this->fetchHtml($chapterUrl); -// $imgUrls = $this->extractMangaPageData($html); -// -// return $this->saveChapterImages($manga, $chapter, $imgUrls); -// } - /** * @throws GuzzleException * @throws Exception */ - private function scrapeChapterMangadex(Chapter $chapter, ContentSource $mangaSource): array|bool + private function scrapeChapterMangadex(Chapter $chapter, ContentSource $mangaSource): bool { - $this->scrapingType = 'mangadex'; $client = new Client(); $chapterUrl = $mangaSource->getBaseUrl() . sprintf($mangaSource->getChapterUrlFormat(), $chapter->getExternalId()); - $mangaTitle = $chapter->getManga()->getTitle(); - $chapterNumber = $chapter->getNumber(); + $manga = $chapter->getManga(); $pageData = []; $response = $client->get($chapterUrl); $results = json_decode($response->getBody()->getContents(), true); - $mangaDir = sprintf('%s/%s', $this->projectDir . self::IMG_BASE_DIR, $mangaTitle); - if (!is_dir($mangaDir)) { - mkdir($mangaDir, 0755, true); - } - $chapterDir = sprintf('%s/%s', $mangaDir, $chapterNumber); - if (!is_dir($chapterDir)) { - mkdir($chapterDir, 0755, true); + if ($results['result'] !== 'ok' || count($results['chapter']['dataSaver']) === 0) { + throw new Exception('Error while fetching chapter data from Mangadex ' . $manga->getTitle() . ' ' . $chapter->getNumber()); } - if(count($results['chapter']['dataSaver']) === 0){ - throw new Exception('Error while fetching chapter data from Mangadex ' . $chapter->getManga()->getTitle() . ' ' . $chapter->getNumber()); + $tempDir = sys_get_temp_dir() . '/' . uniqid('manga_scraper_'); + mkdir($tempDir); + + foreach ($results['chapter']['dataSaver'] as $index => $page) { + $pageUrl = $results['baseUrl'] . '/data-saver/' . $results['chapter']['hash'] . '/' . $page; + $imagePath = $tempDir . '/' . sprintf('%03d.%s', $index + 1, pathinfo($page, PATHINFO_EXTENSION)); + + $this->downloadAndSaveImage($pageUrl, $imagePath); + + $event = new PageScrappingProgressEvent($chapter->getId(), $index + 1, count($results['chapter']['dataSaver'])); + $this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME); + + $pageData[] = [ + 'image_url' => $pageUrl, + 'local_image_url' => $imagePath, + 'page_number' => $index + 1, + ]; } - if ($results['result'] === 'ok') { - foreach ($results['chapter']['dataSaver'] as $page) { - $pageUrl = $results['baseUrl'] . '/data-saver/' . $results['chapter']['hash'] . '/' . $page; - // Déterminer l'extension de l'image - $imageExtension = pathinfo(parse_url($pageUrl, PHP_URL_PATH), PATHINFO_EXTENSION); + $cbzFilePath = $this->generateCbzPath($manga, $chapter); + $this->createCbzFile($tempDir, $pageData, $cbzFilePath); - // Construire le nom de fichier de l'image - $imageName = sprintf('%03d.%s', count($pageData) + 1, $imageExtension); - $imagePath = sprintf('%s/%s', $chapterDir, $imageName); + $chapter->setCbzPath($cbzFilePath); + $this->entityManager->persist($chapter); + $this->entityManager->flush(); - $this->downloadAndSaveImage($pageUrl, $imagePath); + // Nettoyage du répertoire temporaire + $this->cleanupTempFiles($tempDir); - $event = new PageScrappingProgressEvent($chapter->getId(), count($pageData) + 1, count($results['chapter']['dataSaver'])); - $this->eventDispatcher->dispatch($event, PageScrappingProgressEvent::NAME); - - $pageData[] = [ - 'image_url' => $pageUrl, - 'local_image_url' => sprintf('/manga-images/%s/%s/%s', $mangaTitle, $chapterNumber, $imageName), - 'page_number' => count($pageData) + 1, - ]; - } - } - - $event = new MangaScrapedEvent($mangaTitle, $chapterNumber, $pageData, $chapterDir); - $this->eventDispatcher->dispatch($event, MangaScrapedEvent::NAME); - - return $pageData; + return true; } private function scrapeChapterJavaScript(Manga $manga, Chapter $chapter, ContentSource $mangaSource): array|bool @@ -166,7 +149,7 @@ class MangaScraperService $chapterUrl = $mangaSource->getChapterUrl($manga->getTitle(), $chapter->getNumber()); $imgUrls = $this->fetchImagesUsingPuppeteer($chapterUrl, $mangaSource->getImageSelector(), $mangaSource->getNextPageSelector()); - return $this->saveChapterImages($manga, $chapter, $imgUrls); + return false; } private function fetchImagesUsingPuppeteer(string $url, string $imageSelector, string $nextButtonSelector): array @@ -188,34 +171,20 @@ class MangaScraperService */ private function scrapeChapterHtml(Manga $manga, Chapter $chapter, ContentSource $mangaSource): array|bool { - $this->scrapingType = 'html'; $chapterUrl = $mangaSource->getChapterUrl($manga->getSlug(), $chapter->getNumber()); $pageData = []; $currentPageUrl = $chapterUrl; - $mangaTitle = $manga->getTitle(); - $chapterNumber = $chapter->getNumber(); - $mangaDir = sprintf('%s/%s', $this->projectDir . self::IMG_BASE_DIR, $mangaTitle); - if (!is_dir($mangaDir)) { - mkdir($mangaDir, 0755, true); - } - - $chapterDir = sprintf('%s/%s', $mangaDir, $chapterNumber); - if (!is_dir($chapterDir)) { - mkdir($chapterDir, 0755, true); - } + $tempDir = sys_get_temp_dir() . '/' . uniqid('manga_scraper_'); + mkdir($tempDir); do { $html = $this->fetchHtml($currentPageUrl); $page = $this->extractMangaPageData($html, $mangaSource); - // Déterminer l'extension de l'image - $imageExtension = pathinfo(parse_url($page['image_url'], PHP_URL_PATH), PATHINFO_EXTENSION); - - // Construire le nom de fichier de l'image - $imageName = sprintf('%03d.%s', count($pageData) + 1, $imageExtension); - $imagePath = sprintf('%s/%s', $chapterDir, $imageName); + $imageName = sprintf('%03d.%s', count($pageData) + 1, pathinfo(parse_url($page['image_url'], PHP_URL_PATH), PATHINFO_EXTENSION)); + $imagePath = $tempDir . '/' . $imageName; $this->downloadAndSaveImage($page['image_url'], $imagePath); @@ -224,17 +193,24 @@ class MangaScraperService $pageData[] = [ 'image_url' => $page['image_url'], - 'local_image_url' => sprintf('/manga-images/%s/%s/%s', $mangaTitle, $chapterNumber, $imageName), + 'local_image_url' => $imagePath, 'page_number' => count($pageData) + 1, ]; $currentPageUrl = $page['next_page_url']; } while ($currentPageUrl); - $event = new MangaScrapedEvent($mangaTitle, $chapterNumber, $pageData, $chapterDir); - $this->eventDispatcher->dispatch($event, MangaScrapedEvent::NAME); + $cbzFilePath = $this->generateCbzPath($manga, $chapter); + $this->createCbzFile($tempDir, $pageData, $cbzFilePath); - return $pageData; + $chapter->setCbzPath($cbzFilePath); + $this->entityManager->persist($chapter); + $this->entityManager->flush(); + + // Nettoyage du répertoire temporaire + $this->cleanupTempFiles($tempDir); + + return true; } /** @@ -283,13 +259,13 @@ class MangaScraperService if (str_starts_with($contentType, 'image/')) { file_put_contents($destinationPath, $response->getBody()->getContents()); - if ($this->scrapingType === 'mangadex') { - $this->sendReport($imageUrl, true, $isCached, (int)$contentLength, ($endTime - $startTime) * 1000); - } +// if ($this->scrapingType === 'mangadex') { +// $this->sendReport($imageUrl, true, $isCached, (int)$contentLength, ($endTime - $startTime) * 1000); +// } } else { - if ($this->scrapingType === 'mangadex') { - $this->sendReport($imageUrl, false, $isCached, (int)$contentLength, ($endTime - $startTime) * 1000); - } +// if ($this->scrapingType === 'mangadex') { +// $this->sendReport($imageUrl, false, $isCached, (int)$contentLength, ($endTime - $startTime) * 1000); +// } throw new \Exception('Le contenu récupéré n\'est pas une image. Type de contenu : ' . $contentType); } } catch @@ -298,41 +274,6 @@ class MangaScraperService } } - private function saveChapterImages(Manga $manga, Chapter $chapter, array $imgUrls): array - { - $mangaTitle = $manga->getTitle(); - $chapterNumber = $chapter->getNumber(); - - $mangaDir = sprintf('%s/%s', $this->projectDir . self::IMG_BASE_DIR, $mangaTitle); - if (!is_dir($mangaDir)) { - mkdir($mangaDir, 0755, true); - } - - $chapterDir = sprintf('%s/%s', $mangaDir, $chapterNumber); - if (!is_dir($chapterDir)) { - mkdir($chapterDir, 0755, true); - } - - $pageData = []; - foreach ($imgUrls as $index => $imgUrl) { - $imageName = sprintf('%03d.%s', $index + 1, pathinfo(parse_url($imgUrl, PHP_URL_PATH), PATHINFO_EXTENSION)); - $imagePath = sprintf('%s/%s', $chapterDir, $imageName); - - $this->downloadAndSaveImage($imgUrl, $imagePath); - - $pageData[] = [ - 'image_url' => $imgUrl, - 'local_image_url' => sprintf('/manga-images/%s/%s/%s', $mangaTitle, $chapterNumber, $imageName), - 'page_number' => $index + 1, - ]; - } - - $event = new MangaScrapedEvent($mangaTitle, $chapterNumber, $pageData, $chapterDir); - $this->eventDispatcher->dispatch($event, MangaScrapedEvent::NAME); - - return $pageData; - } - /** * @throws GuzzleException */ @@ -379,4 +320,51 @@ class MangaScraperService throw new \Exception('Erreur lors de l\'envoi du rapport : ' . $e->getMessage()); } } + + private function createCbzFile(string $tempDir, array $pageData, string $cbzFilePath): void + { + $zip = new \ZipArchive(); + + if ($zip->open($cbzFilePath, \ZipArchive::CREATE) === TRUE) { + foreach ($pageData as $page) { + $zip->addFile($page['local_image_url'], basename($page['local_image_url'])); + } + $zip->close(); + } + } + + private function generateCbzPath(Manga $manga, Chapter $chapter): string + { + $volumeDir = $this->createDirectories($manga, $chapter->getVolume()); + $fileName = sprintf('%s_vol%d_ch%s.cbz', + $manga->getSlug(), + $chapter->getVolume(), + $chapter->getNumber() + ); + return $volumeDir . '/' . $fileName; + } + + private function createDirectories(Manga $manga, int $volume): string + { + $mangaYear = $manga->getPublicationYear() ?? 'unknown'; + $mangaDir = sprintf('%s/%s (%s)', $this->projectDir . self::PUBLIC_CBZ, ucfirst($manga->getSlug()), $mangaYear); + $volumeDir = sprintf('%s/volume_%d', $mangaDir, sprintf('%02d', $volume)); + + if (!is_dir($volumeDir)) { + mkdir($volumeDir, 0755, true); + } + + return $volumeDir; + } + + private function cleanupTempFiles(string $directory): void + { + $files = glob($directory . '/*'); + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + rmdir($directory); + } } diff --git a/templates/activity/index.html.twig b/templates/activity/index.html.twig index fc5217d..a7f9c23 100644 --- a/templates/activity/index.html.twig +++ b/templates/activity/index.html.twig @@ -16,40 +16,50 @@ </div> {% endblock %} {% block body %} -{# TODO styliser cette page #} - <table class="min-w-full bg-white"> - <thead class="bg-gray-800 text-white"> - <tr> - <th class="w-1/12 py-2 px-4"> - <input type="checkbox"> - </th> - <th class="w-2/12 py-2 px-4 text-left">Manga</th> - <th class="w-1/12 py-2 px-4 text-left">Volume</th> - <th class="w-4/12 py-2 px-4 text-left">Chapter</th> - <th class="w-1/12 py-2 px-4 text-left">Title</th> - <th class="w-1/12 py-2 px-4 text-left">Actions</th> + <div class="container mx-auto mt-2"> + <div class="bg-white overflow-hidden"> + <div class="overflow-x-auto"> + <table class="min-w-full bg-white"> + <thead> + <tr class="bg-gray-800 text-white"> + <th class="w-1/12 py-3 px-4 text-left"> + <input type="checkbox" class="form-checkbox h-5 w-5 text-green-600"> + </th> + <th class="w-2/12 py-3 px-4 text-left">Manga</th> + <th class="w-1/12 py-3 px-4 text-left">Volume</th> + <th class="w-3/12 py-3 px-4 text-left">Chapitre</th> + <th class="w-3/12 py-3 px-4 text-left">Titre</th> + <th class="w-2/12 py-3 px-4 text-left">Actions</th> + </tr> + </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"> + <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.title }}</td> + <td class="py-4 px-4"> + <button class="text-red-500 hover:text-red-700 transition duration-150 ease-in-out"> + <i class="fas fa-trash-alt"></i> + </button> + </td> + </tr> + {% else %} + <tr> + <td colspan="6" class="py-4 px-4 text-center text-gray-500">Aucune activité en cours.</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> - <th class="w-1/12 py-2 px-4"></th> - </tr> - </thead> - <tbody class="text-gray-700"> - {% for manga in status %} - <tr class="border-b"> - <td class="py-2 px-4 text-center"> - <input type="checkbox"> - </td> - <td class="py-2 px-4">{{ manga.manga }}</td> - <td class="py-2 px-4">{{ manga.volume }}</td> - <td class="py-2 px-4">{{ manga.chapter }}</td> - <td class="py-2 px-4">{{ manga.title }}</td> - <td class="py-2 px-4 text-center"> - <button class="text-red-500 hover:text-red-700">×</button> - </td> - </tr> - {% endfor %} - </tbody> - </table> - <div class="mt-4"> - <span>Total records: {{ status|length }}</span> +{# <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 %} diff --git a/templates/base.html.twig b/templates/base.html.twig index c4caf14..eebe243 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -12,7 +12,7 @@ {{ encore_entry_script_tags('app') }} {% endblock %} </head> -<body class="bg-gray-50 h-full overflow-hidden"> +<body class="bg-gray-50 h-full overflow-hidden" data-controller="menu"> <div data-controller="mercure" data-mercure-topic="notification"></div> <div data-controller="alert" class="fixed right-0 z-50 flex justify-center w-full"> @@ -25,6 +25,9 @@ <!-- Header --> <header class="bg-green-600 h-16 flex items-center fixed w-full z-30"> + <button data-action="click->menu#toggleMenu" class="text-white p-2 md:hidden"> + <i class="fas fa-bars"></i> + </button> <div class="flex justify-center ml-10"> <a class="flex flex-row justify-start" href="{{ path('app_manga') }}"> {# <div class="flex items-center"> #} @@ -55,12 +58,12 @@ <!-- Main content area --> <div class="flex h-full pt-16"> <!-- Sidebar --> - <nav class="w-60 bg-white h-full overflow-y-auto fixed left-0"> + <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 ml-60 w-full"> + <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 %} diff --git a/templates/calendar/index.html.twig b/templates/calendar/index.html.twig new file mode 100644 index 0000000..b5e2de2 --- /dev/null +++ b/templates/calendar/index.html.twig @@ -0,0 +1,20 @@ +{% extends 'base.html.twig' %} + +{% block title %}Hello CalendarController!{% endblock %} + +{% block body %} +<style> + .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; } + .example-wrapper code { background: #F5F5F5; padding: 2px 6px; } +</style> + +<div class="example-wrapper"> + <h1>Hello {{ controller_name }}! ✅</h1> + + This friendly message is coming from: + <ul> + <li>Your controller at <code><a href="{{ '/app/src/Controller/CalendarController.php'|file_link(0) }}">src/Controller/CalendarController.php</a></code></li> + <li>Your template at <code><a href="{{ '/app/templates/calendar/index.html.twig'|file_link(0) }}">templates/calendar/index.html.twig</a></code></li> + </ul> +</div> +{% endblock %} diff --git a/templates/import/confirm.html.twig b/templates/import/confirm.html.twig new file mode 100644 index 0000000..9c494e4 --- /dev/null +++ b/templates/import/confirm.html.twig @@ -0,0 +1,13 @@ +{% extends 'base.html.twig' %} + +{% block body %} + <div class="container mx-auto p-4"> + <h1 class="text-2xl font-bold mb-4">Confirmer l'Importation</h1> + <p><strong>Titre:</strong> {{ title }}</p> + <p><strong>Volume:</strong> {{ volume }}</p> + <form method="post"> + <button type="submit" class="mt-2 btn btn-primary">Importer</button> + </form> + </div> +{% endblock %} + diff --git a/templates/import/index.html.twig b/templates/import/index.html.twig new file mode 100644 index 0000000..a3166c7 --- /dev/null +++ b/templates/import/index.html.twig @@ -0,0 +1,43 @@ +{% extends 'base.html.twig' %} + +{% block body %} + <div class="container mx-auto p-4"> + <div class="bg-white shadow-lg rounded-sm overflow-hidden"> + <div class="bg-gray-800 text-white p-4"> + <h1 class="text-2xl font-bold"> + <i class="fas fa-file-import mr-2"></i>Importer un Manga + </h1> + </div> + <div class="p-6"> + <form method="post" enctype="multipart/form-data"> + <div class="mb-4"> + <label for="file-upload" class="block text-sm font-medium text-gray-700 mb-2"> + Choisir un fichier CBZ + </label> + <div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"> + <div class="space-y-1 text-center"> + <i class="fas fa-file-archive text-4xl text-gray-400 mb-3"></i> + <div class="flex text-sm text-gray-600"> + <label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"> + <span>Sélectionner un fichier</span> + <input id="file-upload" name="file" type="file" class="sr-only" accept=".cbz" required> + </label> + <p class="pl-1">ou glisser-déposer</p> + </div> + <p class="text-xs text-gray-500"> + CBZ jusqu'à 100MB + </p> + </div> + </div> + </div> + <div class="mt-6"> + <button type="submit" class="w-full flex items-center justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"> + <span class="mr-2">Suivant</span> + <i class="fas fa-arrow-right"></i> + </button> + </div> + </form> + </div> + </div> + </div> +{% endblock %} diff --git a/templates/import/match.html.twig b/templates/import/match.html.twig new file mode 100644 index 0000000..d5659ea --- /dev/null +++ b/templates/import/match.html.twig @@ -0,0 +1,81 @@ +{% extends 'base.html.twig' %} + +{% block body %} + <div class="container mx-auto p-4"> + <h1 class="text-2xl font-bold mb-6">Correspondances trouvées :</h1> + + {% if mangas %} + {% for manga in mangas %} + <div class="bg-white shadow-lg rounded-lg overflow-hidden mb-8"> + <div class="flex bg-gray-100 p-4"> + <div class="flex-none w-48"> + <img src="{{ manga.imageUrl ?? 'https://placehold.co/150x220' }}" alt="{{ manga.title }}" + class="w-full h-full object-cover rounded" style="width: 150px; height: 220px;"> + </div> + <div class="flex-grow ml-4"> + <h2 class="text-xl font-bold text-gray-900">{{ manga.title }} <span class="text-gray-500">({{ manga.publicationYear }})</span></h2> + <div class="mt-2"> + {% for genre in manga.genres %} + <span class="inline-block bg-gray-200 text-gray-800 text-xs font-semibold mr-2 mb-2 px-2.5 py-0.5 rounded"> + {{ genre }} + </span> + {% endfor %} + </div> + <p class="text-gray-700 text-sm mt-2">{{ manga.description|truncate(150) }}</p> + <div class="mt-2"> + <span class="text-gray-600 text-sm"> + <i class="fas fa-star text-yellow-500"></i> + {{ manga.rating }} + </span> + </div> + </div> + </div> + + {% if chapters[manga.slug] is iterable %} + {% for volume, volumeChapters in chapters[manga.slug] %} + {% set is_first = loop.first %} + <div data-controller="table"> + <div class="border-t border-gray-200"> + <div class="bg-gray-50 px-4 py-3 flex justify-between items-center cursor-pointer" data-action="click->table#toggle"> + <h3 class="text-lg font-semibold">Volume {{ '%02d'|format(volume) }}</h3> + <div class="flex items-center"> + <span class="text-gray-600 mr-2">{{ volumeChapters|length }} Chapitres</span> + <i data-table-target="toggleIcon" class="fas fa-chevron-down"></i> + </div> + </div> + <div data-table-target="body" class="" style="display: none;"> + <table class="min-w-full divide-y divide-gray-200"> + <tbody class="bg-white divide-y divide-gray-200"> + {% for chapter in volumeChapters %} + <tr> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ chapter.number }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ chapter.title ?? 'Sans titre' }}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> + </div> + {% endfor %} + {% endif %} + + <div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> + <form method="post" action="{{ path('import_confirm') }}" class="mt-4 sm:mt-0"> + <input type="hidden" name="manga_slug" value="{{ manga.slug }}"> + <input type="hidden" name="volume" value="{{ volume }}"> + <button type="submit" name="action" value="confirm" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm"> + Confirmer + </button> + <button type="submit" name="action" value="refuse" 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"> + Refuser + </button> + </form> + </div> + </div> + {% endfor %} + {% else %} + <p class="text-gray-700">Aucune correspondance trouvée.</p> + {% endif %} + </div> +{% endblock %} diff --git a/templates/manga/index.html.twig b/templates/manga/index.html.twig index 1f371e1..d6bc946 100644 --- a/templates/manga/index.html.twig +++ b/templates/manga/index.html.twig @@ -22,21 +22,19 @@ </div> {% endblock %} {% block body %} - <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"> + <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-gray-300 hover:shadow-2xl hover:border transition-shadow duration-300"> - <a href="{{ path('manga_show', { 'mangaSlug': manga.slug }) }}"> + <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="w-full"> + class="absolute top-0 left-0 w-full h-full object-cover"> </a> - <div class="p-4"> - <div class="flex justify-between text-xl"> - <span>{{ manga.title }}</span> - <span class="text-md text-gray-500 ml-2">({{ manga.publicationYear }})</span> + <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-gray-500">Added: {{ manga.createdAt|date('M d, Y') }}</p> + <p class="text-xs text-gray-400 mt-1">Added: {{ manga.createdAt|date('M d, Y') }}</p> </div> </div> {% else %} diff --git a/templates/manga/manga_reader.html.twig b/templates/manga/manga_reader.html.twig index d3cd252..90b17f4 100644 --- a/templates/manga/manga_reader.html.twig +++ b/templates/manga/manga_reader.html.twig @@ -6,37 +6,56 @@ <div class="w-full mx-auto p-4"> <h1 class="text-center text-3xl my-4">{{ manga.title }} - Chapitre {{ chapter.number }}</h1> + {% set isCbz = chapter.cbzPath is not null %} + {% set totalPages = isCbz ? totalPages : pages|length %} + {% set currentPageNumber = isCbz ? currentPage : currentPage.number %} + <div class="flex justify-center my-4"> - {% if currentPage.number > 1 %} - <a href="{{ path('read_chapter_page', { 'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPage.number - 1 }) }}" + {% if currentPageNumber > 1 %} + <a href="{{ path('app_manga_read', { 'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPageNumber - 1 }) }}" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 mr-4">« Précédent</a> {% endif %} - {% if currentPage.number < pages|length %} - <a href="{{ path('read_chapter_page', { 'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPage.number + 1 }) }}" + {% if currentPageNumber < totalPages %} + <a href="{{ path('app_manga_read', { 'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPageNumber + 1 }) }}" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Suivant »</a> {% endif %} </div> <div class="page-container flex justify-center"> - {% if currentPage.number < pages|length %} - <a href="{{ path('read_chapter_page', {'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPage.number + 1}) }}"> - <img src="{{ asset(currentPage.imageLocalUrl) }}" alt="Page {{ currentPage.number }}" - class="shadow-lg"> - </a> + {% if isCbz %} + {% if currentPageNumber < totalPages %} + <a href="{{ path('app_manga_read', {'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPageNumber + 1}) }}"> + <img src="data:image/jpeg;base64,{{ pageContent }}" alt="Page {{ currentPageNumber }}" + class="shadow-lg"> + </a> + {% else %} + <img src="data:image/jpeg;base64,{{ pageContent }}" alt="Page {{ currentPageNumber }}" class="shadow-lg"> + {% endif %} {% else %} - <img src="{{ asset(currentPage.imageLocalUrl) }}" alt="Page {{ currentPage.number }}" class="shadow-lg"> + {% if currentPageNumber < totalPages %} + <a href="{{ path('app_manga_read', {'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPageNumber + 1}) }}"> + <img src="{{ asset(currentPage.imageLocalUrl) }}" alt="Page {{ currentPageNumber }}" + class="shadow-lg"> + </a> + {% else %} + <img src="{{ asset(currentPage.imageLocalUrl) }}" alt="Page {{ currentPageNumber }}" class="shadow-lg"> + {% endif %} {% endif %} </div> <div class="flex justify-center my-4"> - {% if currentPage.number > 1 %} - <a href="{{ path('read_chapter_page', { 'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPage.number - 1 }) }}" + {% if currentPageNumber > 1 %} + <a href="{{ path('app_manga_read', { 'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPageNumber - 1 }) }}" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 mr-4">« Précédent</a> {% endif %} - {% if currentPage.number < pages|length %} - <a href="{{ path('read_chapter_page', { 'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPage.number + 1 }) }}" + {% if currentPageNumber < totalPages %} + <a href="{{ path('app_manga_read', { 'mangaSlug': manga.slug, 'chapterNumber': chapter.number, 'pageNumber': currentPageNumber + 1 }) }}" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Suivant »</a> {% endif %} </div> + + <div class="text-center mt-4"> + Page {{ currentPageNumber }} sur {{ totalPages }} + </div> </div> {% endblock %} diff --git a/templates/manga/show_chapters.html.twig b/templates/manga/show_chapters.html.twig index 8064e2c..5e962d3 100644 --- a/templates/manga/show_chapters.html.twig +++ b/templates/manga/show_chapters.html.twig @@ -65,27 +65,31 @@ <div class="p-4"> {% for volume, chapters in chapters_by_volume %} - {% set non_null_chapters = chapters|filter(chapter => chapter.localPath is not null) %} + {% 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"> + + <div data-controller="table" data-table-open-value="{{ is_first ? 'true' : 'false' }}"> <div class="bg-white rounded-sm shadow mb-4"> <div class="flex items-center justify-between bg-white p-4 rounded-t-sm"> <div class="flex flex-row gap-4"> <i class="fas fa-bookmark text-gray-500 text-3xl"></i> - <h2 class="text-xl font-semibold">Volume {{ volume }}</h2> + <h2 class="text-xl font-semibold">Volume {{ '%02d'|format(volume) }}</h2> <div class="flex items-center"> - <span - class="px-2 py-1 text-sm rounded {{ non_null_chapters|length > 0 ? 'bg-green-500 text-white' : 'bg-red-500 text-white' }}"> - {{ non_null_chapters|length }} / {{ total_chapters }} - </span> + <span class="px-2 py-1 text-sm rounded {{ available_chapters|length > 0 ? 'bg-green-500 text-white' : 'bg-red-500 text-white' }}"> + {{ available_chapters|length }} / {{ total_chapters }} + </span> </div> </div> <div class="flex items-center"> <span class="text-gray-600 mr-2">{{ chapters|length }} Chapters</span> - <i data-action="click->table#collapse" class="fas fa-chevron-down cursor-pointer"></i> + <i data-table-target="toggleIcon" data-action="click->table#toggle" class="fas fa-chevron-{{ is_first ? 'up' : 'down' }} cursor-pointer"></i> </div> </div> - <div data-table-target="body" class="p-4 border-t"> + + <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> @@ -95,53 +99,71 @@ </tr> </thead> <tbody> - {% for chapter in chapters %} + {% if all_chapters_same_cbz and volume_cbz_path is not null %} <tr class="border-t hover:bg-green-100"> - {% if chapter.localPath is not null %} - <td class="px-4 py-2 text-green-500"> - <a href="{{ path('read_chapter_page', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}"> - {{ chapter.number }}</a> - </td> - {% else %} - <td class="px-4 py-2">{{ chapter.number }}</td> - {% endif %} - + <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"> - {% if chapter.localPath is not null %} - <a class="" - href="{{ path('read_chapter_page', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}">{{ chapter.title ?? 'No title' }}</a> - {% else %} - {{ chapter.title ?? 'No title' }} - {% endif %} + <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"> - {% if chapter.localPath 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> - <span class="text-gray-500" disabled="disabled"> - <i data-download-chapter-target="icon" class="fas fa-search"></i> - </span> - </button> - {% endif %} - <a href="{{ path('download_cbz', {chapterId: chapter.id}) }}" class="text-gray-500 hover:text-green-500"> + <a href="{{ path('download_cbz', {chapterId: chapters|first.id}) }}" class="text-gray-500 hover:text-green-500"> <i class="fas fa-download"></i> </a> - {# <a href="#" class="text-gray-500 hover:text-green-500"> #} - {# <i class="fas fa-trash"></i> #} - {# </a> #} </td> </tr> - {% endfor %} + {% 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> diff --git a/templates/menu/menu.html.twig b/templates/menu/menu.html.twig index edc8d33..78f1c05 100644 --- a/templates/menu/menu.html.twig +++ b/templates/menu/menu.html.twig @@ -1,43 +1,61 @@ <div id="menu" class="h-full w-full bg-gray-600 text-white overflow-y-auto"> <nav> <ul> - <li class="mb-4 border-l-4 border-green-600"> - <div class="pl-4 p-4 items-center text-green-600 bg-gray-800"> - <a class="flex items-center" href="{{ path('app_manga') }}"> - <i class="fas fa-book mr-2"></i> - <span>Mangas</span> - </a> - </div> - <ul class="ml-8 mt-2 space-y-4"> - <li><a href="{{ path('add_new_manga') }}" class="hover:text-green-600">Ajouter un nouveau</a></li> - <li><a href="{{ path('app_import') }}" class="hover:text-green-600">Import bibliothèque</a></li> - <li><a href="#" class="hover:text-green-600">Collections</a></li> - <li><a href="#" class="hover:text-green-600">Découvrir</a></li> - </ul> + <li class="{{ app.request.get('_route') starts with 'app_manga' ? 'border-l-4 border-green-600' : '' }}"> + <a href="{{ path('app_manga') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') starts with 'app_manga' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}"> + <i class="fas fa-book mr-2"></i> + <span>Mangas</span> + </a> + {% if app.request.get('_route') starts with 'app_manga' %} + <ul class="ml-8 mt-2 space-y-4"> + <li><a href="{{ path('app_manga_new') }}" class="hover:text-green-600">Ajouter un nouveau</a></li> + <li><a href="{{ path('app_import') }}" class="hover:text-green-600">Import bibliothèque</a></li> + <li><a href="#" class="hover:text-green-600">Découvrir</a></li> + </ul> + {% endif %} </li> - <li class="mb-4 pl-4 flex items-center hover:text-green-600"> - <i class="fas fa-calendar-alt mr-2"></i> - <span>Calendrier</span> - </li> - <li class="mb-4 pl-4 hover:text-green-600"> - <a href="{{ path('app_activity') }}"> - <div data-controller="activity" class="flex flex-row justify-between items-center"> - <div class="flex flex-row items-center"> - <i class="fas fa-clock mr-2"></i> - <span>Activité</span> - </div> - <span data-activity-target="activity" - class="bg-green-500 rounded px-2 mr-2 hover:text-white hidden"></span> - </div> + <li class="{{ app.request.get('_route') == 'app_calendar' ? 'border-l-4 border-green-600' : '' }}"> + <a href="{{ path('app_calendar') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') == 'app_calendar' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}"> + <i class="fas fa-calendar-alt mr-2"></i> + <span>Calendrier</span> </a> </li> - <li class="mb-4 pl-4 flex items-center hover:text-green-600"> - <i class="fas fa-cog mr-2"></i> - <span>Paramètres</span> + <li class="{{ app.request.get('_route') == 'app_activity' ? 'border-l-4 border-green-600' : '' }}"> + <a href="{{ path('app_activity') }}" class="block pl-4 py-2 flex items-center justify-between {{ app.request.get('_route') == 'app_activity' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}"> + <div class="flex items-center"> + <i class="fas fa-clock mr-2"></i> + <span>Activité</span> + </div> + <span data-controller="activity" data-activity-target="activity" class="bg-green-500 rounded px-2 mr-2 hover:text-white hidden"></span> + </a> </li> - <li class="mb-4 pl-4 flex items-center hover:text-green-600"> - <i class="fas fa-desktop mr-2"></i> - <span>Système</span> + <li class="{{ app.request.get('_route') starts with 'app_settings' ? 'border-l-4 border-green-600' : '' }}"> + <a href="{{ path('app_settings') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') starts with 'app_settings' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}"> + <i class="fas fa-cog mr-2"></i> + <span>Paramètres</span> + </a> + {% if app.request.get('_route') starts with 'app_settings' %} + <ul class="ml-8 mt-2 space-y-4"> + <li><a href="{{ path('app_settings_general') }}" class="hover:text-green-600">Général</a></li> + <li><a href="{{ path('app_settings_folders') }}" class="hover:text-green-600">Dossiers</a></li> + <li><a href="{{ path('app_settings_scrappers') }}" class="hover:text-green-600">Scrappers</a></li> + <li><a href="{{ path('app_settings_ui') }}" class="hover:text-green-600">UI</a></li> + </ul> + {% endif %} + </li> + <li class="{{ app.request.get('_route') starts with 'app_system' ? 'border-l-4 border-green-600' : '' }}"> + <a href="{{ path('app_system') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') starts with 'app_system' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}"> + <i class="fas fa-desktop mr-2"></i> + <span>Système</span> + </a> + {% if app.request.get('_route') starts with 'app_system' %} + <ul class="ml-8 mt-2 space-y-4"> + <li><a href="{{ path('app_system_status') }}" class="hover:text-green-600">Status</a></li> + <li><a href="{{ path('app_system_backup') }}" class="hover:text-green-600">Backup</a></li> + <li><a href="{{ path('app_system_logs') }}" class="hover:text-green-600">Logs</a></li> + <li><a href="{{ path('app_system_updates') }}" class="hover:text-green-600">Updates</a></li> + </ul> + {% endif %} </li> </ul> </nav> diff --git a/templates/settings/index.html.twig b/templates/settings/index.html.twig new file mode 100644 index 0000000..a94ff44 --- /dev/null +++ b/templates/settings/index.html.twig @@ -0,0 +1,20 @@ +{% extends 'base.html.twig' %} + +{% block title %}Hello SettingsController!{% endblock %} + +{% block body %} +<style> + .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; } + .example-wrapper code { background: #F5F5F5; padding: 2px 6px; } +</style> + +<div class="example-wrapper"> + <h1>Hello {{ controller_name }}! ✅</h1> + + This friendly message is coming from: + <ul> + <li>Your controller at <code><a href="{{ '/app/src/Controller/SettingsController.php'|file_link(0) }}">src/Controller/SettingsController.php</a></code></li> + <li>Your template at <code><a href="{{ '/app/templates/settings/index.html.twig'|file_link(0) }}">templates/settings/index.html.twig</a></code></li> + </ul> +</div> +{% endblock %} diff --git a/templates/system/index.html.twig b/templates/system/index.html.twig new file mode 100644 index 0000000..da85fb6 --- /dev/null +++ b/templates/system/index.html.twig @@ -0,0 +1,20 @@ +{% extends 'base.html.twig' %} + +{% block title %}Hello SystemController!{% endblock %} + +{% block body %} +<style> + .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; } + .example-wrapper code { background: #F5F5F5; padding: 2px 6px; } +</style> + +<div class="example-wrapper"> + <h1>Hello {{ controller_name }}! ✅</h1> + + This friendly message is coming from: + <ul> + <li>Your controller at <code><a href="{{ '/app/src/Controller/SystemController.php'|file_link(0) }}">src/Controller/SystemController.php</a></code></li> + <li>Your template at <code><a href="{{ '/app/templates/system/index.html.twig'|file_link(0) }}">templates/system/index.html.twig</a></code></li> + </ul> +</div> +{% endblock %}