Make first moves toward dropping EasyAdmin
Anything more than running your own CRUD seems too cumbersome.
This commit is contained in:
parent
e3d80d1093
commit
bfd24948c1
11
README.md
11
README.md
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -31,4 +31,6 @@ class PodcastCrudController extends AbstractCrudController
|
|||
->setBasePath(Constants::IMAGES_BASE_PATH),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
133
src/Controller/PodcastController.php
Normal file
133
src/Controller/PodcastController.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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
63
src/Form/PodcastType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
|
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
4
templates/podcast/_delete_form.html.twig
Normal file
4
templates/podcast/_delete_form.html.twig
Normal 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>
|
4
templates/podcast/_form.html.twig
Normal file
4
templates/podcast/_form.html.twig
Normal file
|
@ -0,0 +1,4 @@
|
|||
{{ form_start(form) }}
|
||||
{{ form_widget(form) }}
|
||||
<button class="btn">{{ button_label|default('Save') }}</button>
|
||||
{{ form_end(form) }}
|
13
templates/podcast/edit.html.twig
Normal file
13
templates/podcast/edit.html.twig
Normal 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 %}
|
46
templates/podcast/index.html.twig
Normal file
46
templates/podcast/index.html.twig
Normal 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 %}
|
11
templates/podcast/new.html.twig
Normal file
11
templates/podcast/new.html.twig
Normal 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 %}
|
58
templates/podcast/show.html.twig
Normal file
58
templates/podcast/show.html.twig
Normal 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 %}
|
Loading…
Reference in a new issue