Make first moves toward dropping EasyAdmin

Anything more than running your own CRUD seems too cumbersome.
This commit is contained in:
dece 2023-10-09 00:03:03 +02:00
parent e3d80d1093
commit bfd24948c1
20 changed files with 398 additions and 96 deletions

View file

@ -6,6 +6,17 @@ LSBC
A small platform to create podcasts and episodes, host the audio files and share
the RSS feed, with external sources download abilities.
Features include:
- Lightweight, bare minimum pages;
- Simple backoffice;
- Separate users manage their own podcasts;
- TODO an API
- TODO the API let's you quickly add whole episodes from Youtube links
Podcasts follow mostly open standards but the “target” client is the fantastic
[AntennaPod](https://antennapod.org/)
Install

View file

@ -25,7 +25,8 @@ security:
role_hierarchy:
ROLE_ADMIN: ROLE_USER
access_control:
- { path: ^/admin, roles: ROLE_USER }
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/manage, roles: ROLE_USER }
when@test:
security:

View file

@ -4,6 +4,8 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
audio_directory: '%kernel.project_dir%/public/uploads/files'
images_directory: '%kernel.project_dir%/public/uploads/images'
services:
# default configuration for services in *this* file

View file

@ -31,4 +31,6 @@ class PodcastCrudController extends AbstractCrudController
->setBasePath(Constants::IMAGES_BASE_PATH),
];
}
}

View file

@ -0,0 +1,133 @@
<?php
namespace App\Controller;
use App\Entity\Podcast;
use App\Form\PodcastType;
use App\Repository\PodcastRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\String\Slugger\SluggerInterface;
#[Route('/manage/podcast')]
class PodcastController extends AbstractController
{
#[Route('/', name: 'app_podcast_index', methods: ['GET'])]
public function index(PodcastRepository $podcastRepository): Response
{
return $this->render('podcast/index.html.twig', [
'podcasts' => $podcastRepository->findAllOrderedById(),
]);
}
#[Route('/new', name: 'app_podcast_new', methods: ['GET', 'POST'])]
public function new(
Request $request,
EntityManagerInterface $entityManager,
SluggerInterface $slugger,
): Response
{
$podcast = new Podcast();
$form = $this->createForm(PodcastType::class, $podcast);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!$this->handleLogoChange($form, $podcast, $slugger)) {
$form->get('logo')->addError(new FormError('Could not upload logo.'));
return $this->render('podcast/new.html.twig', [
'podcast' => $podcast,
'form' => $form,
]);
}
if (($owner = $form->get('owner')->getData()) === null) {
$owner = $this->getUser();
}
$podcast->setOwner($owner);
$entityManager->persist($podcast);
$entityManager->flush();
return $this->redirectToRoute('app_podcast_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('podcast/new.html.twig', [
'podcast' => $podcast,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_podcast_show', methods: ['GET'])]
public function show(Podcast $podcast): Response
{
return $this->render('podcast/show.html.twig', [
'podcast' => $podcast,
]);
}
#[Route('/{id}/edit', name: 'app_podcast_edit', methods: ['GET', 'POST'])]
public function edit(
Request $request,
Podcast $podcast,
EntityManagerInterface $entityManager,
SluggerInterface $slugger,
): Response
{
$form = $this->createForm(PodcastType::class, $podcast);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!$this->handleLogoChange($form, $podcast, $slugger)) {
$form->get('logo')->addError(new FormError('Could not upload logo.'));
} else {
$entityManager->flush();
return $this->redirectToRoute('app_podcast_index', [], Response::HTTP_SEE_OTHER);
}
}
return $this->render('podcast/edit.html.twig', [
'podcast' => $podcast,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_podcast_delete', methods: ['POST'])]
public function delete(Request $request, Podcast $podcast, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete'.$podcast->getId(), $request->request->get('_token'))) {
$entityManager->remove($podcast);
$entityManager->flush();
}
return $this->redirectToRoute('app_podcast_index', [], Response::HTTP_SEE_OTHER);
}
protected function handleLogoChange(
FormInterface $form,
Podcast $podcast,
SluggerInterface $slugger
): bool
{
$logoFile = $form->get('logo')->getData();
if ($logoFile) {
$originalFilename = pathinfo($logoFile->getClientOriginalName(), PATHINFO_FILENAME);
$safeFilename = $slugger->slug($originalFilename);
$newFilename = $safeFilename . '-' . uniqid() . '.' . $logoFile->guessExtension();
try {
$logoFile->move($this->getParameter('images_directory'), $newFilename);
} catch (FileException $e) {
return false;
}
$podcast->setLogoFilename($newFilename);
}
return true;
}
}

View file

@ -182,6 +182,15 @@ class Podcast
return $this;
}
public function getLogoPath(): ?string
{
$logoFilename = $this->logoFilename;
if ($logoFilename === null)
return null;
return Constants::IMAGES_BASE_PATH . $logoFilename;
}
public function getOwner(): ?User
{
return $this->owner;

View file

@ -40,6 +40,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->podcasts = new ArrayCollection();
}
public function __toString()
{
return "$this->email ($this->id)";
}
public function getId(): ?int
{
return $this->id;

63
src/Form/PodcastType.php Normal file
View file

@ -0,0 +1,63 @@
<?php
namespace App\Form;
use App\Entity\Podcast;
use App\Entity\User;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\File;
class PodcastType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'help' => 'Your podcast\'s public name.',
])
->add('slug', TextType::class, [
'help' => 'Your podcast\'s slug, used in its URL and to reference it in API calls. Automatically set.',
'disabled' => true,
])
->add('website', TextType::class, [
'help' => 'A website linked to your podcast. Can be this instance URL if you have no idea.',
])
->add('description', TextType::class, [
'help' => 'Description.',
])
->add('author', TextType::class, [
'help' => 'An author name.',
])
->add('email', TextType::class, [
'help' => 'An author contact email address.',
])
->add('logo', FileType::class, [
'help' => 'Your logo; any image will do but keep it lightweight (4 MB max).',
'mapped' => false,
'required' => false,
'constraints' => new File([
'maxSize' => '4m',
'mimeTypes' => ['image/jpeg', 'image/png'],
'mimeTypesMessage' => 'Please upload a valid JPG or PNG file.',
]),
])
->add('owner', EntityType::class, [
'help' => 'The podcast owner is the only user of this instance able to see and modify it.',
'class' => User::class,
'disabled' => true,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Podcast::class,
]);
}
}

View file

@ -38,29 +38,4 @@ class EpisodeRepository extends ServiceEntityRepository
$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()
// ;
// }
}

View file

@ -39,28 +39,8 @@ class PodcastRepository extends ServiceEntityRepository
}
}
// /**
// * @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()
// ;
// }
public function findAllOrderedById(): array
{
return $this->findBy([], ['id' => 'ASC']);
}
}

View file

@ -55,29 +55,4 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
$this->save($user, true);
}
// /**
// * @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

@ -8,6 +8,8 @@
{% block stylesheets %}
<style>
body { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
img { max-width: 100%; }
.help-text { font-size: 0.8em; }
</style>
{{ encore_entry_link_tags('app') }}
{% endblock %}

View file

@ -1,6 +1,6 @@
{% extends 'base.html.twig' %}
{% block body %}{{ podcast.name }}{% endblock %}
{% block title %}{{ podcast.name }} — LSBC{% endblock %}
{% block body %}
<h1>{{ podcast.name }}</h1>
@ -10,4 +10,5 @@
{% set feed = url('app_podcast_feed', {slug: podcast.slug}) %}
Feed URL: <a href="{{ feed }}">{{ feed }}</a><br>
</p>
<img src="{{ podcast.logoPath }}" alt="podcast logo">
{% endblock %}

View file

@ -2,24 +2,31 @@
{% block title %}Login{% endblock %}
{% block body %}
<h1>Login</h1>
{% if error %}
<p>{{ error.messageKey|trans(error.messageData, 'security') }}</p>
{% endif %}
<form action="{{ path('app_login') }}" method="post">
<label for="username">Email:</label>
<input type="text" id="username" name="_username" value="{{ last_username }}">
<label for="password">Password:</label>
<input type="password" id="password" name="_password">
{# If you want to control the URL the user is redirected to on success
<input type="hidden" name="_target_path" value="/account"> #}
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button type="submit">Login</button>
</form>
{% block stylesheets %}
{{ parent() }}
<style>
form { display: flex; flex-direction: column; gap: 0.5em; }
</style>
{% endblock %}
{% block body %}
<h1>Login</h1>
{% if error %}
<p>{{ error.messageKey|trans(error.messageData, 'security') }}</p>
{% endif %}
<form action="{{ path('app_login') }}" method="post">
<label for="username">Email:</label>
<input type="text" id="username" name="_username" value="{{ last_username }}">
<label for="password">Password:</label>
<input type="password" id="password" name="_password">
{# If you want to control the URL the user is redirected to on success
<input type="hidden" name="_target_path" value="/account"> #}
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button type="submit">Login</button>
</form>
{% endblock %}

View file

@ -0,0 +1,4 @@
<form method="post" action="{{ path('app_podcast_delete', {'id': podcast.id}) }}" onsubmit="return confirm('Are you sure you want to delete this item?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ podcast.id) }}">
<button class="btn">Delete</button>
</form>

View file

@ -0,0 +1,4 @@
{{ form_start(form) }}
{{ form_widget(form) }}
<button class="btn">{{ button_label|default('Save') }}</button>
{{ form_end(form) }}

View file

@ -0,0 +1,13 @@
{% extends 'base.html.twig' %}
{% block title %}Edit Podcast{% endblock %}
{% block body %}
<h1>Edit Podcast</h1>
{{ include('podcast/_form.html.twig', {'button_label': 'Update'}) }}
<a href="{{ path('app_podcast_index') }}">back to list</a>
{{ include('podcast/_delete_form.html.twig') }}
{% endblock %}

View file

@ -0,0 +1,46 @@
{% extends 'base.html.twig' %}
{% block title %}Podcast index{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
td img { width: 5em; }
</style>
{% endblock %}
{% block body %}
<h1>Podcast index</h1>
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Author</th>
<th>Logo</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for podcast in podcasts %}
<tr>
<td>{{ podcast.id }}</td>
<td>{{ podcast.name }}<br>(<code>{{ podcast.slug }}</code>)</td>
<td>{{ podcast.author }}</td>
<td><img src="{{ podcast.logoPath }}" alt="{{ podcast.name }} logo"></td>
<td>
<a href="{{ path('app_podcast_show', {'id': podcast.id}) }}">show</a>
<a href="{{ path('app_podcast_edit', {'id': podcast.id}) }}">edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5">no records found</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ path('app_podcast_new') }}">Create new</a>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends 'base.html.twig' %}
{% block title %}New Podcast{% endblock %}
{% block body %}
<h1>Create new Podcast</h1>
{{ include('podcast/_form.html.twig') }}
<a href="{{ path('app_podcast_index') }}">back to list</a>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% extends 'base.html.twig' %}
{% block title %}Podcast{% endblock %}
{% block body %}
<h1>Podcast</h1>
<table class="table">
<tbody>
<tr>
<th>Id</th>
<td>{{ podcast.id }}</td>
</tr>
<tr>
<th>Name</th>
<td>{{ podcast.name }}</td>
</tr>
<tr>
<th>Slug</th>
<td>{{ podcast.slug }}</td>
</tr>
<tr>
<th>Website</th>
<td>{{ podcast.website }}</td>
</tr>
<tr>
<th>Description</th>
<td>{{ podcast.description }}</td>
</tr>
<tr>
<th>Author</th>
<td>{{ podcast.author }}</td>
</tr>
<tr>
<th>Email</th>
<td>{{ podcast.email }}</td>
</tr>
<tr>
<th>LogoFilename</th>
<td>{{ podcast.logoFilename }}</td>
</tr>
<tr>
<th>Logo</th>
<td><img src="{{ podcast.logoPath }}" alt="{{ podcast.name }} logo"></td>
</tr>
<tr>
<th>Owner</th>
<td>{{ podcast.owner }}</td>
</tr>
</tbody>
</table>
<a href="{{ path('app_podcast_index') }}">back to list</a>
<a href="{{ path('app_podcast_edit', {'id': podcast.id}) }}">edit</a>
{{ include('podcast/_delete_form.html.twig') }}
{% endblock %}