From 21442be52bf56bd558fb84e63419b2fd874299b6 Mon Sep 17 00:00:00 2001 From: Marcin Kurczewski Date: Sun, 6 Aug 2023 16:12:33 +0200 Subject: [PATCH] playlists: show in the frontend --- backend/trcustoms/common/errors.py | 9 + backend/trcustoms/playlists/serializers.py | 8 +- .../tests/test_playlist_items_update.py | 2 +- backend/trcustoms/playlists/views.py | 20 ++- backend/trcustoms/urls.py | 6 +- backend/trcustoms/utils/views.py | 6 +- frontend/src/App.tsx | 5 + .../LevelAddToMyPlaylistButton/index.tsx | 74 ++++++++ .../components/common/AutoComplete/index.tsx | 4 +- .../WalkthroughRadioboxes/index.tsx | 12 +- .../common/LevelSearchSidebar/index.tsx | 12 +- .../components/common/LevelSidebar/index.tsx | 3 + .../common/PermissionGuard/index.tsx | 33 +++- .../common/PlaylistAddForm/index.tsx | 68 ++++++++ .../common/PlaylistItemForm/index.tsx | 160 ++++++++++++++++++ .../common/PlaylistTable/EditButton/index.tsx | 57 +++++++ .../PlaylistTable/RemoveButton/index.tsx | 46 +++++ .../common/PlaylistTable/index.module.css | 14 ++ .../components/common/PlaylistTable/index.tsx | 103 +++++++++++ .../components/common/Radioboxes/index.tsx | 4 +- .../components/common/UserSidebar/index.tsx | 6 + .../components/icons/IconBookmark/index.tsx | 22 +++ frontend/src/components/icons/index.tsx | 1 + .../modals/PlaylistItemModal/index.tsx | 40 +++++ .../components/pages/LevelListPage/index.tsx | 21 +-- .../ReviewLevelSuggestionsPage/index.tsx | 10 -- .../src/components/pages/UserPage/index.tsx | 9 - .../pages/UserPlaylistPage/index.module.css | 5 + .../pages/UserPlaylistPage/index.tsx | 66 ++++++++ frontend/src/index.css | 4 + frontend/src/services/LevelService.ts | 22 +-- frontend/src/services/PlaylistService.ts | 123 ++++++++++++++ frontend/src/services/UserService.ts | 1 + frontend/src/themes.css | 15 ++ 34 files changed, 913 insertions(+), 78 deletions(-) create mode 100644 backend/trcustoms/common/errors.py create mode 100644 frontend/src/components/buttons/LevelAddToMyPlaylistButton/index.tsx create mode 100644 frontend/src/components/common/PlaylistAddForm/index.tsx create mode 100644 frontend/src/components/common/PlaylistItemForm/index.tsx create mode 100644 frontend/src/components/common/PlaylistTable/EditButton/index.tsx create mode 100644 frontend/src/components/common/PlaylistTable/RemoveButton/index.tsx create mode 100644 frontend/src/components/common/PlaylistTable/index.module.css create mode 100644 frontend/src/components/common/PlaylistTable/index.tsx create mode 100644 frontend/src/components/icons/IconBookmark/index.tsx create mode 100644 frontend/src/components/modals/PlaylistItemModal/index.tsx create mode 100644 frontend/src/components/pages/UserPlaylistPage/index.module.css create mode 100644 frontend/src/components/pages/UserPlaylistPage/index.tsx create mode 100644 frontend/src/services/PlaylistService.ts diff --git a/backend/trcustoms/common/errors.py b/backend/trcustoms/common/errors.py new file mode 100644 index 00000000..66b7fc9d --- /dev/null +++ b/backend/trcustoms/common/errors.py @@ -0,0 +1,9 @@ +from typing import Any + +from rest_framework.exceptions import APIException + + +class CustomAPIException(APIException): + def __init__(self, detail: Any, code: str) -> None: + self.code = code + super().__init__({**detail, "code": code}) diff --git a/backend/trcustoms/playlists/serializers.py b/backend/trcustoms/playlists/serializers.py index 47e43750..13902382 100644 --- a/backend/trcustoms/playlists/serializers.py +++ b/backend/trcustoms/playlists/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers +from trcustoms.common.errors import CustomAPIException from trcustoms.levels.models import Level from trcustoms.levels.serializers import LevelNestedSerializer from trcustoms.permissions import UserPermission, has_permission @@ -46,8 +47,11 @@ def validate(self, data): .exclude(id=self.instance.id if self.instance else None) .exists() ): - raise serializers.ValidationError( - {"level_id": "This level already appears in this playlist."} + raise CustomAPIException( + detail={ + "level_id": "This level already appears in this playlist.", + }, + code="duplicate_level", ) return validated_data diff --git a/backend/trcustoms/playlists/tests/test_playlist_items_update.py b/backend/trcustoms/playlists/tests/test_playlist_items_update.py index 5b27b456..b6d6d319 100644 --- a/backend/trcustoms/playlists/tests/test_playlist_items_update.py +++ b/backend/trcustoms/playlists/tests/test_playlist_items_update.py @@ -68,7 +68,7 @@ def test_playlist_item_update_allows_edits_from_staff( user=UserFactory(username="unique user") ) resp = staff_api_client.patch( - f"/api/users/{staff_api_client.user.pk}/playlist/{playlist_item.pk}/", + f"/api/users/{playlist_item.user.pk}/playlist/{playlist_item.pk}/", format="json", data={}, ) diff --git a/backend/trcustoms/playlists/views.py b/backend/trcustoms/playlists/views.py index 13603991..07ea8bb8 100644 --- a/backend/trcustoms/playlists/views.py +++ b/backend/trcustoms/playlists/views.py @@ -1,5 +1,8 @@ -from rest_framework import mixins, viewsets +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.generics import get_object_or_404 from rest_framework.permissions import AllowAny +from rest_framework.response import Response from trcustoms.mixins import PermissionsMixin from trcustoms.permissions import ( @@ -22,12 +25,13 @@ class PlaylistItemViewSet( ): queryset = PlaylistItem.objects.all().prefetch_related("level", "user") search_fields = ["level__name"] - ordering_fields = ["level__name", "created", "last_updated"] + ordering_fields = ["level__name", "status", "created", "last_updated"] permission_classes = [AllowNone] permission_classes_by_action = { "retrieve": [AllowAny], "list": [AllowAny], + "by_level_id": [AllowAny], "create": [ HasPermission(UserPermission.EDIT_PLAYLISTS) | IsAccessingOwnResource @@ -48,8 +52,20 @@ class PlaylistItemViewSet( serializer_class = PlaylistItemSerializer + def get_queryset(self): + user_id = self.kwargs["user_id"] + return self.queryset.filter(user_id=user_id) + def get_serializer_context(self) -> dict: return { **super().get_serializer_context(), "user_id": self.kwargs["user_id"], } + + @action(detail=False) + def by_level_id(self, request, user_id: int, level_id: str): + item = get_object_or_404( + self.queryset, user_id=user_id, level_id=level_id + ) + serializer = self.get_serializer(item) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/trcustoms/urls.py b/backend/trcustoms/urls.py index 8059d7da..05694df3 100644 --- a/backend/trcustoms/urls.py +++ b/backend/trcustoms/urls.py @@ -34,7 +34,7 @@ ) from trcustoms.uploads.views import UploadViewSet from trcustoms.users.views import UserViewSet -from trcustoms.utils.views import as_detail_view, as_list_view +from trcustoms.utils.views import as_detail_view, as_list_view, as_view from trcustoms.walkthroughs.views import WalkthroughViewSet router = DefaultRouter() @@ -72,6 +72,10 @@ "api/users//playlist//", as_detail_view(PlaylistItemViewSet), ), + path( + "api/users//playlist/by_level_id//", + as_view(PlaylistItemViewSet, actions={"get": "by_level_id"}), + ), path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path("api/swagger/", SpectacularSwaggerView.as_view(url_name="schema")), path("api/redoc/", SpectacularRedocView.as_view(url_name="schema")), diff --git a/backend/trcustoms/utils/views.py b/backend/trcustoms/utils/views.py index 210f49d0..8021dbcf 100644 --- a/backend/trcustoms/utils/views.py +++ b/backend/trcustoms/utils/views.py @@ -1,4 +1,4 @@ -def _as_view(viewset, actions): +def as_view(viewset, actions): actual_actions = { action: method for action, method in actions.items() @@ -8,7 +8,7 @@ def _as_view(viewset, actions): def as_list_view(viewset): - return _as_view( + return as_view( viewset, actions={ "get": "list", @@ -18,7 +18,7 @@ def as_list_view(viewset): def as_detail_view(viewset): - return _as_view( + return as_view( viewset, actions={ "get": "retrieve", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d4d6b188..8cbb9221 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ import { TrophiesPage } from "src/components/pages/TrophiesPage"; import { UserEditPage } from "src/components/pages/UserEditPage"; import { UserListPage } from "src/components/pages/UserListPage"; import { UserPage } from "src/components/pages/UserPage"; +import { UserPlaylistPage } from "src/components/pages/UserPlaylistPage"; import { UserWalkthroughsPage } from "src/components/pages/UserWalkthroughsPage"; import { WalkthroughEditPage } from "src/components/pages/WalkthroughEditPage"; import { WalkthroughPage } from "src/components/pages/WalkthroughPage"; @@ -120,6 +121,10 @@ const App = () => { path="/users/:userId/walkthroughs" element={} /> + } + /> } diff --git a/frontend/src/components/buttons/LevelAddToMyPlaylistButton/index.tsx b/frontend/src/components/buttons/LevelAddToMyPlaylistButton/index.tsx new file mode 100644 index 00000000..64d6276f --- /dev/null +++ b/frontend/src/components/buttons/LevelAddToMyPlaylistButton/index.tsx @@ -0,0 +1,74 @@ +import { useContext } from "react"; +import { useState } from "react"; +import { useQueryClient } from "react-query"; +import { useQuery } from "react-query"; +import { Button } from "src/components/common/Button"; +import { IconBookmark } from "src/components/icons"; +import { PlaylistItemModal } from "src/components/modals/PlaylistItemModal"; +import { UserContext } from "src/contexts/UserContext"; +import type { LevelNested } from "src/services/LevelService"; +import type { PlaylistItemDetails } from "src/services/PlaylistService"; +import { PlaylistService } from "src/services/PlaylistService"; +import { resetQueries } from "src/utils/misc"; + +interface LevelAddToMyPlaylistButtonProps { + level: LevelNested; +} + +const LevelAddToMyPlaylistButton = ({ + level, +}: LevelAddToMyPlaylistButtonProps) => { + const { user } = useContext(UserContext); + const queryClient = useQueryClient(); + const [isChanged, setIsChanged] = useState(false); + const [isModalActive, setIsModalActive] = useState(false); + + const playlistItemResult = useQuery( + ["playlists", PlaylistService.get, user?.id, level.id], + async () => PlaylistService.get(user?.id, level.id) + ); + + const handleButtonClick = () => { + setIsModalActive(true); + }; + + const handleSubmit = () => { + setIsChanged(true); + }; + + const handleIsModalActiveChange = (value: boolean) => { + setIsModalActive(value); + if (isChanged) { + resetQueries(queryClient, ["playlists"]); + } + }; + + if (!user) { + return <>; + } + + if (playlistItemResult.isLoading) { + return <>; + } + + return ( + <> + + + + + ); +}; + +export { LevelAddToMyPlaylistButton }; diff --git a/frontend/src/components/common/AutoComplete/index.tsx b/frontend/src/components/common/AutoComplete/index.tsx index d87e75ea..bee07e07 100644 --- a/frontend/src/components/common/AutoComplete/index.tsx +++ b/frontend/src/components/common/AutoComplete/index.tsx @@ -17,10 +17,12 @@ interface AutoCompleteProps { onSearchTrigger: (textInput: string) => void; onResultApply: (result: TItem) => void; onNewResultApply?: ((textInput: string) => void) | undefined; + placeholder?: string; } const AutoComplete = ({ maxLength, + placeholder, suggestions, getResultText, getResultKey, @@ -156,7 +158,7 @@ const AutoComplete = ({ onChange={handleInputChange} onKeyDown={handleInputKeyDown} value={textInput} - placeholder="Start typing to search…" + placeholder={placeholder || "Start typing to search…"} /> {showResults && textInput && } {onNewResultApply && ( diff --git a/frontend/src/components/common/LevelSearchSidebar/WalkthroughRadioboxes/index.tsx b/frontend/src/components/common/LevelSearchSidebar/WalkthroughRadioboxes/index.tsx index c78fb9aa..528b3a9c 100644 --- a/frontend/src/components/common/LevelSearchSidebar/WalkthroughRadioboxes/index.tsx +++ b/frontend/src/components/common/LevelSearchSidebar/WalkthroughRadioboxes/index.tsx @@ -1,8 +1,8 @@ import { Radioboxes } from "src/components/common/Radioboxes"; interface WalkthroughRadioboxesProps { - videoWalkthroughs: boolean | null; - textWalkthroughs: boolean | null; + videoWalkthroughs: boolean | null | undefined; + textWalkthroughs: boolean | null | undefined; onChange: ( videoWalkthroughs: boolean | null, textWalkthroughs: boolean | null @@ -10,8 +10,8 @@ interface WalkthroughRadioboxesProps { } interface OptionId { - videoWalkthroughs: boolean | null; - textWalkthroughs: boolean | null; + videoWalkthroughs: boolean | null | undefined; + textWalkthroughs: boolean | null | undefined; } interface Option { @@ -53,9 +53,9 @@ const WalkthroughRadioboxes = ({ }, ]; - const onChangeInternal = (value: OptionId | null): void => { + const onChangeInternal = (value: OptionId | null | undefined): void => { if (value) { - onChange(value.videoWalkthroughs, value.textWalkthroughs); + onChange(value.videoWalkthroughs ?? null, value.textWalkthroughs ?? null); } else { onChange(null, null); } diff --git a/frontend/src/components/common/LevelSearchSidebar/index.tsx b/frontend/src/components/common/LevelSearchSidebar/index.tsx index e36796a4..ef68c3fa 100644 --- a/frontend/src/components/common/LevelSearchSidebar/index.tsx +++ b/frontend/src/components/common/LevelSearchSidebar/index.tsx @@ -242,7 +242,7 @@ const LevelSearchSidebar = ({
@@ -251,7 +251,7 @@ const LevelSearchSidebar = ({
@@ -260,7 +260,7 @@ const LevelSearchSidebar = ({
@@ -278,7 +278,7 @@ const LevelSearchSidebar = ({
@@ -287,7 +287,7 @@ const LevelSearchSidebar = ({
@@ -299,7 +299,7 @@ const LevelSearchSidebar = ({ title="Difficulty" > diff --git a/frontend/src/components/common/LevelSidebar/index.tsx b/frontend/src/components/common/LevelSidebar/index.tsx index 725b8637..1da1eb8f 100644 --- a/frontend/src/components/common/LevelSidebar/index.tsx +++ b/frontend/src/components/common/LevelSidebar/index.tsx @@ -2,6 +2,7 @@ import styles from "./index.module.css"; import { useContext } from "react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { LevelAddToMyPlaylistButton } from "src/components/buttons/LevelAddToMyPlaylistButton"; import { LevelApproveButton } from "src/components/buttons/LevelApproveButton"; import { LevelDeleteButton } from "src/components/buttons/LevelDeleteButton"; import { LevelRejectButton } from "src/components/buttons/LevelRejectButton"; @@ -123,6 +124,8 @@ const LevelSidebar = ({ level, reviewCount }: LevelSidebarProps) => { + {user && } + diff --git a/frontend/src/components/common/PermissionGuard/index.tsx b/frontend/src/components/common/PermissionGuard/index.tsx index 5ff3f3c0..90a3a710 100644 --- a/frontend/src/components/common/PermissionGuard/index.tsx +++ b/frontend/src/components/common/PermissionGuard/index.tsx @@ -3,6 +3,7 @@ import { useContext } from "react"; import { useState } from "react"; import { Error403Page } from "src/components/pages/ErrorPage"; import { UserContext } from "src/contexts/UserContext"; +import type { UserDetails } from "src/services/UserService"; import type { UserNested } from "src/services/UserService"; import { UserPermission } from "src/services/UserService"; @@ -40,6 +41,22 @@ const GenericGuard = ({ return <>{isShown ? children : alternative}; }; +const hasPermission = ( + loggedInUser: UserDetails, + require: UserPermission | string, + owningUserIds?: number[] +): boolean => { + return ( + anonymousPermissions.some((r) => r === require) || + loggedInUser?.permissions?.some((r) => r === require) || + !!( + owningUserIds && + loggedInUser?.id && + owningUserIds.includes(loggedInUser.id) + ) + ); +}; + const PermissionGuard = ({ require, owningUsers, @@ -50,9 +67,11 @@ const PermissionGuard = ({ useEffect(() => { setIsShown( - anonymousPermissions.some((r) => r === require) || - user?.permissions?.includes(require) || - (owningUsers && owningUsers.map((u) => u.id).includes(user?.id)) + hasPermission( + user, + require, + owningUsers?.map((u) => u.id) + ) ); }, [user, owningUsers, require]); @@ -75,11 +94,7 @@ const PageGuard = ({ require, owningUserIds, children }: PageGuardProps) => { const [isShown, setIsShown] = useState(false); useEffect(() => { - setIsShown( - anonymousPermissions.some((r) => r === require) || - user?.permissions?.includes(require) || - owningUserIds?.includes(user?.id) - ); + setIsShown(hasPermission(user, require, owningUserIds)); }, [user, owningUserIds, require]); return ( @@ -89,4 +104,4 @@ const PageGuard = ({ require, owningUserIds, children }: PageGuardProps) => { ); }; -export { PermissionGuard, LoggedInUserGuard, PageGuard }; +export { hasPermission, PermissionGuard, LoggedInUserGuard, PageGuard }; diff --git a/frontend/src/components/common/PlaylistAddForm/index.tsx b/frontend/src/components/common/PlaylistAddForm/index.tsx new file mode 100644 index 00000000..a30b10b4 --- /dev/null +++ b/frontend/src/components/common/PlaylistAddForm/index.tsx @@ -0,0 +1,68 @@ +import { useCallback } from "react"; +import { useState } from "react"; +import { AutoComplete } from "src/components/common/AutoComplete"; +import type { LevelNested } from "src/services/LevelService"; +import { LevelService } from "src/services/LevelService"; +import { PlaylistItemStatus } from "src/services/PlaylistService"; +import { PlaylistService } from "src/services/PlaylistService"; +import type { UserNested } from "src/services/UserService"; + +interface PlaylistAddFormProps { + user: UserNested; + onAdd?: () => void; +} + +const PlaylistAddForm = ({ user, onAdd }: PlaylistAddFormProps) => { + const [suggestions, setSuggestions] = useState([]); + + const handleSearchTrigger = useCallback(async (userInput: string) => { + if (!userInput) { + setSuggestions([]); + return; + } + const searchQuery = { + search: userInput, + }; + try { + const response = await LevelService.searchLevels(searchQuery); + if (response.results) { + setSuggestions(response.results); + } + } catch (error) { + console.error(error); + } + }, []); + + const handleResultApply = useCallback( + async (level: LevelNested) => { + try { + await PlaylistService.create(user.id, { + levelId: level.id, + status: PlaylistItemStatus.NotYetPlayed, + }); + onAdd?.(); + } catch (error) { + console.error(error); + if ((error as any).response?.data.code === "duplicate_level") { + alert("This level was already added to the playlist."); + } else { + alert("Failed to add the level to the playlist."); + } + } + }, + [user, onAdd] + ); + + return ( + level.name} + getResultKey={(level) => level.id} + onSearchTrigger={handleSearchTrigger} + onResultApply={handleResultApply} + placeholder="Search for a level to add it…" + /> + ); +}; + +export { PlaylistAddForm }; diff --git a/frontend/src/components/common/PlaylistItemForm/index.tsx b/frontend/src/components/common/PlaylistItemForm/index.tsx new file mode 100644 index 00000000..43464f4d --- /dev/null +++ b/frontend/src/components/common/PlaylistItemForm/index.tsx @@ -0,0 +1,160 @@ +import { AxiosError } from "axios"; +import axios from "axios"; +import type { FormikHelpers } from "formik"; +import { Formik } from "formik"; +import { Form } from "formik"; +import { useCallback } from "react"; +import { FormGrid } from "src/components/common/FormGrid"; +import { FormGridButtons } from "src/components/common/FormGrid"; +import { FormGridFieldSet } from "src/components/common/FormGrid"; +import { Link } from "src/components/common/Link"; +import { DropDownFormField } from "src/components/formfields/DropDownFormField"; +import { TextFormField } from "src/components/formfields/TextFormField"; +import type { LevelNested } from "src/services/LevelService"; +import type { PlaylistItemDetails } from "src/services/PlaylistService"; +import { PlaylistService } from "src/services/PlaylistService"; +import { PlaylistItemStatus } from "src/services/PlaylistService"; +import type { UserNested } from "src/services/UserService"; +import { filterFalsyObjectValues } from "src/utils/misc"; +import { makeSentence } from "src/utils/string"; + +interface UserFormProps { + user: UserNested; + level?: LevelNested | undefined; + playlistItem?: PlaylistItemDetails | undefined; + onSubmit?: (() => void) | undefined; +} + +interface PlaylistItemFormValues { + levelName?: string; + status?: PlaylistItemStatus; +} + +const PlaylistItemForm = ({ + user, + level, + playlistItem, + onSubmit, +}: UserFormProps) => { + const initialValues: PlaylistItemFormValues = { + levelName: playlistItem?.level?.name || level?.name, + status: playlistItem?.status, + }; + + const handleSubmitError = useCallback( + ( + error: unknown, + { + setSubmitting, + setStatus, + setErrors, + }: FormikHelpers + ) => { + setSubmitting(false); + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + const data = axiosError.response?.data; + if (data.detail) { + setStatus({ error: <>{makeSentence(data.detail)} }); + } + const errors = { + status: data?.status, + }; + if (Object.keys(filterFalsyObjectValues(errors)).length) { + setErrors(errors); + } else { + console.error(error); + setStatus({ error: <>Unknown error. }); + } + } else { + console.error(error); + setStatus({ error: <>Unknown error. }); + } + }, + [] + ); + + const handleSubmit = useCallback( + async ( + values: PlaylistItemFormValues, + helpers: FormikHelpers + ) => { + const { setStatus } = helpers; + setStatus({}); + + try { + if (playlistItem?.id && values.status) { + const payload = { + status: values.status, + }; + await PlaylistService.update(user.id, playlistItem?.id, payload); + } else if (level && values.status) { + const payload = { + levelId: level.id, + status: values.status, + }; + await PlaylistService.create(user.id, payload); + } + + setStatus({ + success: ( + <> + Playlist updated. +
+
+ Click here to see + your playlist. + + ), + }); + onSubmit?.(); + } catch (error) { + handleSubmitError(error, helpers); + } + }, + [user, level, playlistItem, onSubmit, handleSubmitError] + ); + + const statusOptions = [ + { label: "Not yet played", value: PlaylistItemStatus.NotYetPlayed }, + { label: "Playing", value: PlaylistItemStatus.Playing }, + { label: "Finished", value: PlaylistItemStatus.Finished }, + { label: "Dropped", value: PlaylistItemStatus.Dropped }, + { label: "On hold", value: PlaylistItemStatus.OnHold }, + ]; + + return ( + + {({ isSubmitting, setFieldValue, status }) => + status?.success ? ( + status.success + ) : ( +
+ + + + + + + + + + + + + +
+ ) + } +
+ ); +}; + +export { PlaylistItemForm }; diff --git a/frontend/src/components/common/PlaylistTable/EditButton/index.tsx b/frontend/src/components/common/PlaylistTable/EditButton/index.tsx new file mode 100644 index 00000000..15abc140 --- /dev/null +++ b/frontend/src/components/common/PlaylistTable/EditButton/index.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { useQueryClient } from "react-query"; +import { Link } from "src/components/common/Link"; +import { PlaylistItemModal } from "src/components/modals/PlaylistItemModal"; +import type { LevelNested } from "src/services/LevelService"; +import type { PlaylistItemListing } from "src/services/PlaylistService"; +import type { UserNested } from "src/services/UserService"; +import { resetQueries } from "src/utils/misc"; + +interface EditPlaylistItemButtonProps { + user: UserNested; + level?: LevelNested; + item: PlaylistItemListing; +} + +const EditPlaylistItemButton = ({ + user, + level, + item, +}: EditPlaylistItemButtonProps) => { + const queryClient = useQueryClient(); + + const [isModalActive, setIsModalActive] = useState(false); + const [isChanged, setIsChanged] = useState(false); + + const handleSubmit = () => { + setIsChanged(true); + }; + + const handleButtonClick = () => { + setIsModalActive(true); + }; + + const handleIsModalActiveChange = (value: boolean) => { + setIsModalActive(value); + if (isChanged) { + resetQueries(queryClient, ["playlists"]); + } + }; + + return ( + <> + + + Edit + + ); +}; + +export { EditPlaylistItemButton }; diff --git a/frontend/src/components/common/PlaylistTable/RemoveButton/index.tsx b/frontend/src/components/common/PlaylistTable/RemoveButton/index.tsx new file mode 100644 index 00000000..b443a6bb --- /dev/null +++ b/frontend/src/components/common/PlaylistTable/RemoveButton/index.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { useQueryClient } from "react-query"; +import { Link } from "src/components/common/Link"; +import { ConfirmModal } from "src/components/modals/ConfirmModal"; +import { PlaylistService } from "src/services/PlaylistService"; +import type { PlaylistItemListing } from "src/services/PlaylistService"; +import { showAlertOnError } from "src/utils/misc"; +import { resetQueries } from "src/utils/misc"; + +interface RemovePlaylistItemButtonProps { + item: PlaylistItemListing; +} + +const RemovePlaylistItemButton = ({ item }: RemovePlaylistItemButtonProps) => { + const [isModalActive, setIsModalActive] = useState(false); + const queryClient = useQueryClient(); + + const handleButtonClick = () => { + setIsModalActive(true); + }; + + const handleModalConfirm = () => { + showAlertOnError(async () => { + if (item.user) { + await PlaylistService.delete(item.user.id, item.id); + } + resetQueries(queryClient, ["playlists"]); + }); + }; + + return ( + <> + + Are you sure you want to remove this level? + + + Remove + + ); +}; + +export { RemovePlaylistItemButton }; diff --git a/frontend/src/components/common/PlaylistTable/index.module.css b/frontend/src/components/common/PlaylistTable/index.module.css new file mode 100644 index 00000000..ea97c2d9 --- /dev/null +++ b/frontend/src/components/common/PlaylistTable/index.module.css @@ -0,0 +1,14 @@ +.notYetPlayed { +} + +.dropped { + color: var(--failure-fg-color); +} + +.playing { + color: var(--warning-fg-color); +} + +.finished { + color: var(--success-fg-color); +} diff --git a/frontend/src/components/common/PlaylistTable/index.tsx b/frontend/src/components/common/PlaylistTable/index.tsx new file mode 100644 index 00000000..acc3de05 --- /dev/null +++ b/frontend/src/components/common/PlaylistTable/index.tsx @@ -0,0 +1,103 @@ +import { EditPlaylistItemButton } from "./EditButton"; +import { RemovePlaylistItemButton } from "./RemoveButton"; +import styles from "./index.module.css"; +import { useContext } from "react"; +import type { DataTableColumn } from "src/components/common/DataTable"; +import { DataTable } from "src/components/common/DataTable"; +import { hasPermission } from "src/components/common/PermissionGuard"; +import { LevelLink } from "src/components/links/LevelLink"; +import { UserContext } from "src/contexts/UserContext"; +import type { PlaylistItemListing } from "src/services/PlaylistService"; +import type { PlaylistSearchQuery } from "src/services/PlaylistService"; +import { PlaylistItemStatus } from "src/services/PlaylistService"; +import { PlaylistService } from "src/services/PlaylistService"; +import { UserPermission } from "src/services/UserService"; +import { formatDate } from "src/utils/string"; + +interface PlaylistTableProps { + userId: number; + searchQuery: PlaylistSearchQuery; + onSearchQueryChange?: + | ((searchQuery: PlaylistSearchQuery) => void) + | undefined; +} + +const PlaylistTable = ({ + userId, + searchQuery, + onSearchQueryChange, +}: PlaylistTableProps) => { + const loggedInUser = useContext(UserContext).user; + + let columns: DataTableColumn[] = [ + { + name: "status", + sortKey: "status", + label: "Status", + itemElement: ({ item }) => + ({ + [PlaylistItemStatus.NotYetPlayed]: ( + Not yet played + ), + [PlaylistItemStatus.Dropped]: ( + Dropped + ), + [PlaylistItemStatus.Playing]: ( + Playing + ), + [PlaylistItemStatus.OnHold]: ( + On hold + ), + [PlaylistItemStatus.Finished]: ( + Finished + ), + }[item.status] || "Unknown"), + }, + { + name: "name", + sortKey: "level__name", + label: "Level name", + itemElement: ({ item }) => ( + {item.level.name} + ), + }, + { + name: "updated", + sortKey: "last_updated", + label: "Updated", + itemElement: ({ item }) => formatDate(item.last_updated), + }, + ]; + + if (hasPermission(loggedInUser, UserPermission.editPlaylists, [userId])) { + columns.push({ + name: "actions", + label: "Actions", + itemElement: ({ item }) => ( + <> + {" "} + | + + ), + }); + } + + const itemKey = (playlistItem: PlaylistItemListing) => `${playlistItem.id}`; + + return ( + PlaylistService.search(userId, query)} + onSearchQueryChange={onSearchQueryChange} + /> + ); +}; + +export { PlaylistTable }; diff --git a/frontend/src/components/common/Radioboxes/index.tsx b/frontend/src/components/common/Radioboxes/index.tsx index e6a8dd1a..9a28900e 100644 --- a/frontend/src/components/common/Radioboxes/index.tsx +++ b/frontend/src/components/common/Radioboxes/index.tsx @@ -5,8 +5,8 @@ interface RadioboxesProps { header?: React.ReactNode; footer?: React.ReactNode; options: TOption[]; - value: TOptionId | null; - onChange: (value: TOptionId | null) => any; + value: TOptionId | null | undefined; + onChange: (value: TOptionId | null | undefined) => any; getOptionId: (option: TOption) => TOptionId; getOptionName: (option: TOption) => string; getOptionSortPosition?: (option: TOption) => any; diff --git a/frontend/src/components/common/UserSidebar/index.tsx b/frontend/src/components/common/UserSidebar/index.tsx index 8590976e..6e127eea 100644 --- a/frontend/src/components/common/UserSidebar/index.tsx +++ b/frontend/src/components/common/UserSidebar/index.tsx @@ -144,6 +144,12 @@ const UserSidebar = ({ user }: UserSidebarProps) => { Library + + + {user.played_level_count} + + + {user.authored_level_count} diff --git a/frontend/src/components/icons/IconBookmark/index.tsx b/frontend/src/components/icons/IconBookmark/index.tsx new file mode 100644 index 00000000..e4a21bd0 --- /dev/null +++ b/frontend/src/components/icons/IconBookmark/index.tsx @@ -0,0 +1,22 @@ +const IconBookmark = () => { + return ( + + + + ); +}; + +export { IconBookmark }; diff --git a/frontend/src/components/icons/index.tsx b/frontend/src/components/icons/index.tsx index 8e25331e..56de41be 100644 --- a/frontend/src/components/icons/index.tsx +++ b/frontend/src/components/icons/index.tsx @@ -2,6 +2,7 @@ export * from "src/components/icons/IconAnnotation"; export * from "src/components/icons/IconBadgeCheck"; export * from "src/components/icons/IconBan"; export * from "src/components/icons/IconBook"; +export * from "src/components/icons/IconBookmark"; export * from "src/components/icons/IconCheck"; export * from "src/components/icons/IconChevronDown"; export * from "src/components/icons/IconChevronLeft"; diff --git a/frontend/src/components/modals/PlaylistItemModal/index.tsx b/frontend/src/components/modals/PlaylistItemModal/index.tsx new file mode 100644 index 00000000..1cb1bb15 --- /dev/null +++ b/frontend/src/components/modals/PlaylistItemModal/index.tsx @@ -0,0 +1,40 @@ +import { PlaylistItemForm } from "src/components/common/PlaylistItemForm"; +import { BaseModal } from "src/components/modals/BaseModal"; +import type { LevelNested } from "src/services/LevelService"; +import type { PlaylistItemDetails } from "src/services/PlaylistService"; +import type { UserNested } from "src/services/UserService"; + +interface PlaylistItemModalProps { + isActive: boolean; + onIsActiveChange: (isActive: boolean) => void; + user: UserNested; + level?: LevelNested; + playlistItem?: PlaylistItemDetails; + onSubmit?: (() => void) | undefined; +} + +const PlaylistItemModal = ({ + isActive, + onIsActiveChange, + user, + level, + playlistItem, + onSubmit, +}: PlaylistItemModalProps) => { + return ( + + + + ); +}; + +export { PlaylistItemModal }; diff --git a/frontend/src/components/pages/LevelListPage/index.tsx b/frontend/src/components/pages/LevelListPage/index.tsx index 09779b8f..e23934aa 100644 --- a/frontend/src/components/pages/LevelListPage/index.tsx +++ b/frontend/src/components/pages/LevelListPage/index.tsx @@ -17,16 +17,7 @@ const defaultSearchQuery: LevelSearchQuery = { page: null, sort: "-created", search: null, - tags: [], - genres: [], - engines: [], - authors: [], - difficulties: [], - durations: [], - ratings: [], isApproved: true, - videoWalkthroughs: null, - textWalkthroughs: null, }; const deserializeSearchQuery = (qp: { @@ -51,12 +42,12 @@ const serializeSearchQuery = ( ): { [key: string]: any } => filterFalsyObjectValues({ ...serializeGenericSearchQuery(searchQuery, defaultSearchQuery), - tags: searchQuery.tags.join(","), - genres: searchQuery.genres.join(","), - engines: searchQuery.engines.join(","), - difficulties: searchQuery.difficulties.join(","), - durations: searchQuery.durations.join(","), - ratings: searchQuery.ratings.join(","), + tags: searchQuery.tags?.join(","), + genres: searchQuery.genres?.join(","), + engines: searchQuery.engines?.join(","), + difficulties: searchQuery?.difficulties?.join(","), + durations: searchQuery.durations?.join(","), + ratings: searchQuery.ratings?.join(","), approved: searchQuery.isApproved === true ? null diff --git a/frontend/src/components/pages/ReviewLevelSuggestionsPage/index.tsx b/frontend/src/components/pages/ReviewLevelSuggestionsPage/index.tsx index 2db62888..140ae241 100644 --- a/frontend/src/components/pages/ReviewLevelSuggestionsPage/index.tsx +++ b/frontend/src/components/pages/ReviewLevelSuggestionsPage/index.tsx @@ -16,18 +16,8 @@ import { getCurrentSearchParams } from "src/utils/misc"; const defaultSearchQuery: LevelSearchQuery = { page: null, sort: "created", - search: null, - tags: [], - genres: [], - engines: [], - authors: [], - difficulties: [], - durations: [], - ratings: [], isApproved: true, reviewsMax: 5, - videoWalkthroughs: null, - textWalkthroughs: null, }; const deserializeSearchQuery = (qp: { diff --git a/frontend/src/components/pages/UserPage/index.tsx b/frontend/src/components/pages/UserPage/index.tsx index adc4d7e2..7be06dd8 100644 --- a/frontend/src/components/pages/UserPage/index.tsx +++ b/frontend/src/components/pages/UserPage/index.tsx @@ -17,17 +17,8 @@ const getLevelSearchQuery = ( ): LevelSearchQuery => ({ page: null, sort: "-created", - search: null, - tags: [], - genres: [], - engines: [], - difficulties: [], - durations: [], - ratings: [], authors: [userId], isApproved: isLoggedIn ? null : true, - videoWalkthroughs: null, - textWalkthroughs: null, }); const getReviewSearchQuery = (userId: number): ReviewSearchQuery => ({ diff --git a/frontend/src/components/pages/UserPlaylistPage/index.module.css b/frontend/src/components/pages/UserPlaylistPage/index.module.css new file mode 100644 index 00000000..2bab27e2 --- /dev/null +++ b/frontend/src/components/pages/UserPlaylistPage/index.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: flex; + flex-direction: column; + gap: 1rem; +} diff --git a/frontend/src/components/pages/UserPlaylistPage/index.tsx b/frontend/src/components/pages/UserPlaylistPage/index.tsx new file mode 100644 index 00000000..2bf50045 --- /dev/null +++ b/frontend/src/components/pages/UserPlaylistPage/index.tsx @@ -0,0 +1,66 @@ +import styles from "./index.module.css"; +import { useState } from "react"; +import { useQueryClient } from "react-query"; +import { useParams } from "react-router-dom"; +import { PermissionGuard } from "src/components/common/PermissionGuard"; +import { PlaylistAddForm } from "src/components/common/PlaylistAddForm"; +import { PlaylistTable } from "src/components/common/PlaylistTable"; +import { Section } from "src/components/common/Section"; +import { SectionHeader } from "src/components/common/Section"; +import { UserBasePage } from "src/components/pages/UserBasePage"; +import type { UserBasePageChildRenderProps } from "src/components/pages/UserBasePage"; +import type { PlaylistSearchQuery } from "src/services/PlaylistService"; +import { UserPermission } from "src/services/UserService"; +import { resetQueries } from "src/utils/misc"; + +interface UserPlaylistPageParams { + userId: string; +} + +const UserPlaylistPageView = ({ user }: UserBasePageChildRenderProps) => { + const [playlistSearchQuery, setPlaylistSearchQuery] = useState< + PlaylistSearchQuery + >({ + page: null, + sort: "-created", + }); + const queryClient = useQueryClient(); + + const handleAdd = () => { + resetQueries(queryClient, ["playlists"]); + }; + + return ( +
+ Playlist + +
+ + + + + +
+
+ ); +}; + +const UserPlaylistPage = () => { + const { userId } = (useParams() as unknown) as UserPlaylistPageParams; + return ( + + {(props: UserBasePageChildRenderProps) => ( + + )} + + ); +}; + +export { UserPlaylistPage }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 301af5ff..0d8643a3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -144,6 +144,10 @@ blockquote > *:last-child { height: calc(2rem + 4px); box-sizing: border-box; } +.Input:disabled { + border-color: var(--input-disabled-border-color); + background: var(--input-disabled-bg-color); +} .Input:focus { outline: 0; border-color: var(--outline-color); diff --git a/frontend/src/services/LevelService.ts b/frontend/src/services/LevelService.ts index 9d633b85..b42e6ec1 100644 --- a/frontend/src/services/LevelService.ts +++ b/frontend/src/services/LevelService.ts @@ -84,18 +84,18 @@ interface LevelDetails extends LevelListing { } interface LevelSearchQuery extends GenericSearchQuery { - tags: number[]; - genres: number[]; - engines: number[]; - authors: number[]; - difficulties: number[]; - durations: number[]; - ratings: number[]; - isApproved: boolean | null; + tags?: number[]; + genres?: number[]; + engines?: number[]; + authors?: number[]; + difficulties?: number[]; + durations?: number[]; + ratings?: number[]; + isApproved?: boolean | null; reviewsMax?: number | undefined | null; date?: string; - videoWalkthroughs: boolean | null; - textWalkthroughs: boolean | null; + videoWalkthroughs?: boolean | null; + textWalkthroughs?: boolean | null; } interface LevelSearchResult @@ -108,7 +108,7 @@ const searchLevels = async ( ): Promise => { const params = filterFalsyObjectValues({ ...getGenericSearchQuery(searchQuery), - tags: searchQuery.tags.join(","), + tags: searchQuery.tags?.join(","), genres: searchQuery.genres?.join(","), engines: searchQuery.engines?.join(","), authors: searchQuery.authors?.join(","), diff --git a/frontend/src/services/PlaylistService.ts b/frontend/src/services/PlaylistService.ts new file mode 100644 index 00000000..bceec2eb --- /dev/null +++ b/frontend/src/services/PlaylistService.ts @@ -0,0 +1,123 @@ +import { AxiosResponse } from "axios"; +import { api } from "src/api"; +import { API_URL } from "src/constants"; +import type { UploadedFile } from "src/services/FileService"; +import type { LevelNested } from "src/services/LevelService"; +import type { UserNested } from "src/services/UserService"; +import type { GenericSearchQuery } from "src/types"; +import { GenericSearchResult } from "src/types"; +import { filterFalsyObjectValues } from "src/utils/misc"; +import { getGenericSearchQuery } from "src/utils/misc"; + +enum PlaylistItemStatus { + NotYetPlayed = "not_yet_played", + Playing = "playing", + Finished = "finished", + Dropped = "dropped", + OnHold = "on_hold", +} + +interface PlaylistItemPlayer extends UserNested { + picture: UploadedFile | null; + reviewed_level_count: number; +} + +interface PlaylistItemListing { + id: number; + level: LevelNested; + user: PlaylistItemPlayer; + status: PlaylistItemStatus; + created: string; + last_updated: string; +} + +interface PlaylistItemDetails extends PlaylistItemListing {} + +interface PlaylistItemCreatePayload { + levelId: number; + status: PlaylistItemStatus; +} + +interface PlaylistItemUpdatePayload { + status: PlaylistItemStatus; +} + +interface PlaylistSearchQuery extends GenericSearchQuery {} + +interface PlaylistSearchResult + extends GenericSearchResult {} + +const search = async ( + userId: number, + searchQuery: PlaylistSearchQuery +): Promise => { + const params = filterFalsyObjectValues({ + ...getGenericSearchQuery(searchQuery), + }); + const response = (await api.get(`${API_URL}/users/${userId}/playlist/`, { + params, + })) as AxiosResponse; + return { ...response.data, searchQuery }; +}; + +const get = async ( + userId: number, + levelId: number +): Promise => { + const response = (await api.get( + `${API_URL}/users/${userId}/playlist/by_level_id/${levelId}/` + )) as AxiosResponse; + return { ...response.data }; +}; + +const create = async ( + userId: number, + { levelId, status }: PlaylistItemCreatePayload +): Promise => { + const data: { [key: string]: any } = { + level_id: levelId, + status, + }; + const response = (await api.post( + `${API_URL}/users/${userId}/playlist/`, + data + )) as AxiosResponse; + return response.data; +}; + +const update = async ( + userId: number, + playlistItemId: number, + { status }: PlaylistItemUpdatePayload +): Promise => { + const data = { status }; + const response = (await api.patch( + `${API_URL}/users/${userId}/playlist/${playlistItemId}/`, + data + )) as AxiosResponse; + return response.data; +}; + +const delete_ = async ( + userId: number, + playlistItemId: number +): Promise => { + await api.delete(`${API_URL}/users/${userId}/playlist/${playlistItemId}/`); +}; + +const PlaylistService = { + search, + get, + create, + update, + delete: delete_, +}; + +export type { + PlaylistItemDetails, + PlaylistItemListing, + PlaylistSearchQuery, + PlaylistSearchResult, +}; + +export { PlaylistItemStatus, PlaylistService }; diff --git a/frontend/src/services/UserService.ts b/frontend/src/services/UserService.ts index 704988bc..40b1b586 100644 --- a/frontend/src/services/UserService.ts +++ b/frontend/src/services/UserService.ts @@ -29,6 +29,7 @@ enum UserPermission { postWalkthroughs = "post_walkthroughs", editWalkthroughs = "edit_walkthroughs", deleteWalkthroughs = "delete_walkthroughs", + editPlaylists = "edit_playlists", } interface UserBasic { diff --git a/frontend/src/themes.css b/frontend/src/themes.css index 3ce7f1cf..23e2b4d6 100644 --- a/frontend/src/themes.css +++ b/frontend/src/themes.css @@ -1,6 +1,7 @@ :root { --input-border-color: var(--button-bg-color); --input-invalid-border-color: red; + --input-disabled-border-color: grey; --button-hovered-fg-color: var(--button-fg-color); --tab-switch-active-bg-color: var(--navbar-active-tab-bg-color); --tab-switch-inactive-bg-color: var(--label-bg-color); @@ -38,6 +39,7 @@ --outline-color: var(--link-color); --input-bg-color: #a8c7e0; --input-fg-color: black; + --input-disabled-bg-color: #c7c7c7; --button-bg-color: #1e67a1; --button-fg-color: white; --button-hovered-bg-color: #2f7ab6; @@ -76,6 +78,7 @@ --outline-color: var(--link-color); --input-bg-color: var(--bg-color); --input-fg-color: var(--fg-color); + --input-disabled-bg-color: #dddddd; --button-bg-color: grey; --button-fg-color: white; --button-hovered-bg-color: silver; @@ -130,6 +133,11 @@ --outline-color: var(--fg-color); --input-bg-color: hsl(var(--secondary-hue), var(--secondary-sat), 97%); --input-fg-color: var(--fg-color); + --input-disabled-bg-color: hsl( + var(--secondary-hue), + 0%, + var(--secondary-light) + ); --button-bg-color: var(--primary-color); --button-fg-color: var(--fg-color); --button-hovered-bg-color: var(--label-bg-color); @@ -169,6 +177,7 @@ --outline-color: var(--link-color); --input-bg-color: #fff1d1; --input-fg-color: black; + --input-disabled-bg-color: #e8e8e8e8; --button-bg-color: #bc1010; --button-fg-color: white; --button-hovered-bg-color: #d34848; @@ -207,6 +216,7 @@ --outline-color: var(--link-color); --input-bg-color: #e2d6c3; --input-fg-color: black; + --input-disabled-bg-color: #eeeeee; --button-bg-color: #c69532; --button-fg-color: black; --button-hovered-bg-color: #daaa4b; @@ -245,6 +255,7 @@ --outline-color: var(--link-color); --input-bg-color: #eee5cd; --input-fg-color: black; + --input-disabled-bg-color: #eeeeee; --button-bg-color: #a87c0d; --button-fg-color: white; --button-hovered-bg-color: #c5961f; @@ -282,6 +293,7 @@ --outline-color: var(--link-color); --input-bg-color: #f4faff; --input-fg-color: black; + --input-disabled-bg-color: #dddddd; --button-bg-color: #00008b; --button-fg-color: white; --button-hovered-bg-color: #5f5fb4; @@ -320,6 +332,7 @@ --outline-color: var(--link-color); --input-bg-color: #e0a8a8; --input-fg-color: #000; + --input-disabled-bg-color: #eeeeee; --button-bg-color: #a11e1e; --button-fg-color: #fff; --button-hovered-bg-color: #c13d3d; @@ -358,6 +371,7 @@ --outline-color: var(--link-color); --input-bg-color: #c3e4b5; --input-fg-color: #000; + --input-disabled-bg-color: #d8d8d8; --button-bg-color: #45a11e; --button-fg-color: #fff; --button-hovered-bg-color: #5bbb32; @@ -396,6 +410,7 @@ --outline-color: var(--link-color); --input-bg-color: #eaefff; --input-fg-color: #4c005e; + --input-disabled-bg-color: #cccccc; --button-bg-color: #d900de; --button-fg-color: white; --button-hovered-bg-color: #ef5bf3;