Work in progress…
This commit is contained in:
parent
9948627a7d
commit
852b5f3e17
2
.env
2
.env
|
@ -26,7 +26,7 @@ APP_SECRET=c798512b7c48614d5278e8e47bb556ce
|
|||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
|
||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=15&charset=utf8"
|
||||
DATABASE_URL="postgresql://lsbc:dev@127.0.0.1:5432/lsbc?serverVersion=15&charset=utf8"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
/public/uploads/
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
/.env.local
|
||||
|
|
11
.phpactor.json
Normal file
11
.phpactor.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "/home/dece/Dev/DevTools/phpactor/phpactor.schema.json",
|
||||
"language_server_psalm.enabled": true,
|
||||
"symfony.enabled": true,
|
||||
"indexer.exclude_patterns": [
|
||||
"/vendor/**/Tests/**/*",
|
||||
"/vendor/**/tests/**/*",
|
||||
"/var/cache/**/*",
|
||||
"/vendor/composer/**/*"
|
||||
]
|
||||
}
|
|
@ -6,11 +6,13 @@
|
|||
"require": {
|
||||
"php": ">=8.1",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-iconv": "*",
|
||||
"doctrine/annotations": "^2.0",
|
||||
"doctrine/doctrine-bundle": "^2.9",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.2",
|
||||
"doctrine/orm": "^2.15",
|
||||
"easycorp/easyadmin-bundle": "^4",
|
||||
"phpdocumentor/reflection-docblock": "^5.3",
|
||||
"phpstan/phpdoc-parser": "^1.20",
|
||||
"sensio/framework-extra-bundle": "^6.1",
|
||||
|
@ -96,9 +98,10 @@
|
|||
"symfony/browser-kit": "6.2.*",
|
||||
"symfony/css-selector": "6.2.*",
|
||||
"symfony/debug-bundle": "6.2.*",
|
||||
"symfony/maker-bundle": "^1.0",
|
||||
"symfony/maker-bundle": "^1.48",
|
||||
"symfony/phpunit-bridge": "^6.2",
|
||||
"symfony/stopwatch": "6.2.*",
|
||||
"symfony/web-profiler-bundle": "6.2.*"
|
||||
"symfony/web-profiler-bundle": "6.2.*",
|
||||
"vimeo/psalm": "^5.11"
|
||||
}
|
||||
}
|
||||
|
|
1055
composer.lock
generated
1055
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -12,4 +12,5 @@ return [
|
|||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
|
||||
EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true],
|
||||
];
|
||||
|
|
4
config/packages/uid.yaml
Normal file
4
config/packages/uid.yaml
Normal file
|
@ -0,0 +1,4 @@
|
|||
framework:
|
||||
uid:
|
||||
default_uuid_version: 7
|
||||
time_based_uuid_version: 7
|
54
migrations/Version20230506144146.php
Normal file
54
migrations/Version20230506144146.php
Normal file
|
@ -0,0 +1,54 @@
|
|||
<?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 Version20230506144146 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SEQUENCE episode_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
||||
$this->addSql('CREATE SEQUENCE podcast_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
||||
$this->addSql('CREATE TABLE episode (id INT NOT NULL, podcast_id INT NOT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, audio_filename VARCHAR(255) DEFAULT NULL, publication_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE INDEX IDX_DDAA1CDA786136AB ON episode (podcast_id)');
|
||||
$this->addSql('CREATE TABLE podcast (id INT NOT NULL, name VARCHAR(255) NOT NULL, website VARCHAR(255) NOT NULL, description TEXT NOT NULL, author VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, logo_filename VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE TABLE messenger_messages (id BIGSERIAL NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
|
||||
$this->addSql('CREATE OR REPLACE FUNCTION notify_messenger_messages() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify(\'messenger_messages\', NEW.queue_name::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;');
|
||||
$this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;');
|
||||
$this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON messenger_messages FOR EACH ROW EXECUTE PROCEDURE notify_messenger_messages();');
|
||||
$this->addSql('ALTER TABLE episode ADD CONSTRAINT FK_DDAA1CDA786136AB FOREIGN KEY (podcast_id) REFERENCES podcast (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
$this->addSql('DROP SEQUENCE episode_id_seq CASCADE');
|
||||
$this->addSql('DROP SEQUENCE podcast_id_seq CASCADE');
|
||||
$this->addSql('ALTER TABLE episode DROP CONSTRAINT FK_DDAA1CDA786136AB');
|
||||
$this->addSql('DROP TABLE episode');
|
||||
$this->addSql('DROP TABLE podcast');
|
||||
$this->addSql('DROP TABLE messenger_messages');
|
||||
}
|
||||
}
|
36
migrations/Version20230508171219.php
Normal file
36
migrations/Version20230508171219.php
Normal file
|
@ -0,0 +1,36 @@
|
|||
<?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 Version20230508171219 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE podcast ADD slug VARCHAR(255)');
|
||||
$this->addSql('UPDATE podcast SET slug = LOWER(CAST(id AS VARCHAR))');
|
||||
$this->addSql('ALTER TABLE podcast ALTER COLUMN slug SET NOT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_D7E805BD989D9B62 ON podcast (slug)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
$this->addSql('DROP INDEX UNIQ_D7E805BD989D9B62');
|
||||
$this->addSql('ALTER TABLE podcast DROP slug');
|
||||
}
|
||||
}
|
16
psalm.xml
Normal file
16
psalm.xml
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0"?>
|
||||
<psalm
|
||||
errorLevel="3"
|
||||
resolveFromConfigFile="true"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="https://getpsalm.org/schema/config"
|
||||
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
|
||||
findUnusedBaselineEntry="true"
|
||||
>
|
||||
<projectFiles>
|
||||
<directory name="src" />
|
||||
<ignoreFiles>
|
||||
<directory name="vendor" />
|
||||
</ignoreFiles>
|
||||
</projectFiles>
|
||||
</psalm>
|
0
public/uploads/files/.gitkeep
Normal file
0
public/uploads/files/.gitkeep
Normal file
0
public/uploads/images/.gitkeep
Normal file
0
public/uploads/images/.gitkeep
Normal file
12
src/Constants.php
Normal file
12
src/Constants.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
class Constants
|
||||
{
|
||||
public const BASE_PUBLIC_DIR = 'public';
|
||||
|
||||
protected const UPLOADS_BASE_PATH = '/uploads';
|
||||
public const IMAGES_BASE_PATH = Constants::UPLOADS_BASE_PATH.'/images/';
|
||||
public const FILES_BASE_PATH = Constants::UPLOADS_BASE_PATH.'/files/';
|
||||
}
|
34
src/Controller/Admin/DashboardController.php
Normal file
34
src/Controller/Admin/DashboardController.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Controller\Admin\PodcastCrudController;
|
||||
use App\Entity\Episode;
|
||||
use App\Entity\Podcast;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class DashboardController extends AbstractDashboardController
|
||||
{
|
||||
#[Route('/admin', name: 'admin')]
|
||||
public function index(): Response
|
||||
{
|
||||
$adminUrlGenerator = $this->container->get(AdminUrlGenerator::class);
|
||||
return $this->redirect($adminUrlGenerator->setController(PodcastCrudController::class)->generateUrl());
|
||||
}
|
||||
|
||||
public function configureDashboard(): Dashboard
|
||||
{
|
||||
return Dashboard::new()->setTitle('LSBC');
|
||||
}
|
||||
|
||||
public function configureMenuItems(): iterable
|
||||
{
|
||||
yield MenuItem::linkToCrud('Podcasts', 'fas fa-list', Podcast::class);
|
||||
yield MenuItem::linkToCrud('Episodes', 'fas fa-list', Episode::class);
|
||||
}
|
||||
}
|
33
src/Controller/Admin/EpisodeCrudController.php
Normal file
33
src/Controller/Admin/EpisodeCrudController.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Constants;
|
||||
use App\Entity\Episode;
|
||||
use App\Controller\Admin\Field\FileField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextEditorField;
|
||||
|
||||
class EpisodeCrudController extends AbstractCrudController
|
||||
{
|
||||
public static function getEntityFqcn(): string
|
||||
{
|
||||
return Episode::class;
|
||||
}
|
||||
|
||||
public function configureFields(string $pageName): iterable
|
||||
{
|
||||
return [
|
||||
TextField::new('title'),
|
||||
TextEditorField::new('description'),
|
||||
AssociationField::new('podcast'),
|
||||
DateTimeField::new('publicationDate'),
|
||||
FileField::new('audioFilename')
|
||||
->setUploadDir(Constants::BASE_PUBLIC_DIR . Constants::FILES_BASE_PATH)
|
||||
->setBasePath(Constants::FILES_BASE_PATH),
|
||||
];
|
||||
}
|
||||
}
|
80
src/Controller/Admin/Field/FileField.php
Normal file
80
src/Controller/Admin/Field/FileField.php
Normal file
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin\Field;
|
||||
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Asset;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\TextAlign;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\FileUploadType;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
|
||||
/**
|
||||
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
|
||||
*/
|
||||
final class FileField implements FieldInterface
|
||||
{
|
||||
use FieldTrait;
|
||||
|
||||
public const OPTION_BASE_PATH = 'basePath';
|
||||
public const OPTION_UPLOAD_DIR = 'uploadDir';
|
||||
public const OPTION_UPLOADED_FILE_NAME_PATTERN = 'uploadedFileNamePattern';
|
||||
|
||||
/**
|
||||
* @param TranslatableInterface|string|false|null $label
|
||||
*/
|
||||
public static function new(string $propertyName, $label = null): self
|
||||
{
|
||||
return (new self())
|
||||
->setProperty($propertyName)
|
||||
->setLabel($label)
|
||||
->setTemplatePath('admin/field/file.html.twig')
|
||||
->setFormType(FileUploadType::class)
|
||||
->addCssClass('field-file')
|
||||
->addJsFiles(Asset::fromEasyAdminAssetPackage('field-file-upload.js'))
|
||||
->setTextAlign(TextAlign::CENTER)
|
||||
->setCustomOption(self::OPTION_BASE_PATH, null)
|
||||
->setCustomOption(self::OPTION_UPLOAD_DIR, null)
|
||||
->setCustomOption(self::OPTION_UPLOADED_FILE_NAME_PATTERN, '[name].[extension]');
|
||||
}
|
||||
|
||||
public function setBasePath(string $path): self
|
||||
{
|
||||
$this->setCustomOption(self::OPTION_BASE_PATH, $path);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative to project's root directory (e.g. use 'public/uploads/' for `<your-project-dir>/public/uploads/`)
|
||||
* Default upload dir: `<your-project-dir>/public/uploads/images/`.
|
||||
*/
|
||||
public function setUploadDir(string $uploadDirPath): self
|
||||
{
|
||||
$this->setCustomOption(self::OPTION_UPLOAD_DIR, $uploadDirPath);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|\Closure $patternOrCallable
|
||||
*
|
||||
* If it's a string, uploaded files will be renamed according to the given pattern.
|
||||
* The pattern can include the following special values:
|
||||
* [day] [month] [year] [timestamp]
|
||||
* [name] [slug] [extension] [contenthash]
|
||||
* [randomhash] [uuid] [ulid]
|
||||
* (e.g. [year]/[month]/[day]/[slug]-[contenthash].[extension])
|
||||
*
|
||||
* If it's a callable, you will be passed the Symfony's UploadedFile instance and you must
|
||||
* return a string with the new filename.
|
||||
* (e.g. fn(UploadedFile $file) => sprintf('upload_%d_%s.%s', random_int(1, 999), $file->getFilename(), $file->guessExtension()))
|
||||
*/
|
||||
public function setUploadedFileNamePattern($patternOrCallable): self
|
||||
{
|
||||
$this->setCustomOption(self::OPTION_UPLOADED_FILE_NAME_PATTERN, $patternOrCallable);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
34
src/Controller/Admin/PodcastCrudController.php
Normal file
34
src/Controller/Admin/PodcastCrudController.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Constants;
|
||||
use App\Entity\Podcast;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField;
|
||||
use EasyCorp\Bundle\EasyAdminBundle\Field\TextEditorField;
|
||||
|
||||
class PodcastCrudController extends AbstractCrudController
|
||||
{
|
||||
public static function getEntityFqcn(): string
|
||||
{
|
||||
return Podcast::class;
|
||||
}
|
||||
|
||||
public function configureFields(string $pageName): iterable
|
||||
{
|
||||
return [
|
||||
TextField::new('name'),
|
||||
UrlField::new('website'),
|
||||
TextEditorField::new('description'),
|
||||
TextField::new('author'),
|
||||
EmailField::new('email'),
|
||||
ImageField::new('logoFilename')
|
||||
->setUploadDir(Constants::BASE_PUBLIC_DIR . Constants::IMAGES_BASE_PATH)
|
||||
->setBasePath(Constants::IMAGES_BASE_PATH),
|
||||
];
|
||||
}
|
||||
}
|
51
src/Controller/FeedController.php
Normal file
51
src/Controller/FeedController.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\PodcastRepository;
|
||||
use App\Service\FeedService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class FeedController extends AbstractController
|
||||
{
|
||||
#[Route('/', name: 'app_index')]
|
||||
public function index(
|
||||
PodcastRepository $podcastRepository,
|
||||
): Response
|
||||
{
|
||||
$podcasts = $podcastRepository->findAll();
|
||||
return $this->render('feed/index.html.twig', ['podcasts' => $podcasts]);
|
||||
}
|
||||
|
||||
#[Route('/podcasts/{slug}', name: 'app_podcast')]
|
||||
public function podcast(
|
||||
string $slug,
|
||||
PodcastRepository $podcastRepository,
|
||||
): Response
|
||||
{
|
||||
$podcast = $podcastRepository->findOneBy(['slug' => $slug]);
|
||||
return $this->render('feed/podcast.html.twig', ['podcast' => $podcast]);
|
||||
}
|
||||
|
||||
#[Route('/podcasts/{slug}/feed', name: 'app_podcast_feed')]
|
||||
public function podcastFeed(
|
||||
PodcastRepository $podcastRepository,
|
||||
FeedService $feedService
|
||||
): Response
|
||||
{
|
||||
$podcast = $podcastRepository->find(1);
|
||||
if ($podcast === null)
|
||||
return $this->render('404.html.twig');
|
||||
|
||||
$xml = $feedService->generate($podcast);
|
||||
if ($xml === false)
|
||||
return $this->render('500.html.twig');
|
||||
|
||||
$response = new Response();
|
||||
$response->headers->set('Content-Type', 'application/rss+xml');
|
||||
$response->setContent($xml);
|
||||
return $response;
|
||||
}
|
||||
}
|
109
src/Entity/Episode.php
Normal file
109
src/Entity/Episode.php
Normal file
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Constants;
|
||||
use App\Entity\Podcast;
|
||||
use App\Repository\EpisodeRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EpisodeRepository::class)]
|
||||
##[Vich\Uploadable]
|
||||
class Episode
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $title;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private string $description;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $audioFilename;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'episodes')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private Podcast $podcast;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
||||
private \DateTimeInterface $publicationDate;
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): self
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAudioFilename(): ?string
|
||||
{
|
||||
return $this->audioFilename;
|
||||
}
|
||||
|
||||
public function setAudioFilename(?string $audioFilename): self
|
||||
{
|
||||
$this->audioFilename = $audioFilename;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPodcast(): Podcast
|
||||
{
|
||||
return $this->podcast;
|
||||
}
|
||||
|
||||
public function setPodcast(Podcast $podcast): self
|
||||
{
|
||||
$this->podcast = $podcast;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPublicationDate(): \DateTimeInterface
|
||||
{
|
||||
return $this->publicationDate;
|
||||
}
|
||||
|
||||
public function setPublicationDate(\DateTimeInterface $publicationDate): self
|
||||
{
|
||||
$this->publicationDate = $publicationDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAudioUrl(): ?string
|
||||
{
|
||||
if ($this->audioFilename === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Constants::FILES_BASE_PATH . $this->audioFilename;
|
||||
}
|
||||
}
|
179
src/Entity/Podcast.php
Normal file
179
src/Entity/Podcast.php
Normal file
|
@ -0,0 +1,179 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\PodcastRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||
|
||||
#[ORM\Entity(repositoryClass: PodcastRepository::class)]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class Podcast
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(length: 255, unique: true)]
|
||||
private string $slug;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'podcast', targetEntity: Episode::class)]
|
||||
private Collection $episodes;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $website;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private string $description;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $author;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $email;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private string $logoFilename;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->episodes = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSlug(): string
|
||||
{
|
||||
return $this->slug;
|
||||
}
|
||||
|
||||
public function setSlug(string $slug): self
|
||||
{
|
||||
$this->slug = $slug;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function generateSlug(SluggerInterface $slugger): void
|
||||
{
|
||||
$this->slug = $this->id . '-' . $slugger->slug($this->name)->lower();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Episode>
|
||||
*/
|
||||
public function getEpisodes(): Collection
|
||||
{
|
||||
return $this->episodes;
|
||||
}
|
||||
|
||||
public function addEpisode(Episode $episode): self
|
||||
{
|
||||
if (!$this->episodes->contains($episode)) {
|
||||
$this->episodes->add($episode);
|
||||
$episode->setPodcast($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeEpisode(Episode $episode): self
|
||||
{
|
||||
if ($this->episodes->removeElement($episode)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($episode->getPodcast() === $this) {
|
||||
$episode->setPodcast(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWebsite(): string
|
||||
{
|
||||
return $this->website;
|
||||
}
|
||||
|
||||
public function setWebsite(string $website): self
|
||||
{
|
||||
$this->website = $website;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthor(): string
|
||||
{
|
||||
return $this->author;
|
||||
}
|
||||
|
||||
public function setAuthor(string $authorName): self
|
||||
{
|
||||
$this->author = $authorName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): self
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLogoFilename(): string
|
||||
{
|
||||
return $this->logoFilename;
|
||||
}
|
||||
|
||||
public function setLogoFilename(string $logoFilename): self
|
||||
{
|
||||
$this->logoFilename = $logoFilename;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
66
src/Repository/EpisodeRepository.php
Normal file
66
src/Repository/EpisodeRepository.php
Normal file
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Episode;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Episode>
|
||||
*
|
||||
* @method Episode|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method Episode|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method Episode[] findAll()
|
||||
* @method Episode[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class EpisodeRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Episode::class);
|
||||
}
|
||||
|
||||
public function save(Episode $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(Episode $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @return Episode[] Returns an array of Episode objects
|
||||
// */
|
||||
// public function findByExampleField($value): array
|
||||
// {
|
||||
// return $this->createQueryBuilder('e')
|
||||
// ->andWhere('e.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->orderBy('e.id', 'ASC')
|
||||
// ->setMaxResults(10)
|
||||
// ->getQuery()
|
||||
// ->getResult()
|
||||
// ;
|
||||
// }
|
||||
|
||||
// public function findOneBySomeField($value): ?Episode
|
||||
// {
|
||||
// return $this->createQueryBuilder('e')
|
||||
// ->andWhere('e.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->getQuery()
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
}
|
66
src/Repository/PodcastRepository.php
Normal file
66
src/Repository/PodcastRepository.php
Normal file
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Podcast;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Podcast>
|
||||
*
|
||||
* @method Podcast|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method Podcast|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method Podcast[] findAll()
|
||||
* @method Podcast[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class PodcastRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Podcast::class);
|
||||
}
|
||||
|
||||
public function save(Podcast $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(Podcast $entity, bool $flush = false): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @return Podcast[] Returns an array of Podcast 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): ?Podcast
|
||||
// {
|
||||
// return $this->createQueryBuilder('p')
|
||||
// ->andWhere('p.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->getQuery()
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
}
|
81
src/Service/FeedService.php
Normal file
81
src/Service/FeedService.php
Normal file
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Constants;
|
||||
use App\Entity\Podcast;
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMText;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
class FeedService
|
||||
{
|
||||
protected EntityManagerInterface $entityManager;
|
||||
protected ParameterBagInterface $parameterBag;
|
||||
protected string $baseFeedUrl;
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $entityManager,
|
||||
ParameterBagInterface $parameterBag,
|
||||
UrlGeneratorInterface $urlGenerator,
|
||||
)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->parameterBag = $parameterBag;
|
||||
$this->baseFeedUrl = $urlGenerator->generate(
|
||||
'app_index',
|
||||
[],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL
|
||||
) . Constants::FILES_BASE_PATH;
|
||||
}
|
||||
|
||||
public const DOCUMENT_VERSION = '2.0';
|
||||
|
||||
public function generate(Podcast $podcast): string|false
|
||||
{
|
||||
$document = new DOMDocument();
|
||||
$rssElement = new DOMElement('rss');
|
||||
$document->appendChild($rssElement);
|
||||
$rssElement->setAttribute('version', $this::DOCUMENT_VERSION);
|
||||
$channelElement = new DOMElement('channel');
|
||||
$rssElement->appendChild($channelElement);
|
||||
$titleElement = new DOMElement('title');
|
||||
$channelElement->appendChild($titleElement);
|
||||
$titleElement->appendChild(new DOMText($podcast->getName()));
|
||||
$channelElement->appendChild(new DOMElement('description', $podcast->getDescription()));
|
||||
$channelElement->appendChild(new DOMElement('link', $podcast->getWebsite()));
|
||||
|
||||
$episodes = $this->entityManager->createQuery(
|
||||
'SELECT e FROM App\Entity\Episode e'
|
||||
.' WHERE e.podcast = :podcastId'
|
||||
.' AND e.audioFilename IS NOT NULL'
|
||||
)->setParameter('podcastId', $podcast->getId())->getResult();
|
||||
foreach ($episodes as $episode) {
|
||||
$audioUrl = $this->baseFeedUrl . $episode->getAudioFilename(); // Also used as ID.
|
||||
$itemElement = new DOMElement('item');
|
||||
$channelElement->appendChild($itemElement);
|
||||
$titleElement = new DOMElement('title');
|
||||
$itemElement->appendChild($titleElement);
|
||||
$titleElement->appendChild(new DOMText($episode->getTitle()));
|
||||
$itemElement->appendChild(new DOMElement('description', $episode->getDescription()));
|
||||
$itemElement->appendChild(new DOMElement('link', $audioUrl));
|
||||
$itemElement->appendChild(new DOMElement('guid', $audioUrl));
|
||||
$enclosureElement = new DOMElement('enclosure');
|
||||
$itemElement->appendChild($enclosureElement);
|
||||
$enclosureElement->setAttribute('url', $audioUrl);
|
||||
$filepath =
|
||||
$this->parameterBag->get('kernel.project_dir')
|
||||
. '/'
|
||||
. Constants::BASE_PUBLIC_DIR
|
||||
. Constants::FILES_BASE_PATH
|
||||
. $episode->getAudioFilename();
|
||||
$enclosureElement->setAttribute('type', mime_content_type($filepath));
|
||||
$enclosureElement->setAttribute('length', (string) filesize($filepath));
|
||||
}
|
||||
|
||||
return $document->saveXML();
|
||||
}
|
||||
}
|
21
symfony.lock
21
symfony.lock
|
@ -35,6 +35,15 @@
|
|||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"easycorp/easyadmin-bundle": {
|
||||
"version": "4.6",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.0",
|
||||
"ref": "b131e6cbfe1b898a508987851963fff485986285"
|
||||
}
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "9.6",
|
||||
"recipe": {
|
||||
|
@ -239,6 +248,18 @@
|
|||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "6.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.2",
|
||||
"ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/uid.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "6.2",
|
||||
"recipe": {
|
||||
|
|
6
templates/404.html.twig
Normal file
6
templates/404.html.twig
Normal file
|
@ -0,0 +1,6 @@
|
|||
{% extends "base.html.twig" %}
|
||||
|
||||
{% block body %}
|
||||
<h1>404</h1>
|
||||
<p>Page not found. 🤔🔎</p>
|
||||
{% endblock %}
|
6
templates/500.html.twig
Normal file
6
templates/500.html.twig
Normal file
|
@ -0,0 +1,6 @@
|
|||
{% extends "base.html.twig" %}
|
||||
|
||||
{% block body %}
|
||||
<h1>500</h1>
|
||||
<p>An error occured, sorry! 😭</p>
|
||||
{% endblock %}
|
11
templates/admin/field/file.html.twig
Normal file
11
templates/admin/field/file.html.twig
Normal file
|
@ -0,0 +1,11 @@
|
|||
{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
|
||||
{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
|
||||
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
|
||||
{% set files = field.formattedValue %}
|
||||
{% if files is not iterable %}
|
||||
{% set files = [files] %}
|
||||
{% endif %}
|
||||
|
||||
{% for file in files %}
|
||||
<span>{{ file }}</span>
|
||||
{% endfor %}
|
|
@ -2,13 +2,16 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Welcome!{% endblock %}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}LSBC{% 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></svg>">
|
||||
{# Run `composer require symfony/webpack-encore-bundle` to start using Symfony UX #}
|
||||
{% block stylesheets %}
|
||||
<style>
|
||||
body { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
|
||||
</style>
|
||||
{{ encore_entry_link_tags('app') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ encore_entry_script_tags('app') }}
|
||||
{% endblock %}
|
||||
|
|
10
templates/feed/index.html.twig
Normal file
10
templates/feed/index.html.twig
Normal file
|
@ -0,0 +1,10 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<p>Podcasts available on this LSBC instance:</p>
|
||||
<ul>
|
||||
{% for podcast in podcasts %}
|
||||
<li><a href="{{ path('app_podcast', {slug: podcast.slug}) }}">{{ podcast.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
11
templates/feed/podcast.html.twig
Normal file
11
templates/feed/podcast.html.twig
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h1>{{ podcast.name }}</h1>
|
||||
<p>
|
||||
By {{ podcast.author }}.<br>
|
||||
Contact: <code>{{ podcast.email }}</code><br>
|
||||
{% set feed = url('app_podcast_feed', {slug: podcast.slug}) %}
|
||||
Feed URL: <a href="{{ feed }}">{{ feed }}</a><br>
|
||||
</p>
|
||||
{% endblock %}
|
Loading…
Reference in a new issue