From e1f46424e1ba72d0d2e08d89c793f43c730b01b1 Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Wed, 22 Jul 2026 22:13:59 +0200 Subject: [PATCH 1/3] Implement user defined collections (#28) --- migrations/Version20260722201120.php | 29 +++++ .../php/tracky/UserCollectionItemType.php | 11 ++ .../controller/UserCollectionController.php | 120 ++++++++++++++++++ src/main/php/tracky/model/UserCollection.php | 77 +++++++++++ .../php/tracky/model/UserCollectionItem.php | 91 +++++++++++++ .../php/tracky/orm/AbstractRepository.php | 20 +++ src/main/php/tracky/orm/EpisodeRepository.php | 19 +-- src/main/php/tracky/orm/MovieRepository.php | 19 +-- .../php/tracky/orm/MovieSetRepository.php | 19 +-- .../orm/ScrobbleQueueItemRepository.php | 3 +- src/main/php/tracky/orm/SeasonRepository.php | 3 +- src/main/php/tracky/orm/SettingRepository.php | 3 +- src/main/php/tracky/orm/ShowRepository.php | 19 +-- .../orm/UserCollectionItemRepository.php | 13 ++ .../tracky/orm/UserCollectionRepository.php | 13 ++ src/main/php/tracky/orm/UserRepository.php | 3 +- src/main/php/tracky/orm/ViewRepository.php | 3 +- .../resources/lang/messages+intl-icu.de.yaml | 8 ++ .../resources/lang/messages+intl-icu.en.yaml | 14 ++ templates/components/macros.twig | 18 +++ templates/page.twig | 36 ++---- templates/user/collections/collection.twig | 31 +++++ templates/user/collections/collections.twig | 47 +++++++ templates/user/page.twig | 20 ++- 24 files changed, 529 insertions(+), 110 deletions(-) create mode 100644 migrations/Version20260722201120.php create mode 100644 src/main/php/tracky/UserCollectionItemType.php create mode 100644 src/main/php/tracky/controller/UserCollectionController.php create mode 100644 src/main/php/tracky/model/UserCollection.php create mode 100644 src/main/php/tracky/model/UserCollectionItem.php create mode 100644 src/main/php/tracky/orm/AbstractRepository.php create mode 100644 src/main/php/tracky/orm/UserCollectionItemRepository.php create mode 100644 src/main/php/tracky/orm/UserCollectionRepository.php create mode 100644 templates/user/collections/collection.twig create mode 100644 templates/user/collections/collections.twig diff --git a/migrations/Version20260722201120.php b/migrations/Version20260722201120.php new file mode 100644 index 0000000..f5a63ad --- /dev/null +++ b/migrations/Version20260722201120.php @@ -0,0 +1,29 @@ +addSql('CREATE TABLE usercollectionitems (id INT AUTO_INCREMENT NOT NULL, collection INT NOT NULL, type ENUM(\'show\', \'season\', \'episode\', \'movie\', \'movieset\') NOT NULL, item INT NOT NULL, addedAt DATETIME NOT NULL, INDEX IDX_41B0376EFC4D6532 (collection), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE usercollections (id INT AUTO_INCREMENT NOT NULL, user INT NOT NULL, name VARCHAR(255) NOT NULL, createdAt DATETIME NOT NULL, INDEX IDX_1709E57A8D93D649 (user), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE usercollectionitems ADD CONSTRAINT FK_41B0376EFC4D6532 FOREIGN KEY (collection) REFERENCES usercollections (id)'); + $this->addSql('ALTER TABLE usercollections ADD CONSTRAINT FK_1709E57A8D93D649 FOREIGN KEY (user) REFERENCES users (id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE usercollectionitems DROP FOREIGN KEY FK_41B0376EFC4D6532'); + $this->addSql('ALTER TABLE usercollections DROP FOREIGN KEY FK_1709E57A8D93D649'); + $this->addSql('DROP TABLE usercollectionitems'); + $this->addSql('DROP TABLE usercollections'); + } +} diff --git a/src/main/php/tracky/UserCollectionItemType.php b/src/main/php/tracky/UserCollectionItemType.php new file mode 100644 index 0000000..ba2ae85 --- /dev/null +++ b/src/main/php/tracky/UserCollectionItemType.php @@ -0,0 +1,11 @@ +render("user/collections/collections.twig", [ + "user" => $user, + "collections" => $userCollectionRepository->findBy(["user" => $user]) + ]); + } + + #[Route("/users/{username}/collections/{collection}", name: "user_profile_collection_page", methods: ["GET"])] + public function getCollectionPage( + User $user, + UserCollection $collection, + ShowRepository $showRepository, + SeasonRepository $seasonRepository, + EpisodeRepository $episodeRepository, + MovieRepository $movieRepository, + MovieSetRepository $movieSetRepository + ): Response + { + $typeToRepositoryMap = [ + UserCollectionItemType::SHOW->value => $showRepository, + UserCollectionItemType::SEASON->value => $seasonRepository, + UserCollectionItemType::EPISODE->value => $episodeRepository, + UserCollectionItemType::MOVIE->value => $movieRepository, + UserCollectionItemType::MOVIE_SET->value => $movieSetRepository + ]; + + /** + * @var array> + */ + $perTypeItems = []; + + foreach ($collection->getItems() as $item) { + $type = $item->getType()->value; + + if (!isset($perTypeItems[$type])) { + $perTypeItems[$type] = []; + } + + $perTypeItems[$type][$item->getId()] = $item; + } + + foreach ($perTypeItems as $type => $items) { + $itemIds = array_map(fn($item) => $item->getItem(), $items); + + /** + * @var BaseEntity $resolvedItem + */ + foreach ($typeToRepositoryMap[$type]->findByIds($itemIds) as $resolvedItem) { + $index = array_search($resolvedItem->getId(), $itemIds); + $items[$index]->setResolvedItem($resolvedItem); + } + } + + return $this->render("user/collections/collection.twig", [ + "user" => $user, + "collection" => $collection + ]); + } + + #[Route("/users/{username}/collections", name: "user_profile_collection_create_action", methods: ["POST"])] + #[IsGranted("IS_AUTHENTICATED")] + public function createCollection(User $user, Request $request, UserCollectionRepository $userCollectionRepository, EntityManagerInterface $entityManagerInterface): Response + { + /** + * @var User + */ + $currentUser = $this->getUser(); + + if ($user->getId() !== $currentUser->getId()) { + throw new AccessDeniedHttpException; + } + + $name = trim($request->request->getString("name")); + + if ($name === "") { + return $this->redirectToRoute("user_profile_collections_page", ["username" => $user->getUsername(), "flash" => "error", "error" => "empty-name", "name" => $name]); + } + + if ($userCollectionRepository->count(["user" => $user, "name" => $name])) { + return $this->redirectToRoute("user_profile_collections_page", ["username" => $user->getUsername(), "flash" => "error", "error" => "duplicate-collection", "name" => $name]); + } + + $collection = new UserCollection; + $collection->setUser($user); + $collection->setName($name); + $collection->setCreatedAt(new DateTime); + + $entityManagerInterface->persist($collection); + $entityManagerInterface->flush(); + + return $this->redirectToRoute("user_profile_collections_page", ["username" => $user->getUsername(), "flash" => "success", "name" => $name]); + } +} diff --git a/src/main/php/tracky/model/UserCollection.php b/src/main/php/tracky/model/UserCollection.php new file mode 100644 index 0000000..fce803c --- /dev/null +++ b/src/main/php/tracky/model/UserCollection.php @@ -0,0 +1,77 @@ +user; + } + + public function setUser(User $user): self + { + $this->user = $user; + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function setCreatedAt(DateTime $date): self + { + $this->createdAt = $date; + return $this; + } + + /** + * @return UserCollectionItem[] + */ + public function getItems(): mixed + { + return $this->items; + } + + public function setItems(mixed $items): self + { + $this->items = $items; + return $this; + } + + public function addItem(UserCollectionItem $item): self + { + $this->items[] = $item; + return $this; + } +} diff --git a/src/main/php/tracky/model/UserCollectionItem.php b/src/main/php/tracky/model/UserCollectionItem.php new file mode 100644 index 0000000..f2f8221 --- /dev/null +++ b/src/main/php/tracky/model/UserCollectionItem.php @@ -0,0 +1,91 @@ +collection; + } + + public function setCollection(UserCollection $collection): self + { + $this->collection = $collection; + return $this; + } + + public function getType(): UserCollectionItemType + { + return $this->type; + } + + public function setType(UserCollectionItemType $type): self + { + $this->type = $type; + return $this; + } + + public function getItem(): int + { + return $this->item; + } + + public function setItem(Show|Season|Episode|Movie|MovieSet $item): self + { + $this->item = $item->getId(); + $this->resolvedItem = $item; + return $this; + } + + public function getAddedAt(): DateTime + { + return $this->addedAt; + } + + public function setAddedAt(DateTime $date): self + { + $this->addedAt = $date; + return $this; + } + + public function getResolvedItem(): BaseEntity + { + return $this->resolvedItem; + } + + public function setResolvedItem(Show|Season|Episode|Movie|MovieSet $item): self + { + $this->resolvedItem = $item; + return $this; + } +} diff --git a/src/main/php/tracky/orm/AbstractRepository.php b/src/main/php/tracky/orm/AbstractRepository.php new file mode 100644 index 0000000..9c878ef --- /dev/null +++ b/src/main/php/tracky/orm/AbstractRepository.php @@ -0,0 +1,20 @@ +createQueryBuilder("entity"); + + $queryBuilder->where($queryBuilder->expr()->in("entity.id", $ids)); + + return $queryBuilder->getQuery()->getResult(); + } +} diff --git a/src/main/php/tracky/orm/EpisodeRepository.php b/src/main/php/tracky/orm/EpisodeRepository.php index a804c61..c1adc87 100644 --- a/src/main/php/tracky/orm/EpisodeRepository.php +++ b/src/main/php/tracky/orm/EpisodeRepository.php @@ -1,11 +1,10 @@ getQuery() ->getResult(); } - - /** - * @return Episode[] - */ - public function findByIds(array $ids): array - { - if (empty($ids)) { - return []; - } - - $queryBuilder = $this->createQueryBuilder("episode"); - - $queryBuilder->where($queryBuilder->expr()->in("episode.id", $ids)); - - return $queryBuilder->getQuery()->getResult(); - } } diff --git a/src/main/php/tracky/orm/MovieRepository.php b/src/main/php/tracky/orm/MovieRepository.php index 71608d3..702f742 100644 --- a/src/main/php/tracky/orm/MovieRepository.php +++ b/src/main/php/tracky/orm/MovieRepository.php @@ -1,11 +1,10 @@ createQueryBuilder("movie"); - - $queryBuilder->where($queryBuilder->expr()->in("movie.id", $ids)); - - return $queryBuilder->getQuery()->getResult(); - } } diff --git a/src/main/php/tracky/orm/MovieSetRepository.php b/src/main/php/tracky/orm/MovieSetRepository.php index 86deb65..13b485a 100644 --- a/src/main/php/tracky/orm/MovieSetRepository.php +++ b/src/main/php/tracky/orm/MovieSetRepository.php @@ -1,12 +1,11 @@ getResult(); } - - /** - * @return MovieSet[] - */ - public function findByIds(array $ids): array - { - if (empty($ids)) { - return []; - } - - $queryBuilder = $this->createQueryBuilder("movieset"); - - $queryBuilder->where($queryBuilder->expr()->in("movieset.id", $ids)); - - return $queryBuilder->getQuery()->getResult(); - } } diff --git a/src/main/php/tracky/orm/ScrobbleQueueItemRepository.php b/src/main/php/tracky/orm/ScrobbleQueueItemRepository.php index a15affb..b54a654 100644 --- a/src/main/php/tracky/orm/ScrobbleQueueItemRepository.php +++ b/src/main/php/tracky/orm/ScrobbleQueueItemRepository.php @@ -1,11 +1,10 @@ getOneOrNullResult(); } - - /** - * @return Show[] - */ - public function findByIds(array $ids): array - { - if (empty($ids)) { - return []; - } - - $queryBuilder = $this->createQueryBuilder("show"); - - $queryBuilder->where($queryBuilder->expr()->in("show.id", $ids)); - - return $queryBuilder->getQuery()->getResult(); - } } diff --git a/src/main/php/tracky/orm/UserCollectionItemRepository.php b/src/main/php/tracky/orm/UserCollectionItemRepository.php new file mode 100644 index 0000000..4c6b923 --- /dev/null +++ b/src/main/php/tracky/orm/UserCollectionItemRepository.php @@ -0,0 +1,13 @@ +{{ ((watchedEpisodes / episodes|length) * 100)|round }}% {% endmacro %} + +{% macro navItem(path, title, icon_classes = null, route_starts_with = null, parameters = {}) %} + {% if route_starts_with != null and app.request.attributes.get("_route") starts with route_starts_with %} + {% set active = true %} + {% elseif app.request.attributes.get("_route") == path %} + {% set active = true %} + {% else %} + {% set active = false %} + {% endif %} + +{% endmacro %} diff --git a/templates/page.twig b/templates/page.twig index 33e8c8b..5b1c102 100644 --- a/templates/page.twig +++ b/templates/page.twig @@ -1,15 +1,4 @@ -{% macro navItem(icon_classes, path, title, route_starts_with = null, parameters = {}) %} - {% if route_starts_with != null and app.request.attributes.get("_route") starts with route_starts_with %} - {% set active = true %} - {% elseif app.request.attributes.get("_route") == path %} - {% set active = true %} - {% else %} - {% set active = false %} - {% endif %} - -{% endmacro %} +{% from "components/macros.twig" import navItem %} @@ -53,11 +42,11 @@
@@ -67,9 +56,10 @@ {% set profile_page_parameters = {"username": app.user.username} %} {% endif %}
@@ -77,15 +67,15 @@
diff --git a/templates/user/collections/collection.twig b/templates/user/collections/collection.twig new file mode 100644 index 0000000..1d35c2b --- /dev/null +++ b/templates/user/collections/collection.twig @@ -0,0 +1,31 @@ +{% extends "user/page.twig" %} + +{% block title %}{{ collection.name }} - {{ user.username }}{% endblock %} + +{% block profileContent %} +

{{ collection.name }}

+ + {% if collection.items|length %} + {% set UserCollectionItemType = enum("tracky\\UserCollectionItemType") %} + +
+ {% for item in collection.items %} +
+ {% if item.type == UserCollectionItemType.SHOW %} + {% include "components/show-card-small.twig" with {"show": item.resolvedItem} %} + {% elseif item.type == UserCollectionItemType.SEASON %} + {% include "components/season-card-small.twig" with {"season": item.resolvedItem} %} + {% elseif item.type == UserCollectionItemType.EPISODE %} + {% include "components/episode-card-small.twig" with {"episode": item.resolvedItem} %} + {% elseif item.type == UserCollectionItemType.MOVIE %} + {% include "components/movie-card-small.twig" with {"movie": item.resolvedItem} %} + {% elseif item.type == UserCollectionItemType.MOVIE_SET %} + {% include "components/movieset-card-small.twig" with {"movieSet": item.resolvedItem} %} + {% endif %} +
+ {% endfor %} +
+ {% else %} +
{{ "user.collections.no-items-available"|trans }}
+ {% endif %} +{% endblock %} diff --git a/templates/user/collections/collections.twig b/templates/user/collections/collections.twig new file mode 100644 index 0000000..ef1588e --- /dev/null +++ b/templates/user/collections/collections.twig @@ -0,0 +1,47 @@ +{% extends "user/page.twig" %} + +{% block title %}{{ "user.collections.title"|trans }} - {{ user.username }}{% endblock %} + +{% block profileContent %} +

{{ "user.collections.title"|trans }}

+ + {% if user == app.user %} + {% if app.request.query.get("flash") == "success" %} +
{{ "user.collections.create.submit-response.success"|trans({"name": app.request.query.get("name")}) }}
+ {% elseif app.request.query.get("flash") == "error" %} +
{{ ("user.collections.create.submit-response.error." ~ app.request.query.get("error"))|trans({"name": app.request.query.get("name")}) }}
+ {% endif %} + +
+
+ +
+ +
+ {% endif %} + + {% if collections %} + + + + + + + + + + + {% for collection in collections %} + + + + + + + {% endfor %} + +
#{{ "user.collections.name"|trans }}{{ "user.collections.items"|trans }}{{ "user.collections.creation-date"|trans }}
{{ loop.index }}{{ collection.name }}{{ collection.items|length }}{{ collection.createdAt.formatForDisplay }}
+ {% else %} +
{{ "user.collections.no-collections-available"|trans }}
+ {% endif %} +{% endblock %} diff --git a/templates/user/page.twig b/templates/user/page.twig index 7a7e5fa..7474310 100644 --- a/templates/user/page.twig +++ b/templates/user/page.twig @@ -1,8 +1,17 @@ +{% from "components/macros.twig" import navItem %} + {% extends "page.twig" %} -{% macro navItem(path, parameters, title) %} +{% macro navItem(path, title, route_starts_with = null, parameters = {}) %} + {% if route_starts_with != null and app.request.attributes.get("_route") starts with route_starts_with %} + {% set active = true %} + {% elseif app.request.attributes.get("_route") == path %} + {% set active = true %} + {% else %} + {% set active = false %} + {% endif %} {% endmacro %} @@ -10,9 +19,10 @@

{{ user.username }}

{% block profileContent %}{% endblock %} From 9d04f15afb0d65e58c79add105c8a24a57ccd828 Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Thu, 23 Jul 2026 23:12:53 +0200 Subject: [PATCH 2/3] Fix missing unique contraint in usercollectionitems table --- migrations/Version20260722201120.php | 2 +- src/main/php/tracky/model/UserCollectionItem.php | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/migrations/Version20260722201120.php b/migrations/Version20260722201120.php index f5a63ad..b1feefe 100644 --- a/migrations/Version20260722201120.php +++ b/migrations/Version20260722201120.php @@ -13,7 +13,7 @@ public function getDescription(): string public function up(Schema $schema): void { - $this->addSql('CREATE TABLE usercollectionitems (id INT AUTO_INCREMENT NOT NULL, collection INT NOT NULL, type ENUM(\'show\', \'season\', \'episode\', \'movie\', \'movieset\') NOT NULL, item INT NOT NULL, addedAt DATETIME NOT NULL, INDEX IDX_41B0376EFC4D6532 (collection), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE usercollectionitems (id INT AUTO_INCREMENT NOT NULL, collection INT NOT NULL, type ENUM(\'show\', \'season\', \'episode\', \'movie\', \'movieset\') NOT NULL, item INT NOT NULL, addedAt DATETIME NOT NULL, INDEX IDX_41B0376EFC4D6532 (collection), UNIQUE INDEX unique_collection_type_item (collection, type, item), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('CREATE TABLE usercollections (id INT AUTO_INCREMENT NOT NULL, user INT NOT NULL, name VARCHAR(255) NOT NULL, createdAt DATETIME NOT NULL, INDEX IDX_1709E57A8D93D649 (user), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); $this->addSql('ALTER TABLE usercollectionitems ADD CONSTRAINT FK_41B0376EFC4D6532 FOREIGN KEY (collection) REFERENCES usercollections (id)'); $this->addSql('ALTER TABLE usercollections ADD CONSTRAINT FK_1709E57A8D93D649 FOREIGN KEY (user) REFERENCES users (id)'); diff --git a/src/main/php/tracky/model/UserCollectionItem.php b/src/main/php/tracky/model/UserCollectionItem.php index f2f8221..bc6feba 100644 --- a/src/main/php/tracky/model/UserCollectionItem.php +++ b/src/main/php/tracky/model/UserCollectionItem.php @@ -7,15 +7,8 @@ use tracky\UserCollectionItemType; #[ORM\Entity(repositoryClass: UserCollectionItemRepository::class)] -#[ORM\Table( - name: "usercollectionitems", - uniqueConstraints: [ - new ORM\UniqueConstraint( - name: "unique_collection_type_item", - columns: ["collection", "type", "item"] - ) - ] -)] +#[ORM\Table(name: "usercollectionitems")] +#[ORM\UniqueConstraint(name: "unique_collection_type_item", columns: ["collection", "type", "item"])] class UserCollectionItem extends BaseEntity { #[ORM\ManyToOne(targetEntity: UserCollection::class)] From 8c4f5841b2e9030d38114ad9bab1152ab25128b9 Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Thu, 23 Jul 2026 23:14:16 +0200 Subject: [PATCH 3/3] Implement adding items to collections Currently, there are only buttons for movies and episodes --- .../php/tracky/UserCollectionItemType.php | 20 ++++ .../controller/UserCollectionController.php | 99 +++++++++++++++---- .../php/tracky/model/UserCollectionItem.php | 2 + src/main/resources/assets/script/main.ts | 1 + .../assets/script/usercollections.ts | 76 ++++++++++++++ .../resources/lang/messages+intl-icu.en.yaml | 5 + templates/components/view-buttons.twig | 1 + templates/page.twig | 19 ++++ 8 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 src/main/resources/assets/script/usercollections.ts diff --git a/src/main/php/tracky/UserCollectionItemType.php b/src/main/php/tracky/UserCollectionItemType.php index ba2ae85..1fe57de 100644 --- a/src/main/php/tracky/UserCollectionItemType.php +++ b/src/main/php/tracky/UserCollectionItemType.php @@ -1,6 +1,13 @@ self::SHOW, + Season::class => self::SEASON, + Episode::class => self::EPISODE, + Movie::class => self::MOVIE, + MovieSet::class => self::MOVIE_SET + ]; + + return $typeMap[$entity::class] ?? null; + } } diff --git a/src/main/php/tracky/controller/UserCollectionController.php b/src/main/php/tracky/controller/UserCollectionController.php index 5e19ed8..3ae377e 100644 --- a/src/main/php/tracky/controller/UserCollectionController.php +++ b/src/main/php/tracky/controller/UserCollectionController.php @@ -6,6 +6,9 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use tracky\datetime\DateTime; @@ -13,44 +16,64 @@ use tracky\model\User; use tracky\model\UserCollection; use tracky\model\UserCollectionItem; +use tracky\orm\AbstractRepository; use tracky\orm\EpisodeRepository; use tracky\orm\MovieRepository; use tracky\orm\MovieSetRepository; use tracky\orm\SeasonRepository; use tracky\orm\ShowRepository; +use tracky\orm\UserCollectionItemRepository; use tracky\orm\UserCollectionRepository; use tracky\UserCollectionItemType; class UserCollectionController extends AbstractController { - #[Route("/users/{username}/collections", name: "user_profile_collections_page", methods: ["GET"])] - public function getListPage(User $user, UserCollectionRepository $userCollectionRepository): Response - { - return $this->render("user/collections/collections.twig", [ - "user" => $user, - "collections" => $userCollectionRepository->findBy(["user" => $user]) - ]); - } + /** + * @var array + */ + private array $typeToRepositoryMap; - #[Route("/users/{username}/collections/{collection}", name: "user_profile_collection_page", methods: ["GET"])] - public function getCollectionPage( - User $user, - UserCollection $collection, + public function __construct( ShowRepository $showRepository, SeasonRepository $seasonRepository, EpisodeRepository $episodeRepository, MovieRepository $movieRepository, MovieSetRepository $movieSetRepository - ): Response + ) { - $typeToRepositoryMap = [ + $this->typeToRepositoryMap = [ UserCollectionItemType::SHOW->value => $showRepository, UserCollectionItemType::SEASON->value => $seasonRepository, UserCollectionItemType::EPISODE->value => $episodeRepository, UserCollectionItemType::MOVIE->value => $movieRepository, UserCollectionItemType::MOVIE_SET->value => $movieSetRepository ]; + } + + #[Route("/users/{username}/collections", name: "user_profile_collections_page", methods: ["GET"])] + public function getListPage(User $user, UserCollectionRepository $userCollectionRepository): Response + { + return $this->render("user/collections/collections.twig", [ + "user" => $user, + "collections" => $userCollectionRepository->findBy(["user" => $user]) + ]); + } + + #[Route("/users/{username}/collections.json", name: "user_profile_collections_json", methods: ["GET"])] + public function getCollectionsJson(User $user, UserCollectionRepository $userCollectionRepository): Response + { + $data = []; + + foreach ($userCollectionRepository->findBy(["user" => $user]) as $collection) { + $data[$collection->getId()] = $collection->getName(); + } + + return $this->json($data); + } + #[Route("/users/{username}/collections/{collection}", name: "user_profile_collection_page", methods: ["GET"])] + public function getCollectionPage(User $user, UserCollection $collection): Response + { /** * @var array> */ @@ -72,7 +95,7 @@ public function getCollectionPage( /** * @var BaseEntity $resolvedItem */ - foreach ($typeToRepositoryMap[$type]->findByIds($itemIds) as $resolvedItem) { + foreach ($this->typeToRepositoryMap[$type]->findByIds($itemIds) as $resolvedItem) { $index = array_search($resolvedItem->getId(), $itemIds); $items[$index]->setResolvedItem($resolvedItem); } @@ -86,7 +109,7 @@ public function getCollectionPage( #[Route("/users/{username}/collections", name: "user_profile_collection_create_action", methods: ["POST"])] #[IsGranted("IS_AUTHENTICATED")] - public function createCollection(User $user, Request $request, UserCollectionRepository $userCollectionRepository, EntityManagerInterface $entityManagerInterface): Response + public function createCollection(User $user, Request $request, UserCollectionRepository $userCollectionRepository, EntityManagerInterface $entityManager): Response { /** * @var User @@ -112,9 +135,49 @@ public function createCollection(User $user, Request $request, UserCollectionRep $collection->setName($name); $collection->setCreatedAt(new DateTime); - $entityManagerInterface->persist($collection); - $entityManagerInterface->flush(); + $entityManager->persist($collection); + $entityManager->flush(); return $this->redirectToRoute("user_profile_collections_page", ["username" => $user->getUsername(), "flash" => "success", "name" => $name]); } + + #[Route("/users/{username}/collections/{collection}/add-item", name: "user_profile_collection_add_item_action", methods: ["POST"])] + #[IsGranted("IS_AUTHENTICATED")] + public function addItemToCollection(User $user, UserCollection $collection, Request $request, UserCollectionItemRepository $userCollectionItemRepository, EntityManagerInterface $entityManager): Response + { + /** + * @var User + */ + $currentUser = $this->getUser(); + + if ($collection->getUser()->getId() !== $user->getId() or $user->getId() !== $currentUser->getId()) { + throw new AccessDeniedHttpException; + } + + $payload = $request->getPayload(); + + $type = UserCollectionItemType::tryFrom($payload->getString("type")); + if ($type === null) { + throw new BadRequestHttpException("Invalid type!"); + } + + $item = $this->typeToRepositoryMap[$type->value]->findOneBy(["id" => $payload->getInt("item")]); + if ($item === null) { + throw new NotFoundHttpException("Item not found!"); + } + + if ($userCollectionItemRepository->count(["collection" => $collection->getId(), "type" => $type->value, "item" => $item->getId()])) { + throw new UnprocessableEntityHttpException("Item already exists in collection!"); + } + + $collectionItem = new UserCollectionItem; + $collectionItem->setCollection($collection); + $collectionItem->setItem($item); + $collectionItem->setAddedAt(new DateTime); + + $entityManager->persist($collectionItem); + $entityManager->flush(); + + return new Response("Item added"); + } } diff --git a/src/main/php/tracky/model/UserCollectionItem.php b/src/main/php/tracky/model/UserCollectionItem.php index bc6feba..d3386ff 100644 --- a/src/main/php/tracky/model/UserCollectionItem.php +++ b/src/main/php/tracky/model/UserCollectionItem.php @@ -55,8 +55,10 @@ public function getItem(): int public function setItem(Show|Season|Episode|Movie|MovieSet $item): self { + $this->type = UserCollectionItemType::fromEntity($item); $this->item = $item->getId(); $this->resolvedItem = $item; + return $this; } diff --git a/src/main/resources/assets/script/main.ts b/src/main/resources/assets/script/main.ts index 57ba5b4..86bd9aa 100644 --- a/src/main/resources/assets/script/main.ts +++ b/src/main/resources/assets/script/main.ts @@ -1,6 +1,7 @@ import "./common"; import "./history"; import "./library-management"; +import "./usercollections"; import "./view"; import missingImagePoster from "../images/missing-image-poster.svg"; diff --git a/src/main/resources/assets/script/usercollections.ts b/src/main/resources/assets/script/usercollections.ts new file mode 100644 index 0000000..223d098 --- /dev/null +++ b/src/main/resources/assets/script/usercollections.ts @@ -0,0 +1,76 @@ +import {createPopper} from "@popperjs/core"; + +interface Dictionary { + [key: string]: string; +} + +document.addEventListener("DOMContentLoaded", () => { + let username = document.querySelector('meta[name="username"]')?.content; + let addCollectionItemTooltipElement = document.querySelector("#add-collection-item-tooltip")!; + let collections: Dictionary | null = null; + let activeAddCollectionItemEntry: DOMStringMap | null = null; + + function loadCollections() { + if (collections === null) { + fetch(`/users/${username}/collections.json`) + .then((response) => response.json()) + .then((json) => { + collections = json as Dictionary; + + let selectElement = document.querySelector("#add-collection-item-tooltip-collection"); + + Object.entries(collections).forEach(([id, name]) => { + let optionElement = document.createElement("option"); + optionElement.value = id; + optionElement.textContent = name; + selectElement?.add(optionElement); + }); + }); + } + } + + document.querySelectorAll(".add-to-collection").forEach((buttonElement) => { + buttonElement.addEventListener("click", () => { + loadCollections(); + + createPopper(buttonElement, addCollectionItemTooltipElement, { + placement: "bottom" + }); + + addCollectionItemTooltipElement.style.display = "block"; + activeAddCollectionItemEntry = buttonElement.dataset; + }); + }); + + document.querySelector("#add-collection-item-tooltip-form")?.addEventListener("submit", (event) => { + event.preventDefault(); + + if (activeAddCollectionItemEntry === null) { + return; + } + + let selectElement = document.querySelector("#add-collection-item-tooltip-collection"); + let collectionId = selectElement?.selectedOptions.item(0)?.value ?? null; + + if (collectionId === null) { + return; + } + + fetch(`/users/${username}/collections/${collectionId}/add-item`, { + method: "POST", + body: JSON.stringify({ + type: activeAddCollectionItemEntry.type, + item: activeAddCollectionItemEntry.item + }) + }); + + // Hide the modal + addCollectionItemTooltipElement.style.display = "none"; + activeAddCollectionItemEntry = null; + }); + + document.querySelector("#add-collection-item-tooltip-cancel")?.addEventListener("click", () => { + addCollectionItemTooltipElement.style.display = "none"; + activeAddCollectionItemEntry = null; + }); +}); diff --git a/src/main/resources/lang/messages+intl-icu.en.yaml b/src/main/resources/lang/messages+intl-icu.en.yaml index e782d43..7996985 100644 --- a/src/main/resources/lang/messages+intl-icu.en.yaml +++ b/src/main/resources/lang/messages+intl-icu.en.yaml @@ -75,6 +75,11 @@ user: error: empty-name: "No name specified!" duplicate-collection: "Another collection with the name ''{name}'' already exists!" + add-item: + header: "Add item to collection" + ok: "OK" + cancel: "Cancel" + add-to-collection: "Add to collection" no-collections-available: "No collections available." no-items-available: "No items available." name: "Name" diff --git a/templates/components/view-buttons.twig b/templates/components/view-buttons.twig index 024898f..9891890 100644 --- a/templates/components/view-buttons.twig +++ b/templates/components/view-buttons.twig @@ -5,6 +5,7 @@ {% set attributes = {"data-show-id": item.season.show.id, "data-season": item.season.number, "data-episode": item.number} %} {% endif %}
+
diff --git a/templates/page.twig b/templates/page.twig index 5b1c102..4096fb9 100644 --- a/templates/page.twig +++ b/templates/page.twig @@ -6,6 +6,10 @@ + {% if app.user %} + + {% endif %} + {% set pageTitle = block("title") %} {% if pageTitle is not empty %}{{ pageTitle|raw }} - {% endif %}Tracky @@ -134,6 +138,21 @@ +
+
+ +
+
{{ "user.collections.add-item.header"|trans }}
+
+ +
+ + +
+
+
+
+ {% block additional_content %}{% endblock %}