From e1f46424e1ba72d0d2e08d89c793f43c730b01b1 Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Wed, 22 Jul 2026 22:13:59 +0200 Subject: [PATCH 1/8] 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/8] 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/8] 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 %} From 3c7ea61b06ffb841502bc73c9b44037f6847283e Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Sat, 1 Aug 2026 18:57:49 +0200 Subject: [PATCH 4/8] Add buttons to add shows and seasons to user collections --- templates/shows/season.twig | 11 ++++++++++- templates/shows/show.twig | 22 ++++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/templates/shows/season.twig b/templates/shows/season.twig index b8f056b..af728de 100644 --- a/templates/shows/season.twig +++ b/templates/shows/season.twig @@ -44,7 +44,16 @@

{{ "shows.episodes"|trans }}

{% if is_granted("IS_AUTHENTICATED") and season.episodes|length > 0 %} + + + + {% endif %} + {% if show.plot %} {{ show.plot }} From 541b5f8edc2da53a7f37012689a9270c6f1ac3a7 Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Sat, 1 Aug 2026 19:03:51 +0200 Subject: [PATCH 5/8] Add button to add movie sets to user collections --- templates/movieset.twig | 52 ++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/templates/movieset.twig b/templates/movieset.twig index 0373509..72e80c7 100644 --- a/templates/movieset.twig +++ b/templates/movieset.twig @@ -9,33 +9,43 @@
-

{{ movieSet.title }}

+
+
+

{{ movieSet.title }}

-
- {% if movieSet.tmdbId %} - TMDB - {% endif %} - {% if movieSet.tvdbId %} - TVDB - {% endif %} -
+
+ {% if movieSet.tmdbId %} + TMDB + {% endif %} + {% if movieSet.tvdbId %} + TVDB + {% endif %} +
-
- {{ "movies.count"|trans({"%count%": movieSet.movies|length}) }} +
+ {{ "movies.count"|trans({"%count%": movieSet.movies|length}) }} - {% set firstMovie = movieSet.movies.first %} - {% set lastMovie = movieSet.movies.last %} + {% set firstMovie = movieSet.movies.first %} + {% set lastMovie = movieSet.movies.last %} - {% if firstMovie.year == lastMovie.year %} - {% set year = firstMovie.year %} - {% else %} - {% set year = firstMovie.year ~ " - " ~ lastMovie.year %} - {% endif %} + {% if firstMovie.year == lastMovie.year %} + {% set year = firstMovie.year %} + {% else %} + {% set year = firstMovie.year ~ " - " ~ lastMovie.year %} + {% endif %} + + {{ year }} - {{ year }} + {% if movieSet.runtime %} + {{ "runtime"|trans({"%runtime%": movieSet.runtime}) }} + {% endif %} +
+
- {% if movieSet.runtime %} - {{ "runtime"|trans({"%runtime%": movieSet.runtime}) }} + {% if is_granted("IS_AUTHENTICATED") %} +
+ +
{% endif %}
From d6b3c13e1ede3793b1a259253883264fd7aeef9a Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Sat, 1 Aug 2026 21:02:27 +0200 Subject: [PATCH 6/8] Implement renaming and removing collections --- .../controller/UserCollectionController.php | 58 ++++++++++++++++++- src/main/php/tracky/model/UserCollection.php | 2 +- .../assets/script/usercollections.ts | 45 ++++++++++++++ .../resources/lang/messages+intl-icu.en.yaml | 16 +++++ templates/page.twig | 12 ++++ templates/user/collections/collection.twig | 26 ++++++++- templates/user/collections/collections.twig | 4 +- 7 files changed, 156 insertions(+), 7 deletions(-) diff --git a/src/main/php/tracky/controller/UserCollectionController.php b/src/main/php/tracky/controller/UserCollectionController.php index 3ae377e..b8b52f3 100644 --- a/src/main/php/tracky/controller/UserCollectionController.php +++ b/src/main/php/tracky/controller/UserCollectionController.php @@ -123,11 +123,11 @@ public function createCollection(User $user, Request $request, UserCollectionRep $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]); + return $this->redirectToRoute("user_profile_collections_page", ["username" => $user->getUsername(), "flash" => "error", "action" => "create", "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]); + return $this->redirectToRoute("user_profile_collections_page", ["username" => $user->getUsername(), "flash" => "error", "action" => "create", "error" => "duplicate-collection", "name" => $name]); } $collection = new UserCollection; @@ -138,7 +138,59 @@ public function createCollection(User $user, Request $request, UserCollectionRep $entityManager->persist($collection); $entityManager->flush(); - return $this->redirectToRoute("user_profile_collections_page", ["username" => $user->getUsername(), "flash" => "success", "name" => $name]); + return $this->redirectToRoute("user_profile_collections_page", ["username" => $user->getUsername(), "flash" => "success", "action" => "create", "name" => $name]); + } + + #[Route("/users/{username}/collections/{collection}", name: "user_profile_collection_remove_action", methods: ["DELETE"])] + #[IsGranted("IS_AUTHENTICATED")] + public function removeCollection(User $user, UserCollection $collection, EntityManagerInterface $entityManager): Response + { + /** + * @var User + */ + $currentUser = $this->getUser(); + + if ($collection->getUser()->getId() !== $user->getId() or $user->getId() !== $currentUser->getId()) { + throw new AccessDeniedHttpException; + } + + $entityManager->remove($collection); + $entityManager->flush(); + + return new Response("Collection removed"); + } + + #[Route("/users/{username}/collections/{collection}/rename", name: "user_profile_collection_rename_action", methods: ["POST"])] + #[IsGranted("IS_AUTHENTICATED")] + public function renameCollection(User $user, UserCollection $collection, Request $request, UserCollectionRepository $userCollectionRepository, EntityManagerInterface $entityManager): Response + { + /** + * @var User + */ + $currentUser = $this->getUser(); + + if ($collection->getUser()->getId() !== $user->getId() or $user->getId() !== $currentUser->getId()) { + throw new AccessDeniedHttpException; + } + + $name = trim($request->request->getString("name")); + if ($name === "") { + return $this->redirectToRoute("user_profile_collection_page", ["username" => $user->getUsername(), "collection" => $collection->getId(), "flash" => "error", "error" => "empty-name"]); + } + + foreach ($userCollectionRepository->findBy(["user" => $user, "name" => $name]) as $otherCollection) { + if ($otherCollection->getId() !== $collection->getId()) { + return $this->redirectToRoute("user_profile_collection_page", ["username" => $user->getUsername(), "collection" => $collection->getId(), "flash" => "error", "error" => "duplicate-collection", "name" => $name]); + } + } + + $oldName = $collection->getName(); + $collection->setName($name); + + $entityManager->persist($collection); + $entityManager->flush(); + + return $this->redirectToRoute("user_profile_collection_page", ["username" => $user->getUsername(), "collection" => $collection->getId(), "flash" => "success", "name" => $oldName, "new-name" => $name]); } #[Route("/users/{username}/collections/{collection}/add-item", name: "user_profile_collection_add_item_action", methods: ["POST"])] diff --git a/src/main/php/tracky/model/UserCollection.php b/src/main/php/tracky/model/UserCollection.php index fce803c..8da6992 100644 --- a/src/main/php/tracky/model/UserCollection.php +++ b/src/main/php/tracky/model/UserCollection.php @@ -19,7 +19,7 @@ class UserCollection extends BaseEntity #[ORM\Column(name: "createdAt", type: "datetime", nullable: false)] private DateTime $createdAt; - #[ORM\OneToMany(mappedBy: "collection", targetEntity: UserCollectionItem::class, cascade: ["persist"])] + #[ORM\OneToMany(mappedBy: "collection", targetEntity: UserCollectionItem::class, cascade: ["persist"], orphanRemoval: true)] private mixed $items = []; public function getUser(): User diff --git a/src/main/resources/assets/script/usercollections.ts b/src/main/resources/assets/script/usercollections.ts index 223d098..ba16e98 100644 --- a/src/main/resources/assets/script/usercollections.ts +++ b/src/main/resources/assets/script/usercollections.ts @@ -7,8 +7,10 @@ interface Dictionary { document.addEventListener("DOMContentLoaded", () => { let username = document.querySelector('meta[name="username"]')?.content; let addCollectionItemTooltipElement = document.querySelector("#add-collection-item-tooltip")!; + let removeCollectionTooltipElement = document.querySelector("#remove-collection-tooltip")!; let collections: Dictionary | null = null; let activeAddCollectionItemEntry: DOMStringMap | null = null; + let activeRemoveCollectionEntry: DOMStringMap | null = null; function loadCollections() { if (collections === null) { @@ -73,4 +75,47 @@ document.addEventListener("DOMContentLoaded", () => { addCollectionItemTooltipElement.style.display = "none"; activeAddCollectionItemEntry = null; }); + + document.querySelector("#rename-collection")?.addEventListener("click", () => { + document.querySelectorAll("#collection-name, #rename-collection").forEach((element) => element.classList.add("d-none")); + document.querySelector("#collection-rename-form")?.classList.remove("d-none"); + }); + + document.querySelector("#collection-rename-cancel")?.addEventListener("click", () => { + document.querySelectorAll("#collection-name, #rename-collection").forEach((element) => element.classList.remove("d-none")); + + let form = document.querySelector("#collection-rename-form"); + form?.classList.add("d-none"); + form?.reset(); + }); + + document.querySelector("#remove-collection")?.addEventListener("click", () => { + let buttonElement = document.querySelector("#remove-collection")!; + + createPopper(buttonElement, removeCollectionTooltipElement, { + placement: "bottom" + }); + + removeCollectionTooltipElement.style.display = "block"; + activeRemoveCollectionEntry = buttonElement.dataset; + }); + + document.querySelector("#remove-collection-tooltip-confirm")?.addEventListener("click", () => { + if (activeRemoveCollectionEntry === null) { + return; + } + + let collectionName = activeRemoveCollectionEntry.name; + + fetch(`/users/${username}/collections/${activeRemoveCollectionEntry.id}`, { + method: "DELETE" + }).then(() => { + document.location.href = `/users/${username}/collections?flash=success&action=remove&name=${encodeURIComponent(collectionName ?? "")}`; + }); + }); + + document.querySelector("#remove-collection-tooltip-cancel")?.addEventListener("click", () => { + removeCollectionTooltipElement.style.display = "none"; + activeRemoveCollectionEntry = null; + }); }); diff --git a/src/main/resources/lang/messages+intl-icu.en.yaml b/src/main/resources/lang/messages+intl-icu.en.yaml index 7996985..f4d6931 100644 --- a/src/main/resources/lang/messages+intl-icu.en.yaml +++ b/src/main/resources/lang/messages+intl-icu.en.yaml @@ -75,6 +75,22 @@ user: error: empty-name: "No name specified!" duplicate-collection: "Another collection with the name ''{name}'' already exists!" + rename: + title: "Rename" + submit-response: + success: "Collection ''{name}'' renamed to ''{newName}''." + error: + empty-name: "No name specified!" + duplicate-collection: "Another collection with the name ''{name}'' already exists!" + ok: "OK" + cancel: "Cancel" + remove: + title: "Remove" + header: "Remove collection" + ok: "OK" + cancel: "Cancel" + submit-response: + success: "Collection ''{name}'' removed." add-item: header: "Add item to collection" ok: "OK" diff --git a/templates/page.twig b/templates/page.twig index 4096fb9..4aa15df 100644 --- a/templates/page.twig +++ b/templates/page.twig @@ -153,6 +153,18 @@
+
+
+ +
+
{{ "user.collections.remove.header"|trans }}
+
+ + +
+
+
+ {% block additional_content %}{% endblock %} diff --git a/templates/user/collections/collection.twig b/templates/user/collections/collection.twig index 1d35c2b..a801ea7 100644 --- a/templates/user/collections/collection.twig +++ b/templates/user/collections/collection.twig @@ -3,7 +3,31 @@ {% block title %}{{ collection.name }} - {{ user.username }}{% endblock %} {% block profileContent %} -

{{ collection.name }}

+
+
+

{{ collection.name }}

+
+
+ + + +
+
+
+ + {% if app.user == collection.user %} + + + {% endif %} +
+ + {% if user == app.user %} + {% if app.request.query.get("flash") == "success" %} +
{{ "user.collections.rename.submit-response.success"|trans({"name": app.request.query.get("name"), "newName": app.request.query.get("new-name")}) }}
+ {% elseif app.request.query.get("flash") == "error" %} +
{{ ("user.collections.rename.submit-response.error." ~ app.request.query.get("error"))|trans({"name": app.request.query.get("name")}) }}
+ {% endif %} + {% endif %} {% if collection.items|length %} {% set UserCollectionItemType = enum("tracky\\UserCollectionItemType") %} diff --git a/templates/user/collections/collections.twig b/templates/user/collections/collections.twig index ef1588e..12f466b 100644 --- a/templates/user/collections/collections.twig +++ b/templates/user/collections/collections.twig @@ -7,9 +7,9 @@ {% if user == app.user %} {% if app.request.query.get("flash") == "success" %} -
{{ "user.collections.create.submit-response.success"|trans({"name": app.request.query.get("name")}) }}
+
{{ ("user.collections." ~ app.request.query.get("action") ~ ".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")}) }}
+
{{ ("user.collections." ~ app.request.query.get("action") ~ ".submit-response.error." ~ app.request.query.get("error"))|trans({"name": app.request.query.get("name")}) }}
{% endif %}
From d872737349d24977add2ee932e28f6a023767f57 Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Sat, 1 Aug 2026 21:58:47 +0200 Subject: [PATCH 7/8] Implement sort options for collection items (like on movies page) --- .../controller/UserCollectionController.php | 60 ++++++++++++++++++- src/main/php/tracky/model/Season.php | 13 ++-- src/main/php/tracky/model/Show.php | 11 ++++ .../php/tracky/model/UserCollectionItem.php | 2 +- src/main/php/tracky/model/traits/Runtime.php | 4 +- .../resources/lang/messages+intl-icu.en.yaml | 5 ++ templates/components/season-card-small.twig | 2 +- templates/shows/season.twig | 2 +- templates/user/collections/collection.twig | 59 +++++++++++++----- 9 files changed, 126 insertions(+), 32 deletions(-) diff --git a/src/main/php/tracky/controller/UserCollectionController.php b/src/main/php/tracky/controller/UserCollectionController.php index b8b52f3..4b9a4aa 100644 --- a/src/main/php/tracky/controller/UserCollectionController.php +++ b/src/main/php/tracky/controller/UserCollectionController.php @@ -72,8 +72,22 @@ public function getCollectionsJson(User $user, UserCollectionRepository $userCol } #[Route("/users/{username}/collections/{collection}", name: "user_profile_collection_page", methods: ["GET"])] - public function getCollectionPage(User $user, UserCollection $collection): Response + public function getCollectionPage(User $user, UserCollection $collection, Request $request): Response { + $sort = explode("/", trim($request->query->get("sort", "")), 2); + + list($sortBy, $sortDirection) = $sort + ["", ""]; + + $sortOptions = ["title", "type", "added", "runtime"]; + + if (!in_array($sortBy, $sortOptions)) { + $sortBy = "added"; + } + + if (!in_array($sortDirection, ["asc", "desc"])) { + $sortDirection = "asc"; + } + /** * @var array> */ @@ -101,9 +115,51 @@ public function getCollectionPage(User $user, UserCollection $collection): Respo } } + $items = iterator_to_array($collection->getItems()); + + usort($items, function(UserCollectionItem $item1, UserCollectionItem $item2) use ($sortBy, $sortDirection) { + $resolvedItem1 = $item1->getResolvedItem(); + $resolvedItem2 = $item2->getResolvedItem(); + + switch ($sortBy) { + case "title": + $value1 = $resolvedItem1->getTitle(); + $value2 = $resolvedItem2->getTitle(); + break; + case "type": + $value1 = $item1->getType(); + $value2 = $item2->getType(); + break; + case "added": + $value1 = $item1->getAddedAt()->getTimestamp(); + $value2 = $item2->getAddedAt()->getTimestamp(); + break; + case "runtime": + $value1 = $resolvedItem1->getRuntime(); + $value2 = $resolvedItem2->getRuntime(); + break; + default: + return 0; + } + + if ($value1 < $value2) { + return $sortDirection == "asc" ? -1 : 1; + } elseif ($value1 > $value2) { + return $sortDirection == "asc" ? 1 : -1; + } else { + return 0; + } + }); + return $this->render("user/collections/collection.twig", [ "user" => $user, - "collection" => $collection + "collection" => $collection, + "items" => $items, + "sortOptions" => $sortOptions, + "sort" => [ + "field" => $sortBy, + "direction" => $sortDirection + ] ]); } diff --git a/src/main/php/tracky/model/Season.php b/src/main/php/tracky/model/Season.php index e52a2f7..905f1be 100644 --- a/src/main/php/tracky/model/Season.php +++ b/src/main/php/tracky/model/Season.php @@ -137,20 +137,15 @@ public function fetchPosterImages(ImageFetcher $imageFetcher, bool $includeEpiso } } - public function getTotalRuntime(): int + public function getRuntime(): int { - $totalRuntime = 0; + $runtime = 0; foreach ($this->getEpisodes() as $episode) { - $runtime = $episode->getRuntime(); - if ($runtime === null) { - continue; - } - - $totalRuntime += $runtime; + $runtime += $episode->getRuntime(); } - return $totalRuntime; + return $runtime; } public function getFirstAired(): ?Date diff --git a/src/main/php/tracky/model/Show.php b/src/main/php/tracky/model/Show.php index 84e3128..a1b07cc 100644 --- a/src/main/php/tracky/model/Show.php +++ b/src/main/php/tracky/model/Show.php @@ -189,6 +189,17 @@ public function getRandomEpisodes(int $count): array return $randomEpisodes; } + public function getRuntime(): int + { + $runtime = 0; + + foreach ($this->getSeasons() as $season) { + $runtime += $season->getRuntime(); + } + + return $runtime; + } + /** * @return list */ diff --git a/src/main/php/tracky/model/UserCollectionItem.php b/src/main/php/tracky/model/UserCollectionItem.php index d3386ff..d706ef1 100644 --- a/src/main/php/tracky/model/UserCollectionItem.php +++ b/src/main/php/tracky/model/UserCollectionItem.php @@ -73,7 +73,7 @@ public function setAddedAt(DateTime $date): self return $this; } - public function getResolvedItem(): BaseEntity + public function getResolvedItem(): Show|Season|Episode|Movie|MovieSet { return $this->resolvedItem; } diff --git a/src/main/php/tracky/model/traits/Runtime.php b/src/main/php/tracky/model/traits/Runtime.php index 52af9b8..dc3e1c0 100644 --- a/src/main/php/tracky/model/traits/Runtime.php +++ b/src/main/php/tracky/model/traits/Runtime.php @@ -8,9 +8,9 @@ trait Runtime #[ORM\Column(type: "integer", nullable: true)] private ?int $runtime; - public function getRuntime(): ?int + public function getRuntime(): int { - return $this->runtime; + return $this->runtime ?? 0; } public function setRuntime(?int $runtime): self diff --git a/src/main/resources/lang/messages+intl-icu.en.yaml b/src/main/resources/lang/messages+intl-icu.en.yaml index f4d6931..157632a 100644 --- a/src/main/resources/lang/messages+intl-icu.en.yaml +++ b/src/main/resources/lang/messages+intl-icu.en.yaml @@ -101,6 +101,11 @@ user: name: "Name" items: "Items" creation-date: "Creation date" + sort: + title: "Title" + type: "Type" + added: "Added" + runtime: "Runtime" search: title: "Search" diff --git a/templates/components/season-card-small.twig b/templates/components/season-card-small.twig index 407f48f..478a476 100644 --- a/templates/components/season-card-small.twig +++ b/templates/components/season-card-small.twig @@ -11,7 +11,7 @@ {{ season.year }} {{ "shows.count.episodes"|trans({"%count%": season.episodes|length}) }} - {{ "runtime"|trans({"%runtime%": season.totalRuntime}) }} + {{ "runtime"|trans({"%runtime%": season.runtime}) }} {% if app.user %} diff --git a/templates/shows/season.twig b/templates/shows/season.twig index af728de..29480ef 100644 --- a/templates/shows/season.twig +++ b/templates/shows/season.twig @@ -32,7 +32,7 @@ {{ season.year }} - {{ "runtime"|trans({"%runtime%": season.totalRuntime}) }} + {{ "runtime"|trans({"%runtime%": season.runtime}) }} {% if season.plot %} {{ season.plot }} diff --git a/templates/user/collections/collection.twig b/templates/user/collections/collection.twig index a801ea7..e85740c 100644 --- a/templates/user/collections/collection.twig +++ b/templates/user/collections/collection.twig @@ -3,22 +3,49 @@ {% block title %}{{ collection.name }} - {{ user.username }}{% endblock %} {% block profileContent %} -
-
-

{{ collection.name }}

- -
- - - -
- +
+
+
+

{{ collection.name }}

+
+
+ + + +
+
+
+ + {% if app.user == collection.user %} + + + {% endif %}
- {% if app.user == collection.user %} - - - {% endif %} +
+ +
{% if user == app.user %} @@ -29,11 +56,11 @@ {% endif %} {% endif %} - {% if collection.items|length %} + {% if items|length %} {% set UserCollectionItemType = enum("tracky\\UserCollectionItemType") %}
- {% for item in collection.items %} + {% for item in items %}
{% if item.type == UserCollectionItemType.SHOW %} {% include "components/show-card-small.twig" with {"show": item.resolvedItem} %} From 602bd7270193e40fd92e6ffeea21daf71708ff4f Mon Sep 17 00:00:00 2001 From: Michael Wieland Date: Sun, 2 Aug 2026 16:18:51 +0200 Subject: [PATCH 8/8] Implement removing items from user collections --- .../controller/UserCollectionController.php | 25 ++++++++++-- .../assets/script/usercollections.ts | 33 ++++++++++++++++ .../resources/lang/messages+intl-icu.en.yaml | 7 ++++ templates/page.twig | 12 ++++++ templates/user/collections/collection.twig | 39 ++++++++++++------- 5 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/main/php/tracky/controller/UserCollectionController.php b/src/main/php/tracky/controller/UserCollectionController.php index 4b9a4aa..5ee1ad6 100644 --- a/src/main/php/tracky/controller/UserCollectionController.php +++ b/src/main/php/tracky/controller/UserCollectionController.php @@ -231,12 +231,12 @@ public function renameCollection(User $user, UserCollection $collection, Request $name = trim($request->request->getString("name")); if ($name === "") { - return $this->redirectToRoute("user_profile_collection_page", ["username" => $user->getUsername(), "collection" => $collection->getId(), "flash" => "error", "error" => "empty-name"]); + return $this->redirectToRoute("user_profile_collection_page", ["username" => $user->getUsername(), "collection" => $collection->getId(), "flash" => "error", "action" => "rename", "error" => "empty-name"]); } foreach ($userCollectionRepository->findBy(["user" => $user, "name" => $name]) as $otherCollection) { if ($otherCollection->getId() !== $collection->getId()) { - return $this->redirectToRoute("user_profile_collection_page", ["username" => $user->getUsername(), "collection" => $collection->getId(), "flash" => "error", "error" => "duplicate-collection", "name" => $name]); + return $this->redirectToRoute("user_profile_collection_page", ["username" => $user->getUsername(), "collection" => $collection->getId(), "flash" => "error", "action" => "rename", "error" => "duplicate-collection", "name" => $name]); } } @@ -246,7 +246,7 @@ public function renameCollection(User $user, UserCollection $collection, Request $entityManager->persist($collection); $entityManager->flush(); - return $this->redirectToRoute("user_profile_collection_page", ["username" => $user->getUsername(), "collection" => $collection->getId(), "flash" => "success", "name" => $oldName, "new-name" => $name]); + return $this->redirectToRoute("user_profile_collection_page", ["username" => $user->getUsername(), "collection" => $collection->getId(), "flash" => "success", "action" => "rename", "name" => $oldName, "new-name" => $name]); } #[Route("/users/{username}/collections/{collection}/add-item", name: "user_profile_collection_add_item_action", methods: ["POST"])] @@ -288,4 +288,23 @@ public function addItemToCollection(User $user, UserCollection $collection, Requ return new Response("Item added"); } + + #[Route("/users/{username}/collections/{collection}/{collectionItem}", name: "user_profile_collection_remove_item_action", methods: ["DELETE"])] + #[IsGranted("IS_AUTHENTICATED")] + public function removeItemFromCollection(User $user, UserCollection $collection, UserCollectionItem $collectionItem, EntityManagerInterface $entityManager): Response + { + /** + * @var User + */ + $currentUser = $this->getUser(); + + if ($collection->getUser()->getId() !== $user->getId() or $user->getId() !== $currentUser->getId() or $collection->getId() !== $collectionItem->getCollection()->getId()) { + throw new AccessDeniedHttpException; + } + + $entityManager->remove($collectionItem); + $entityManager->flush(); + + return new Response("Item removed"); + } } diff --git a/src/main/resources/assets/script/usercollections.ts b/src/main/resources/assets/script/usercollections.ts index ba16e98..e5f4387 100644 --- a/src/main/resources/assets/script/usercollections.ts +++ b/src/main/resources/assets/script/usercollections.ts @@ -8,9 +8,11 @@ document.addEventListener("DOMContentLoaded", () => { let username = document.querySelector('meta[name="username"]')?.content; let addCollectionItemTooltipElement = document.querySelector("#add-collection-item-tooltip")!; let removeCollectionTooltipElement = document.querySelector("#remove-collection-tooltip")!; + let removeCollectionItemTooltipElement = document.querySelector("#remove-collection-item-tooltip")!; let collections: Dictionary | null = null; let activeAddCollectionItemEntry: DOMStringMap | null = null; let activeRemoveCollectionEntry: DOMStringMap | null = null; + let activeRemoveCollectionItemEntry: DOMStringMap | null = null; function loadCollections() { if (collections === null) { @@ -118,4 +120,35 @@ document.addEventListener("DOMContentLoaded", () => { removeCollectionTooltipElement.style.display = "none"; activeRemoveCollectionEntry = null; }); + + document.querySelectorAll(".remove-collection-item").forEach((buttonElement) => { + buttonElement.addEventListener("click", () => { + createPopper(buttonElement, removeCollectionItemTooltipElement, { + placement: "bottom" + }); + + removeCollectionItemTooltipElement.style.display = "block"; + activeRemoveCollectionItemEntry = buttonElement.dataset; + }); + }); + + document.querySelector("#remove-collection-item-tooltip-confirm")?.addEventListener("click", () => { + if (activeRemoveCollectionItemEntry === null) { + return; + } + + let collectionId = document.querySelector("#collection-items")?.dataset.id; + let itemName = activeRemoveCollectionItemEntry.itemName; + + fetch(`/users/${username}/collections/${collectionId}/${activeRemoveCollectionItemEntry.id}`, { + method: "DELETE" + }).then(() => { + document.location.href = `/users/${username}/collections/${collectionId}?flash=success&action=remove-item&name=${encodeURIComponent(itemName ?? "")}`; + }); + }); + + document.querySelector("#remove-collection-item-tooltip-cancel")?.addEventListener("click", () => { + removeCollectionItemTooltipElement.style.display = "none"; + activeRemoveCollectionItemEntry = null; + }); }); diff --git a/src/main/resources/lang/messages+intl-icu.en.yaml b/src/main/resources/lang/messages+intl-icu.en.yaml index 157632a..29db94e 100644 --- a/src/main/resources/lang/messages+intl-icu.en.yaml +++ b/src/main/resources/lang/messages+intl-icu.en.yaml @@ -95,6 +95,13 @@ user: header: "Add item to collection" ok: "OK" cancel: "Cancel" + remove-item: + title: "Remove from collection" + header: "Remove item from collection" + ok: "OK" + cancel: "Cancel" + submit-response: + success: "Item ''{name}'' removed from collection." add-to-collection: "Add to collection" no-collections-available: "No collections available." no-items-available: "No items available." diff --git a/templates/page.twig b/templates/page.twig index 4aa15df..60f802b 100644 --- a/templates/page.twig +++ b/templates/page.twig @@ -165,6 +165,18 @@
+
+
+ +
+
{{ "user.collections.remove-item.header"|trans }}
+
+ + +
+
+
+ {% block additional_content %}{% endblock %} diff --git a/templates/user/collections/collection.twig b/templates/user/collections/collection.twig index e85740c..8fb4768 100644 --- a/templates/user/collections/collection.twig +++ b/templates/user/collections/collection.twig @@ -50,29 +50,40 @@ {% if user == app.user %} {% if app.request.query.get("flash") == "success" %} -
{{ "user.collections.rename.submit-response.success"|trans({"name": app.request.query.get("name"), "newName": app.request.query.get("new-name")}) }}
+
{{ ("user.collections." ~ app.request.query.get("action") ~ ".submit-response.success")|trans({"name": app.request.query.get("name"), "newName": app.request.query.get("new-name")}) }}
{% elseif app.request.query.get("flash") == "error" %} -
{{ ("user.collections.rename.submit-response.error." ~ app.request.query.get("error"))|trans({"name": app.request.query.get("name")}) }}
+
{{ ("user.collections." ~ app.request.query.get("action") ~ ".submit-response.error." ~ app.request.query.get("error"))|trans({"name": app.request.query.get("name")}) }}
{% endif %} {% endif %} {% if items|length %} {% set UserCollectionItemType = enum("tracky\\UserCollectionItemType") %} -
+
{% for item in 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 %} +
+ {% if item.type == UserCollectionItemType.SHOW %} + {% set itemName = item.resolvedItem.title %} + {% include "components/show-card-small.twig" with {"show": item.resolvedItem} %} + {% elseif item.type == UserCollectionItemType.SEASON %} + {% set itemName = item.resolvedItem.show.title ~ " - " ~ ("shows.season"|trans({"%number%": item.resolvedItem.number }))%} + {% include "components/season-card-small.twig" with {"season": item.resolvedItem} %} + {% elseif item.type == UserCollectionItemType.EPISODE %} + {% set itemName = item.resolvedItem.season.show.title ~ " - " ~ ("shows.episode"|trans({"%number%": item.resolvedItem.season.number ~ "x" ~ item.resolvedItem.number }))%} + {% include "components/episode-card-small.twig" with {"episode": item.resolvedItem} %} + {% elseif item.type == UserCollectionItemType.MOVIE %} + {% set itemName = item.resolvedItem.title %} + {% include "components/movie-card-small.twig" with {"movie": item.resolvedItem} %} + {% elseif item.type == UserCollectionItemType.MOVIE_SET %} + {% set itemName = item.resolvedItem.title %} + {% include "components/movieset-card-small.twig" with {"movieSet": item.resolvedItem} %} + {% endif %} + + {% if app.user == collection.user %} + + {% endif %} +
{% endfor %}