diff --git a/README.md b/README.md index 71d75e0..bfa2a06 100644 --- a/README.md +++ b/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 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index db1155d..291bd6d 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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: diff --git a/config/services.yaml b/config/services.yaml index 2d6a76f..816ae00 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/src/Controller/Admin/PodcastCrudController.php b/src/Controller/Admin/PodcastCrudController.php index 3d05b03..60d67da 100644 --- a/src/Controller/Admin/PodcastCrudController.php +++ b/src/Controller/Admin/PodcastCrudController.php @@ -31,4 +31,6 @@ class PodcastCrudController extends AbstractCrudController ->setBasePath(Constants::IMAGES_BASE_PATH), ]; } + + } diff --git a/src/Controller/PodcastController.php b/src/Controller/PodcastController.php new file mode 100644 index 0000000..ef9b7a0 --- /dev/null +++ b/src/Controller/PodcastController.php @@ -0,0 +1,133 @@ +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; + } +} diff --git a/src/Entity/Podcast.php b/src/Entity/Podcast.php index 6b815cf..2d742e5 100644 --- a/src/Entity/Podcast.php +++ b/src/Entity/Podcast.php @@ -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; diff --git a/src/Entity/User.php b/src/Entity/User.php index ff381ac..39162f3 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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; diff --git a/src/Form/PodcastType.php b/src/Form/PodcastType.php new file mode 100644 index 0000000..7343d63 --- /dev/null +++ b/src/Form/PodcastType.php @@ -0,0 +1,63 @@ +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, + ]); + } +} diff --git a/src/Repository/EpisodeRepository.php b/src/Repository/EpisodeRepository.php index 59c5196..46c672b 100644 --- a/src/Repository/EpisodeRepository.php +++ b/src/Repository/EpisodeRepository.php @@ -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() -// ; -// } } diff --git a/src/Repository/PodcastRepository.php b/src/Repository/PodcastRepository.php index d29dcf6..4ad6110 100644 --- a/src/Repository/PodcastRepository.php +++ b/src/Repository/PodcastRepository.php @@ -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']); + } } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 9b60483..8f7b0e1 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -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() -// ; -// } } diff --git a/templates/base.html.twig b/templates/base.html.twig index dba00ec..5991ecc 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -8,6 +8,8 @@ {% block stylesheets %} {{ encore_entry_link_tags('app') }} {% endblock %} diff --git a/templates/feed/podcast.html.twig b/templates/feed/podcast.html.twig index 13ed4e5..77a50f3 100644 --- a/templates/feed/podcast.html.twig +++ b/templates/feed/podcast.html.twig @@ -1,6 +1,6 @@ {% extends 'base.html.twig' %} -{% block body %}{{ podcast.name }}{% endblock %} +{% block title %}{{ podcast.name }} — LSBC{% endblock %} {% block body %}

{{ podcast.name }}

@@ -10,4 +10,5 @@ {% set feed = url('app_podcast_feed', {slug: podcast.slug}) %} Feed URL: {{ feed }}

+ podcast logo {% endblock %} diff --git a/templates/login/index.html.twig b/templates/login/index.html.twig index b8829ad..6418537 100644 --- a/templates/login/index.html.twig +++ b/templates/login/index.html.twig @@ -2,24 +2,31 @@ {% block title %}Login{% endblock %} -{% block body %} -

Login

- {% if error %} -

{{ error.messageKey|trans(error.messageData, 'security') }}

- {% endif %} - -
- - - - - - - {# If you want to control the URL the user is redirected to on success - #} - - - - -
+{% block stylesheets %} + {{ parent() }} + +{% endblock %} + +{% block body %} +

Login

+ {% if error %} +

{{ error.messageKey|trans(error.messageData, 'security') }}

+ {% endif %} + +
+ + + + + + + {# If you want to control the URL the user is redirected to on success + #} + + + + +
{% endblock %} diff --git a/templates/podcast/_delete_form.html.twig b/templates/podcast/_delete_form.html.twig new file mode 100644 index 0000000..aae9488 --- /dev/null +++ b/templates/podcast/_delete_form.html.twig @@ -0,0 +1,4 @@ +
+ + +
diff --git a/templates/podcast/_form.html.twig b/templates/podcast/_form.html.twig new file mode 100644 index 0000000..bf20b98 --- /dev/null +++ b/templates/podcast/_form.html.twig @@ -0,0 +1,4 @@ +{{ form_start(form) }} + {{ form_widget(form) }} + +{{ form_end(form) }} diff --git a/templates/podcast/edit.html.twig b/templates/podcast/edit.html.twig new file mode 100644 index 0000000..6d80131 --- /dev/null +++ b/templates/podcast/edit.html.twig @@ -0,0 +1,13 @@ +{% extends 'base.html.twig' %} + +{% block title %}Edit Podcast{% endblock %} + +{% block body %} +

Edit Podcast

+ + {{ include('podcast/_form.html.twig', {'button_label': 'Update'}) }} + + back to list + + {{ include('podcast/_delete_form.html.twig') }} +{% endblock %} diff --git a/templates/podcast/index.html.twig b/templates/podcast/index.html.twig new file mode 100644 index 0000000..daa245a --- /dev/null +++ b/templates/podcast/index.html.twig @@ -0,0 +1,46 @@ +{% extends 'base.html.twig' %} + +{% block title %}Podcast index{% endblock %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock %} + +{% block body %} +

Podcast index

+ + + + + + + + + + + + + {% for podcast in podcasts %} + + + + + + + + {% else %} + + + + {% endfor %} + +
IdNameAuthorLogoactions
{{ podcast.id }}{{ podcast.name }}
({{ podcast.slug }})
{{ podcast.author }}{{ podcast.name }} logo + show + edit +
no records found
+ + Create new +{% endblock %} diff --git a/templates/podcast/new.html.twig b/templates/podcast/new.html.twig new file mode 100644 index 0000000..f47fe0c --- /dev/null +++ b/templates/podcast/new.html.twig @@ -0,0 +1,11 @@ +{% extends 'base.html.twig' %} + +{% block title %}New Podcast{% endblock %} + +{% block body %} +

Create new Podcast

+ + {{ include('podcast/_form.html.twig') }} + + back to list +{% endblock %} diff --git a/templates/podcast/show.html.twig b/templates/podcast/show.html.twig new file mode 100644 index 0000000..f2f993c --- /dev/null +++ b/templates/podcast/show.html.twig @@ -0,0 +1,58 @@ +{% extends 'base.html.twig' %} + +{% block title %}Podcast{% endblock %} + +{% block body %} +

Podcast

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Id{{ podcast.id }}
Name{{ podcast.name }}
Slug{{ podcast.slug }}
Website{{ podcast.website }}
Description{{ podcast.description }}
Author{{ podcast.author }}
Email{{ podcast.email }}
LogoFilename{{ podcast.logoFilename }}
Logo{{ podcast.name }} logo
Owner{{ podcast.owner }}
+ + back to list + + edit + + {{ include('podcast/_delete_form.html.twig') }} +{% endblock %}