Compare commits

..

24 Commits

Author SHA1 Message Date
b23d8a2ba3 fix: prefills provider preference on filter 2025-04-30 22:23:42 -05:00
bfd5f53d67 fix: multiple options being pre-selected after filter change 2025-04-30 22:03:19 -05:00
f10168a1a7 fix: language filter 2025-04-30 21:39:41 -05:00
8970ca0f8f fix: deploys replica app container 2025-04-30 18:51:09 -05:00
994bd775ea fix: stores session in DB 2025-04-30 18:13:19 -05:00
d0e7941809 fix: chanaged wrong service last commit 2025-04-30 17:31:17 -05:00
a28f0d9369 fix: only deploys 1 replica webapp until a better session handling method is implemented 2025-04-30 17:15:41 -05:00
964cdca151 fix: adds migrateDb to build.xml 2025-04-30 16:00:20 -05:00
b59069551a fix: download options filter uses user preferences 2025-04-30 15:53:10 -05:00
3971cf3260 wip: filter twig component pre-select options 2025-04-29 23:40:27 -05:00
8a1a89f17d wip-feat: populates filter from api options 2025-04-29 22:10:47 -05:00
c3eaf109e3 feat: stores user's media preferences 2025-04-29 16:17:40 -05:00
0225bead60 fix: displays user's name & email in left footer 2025-04-28 21:49:54 -05:00
1b1feaebec wip-feat: user, login/logout, authentication/authorization 2025-04-28 21:45:12 -05:00
7045116b56 wip: adds preference & preference_option tables 2025-04-28 08:50:51 -05:00
883442225f fix: broken search button 2025-04-27 22:11:09 -05:00
c664e9fbca feat: presents popular tv shows on landing page 2025-04-27 21:36:45 -05:00
5d257e4404 fix: removes duplicate Dockerfile instruction 2025-04-27 21:15:01 -05:00
a6dc4f0b03 fix: removes default apache vhost 2025-04-27 16:33:21 -05:00
3c6e41af94 wip-feat: adds mercure to deployment 2025-04-27 16:32:34 -05:00
3e081df01c fix: adds download record at time of download 2025-04-27 16:30:08 -05:00
3384720c09 wip-feat: mercure download progress 2025-04-27 11:04:40 -05:00
c32ff2e464 wip-feat: mercure alerts 2025-04-27 09:55:55 -05:00
6138c94d7a fix: missing app secret 2025-04-27 00:24:55 -05:00
86 changed files with 2982 additions and 332 deletions

View File

@@ -1 +1,4 @@
FROM registry.caldwell.digital/library/php:8.4-apache
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf

View File

@@ -2,5 +2,4 @@ FROM registry.caldwell.digital/library/php:8.4-apache
COPY --chown=www-data:www-data . /var/www
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf

View File

@@ -8,3 +8,13 @@ import './bootstrap.js';
import './styles/app.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
let alert = document.querySelector('.alert');
var observer = new MutationObserver(function(mutations) {
if (document.contains(alert)) {
observer.disconnect();
}
});
observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});

View File

@@ -8,6 +8,16 @@
"@symfony/ux-live-component/dist/live.min.css": true
}
}
},
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": true,
"fetch": "eager"
}
}
},
"entrypoints": []

View File

@@ -0,0 +1,18 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
let timer = setTimeout(() => {
this.element.remove();
},
"3000"
);
this.element.addEventListener('mouseout', () => timer = setTimeout(() => {
this.element.remove();
},
"3000"
));
this.element.addEventListener('mouseover', () => clearTimeout(timer));
}
}

View File

@@ -1,5 +1,5 @@
import { Controller } from '@hotwired/stimulus';
import flasher from '@flasher/flasher'
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
@@ -31,7 +31,7 @@ export default class extends Controller {
})
.then(res => res.json())
.then(json => {
flasher.success(json.message);
})
console.log(json)
})
}
}

View File

@@ -6,6 +6,9 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
static values = {
title: String,
tmdbId: String,
@@ -37,7 +40,54 @@ export default class extends Controller {
return true;
}
listTargetConnected(target) {
// console.log(target);
async filter(activeFilter) {
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
}
}

View File

@@ -27,10 +27,11 @@ export default class extends Controller {
'episodes': Array,
}
connect() {
async connect() {
if (this.mediaTypeValue === "tvshows") {
this.activeFilter['season'] = 1;
}
await this.filter();
}
async movieResultsOutletConnected(outlet) {
@@ -49,6 +50,7 @@ export default class extends Controller {
this.addLanguages(option, option.dataset);
this.addProviders(option, option.dataset);
})
await this.filter();
}
addLanguages(option, props) {
@@ -59,9 +61,22 @@ export default class extends Controller {
}
});
this.languageTarget.innerHTML = '<option value="">n/a</option>';
const preferred = this.languageTarget.dataset.preferred;
if (preferred) {
this.languageTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.languageTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.languageTarget.innerHTML = '<option value="">n/a</option>';
}
this.languageTarget.innerHTML += this.languages.sort()
.map((language) => '<option value="'+language+'">'+language+'</option>')
.map((language) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === language) {
return;
}
return '<option value="'+language+'">'+language+'</option>';
})
.join();
}
@@ -70,9 +85,22 @@ export default class extends Controller {
this.providers.push(props['provider']);
}
this.providerTarget.innerHTML = '<option value="">n/a</option>';
const preferred = this.providerTarget.dataset.preferred;
if (preferred) {
this.providerTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.providerTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.providerTarget.innerHTML = '<option value="">n/a</option>';
}
this.providerTarget.innerHTML += this.providers.sort()
.map((provider) => '<option value="'+provider+'">'+provider+'</option>')
.map((provider) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === provider) {
return;
}
return '<option value="' + provider + '">' + provider + '</option>'
})
.join();
}
@@ -90,79 +118,13 @@ export default class extends Controller {
if ("movies" === this.mediaTypeValue) {
results = this.movieResultsOutlets;
await results.forEach((list) => list.filter(this.activeFilter));
} else if ("tvshows" === this.mediaTypeValue) {
results = this.tvResultsOutlets;
this.activeFilter.season = this.seasonTarget.value;
await results.forEach((list) => list.filter(this.activeFilter, currentSeason, this.seasonTarget.value));
}
const filterOperation = async (resultList, currentSeason) => {
if ("tvshows" === this.mediaTypeValue && currentSeason !== this.activeFilter['season']) {
if (resultList.seasonValue === this.seasonTarget.value) {
await resultList.setActive();
} else {
resultList.setInActive();
}
}
if (false === resultList.isActive()) {
return;
}
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
resultList.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
for (let [key, value] of Object.entries(this.activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
if ("tvshows" === this.mediaTypeValue) {
resultList.countTarget.innerText = count;
}
});
}
await results.forEach((list) => filterOperation(list, currentSeason));
}
uncheckSelectAllBtn() {

View File

@@ -6,6 +6,9 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
static values = {
title: String,
tmdbId: String,
@@ -26,7 +29,8 @@ export default class extends Controller {
}
async setOptions() {
if (true === this.activeValue) {
if (true === this.activeValue && this.optionsLoaded === false) {
this.optionsLoaded = true;
await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
.then(res => res.text())
.then(response => {
@@ -38,7 +42,6 @@ export default class extends Controller {
} else {
this.episodeSelectorTarget.disabled = true;
}
this.optionsLoaded = true;
this.loadingIconOutlet.increaseCount();
});
}
@@ -52,9 +55,11 @@ export default class extends Controller {
}
}
setInActive() {
async setInActive() {
this.activeValue = false;
// if (true === this.hasEpisodeSelectorTarget()) {
this.episodeSelectorTarget.checked = false;
// }
this.element.classList.add('hidden');
}
@@ -88,4 +93,69 @@ export default class extends Controller {
}
})
}
async filter(activeFilter, currentSeason, newSeason) {
if (currentSeason !== activeFilter['season']) {
if (this.seasonValue === newSeason) {
await this.setActive();
} else {
await this.setInActive();
}
}
if (false === this.isActive()) {
return;
}
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
this.countTarget.innerText = count;
});
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h7v2H5v14h7v2zm11-4l-1.375-1.45l2.55-2.55H9v-2h8.175l-2.55-2.55L16 7l5 5z"/></svg>

After

Width:  |  Height:  |  Size: 224 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 22a8 8 0 1 1 16 0h-2a6 6 0 0 0-12 0zm8-9c-3.315 0-6-2.685-6-6s2.685-6 6-6s6 2.685 6 6s-2.685 6-6 6m0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4"/></svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -4,6 +4,11 @@
DocumentRoot /var/www/public
DirectoryIndex /index.php
<LocationMatch "/hub/">
ProxyPass http://mercure:80/
ProxyPassReverse http://mercure:80/
</LocationMatch>
<Directory /var/www/public>
AllowOverride None
Order Allow,Deny

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project name="Caldwell Digital Symfony Template" default="build">
<!-- build dev for dev envs -->
<target name="build" depends="setEnv,composer,compileAssets" />
<target name="build" depends="setEnv,composer,compileAssets,migrateDb" />
<target name="composer" description="Run composer">
<exec executable="composer">
@@ -38,4 +38,12 @@
</filterchain>
</copy>
</target>
<target name="migrateDb" description="Migrate the database">
<exec executable="php">
<arg value="bin/console" />
<arg value="--no-interaction" />
<arg value="doctrine:migrations:migrate" />
</exec>
</target>
</project>

View File

@@ -24,6 +24,24 @@ services:
- ./var/download:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3000:80"
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins *
anonymous
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
volumes:
- mercure_data:/data
- mercure_config:/config
database:
image: mariadb:10.11.2
ports:
@@ -43,3 +61,5 @@ services:
volumes:
mysql:
mercure_data:
mercure_config:

View File

@@ -8,6 +8,7 @@
"ext-ctype": "*",
"ext-iconv": "*",
"1tomany/rich-bundle": "^1.8",
"aimeos/map": "^3.12",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4",
@@ -15,7 +16,6 @@
"nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2",
"php-flasher/flasher-symfony": "^2.1",
"php-tmdb/api": "^4.1",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
@@ -24,6 +24,7 @@
"symfony/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/security-bundle": "7.2.*",
@@ -31,6 +32,7 @@
"symfony/twig-bundle": "7.2.*",
"symfony/ux-icons": "^2.24",
"symfony/ux-live-component": "^2.24",
"symfony/ux-turbo": "^2.24",
"symfony/ux-twig-component": "^2.24",
"symfony/yaml": "7.2.*",
"symfonycasts/tailwind-bundle": "^0.10.0",
@@ -90,6 +92,8 @@
}
},
"require-dev": {
"symfony/maker-bundle": "^1.62"
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
}
}

749
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "02f1ff0023fc6fb23a0307520495e75c",
"content-hash": "e0322cfec0023bee458190f38b4cab8c",
"packages": [
{
"name": "1tomany/data-uri",
@@ -122,6 +122,59 @@
},
"time": "2025-04-14T20:49:47+00:00"
},
{
"name": "aimeos/map",
"version": "3.12.0",
"source": {
"type": "git",
"url": "https://github.com/aimeos/map.git",
"reference": "3cb4aff05a92cc47a45a7488094b945370b83381"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aimeos/map/zipball/3cb4aff05a92cc47a45a7488094b945370b83381",
"reference": "3cb4aff05a92cc47a45a7488094b945370b83381",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-pcre": "*",
"php": "^7.1||^8.0"
},
"require-dev": {
"php-coveralls/php-coveralls": "~2.0",
"phpunit/phpunit": "~7.0||~8.0||~9.0",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"autoload": {
"files": [
"src/function.php"
],
"psr-4": {
"Aimeos\\": "src/"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Easy and elegant handling of PHP arrays as array-like collection objects similar to jQuery and Laravel Collections",
"keywords": [
"array",
"collection",
"map",
"php"
],
"support": {
"issues": "https://github.com/aimeos/map/issues",
"source": "https://github.com/aimeos/map/tree/3.12.0"
},
"time": "2025-03-05T09:16:18+00:00"
},
{
"name": "composer/semver",
"version": "3.4.3",
@@ -1421,6 +1474,79 @@
},
"time": "2025-01-24T11:45:48+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.5.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
"reference": "a835af59b030d3f2967725697cf88300f579088e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/a835af59b030d3f2967725697cf88300f579088e",
"reference": "a835af59b030d3f2967725697cf88300f579088e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0",
"psr/clock": "^1.0"
},
"require-dev": {
"infection/infection": "^0.29",
"lcobucci/clock": "^3.2",
"lcobucci/coding-standard": "^11.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.10.7",
"phpstan/phpstan-deprecation-rules": "^1.1.3",
"phpstan/phpstan-phpunit": "^1.3.10",
"phpstan/phpstan-strict-rules": "^1.5.0",
"phpunit/phpunit": "^11.1"
},
"suggest": {
"lcobucci/clock": ">= 3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com",
"role": "Developer"
}
],
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
"keywords": [
"JWS",
"jwt"
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
"source": "https://github.com/lcobucci/jwt/tree/5.5.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-01-26T21:29:45+00:00"
},
{
"name": "nihilarr/parse-torrent-name",
"version": "v0.0.1",
@@ -1780,141 +1906,6 @@
},
"time": "2024-02-19T18:29:05+00:00"
},
{
"name": "php-flasher/flasher",
"version": "v2.1.6",
"source": {
"type": "git",
"url": "https://github.com/php-flasher/flasher.git",
"reference": "054a209515d2eb1bb72467023d8614a29b5d2e60"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-flasher/flasher/zipball/054a209515d2eb1bb72467023d8614a29b5d2e60",
"reference": "054a209515d2eb1bb72467023d8614a29b5d2e60",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"type": "library",
"autoload": {
"files": [
"functions.php",
"helpers.php"
],
"psr-4": {
"Flasher\\Prime\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Younes ENNAJI",
"email": "younes.ennaji.pro@gmail.com",
"homepage": "https://www.linkedin.com/in/younes--ennaji/",
"role": "Developer"
}
],
"description": "The foundational PHP library for PHPFlasher, enabling the creation of framework-agnostic flash notifications. Ideal for building custom integrations or for use in PHP projects.",
"homepage": "https://php-flasher.io",
"keywords": [
"custom-integrations",
"flash-notifications",
"flasher-core",
"framework-agnostic",
"open-source",
"php"
],
"support": {
"issues": "https://github.com/php-flasher/php-flasher/issues",
"source": "https://github.com/php-flasher/php-flasher"
},
"funding": [
{
"url": "https://www.paypal.com/paypalme/yoeunes",
"type": "custom"
},
{
"url": "https://github.com/yoeunes",
"type": "github"
}
],
"time": "2025-02-21T20:05:00+00:00"
},
{
"name": "php-flasher/flasher-symfony",
"version": "v2.1.6",
"source": {
"type": "git",
"url": "https://github.com/php-flasher/flasher-symfony.git",
"reference": "14bd1ba6bbd1184bde0300a5b02455e886845cea"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-flasher/flasher-symfony/zipball/14bd1ba6bbd1184bde0300a5b02455e886845cea",
"reference": "14bd1ba6bbd1184bde0300a5b02455e886845cea",
"shasum": ""
},
"require": {
"php": ">=8.2",
"php-flasher/flasher": "^2.1.6",
"symfony/config": "^7.0",
"symfony/console": "^7.0",
"symfony/dependency-injection": "^7.0",
"symfony/http-kernel": "^7.0"
},
"suggest": {
"symfony/translation": "To translate flash messages, title and presets",
"symfony/ux-twig-component": "To utilize and interact with flash messages components in Twig templates"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Flasher\\Symfony\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Younes ENNAJI",
"email": "younes.ennaji.pro@gmail.com",
"homepage": "https://www.linkedin.com/in/younes--ennaji/",
"role": "Developer"
}
],
"description": "Integrate flash notifications into Symfony projects effortlessly with PHPFlasher. Improve user experience and application feedback loops easily.",
"homepage": "https://php-flasher.io",
"keywords": [
"flash-notifications",
"open-source",
"php",
"phpflasher",
"symfony",
"user-feedback"
],
"support": {
"issues": "https://github.com/php-flasher/php-flasher/issues",
"source": "https://github.com/php-flasher/php-flasher"
},
"funding": [
{
"url": "https://www.paypal.com/paypalme/yoeunes",
"type": "custom"
},
{
"url": "https://github.com/yoeunes",
"type": "github"
}
],
"time": "2025-02-21T20:05:00+00:00"
},
{
"name": "php-http/discovery",
"version": "1.20.0",
@@ -2683,6 +2674,62 @@
},
"time": "2023-04-04T09:50:52+00:00"
},
{
"name": "psr/link",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/link.git",
"reference": "84b159194ecfd7eaa472280213976e96415433f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/link/zipball/84b159194ecfd7eaa472280213976e96415433f7",
"reference": "84b159194ecfd7eaa472280213976e96415433f7",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"suggest": {
"fig/link-util": "Provides some useful PSR-13 utilities"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Link\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Common interfaces for HTTP links",
"homepage": "https://github.com/php-fig/link",
"keywords": [
"http",
"http-link",
"link",
"psr",
"psr-13",
"rest"
],
"support": {
"source": "https://github.com/php-fig/link/tree/2.0.1"
},
"time": "2021-03-11T23:00:27+00:00"
},
{
"name": "psr/log",
"version": "3.0.2",
@@ -4791,6 +4838,173 @@
],
"time": "2025-03-28T13:32:50+00:00"
},
{
"name": "symfony/mercure",
"version": "v0.6.5",
"source": {
"type": "git",
"url": "https://github.com/symfony/mercure.git",
"reference": "304cf84609ef645d63adc65fc6250292909a461b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mercure/zipball/304cf84609ef645d63adc65fc6250292909a461b",
"reference": "304cf84609ef645d63adc65fc6250292909a461b",
"shasum": ""
},
"require": {
"php": ">=7.1.3",
"symfony/deprecation-contracts": "^2.0|^3.0|^4.0",
"symfony/http-client": "^4.4|^5.0|^6.0|^7.0",
"symfony/http-foundation": "^4.4|^5.0|^6.0|^7.0",
"symfony/polyfill-php80": "^1.22",
"symfony/web-link": "^4.4|^5.0|^6.0|^7.0"
},
"require-dev": {
"lcobucci/jwt": "^3.4|^4.0|^5.0",
"symfony/event-dispatcher": "^4.4|^5.0|^6.0|^7.0",
"symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0",
"symfony/phpunit-bridge": "^5.2|^6.0|^7.0",
"symfony/stopwatch": "^4.4|^5.0|^6.0|^7.0",
"twig/twig": "^2.0|^3.0|^4.0"
},
"suggest": {
"symfony/stopwatch": "Integration with the profiler performances"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/dunglas/mercure",
"name": "dunglas/mercure"
},
"branch-alias": {
"dev-main": "0.6.x-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Component\\Mercure\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kévin Dunglas",
"email": "dunglas@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony Mercure Component",
"homepage": "https://symfony.com",
"keywords": [
"mercure",
"push",
"sse",
"updates"
],
"support": {
"issues": "https://github.com/symfony/mercure/issues",
"source": "https://github.com/symfony/mercure/tree/v0.6.5"
},
"funding": [
{
"url": "https://github.com/dunglas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/mercure",
"type": "tidelift"
}
],
"time": "2024-04-08T12:51:34+00:00"
},
{
"name": "symfony/mercure-bundle",
"version": "v0.3.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/mercure-bundle.git",
"reference": "77435d740b228e9f5f3f065b6db564f85f2cdb64"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/77435d740b228e9f5f3f065b6db564f85f2cdb64",
"reference": "77435d740b228e9f5f3f065b6db564f85f2cdb64",
"shasum": ""
},
"require": {
"lcobucci/jwt": "^3.4|^4.0|^5.0",
"php": ">=7.1.3",
"symfony/config": "^4.4|^5.0|^6.0|^7.0",
"symfony/dependency-injection": "^4.4|^5.4|^6.0|^7.0",
"symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0",
"symfony/mercure": "^0.6.1",
"symfony/web-link": "^4.4|^5.0|^6.0|^7.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^4.3.7|^5.0|^6.0|^7.0",
"symfony/stopwatch": "^4.3.7|^5.0|^6.0|^7.0",
"symfony/ux-turbo": "*",
"symfony/var-dumper": "^4.3.7|^5.0|^6.0|^7.0"
},
"suggest": {
"symfony/messenger": "To use the Messenger integration"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-main": "0.3.x-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Bundle\\MercureBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kévin Dunglas",
"email": "dunglas@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony MercureBundle",
"homepage": "https://symfony.com",
"keywords": [
"mercure",
"push",
"sse",
"updates"
],
"support": {
"issues": "https://github.com/symfony/mercure-bundle/issues",
"source": "https://github.com/symfony/mercure-bundle/tree/v0.3.9"
},
"funding": [
{
"url": "https://github.com/dunglas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle",
"type": "tidelift"
}
],
"time": "2024-05-31T09:07:18+00:00"
},
{
"name": "symfony/messenger",
"version": "v7.2.5",
@@ -7205,6 +7419,104 @@
],
"time": "2025-03-12T08:41:47+00:00"
},
{
"name": "symfony/ux-turbo",
"version": "v2.24.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-turbo.git",
"reference": "22954300bd0b01ca46f17c7890ea15138d9cf67f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ux-turbo/zipball/22954300bd0b01ca46f17c7890ea15138d9cf67f",
"reference": "22954300bd0b01ca46f17c7890ea15138d9cf67f",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/stimulus-bundle": "^2.9.1"
},
"conflict": {
"symfony/flex": "<1.13"
},
"require-dev": {
"dbrekelmans/bdi": "dev-main",
"doctrine/doctrine-bundle": "^2.4.3",
"doctrine/orm": "^2.8 | 3.0",
"phpstan/phpstan": "^1.10",
"symfony/asset-mapper": "^6.4|^7.0",
"symfony/debug-bundle": "^5.4|^6.0|^7.0",
"symfony/expression-language": "^5.4|^6.0|^7.0",
"symfony/form": "^5.4|^6.0|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/mercure-bundle": "^0.3.7",
"symfony/messenger": "^5.4|^6.0|^7.0",
"symfony/panther": "^2.1",
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0",
"symfony/process": "^5.4|6.3.*|^7.0",
"symfony/property-access": "^5.4|^6.0|^7.0",
"symfony/security-core": "^5.4|^6.0|^7.0",
"symfony/stopwatch": "^5.4|^6.0|^7.0",
"symfony/twig-bundle": "^6.4|^7.0",
"symfony/ux-twig-component": "^2.21",
"symfony/web-profiler-bundle": "^5.4|^6.0|^7.0"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/ux",
"name": "symfony/ux"
}
},
"autoload": {
"psr-4": {
"Symfony\\UX\\Turbo\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kévin Dunglas",
"email": "kevin@dunglas.fr"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Hotwire Turbo integration for Symfony",
"homepage": "https://symfony.com",
"keywords": [
"hotwire",
"javascript",
"mercure",
"symfony-ux",
"turbo",
"turbo-stream"
],
"support": {
"source": "https://github.com/symfony/ux-turbo/tree/v2.24.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": "2025-04-04T17:29:20+00:00"
},
{
"name": "symfony/ux-twig-component",
"version": "v2.24.0",
@@ -7544,6 +7856,89 @@
],
"time": "2025-03-13T12:21:46+00:00"
},
{
"name": "symfony/web-link",
"version": "v7.2.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/web-link.git",
"reference": "f537556a885e14a1d28f6c759d41e57e93d0a532"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/web-link/zipball/f537556a885e14a1d28f6c759d41e57e93d0a532",
"reference": "f537556a885e14a1d28f6c759d41e57e93d0a532",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/link": "^1.1|^2.0"
},
"conflict": {
"symfony/http-kernel": "<6.4"
},
"provide": {
"psr/link-implementation": "1.0|2.0"
},
"require-dev": {
"symfony/http-kernel": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\WebLink\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kévin Dunglas",
"email": "dunglas@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Manages links between resources",
"homepage": "https://symfony.com",
"keywords": [
"dns-prefetch",
"http",
"http2",
"link",
"performance",
"prefetch",
"preload",
"prerender",
"psr13",
"push"
],
"support": {
"source": "https://github.com/symfony/web-link/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-09-25T14:21:43+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.2.5",
@@ -8034,6 +8429,88 @@
}
],
"time": "2025-01-15T00:21:40+00:00"
},
{
"name": "symfony/web-profiler-bundle",
"version": "v7.2.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/web-profiler-bundle.git",
"reference": "4ffde1c860a100533b02697d9aaf5f45759ec26a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/4ffde1c860a100533b02697d9aaf5f45759ec26a",
"reference": "4ffde1c860a100533b02697d9aaf5f45759ec26a",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/config": "^6.4|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/routing": "^6.4|^7.0",
"symfony/twig-bundle": "^6.4|^7.0",
"twig/twig": "^3.12"
},
"conflict": {
"symfony/form": "<6.4",
"symfony/mailer": "<6.4",
"symfony/messenger": "<6.4",
"symfony/serializer": "<7.2"
},
"require-dev": {
"symfony/browser-kit": "^6.4|^7.0",
"symfony/console": "^6.4|^7.0",
"symfony/css-selector": "^6.4|^7.0",
"symfony/stopwatch": "^6.4|^7.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\Bundle\\WebProfilerBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a development tool that gives detailed information about the execution of any request",
"homepage": "https://symfony.com",
"keywords": [
"dev"
],
"support": {
"source": "https://github.com/symfony/web-profiler-bundle/tree/v7.2.4"
},
"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": "2025-02-14T14:27:24+00:00"
}
],
"aliases": [],

View File

@@ -14,5 +14,7 @@ return [
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true],
Flasher\Symfony\FlasherSymfonyBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
];

View File

@@ -18,18 +18,18 @@ doctrine:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
# App:
# type: attribute
# is_bundle: false
# dir: '%kernel.project_dir%/src/Entity'
# prefix: 'App\Entity'
# alias: App
Download:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Download/Framework/Entity'
prefix: 'App\Download\Framework\Entity'
alias: Download
User:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/User/Framework/Entity'
prefix: 'App\User\Framework\Entity'
alias: User
controller_resolver:
auto_mapping: false

View File

@@ -1,48 +0,0 @@
flasher:
# Default notification library (e.g., 'flasher', 'toastr', 'noty', 'notyf', 'sweetalert')
default: flasher
# Path to the main PHPFlasher JavaScript file
main_script: '/vendor/flasher/flasher.min.js'
# List of CSS files to style your notifications
styles:
- '/vendor/flasher/flasher.min.css'
# Set global options for all notifications (optional)
# options:
# # Time in milliseconds before the notification disappears
# timeout: 5000
# # Where the notification appears on the screen
# position: 'top-right'
# Automatically inject JavaScript and CSS assets into your HTML pages
inject_assets: true
# Enable message translation using Symfony's translation service
translate: true
# URL patterns to exclude from asset injection and flash_bag conversion
excluded_paths:
- '/^\/_profiler/'
- '/^\/_fragment/'
# Map Symfony flash message keys to notification types
flash_bag:
success: ['success']
error: ['error', 'danger']
warning: ['warning', 'alarm']
info: ['info', 'notice', 'alert']
# Set criteria to filter which notifications are displayed (optional)
# filter:
# # Maximum number of notifications to show at once
# limit: 5
# Define notification presets to simplify notification creation (optional)
# presets:
# # Example preset:
# entity_saved:
# type: 'success'
# title: 'Entity saved'
# message: 'Entity saved successfully'

View File

@@ -2,8 +2,12 @@
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
trusted_proxies: 'private_ranges'
# trust *all* "X-Forwarded-*" headers
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ]
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
#esi: true
#fragments: true

View File

@@ -0,0 +1,8 @@
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'

View File

@@ -5,13 +5,26 @@ security:
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\User\Framework\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: users_in_memory
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# where to redirect after logout
# target: app_any_route
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
@@ -22,8 +35,9 @@ security:
# 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: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:

View File

@@ -0,0 +1,11 @@
when@dev:
web_profiler:
toolbar: true
framework:
profiler:
collect_serializer_data: true
when@test:
framework:
profiler: { collect: false }

View File

@@ -6,3 +6,10 @@ controllersIndex:
defaults:
schemes: [ 'https' ]
controllersUser:
resource:
path: ../src/User/Framework/Controller
namespace: App\User\Framework\Controller
type: attribute
defaults:
schemes: ['https']

View File

@@ -0,0 +1,8 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
prefix: /_profiler

View File

@@ -23,3 +23,7 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader"
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- '%env(DATABASE_URL)%'

View File

@@ -12,4 +12,25 @@ services:
- /mnt/media/downloads:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
deploy:
replicas: 2
replicas: 2
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3000:80"
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins *
anonymous
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
volumes:
- mercure_data:/data
- mercure_config:/config
volumes:
mercure_config:
mercure_data:

View File

@@ -25,7 +25,7 @@ return [
'@symfony/ux-live-component' => [
'path' => './vendor/symfony/ux-live-component/assets/dist/live_controller.js',
],
'@flasher/flasher' => [
'version' => '2.1.5',
'@hotwired/turbo' => [
'version' => '7.3.0',
],
];

View File

@@ -0,0 +1,46 @@
<?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 Version20250428133608 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE preference (id VARCHAR(255) NOT NULL, name VARCHAR(255) DEFAULT NULL, description VARCHAR(255) DEFAULT NULL, enabled TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE preference_option (id INT AUTO_INCREMENT NOT NULL, preference_id VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, value VARCHAR(255) DEFAULT NULL, enabled TINYINT(1) NOT NULL, INDEX IDX_607C52FD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE preference_option ADD CONSTRAINT FK_607C52FD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (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 preference_option DROP FOREIGN KEY FK_607C52FD81022C0
SQL);
$this->addSql(<<<'SQL'
DROP TABLE preference
SQL);
$this->addSql(<<<'SQL'
DROP TABLE preference_option
SQL);
}
}

View File

@@ -0,0 +1,83 @@
<?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 Version20250428140450 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'
CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL COMMENT '(DC2Type:json)', password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE episode DROP FOREIGN KEY FK_DDAA1CDAACB7A4A
SQL);
$this->addSql(<<<'SQL'
DROP TABLE processed_messages
SQL);
$this->addSql(<<<'SQL'
DROP TABLE episode
SQL);
$this->addSql(<<<'SQL'
DROP TABLE series
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE messenger_messages CHANGE id id BIGINT AUTO_INCREMENT NOT NULL
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE processed_messages (id INT AUTO_INCREMENT NOT NULL, run_id INT NOT NULL, attempt SMALLINT NOT NULL, message_type VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, description VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, dispatched_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', received_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', finished_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', wait_time BIGINT NOT NULL, handle_time BIGINT NOT NULL, memory_usage INT NOT NULL, transport VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, tags VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, failure_type VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, failure_message LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, results JSON DEFAULT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE episode (id INT AUTO_INCREMENT NOT NULL, series_id_id INT DEFAULT NULL, imdb_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, tvdb_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, title VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, year VARCHAR(5) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, poster VARCHAR(500) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, season VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, episode VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, episode_code VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, download_directory VARCHAR(500) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, INDEX IDX_DDAA1CDAACB7A4A (series_id_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE series (id INT AUTO_INCREMENT NOT NULL, imdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, tvdb_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, title VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, year VARCHAR(5) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, poster VARCHAR(500) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, directory VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, number_seasons INT DEFAULT NULL, number_episodes INT DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE episode ADD CONSTRAINT FK_DDAA1CDAACB7A4A FOREIGN KEY (series_id_id) REFERENCES series (id)
SQL);
$this->addSql(<<<'SQL'
DROP TABLE user
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE messenger_messages CHANGE id id INT AUTO_INCREMENT NOT NULL
SQL);
}
}

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 Version20250429020903 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 name 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 name
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 Version20250429032311 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'
CREATE TABLE user_preference (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, preference_id VARCHAR(255) NOT NULL, preference_value VARCHAR(255) DEFAULT NULL, INDEX IDX_FA0E76BFA76ED395 (user_id), INDEX IDX_FA0E76BFD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference ADD CONSTRAINT FK_FA0E76BFA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference ADD CONSTRAINT FK_FA0E76BFD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (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 user_preference DROP FOREIGN KEY FK_FA0E76BFA76ED395
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference DROP FOREIGN KEY FK_FA0E76BFD81022C0
SQL);
$this->addSql(<<<'SQL'
DROP TABLE user_preference
SQL);
}
}

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 Version20250430231033 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'
CREATE TABLE sessions (sess_id VARBINARY(128) NOT NULL, sess_data LONGBLOB NOT NULL, sess_lifetime INT UNSIGNED NOT NULL, sess_time INT UNSIGNED NOT NULL, INDEX sess_lifetime_idx (sess_lifetime), PRIMARY KEY(sess_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_bin` ENGINE = InnoDB
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE sessions
SQL);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +0,0 @@
{
"/vendor/flasher/flasher.min.js": "/vendor/flasher/flasher.min.js?id=9a255a6680873c0d5fc3d394a2ba3195",
"/vendor/flasher/flasher.min.css": "/vendor/flasher/flasher.min.css?id=7a96e40c68626198d5128ad2fb5d77e0"
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
final class AlertController extends AbstractController
{
public function __construct(
#[Autowire(service: 'twig')] private readonly Environment $renderer,
private readonly HubInterface $hub,
) {}
#[Route('/alert', name: 'app_alert')]
public function index(): Response
{
$update = new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
'alert_id' => 1,
'title' => 'Added to queue',
'message' => 'This is a testy test!',
])
);
$this->hub->publish($update);
return $this->json([
'Success' => 'Published'
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Controller;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -11,6 +12,7 @@ use Symfony\Component\Routing\Attribute\Route;
class DownloadController extends AbstractController
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
) {}
@@ -18,6 +20,15 @@ class DownloadController extends AbstractController
public function download(
DownloadMediaInput $input,
): Response {
$download = $this->downloadRepository->insert(
$input->url,
$input->title,
$input->filename,
$input->imdbId,
$input->mediaType,
"",
);
$input->downloadId = $download->getId();
try {
$this->bus->dispatch($input->toCommand());
} catch (\Throwable $exception) {

View File

@@ -22,6 +22,7 @@ final class IndexController extends AbstractController
'active_downloads' => $this->downloadRepository->getActivePaginated(),
'recent_downloads' => $this->downloadRepository->latest(5),
'popular_movies' => $this->tmdb->popularMovies(1, 6),
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]);
}
}

View File

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

View File

@@ -21,14 +21,19 @@ readonly class DownloadMediaHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface
{
$download = $this->downloadRepository->insert(
$command->url,
$command->title,
$command->filename,
$command->imdbId,
$command->mediaType,
""
);
if (null === $command->downloadId) {
$download = $this->downloadRepository->insert(
$command->url,
$command->title,
$command->filename,
$command->imdbId,
$command->mediaType,
""
);
} else {
$download = $this->downloadRepository->find($command->downloadId);
}
try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');

View File

@@ -25,6 +25,8 @@ class DownloadMediaInput implements InputInterface
#[SourceRequest('imdbId')]
public string $imdbId,
public ?int $downloadId = null,
) {}
public function toCommand(): CommandInterface
@@ -35,6 +37,7 @@ class DownloadMediaInput implements InputInterface
$this->filename,
$this->mediaType,
$this->imdbId,
$this->downloadId,
);
}
}

View File

@@ -7,7 +7,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
#[Broadcast]
#[Broadcast(template: 'broadcast/Download.stream.html.twig')]
class Download
{
#[ORM\Id]

View File

@@ -111,6 +111,23 @@ class Tmdb
return $movies;
}
public function popularTvShows(int $page = 1, ?int $limit = null)
{
$movies = $this->tvRepository->getPopular(['page' => $page]);
$movies = $movies->map(function ($movie) use ($movies) {
return $this->parseResult($movies[$movie], "movie");
});
$movies = array_values($movies->toArray());
if (null !== $limit) {
$movies = array_slice($movies, 0, $limit);
}
return $movies;
}
public function search(string $term, int $page = 1)
{
$searchRepository = new SearchRepository($this->client);

View File

@@ -3,6 +3,7 @@
namespace App\Torrentio\Result;
use App\Util\CountryCodes;
use App\Util\CountryLanguages;
use Nihilarr\PTN;
class ResultFactory
@@ -89,13 +90,14 @@ class ResultFactory
});
$languages = array_map(function ($flag) {
return CountryCodes::convertFromAbbr(strtoupper(substr($flag['short_name'], strlen('flag-'))));
return CountryLanguages::fromCountryCode(strtoupper(substr($flag['short_name'], strlen('flag-'))));
},
$flags);
if (count($languages) > 0) {
return array_values($languages);
} else {
return ["English"];
return ["English (US)"];
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class Alert
{
use DefaultActionTrait;
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Twig\Components;
use Aimeos\Map;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class Filter
{
use DefaultActionTrait;
public array $preferences = [];
public array $userPreferences = [];
public function __construct(
private readonly PreferencesRepository $preferencesRepository,
private readonly Security $security,
) {
$this->preferences = Map::from($this->preferencesRepository->findEnabled())
->rekey(fn($element) => $element->getId())
->map(fn($element) => $element->getPreferenceOptions()->toArray())
->toArray();
$this->userPreferences = Map::from($this->security->getUser()->getUserPreferenceValues())
->toArray();
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\User\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<SaveUserMediaPreferencesCommand> */
class SaveUserMediaPreferencesCommand implements CommandInterface
{
public function __construct(
public string $resolution,
public string $codec,
public string $language,
public string $provider,
) {}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\User\Action\Handler;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Result\SaveUserMediaPreferencesResult;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface as R;
use Symfony\Bundle\SecurityBundle\Security;
/** @implements HandlerInterface<SaveUserMediaPreferencesCommand> */
class SaveUserMediaPreferencesHandler implements HandlerInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly PreferencesRepository $preferenceRepository,
private readonly Security $token,
) {}
public function handle(C $command): R
{
/** @var User $user */
$user = $this->token->getUser();
foreach ($command as $preference => $value) {
if ($user->hasUserPreference($preference)) {
$user->updateUserPreference($preference, $value);
$this->entityManager->flush();
continue;
}
$preference = $this->preferenceRepository->find($preference);
$user->addUserPreference(
(new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue($value)
);
}
$this->entityManager->flush();
return new SaveUserMediaPreferencesResult($user->getUserPreferences());
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\User\Action\Input;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<SaveUserMediaPreferencesInput, SaveUserMediaPreferencesCommand> */
class SaveUserMediaPreferencesInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public mixed $userId,
#[SourceRequest('resolution')]
public string $resolution,
#[SourceRequest('codec')]
public string $codec,
#[SourceRequest('language')]
public string $language,
#[SourceRequest('provider')]
public string $provider,
) {}
public function toCommand(): C
{
return new SaveUserMediaPreferencesCommand(
$this->resolution,
$this->codec,
$this->language,
$this->provider,
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\User\Action\Result;
use Doctrine\Common\Collections\Collection;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface */
class SaveUserMediaPreferencesResult implements ResultInterface
{
public function __construct(
public Collection $userPreferences,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\User\Framework\Controller\Api;
use Aimeos\Map;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class PreferencesApiController extends AbstractController
{
#[Route('/api/preferences/media', name: 'api_preferences_media')]
public function getMediaPreferences(
PreferencesRepository $preferencesRepository,
): Response {
return $this->json(
Map::from($preferencesRepository->findEnabled())
->rekey(fn($element) => $element->getId())
->toArray()
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\User\Framework\Controller\Api;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class UserApiController extends AbstractController
{
#[Route('/api/user/preferences/values', name: 'api_user_preferences_values')]
public function getUserPreferenceValues(
Security $security
): Response {
return $this->json(
$security->getUser()->getUserPreferenceValues()
);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\User\Framework\Controller\Web;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class LoginController extends AbstractController
{
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('user/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
#[Route(path: '/logout', name: 'app_logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\User\Framework\Controller\Web;
use Aimeos\Map;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use App\Util\CountryCodes;
use App\Util\CountryLanguages;
use App\Util\ProviderList;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class PreferencesController extends AbstractController
{
public function __construct(
private readonly PreferencesRepository $preferencesRepository,
private readonly SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
private readonly Security $security,
) {}
#[Route('/media/preferences', 'app_media_preferences', methods: ['GET'])]
public function mediaPreferences(): Response
{
$enabledPreferences = $this->preferencesRepository->findEnabled();
if ($this->security->getUser()->getUserPreferences()->count() !== count($enabledPreferences)) {
$this->setUserPreferences($this->security->getUser(), $enabledPreferences);
}
$userPreferences = $this->security->getUser()->getUserPreferences()->toArray();
$userPreferences = Map::from($userPreferences)
->rekey(fn($preference) => $preference->getPreference()->getId());
$languages = CountryLanguages::$languages;
sort($languages);
return $this->render(
'user/preferences.html.twig',
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'userPreferences' => $userPreferences->toArray(),
]
);
}
#[Route('/media/preferences', 'app_save_media_preferences', methods: ['POST'])]
public function saveMediaPreferences(
SaveUserMediaPreferencesInput $input,
): Response
{
$userPreferences = $this->saveUserMediaPreferencesHandler->handle($input->toCommand())->userPreferences;
$userPreferences = Map::from($userPreferences)->rekey(fn($preference) => $preference->getPreference()->getId());
$languages = CountryLanguages::$languages;
sort($languages);
return $this->render(
'user/preferences.html.twig',
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'userPreferences' => $userPreferences->toArray(),
]
);
}
private function setUserPreferences(User $user, array $preferences): void
{
foreach ($preferences as $preference) {
if (false === $user->hasUserPreference($preference->getId())) {
$user->addUserPreference((new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue(null)
);
}
}
$this->preferencesRepository->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\User\Framework\Controller\Web;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Form\RegistrationFormType;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $userPasswordHasher,
EntityManagerInterface $entityManager,
PreferencesRepository $preferencesRepository,
): Response {
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $plainPassword */
$plainPassword = $form->get('plainPassword')->getData();
// encode the plain password
$user->setPassword($userPasswordHasher->hashPassword($user, $plainPassword));
$entityManager->persist($user);
$entityManager->flush();
$this->setUserPreferences($user, $preferencesRepository->findEnabled());
$preferencesRepository->getEntityManager()->flush();
return $this->redirectToRoute('app_index');
}
return $this->render('user/register.html.twig', [
'registrationForm' => $form,
]);
}
private function setUserPreferences(User $user, array $preferences): void
{
foreach ($preferences as $preference) {
$user->addUserPreference((new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue(null)
);
}
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\User\Framework\Entity;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PreferencesRepository::class)]
class Preference
{
#[ORM\Id]
#[ORM\Column]
private ?string $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $description = null;
#[ORM\Column]
private ?bool $enabled = null;
/**
* @var Collection<int, PreferenceOption>
*/
#[ORM\OneToMany(targetEntity: PreferenceOption::class, mappedBy: 'preference', fetch: 'EAGER')]
private Collection $preferenceOptions;
public function __construct()
{
$this->preferenceOptions = new ArrayCollection();
}
public function getId(): ?string
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function isEnabled(): ?bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
/**
* @return Collection<int, PreferenceOption>
*/
public function getPreferenceOptions(): Collection
{
return $this->preferenceOptions;
}
public function addPreferenceOption(PreferenceOption $preferenceOption): static
{
if (!$this->preferenceOptions->contains($preferenceOption)) {
$this->preferenceOptions->add($preferenceOption);
$preferenceOption->setPreference($this);
}
return $this;
}
public function removePreferenceOption(PreferenceOption $preferenceOption): static
{
if ($this->preferenceOptions->removeElement($preferenceOption)) {
// set the owning side to null (unless already changed)
if ($preferenceOption->getPreference() === $this) {
$preferenceOption->setPreference(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\User\Framework\Entity;
use App\User\Framework\Repository\PreferenceOptionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Ignore;
#[ORM\Entity(repositoryClass: PreferenceOptionRepository::class)]
class PreferenceOption
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $value = null;
#[Ignore]
#[ORM\ManyToOne(inversedBy: 'preferenceOptions')]
private ?Preference $preference = null;
#[ORM\Column]
private ?bool $enabled = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
public function getValue(): ?string
{
return $this->value;
}
public function setValue(?string $value): static
{
$this->value = $value;
return $this;
}
public function getPreference(): ?Preference
{
return $this->preference;
}
public function setPreference(?Preference $preference): static
{
$this->preference = $preference;
return $this;
}
public function isEnabled(): ?bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\User\Framework\Entity;
use Aimeos\Map;
use App\User\Framework\Repository\PreferencesRepository;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $name;
#[ORM\Column(type: 'json')]
private array $roles = [];
#[ORM\Column(type: 'string')]
private string $password;
/**
* @var Collection<int, UserPreference>
*/
#[ORM\OneToMany(targetEntity: UserPreference::class, mappedBy: 'user', cascade: ['persist', 'remove'])]
private Collection $userPreferences;
public function __construct()
{
$this->userPreferences = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): self
{
$this->name = $name;
return $this;
}
/**
* The public representation of the user (e.g. a username, an email address, etc.)
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
/**
* @return Collection<int, UserPreference>
*/
public function getUserPreferences(): Collection
{
return $this->userPreferences;
}
public function getUserPreference(string $preferenceName)
{
foreach ($this->userPreferences as $userPreference) {
if ($userPreference->getPreference()->getName() === $preferenceName) {
return $userPreference->getPreference();
}
}
}
public function hasUserPreference(string $preferenceName): bool
{
foreach ($this->userPreferences as $userPreference) {
if ($userPreference->getPreference()->getId() === $preferenceName) {
return true;
}
}
return false;
}
public function updateUserPreference(string $preferenceName, mixed $preferenceValue): static
{
foreach ($this->userPreferences as $userPreference) {
if ($userPreference->getPreference()->getId() === $preferenceName) {
$userPreference->setPreferenceValue($preferenceValue);
}
}
return $this;
}
public function addUserPreference(UserPreference $userPreference): static
{
if (!$this->userPreferences->contains($userPreference)) {
$this->userPreferences->add($userPreference);
$userPreference->setUser($this);
}
return $this;
}
public function removeUserPreference(UserPreference $userPreference): static
{
if ($this->userPreferences->removeElement($userPreference)) {
// set the owning side to null (unless already changed)
if ($userPreference->getUser() === $this) {
$userPreference->setUser(null);
}
}
return $this;
}
public function getUserPreferenceValues()
{
return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
->map(function (UserPreference $userPreference) {
if (in_array($userPreference->getPreference()->getId(), ['language', 'provider'])) {
return $userPreference->getPreferenceValue();
}
foreach ($userPreference->getPreference()->getPreferenceOptions() as $preferenceOption) {
// dd((int) $userPreference->getPreferenceValue(), $preferenceOption->getId(), $preferenceOption->getValue());
if ($preferenceOption->getId() === (int) $userPreference->getPreferenceValue()) {
return $preferenceOption->getValue();
}
}
return null;
})
->toArray();
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\User\Framework\Entity;
use App\User\Framework\Repository\UserPreferenceRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserPreferenceRepository::class)]
class UserPreference
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'userPreferences')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\ManyToOne(fetch: 'EAGER')]
#[ORM\JoinColumn(nullable: false)]
private ?Preference $preference = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $preference_value = null;
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getPreference(): ?Preference
{
return $this->preference;
}
public function setPreference(?Preference $preference): static
{
$this->preference = $preference;
return $this;
}
public function getPreferenceValue(): ?string
{
return $this->preference_value;
}
public function setPreferenceValue(?string $preference_value): static
{
$this->preference_value = $preference_value;
return $this;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\User\Framework\Form;
use App\User\Framework\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class RegistrationFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email')
->add('name')
->add('plainPassword', PasswordType::class, [
// instead of being set onto the object directly,
// this is read and encoded in the controller
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 6,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\User\Framework\Repository;
use App\User\Framework\Entity\PreferenceOption;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PreferenceOption>
*/
class PreferenceOptionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PreferenceOption::class);
}
// /**
// * @return PreferenceOption[] Returns an array of PreferenceOption objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('p.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?PreferenceOption
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\User\Framework\Repository;
use App\User\Framework\Entity\Preference;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Preference>
*/
class PreferencesRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Preference::class);
}
/** @return Preference[] Returns an array of Preferences objects */
public function findEnabled(): array
{
return $this->createQueryBuilder('p')
->andWhere('p.enabled = :val')
->setParameter('val', true)
->getQuery()
->getResult();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\User\Framework\Repository;
use App\User\Framework\Entity\UserPreference;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserPreference>
*/
class UserPreferenceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserPreference::class);
}
public function findUserPreferences(int $userId): array
{
return $this->findBy(['userId' => $userId]);
}
public function findUserResolution(int $userId): ?UserPreference
{
return $this->findOneBy(['userId' => $userId, 'preference' => 'resolution']);
}
public function findUserCodec(int $userId): ?UserPreference
{
return $this->findOneBy(['userId' => $userId, 'preference' => 'codec']);
}
public function findUserLanguage(int $userId): ?UserPreference
{
return $this->findOneBy(['userId' => $userId, 'preference' => 'language']);
}
public function findUserProvider(int $userId): ?UserPreference
{
return $this->findOneBy(['userId' => $userId, 'preference' => 'resolution']);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\User\Framework\Repository;
use App\User\Framework\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/**
* Used to upgrade (rehash) the user's password automatically over time.
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
// /**
// * @return User[] Returns an array of User objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('u.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?User
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Util;
class CountryLanguages
{
public static $languages = [
'English (US)',
'English',
'French',
'German',
'Italian',
'Spanish',
'Portuguese',
'Mandarin',
'Japanese',
'Korean',
'Russian',
'Hindi',
'Arabic',
'Dutch',
'Swedish',
'Norwegian',
'Danish',
'Finnish',
'Polish',
'Turkish',
'Persian',
'Thai',
'Vietnamese',
'Indonesian',
'Urdu',
'Bengali',
'Ukrainian',
'Greek',
'Hebrew',
'Malay',
'Filipino'
];
public static function fromCountryCode(string $countryCode): string
{
$countryLanguages = [
'US' => 'English (US)',
'GB' => 'English',
'CA' => 'English', // Note: French is also official in parts of Canada
'FR' => 'French',
'DE' => 'German',
'IT' => 'Italian',
'ES' => 'Spanish',
'MX' => 'Spanish',
'BR' => 'Portuguese',
'PT' => 'Portuguese',
'CN' => 'Mandarin',
'JP' => 'Japanese',
'KR' => 'Korean',
'RU' => 'Russian',
'IN' => 'Hindi', // Note: India has multiple official languages
'SA' => 'Arabic',
'EG' => 'Arabic',
'ZA' => 'English', // South Africa has 11 official languages
'NG' => 'English',
'AU' => 'English',
'AR' => 'Spanish',
'CH' => 'German', // Also French, Italian, Romansh
'BE' => 'Dutch', // Also French and German
'NL' => 'Dutch',
'SE' => 'Swedish',
'NO' => 'Norwegian',
'DK' => 'Danish',
'FI' => 'Finnish',
'PL' => 'Polish',
'TR' => 'Turkish',
'IR' => 'Persian',
'TH' => 'Thai',
'VN' => 'Vietnamese',
'ID' => 'Indonesian',
'PK' => 'Urdu',
'BD' => 'Bengali',
'UA' => 'Ukrainian',
'GR' => 'Greek',
'IL' => 'Hebrew',
'MY' => 'Malay',
'PH' => 'Filipino',
'KE' => 'English', // Also Swahili
];
return $countryLanguages[$countryCode];
}
public static function fromCountryName(string $countryName): string
{
$countryLanguages = [
'United States' => 'English (US)',
'United Kingdom' => 'English',
'Canada' => 'English', // French is also official
'France' => 'French',
'Germany' => 'German',
'Italy' => 'Italian',
'Spain' => 'Spanish',
'Mexico' => 'Spanish',
'Brazil' => 'Portuguese',
'Portugal' => 'Portuguese',
'China' => 'Mandarin',
'Japan' => 'Japanese',
'South Korea' => 'Korean',
'Russia' => 'Russian',
'India' => 'Hindi', // Also English and many regional languages
'Saudi Arabia' => 'Arabic',
'Egypt' => 'Arabic',
'South Africa' => 'English', // One of 11 official languages
'Nigeria' => 'English',
'Australia' => 'English',
'Argentina' => 'Spanish',
'Switzerland' => 'German', // Also French, Italian, Romansh
'Belgium' => 'Dutch', // Also French and German
'Netherlands' => 'Dutch',
'Sweden' => 'Swedish',
'Norway' => 'Norwegian',
'Denmark' => 'Danish',
'Finland' => 'Finnish',
'Poland' => 'Polish',
'Turkey' => 'Turkish',
'Iran' => 'Persian',
'Thailand' => 'Thai',
'Vietnam' => 'Vietnamese',
'Indonesia' => 'Indonesian',
'Pakistan' => 'Urdu',
'Bangladesh' => 'Bengali',
'Ukraine' => 'Ukrainian',
'Greece' => 'Greek',
'Israel' => 'Hebrew',
'Malaysia' => 'Malay',
'Philippines' => 'Filipino',
'Kenya' => 'English', // Also Swahili
];
return $countryLanguages[$countryName];
}
}

26
src/Util/ProviderList.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Util;
class ProviderList
{
public static $providers = [
'1337x',
'Comando',
'EZTV',
'ilCorSaRoNeRo',
'MagnetDL',
'MejorTorrent',
'RARBG',
'Rutor',
'Rutracker',
'ThePirateBay',
'Torrent9',
'TorrentGalaxy',
];
public static function getProviders()
{
return self::$providers;
}
}

View File

@@ -29,9 +29,6 @@
"migrations/.gitignore"
]
},
"php-flasher/flasher-symfony": {
"version": "v2.1.6"
},
"php-http/discovery": {
"version": "1.20",
"recipe": {
@@ -124,6 +121,18 @@
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/mercure-bundle": {
"version": "0.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "0.3",
"ref": "528285147494380298f8f991ee8c47abebaf79db"
},
"files": [
"config/packages/mercure.yaml"
]
},
"symfony/messenger": {
"version": "7.2",
"recipe": {
@@ -214,6 +223,15 @@
"config/routes/ux_live_component.yaml"
]
},
"symfony/ux-turbo": {
"version": "2.24",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.20",
"ref": "c85ff94da66841d7ff087c19cbcd97a2df744ef9"
}
},
"symfony/ux-twig-component": {
"version": "2.24",
"recipe": {
@@ -238,6 +256,19 @@
"config/packages/validator.yaml"
]
},
"symfony/web-profiler-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.1",
"ref": "8b51135b84f4266e3b4c8a6dc23c9d1e32e543b7"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
},
"symfonycasts/tailwind-bundle": {
"version": "0.10",
"recipe": {

21
templates/bare.html.twig Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
{% endblock %}
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
</head>
<body class="bg-cyan-950 flex flex-col">
<h1 class="px-4 py-4 text-3xl font-extrabold text-orange-500">Torsearch</h1>
<div class="flex flex-col justify-center items-center">
{% block body %}{% endblock %}
</div>
</body>
</html>

View File

@@ -20,6 +20,7 @@
<twig:NavBar />
</div>
<div class="col-span-5">
<h2 class="p-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
{% block body %}{% endblock %}
</div>
</div>

View File

@@ -0,0 +1,5 @@
<turbo-stream action="prepend" target="alert_list">
<template>
<twig:Alert :title="title|default('')" :message="message" :alert_id="alert_id" data-controller="alert" />
</template>
</turbo-stream>

View File

@@ -0,0 +1,69 @@
{# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #}
{% block create %}
<turbo-stream action="append" target="active_downloads">
<template>
<tr id="ad_download_{{ entity.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<span class="p-1.5 bg-purple-600 rounded-full">
<span class="w-4 inline-block text-center text-gray-50">{{ entity.progress }}</span>
</span>
</td>
</tr>
</template>
</turbo-stream>
<turbo-stream action="prepend" target="alert_list">
<template>
<twig:Alert title="Success" message="{{ entity.title }} has been added to the Download queue" alert_id="{{ entity.id }}" data-controller="alert" />
</template>
</turbo-stream>
{% endblock %}
{% block update %}
{% if entity.status != "Complete" %}
<turbo-stream action="update" target="ad_download_{{ id }}">
<template>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<span class="p-1.5 bg-purple-600 rounded-full">
<span class="w-4 inline-block text-center text-gray-50">{{ entity.progress }}</span>
</span>
</td>
</template>
</turbo-stream>
{% else %}
<turbo-stream action="remove" target="ad_download_{{ id }}">
</turbo-stream>
<turbo-stream action="prepend" target="alert_list">
<template>
<twig:Alert title="Finished downloading" message="{{ entity.title }}" alert_id="{{ entity.id }}" data-controller="alert" />
</template>
</turbo-stream>
<turbo-stream action="prepend" target="recent_downloads">
<template>
<tr id="recent_download_{{ entity.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<span class="p-1.5 bg-purple-600 rounded-full">
<span class="w-4 inline-block text-center text-gray-50">{{ entity.progress }}</span>
</span>
</td>
</tr>
</template>
</turbo-stream>
{% endif %}
{% endblock %}
{% block remove %}
<turbo-stream action="remove" target="ad_download_{{ id }}"></turbo-stream>
{# <turbo-stream action="remove" target="cd_download_{{ id }}"></turbo-stream>#}
{% endblock %}

View File

@@ -1,5 +1,5 @@
<div{{ attributes }} class="min-w-48" data-poll="getDownloads">
<table class="divide-y divide-gray-200 dark:divide-gray-50 dark:bg-gray-50 table-fixed">
<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"
@@ -15,14 +15,14 @@
<tbody class="divide-y divide-gray-200 dark:divide-gray-50">
{% if this.getDownloads()|length > 0 %}
{% for download in this.getDownloads() %}
<tr>
<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>
<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 %}

View File

@@ -0,0 +1,23 @@
<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">
<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">
<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"/>
</svg>
<span class="sr-only">Info</span>
<h3 class="text-lg font-medium">{{ title|default('') }}</h3>
</div>
<div class="mt-2 text-sm">
{{ message }}
</div>
{# <div class="flex">#}
{# <button type="button" class="text-white bg-green-800 hover:bg-green-900 focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-xs px-3 py-1.5 me-2 text-center inline-flex items-center dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-800">#}
{# <svg class="me-2 h-3 w-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 14">#}
{# <path d="M10 0C4.612 0 0 5.336 0 7c0 1.742 3.546 7 10 7 6.454 0 10-5.258 10-7 0-1.664-4.612-7-10-7Zm0 10a3 3 0 1 1 0-6 3 3 0 0 1 0 6Z"/>#}
{# </svg>#}
{# View more#}
{# </button>#}
{# <button type="button" class="text-green-800 bg-transparent border border-green-800 hover:bg-green-900 hover:text-white focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-xs px-3 py-1.5 text-center dark:hover:bg-green-600 dark:border-green-600 dark:text-green-400 dark:hover:text-white dark:focus:ring-green-800" data-dismiss-target="#alert-additional-content-3" aria-label="Close">#}
{# Dismiss#}
{# </button>#}
{# </div>#}
</li>

View File

@@ -0,0 +1,94 @@
<div id="filter" class="flex flex-col gap-4"
{{ stimulus_controller('result_filter') }}
{{ stimulus_action('result_filter', 'filter', 'change') }}
data-result-filter-media-type-value="{{ results.media.mediaType }}"
data-result-filter-movie-results-outlet=".results"
data-result-filter-tv-results-outlet=".results"
>
<div class="w-full p-4 flex flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg">
<label for="resolution">
Resolution
<select id="resolution"
data-result-filter-target="resolution"
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
value="{{ app.user.userPreferenceValues["resolution"] }}"
>
<option value="">n/a</option>
{% for option in this.preferences['resolution'] %}
<option value="{{ option.value }}"
{{ option.value == this.userPreferences['resolution'] ? 'selected' }}
>{{ option.name }}</option>
{% endfor %}
</select>
</label>
<label for="codec">
Codec
<select id="codec" data-result-filter-target="codec" class="px-1 py-0.5 bg-stone-100 text-sm text-gray-800 rounded-md">
<option value="">n/a</option>
{% for option in this.preferences['codec'] %}
<option value="{{ option.value }}"
{{ option.value == this.userPreferences['codec'] ? 'selected' }}
>{{ option.name }}</option>
{% endfor %}
</select>
</label>
<label for="language">
Language
<select id="language"
data-result-filter-target="language"
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
{% if this.userPreferences['language'] != null %}
data-preferred="{{ this.userPreferences['language'] }}"
{% endif %}
>
</select>
</label>
<label for="provider">
Provider
<select id="provider"
data-result-filter-target="provider"
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
{% if this.userPreferences['provider'] != null %}
data-preferred="{{ this.userPreferences['provider'] }}"
{% endif %}
>
</select>
</label>
{% if results.media.mediaType == "tvshows" %}
<label for="season">
Season
<select id="season" name="season" value="1" data-result-filter-target="season" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}>
<option selected value="1">1</option>
{% for season in range(2, results.media.episodes|length) %}
<option value="{{ season }}">{{ season }}</option>
{% endfor %}
</select>
</label>
{# <label for="episodeNumber">#}
{# Episode#}
{# <select id="episodeNumber" name="episodeNumber" data-result-filter-target="episode" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-sm">#}
{# <option selected value="">n/a</option>#}
{# </select>#}
{# </label>#}
{% endif %}
<span {{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }}
class="loading-icon"
>
<twig:ux:icon name="codex:loader" height="20" width="20" data-loading-icon-target="icon" class="text-end" />
</span>
</div>
{% if results.media.mediaType == "tvshows" %}
<div class="flex flex-row gap-2 justify-end px-8">
<button class="px-1.5 py-1 bg-green-600 rounded-md text-sm"
{{ stimulus_target('result_filter', 'downloadSelected') }}
{{ stimulus_action('result_filter', 'downloadSelectedEpisodes', 'click') }}
>Download Selected</button>
<input type="checkbox" name="selectAll" id="selectAll"
{{ stimulus_target('result_filter', 'selectAll') }}
{{ stimulus_action('result_filter', 'selectAllEpisodes', 'change') }}
/>
</div>
{% endif %}
</div>

View File

@@ -6,10 +6,21 @@
<div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block">
<ul class="flex items-center gap-6 text-sm">
<twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/>
<li><twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/></li>
<li>
<a href="{{ path('app_logout') }}">
<twig:ux:icon name="material-symbols:logout" width="25px" class="text-orange-500" />
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div {{ turbo_stream_listen('alerts') }} class="absolute top-10 right-10 size-96">
<div >
<ul id="alert_list">
</ul>
</div>
</div>
</header>

View File

@@ -59,7 +59,7 @@
<li>
<a
href="#"
href="{{ path('app_media_preferences') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700"
>
Preferences
@@ -68,19 +68,19 @@
</ul>
</div>
<div class="sticky inset-x-0 bottom-0 border-t border-gray-100">
<a href="#" class="flex items-center gap-2 bg-white p-4 hover:bg-gray-50">
<img
alt=""
src="https://images.unsplash.com/photo-1600486913747-55e5470d6f40?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1770&q=80"
class="size-10 rounded-full object-cover"
/>
<div class="sticky inset-x-0 bottom-0 border-t border-orange-500">
<a href="#" class="flex items-center gap-2 p-4 bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60">
<span class="rounded-full p-2 border-orange-500 border-2">
<twig:ux:icon name="ri:user-line" width="30" class="text-gray-50"/>
</span>
<div>
<p class="text-xs">
<strong class="block font-medium">Eric Frusciante</strong>
{% if app.user.name %}
<strong class="block font-medium text-white">{{ app.user.name }}</strong>
{% endif %}
<span> eric@frusciante.com </span>
<span class="text-white"> {{ app.user.email }} </span>
</p>
</div>
</a>

View File

@@ -1,12 +1,12 @@
<div{{ attributes }}>
<a href="{{ path('app_search_result', {
mediaType: "movies",
mediaType: mediaType,
tmdbId: tmdbId
}) }}">
<img src="{{ image }}" class="w-40 rounded-md" />
</a>
<a href="{{ path('app_search_result', {
mediaType: "movies",
mediaType: mediaType,
tmdbId: tmdbId
}) }}">
<h3 class="text-center text-gray-50 max-w-[16ch] text-extrabold">{{ title }}</h3>

View File

@@ -12,7 +12,7 @@
/>
<button
class="absolute top-1 right-1 flex items-center rounded bg-green-600 py-1 px-2.5 border border-transparent text-center text-sm text-white transition-all shadow-sm hover:shadow focus:bg-green-700 focus:shadow-none active:bg-green-700 hover:bg-green-700 active:shadow-none disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
type="submit"
>
Search
</button>

View File

@@ -11,7 +11,7 @@
</twig:Card>
<twig:Card title="Recent Downloads" class="w-full">
<table class="divide-y divide-gray-200 dark:divide-gray-50 dark:bg-gray-50 table-fixed">
<table id="recent_downloads" class="divide-y divide-gray-200 dark:divide-gray-50 dark:bg-gray-50 table-fixed">
<thead>
<tr class="dark:bg-gray-50">
<th scope="col"
@@ -54,7 +54,7 @@
</table>
</twig:Card>
</div>
<div class="">
<div class="flex flex-col gap-4">
<twig:Card title="Popular Movies" contentClass="flex flex-row justify-between w-full">
{% for movie in popular_movies %}
<twig:Poster imdbId=""
@@ -62,7 +62,21 @@
title="{{ movie.title }}"
description="{{ movie.description }}"
image="{{ movie.poster }}"
year="{{ movie.year }}" />
year="{{ movie.year }}"
mediaType="movies"
/>
{% endfor %}
</twig:Card>
<twig:Card title="Popular TV Shows" contentClass="flex flex-row justify-between w-full">
{% for movie in popular_tvshows %}
<twig:Poster imdbId=""
tmdbId="{{ movie.tmdbId }}"
title="{{ movie.title }}"
description="{{ movie.description }}"
image="{{ movie.poster }}"
year="{{ movie.year }}"
mediaType="tvshows"
/>
{% endfor %}
</twig:Card>
</div>

View File

@@ -8,7 +8,11 @@
<div class="w-full p-4 flex flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg">
<label for="resolution">
Resolution
<select id="resolution" data-result-filter-target="resolution" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md">
<select id="resolution"
data-result-filter-target="resolution"
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
value="{{ app.user.userPreferenceValues["resolution"] }}"
>
<option {{ filter.resolution == "n/a" ? "selected" }}
value="">n/a</option>
<option {{ filter.resolution == "720p" ? "selected" }}

View File

@@ -19,7 +19,7 @@
</div>
</div>
{{ include('search/partial/filter.html.twig') }}
<twig:Filter results="{{ results }}" filter="{{ filter }}" />
{% if "movies" == results.media.mediaType %}
<div class="results" {{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}>

View File

@@ -34,7 +34,7 @@
</thead>
<tbody>
{% for result in results.results %}
<tr class="bg-white border-b dark:bg-slate-700 dark:border-gray-600 border-gray-200" data-languages="{{ result.languages|json_encode }}" {% if "tvshows" == results.media.mediaType %} data-season="{{ results.season }} {% endif %}">
<tr class="bg-white border-b dark:bg-slate-700 dark:border-gray-600 border-gray-200" data-provider="{{ result.provider }}" data-languages="{{ result.languages|json_encode }}" {% if "tvshows" == results.media.mediaType %} data-season="{{ results.season }} {% endif %}">
<td id="size" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.size }}
</td>

View File

@@ -0,0 +1,52 @@
{% extends 'bare.html.twig' %}
{% block title %}Log in &mdash; Torsearch{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500 p-4 rounded-lg gap-2 min-w-96">
<h2 class="text-xl text-gray-50">Login</h2>
<form method="post" class="flex flex-col gap-2">
{% if error %}
<div class="bg-red-400 border-red-600 rounded p-2 text-red-600">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
{% endif %}
{% if app.user %}
<div class="mb-3">
You are logged in as {{ app.user.userIdentifier }}, <a href="{{ path('app_logout') }}">Logout</a>
</div>
{% endif %}
<label for="username" class="mb-2 flex flex-col text-gray-950">
Email
<input type="email"
value="{{ last_username }}"
name="_username"
id="username"
class="bg-gray-50 text-gray-950 p-1 rounded-md"
autocomplete="email"
required autofocus>
</label>
<label for="password" class="mb-2 flex flex-col text-gray-950">
Password
<input type="password"
name="_password"
id="password"
class="bg-gray-50 text-gray-950 p-1 rounded-md"
autocomplete="current-password"
required>
</label>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" data-controller="csrf-protection">
<div class="mb-2">
<input type="checkbox" name="_remember_me" id="_remember_me">
<label for="_remember_me">Remember me</label>
</div>
<button class="bg-green-600 px-1.5 py-1 rounded-md text-gray-50" type="submit">
Sign in
</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends 'base.html.twig' %}
{% block title %}Preferences{% endblock %}
{% block h2 %}Preferences{% endblock %}
{% block body %}
<div class="p-4 flex flex-col">
<twig:Card title="Choose your preferences">
<p class="text-gray-50 mb-2">Define a set of filters to apply to your media download option results.</p>
<form id="media_preferences" class="flex flex-col max-w-64" name="media_preferences" method="post" action="{{ path('app_media_preferences') }}">
<label class="text-gray-50" for="resolution">Resolution</label>
<select class="p-1.5 rounded-md mb-2" name="resolution" id="resolution" value="{{ userPreferences['resolution'].getPreferenceValue() }}">
{% for pref in userPreferences['resolution'].getPreference().getPreferenceOptions() %}
<option class="text-gray-800"
value="{{ pref.id }}"
{{ pref.id == userPreferences['resolution'].getPreferenceValue() ? "selected" }}
>{{ pref.name }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="codec">Codec</label>
<select class="p-1.5 rounded-md mb-2" name="codec" id="codec" value="{{ userPreferences['codec'].getPreferenceValue() }}">
{% for pref in userPreferences['codec'].getPreference().getPreferenceOptions() %}
<option class="text-gray-800"
value="{{ pref.id }}"
{{ pref.id == userPreferences['codec'].getPreferenceValue() ? "selected" }}
>{{ pref.name }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="provider">Provider</label>
<select class="p-1.5 rounded-md mb-2" name="provider" id="provider" value="{{ userPreferences['provider'].getPreferenceValue() }}">
<option class="text-gray-800" value=""
{{ "" == userPreferences['provider'].getPreferenceValue() ? "selected" }}
>n/a</option>
{% for provider in providers %}
<option class="text-gray-800"
value="{{ provider }}"
{{ provider == userPreferences['provider'].getPreferenceValue() ? "selected" }}
>{{ provider }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="language">Language</label>
<select class="p-1.5 rounded-md mb-2" name="language" id="language" value="{{ userPreferences['language'].getPreferenceValue() }}">
{% for language in languages %}
<option class="text-gray-800"
value="{{ language }}"
{{ language == userPreferences['language'].getPreferenceValue() ? "selected" }}
>{{ language }}</option>
{% endfor %}
</select>
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
</form>
</twig:Card>
</div>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends 'bare.html.twig' %}
{% block title %}Register{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500 p-4 rounded-lg gap-2 min-w-96">
<h2 class="text-xl text-gray-50">Register</h2>
{{ form_errors(registrationForm) }}
{{ form_start(registrationForm) }}
<label for="name" class="flex flex-col mb-2">
{{ field_label(registrationForm.name) }}
<input type="text"
name="{{ field_name(registrationForm.name) }}"
id="{{ field_name(registrationForm.name) }}"
value="{{ field_value(registrationForm.name) }}"
class="bg-gray-50 text-gray-950 p-1 rounded-md" />
</label>
<label for="email" class="flex flex-col mb-2">
{{ field_label(registrationForm.email) }}
<input type="email"
name="{{ field_name(registrationForm.email) }}"
id="{{ field_name(registrationForm.email) }}"
value="{{ field_value(registrationForm.email) }}"
class="bg-gray-50 text-gray-950 p-1 rounded-md" />
</label>
<label for="password" class="flex flex-col mb-2">
{{ field_label(registrationForm.plainPassword) }}
<input type="password"
name="{{ field_name(registrationForm.plainPassword) }}"
id="{{ field_name(registrationForm.plainPassword) }}"
value="{{ field_value(registrationForm.plainPassword) }}"
class="bg-gray-50 text-gray-950 p-1 rounded-md" />
</label>
<button type="submit" class="bg-green-600 px-1.5 py-1 rounded-md text-gray-50">Register</button>
{{ form_end(registrationForm) }}
</div>
{% endblock %}