diff --git a/migrations/Version20260906143919.php b/migrations/Version20260906143919.php new file mode 100644 index 0000000..f011cc8 --- /dev/null +++ b/migrations/Version20260906143919.php @@ -0,0 +1,29 @@ +addSql('CREATE TABLE artwork (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(200) NOT NULL, filename VARCHAR(120) NOT NULL, original_name VARCHAR(255) DEFAULT NULL, mime_type VARCHAR(100) NOT NULL, size INT NOT NULL, uploaded_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE artwork'); + } +} diff --git a/src/Controller/ArtController.php b/src/Controller/ArtController.php new file mode 100644 index 0000000..6182feb --- /dev/null +++ b/src/Controller/ArtController.php @@ -0,0 +1,111 @@ +render('art/index.html.twig', [ + 'artworks' => $repo->findBy([], ['uploadedAt' => 'DESC']), + ]); + } + + #[Route('/art/upload', name: 'app_art_upload', methods: ['GET', 'POST'])] + public function upload(Request $request, EntityManagerInterface $em): Response + { + $artwork = new Artwork(); + $form = $this->createForm(ArtworkType::class, $artwork); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var UploadedFile $file */ + $file = $form->get('file')->getData(); + $ext = $file->guessExtension() ?: 'bin'; + $stored = bin2hex(random_bytes(16)).'.'.$ext; + + try { + if (!is_dir($this->artDir)) { + mkdir($this->artDir, 0775, true); + } + $file->move($this->artDir, $stored); + } catch (FileException) { + $this->addFlash('error', 'Could not store the uploaded file.'); + + return $this->redirectToRoute('app_art_upload'); + } + + $artwork + ->setFilename($stored) + ->setOriginalName($file->getClientOriginalName()) + ->setMimeType($file->getClientMimeType()) + ->setSize((int) (@filesize($this->artDir.'/'.$stored) ?: 0)); + + $em->persist($artwork); + $em->flush(); + $this->addFlash('success', sprintf('Uploaded "%s".', $artwork->getName())); + + return $this->redirectToRoute('app_art'); + } + + return $this->render('art/upload.html.twig', ['form' => $form]); + } + + #[Route('/art/{id}/file', name: 'app_art_file', requirements: ['id' => '\d+'], methods: ['GET'])] + public function serve(Artwork $artwork): BinaryFileResponse + { + $path = $this->artDir.'/'.$artwork->getFilename(); + if (!is_file($path)) { + throw $this->createNotFoundException('File missing on disk.'); + } + + $response = new BinaryFileResponse($path); + $response->headers->set('Content-Type', $artwork->getMimeType()); + $response->setContentDisposition( + HeaderUtils::DISPOSITION_INLINE, + $artwork->getOriginalName() ?? $artwork->getFilename(), + ); + $response->setPrivate(); + + return $response; + } + + #[Route('/art/{id}/delete', name: 'app_art_delete', requirements: ['id' => '\d+'], methods: ['POST'])] + public function delete(Request $request, Artwork $artwork, EntityManagerInterface $em): Response + { + if ($this->isCsrfTokenValid('delete-art-'.$artwork->getId(), (string) $request->request->get('_token'))) { + $path = $this->artDir.'/'.$artwork->getFilename(); + if (is_file($path)) { + @unlink($path); + } + $em->remove($artwork); + $em->flush(); + $this->addFlash('success', sprintf('Deleted "%s".', $artwork->getName())); + } + + return $this->redirectToRoute('app_art'); + } +} diff --git a/src/Entity/Artwork.php b/src/Entity/Artwork.php new file mode 100644 index 0000000..f547265 --- /dev/null +++ b/src/Entity/Artwork.php @@ -0,0 +1,113 @@ +uploadedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function setFilename(string $filename): static + { + $this->filename = $filename; + + return $this; + } + + public function getOriginalName(): ?string + { + return $this->originalName; + } + + public function setOriginalName(?string $originalName): static + { + $this->originalName = $originalName; + + return $this; + } + + public function getMimeType(): string + { + return $this->mimeType; + } + + public function setMimeType(string $mimeType): static + { + $this->mimeType = $mimeType; + + return $this; + } + + public function getSize(): int + { + return $this->size; + } + + public function setSize(int $size): static + { + $this->size = $size; + + return $this; + } + + public function getUploadedAt(): \DateTimeImmutable + { + return $this->uploadedAt; + } +} diff --git a/src/Form/ArtworkType.php b/src/Form/ArtworkType.php new file mode 100644 index 0000000..ef9be94 --- /dev/null +++ b/src/Form/ArtworkType.php @@ -0,0 +1,40 @@ +add('name', TextType::class, [ + 'help' => 'Shown in the gallery.', + ]) + ->add('file', FileType::class, [ + 'label' => 'Image file', + 'mapped' => false, + 'constraints' => [ + new NotNull(message: 'Please choose an image.'), + new Image( + maxSize: '20M', + mimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'], + mimeTypesMessage: 'Upload a PNG, JPEG, GIF or WebP image.', + ), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => Artwork::class]); + } +} diff --git a/src/Repository/ArtworkRepository.php b/src/Repository/ArtworkRepository.php new file mode 100644 index 0000000..06c1095 --- /dev/null +++ b/src/Repository/ArtworkRepository.php @@ -0,0 +1,18 @@ + + */ +class ArtworkRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Artwork::class); + } +} diff --git a/templates/art/index.html.twig b/templates/art/index.html.twig new file mode 100644 index 0000000..c3baad1 --- /dev/null +++ b/templates/art/index.html.twig @@ -0,0 +1,45 @@ +{% extends 'base.html.twig' %} + +{% block title %}Art · dmtools{% endblock %} + +{% block body %} +