Compare commits

..

24 Commits

Author SHA1 Message Date
46deba2982 wip: active downloads in header 2025-05-14 16:18:33 -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
8aba35fee1 fix: scopes downloads and monitors to users 2025-05-11 16:27:53 -05:00
6817bd8c80 wip: scopes downloads to usrs 2025-05-11 00:12:55 -05:00
854177a121 feat: command to set auth method 2025-05-10 23:53:46 -05:00
ddb71b3bb0 chore: cleaning 2025-05-10 20:05:57 -05:00
35a3e48ac9 fix: mostly working ldap 2025-05-10 20:03:17 -05:00
6e55195e6f wip-feat: authenticates with LDAP 2025-05-10 08:48:12 -05:00
e325687af5 chore: style updates 2025-05-09 21:30:43 -05:00
65 changed files with 1551 additions and 308 deletions

View File

@@ -10,3 +10,22 @@ MERCURE_JWT_SECRET="%%mercure_jwt_secret%%"
JELLYFIN_URL=%%jellyfin_url%% JELLYFIN_URL=%%jellyfin_url%%
JELLYFIN_TOKEN=%%jellyfin_token%% JELLYFIN_TOKEN=%%jellyfin_token%%
REDIS_HOST="%%redis_host%%" REDIS_HOST="%%redis_host%%"
LDAP_HOST=
LDAP_PORT=
LDAP_ENCRYPTION=
LDAP_BASE_DN=
LDAP_BIND_USER=
LDAP_BIND_PASS=
LDAP_DN_STRING=
LDAP_UID_KEY="uid"
# LDAP group that identifies an Admin
# Users with this LDAP group will automatically
# get the admin role in this system.
LDAP_ADMIN_ROLE_DN="cn=admins,cn=groups,cn=accounts,dc=caldwell,dc=local"
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_USERNAME_ATTRIBUTE=uid
LDAP_NAME_ATTRIBUTE=displayname

View File

@@ -1,4 +1,10 @@
FROM registry.caldwell.digital/library/php:8.4-apache FROM registry.caldwell.digital/library/php:8.4-apache
RUN apt-get update && \
apt-get install libldap2-dev -y && \
rm -rf /var/lib/apt/lists/* && \
docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ && \
docker-php-ext-install ldap
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf RUN rm /etc/apache2/sites-enabled/000-default.conf

View File

@@ -1,5 +1,11 @@
FROM registry.caldwell.digital/library/php:8.4-apache FROM registry.caldwell.digital/library/php:8.4-apache
RUN apt-get update && \
apt-get install libldap2-dev -y && \
rm -rf /var/lib/apt/lists/* && \
docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ && \
docker-php-ext-install ldap
COPY --chown=www-data:www-data . /var/www COPY --chown=www-data:www-data . /var/www
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf RUN rm /etc/apache2/sites-enabled/000-default.conf

View File

@@ -15,7 +15,7 @@ export default class extends Controller {
} }
download() { download() {
fetch('/download', { fetch('/api/download', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -26,7 +26,7 @@ export default class extends Controller {
title: this.element.dataset['title'], title: this.element.dataset['title'],
filename: this.filenameValue, filename: this.filenameValue,
mediaType: this.mediaTypeValue, mediaType: this.mediaTypeValue,
imdbId: this.imdbIdValue imdbId: this.imdbIdValue,
}) })
}) })
.then(res => res.json()) .then(res => res.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

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5l-4 5l-4-5m9 8h.01"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?> <?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 --> <!-- build dev for dev envs -->
<target name="build" depends="setEnv,composer,compileAssets,migrateDb,clearCache" /> <target name="build" depends="setEnv,composer,compileAssets,migrateDb,clearCache" />
<target name="composer" description="Run composer"> <target name="composer" description="Run composer">
<exec executable="composer"> <exec executable="composer">
<arg value="install" /> <arg value="install" />
<arg value="--ignore-platform-reqs" />
</exec> </exec>
</target> </target>

View File

@@ -29,7 +29,7 @@ services:
volumes: volumes:
- ./:/var/www - ./:/var/www
- ./var/download:/var/download - ./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: scheduler:
build: . build: .

View File

@@ -29,6 +29,7 @@
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.2.*", "symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*", "symfony/framework-bundle": "7.2.*",
"symfony/ldap": "7.2.*",
"symfony/mercure-bundle": "^0.3.9", "symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*", "symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*", "symfony/runtime": "7.2.*",

77
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "7e29123297e1ac72cd417967d2a761b4", "content-hash": "c179718ee29dbe018b93ea7d46764931",
"packages": [ "packages": [
{ {
"name": "1tomany/rich-bundle", "name": "1tomany/rich-bundle",
@@ -5082,6 +5082,81 @@
], ],
"time": "2025-05-02T09:04:03+00:00" "time": "2025-05-02T09:04:03+00:00"
}, },
{
"name": "symfony/ldap",
"version": "v7.2.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ldap.git",
"reference": "48013cfa9d394343162dae7da914112a6206b575"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ldap/zipball/48013cfa9d394343162dae7da914112a6206b575",
"reference": "48013cfa9d394343162dae7da914112a6206b575",
"shasum": ""
},
"require": {
"ext-ldap": "*",
"php": ">=8.2",
"symfony/options-resolver": "^6.4|^7.0"
},
"conflict": {
"symfony/options-resolver": "<6.4",
"symfony/security-core": "<6.4"
},
"require-dev": {
"symfony/security-core": "^6.4|^7.0",
"symfony/security-http": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Ldap\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Charles Sarrazin",
"email": "charles@sarraz.in"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a LDAP client for PHP on top of PHP's ldap extension",
"homepage": "https://symfony.com",
"keywords": [
"active-directory",
"ldap"
],
"support": {
"source": "https://github.com/symfony/ldap/tree/v7.2.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-11-25T15:21:05+00:00"
},
{ {
"name": "symfony/mercure", "name": "symfony/mercure",
"version": "v0.6.5", "version": "v0.6.5",

56
config/dist/ldap.security.yaml vendored Normal file
View File

@@ -0,0 +1,56 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_ldap
form_login_ldap:
login_path: app_login
check_path: app_login
enable_csrf: true
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(LDAP_DN_STRING)%'
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

54
config/dist/local.security.yaml vendored Normal file
View File

@@ -0,0 +1,54 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_local
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -5,26 +5,29 @@ security:
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers: providers:
users_in_memory: { memory: null } users_in_memory: { memory: null }
app_user_provider: app_local:
entity: entity:
class: App\User\Framework\Entity\User class: App\User\Framework\Entity\User
property: email property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls: firewalls:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
main: main:
lazy: true lazy: true
provider: app_user_provider provider: app_ldap
form_login: form_login_ldap:
login_path: app_login login_path: app_login
check_path: app_login check_path: app_login
enable_csrf: true enable_csrf: true
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(LDAP_DN_STRING)%'
logout: logout:
path: app_logout path: app_logout
# where to redirect after logout
# target: app_any_route
# activate different ways to authenticate # activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall # https://symfony.com/doc/current/security.html#the-firewall

View File

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

61
config/security.yaml Normal file
View File

@@ -0,0 +1,61 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_ldap
entry_point: form_login_ldap
form_login_ldap:
login_path: app_login
check_path: app_login
enable_csrf: true
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(LDAP_DN_STRING)%'
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -28,6 +28,37 @@ services:
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader" App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader"
# Session
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments: arguments:
- '%env(DATABASE_URL)%' - '%env(DATABASE_URL)%'
# LDAP
App\User\Framework\Security\LdapUserProvider:
arguments:
$userRepository: '@App\User\Framework\Repository\UserRepository'
$ldap: '@Symfony\Component\Ldap\LdapInterface'
$baseDn: '%env(LDAP_BASE_DN)%'
$searchDn: '%env(LDAP_BIND_USER)%'
$searchPassword: '%env(LDAP_BIND_PASS)%'
$defaultRoles: ['ROLE_USER']
$uidKey: '%env(LDAP_UID_KEY)%'
# $passwordAttribute: '%env(LDAP_PASSWORD_ATTRIBUTE)%'
Symfony\Component\Ldap\LdapInterface: '@Symfony\Component\Ldap\Ldap'
Symfony\Component\Ldap\Ldap:
arguments: [ '@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter' ]
tags:
- ldap
Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
arguments:
- host: '%env(LDAP_HOST)%'
port: '%env(LDAP_PORT)%'
encryption: '%env(LDAP_ENCRYPTION)%'
options:
protocol_version: 3
referrals: false

View File

@@ -0,0 +1,35 @@
<?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 Version20250510185814 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(<<<'SQL'
ALTER TABLE user ADD username VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user DROP username
SQL);
}
}

View File

@@ -0,0 +1,47 @@
<?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 Version20250511050008 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(<<<'SQL'
ALTER TABLE download ADD user_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download ADD CONSTRAINT FK_781A8270A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_781A8270A76ED395 ON download (user_id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE download DROP FOREIGN KEY FK_781A8270A76ED395
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_781A8270A76ED395 ON download
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download DROP user_id
SQL);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'config:set',
description: 'Add a short description for your command',
)]
class ConfigSetCommand extends Command
{
public function __construct()
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('key', InputArgument::REQUIRED, 'Config key')
->addArgument('value', InputArgument::REQUIRED, 'Config value')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$key = $input->getArgument('key');
$handlers = [
'auth.method' => 'setAuthMethod',
];
$handler = $handlers[$key];
$this->$handler($input, $io);
$io->success('Success: "' . $input->getArgument('key') . '" set to "' . $input->getArgument('value') . '"');
return Command::SUCCESS;
}
private function setAuthMethod(InputInterface $input, SymfonyStyle $io)
{
$config = [
'local' => 'config/dist/local.security.yaml',
'ldap' => 'config/dist/ldap.security.yaml',
];
$authMethod = $input->getArgument('value');
$io->text('> Setting auth method to: ' . $authMethod);
copy($config[$authMethod], 'config/packages/security.yaml');
}
}

View File

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

View File

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

View File

@@ -34,24 +34,18 @@ final class SearchController extends AbstractController
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')] #[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')]
public function result( public function result(
GetMediaInfoInput $input, GetMediaInfoInput $input,
CacheInterface $cache
): Response { ): Response {
$cacheId = sprintf("page.%s.%s", $input->mediaType, $input->tmdbId); $result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) { 'results' => $result,
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); 'filter' => [
$result = $this->getMediaInfoHandler->handle($input->toCommand()); 'resolution' => '',
return $this->render('search/result.html.twig', [ 'codec' => '',
'results' => $result, 'provider' => '',
'filter' => [ 'language' => '',
'resolution' => '', 'season' => 1,
'codec' => '', 'episode' => ''
'provider' => '', ]
'language' => '', ]);
'season' => 1,
'episode' => ''
]
]);
// });
} }
} }

View File

@@ -8,6 +8,7 @@ use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput; use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use Carbon\Carbon; use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update; 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')] #[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( $cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s", "page.torrentio.tvshows.%s.%s.%s.%s",
@@ -75,8 +76,8 @@ final class TorrentioController extends AbstractController
$cache->delete($cacheId); $cache->delete($cacheId);
$this->hub->publish(new Update( $this->hub->publish(new Update(
'alerts', $request->getSession()->get('mercure_alert_topic'),
$this->renderer->render('broadcast/Alert.html.twig', [ $this->renderer->render('Alert.stream.html.twig', [
'alert_id' => uniqid(), 'alert_id' => uniqid(),
'title' => 'Success', 'title' => 'Success',
'message' => 'Torrentio cache Cleared.', 'message' => 'Torrentio cache Cleared.',

View File

@@ -15,6 +15,7 @@ class DownloadMediaCommand implements CommandInterface
public string $filename, public string $filename,
public string $mediaType, public string $mediaType,
public string $imdbId, public string $imdbId,
public int $userId,
public ?int $downloadId = null, public ?int $downloadId = null,
) {} ) {}
} }

View File

@@ -6,6 +6,7 @@ use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Result\DownloadMediaResult; use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface; use App\Download\Downloader\DownloaderInterface;
use App\User\Framework\Repository\UserRepository;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface; use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
@@ -17,12 +18,14 @@ readonly class DownloadMediaHandler implements HandlerInterface
public function __construct( public function __construct(
private DownloaderInterface $downloader, private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository, private DownloadRepository $downloadRepository,
private UserRepository $userRepository,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
if (null === $command->downloadId) { if (null === $command->downloadId) {
$download = $this->downloadRepository->insert( $download = $this->downloadRepository->insert(
$this->userRepository->find($command->userId),
$command->url, $command->url,
$command->title, $command->title,
$command->filename, $command->filename,
@@ -34,6 +37,7 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download = $this->downloadRepository->find($command->downloadId); $download = $this->downloadRepository->find($command->downloadId);
} }
dump($download);
try { try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress'); $this->downloadRepository->updateStatus($download->getId(), 'In Progress');

View File

@@ -26,6 +26,8 @@ class DownloadMediaInput implements InputInterface
#[SourceRequest('imdbId')] #[SourceRequest('imdbId')]
public string $imdbId, public string $imdbId,
public ?int $userId = null,
public ?int $downloadId = null, public ?int $downloadId = null,
) {} ) {}
@@ -37,6 +39,7 @@ class DownloadMediaInput implements InputInterface
$this->filename, $this->filename,
$this->mediaType, $this->mediaType,
$this->imdbId, $this->imdbId,
$this->userId,
$this->downloadId, $this->downloadId,
); );
} }

View File

@@ -1,26 +1,32 @@
<?php <?php
namespace App\Controller; namespace App\Download\Framework\Controller;
use App\Download\Action\Input\DownloadMediaInput; use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
class DownloadController extends AbstractController class ApiController extends AbstractController
{ {
public function __construct( public function __construct(
private DownloadRepository $downloadRepository, private DownloadRepository $downloadRepository,
private MessageBusInterface $bus, 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( public function download(
Request $request,
DownloadMediaInput $input, DownloadMediaInput $input,
): Response { ): Response {
$download = $this->downloadRepository->insert( $download = $this->downloadRepository->insert(
$this->getUser(),
$input->url, $input->url,
$input->title, $input->title,
$input->filename, $input->filename,
@@ -28,13 +34,26 @@ class DownloadController extends AbstractController
$input->mediaType, $input->mediaType,
"", "",
); );
$this->downloadRepository->getEntityManager()->persist($download);
$this->downloadRepository->getEntityManager()->flush();
$input->downloadId = $download->getId(); $input->downloadId = $download->getId();
$input->userId = $this->getUser()->getId();
try { try {
$this->bus->dispatch($input->toCommand()); $this->bus->dispatch($input->toCommand());
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); 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']); 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

@@ -3,6 +3,7 @@
namespace App\Download\Framework\Entity; namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast; use Symfony\UX\Turbo\Attribute\Broadcast;
@@ -39,6 +40,9 @@ class Download
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
private ?string $batchId = null; private ?string $batchId = null;
#[ORM\ManyToOne(inversedBy: 'downloads')]
private ?User $user = null;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -146,4 +150,16 @@ class Download
return $this; return $this;
} }
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
} }

View File

@@ -3,11 +3,11 @@
namespace App\Download\Framework\Repository; namespace App\Download\Framework\Repository;
use App\Download\Framework\Entity\Download; use App\Download\Framework\Entity\Download;
use App\ValueObject\DownloadRequest; use App\User\Framework\Entity\User;
use App\Util\Paginator;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Knp\Component\Pager\Paginator; use Symfony\Component\Security\Core\User\UserInterface;
use Knp\Component\Pager\PaginatorInterface;
/** /**
* @extends ServiceEntityRepository<Download> * @extends ServiceEntityRepository<Download>
@@ -16,41 +16,43 @@ class DownloadRepository extends ServiceEntityRepository
{ {
private ManagerRegistry $managerRegistry; 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); parent::__construct($registry, Download::class);
$this->managerRegistry = $managerRegistry; $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') $query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)') ->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->orderBy('d.id', 'DESC') ->orderBy('d.id', 'DESC')
->setParameter('statuses', ['Complete']) ->setParameter('statuses', ['Complete'])
->setFirstResult($firstResult) ->setParameter('user', $user)
->setMaxResults($perPage)
->getQuery(); ->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') $query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)') ->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->orderBy('d.id', 'ASC') ->orderBy('d.id', 'ASC')
->setParameter('statuses', ['New', 'In Progress']) ->setParameter('statuses', ['New', 'In Progress'])
->setFirstResult($firstResult) ->setParameter('user', $user)
->setMaxResults($perPage)
->getQuery(); ->getQuery();
return new \Doctrine\ORM\Tools\Pagination\Paginator($query); return $this->paginator->paginate($query, $pageNumber, $perPage);
} }
public function insert( public function insert(
UserInterface $user,
string $url, string $url,
string $title, string $title,
string $filename, string $filename,
@@ -59,7 +61,9 @@ class DownloadRepository extends ServiceEntityRepository
string $batchId, string $batchId,
string $status = 'New' string $status = 'New'
): Download { ): Download {
/** @var User $user */
$download = (new Download()) $download = (new Download())
->setUser($user)
->setUrl($url) ->setUrl($url)
->setTitle($title) ->setTitle($title)
->setFilename($filename) ->setFilename($filename)
@@ -75,22 +79,6 @@ class DownloadRepository extends ServiceEntityRepository
return $download; return $download;
} }
public function insertFromDownloadRequest(DownloadRequest $request): Download
{
$download = (new Download())
->setUrl($request->downloadUrl)
->setTitle($request->seriesName)
->setFilename($request->filename)
->setImdbId($request->imdbCode)
->setMediaType($request->mediaType)
->setStatus('New');
$this->getEntityManager()->persist($download);
$this->getEntityManager()->flush();
return $download;
}
public function updateStatus(int $id, string $status): Download public function updateStatus(int $id, string $status): Download
{ {
$download = $this->find($id); $download = $this->find($id);
@@ -106,18 +94,6 @@ class DownloadRepository extends ServiceEntityRepository
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
} }
public function getPendingByBatchId(string $batchId): ?array
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.batchId = :batchId')
->setParameter('statuses', ['New', 'In Progress'])
->setParameter('batchId', $batchId)
->getQuery();
return $query->getResult();
}
public function latest(int $limit = 1) public function latest(int $limit = 1)
{ {
return $this->createQueryBuilder('d') return $this->createQueryBuilder('d')

View File

@@ -7,7 +7,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
class AddMonitorCommand implements CommandInterface class AddMonitorCommand implements CommandInterface
{ {
public function __construct( public function __construct(
public string $userEmail, public string $userId,
public string $title, public string $title,
public string $imdbId, public string $imdbId,
public string $tmdbId, public string $tmdbId,

View File

@@ -22,7 +22,7 @@ readonly class AddMonitorHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$user = $this->userRepository->findOneBy(['email' => $command->userEmail]); $user = $this->userRepository->find($command->userId);
$monitor = (new Monitor()) $monitor = (new Monitor())
->setUser($user) ->setUser($user)
->setTmdbId($command->tmdbId) ->setTmdbId($command->tmdbId)

View File

@@ -2,9 +2,10 @@
namespace App\Monitor\Action\Handler; namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand; use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult; use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator; use App\Monitor\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetMovieOptionsCommand; use App\Torrentio\Action\Command\GetMovieOptionsCommand;
@@ -15,6 +16,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface; use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */ /** @implements HandlerInterface<MonitorMovieCommand> */
@@ -27,11 +29,13 @@ readonly class MonitorMovieHandler implements HandlerInterface
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private MessageBusInterface $bus, private MessageBusInterface $bus,
private LoggerInterface $logger, private LoggerInterface $logger,
private Security $security,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$this->logger->info('> [MonitorMovieHandler] Executing MonitorMovieHandler'); $this->logger->info('> [MonitorMovieHandler] Executing MonitorMovieHandler');
/** @var Monitor $monitor */
$monitor = $this->movieMonitorRepository->find($command->movieMonitorId); $monitor = $this->movieMonitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress'); $monitor->setStatus('In Progress');
@@ -51,6 +55,7 @@ readonly class MonitorMovieHandler implements HandlerInterface
$result->filename, $result->filename,
'movies', 'movies',
$monitor->getImdbId(), $monitor->getImdbId(),
$monitor->getUser()->getId(),
)); ));
$monitor->setStatus('Complete'); $monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeIMmutable()); $monitor->setDownloadedAt(new DateTimeIMmutable());
@@ -59,7 +64,7 @@ readonly class MonitorMovieHandler implements HandlerInterface
} }
$monitor->setLastSearch(new DateTimeImmutable()); $monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->incrementSearchCount();
$this->entityManager->flush(); $this->entityManager->flush();
return new MonitorMovieResult( return new MonitorMovieResult(

View File

@@ -58,6 +58,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$result->filename, $result->filename,
'tvshows', 'tvshows',
$monitor->getImdbId(), $monitor->getImdbId(),
$monitor->getUser()->getId(),
)); ));
$monitor->setStatus('Complete'); $monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeImmutable()); $monitor->setDownloadedAt(new DateTimeImmutable());

View File

@@ -12,7 +12,7 @@ class AddMonitorInput implements InputInterface
{ {
public function __construct( public function __construct(
#[SourceSecurity] #[SourceSecurity]
public string $userEmail, public int|string $userId,
#[SourceRequest('tmdbId')] #[SourceRequest('tmdbId')]
public string $tmdbId, public string $tmdbId,
@@ -36,7 +36,7 @@ class AddMonitorInput implements InputInterface
public function toCommand(): CommandInterface public function toCommand(): CommandInterface
{ {
return new AddMonitorCommand( return new AddMonitorCommand(
$this->userEmail, $this->userId,
$this->title, $this->title,
$this->imdbId, $this->imdbId,
$this->tmdbId, $this->tmdbId,

View File

@@ -5,6 +5,7 @@ namespace App\Monitor\Framework\Controller;
use App\Monitor\Action\Handler\AddMonitorHandler; use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput; use App\Monitor\Action\Input\AddMonitorInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update; use Symfony\Component\Mercure\Update;
@@ -17,6 +18,7 @@ class ApiController extends AbstractController
#[Autowire(service: 'twig')] #[Autowire(service: 'twig')]
private readonly Environment $renderer, private readonly Environment $renderer,
private readonly HubInterface $hub, private readonly HubInterface $hub,
private readonly Security $security,
) {} ) {}
#[Route('/api/monitor', name: 'api_monitor', methods: ['POST'])] #[Route('/api/monitor', name: 'api_monitor', methods: ['POST'])]
@@ -25,11 +27,13 @@ class ApiController extends AbstractController
AddMonitorHandler $handler, AddMonitorHandler $handler,
HubInterface $hub, HubInterface $hub,
) { ) {
$response = $handler->handle($input->toCommand()); $command = $input->toCommand();
$command->userId = $this->security->getUser()->getId();
$response = $handler->handle($command);
$hub->publish(new Update( $hub->publish(new Update(
'alerts', 'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [ $this->renderer->render('Alert.stream.html.twig', [
'alert_id' => uniqid(), 'alert_id' => uniqid(),
'title' => 'Success', 'title' => 'Success',
'message' => "New monitor added for {$input->title}", 'message' => "New monitor added for {$input->title}",

View File

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

View File

@@ -11,7 +11,7 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask; use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('*/10 * * * *', schedule: 'monitor')] #[AsCronTask('* * * * *', schedule: 'monitor')]
class MonitorDispatcher class MonitorDispatcher
{ {
public function __construct( public function __construct(
@@ -27,7 +27,7 @@ class MonitorDispatcher
'movie' => MonitorMovieCommand::class, 'movie' => MonitorMovieCommand::class,
'tvepisode' => MonitorTvEpisodeCommand::class, 'tvepisode' => MonitorTvEpisodeCommand::class,
'tvseason' => MonitorTvSeasonCommand::class, 'tvseason' => MonitorTvSeasonCommand::class,
'tvshow' => MonitorTvShowCommand::class, 'tvshows' => MonitorTvShowCommand::class,
]; ];
$monitors = $this->monitorRepository->findBy(['status' => ['New', 'Active']]); $monitors = $this->monitorRepository->findBy(['status' => ['New', 'Active']]);

View File

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

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

@@ -7,4 +7,8 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
final class Header final class Header
{ {
public function getActiveDownloads()
{
return [];
}
} }

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 App\Util\ProviderList;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
class PreferencesController extends AbstractController class PreferencesController extends AbstractController
@@ -24,6 +27,7 @@ class PreferencesController extends AbstractController
private readonly PreferencesRepository $preferencesRepository, private readonly PreferencesRepository $preferencesRepository,
private readonly SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler, private readonly SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
private readonly Security $security, private readonly Security $security,
private readonly HubInterface $hub,
) {} ) {}
#[Route('/media/preferences', 'app_media_preferences', methods: ['GET'])] #[Route('/media/preferences', 'app_media_preferences', methods: ['GET'])]
public function mediaPreferences(): Response public function mediaPreferences(): Response
@@ -54,6 +58,7 @@ class PreferencesController extends AbstractController
#[Route('/media/preferences', 'app_save_media_preferences', methods: ['POST'])] #[Route('/media/preferences', 'app_save_media_preferences', methods: ['POST'])]
public function saveMediaPreferences( public function saveMediaPreferences(
Request $request,
SaveUserMediaPreferencesInput $input, SaveUserMediaPreferencesInput $input,
): Response ): Response
{ {
@@ -63,6 +68,15 @@ class PreferencesController extends AbstractController
$languages = CountryLanguages::$languages; $languages = CountryLanguages::$languages;
sort($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( return $this->render(
'user/preferences.html.twig', 'user/preferences.html.twig',
[ [

View File

@@ -3,6 +3,7 @@
namespace App\User\Framework\Entity; namespace App\User\Framework\Entity;
use Aimeos\Map; use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Entity\Monitor;
use App\User\Framework\Repository\UserRepository; use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -22,6 +23,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private int $id; private int $id;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $username;
#[ORM\Column(type: 'string', length: 180, unique: true)] #[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email; private ?string $email;
@@ -44,12 +48,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* @var Collection<int, Monitor> * @var Collection<int, Monitor>
*/ */
#[ORM\OneToMany(targetEntity: Monitor::class, mappedBy: 'user', orphanRemoval: true)] #[ORM\OneToMany(targetEntity: Monitor::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $yes; private Collection $monitors;
/**
* @var Collection<int, Download>
*/
#[ORM\OneToMany(targetEntity: Download::class, mappedBy: 'user')]
private Collection $downloads;
public function __construct() public function __construct()
{ {
$this->userPreferences = new ArrayCollection(); $this->userPreferences = new ArrayCollection();
$this->yes = new ArrayCollection(); $this->monitors = new ArrayCollection();
$this->downloads = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -88,7 +99,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*/ */
public function getUserIdentifier(): string public function getUserIdentifier(): string
{ {
return (string) $this->email; return (string) $this->username ?? $this->email;
} }
/** /**
@@ -215,30 +226,90 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/** /**
* @return Collection<int, Monitor> * @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)) { if (!$this->monitors->contains($monitor)) {
$this->yes->add($ye); $this->monitors->add($monitor);
$ye->setUser($this); $monitor->setUser($this);
} }
return $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) // set the owning side to null (unless already changed)
if ($ye->getUser() === $this) { if ($monitor->getUser() === $this) {
$ye->setUser(null); $monitor->setUser(null);
} }
} }
return $this; return $this;
} }
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(?string $username): static
{
$this->username = $username;
return $this;
}
/**
* @return Collection<int, Download>
*/
public function getDownloads(): Collection
{
return $this->downloads;
}
/**
* @return Collection<int, Download>
*/
public function getActiveDownloads(): Collection
{
return $this->downloads->filter(fn(Download $download) => in_array($download->getStatus(), ['New', 'In Progress']));
}
public function addDownload(Download $download): static
{
if (!$this->downloads->contains($download)) {
$this->downloads->add($download);
$download->setUser($this);
}
return $this;
}
public function removeDownload(Download $download): static
{
if ($this->downloads->removeElement($download)) {
// set the owning side to null (unless already changed)
if ($download->getUser() === $this) {
$download->setUser(null);
}
}
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

@@ -0,0 +1,73 @@
<?php
namespace App\User\Framework\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
/**
* @see https://symfony.com/doc/current/security/custom_authenticator.html
*/
class LdapAuthenticator extends AbstractAuthenticator
{
/**
* Called on every request to decide if this authenticator should be
* used for the request. Returning `false` will cause this authenticator
* to be skipped.
*/
public function supports(Request $request): ?bool
{
// return $request->headers->has('X-AUTH-TOKEN');
}
public function authenticate(Request $request): Passport
{
// $apiToken = $request->headers->get('X-AUTH-TOKEN');
// if (null === $apiToken) {
// The token header was empty, authentication fails with HTTP Status
// Code 401 "Unauthorized"
// throw new CustomUserMessageAuthenticationException('No API token provided');
// }
// implement your own logic to get the user identifier from `$apiToken`
// e.g. by looking up a user in the database using its API key
// $userIdentifier = /** ... */;
// return new SelfValidatingPassport(new UserBadge($userIdentifier));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// on success, let the request continue
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$data = [
// you may want to customize or obfuscate the message first
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
// or to translate this message
// $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
];
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
// public function start(Request $request, ?AuthenticationException $authException = null): Response
// {
// /*
// * If you would like this class to control what happens when an anonymous user accesses a
// * protected page (e.g. redirect to /login), uncomment this method and make this class
// * implement Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface.
// *
// * For more details, see https://symfony.com/doc/current/security/experimental_authenticators.html#configuring-the-authentication-entry-point
// */
// }
}

View File

@@ -0,0 +1,212 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\User\Framework\Security;
use App\User\Framework\Entity\User;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Exception\ExceptionInterface;
use Symfony\Component\Ldap\Exception\InvalidCredentialsException;
use Symfony\Component\Ldap\Exception\InvalidSearchCredentialsException;
use Symfony\Component\Ldap\LdapInterface;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Ldap\Security\LdapUser;
/**
* LdapUserProvider is a simple user provider on top of LDAP.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
* @author Charles Sarrazin <charles@sarraz.in>
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @template-implements UserProviderInterface<LdapUser>
*/
class LdapUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
private string $uidKey;
private string $defaultSearch;
private string $usernameAttribute;
private string $emailAttribute;
private string $displayNameAttribute;
public function __construct(
private UserRepository $userRepository,
private LdapInterface $ldap,
private string $baseDn,
private ?string $searchDn = null,
#[\SensitiveParameter] private ?string $searchPassword = null,
private array $defaultRoles = [],
?string $uidKey = null,
?string $filter = null,
private ?string $passwordAttribute = null,
private array $extraFields = [],
string $usernameAttribute = 'uid',
string $emailAttribute = 'mail',
string $displayNameAttribute = 'displayName',
) {
$uidKey ??= 'sAMAccountName';
$filter ??= '({uid_key}={user_identifier})';
$this->uidKey = $uidKey;
$this->defaultSearch = str_replace('{uid_key}', $uidKey, $filter);
$this->usernameAttribute = $usernameAttribute;
$this->emailAttribute = $emailAttribute;
$this->displayNameAttribute = $displayNameAttribute;
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
try {
$this->ldap->bind($this->searchDn, $this->searchPassword);
} catch (InvalidCredentialsException) {
throw new InvalidSearchCredentialsException();
}
$identifier = $this->ldap->escape($identifier, '', LdapInterface::ESCAPE_FILTER);
$query = str_replace('{user_identifier}', $identifier, $this->defaultSearch);
$search = $this->ldap->query($this->baseDn, $query, ['filter' => 0 == \count($this->extraFields) ? '*' : $this->extraFields]);
$entries = $search->execute();
$count = \count($entries);
if (!$count) {
$e = new UserNotFoundException(\sprintf('User "%s" not found.', $identifier));
$e->setUserIdentifier($identifier);
throw $e;
}
if ($count > 1) {
$e = new UserNotFoundException('More than one user found.');
$e->setUserIdentifier($identifier);
throw $e;
}
$entry = $entries[0];
try {
$identifier = $this->getAttributeValue($entry, $this->uidKey);
} catch (InvalidArgumentException) {
// This is empty
}
return $this->loadUser($identifier, $entry);
}
public function refreshUser(UserInterface $user): UserInterface
{
if (!$user instanceof LdapUser) {
throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
}
return new LdapUser($user->getEntry(), $user->getUserIdentifier(), $user->getPassword(), $user->getRoles(), $user->getExtraFields());
}
/**
* @final
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof LdapUser) {
throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
}
if (null === $this->passwordAttribute) {
return;
}
try {
$user->getEntry()->setAttribute($this->passwordAttribute, [$newHashedPassword]);
$this->ldap->getEntryManager()->update($user->getEntry());
$user->setPassword($newHashedPassword);
} catch (ExceptionInterface) {
// ignore failed password upgrades
}
}
public function supportsClass(string $class): bool
{
return User::class === $class;
}
/**
* Loads a user from an LDAP entry.
*/
protected function loadUser(string $identifier, Entry $entry): UserInterface
{
$extraFields = [];
foreach ($this->extraFields as $field) {
$extraFields[$field] = $this->getAttributeValue($entry, $field);
}
$dbUser = $this->getDbUser($identifier, $entry);
if (null === $dbUser) {
$dbUser = new User();
$dbUser->setPassword("test");
}
$dbUser
->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();
return $dbUser;
}
private function getDbUser(string $identifier, Entry $entry): ?UserInterface
{
if (in_array($this->uidKey, ['mail', 'email'])) {
$dbUser = $this->userRepository->findOneBy(['email' => $identifier]);
} else {
$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
{
if (!$entry->hasAttribute($attribute)) {
throw new InvalidArgumentException(\sprintf('Missing attribute "%s" for user "%s".', $attribute, $entry->getDn()));
}
$values = $entry->getAttribute($attribute);
if (!\in_array($attribute, [$this->uidKey, $this->passwordAttribute])) {
return $values;
}
if (1 !== \count($values)) {
throw new InvalidArgumentException(\sprintf('Attribute "%s" has multiple values.', $attribute));
}
return $values[0];
}
}

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

@@ -12,15 +12,14 @@
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %} {% endblock %}
</head> </head>
<body class="bg-neutral-700 flex flex-col"> <body class="bg-neutral-700 flex flex-col backdrop-filter backdrop-blur-sm bg-opacity-100">
<twig:Header />
<div class="grid grid-cols-6"> <div class="grid grid-cols-6">
<div class="col-span-1"> <div class="col-span-1 h-screen">
<twig:NavBar /> <twig:NavBar />
</div> </div>
<div class="col-span-5"> <div class="col-span-5 h-screen overflow-y-scroll">
<h2 class="p-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2> <twig:Header />
<h2 class="px-4 my-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>
</div> </div>

View File

@@ -1,21 +1,26 @@
{# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #} {# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #}
{% block create %} {% block create %}
<turbo-stream action="remove" target="active_downloads_no_downloads">
</turbo-stream>
<turbo-stream action="append" target="active_downloads"> <turbo-stream action="append" target="active_downloads">
<template> <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"> <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 }} {{ entity.title }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-50">
<span class="p-1.5 bg-purple-600 rounded-full"> {% if entity.progress < 100 %}
<span class="w-4 inline-block text-center text-gray-50">{{ entity.progress }}</span> <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">
</span> {{ entity.progress }}
</div>
{% else %}
<twig:StatusBadge color="green" status="Complete" />
{% endif %}
</td> </td>
</tr> </tr>
</template> </template>
</turbo-stream> </turbo-stream>
<twig:Alert title="Success" message="{{ entity.title }} has been added to the Download queue" alert_id="{{ entity.id }}" data-controller="alert" />
{% endblock %} {% endblock %}
{% block update %} {% block update %}
@@ -27,12 +32,15 @@
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50"> <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="p-1.5 bg-purple-600 rounded-full">
<span class="w-4 inline-block text-center text-gray-50">{{ entity.progress }}</span> <span class="mw-4 inline-block text-center text-gray-50">{{ entity.progress }}</span>
</span> </span>
</td> </td>
</template> </template>
</turbo-stream> </turbo-stream>
{% else %} {% else %}
<turbo-stream action="remove" target="complete_downloads_no_downloads">
</turbo-stream>
<turbo-stream action="remove" target="ad_download_{{ id }}"> <turbo-stream action="remove" target="ad_download_{{ id }}">
</turbo-stream> </turbo-stream>
@@ -42,16 +50,14 @@
</template> </template>
</turbo-stream> </turbo-stream>
<turbo-stream action="prepend" target="recent_downloads"> <turbo-stream action="prepend" target="complete_downloads">
<template> <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"> <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 }} {{ entity.title }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-50">
<span class="p-1.5 bg-purple-600 rounded-full"> <twig:StatusBadge color="green" status="Complete" />
<span class="w-4 inline-block text-center text-gray-50">{{ entity.progress }}</span>
</span>
</td> </td>
</tr> </tr>
</template> </template>
@@ -61,5 +67,4 @@
{% block remove %} {% block remove %}
<turbo-stream action="remove" target="ad_download_{{ id }}"></turbo-stream> <turbo-stream action="remove" target="ad_download_{{ id }}"></turbo-stream>
{# <turbo-stream action="remove" target="cd_download_{{ id }}"></turbo-stream>#}
{% endblock %} {% 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,4 +1,9 @@
<li {{ attributes }} id="alert_{{ alert_id }}" class="alert p-4 text-green-800 border border-green-300 rounded-lg bg-green-50 dark:bg-gray-800 dark:text-green-400 dark:border-green-800" role="alert"> <li {{ attributes }} id="alert_{{ alert_id }}" class="
text-white bg-green-950 text-sm min-w-[250px]
hover:bg-green-900 border border-green-500 px-4 py-3
rounded-md z-40"
role="alert"
>
<div class="flex items-center"> <div class="flex items-center">
<svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"> <svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/> <path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>

View File

@@ -1,7 +1,9 @@
<div{{ attributes }}> <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-slate-600 dark:border-neutral-700 dark:border-t-orange-500 dark:shadow-neutral-700/70"> <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"> <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 }} {{ title }}
</h3> </h3>

View File

@@ -0,0 +1,80 @@
<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="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

@@ -1,15 +1,32 @@
<header {{ attributes }} class="bg-cyan-950"> <header {{ attributes }} class="bg-cyan-950 z-40">
<div class="px-4 sm:px-6 lg:px-8"> <div class="px-4 sm:px-6 lg:px-8">
<div class="h-16 flex flex-row items-center justify-between"> <div class="h-16 flex flex-row items-center justify-between">
<h1 class="text-3xl font-extrabold text-orange-500">Torsearch</h1>
<twig:SearchBar /> <twig:SearchBar />
<div class="md:flex md:items-center md:gap-12"> <div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block"> <nav aria-label="Global" class="md:block">
<ul class="flex items-center gap-6 text-sm"> <ul class="flex items-center align-middle gap-6 text-sm">
<li><twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/></li> <li id="active_download_notis" class="">
<ul>
{% if this.activeDownloads|length > 0 %}
<li class="flex flex-col gap-1 align-middle">
<twig:ux:icon name="flowbite:download-outline" width="1.75rem" color="#f97316" class="border-2 border-orange-500 rounded-md p-1" />
{# {% include 'partial/alert-status.html.twig' %}#}
{# <div class="absolute" style="top: 3.5rem; right: 7rem; z-index: 1000;">#}
{# <div class="flex flex-col gap-1 bg-cyan-950 border-2 border-orange-500 text-white rounded-md p-3">#}
{# <h3>Inception</h3>#}
{# <div class="border-2 border-green-700 rounded-md w-full h-6 align-middle overflow-hidden">#}
{# <div class="rounded-sm text-bold text-center text-white bg-green-600 h-5" style="width:47%">47%</div>#}
{# </div>#}
{# </div>#}
{# </div>#}
</li>
{% endif %}
</ul>
</li>
<li><twig:ux:icon name="fluent:alert-12-regular" width="2.5rem" class="text-orange-500 rounded-full p-2"/></li>
<li> <li>
<a href="{{ path('app_logout') }}"> <a href="{{ path('app_logout') }}">
<twig:ux:icon name="material-symbols:logout" width="25px" class="text-orange-500" /> <twig:ux:icon name="material-symbols:logout" width="2rem" class="text-orange-500" />
</a> </a>
</li> </li>
</ul> </ul>
@@ -17,7 +34,7 @@
</div> </div>
</div> </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 > <div >
<ul id="alert_list"> <ul id="alert_list">
</ul> </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

@@ -1,6 +1,7 @@
<nav {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950"> <nav {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950">
<div class="px-4 py-6"> <div class="px-4 py-4 flex flex-col gap-12">
<ul class="mt-6 space-y-1"> <h1 class="text-3xl font-extrabold text-orange-500 mb-3">Torsearch</h1>
<ul class="space-y-1">
<li> <li>
<a href="{{ path('app_index') }}" <a href="{{ path('app_index') }}"
class="block rounded-lg class="block rounded-lg
@@ -11,40 +12,12 @@
</li> </li>
<li> <li>
<details class="group [&_summary::-webkit-details-marker]:hidden"> <a href="{{ path('app_downloads') }}"
<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"> class="block rounded-lg
<span class="text-sm font-medium">Downloads</span> bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60
<span class="shrink-0 transition duration-300 group-open:-rotate-180"> px-4 py-2 text-sm font-medium text-gray-50">
<svg Downloads
xmlns="http://www.w3.org/2000/svg" </a>
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>
</li> </li>
<li> <li>

View File

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

@@ -3,55 +3,20 @@
{% block title %}Dashboard &mdash; Torsearch{% endblock %} {% block title %}Dashboard &mdash; Torsearch{% endblock %}
{% block body %} {% block body %}
<div class="p-4 flex flex-col grow gap-4"> <div class="p-4 flex flex-col grow gap-4 z-30">
<h2 class="mb-2 text-3xl font-bold text-gray-50">Dashboard</h2> <h2 class="mb-2 text-3xl font-bold text-gray-50">Dashboard</h2>
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<twig:Card title="Active Downloads" class="w-full"> <twig:Card title="Active Downloads" class="w-full">
<twig:ActiveDownloadList /> <twig:DownloadList :type="'active'" />
</twig:Card> </twig:Card>
<twig:Card title="Recent Downloads" class="w-full"> <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"> <twig:DownloadList :type="'complete'" />
<thead> </twig:Card>
<tr class="dark:bg-gray-50"> </div>
<th scope="col" <div class="flex flex-row gap-4">
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 rounded-tl-md"> <twig:Card title="Monitors" class="w-full">
Title <twig:MonitorList />
</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:Card> </twig:Card>
</div> </div>
<div class="flex flex-col gap-4"> <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="display: block;width: 8px;height: 8px;background: greenyellow;border-radius: 5px; z-index: 1000;margin-left: -10px;margin-bottom: -10px;"></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 %}

View File

@@ -17,8 +17,8 @@
{% endif %} {% endif %}
<label for="username" class="mb-2 flex flex-col"> <label for="username" class="mb-2 flex flex-col">
Email User
<input type="email" <input type=""
value="{{ last_username }}" value="{{ last_username }}"
name="_username" name="_username"
id="username" id="username"