Compare commits

..

19 Commits

Author SHA1 Message Date
546039aa43 docs: adds images 2025-05-14 17:50:41 -05:00
e6196ce078 docs: starts readme 2025-05-14 17:25:00 -05:00
eecd5444a6 fix: uses progress bar to show download progress 2025-05-14 11:33:20 -05:00
74506f6928 fix: adds alert when preferences are saved 2025-05-13 22:03:34 -05:00
845a67bdd8 fix: search buton 2025-05-13 20:49:10 -05:00
651697640c fix: nav bar cleanup 2025-05-13 20:24:23 -05:00
dd48cc542f fix: better paginator functionality 2025-05-13 16:28:42 -05:00
6c0e42d291 fix: mostly working paginator 2025-05-13 16:11:30 -05:00
e230913c89 wip: adds downloads page, makes DownloadList a widget or a full page list 2025-05-13 11:18:08 -05:00
8967d407cb fix: adds 'view all ...' button to dashboard widgets 2025-05-13 09:07:20 -05:00
217a667df2 fix: scopes alerts to user session 2025-05-12 22:04:10 -05:00
4653feb123 wip: pagination 2025-05-12 20:27:39 -05:00
6ad10a585d fix: limits active download list to 5 items 2025-05-12 16:33:47 -05:00
eed2e70d21 fix: download progress indicator 2025-05-12 15:10:25 -05:00
eded5a2fc8 fix: pixel perfect status badge 2025-05-12 14:33:06 -05:00
8428fc6cf6 fix: adds status badges 2025-05-12 14:17:22 -05:00
888a030680 fix: broken download, added to queue alert, download list component; feat: monitor list 2025-05-12 11:23:03 -05:00
a628d85ef2 fix: broken LDAP 2025-05-11 18:33:55 -05:00
afb62645f6 fix: ignores platform reqs on composer install 2025-05-11 16:43:28 -05:00
47 changed files with 699 additions and 249 deletions

View File

@@ -1,13 +1,41 @@
# Caldwell Digital - Symfony Template
Get up and running quickly with this Symfony framework template!
# Torsearch
Torsearch is a "media acquisition tool" that works strictly with Real Debrid. Torsearch makes it easy to search for
and download your favorite movies and tv shows. You can think of it like Stremio, but without the streaming. Why the
comparison to Stremio? That's because Torsearch uses the same source for media files that Stremio uses: Torrentio
(hence the name: Torsearch).
After two failed attempts at running a media server, I decided to hang up my hat and give up on my dream of a self-hosted
media server. I figured the days of torrenting were mostly over and everybody ranting & raving about their media collections
must be going to Walmart and buying up the bucket of old movies they have. That is until I stumbled across Stremio and
bookmarked it to read about after I got frustrated with whatever project I was working on at the time.
Cue the day I got frustrated enough with that project and finally looked into Stremio. At first, it seemed too good to
be true, but I was yearning for something just sketchy enough to try out. What could go wrong with handing over my card information
to an unknown organization across the pond? At the end of the day, the cost benefit analysis landed in my favor, and about
30 minutes after purchasing my Real Debrid subscription and setting up Stremio on my laptop, I was in business.
My mind was blown. I might not have the most "cultured" taste in media, but it had everything I searched for! After
watching a few movies, I noticed the "Copy Download Link" button. "What's this lil guy do?" I asked myself. Duh, it
downloads the fricking movie. And there's the 💡flashing over my head. There's gotta be a way to automate this, I told myself.
After about a month of studying Stremio's code and lots of tinkering, I figured it out. Torrentio is the magic behind
the scenes. You feed it a Real Debrid API key and an IMDB ID, and it gives you a list of results to download that media. Easy peasy.
In about an hour I had a proof of concept working. It wasn't pretty, but it wasn't supposed to be. That proof-of-concept
has blossomed into the beautiful Torsearch that I've been using nearly every day since then. The code in this repo
is a complete re-write of the proof-of-concept that started out ugly and ended up even uglier. Knowing the core functionality
required to make it work, I was able to re-write the app with some design patterns in place. It's now less ugly and quite
functional. If you dig deep enough, you'll stumble across some of that ugly code that surprisingly still works (why fix it if ain't broke?).
## Pics or didn't happen
## Features
- Search for Movies & TV Shows by their name
- Download directly to your NAS
- Monitor TV Shows for new episodes and automatically download them
- Browse popular media and click into it to view download options
## Getting Started
1. Run `source bash/get_certs.sh` to grab the wildcard certs
2. Set the docker image tag in the `bash/build.sh` file
3. Set the docker image tag in the `deploy.compose.yml` file
4. run `composer install`
1. Clone the repo
## Optional steps
1. Set phing vars
2. Update the project name in `build.xml`
3. Set a custom development hostname in `bash/vhost.conf`

View File

@@ -15,7 +15,7 @@ export default class extends Controller {
}
download() {
fetch('/download', {
fetch('/api/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -0,0 +1,42 @@
import { Controller } from '@hotwired/stimulus';
import { getComponent } from '@symfony/ux-live-component';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['download']
async initialize() {
this.component = await getComponent(this.element);
}
connect() {
// Called every time the controller is connected to the DOM
// (on page load, when it's added to the DOM, moved in the DOM, etc.)
// Here you can add event listeners on the element or target elements,
// add or remove classes, attributes, dispatch custom events, etc.
// this.fooTarget.addEventListener('click', this._fooBar)
}
downloadTargetConnected(target) {
let downloads = this.element.querySelectorAll('tbody tr');
if (downloads.length > 5) {
target.classList.add('hidden');
}
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
}
}

View File

@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project name="Caldwell Digital Symfony Template" default="build">
<project name="Torsearch" default="build">
<!-- build dev for dev envs -->
<target name="build" depends="setEnv,composer,compileAssets,migrateDb,clearCache" />
<target name="composer" description="Run composer">
<exec executable="composer">
<arg value="install" />
<arg value="--ignore-platform-reqs" />
</exec>
</target>

View File

@@ -29,7 +29,7 @@ services:
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume async -vv --time-limit=3600
command: php ./bin/console messenger:consume async -vvv --time-limit=3600
scheduler:
build: .

View File

@@ -14,6 +14,14 @@ controllersUser:
defaults:
schemes: ['https']
controllersDownload:
resource:
path: ../src/Download/Framework/Controller
namespace: App\Download\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersMonitor:
resource:
path: ../src/Monitor/Framework/Controller

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

View File

@@ -22,7 +22,7 @@ final class AlertController extends AbstractController
{
$update = new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => 1,
'title' => 'Added to queue',
'message' => 'This is a testy test!',

View File

@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Download\Framework\Repository\DownloadRepository;
use App\Tmdb\Tmdb;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -16,9 +17,10 @@ final class IndexController extends AbstractController
) {}
#[Route('/', name: 'app_index')]
public function index(): Response
public function index(Request $request): Response
{
// dd($this->getUser()->getActiveDownloads());
$request->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
return $this->render('index/index.html.twig', [
'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(),

View File

@@ -8,6 +8,7 @@ use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
@@ -63,7 +64,7 @@ final class TorrentioController extends AbstractController
}
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
@@ -75,8 +76,8 @@ final class TorrentioController extends AbstractController
$cache->delete($cacheId);
$this->hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
$request->getSession()->get('mercure_alert_topic'),
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Torrentio cache Cleared.',

View File

@@ -37,6 +37,8 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download = $this->downloadRepository->find($command->downloadId);
}
dump($download);
try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');

View File

@@ -39,8 +39,8 @@ class DownloadMediaInput implements InputInterface
$this->filename,
$this->mediaType,
$this->imdbId,
$this->userId,
$this->downloadId,
$this->userId
);
}
}

View File

@@ -1,24 +1,28 @@
<?php
namespace App\Controller;
namespace App\Download\Framework\Controller;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
class DownloadController extends AbstractController
class ApiController extends AbstractController
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly HubInterface $hub,
) {}
#[Route('/download', name: 'app_download', methods: ['POST'])]
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
public function download(
Request $request,
DownloadMediaInput $input,
): Response {
$download = $this->downloadRepository->insert(
@@ -30,13 +34,26 @@ class DownloadController extends AbstractController
$input->mediaType,
"",
);
$this->downloadRepository->getEntityManager()->persist($download);
$this->downloadRepository->getEntityManager()->flush();
$input->downloadId = $download->getId();
$input->userId = $this->getUser()->getId();
try {
$this->bus->dispatch($input->toCommand());
} catch (\Throwable $exception) {
return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderView('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => '"' . $input->title . '" added to Queue',
])
));
return $this->json(['status' => 200, 'message' => 'Added to Queue']);
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Download\Framework\Controller;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
class WebController extends AbstractController
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly HubInterface $hub,
) {}
#[Route('/downloads', name: 'app_downloads', methods: ['GET'])]
public function download(): Response {
return $this->render('downloads/index.html.twig');
}
}

View File

@@ -4,10 +4,9 @@ namespace App\Download\Framework\Repository;
use App\Download\Framework\Entity\Download;
use App\User\Framework\Entity\User;
use App\Util\Paginator;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Knp\Component\Pager\Paginator;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
@@ -17,38 +16,39 @@ class DownloadRepository extends ServiceEntityRepository
{
private ManagerRegistry $managerRegistry;
public function __construct(ManagerRegistry $registry, ManagerRegistry $managerRegistry)
private Paginator $paginator;
public function __construct(ManagerRegistry $registry, ManagerRegistry $managerRegistry, Paginator $paginator)
{
parent::__construct($registry, Download::class);
$this->managerRegistry = $managerRegistry;
$this->paginator = $paginator;
}
public function getCompletePaginated(int $pageNumber = 1, int $perPage = 10)
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10)
{
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->orderBy('d.id', 'DESC')
->setParameter('statuses', ['Complete'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
->setParameter('user', $user)
->getQuery();
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
return $this->paginator->paginate($query, $pageNumber, $perPage);
}
public function getActivePaginated(int $pageNumber = 1, int $perPage = 5)
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5)
{
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->orderBy('d.id', 'ASC')
->setParameter('statuses', ['New', 'In Progress'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
->setParameter('user', $user)
->getQuery();
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
return $this->paginator->paginate($query, $pageNumber, $perPage);
}
public function insert(

View File

@@ -33,7 +33,7 @@ class ApiController extends AbstractController
$hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => "New monitor added for {$input->title}",

View File

@@ -3,41 +3,34 @@
namespace App\Monitor\Framework\Repository;
use App\Monitor\Framework\Entity\Monitor;
use App\Util\Paginator;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @extends ServiceEntityRepository<Monitor>
*/
class MonitorRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
private Paginator $paginator;
public function __construct(ManagerRegistry $registry, Paginator $paginator)
{
parent::__construct($registry, Monitor::class);
$this->paginator = $paginator;
}
// /**
// * @return MovieMonitor[] Returns an array of MovieMonitor objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('m.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
public function getUserMonitorsPaginated(UserInterface $user, int $page, int $perPage)
{
$query = $this->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)')
->andWhere('m.user = :user')
->orderBy('m.id', 'ASC')
->setParameter('statuses', ['Active', 'New', 'In Progress', 'Complete'])
->setParameter('user', $user)
->getQuery();
// public function findOneBySomeField($value): ?MovieMonitor
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
return $this->paginator->paginate($query, $page, $perPage);
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Twig\Components;
use App\Download\Framework\Repository\DownloadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class ActiveDownloadList extends AbstractController
{
use DefaultActionTrait;
public function __construct(
private DownloadRepository $downloadRepository,
) {}
#[LiveAction]
public function getDownloads()
{
return $this->getUser()->getActiveDownloads();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Twig\Components;
use App\Download\Framework\Repository\DownloadRepository;
use App\Util\Paginator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class DownloadList extends AbstractController
{
use DefaultActionTrait;
#[LiveProp(writable: true)]
public string $type;
#[LiveProp(writable: true)]
public int $pageNumber = 1;
#[LiveProp(writable: true)]
public int $perPage = 5;
#[LiveProp(writable: true)]
public bool $isWidget = true;
public function __construct(
private readonly DownloadRepository $downloadRepository,
) {}
public function getDownloads()
{
if ($this->type === "active") {
return $this->downloadRepository->getActivePaginated($this->getUser(), $this->pageNumber, $this->perPage);
} elseif ($this->type === "complete") {
return $this->downloadRepository->getCompletePaginated($this->getUser(), $this->pageNumber, $this->perPage);
}
return [];
}
#[LiveAction]
public function paginate(#[LiveArg] int $page)
{
$this->pageNumber = $page;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Twig\Components;
use App\Monitor\Framework\Repository\MonitorRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class MonitorList extends AbstractController
{
public bool $isWidget = true;
public function __construct(
private readonly MonitorRepository $monitorRepository,
) {}
#[LiveAction]
public function getUserMonitors(#[LiveArg] int $page = 1, #[LiveArg] int $perPage = 5)
{
return $this->monitorRepository->getUserMonitorsPaginated($this->getUser(), $page, $perPage);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class StatusBadge
{
}

View File

@@ -15,7 +15,10 @@ use App\Util\CountryLanguages;
use App\Util\ProviderList;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
class PreferencesController extends AbstractController
@@ -24,6 +27,7 @@ class PreferencesController extends AbstractController
private readonly PreferencesRepository $preferencesRepository,
private readonly SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
private readonly Security $security,
private readonly HubInterface $hub,
) {}
#[Route('/media/preferences', 'app_media_preferences', methods: ['GET'])]
public function mediaPreferences(): Response
@@ -54,6 +58,7 @@ class PreferencesController extends AbstractController
#[Route('/media/preferences', 'app_save_media_preferences', methods: ['POST'])]
public function saveMediaPreferences(
Request $request,
SaveUserMediaPreferencesInput $input,
): Response
{
@@ -63,6 +68,15 @@ class PreferencesController extends AbstractController
$languages = CountryLanguages::$languages;
sort($languages);
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderView('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Your media preferences have been saved.',
])
));
return $this->render(
'user/preferences.html.twig',
[

View File

@@ -48,7 +48,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* @var Collection<int, Monitor>
*/
#[ORM\OneToMany(targetEntity: Monitor::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $yes;
private Collection $monitors;
/**
* @var Collection<int, Download>
@@ -59,7 +59,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
$this->userPreferences = new ArrayCollection();
$this->yes = new ArrayCollection();
$this->monitors = new ArrayCollection();
$this->downloads = new ArrayCollection();
}
@@ -226,27 +226,27 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/**
* @return Collection<int, Monitor>
*/
public function getYes(): Collection
public function getMonitors(): Collection
{
return $this->yes;
return $this->monitors;
}
public function addYe(Monitor $ye): static
public function addMonitor(Monitor $monitor): static
{
if (!$this->yes->contains($ye)) {
$this->yes->add($ye);
$ye->setUser($this);
if (!$this->monitors->contains($monitor)) {
$this->monitors->add($monitor);
$monitor->setUser($this);
}
return $this;
}
public function removeYe(Monitor $ye): static
public function removeMonitor(Monitor $monitor): static
{
if ($this->yes->removeElement($ye)) {
if ($this->monitors->removeElement($monitor)) {
// set the owning side to null (unless already changed)
if ($ye->getUser() === $this) {
$ye->setUser(null);
if ($monitor->getUser() === $this) {
$monitor->setUser(null);
}
}
@@ -302,4 +302,14 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function queryDownloads(string $type = 'complete', int $limit = 5)
{
if ($type === 'complete') {
return $this->downloads->filter(fn($item) => in_array($item->getStatus(), ['Complete']))->slice(0, $limit);
} elseif ($type === 'in-progress') {
return $this->downloads->filter(fn($item) => in_array($item->getStatus(), ['New', 'In Progress']))->slice(0, $limit);
}
return [];
}
}

View File

@@ -156,7 +156,7 @@ class LdapUserProvider implements UserProviderInterface, PasswordUpgraderInterfa
$extraFields[$field] = $this->getAttributeValue($entry, $field);
}
$dbUser = $this->getDbUser($identifier);
$dbUser = $this->getDbUser($identifier, $entry);
if (null === $dbUser) {
$dbUser = new User();
@@ -164,9 +164,9 @@ class LdapUserProvider implements UserProviderInterface, PasswordUpgraderInterfa
}
$dbUser
->setName($entry->getAttribute($this->displayNameAttribute)[0] ?? null)
->setEmail($entry->getAttribute($this->emailAttribute)[0] ?? null)
->setUsername($entry->getAttribute($this->usernameAttribute)[0] ?? null);
->setName( $this->getAttributeValue($entry, $this->displayNameAttribute)[0] ?? null)
->setEmail($this->getAttributeValue($entry, $this->emailAttribute)[0] ?? null)
->setUsername($this->getAttributeValue($entry, $this->usernameAttribute) ?? null);
$this->userRepository->getEntityManager()->persist($dbUser);
$this->userRepository->getEntityManager()->flush();
@@ -174,13 +174,22 @@ class LdapUserProvider implements UserProviderInterface, PasswordUpgraderInterfa
return $dbUser;
}
private function getDbUser(string $identifier): ?UserInterface
private function getDbUser(string $identifier, Entry $entry): ?UserInterface
{
if (in_array($this->uidKey, ['mail', 'email'])) {
return $this->userRepository->findOneBy(['email' => $identifier]);
$dbUser = $this->userRepository->findOneBy(['email' => $identifier]);
} else {
return $this->userRepository->findOneBy(['username' => $identifier]);
$dbUser = $this->userRepository->findOneBy(['username' => $identifier]);
}
// Attempt to map LDAP user to existing user
if (null === $dbUser) {
if ($entry->hasAttribute($this->emailAttribute)) {
$dbUser = $this->userRepository->findOneBy(['email' => $this->getAttributeValue($entry, $this->emailAttribute)]);;
}
}
return $dbUser;
}
private function getAttributeValue(Entry $entry, string $attribute): mixed

62
src/Util/Paginator.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
namespace App\Util;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as OrmPaginator;
class Paginator
{
/**
* @var integer
*/
private $total;
/**
* @var integer
*/
private $lastPage;
private $items;
public $currentPage = 1;
/**
* @param QueryBuilder|Query $query
* @param int $page
* @param int $limit
* @return Paginator
*/
public function paginate($query, int $page = 1, int $limit = 5): Paginator
{
$paginator = new OrmPaginator($query);
$paginator
->getQuery()
->setFirstResult($limit * ($page - 1))
->setMaxResults($limit);
$this->total = $paginator->count();
$this->lastPage = (int) ceil($paginator->count() / $paginator->getQuery()->getMaxResults());
$this->items = $paginator;
$this->currentPage = $page;
return $this;
}
public function getTotal(): int
{
return $this->total;
}
public function getLastPage(): int
{
return $this->lastPage;
}
public function getItems()
{
return $this->items;
}
}

View File

@@ -19,7 +19,7 @@
</div>
<div class="col-span-5 h-screen overflow-y-scroll">
<twig:Header />
<h2 class="px-2 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<h2 class="px-4 my-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
{% block body %}{% endblock %}
</div>
</div>

View File

@@ -1,21 +1,26 @@
{# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #}
{% block create %}
<turbo-stream action="remove" target="active_downloads_no_downloads">
</turbo-stream>
<turbo-stream action="append" target="active_downloads">
<template>
<tr id="ad_download_{{ entity.id }}">
<tr data-download-list-target="download" id="ad_download_{{ entity.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<span class="p-1.5 bg-purple-600 rounded-full">
<span class="w-4 inline-block text-center text-gray-50">{{ entity.progress }}</span>
</span>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-50">
{% if entity.progress < 100 %}
<div class="w-[3.25ch] h-[3.25ch] bg-purple-600 rounded-full block text-center table-cell align-middle text-xs text-gray-50">
{{ entity.progress }}
</div>
{% else %}
<twig:StatusBadge color="green" status="Complete" />
{% endif %}
</td>
</tr>
</template>
</turbo-stream>
<twig:Alert title="Success" message="{{ entity.title }} has been added to the Download queue" alert_id="{{ entity.id }}" data-controller="alert" />
{% endblock %}
{% block update %}
@@ -26,13 +31,16 @@
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<span class="p-1.5 bg-purple-600 rounded-full">
<span class="w-4 inline-block text-center text-gray-50">{{ entity.progress }}</span>
</span>
<div class="border-2 border-green-700 rounded-md w-full h-6 align-middle overflow-hidden">
<div class="text-green-700 rounded-sm text-bold text-gray-950 text-center bg-green-600 h-5" style="width:{{ entity.progress }}%">{{ entity.progress }}%</div>
</div>
</td>
</template>
</turbo-stream>
{% else %}
<turbo-stream action="remove" target="complete_downloads_no_downloads">
</turbo-stream>
<turbo-stream action="remove" target="ad_download_{{ id }}">
</turbo-stream>
@@ -42,16 +50,14 @@
</template>
</turbo-stream>
<turbo-stream action="prepend" target="recent_downloads">
<turbo-stream action="prepend" target="complete_downloads">
<template>
<tr id="recent_download_{{ entity.id }}">
<tr id="ad_download_{{ entity.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<span class="p-1.5 bg-purple-600 rounded-full">
<span class="w-4 inline-block text-center text-gray-50">{{ entity.progress }}</span>
</span>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-50">
<twig:StatusBadge color="green" status="Complete" />
</td>
</tr>
</template>
@@ -61,5 +67,4 @@
{% block remove %}
<turbo-stream action="remove" target="ad_download_{{ id }}"></turbo-stream>
{# <turbo-stream action="remove" target="cd_download_{{ id }}"></turbo-stream>#}
{% endblock %}

View File

@@ -1,39 +0,0 @@
<div{{ attributes }} class="min-w-48">
<table id="active_downloads" class="divide-y divide-gray-200 dark:divide-gray-50 dark:bg-gray-50 table-fixed" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
<thead>
<tr class="dark:bg-gray-50">
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 min-w-[55ch] max-w-[55ch] truncate">
Title
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
Progress
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-50">
{% if this.getDownloads()|length > 0 %}
{% for download in this.getDownloads() %}
<tr id="ad_download_{{ download.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ download.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<span class="p-1.5 bg-purple-600 rounded-full">
<span class="w-4 inline-block text-center text-gray-50">{{ download.progress }}</span>
</span>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium text-gray-800 dark:text-stone-800" colspan="2">
No active downloads
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>

View File

@@ -1,9 +1,9 @@
<div{{ attributes }}>
<div class="flex flex-col bg-white border border-gray-200 border-t-4 border-t-orange-500 shadow-2xs rounded-xl dark:bg-sky-950 dark:border-neutral-700 dark:border-t-orange-500 dark:shadow-neutral-700/70
dark:backdrop-filter dark:backdrop-blur-md dark:bg-opacity-40
<div class="flex flex-col bg-sky-950 border-neutral-700 border-t-4 border-t-orange-500 rounded-xl
backdrop-filter backdrop-blur-md bg-opacity-40
">
<div class="p-4 md:p-5">
<h3 class="mb-4 text-lg font-bold text-gray-800 dark:text-white">
<h3 class="mb-4 text-lg font-bold text-white">
{{ title }}
</h3>

View File

@@ -0,0 +1,83 @@
<div{{ attributes.defaults(stimulus_controller('download_list')) }} class="min-w-48" >
{% set table_body_id = (type == "complete") ? "complete_downloads" : "active_downloads" %}
<table id="downloads" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
<thead>
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 {% if this.isWidget == true %}min-w-[45ch] max-w-[45ch]{% endif %} truncate">
Title
</th>
{% if this.isWidget == false %}
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate">
Filename
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate">
Media type
</th>
{% endif %}
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
Progress
</th>
</tr>
</thead>
<tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50">
{% if this.downloads.items|length > 0 %}
{% for download in this.downloads.items %}
<tr id="ad_download_{{ download.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 {% if this.isWidget == true %}min-w-[45ch] max-w-[45ch]{% endif %} truncate">
{{ download.title }}
</td>
{% if this.isWidget == false %}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.filename }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.mediaType }}
</td>
{% endif %}
<td class="px-6 py-4 whitespace-nowrap text-sm align-middle text-gray-800 dark:text-gray-50">
{% if download.progress < 100 %}
<div class="border-2 border-green-700 rounded-md w-full h-6 align-middle overflow-hidden">
<div class="text-green-700 rounded-sm text-bold text-gray-950 text-center bg-green-600 h-5" style="width:{{ download.progress }}%">{{ download.progress }}%</div>
</div>
{# <div class="w-[3.25ch] h-[3.25ch] bg-purple-600 rounded-full block text-center table-cell align-middle text-xs text-gray-50">#}
{# {{ download.progress }}#}
{# </div>#}
{% else %}
<twig:StatusBadge color="green" status="Completed" />
{% endif %}
</td>
</tr>
{% endfor %}
{% if this.isWidget == true and this.downloads.items|length > this.perPage %}
<tr id="download_view_all">
<td class="py-2 whitespace-nowrap bg-orange-500 uppercase text-sm font-medium text-center text-white truncate" colspan="100%">
<a href="{{ path('app_downloads') }}">View All Downloads</a>
</td>
</tr>
{% endif %}
{% else %}
<tr id="{{ table_body_id }}_no_downloads">
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center font-medium text-gray-800 dark:text-stone-800" colspan="100%">
No downloads
</td>
</tr>
{% endif %}
</tbody>
</table>
{% if this.isWidget == false %}
{% if this.downloads.items|length > 0 %}
{% set paginator = this.downloads %}
{% include 'partial/paginator.html.twig' %}
{% endif %}
{% endif %}
</div>

View File

@@ -16,7 +16,7 @@
</div>
</div>
</div>
<div {{ turbo_stream_listen('alerts') }} class="absolute top-10 right-10">
<div {{ turbo_stream_listen(app.session.get('mercure_alert_topic')) }} class="absolute top-10 right-10">
<div >
<ul id="alert_list">
</ul>

View File

@@ -0,0 +1,79 @@
<div{{ attributes }} class="min-w-48">
<p class="text-white mb-1">The items you're currently monitoring to automatically download.</p>
<table id="downloads" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-fixed" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
<thead>
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase min-w-[45ch] max-w-[45ch] truncate">
Title
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Search Count
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Created at
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Last Search Date
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% if this.userMonitors.items|length > 0 %}
{% for monitor in this.userMonitors.items %}
<tr id="monitor_{{ monitor.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 min-w-[50ch] max-w-[50ch] truncate">
{{ monitor.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.searchCount }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.createdAt|date('m/d/Y h:i a') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.lastSearch|date('m/d/Y h:i a') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{% if monitor.status == "New" %}
<twig:StatusBadge color="orange" status="{{ monitor.status }}" />
{% elseif monitor.status == "In Progress" or monitor.status == "Active" %}
<twig:StatusBadge color="purple" status="{{ monitor.status }}" />
{% else %}
<twig:StatusBadge color="green" status="{{ monitor.status }}" />
{% endif %}
</td>
</tr>
{% endfor %}
{% if this.userMonitors.items|length > 5 %}
<tr id="monitor_view_all">
<td colspan="5" class="py-2 whitespace-nowrap bg-orange-500 uppercase text-sm font-medium text-center text-white min-w-[50ch] max-w-[50ch] truncate">
<a href="#">View All Monitors</a>
</td>
</tr>
{% endif %}
{% else %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium text-stone-800" colspan="2">
No monitors
</td>
</tr>
{% endif %}
</tbody>
</table>
{% if this.isWidget == false %}
{% if this.userMonitors.items|length > 0 %}
{% set paginator = this.userMonitors %}
{% include 'partial/paginator.html.twig' %}
{% endif %}
{% endif %}
</div>

View File

@@ -12,40 +12,12 @@
</li>
<li>
<details class="group [&_summary::-webkit-details-marker]:hidden">
<summary class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 text-gray-50 hover:bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60">
<span class="text-sm font-medium">Downloads</span>
<span class="shrink-0 transition duration-300 group-open:-rotate-180">
<svg
xmlns="http://www.w3.org/2000/svg"
class="size-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</span>
</summary>
<ul class="mt-2 space-y-1 px-4">
<li>
<a href="{{ path('app_search') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">
In Progress
</a>
</li>
<li>
<a href="{{ path('app_search') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">
Complete
</a>
</li>
</ul>
</details>
<a href="{{ path('app_downloads') }}"
class="block rounded-lg
bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60
px-4 py-2 text-sm font-medium text-gray-50">
Downloads
</a>
</li>
<li>

View File

@@ -13,13 +13,12 @@
<button
class="absolute top-1 right-1 flex items-center rounded
bg-green-600 py-1 px-2.5 border border-transparent text-center
text-sm text-white transition-all shadow-sm hover:shadow
focus:bg-green-700 focus:shadow-none active:bg-green-700
hover:bg-green-700 active:shadow-none disabled:pointer-events-none
disabled:opacity-50 disabled:shadow-none
text-sm text-white transition-all
focus:bg-green-700 active:bg-green-700 hover:bg-green-700
text-white bg-green-800 bg-opacity-60 text-sm
hover:bg-green-900 border border-green-500
text-white bg-green-600 text-sm
border border-green-500
backdrop-filter backdrop-blur-md bg-opacity-80
"
type="submit"
>

View File

@@ -0,0 +1,3 @@
<span {{ attributes }} class="py-[3px] px-[7px] bg-{{ color|default('green') }}-600 rounded-lg inline-block text-center text-xs text-white">
{{ status }}
</span>

View File

@@ -0,0 +1,18 @@
{% extends 'base.html.twig' %}
{% block title %}Downloads &mdash; Torsearch{% endblock %}
{% block h2 %}Downloads{% endblock %}
{% block body %}
<div class="p-4">
<twig:Card title="Active Downloads">
<twig:DownloadList type="active" :isWidget="false" :perPage="10"></twig:DownloadList>
</twig:Card>
</div>
<div class="p-4">
<twig:Card title="Recent Downloads">
<twig:DownloadList type="complete" :isWidget="false" :perPage="10"></twig:DownloadList>
</twig:Card>
</div>
{% endblock %}

View File

@@ -7,51 +7,16 @@
<h2 class="mb-2 text-3xl font-bold text-gray-50">Dashboard</h2>
<div class="flex flex-row gap-4">
<twig:Card title="Active Downloads" class="w-full">
<twig:ActiveDownloadList />
<twig:DownloadList :type="'active'" />
</twig:Card>
<twig:Card title="Recent Downloads" class="w-full">
<table id="recent_downloads" class="divide-y divide-gray-200 dark:divide-gray-50 dark:bg-gray-50 table-fixed">
<thead>
<tr class="dark:bg-gray-50">
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 rounded-tl-md">
Title
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-50">
{% if recent_downloads|length > 0 %}
{% for download in recent_downloads %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800">
{{ download.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<span class="p-1 bg-green-600 rounded-lg">
<span class="text-gray-50">Complete</span>
</span>
</td>
</tr>
{% endfor %}
<tr class="bg-blue-400">
<td class="px-6 py-3 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium text-rose-400 dark:text-stone-800" colspan="2">
<a href="#">View all downloads</a>
</td>
</tr>
{% else %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium text-gray-800 dark:text-stone-800" colspan="2">
No recent downloads
</td>
</tr>
{% endif %}
</tbody>
</table>
<twig:DownloadList :type="'complete'" />
</twig:Card>
</div>
<div class="flex flex-row gap-4">
<twig:Card title="Monitors" class="w-full">
<twig:MonitorList />
</twig:Card>
</div>
<div class="flex flex-col gap-4">

View File

@@ -0,0 +1,5 @@
{# The status indicator for the Alert button. Rendering this will add a small colored span
to the Alert button, indicating there are unread alerts.
#}
<span style="position: absolute;width: 8px;height: 8px;background: greenyellow;border-radius: 5px; z-index: 1000;margin-left: 3px;margin-top: -5px;"></span>

View File

@@ -0,0 +1,83 @@
{% set _currentPage = paginator.currentPage ?: 1 %}
{% set _lastPage = paginator.lastPage %}
{% set _showingPerPage = (_currentPage == _lastPage) ? paginator.total - (this.perPage * (_lastPage - 1)) : paginator.items.query.maxResults %}
<p class="text-white mt-1">Showing {{ _showingPerPage }} of {{ paginator.total }} total results</p>
{% if paginator.lastPage > 1 %}
<nav>
<ul class="mt-2 flex flex-row justify-content-center gap-1 py-1 text-white text-sm">
<li class="page-item{{ _currentPage <= 1 ? ' disabled' : '' }}">
<a {% if _currentPage > 1 %}
data-action="click->live#action"
data-live-action-param="paginate"
data-live-page-param="{{ _currentPage - 1 }}"
{% endif %}
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Previous"
href="#"
>
&laquo;
</a>
</li>
{% set startPage = max(1, _currentPage - 2) %}
{% set endPage = min(_lastPage, startPage + 4) %}
{% if startPage > 1 %}
<li class="page-item">
<a data-action="click->live#action"
data-live-action-param="paginate"
data-live-page-param="{{ "1"|number_format }}"
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Next"
href="#"
>1</a>
</li>
{% if startPage > 2 %}
<li class="page-item disabled">
<span class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle">...</span>
</li>
{% endif %}
{% endif %}
{% for i in startPage..endPage %}
<li class="page-item}">
<a data-action="click->live#action"
data-live-action-param="paginate"
data-live-page-param="{{ i|number_format }}"
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 text-white align-middle"
{% if i == _currentPage %}style="background-color: #fff; color: darkorange; border: 2px solid darkorange;"{% endif %}
href="#"
>{{ i }}</a>
</li>
{% endfor %}
{% if endPage < _lastPage %}
{% if endPage < _lastPage - 1 %}
<li class="page-item disabled">
<span class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle">...</span>
</li>
{% endif %}
<li class="page-item">
<a data-action="click->live#action"
data-live-action-param="paginate"
data-live-page-param="{{ _lastPage }}"
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Next"
href="#"
>{{ _lastPage }}</a>
</li>
{% endif %}
<li class="page-item {{ _currentPage >= paginator.lastPage ? ' disabled' : '' }}">
<a {% if _currentPage < _lastPage %}
data-action="click->live#action"
data-live-action-param="paginate"
data-live-page-param="{{ _currentPage + 1 }}"
{% endif %}
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Next"
href="#"
>
&raquo;
</a>
</li>
</ul>
</nav>
{% endif %}