- toogle chapter visibility
- delete chapter cbz
- preferred ContentSource.php and modal
- minor fixes
This commit is contained in:
Jérémy Guillot
2024-07-21 22:21:04 +02:00
parent fafff5014c
commit c56f72b813
17 changed files with 474 additions and 82 deletions

View File

@@ -0,0 +1,101 @@
// assets/controllers/preferred-sources_controller.js
import {Controller} from "@hotwired/stimulus"
import Sortable from 'sortablejs'
export default class extends Controller {
static targets = ["preferredList", "availableList"]
static values = {
mangaId: Number,
preferredSources: Array,
allSources: Array
}
connect() {
this.initSortable()
}
initSortable() {
new Sortable(this.preferredListTarget, {
animation: 150,
ghostClass: 'bg-gray-300',
onEnd: this.handleDragEnd.bind(this)
})
}
handleDragEnd() {
this.updatePreferredSources()
}
addSource(event) {
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
if (!this.preferredSourcesValue.includes(sourceId)) {
this.preferredSourcesValue = [...this.preferredSourcesValue, sourceId]
this.updateLists()
this.save()
}
}
removeSource(event) {
const sourceId = parseInt(event.currentTarget.dataset.sourceId)
this.preferredSourcesValue = this.preferredSourcesValue.filter(id => id !== sourceId)
this.updateLists()
this.save()
}
updatePreferredSources() {
this.preferredSourcesValue = Array.from(this.preferredListTarget.children).map(li => parseInt(li.dataset.id))
this.save()
}
updateLists() {
this.preferredListTarget.innerHTML = this.preferredSourcesValue
.map(id => this.allSourcesValue.find(s => s.id === id))
.map(source => this.sourceTemplate(source, true))
.join('')
this.availableListTarget.innerHTML = this.allSourcesValue
.filter(source => !this.preferredSourcesValue.includes(source.id))
.map(source => this.sourceTemplate(source, false))
.join('')
this.initSortable()
}
sourceTemplate(source, isPreferred) {
return `
<li data-id="${source.id}" draggable="true" class="flex items-center justify-between p-2 bg-gray-100 rounded ${isPreferred ? 'cursor-move' : ''}">
<span>${source.name}</span>
<button type="button" data-action="preferred-sources#${isPreferred ? 'removeSource' : 'addSource'}" data-source-id="${source.id}" class="text-${isPreferred ? 'red' : 'green'}-500 hover:text-${isPreferred ? 'red' : 'green'}-700">
<i class="fas fa-${isPreferred ? 'times' : 'plus'}"></i>
</button>
</li>
`
}
async save() {
try {
const response = await fetch(`/manga/${this.mangaIdValue}/preferred-sources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
preferredSources: this.preferredSourcesValue
})
})
if (response.ok) {
console.log('Preferred sources saved successfully')
// Optionally show a success message
} else {
console.error('Error saving preferred sources')
// Optionally show an error message
}
} catch (error) {
console.error('Error:', error)
// Optionally show an error message
}
}
}

View File

@@ -94,6 +94,11 @@ export default class extends Controller {
document.dispatchEvent(event); document.dispatchEvent(event);
} }
editPreferredSources() {
const event = new CustomEvent('openPreferredSourcesModal');
document.dispatchEvent(event);
}
deleteMangas() { deleteMangas() {
console.log("Deleting mangas..."); console.log("Deleting mangas...");
} }

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240721172633 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE manga_content_source (manga_id INT NOT NULL, content_source_id INT NOT NULL, PRIMARY KEY(manga_id, content_source_id))');
$this->addSql('CREATE INDEX IDX_2C40CB647B6461 ON manga_content_source (manga_id)');
$this->addSql('CREATE INDEX IDX_2C40CB64A5DB5462 ON manga_content_source (content_source_id)');
$this->addSql('ALTER TABLE manga_content_source ADD CONSTRAINT FK_2C40CB647B6461 FOREIGN KEY (manga_id) REFERENCES manga (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE manga_content_source ADD CONSTRAINT FK_2C40CB64A5DB5462 FOREIGN KEY (content_source_id) REFERENCES content_source (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE manga_content_source DROP CONSTRAINT FK_2C40CB647B6461');
$this->addSql('ALTER TABLE manga_content_source DROP CONSTRAINT FK_2C40CB64A5DB5462');
$this->addSql('DROP TABLE manga_content_source');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240721190312 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE chapter ADD visible BOOLEAN NOT NULL DEFAULT TRUE');
}
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 visible');
}
}

7
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"postcss-loader": "^7.1.0", "postcss-loader": "^7.1.0",
"puppeteer": "^22.10.0", "puppeteer": "^22.10.0",
"sortablejs": "^1.15.2",
"tailwindcss": "^3.2.7" "tailwindcss": "^3.2.7"
}, },
"devDependencies": { "devDependencies": {
@@ -9157,6 +9158,11 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/sortablejs": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
"integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -10661,6 +10667,7 @@
} }
}, },
"vendor/symfony/ux-turbo/assets": { "vendor/symfony/ux-turbo/assets": {
"name": "@symfony/ux-turbo",
"version": "0.1.0", "version": "0.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",

View File

@@ -33,6 +33,7 @@
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"postcss-loader": "^7.1.0", "postcss-loader": "^7.1.0",
"puppeteer": "^22.10.0", "puppeteer": "^22.10.0",
"sortablejs": "^1.15.2",
"tailwindcss": "^3.2.7" "tailwindcss": "^3.2.7"
} }
} }

View File

@@ -9,6 +9,7 @@ use App\Manager\Toolbar\Factory\ToolbarFactory;
use App\Message\DownloadChapter; use App\Message\DownloadChapter;
use App\Message\RefreshMetadata; use App\Message\RefreshMetadata;
use App\Repository\ChapterRepository; use App\Repository\ChapterRepository;
use App\Repository\ContentSourceRepository;
use App\Repository\MangaRepository; use App\Repository\MangaRepository;
use App\Service\CbzService; use App\Service\CbzService;
use App\Service\MangadexProvider; use App\Service\MangadexProvider;
@@ -37,16 +38,17 @@ class MangaController extends AbstractController
private ImageManager $imageManager; private ImageManager $imageManager;
public function __construct( public function __construct(
private readonly string $projectDir, private readonly string $projectDir,
private readonly MangaRepository $mangaRepository, private readonly MangaRepository $mangaRepository,
private readonly ChapterRepository $chapterRepository, private readonly ChapterRepository $chapterRepository,
private readonly MessageBusInterface $bus, private readonly MessageBusInterface $bus,
private readonly CbzService $cbzService, private readonly CbzService $cbzService,
private readonly ToolbarFactory $toolbarFactory, private readonly ToolbarFactory $toolbarFactory,
private readonly MangadexProvider $mangadexProvider, private readonly MangadexProvider $mangadexProvider,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly NotificationService $notificationService, private readonly NotificationService $notificationService,
private readonly SluggerInterface $slugger private readonly SluggerInterface $slugger,
private readonly ContentSourceRepository $contentSourceRepository
) )
{ {
$this->imageManager = new ImageManager(new Driver()); $this->imageManager = new ImageManager(new Driver());
@@ -81,11 +83,13 @@ class MangaController extends AbstractController
} }
$form = $this->createForm(MangaEditType::class, $manga); $form = $this->createForm(MangaEditType::class, $manga);
$contentSources = $this->contentSourceRepository->findAll();
return $this->render('manga/show_chapters.html.twig', [ return $this->render('manga/show_chapters.html.twig', [
'manga' => $manga, 'manga' => $manga,
'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId(), 'isMonitored' => (int) $manga->isMonitored()])->getGroups(), 'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId(), 'isMonitored' => (int)$manga->isMonitored()])->getGroups(),
'form' => $form->createView(), 'form' => $form->createView(),
'contentSources' => $contentSources,
]); ]);
} }
@@ -94,6 +98,7 @@ class MangaController extends AbstractController
{ {
try { try {
foreach ($manga->getChapters() as $chapter) { foreach ($manga->getChapters() as $chapter) {
file_exists($chapter->getCbzPath()) ?? unlink($chapter->getCbzPath());
$this->entityManager->remove($chapter); $this->entityManager->remove($chapter);
} }
$this->entityManager->remove($manga); $this->entityManager->remove($manga);
@@ -125,6 +130,30 @@ class MangaController extends AbstractController
return new JsonResponse(['errors' => $errors], 400); return new JsonResponse(['errors' => $errors], 400);
} }
#[Route('/manga/{id}/preferred-sources', name: 'manga_preferred_sources', methods: ['POST'])]
public function updatePreferredSources(
Request $request,
Manga $manga,
ContentSourceRepository $contentSourceRepository
): JsonResponse
{
$data = json_decode($request->getContent(), true);
$preferredSourceIds = $data['preferredSources'] ?? [];
$preferredSources = $contentSourceRepository->findBy(['id' => $preferredSourceIds]);
// This will maintain the order of the sources as they were sent in the request
$orderedPreferredSources = array_map(
fn($id) => current(array_filter($preferredSources, fn($s) => $s->getId() == $id)),
$preferredSourceIds
);
$manga->setPreferredSources(array_filter($orderedPreferredSources));
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
}
public function _chaptersByManga(int $id): Response public function _chaptersByManga(int $id): Response
{ {
$manga = $this->mangaRepository->find($id); $manga = $this->mangaRepository->find($id);
@@ -157,6 +186,33 @@ class MangaController extends AbstractController
]); ]);
} }
#[Route('/delete_cbz/{id}', name: 'app_delete_cbz')]
public function deleteChapterCbz(Chapter $chapter): JsonResponse
{
$cbzPath = $chapter->getCbzPath();
if (!$cbzPath) {
return new JsonResponse(['error' => 'No CBZ path for this chapter.'], 400);
}
file_exists($cbzPath) ?? unlink($cbzPath);
$chapter->setCbzPath(null);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
return new JsonResponse(['success' => 'CBZ file deleted.'], 200);
}
#[Route('/hide_chapter/{id}', name: 'app_hide_chapter')]
public function hideChapter(Chapter $chapter): JsonResponse
{
$chapter->setVisible(false);
$this->entityManager->persist($chapter);
$this->entityManager->flush();
return new JsonResponse(['success' => 'Chapter hidden.'], 200);
}
#[Route('/manga/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_manga_read')] #[Route('/manga/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_manga_read')]
public function readChapterPage(string $mangaSlug, float $chapterNumber, int $pageNumber = 1): Response public function readChapterPage(string $mangaSlug, float $chapterNumber, int $pageNumber = 1): Response
{ {
@@ -171,17 +227,7 @@ class MangaController extends AbstractController
} }
if (is_null($chapter->getCbzPath())) { if (is_null($chapter->getCbzPath())) {
$currentPage = $chapter->getPageByNumber($pageNumber); throw $this->createNotFoundException("Le chapitre demandé n'a pas été scrapé.");
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); $pageContent = $this->cbzService->getPageContent($chapter->getCbzPath(), $pageNumber);
@@ -226,7 +272,9 @@ class MangaController extends AbstractController
->setAuthor($request->request->get('author')) ->setAuthor($request->request->get('author'))
->setPublicationYear($request->request->get('publicationYear')) ->setPublicationYear($request->request->get('publicationYear'))
->setRating($request->request->get('rating')) ->setRating($request->request->get('rating'))
->setExternalId($request->request->get('externalId')); ->setExternalId($request->request->get('externalId'))
->setMonitored(false)
;
// Traitement de l'image // Traitement de l'image
$imageUrl = $request->request->get('imageUrl'); $imageUrl = $request->request->get('imageUrl');
@@ -327,7 +375,8 @@ class MangaController extends AbstractController
$volumeChapters = $this->chapterRepository->findBy([ $volumeChapters = $this->chapterRepository->findBy([
'manga' => $manga, 'manga' => $manga,
'volume' => $volume 'volume' => $volume,
'visible' => true
]); ]);
if (empty($volumeChapters)) { if (empty($volumeChapters)) {
@@ -374,7 +423,8 @@ class MangaController extends AbstractController
$volumeChapters = $this->chapterRepository->findBy([ $volumeChapters = $this->chapterRepository->findBy([
'manga' => $manga, 'manga' => $manga,
'volume' => $volume 'volume' => $volume,
'visible' => true
]); ]);
if (empty($volumeChapters)) { if (empty($volumeChapters)) {

View File

@@ -45,6 +45,9 @@ class Chapter
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
private ?string $cbzPath = null; private ?string $cbzPath = null;
#[ORM\Column]
private ?bool $visible = true;
public function __construct() public function __construct()
{ {
$this->pagesLink = new ArrayCollection(); $this->pagesLink = new ArrayCollection();
@@ -194,4 +197,16 @@ class Chapter
return $this; return $this;
} }
public function isVisible(): ?bool
{
return $this->visible;
}
public function setVisible(bool $visible): static
{
$this->visible = $visible;
return $this;
}
} }

View File

@@ -118,4 +118,13 @@ class ContentSource
return $this; return $this;
} }
public function getCleanBaseUrl(): string
{
return preg_replace(
'/^(https?:\/\/)?(www\.)?|\/+$/',
'',
$this->baseUrl
);
}
} }

View File

@@ -62,10 +62,14 @@ class Manga
#[ORM\Column(type: Types::JSON, nullable: true)] #[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $AlternativeSlugs = null; private ?array $AlternativeSlugs = null;
#[ORM\ManyToMany(targetEntity: ContentSource::class)]
private Collection $preferredSources;
public function __construct() public function __construct()
{ {
$this->chapters = new ArrayCollection(); $this->chapters = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable(); $this->createdAt = new \DateTimeImmutable();
$this->preferredSources = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -280,4 +284,38 @@ class Manga
return $this; return $this;
} }
/**
* @return Collection<int, ContentSource>
*/
public function getPreferredSources(): Collection
{
return $this->preferredSources;
}
public function addPreferredSource(ContentSource $preferredSource): static
{
if (!$this->preferredSources->contains($preferredSource)) {
$this->preferredSources->add($preferredSource);
}
return $this;
}
public function removePreferredSource(ContentSource $preferredSource): static
{
$this->preferredSources->removeElement($preferredSource);
return $this;
}
public function setPreferredSources(array $sources): self
{
$this->preferredSources->clear();
foreach ($sources as $source) {
$this->addPreferredSource($source);
}
return $this;
}
} }

View File

@@ -17,7 +17,7 @@ class ChapterListToolbar extends Toolbar
->addToLeftGroup(new ToolbarDivider()) ->addToLeftGroup(new ToolbarDivider())
->addToLeftGroup(new ToolbarButton('keyboard', 'Rename chapters', 'toolbar#renameChapters')) ->addToLeftGroup(new ToolbarButton('keyboard', 'Rename chapters', 'toolbar#renameChapters'))
->addToLeftGroup(new ToolbarButton('file-zipper', 'Manage cbz', 'toolbar#manageCbz', $contextData)) ->addToLeftGroup(new ToolbarButton('file-zipper', 'Manage cbz', 'toolbar#manageCbz', $contextData))
->addToLeftGroup(new ToolbarButton('history', 'History', 'toolbar#history', $contextData)) ->addToLeftGroup(new ToolbarButton('gear', 'Preferred Sources', 'toolbar#editPreferredSources', $contextData))
->addToRightGroup(new ToolbarButton('bookmark', $monitoredTitle, 'toolbar#monitoring', array_merge($contextData, ['buttonClass' => $monitoredColor]))) ->addToRightGroup(new ToolbarButton('bookmark', $monitoredTitle, 'toolbar#monitoring', array_merge($contextData, ['buttonClass' => $monitoredColor])))

View File

@@ -6,8 +6,8 @@ use App\Entity\ContentSource;
use App\Message\DownloadChapter; use App\Message\DownloadChapter;
use App\Repository\ChapterRepository; use App\Repository\ChapterRepository;
use App\Repository\ContentSourceRepository; use App\Repository\ContentSourceRepository;
use App\Service\MangaScraperService;
use App\Service\NotificationService; use App\Service\NotificationService;
use App\Service\Scraper\MangaScraperService;
use Exception; use Exception;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -40,7 +40,16 @@ readonly class DownloadChapterHandler
throw new BadRequestHttpException('Chapter already downloaded'); throw new BadRequestHttpException('Chapter already downloaded');
} }
$sources = $this->contentSourceRepository->findAll(); $manga = $chapter->getManga();
$preferredSources = $manga->getPreferredSources()->toArray();
$allSources = $this->contentSourceRepository->findAll();
$filteredSources = array_udiff($allSources, $preferredSources, function ($a, $b) {
return $a->getId() - $b->getId();
});
$sources = array_merge($preferredSources, $filteredSources);
$sources[] = $sources[] =
(new ContentSource()) (new ContentSource())
->setBaseUrl('https://api.mangadex.org/') ->setBaseUrl('https://api.mangadex.org/')

View File

@@ -227,7 +227,6 @@ readonly class MangadexProvider implements MetadataProviderInterface
$mergedChapters = []; $mergedChapters = [];
foreach ($allChapters as $chapter) { foreach ($allChapters as $chapter) {
$number = $chapter->getNumber(); $number = $chapter->getNumber();
$existingChapter = $manga->getChapterByNumber($number); $existingChapter = $manga->getChapterByNumber($number);
if ($existingChapter) { if ($existingChapter) {
if ($existingChapter->getExternalId() !== $chapter->getExternalId() && is_null($existingChapter->getExternalId())) { if ($existingChapter->getExternalId() !== $chapter->getExternalId() && is_null($existingChapter->getExternalId())) {

View File

@@ -37,6 +37,7 @@ class JavascriptScraper extends AbstractScraper
$imagePath = $tempDir . '/' . $imageName; $imagePath = $tempDir . '/' . $imageName;
file_put_contents($imagePath, file_get_contents($page['image_url'])); file_put_contents($imagePath, file_get_contents($page['image_url']));
$this->dispatchProgressEvent($chapter, $index + 1, count($pageData));
$page['local_image_url'] = $imagePath; $page['local_image_url'] = $imagePath;
} }
@@ -95,7 +96,7 @@ class JavascriptScraper extends AbstractScraper
{ {
$chapterSelector = $contentSource->getChapterSelector(); $chapterSelector = $contentSource->getChapterSelector();
if (!$chapterSelector) { if (!$chapterSelector) {
return; // Si aucun sélecteur n'est défini, on ne fait rien return;
} }
$crawler = $pantherClient->waitFor($chapterSelector); $crawler = $pantherClient->waitFor($chapterSelector);

View File

@@ -5,7 +5,7 @@
{% set volume_cbz_path = chapters|first.cbzPath %} {% 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 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 available_chapters = chapters|filter(chapter => chapter.cbzPath is not null) %}
{% set total_chapters = chapters|length %} {% set total_chapters = chapters|filter(chapter => chapter.visible)|length %}
<div data-controller="table" data-table-open-value="{{ is_first ? 'true' : 'false' }}"> <div data-controller="table" data-table-open-value="{{ is_first ? 'true' : 'false' }}">
<div class="bg-white rounded-sm shadow mb-4"> <div class="bg-white rounded-sm shadow mb-4">

View File

@@ -1,48 +1,65 @@
<tr id="chapter-{{ chapter.id }}" class="border-t hover:bg-green-100"> {% if chapter.visible %}
{% if chapter.cbzPath is not null %} <tr id="chapter-{{ chapter.id }}" class="border-t hover:bg-green-100">
<td class="px-4 py-2 text-green-500">
<a data-turbo-frame="_top" href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: 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 %} {% if chapter.cbzPath is not null %}
<a data-turbo-frame="_top" href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}"> <td class="px-4 py-2 text-green-500">
{{ chapter.title ?? 'No title' }} <a data-turbo-frame="_top"
</a> href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}">
{{ chapter.number < 10 ? '0' ~ chapter.number : chapter.number }}
</a>
</td>
{% else %} {% else %}
{{ chapter.title ?? 'No title' }} <td class="px-4 py-2">{{ chapter.number < 10 ? '0' ~ chapter.number : chapter.number }}</td>
{% endif %} {% endif %}
</td>
<td class="px-4 py-2 flex justify-end gap-2"> <td class="px-4 py-2 w-full text-left">
{% if chapter.cbzPath is null %} {% if chapter.cbzPath is not null %}
<button <a data-turbo-frame="_top"
data-controller="download" href="{{ path('app_manga_read', { mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: 1 }) }}">
data-action="download#download" {{ chapter.title ?? 'No title' }}
data-download-url-value="{{ path('search_chapter', {id: chapter.id}) }}" </a>
> {% else %}
{{ chapter.title ?? 'No title' }}
{% endif %}
</td>
<td class="px-4 py-2 flex justify-end gap-2">
{% if chapter.cbzPath is null %}
<button
data-controller="download"
data-action="download#download"
data-download-url-value="{{ path('search_chapter', {id: chapter.id}) }}"
>
<span class="text-gray-500 hover:text-green-500"> <span class="text-gray-500 hover:text-green-500">
<i data-download-target="icon" class="fas fa-search"></i> <i data-download-target="icon" class="fas fa-search"></i>
</span> </span>
</button> </button>
{% else %} {% else %}
<button disabled> <button
<span class="text-gray-500"> data-controller="download"
<i class="fas fa-search"></i> data-action="download#download"
data-download-url-value="{{ path('app_delete_cbz', {id: chapter.id}) }}"
>
<span class="text-gray-500 hover:text-green-500">
<i data-download-target="icon" class="fas fa-times"></i>
</span>
</button>
{% endif %}
<a href="#"
data-controller="download"
data-action="download#download"
data-download-url-value="{{ path('download_cbz', {chapterId: chapter.id}) }}"
class="w-8 text-center">
<i data-download-target="icon"
class="fas fa-download text-gray-500 hover:text-green-500"></i>
</a>
<button
data-controller="download"
data-action="download#download"
data-download-url-value="{{ path('app_hide_chapter', {id: chapter.id}) }}"
>
<span class="text-gray-500 hover:text-green-500">
<i data-download-target="icon" class="fas fa-trash-can"></i>
</span> </span>
</button> </button>
{% endif %} </td>
<a href="#" </tr>
data-controller="download" {% endif %}
data-action="download#download"
data-download-url-value="{{ path('download_cbz', {chapterId: chapter.id}) }}"
class="w-8 text-center">
<i data-download-target="icon"
class="fas fa-download text-gray-500 hover:text-green-500"></i>
</a>
</td>
</tr>

View File

@@ -49,7 +49,9 @@
</div> </div>
<turbo-frame id="chapter_list" <turbo-frame id="chapter_list"
src="{{ fragment_uri(controller('App\\Controller\\MangaController::_chaptersByManga', {'id': manga.id})) }}"></turbo-frame> src="{{ fragment_uri(controller('App\\Controller\\MangaController::_chaptersByManga', {'id': manga.id})) }}"
>
</turbo-frame>
{# Modal d'édition #} {# Modal d'édition #}
<twig:Modal <twig:Modal
openTrigger="openEditModal" openTrigger="openEditModal"
@@ -81,21 +83,26 @@
<label class="block text-sm font-medium text-gray-700">Slugs alternatifs</label> <label class="block text-sm font-medium text-gray-700">Slugs alternatifs</label>
<div data-collection-target="container" class="grid grid-cols-4 gap-2 mt-1"> <div data-collection-target="container" class="grid grid-cols-4 gap-2 mt-1">
{% for slug in form.alternativeSlugs %} {% for slug in form.alternativeSlugs %}
<div class="inline-flex items-center bg-gray-100 rounded-full px-3 py-1 text-sm collection-item"> <div
class="inline-flex items-center bg-gray-100 rounded-full px-3 py-1 text-sm collection-item">
{{ form_widget(slug, {'attr': {'class': 'bg-transparent border-none focus:outline-none focus:border-b focus:border-green-500 p-0 w-full'}}) }} {{ form_widget(slug, {'attr': {'class': 'bg-transparent border-none focus:outline-none focus:border-b focus:border-green-500 p-0 w-full'}}) }}
<button type="button" data-action="collection#remove" class="ml-2 text-gray-500 hover:text-green-500 flex-shrink-0"> <button type="button" data-action="collection#remove"
class="ml-2 text-gray-500 hover:text-green-500 flex-shrink-0">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<button type="button" data-action="collection#add" class="mt-2 text-sm text-green-500 hover:text-green-700"> <button type="button" data-action="collection#add"
class="mt-2 text-sm text-green-500 hover:text-green-700">
+ Ajouter un slug alternatif + Ajouter un slug alternatif
</button> </button>
<template data-collection-target="template"> <template data-collection-target="template">
<div class="inline-flex items-center bg-gray-100 rounded-full px-3 py-1 text-sm collection-item"> <div
class="inline-flex items-center bg-gray-100 rounded-full px-3 py-1 text-sm collection-item">
{{ form_widget(form.alternativeSlugs.vars.prototype, {'attr': {'class': 'bg-transparent border-none focus:outline-none focus:border-b focus:border-green-500 p-0 w-full'}}) }} {{ form_widget(form.alternativeSlugs.vars.prototype, {'attr': {'class': 'bg-transparent border-none focus:outline-none focus:border-b focus:border-green-500 p-0 w-full'}}) }}
<button type="button" data-action="collection#remove" class="ml-2 text-gray-500 hover:text-green-500 flex-shrink-0"> <button type="button" data-action="collection#remove"
class="ml-2 text-gray-500 hover:text-green-500 flex-shrink-0">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
@@ -106,21 +113,26 @@
<label class="block text-sm font-medium text-gray-700">{{ form_label(form.genres) }}</label> <label class="block text-sm font-medium text-gray-700">{{ form_label(form.genres) }}</label>
<div data-collection-target="container" class="grid grid-cols-4 gap-2 mt-1"> <div data-collection-target="container" class="grid grid-cols-4 gap-2 mt-1">
{% for genre in form.genres %} {% for genre in form.genres %}
<div class="inline-flex items-center bg-gray-100 rounded-full px-3 py-1 text-sm collection-item"> <div
class="inline-flex items-center bg-gray-100 rounded-full px-3 py-1 text-sm collection-item">
{{ form_widget(genre, {'attr': {'class': 'bg-transparent border-none focus:outline-none focus:border-b focus:border-green-500 p-0 w-full'}}) }} {{ form_widget(genre, {'attr': {'class': 'bg-transparent border-none focus:outline-none focus:border-b focus:border-green-500 p-0 w-full'}}) }}
<button type="button" data-action="collection#remove" class="ml-2 text-gray-500 hover:text-green-500 flex-shrink-0"> <button type="button" data-action="collection#remove"
class="ml-2 text-gray-500 hover:text-green-500 flex-shrink-0">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<button type="button" data-action="collection#add" class="mt-2 text-sm text-green-500 hover:text-green-700"> <button type="button" data-action="collection#add"
class="mt-2 text-sm text-green-500 hover:text-green-700">
+ Ajouter un genre + Ajouter un genre
</button> </button>
<template data-collection-target="template"> <template data-collection-target="template">
<div class="inline-flex items-center bg-gray-100 rounded-full px-3 py-1 text-sm collection-item"> <div
class="inline-flex items-center bg-gray-100 rounded-full px-3 py-1 text-sm collection-item">
{{ form_widget(form.genres.vars.prototype, {'attr': {'class': 'bg-transparent border-none focus:outline-none focus:border-b focus:border-green-500 p-0 w-full'}}) }} {{ form_widget(form.genres.vars.prototype, {'attr': {'class': 'bg-transparent border-none focus:outline-none focus:border-b focus:border-green-500 p-0 w-full'}}) }}
<button type="button" data-action="collection#remove" class="ml-2 text-gray-500 hover:text-green-500 flex-shrink-0"> <button type="button" data-action="collection#remove"
class="ml-2 text-gray-500 hover:text-green-500 flex-shrink-0">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
@@ -166,5 +178,63 @@
</button> </button>
</twig:block> </twig:block>
</twig:Modal> </twig:Modal>
<twig:Modal
openTrigger="openPreferredSourcesModal"
closeTrigger="closePreferredSourcesModal"
title="Manage Preferred Sources"
modalClass="w-full max-w-4xl"
>
{% block content %}
<div {{ stimulus_controller('preferred-sources', {
mangaId: manga.id,
preferredSources: manga.preferredSources|map(s => s.id)|json_encode,
allSources: contentSources|map(s => {
id: s.id,
name: s.cleanBaseUrl
})|json_encode
}) }}>
<div>
<h3 class="text-lg font-medium text-gray-900">Preferred Sources</h3>
<ul data-preferred-sources-target="preferredList" class="mt-2 space-y-2">
{% for source in manga.preferredSources %}
<li data-id="{{ source.id }}" draggable="true"
class="flex items-center justify-between p-2 bg-gray-100 rounded cursor-move">
<span>{{ source.cleanBaseUrl }}</span>
<button type="button" data-action="preferred-sources#removeSource"
data-source-id="{{ source.id }}" class="text-red-500 hover:text-red-700">
<i class="fas fa-times"></i>
</button>
</li>
{% endfor %}
</ul>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900">Available Sources</h3>
<ul data-preferred-sources-target="availableList" class="mt-2 space-y-2">
{% for source in contentSources %}
{% if source not in manga.preferredSources %}
<li class="flex items-center justify-between p-2 bg-gray-100 rounded">
<span>{{ source.cleanBaseUrl }}</span>
<button type="button" data-action="preferred-sources#addSource"
data-source-id="{{ source.id }}"
class="text-green-500 hover:text-green-700">
<i class="fas fa-plus"></i>
</button>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
{% endblock %}
{% block footer %}
<button type="button" data-action="modal#close"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Close
</button>
{% endblock %}
</twig:Modal>
{% endblock %} {% endblock %}