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 A small platform to create podcasts and episodes, host the audio files and share
the RSS feed, with external sources download abilities. 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 Install

View file

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

View file

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

View file

@ -31,4 +31,6 @@ class PodcastCrudController extends AbstractCrudController
->setBasePath(Constants::IMAGES_BASE_PATH), ->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; return $this;
} }
public function getLogoPath(): ?string
{
$logoFilename = $this->logoFilename;
if ($logoFilename === null)
return null;
return Constants::IMAGES_BASE_PATH . $logoFilename;
}
public function getOwner(): ?User public function getOwner(): ?User
{ {
return $this->owner; return $this->owner;

View file

@ -40,6 +40,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->podcasts = new ArrayCollection(); $this->podcasts = new ArrayCollection();
} }
public function __toString()
{
return "$this->email ($this->id)";
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; 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(); $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
} }
} }
// /** public function findAllOrderedById(): array
// * @return Podcast[] Returns an array of Podcast objects {
// */ return $this->findBy([], ['id' => 'ASC']);
// 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()
// ;
// }
} }

View file

@ -55,29 +55,4 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
$this->save($user, true); $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 %} {% block stylesheets %}
<style> <style>
body { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; } 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> </style>
{{ encore_entry_link_tags('app') }} {{ encore_entry_link_tags('app') }}
{% endblock %} {% endblock %}

View file

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

View file

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