From ca8791cc0da03bdc95578856331eb3ccaa901bc3 Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Tue, 17 Mar 2026 22:04:48 +0100 Subject: [PATCH 1/2] feat(system): page Status avec endpoint API Platform et composants Vue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouveau domaine System/Domain/Model/SystemStatus (value object) - QueryHandler agrégeant métriques mangas, chapitres, jobs (global/24h/7j), stockage et sources - Endpoint GET /api/system/status via API Platform (singleton) - Calcul de l'espace disque par RecursiveDirectoryIterator sur public/images - Page Vue /system/status avec 6 cards (Mangas, Chapitres, Jobs, Stockage, Sources, Système) - Nettoyage du router : suppression des PlaceholderComponent et routes placeholder - Sidebar : suppression des entrées sans page réelle --- .../system/application/store/statusStore.js | 26 +++++ .../infrastructure/api/ApiStatusRepository.js | 13 +++ .../components/ChaptersStatusCard.vue | 36 ++++++ .../components/JobsStatusCard.vue | 104 ++++++++++++++++++ .../components/MangasStatusCard.vue | 33 ++++++ .../components/SourcesStatusCard.vue | 41 +++++++ .../presentation/components/StatusCard.vue | 22 ++++ .../components/StorageStatusCard.vue | 40 +++++++ .../components/SystemInfoCard.vue | 32 ++++++ .../system/presentation/pages/StatusPage.vue | 71 ++++++++++++ assets/vue/app/router/index.js | 59 +--------- .../app/shared/components/layout/Sidebar.vue | 8 +- config/packages/api_platform.yaml | 1 + config/services.yaml | 9 ++ .../Query/GetSystemStatusQuery.php | 7 ++ .../GetSystemStatusQueryHandler.php | 93 ++++++++++++++++ .../SystemStatusRepositoryInterface.php | 22 ++++ .../System/Domain/Model/SystemStatus.php | 44 ++++++++ .../Resource/GetSystemStatusResource.php | 56 ++++++++++ .../Provider/GetSystemStatusStateProvider.php | 88 +++++++++++++++ .../DoctrineSystemStatusRepository.php | 81 ++++++++++++++ 21 files changed, 825 insertions(+), 61 deletions(-) create mode 100644 assets/vue/app/domain/system/application/store/statusStore.js create mode 100644 assets/vue/app/domain/system/infrastructure/api/ApiStatusRepository.js create mode 100644 assets/vue/app/domain/system/presentation/components/ChaptersStatusCard.vue create mode 100644 assets/vue/app/domain/system/presentation/components/JobsStatusCard.vue create mode 100644 assets/vue/app/domain/system/presentation/components/MangasStatusCard.vue create mode 100644 assets/vue/app/domain/system/presentation/components/SourcesStatusCard.vue create mode 100644 assets/vue/app/domain/system/presentation/components/StatusCard.vue create mode 100644 assets/vue/app/domain/system/presentation/components/StorageStatusCard.vue create mode 100644 assets/vue/app/domain/system/presentation/components/SystemInfoCard.vue create mode 100644 assets/vue/app/domain/system/presentation/pages/StatusPage.vue create mode 100644 src/Domain/System/Application/Query/GetSystemStatusQuery.php create mode 100644 src/Domain/System/Application/QueryHandler/GetSystemStatusQueryHandler.php create mode 100644 src/Domain/System/Domain/Contract/Repository/SystemStatusRepositoryInterface.php create mode 100644 src/Domain/System/Domain/Model/SystemStatus.php create mode 100644 src/Domain/System/Infrastructure/ApiPlatform/Resource/GetSystemStatusResource.php create mode 100644 src/Domain/System/Infrastructure/ApiPlatform/State/Provider/GetSystemStatusStateProvider.php create mode 100644 src/Domain/System/Infrastructure/Persistence/Repository/DoctrineSystemStatusRepository.php diff --git a/assets/vue/app/domain/system/application/store/statusStore.js b/assets/vue/app/domain/system/application/store/statusStore.js new file mode 100644 index 0000000..10fe8b6 --- /dev/null +++ b/assets/vue/app/domain/system/application/store/statusStore.js @@ -0,0 +1,26 @@ +import { defineStore } from 'pinia'; +import { ApiStatusRepository } from '../../infrastructure/api/ApiStatusRepository'; + +const statusRepository = new ApiStatusRepository(); + +export const useStatusStore = defineStore('system-status', { + state: () => ({ + status: null, + loading: false, + error: null, + }), + + actions: { + async loadStatus() { + this.loading = true; + this.error = null; + try { + this.status = await statusRepository.getStatus(); + } catch (e) { + this.error = e.message ?? 'Erreur lors du chargement du statut système'; + } finally { + this.loading = false; + } + }, + }, +}); diff --git a/assets/vue/app/domain/system/infrastructure/api/ApiStatusRepository.js b/assets/vue/app/domain/system/infrastructure/api/ApiStatusRepository.js new file mode 100644 index 0000000..c56d89c --- /dev/null +++ b/assets/vue/app/domain/system/infrastructure/api/ApiStatusRepository.js @@ -0,0 +1,13 @@ +export class ApiStatusRepository { + async getStatus() { + const response = await fetch('/api/system/status', { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Erreur HTTP ${response.status}`); + } + + return response.json(); + } +} diff --git a/assets/vue/app/domain/system/presentation/components/ChaptersStatusCard.vue b/assets/vue/app/domain/system/presentation/components/ChaptersStatusCard.vue new file mode 100644 index 0000000..c66de2d --- /dev/null +++ b/assets/vue/app/domain/system/presentation/components/ChaptersStatusCard.vue @@ -0,0 +1,36 @@ + + + diff --git a/assets/vue/app/domain/system/presentation/components/JobsStatusCard.vue b/assets/vue/app/domain/system/presentation/components/JobsStatusCard.vue new file mode 100644 index 0000000..a1aade4 --- /dev/null +++ b/assets/vue/app/domain/system/presentation/components/JobsStatusCard.vue @@ -0,0 +1,104 @@ + + + diff --git a/assets/vue/app/domain/system/presentation/components/MangasStatusCard.vue b/assets/vue/app/domain/system/presentation/components/MangasStatusCard.vue new file mode 100644 index 0000000..0b22004 --- /dev/null +++ b/assets/vue/app/domain/system/presentation/components/MangasStatusCard.vue @@ -0,0 +1,33 @@ + + + diff --git a/assets/vue/app/domain/system/presentation/components/SourcesStatusCard.vue b/assets/vue/app/domain/system/presentation/components/SourcesStatusCard.vue new file mode 100644 index 0000000..0dc5168 --- /dev/null +++ b/assets/vue/app/domain/system/presentation/components/SourcesStatusCard.vue @@ -0,0 +1,41 @@ + + + diff --git a/assets/vue/app/domain/system/presentation/components/StatusCard.vue b/assets/vue/app/domain/system/presentation/components/StatusCard.vue new file mode 100644 index 0000000..1b93ecd --- /dev/null +++ b/assets/vue/app/domain/system/presentation/components/StatusCard.vue @@ -0,0 +1,22 @@ + + + diff --git a/assets/vue/app/domain/system/presentation/components/StorageStatusCard.vue b/assets/vue/app/domain/system/presentation/components/StorageStatusCard.vue new file mode 100644 index 0000000..8971b11 --- /dev/null +++ b/assets/vue/app/domain/system/presentation/components/StorageStatusCard.vue @@ -0,0 +1,40 @@ + + + diff --git a/assets/vue/app/domain/system/presentation/components/SystemInfoCard.vue b/assets/vue/app/domain/system/presentation/components/SystemInfoCard.vue new file mode 100644 index 0000000..4057818 --- /dev/null +++ b/assets/vue/app/domain/system/presentation/components/SystemInfoCard.vue @@ -0,0 +1,32 @@ + + + diff --git a/assets/vue/app/domain/system/presentation/pages/StatusPage.vue b/assets/vue/app/domain/system/presentation/pages/StatusPage.vue new file mode 100644 index 0000000..ba60117 --- /dev/null +++ b/assets/vue/app/domain/system/presentation/pages/StatusPage.vue @@ -0,0 +1,71 @@ + + + diff --git a/assets/vue/app/router/index.js b/assets/vue/app/router/index.js index 89f1b3a..edbf340 100644 --- a/assets/vue/app/router/index.js +++ b/assets/vue/app/router/index.js @@ -11,24 +11,9 @@ import ScrapperConfigurations from '../domain/setting/presentation/pages/Scrappe import ScrapperEdit from '../domain/setting/presentation/pages/ScrapperEdit.vue'; import UserPreferencesPage from '../domain/setting/presentation/pages/UserPreferencesPage.vue'; import LogsPage from '../domain/system/presentation/pages/LogsPage.vue'; +import StatusPage from '../domain/system/presentation/pages/StatusPage.vue'; import Layout from '../shared/components/layout/Layout.vue'; -// Placeholder component for new routes -const PlaceholderComponent = { - props: { - title: { - type: String, - required: true - } - }, - template: ` -
-

{{ title }}

-

Cette fonctionnalité sera bientôt disponible.

-
- ` -}; - const routes = [ { path: '/', @@ -66,13 +51,6 @@ const routes = [ name: 'import', component: NewImportPage }, - // Pages placeholder avec chargement différé - { - path: '/manga/import', - name: 'manga-import', - component: PlaceholderComponent, - props: { title: 'Import de bibliothèque' } - }, { path: '/manga/discover', name: 'discover', @@ -91,21 +69,7 @@ const routes = [ // Paramètres { path: '/settings', - name: 'settings', - component: PlaceholderComponent, - props: { title: 'Paramètres' } - }, - { - path: '/settings/general', - name: 'settings-general', - component: PlaceholderComponent, - props: { title: 'Paramètres généraux' } - }, - { - path: '/settings/folders', - name: 'settings-folders', - component: PlaceholderComponent, - props: { title: 'Gestion des dossiers' } + redirect: '/settings/scrappers', }, { path: '/settings/scrappers', @@ -130,33 +94,18 @@ const routes = [ // Système { path: '/system', - name: 'system', - component: PlaceholderComponent, - props: { title: 'Système' } + redirect: '/system/status', }, { path: '/system/status', name: 'system-status', - component: PlaceholderComponent, - props: { title: 'Status du système' } - }, - { - path: '/system/backup', - name: 'system-backup', - component: PlaceholderComponent, - props: { title: 'Sauvegarde' } + component: StatusPage, }, { path: '/system/logs', name: 'system-logs', component: LogsPage, }, - { - path: '/system/updates', - name: 'system-updates', - component: PlaceholderComponent, - props: { title: 'Mises à jour' } - } ] } ]; diff --git a/assets/vue/app/shared/components/layout/Sidebar.vue b/assets/vue/app/shared/components/layout/Sidebar.vue index 16fa2ed..aec7e24 100644 --- a/assets/vue/app/shared/components/layout/Sidebar.vue +++ b/assets/vue/app/shared/components/layout/Sidebar.vue @@ -78,11 +78,9 @@ import MenuGroup from './sidebar/MenuGroup.vue'; { icon: Cog6ToothIcon, text: 'Paramètres', - to: '/settings', + to: '/settings/scrappers', id: 'settings', subItems: [ - { icon: null, text: 'Général', to: '/settings/general' }, - { icon: null, text: 'Dossiers', to: '/settings/folders' }, { icon: null, text: 'Scrappers', to: '/settings/scrappers' }, { icon: null, text: 'UI', to: '/settings/ui' } ] @@ -90,13 +88,11 @@ import MenuGroup from './sidebar/MenuGroup.vue'; { icon: ComputerDesktopIcon, text: 'Système', - to: '/system', + to: '/system/status', id: 'system', subItems: [ { icon: null, text: 'Status', to: '/system/status' }, - { icon: null, text: 'Backup', to: '/system/backup' }, { icon: null, text: 'Logs', to: '/system/logs' }, - { icon: null, text: 'Updates', to: '/system/updates' } ] } ]; diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index d4be244..1299de5 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -34,5 +34,6 @@ api_platform: - '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Conversion/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource' + - '%kernel.project_dir%/src/Domain/System/Infrastructure/ApiPlatform/Resource' patch_formats: json: ['application/merge-patch+json'] diff --git a/config/services.yaml b/config/services.yaml index 0b10b2b..7713181 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -200,3 +200,12 @@ services: # Import Domain API Platform Services App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~ App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~ + + # System Domain + App\Domain\System\Domain\Contract\Repository\SystemStatusRepositoryInterface: + alias: App\Domain\System\Infrastructure\Persistence\Repository\DoctrineSystemStatusRepository + + App\Domain\System\Application\QueryHandler\GetSystemStatusQueryHandler: + arguments: + $mangaDataPath: '%env(resolve:MANGA_DATA_PATH)%' + $imagesStoragePath: '%kernel.project_dir%/public/images' diff --git a/src/Domain/System/Application/Query/GetSystemStatusQuery.php b/src/Domain/System/Application/Query/GetSystemStatusQuery.php new file mode 100644 index 0000000..5120521 --- /dev/null +++ b/src/Domain/System/Application/Query/GetSystemStatusQuery.php @@ -0,0 +1,7 @@ +modify('-24 hours'); + $last7d = $now->modify('-7 days'); + + $totalJobs = $this->jobRepository->countByCriteria([]); + $completedJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED]); + $failedJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED]); + $pendingJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::PENDING]); + $inProgressJobs = $this->jobRepository->countByCriteria(['status' => JobStatus::IN_PROGRESS]); + + $totalJobsLast24h = $this->jobRepository->countByCriteria(['createdAfter' => $last24h]); + $completedJobsLast24h = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED, 'createdAfter' => $last24h]); + $failedJobsLast24h = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED, 'createdAfter' => $last24h]); + + $totalJobsLast7d = $this->jobRepository->countByCriteria(['createdAfter' => $last7d]); + $completedJobsLast7d = $this->jobRepository->countByCriteria(['status' => JobStatus::COMPLETED, 'createdAfter' => $last7d]); + $failedJobsLast7d = $this->jobRepository->countByCriteria(['status' => JobStatus::FAILED, 'createdAfter' => $last7d]); + + $storagePath = $this->imagesStoragePath; + $storageTotalBytes = (int) (@disk_total_space($storagePath) ?: 0); + $storageFreeBytes = (int) (@disk_free_space($storagePath) ?: 0); + $storageUsedBytes = $this->computeDirectorySize($storagePath); + + return new SystemStatus( + totalMangas: $this->systemStatusRepository->countMangas(), + monitoredMangas: $this->systemStatusRepository->countMonitoredMangas(), + mangasByStatus: $this->systemStatusRepository->countMangasByStatus(), + totalChapters: $this->systemStatusRepository->countChapters(), + downloadedChapters: $this->systemStatusRepository->countDownloadedChapters(), + totalJobs: $totalJobs, + completedJobs: $completedJobs, + failedJobs: $failedJobs, + pendingJobs: $pendingJobs, + inProgressJobs: $inProgressJobs, + totalJobsLast24h: $totalJobsLast24h, + completedJobsLast24h: $completedJobsLast24h, + failedJobsLast24h: $failedJobsLast24h, + totalJobsLast7d: $totalJobsLast7d, + completedJobsLast7d: $completedJobsLast7d, + failedJobsLast7d: $failedJobsLast7d, + storagePath: $this->mangaDataPath, + storageTotalBytes: $storageTotalBytes, + storageFreeBytes: $storageFreeBytes, + storageUsedBytes: $storageUsedBytes, + totalSources: $this->systemStatusRepository->countContentSources(), + sourcesByHealth: $this->systemStatusRepository->countContentSourcesByHealth(), + phpVersion: PHP_VERSION, + generatedAt: $now, + ); + } + + private function computeDirectorySize(string $path): int + { + if (!is_dir($path)) { + return 0; + } + + $size = 0; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile()) { + $size += $file->getSize(); + } + } + + return $size; + } +} diff --git a/src/Domain/System/Domain/Contract/Repository/SystemStatusRepositoryInterface.php b/src/Domain/System/Domain/Contract/Repository/SystemStatusRepositoryInterface.php new file mode 100644 index 0000000..2b48390 --- /dev/null +++ b/src/Domain/System/Domain/Contract/Repository/SystemStatusRepositoryInterface.php @@ -0,0 +1,22 @@ + */ + public function countMangasByStatus(): array; + + public function countChapters(): int; + + public function countDownloadedChapters(): int; + + public function countContentSources(): int; + + /** @return array */ + public function countContentSourcesByHealth(): array; +} diff --git a/src/Domain/System/Domain/Model/SystemStatus.php b/src/Domain/System/Domain/Model/SystemStatus.php new file mode 100644 index 0000000..f85573b --- /dev/null +++ b/src/Domain/System/Domain/Model/SystemStatus.php @@ -0,0 +1,44 @@ + */ + public array $mangasByStatus, + // Chapitres + public int $totalChapters, + public int $downloadedChapters, + // Jobs global + public int $totalJobs, + public int $completedJobs, + public int $failedJobs, + public int $pendingJobs, + public int $inProgressJobs, + // Jobs 24h + public int $totalJobsLast24h, + public int $completedJobsLast24h, + public int $failedJobsLast24h, + // Jobs 7j + public int $totalJobsLast7d, + public int $completedJobsLast7d, + public int $failedJobsLast7d, + // Stockage + public string $storagePath, + public int $storageTotalBytes, + public int $storageFreeBytes, + public int $storageUsedBytes, + // Sources + public int $totalSources, + /** @var array */ + public array $sourcesByHealth, + // Système + public string $phpVersion, + public \DateTimeImmutable $generatedAt, + ) { + } +} diff --git a/src/Domain/System/Infrastructure/ApiPlatform/Resource/GetSystemStatusResource.php b/src/Domain/System/Infrastructure/ApiPlatform/Resource/GetSystemStatusResource.php new file mode 100644 index 0000000..b814004 --- /dev/null +++ b/src/Domain/System/Infrastructure/ApiPlatform/Resource/GetSystemStatusResource.php @@ -0,0 +1,56 @@ + */ + public array $mangasByStatus = []; + public int $totalChapters = 0; + public int $downloadedChapters = 0; + public int $pendingChapters = 0; + public int $totalJobs = 0; + public int $completedJobs = 0; + public int $failedJobs = 0; + public int $pendingJobs = 0; + public int $inProgressJobs = 0; + public int $totalJobsLast24h = 0; + public int $completedJobsLast24h = 0; + public int $failedJobsLast24h = 0; + public float $successRateLast24h = 0.0; + public int $totalJobsLast7d = 0; + public int $completedJobsLast7d = 0; + public int $failedJobsLast7d = 0; + public float $successRateLast7d = 0.0; + public string $storagePath = ''; + public int $storageTotalBytes = 0; + public int $storageFreeBytes = 0; + public int $storageUsedBytes = 0; + public string $storageTotalHuman = ''; + public string $storageFreeHuman = ''; + public string $storageUsedHuman = ''; + public int $totalSources = 0; + /** @var array */ + public array $sourcesByHealth = []; + public string $phpVersion = ''; + public string $generatedAt = ''; +} diff --git a/src/Domain/System/Infrastructure/ApiPlatform/State/Provider/GetSystemStatusStateProvider.php b/src/Domain/System/Infrastructure/ApiPlatform/State/Provider/GetSystemStatusStateProvider.php new file mode 100644 index 0000000..122dd90 --- /dev/null +++ b/src/Domain/System/Infrastructure/ApiPlatform/State/Provider/GetSystemStatusStateProvider.php @@ -0,0 +1,88 @@ +handler->handle(new GetSystemStatusQuery()); + + return $this->toResource($response); + } + + private function toResource(SystemStatus $response): GetSystemStatusResource + { + $resource = new GetSystemStatusResource(); + $resource->id = 'current'; + + $resource->totalMangas = $response->totalMangas; + $resource->monitoredMangas = $response->monitoredMangas; + $resource->mangasByStatus = $response->mangasByStatus; + + $resource->totalChapters = $response->totalChapters; + $resource->downloadedChapters = $response->downloadedChapters; + $resource->pendingChapters = $response->totalChapters - $response->downloadedChapters; + + $resource->totalJobs = $response->totalJobs; + $resource->completedJobs = $response->completedJobs; + $resource->failedJobs = $response->failedJobs; + $resource->pendingJobs = $response->pendingJobs; + $resource->inProgressJobs = $response->inProgressJobs; + + $resource->totalJobsLast24h = $response->totalJobsLast24h; + $resource->completedJobsLast24h = $response->completedJobsLast24h; + $resource->failedJobsLast24h = $response->failedJobsLast24h; + $resource->successRateLast24h = $response->totalJobsLast24h > 0 + ? round($response->completedJobsLast24h / $response->totalJobsLast24h * 100, 1) + : 0.0; + + $resource->totalJobsLast7d = $response->totalJobsLast7d; + $resource->completedJobsLast7d = $response->completedJobsLast7d; + $resource->failedJobsLast7d = $response->failedJobsLast7d; + $resource->successRateLast7d = $response->totalJobsLast7d > 0 + ? round($response->completedJobsLast7d / $response->totalJobsLast7d * 100, 1) + : 0.0; + + $resource->storagePath = $response->storagePath; + $resource->storageTotalBytes = $response->storageTotalBytes; + $resource->storageFreeBytes = $response->storageFreeBytes; + $resource->storageUsedBytes = $response->storageUsedBytes; + $resource->storageTotalHuman = $this->formatBytes($response->storageTotalBytes); + $resource->storageFreeHuman = $this->formatBytes($response->storageFreeBytes); + $resource->storageUsedHuman = $this->formatBytes($response->storageUsedBytes); + + $resource->totalSources = $response->totalSources; + $resource->sourcesByHealth = $response->sourcesByHealth; + + $resource->phpVersion = $response->phpVersion; + $resource->generatedAt = $response->generatedAt->format(\DateTimeInterface::ATOM); + + return $resource; + } + + private function formatBytes(int $bytes): string + { + if ($bytes <= 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $exp = (int) floor(log($bytes, 1024)); + $exp = min($exp, count($units) - 1); + + return round($bytes / (1024 ** $exp), 2) . ' ' . $units[$exp]; + } +} diff --git a/src/Domain/System/Infrastructure/Persistence/Repository/DoctrineSystemStatusRepository.php b/src/Domain/System/Infrastructure/Persistence/Repository/DoctrineSystemStatusRepository.php new file mode 100644 index 0000000..0ccc5f2 --- /dev/null +++ b/src/Domain/System/Infrastructure/Persistence/Repository/DoctrineSystemStatusRepository.php @@ -0,0 +1,81 @@ +entityManager + ->createQuery('SELECT COUNT(m) FROM ' . Manga::class . ' m') + ->getSingleScalarResult(); + } + + public function countMonitoredMangas(): int + { + return (int) $this->entityManager + ->createQuery('SELECT COUNT(m) FROM ' . Manga::class . ' m WHERE m.monitored = true') + ->getSingleScalarResult(); + } + + public function countMangasByStatus(): array + { + $results = $this->entityManager + ->createQuery('SELECT m.status, COUNT(m) as cnt FROM ' . Manga::class . ' m GROUP BY m.status') + ->getResult(); + + $counts = []; + foreach ($results as $row) { + $status = $row['status'] ?? 'unknown'; + $counts[$status] = (int) $row['cnt']; + } + + return $counts; + } + + public function countChapters(): int + { + return (int) $this->entityManager + ->createQuery('SELECT COUNT(c) FROM ' . Chapter::class . ' c') + ->getSingleScalarResult(); + } + + public function countDownloadedChapters(): int + { + return (int) $this->entityManager + ->createQuery('SELECT COUNT(c) FROM ' . Chapter::class . ' c WHERE c.cbzPath IS NOT NULL') + ->getSingleScalarResult(); + } + + public function countContentSources(): int + { + return (int) $this->entityManager + ->createQuery('SELECT COUNT(s) FROM ' . ContentSource::class . ' s') + ->getSingleScalarResult(); + } + + public function countContentSourcesByHealth(): array + { + $results = $this->entityManager + ->createQuery('SELECT s.healthStatus, COUNT(s) as cnt FROM ' . ContentSource::class . ' s GROUP BY s.healthStatus') + ->getResult(); + + $counts = []; + foreach ($results as $row) { + $counts[$row['healthStatus']] = (int) $row['cnt']; + } + + return $counts; + } +} From 59f72339fa375e396878b67801ab054e96e4da2f Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Thu, 26 Mar 2026 15:56:18 +0100 Subject: [PATCH 2/2] fix(reader): corriger le chevauchement des pages en mode scroll avec zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En mode scroll infini, le zoom était appliqué via transform: scale() qui n'affecte pas le flux de mise en page. Les pages se chevauchaient visuellement quand le zoom était modifié. Passage à la propriété CSS zoom dans les deux modes pour un comportement de layout correct. Met aussi à jour le calcul de hauteur des placeholders pour inclure le facteur de zoom et éviter les sauts de layout lors du chargement paresseux. --- .../reader/presentation/components/InfiniteReader.vue | 6 +++--- .../domain/reader/presentation/components/ReaderPage.vue | 8 ++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue b/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue index be5636e..29fe30a 100644 --- a/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue +++ b/assets/vue/app/domain/reader/presentation/components/InfiniteReader.vue @@ -94,14 +94,14 @@ import ReaderPage from './ReaderPage.vue'; }); }; - // Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage + // Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage, zoom inclus const getPlaceholderHeight = (page) => { const dims = page?.dimensions; - if (!dims?.width || !dims?.height) return 800; + if (!dims?.width || !dims?.height) return Math.round(800 * props.zoom); const displayWidth = windowWidth.value < 1200 ? Math.min(dims.width, windowWidth.value * 0.95) : Math.min(dims.width, 1200); - return Math.round((dims.height / dims.width) * displayWidth); + return Math.round((dims.height / dims.width) * displayWidth * props.zoom); }; const setupObservers = () => { diff --git a/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue b/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue index 36d88db..2cce8a6 100644 --- a/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue +++ b/assets/vue/app/domain/reader/presentation/components/ReaderPage.vue @@ -87,13 +87,9 @@ import { useReaderStore } from '../../application/store/readerStore'; const store = useReaderStore(); - // En mode single : zoom via la propriété CSS `zoom` (affecte le layout → scrollbars naturelles) - // En mode infinite : zoom via transform: scale (pas d'impact layout souhaité) + // zoom via la propriété CSS `zoom` dans les deux modes (affecte le layout → pas de chevauchement en mode scroll) const containerStyle = computed(() => { - if (store.readingMode === 'single') { - return { zoom: props.zoom }; - } - return { transform: `scale(${props.zoom})` }; + return { zoom: props.zoom }; }); const imageRef = ref(null);