From 453da7c117366c57d13450a8a1e07c67787dcd3f Mon Sep 17 00:00:00 2001 From: mio Date: Tue, 24 Oct 2023 16:46:26 -0400 Subject: [PATCH 01/11] Initial playlist page code. --- .../src/components/playlist/PlaylistEntry.tsx | 40 +++++++++++ .../react/src/components/video/VideoCard.tsx | 12 +--- packages/react/src/lib/utils.ts | 12 ++++ packages/react/src/routes/playlists.tsx | 16 +++++ packages/react/src/routes/router.tsx | 3 +- packages/react/src/shadcn/ui/typography.tsx | 66 +++++++++++++++++++ packages/react/src/store/i18n.ts | 6 -- 7 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 packages/react/src/components/playlist/PlaylistEntry.tsx create mode 100644 packages/react/src/routes/playlists.tsx create mode 100644 packages/react/src/shadcn/ui/typography.tsx diff --git a/packages/react/src/components/playlist/PlaylistEntry.tsx b/packages/react/src/components/playlist/PlaylistEntry.tsx new file mode 100644 index 000000000..03ce9e1dc --- /dev/null +++ b/packages/react/src/components/playlist/PlaylistEntry.tsx @@ -0,0 +1,40 @@ +import { makeYtThumbnailUrl } from "@/lib/utils"; +import { TypographyLarge, TypographyP } from "@/shadcn/ui/typography"; +import { useAtomValue } from "jotai"; +import { localeAtom } from "@/store/i18n"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shadcn/ui/button"; + +export default function PlaylistEntry({ + video_ids, + name, + updated_at, +}: PlaylistStub) { + const { dayjs } = useAtomValue(localeAtom); + const { t } = useTranslation(); + + return ( +
+ +
+ {name} + + {t("views.playlist.item-last-updated") + + " " + + dayjs(updated_at).format("LLL")} + +
+ + + +
+
+
+ ); +} diff --git a/packages/react/src/components/video/VideoCard.tsx b/packages/react/src/components/video/VideoCard.tsx index 7f91c850d..c75552b15 100644 --- a/packages/react/src/components/video/VideoCard.tsx +++ b/packages/react/src/components/video/VideoCard.tsx @@ -3,7 +3,7 @@ import { Button } from "@/shadcn/ui/button"; import { Link } from "react-router-dom"; import { useSeconds } from "use-seconds"; import { VideoMenu } from "./VideoMenu"; -import { cn } from "@/lib/utils"; +import { cn, makeYtThumbnailUrl } from "@/lib/utils"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useAtomValue } from "jotai"; @@ -53,15 +53,7 @@ export function VideoCard({ () => (() => { if (type === "placeholder") return thumbnail; - switch (size) { - case "sm": - return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`; - case "md": - return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`; - case "lg": - default: - return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; - } + return makeYtThumbnailUrl(id, size); })(), [type, thumbnail, id, size], ); diff --git a/packages/react/src/lib/utils.ts b/packages/react/src/lib/utils.ts index 6dafe9f04..411e8b6fc 100644 --- a/packages/react/src/lib/utils.ts +++ b/packages/react/src/lib/utils.ts @@ -9,3 +9,15 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function makeYtThumbnailUrl(id: string, size: VideoCardSize) { + switch (size) { + case "sm": + return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`; + case "md": + return `https://i.ytimg.com/vi/${id}/mqdefault.jpg`; + case "lg": + default: + return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; + } +} diff --git a/packages/react/src/routes/playlists.tsx b/packages/react/src/routes/playlists.tsx new file mode 100644 index 000000000..e5c61d8d7 --- /dev/null +++ b/packages/react/src/routes/playlists.tsx @@ -0,0 +1,16 @@ +import { usePlaylists } from "@/services/playlist.service"; +import PlaylistEntry from "@/components/playlist/PlaylistEntry"; +import { TypographyH3 } from "@/shadcn/ui/typography"; + +export default function Playlists() { + const { data: playlists } = usePlaylists(); + + return ( +
+ Your Playlists + {playlists?.map((playlist) => { + return ; + })} +
+ ); +} diff --git a/packages/react/src/routes/router.tsx b/packages/react/src/routes/router.tsx index 423e068fc..7a4eda1c4 100644 --- a/packages/react/src/routes/router.tsx +++ b/packages/react/src/routes/router.tsx @@ -21,6 +21,7 @@ const AboutPrivacy = React.lazy(() => import("./about/privacy")); const ChannelsOrg = React.lazy(() => import("./channelsOrg")); const Channel = React.lazy(() => import("./channel")); const Kitchensink = React.lazy(() => import("@/Kitchensink")); +const Playlists = React.lazy(() => import("./playlists")); const store = getDefaultStore(); @@ -92,7 +93,7 @@ const router = createBrowserRouter([ }, { path: "playlists", - element:
Playlists
, + element: , }, { path: "about", diff --git a/packages/react/src/shadcn/ui/typography.tsx b/packages/react/src/shadcn/ui/typography.tsx new file mode 100644 index 000000000..49ffe65fc --- /dev/null +++ b/packages/react/src/shadcn/ui/typography.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +export interface H3Props extends React.ComponentPropsWithRef<"h3"> {} + +const TypographyH3 = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +

+ ); + }, +); + +export interface H4Props extends React.ComponentPropsWithRef<"h4"> {} + +const TypographyH4 = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +

+ ); + }, +); + +export interface LargeProps extends React.ComponentPropsWithRef<"div"> {} + +const TypographyLarge = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +
+ ); + }, +); + +export interface ParagraphProps extends React.ComponentPropsWithRef<"p"> {} + +const TypographyP = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +

+ ); + }, +); + +export { TypographyH3, TypographyH4, TypographyLarge, TypographyP }; diff --git a/packages/react/src/store/i18n.ts b/packages/react/src/store/i18n.ts index 301d56709..96b0590c2 100644 --- a/packages/react/src/store/i18n.ts +++ b/packages/react/src/store/i18n.ts @@ -10,11 +10,6 @@ export const localeAtom = atom({ dayjs: (...args: Parameters) => dayjs(...args), }); -export const localeAtom = atom({ - lang: window.localStorage.getItem("i18nextLng") ?? navigator.language, - dayjs: (...args: Parameters) => dayjs(...args), -}); - export const currentLangAtom = atomWithStorage("lang", { val: "en", display: "English", @@ -33,4 +28,3 @@ export function useSyncTFunction() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [t]); } - From 409a5268b79216a3dc2ed8f59e338a345482b08e Mon Sep 17 00:00:00 2001 From: mio Date: Tue, 24 Oct 2023 22:42:08 -0400 Subject: [PATCH 02/11] Add individual playlist page (markup only). --- packages/react/package-lock.json | 10 +++ packages/react/package.json | 1 + .../playlist/IndividualPlaylist.tsx | 80 +++++++++++++++++++ .../src/components/playlist/PlaylistEntry.tsx | 14 +++- packages/react/src/routes/router.tsx | 7 ++ packages/react/src/types/playlist.d.ts | 1 + 6 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 packages/react/src/components/playlist/IndividualPlaylist.tsx diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index 7d41c5f92..4c2668f20 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -70,6 +70,7 @@ "@types/node": "^20.8.7", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", + "@types/react-grid-layout": "^1.3.4", "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", "@unocss/eslint-config": "^0.56.5", @@ -2801,6 +2802,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.4.tgz", + "integrity": "sha512-ZQqOhkizXv8t8AWyH2OcdWxeDr08gJPp/hg4mLCOz1xz6McqQc6HLBEXDIc/r0YsMeIWKOD31Br8i4DA/gilbQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.5", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.5.tgz", diff --git a/packages/react/package.json b/packages/react/package.json index 429e3f9c6..5b2e44efb 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -77,6 +77,7 @@ "@types/node": "^20.8.7", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", + "@types/react-grid-layout": "^1.3.4", "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", "@unocss/eslint-config": "^0.56.5", diff --git a/packages/react/src/components/playlist/IndividualPlaylist.tsx b/packages/react/src/components/playlist/IndividualPlaylist.tsx new file mode 100644 index 000000000..c74922e0c --- /dev/null +++ b/packages/react/src/components/playlist/IndividualPlaylist.tsx @@ -0,0 +1,80 @@ +import { usePlaylist } from "@/services/playlist.service"; +import { VideoCard } from "@/components/video/VideoCard"; +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { TypographyH3, TypographyP } from "@/shadcn/ui/typography"; +import { Button } from "@/shadcn/ui/button"; +import { Separator } from "@/shadcn/ui/separator"; + +export default function IndividualPlaylist() { + const { id } = useParams(); + + const { data: playlist, status } = usePlaylist(parseInt(id!)); + + const { t } = useTranslation(); + + if (status === "pending") { + return

Loading...
; + } + + if (status === "error") { + return
{t("component.apiError.title")}
; + } + + return ( +
+
+ +
+ {playlist.name} + + {playlist.videos.length} Videos + +
+ + +
+
+
+ + {playlist.videos.map((video, index) => { + return ( +
+
+ + +
+
+ +
+
+ ); + })} +
+ ); +} diff --git a/packages/react/src/components/playlist/PlaylistEntry.tsx b/packages/react/src/components/playlist/PlaylistEntry.tsx index 03ce9e1dc..9513a60fa 100644 --- a/packages/react/src/components/playlist/PlaylistEntry.tsx +++ b/packages/react/src/components/playlist/PlaylistEntry.tsx @@ -3,18 +3,21 @@ import { TypographyLarge, TypographyP } from "@/shadcn/ui/typography"; import { useAtomValue } from "jotai"; import { localeAtom } from "@/store/i18n"; import { useTranslation } from "react-i18next"; +import { buttonVariants } from "@/shadcn/ui/button.variants"; +import { Link } from "react-router-dom"; import { Button } from "@/shadcn/ui/button"; export default function PlaylistEntry({ video_ids, name, updated_at, + id, }: PlaylistStub) { const { dayjs } = useAtomValue(localeAtom); const { t } = useTranslation(); return ( -
+
{name} @@ -23,13 +26,16 @@ export default function PlaylistEntry({ " " + dayjs(updated_at).format("LLL")} -
+
- + diff --git a/packages/react/src/routes/router.tsx b/packages/react/src/routes/router.tsx index 7a4eda1c4..0a85570c0 100644 --- a/packages/react/src/routes/router.tsx +++ b/packages/react/src/routes/router.tsx @@ -22,6 +22,9 @@ const ChannelsOrg = React.lazy(() => import("./channelsOrg")); const Channel = React.lazy(() => import("./channel")); const Kitchensink = React.lazy(() => import("@/Kitchensink")); const Playlists = React.lazy(() => import("./playlists")); +const IndividualPlaylist = React.lazy( + () => import("@/components/playlist/IndividualPlaylist"), +); const store = getDefaultStore(); @@ -95,6 +98,10 @@ const router = createBrowserRouter([ path: "playlists", element: , }, + { + path: "playlist/:id", + element: , + }, { path: "about", element: , diff --git a/packages/react/src/types/playlist.d.ts b/packages/react/src/types/playlist.d.ts index 75c06fd52..d932101b7 100644 --- a/packages/react/src/types/playlist.d.ts +++ b/packages/react/src/types/playlist.d.ts @@ -22,6 +22,7 @@ interface PlaylistVideo { type: VideoType; available_at: string; published_at: string; + channel: ShortChannel; } interface PlaylistInclude { From 9366c67215ef503ac18b80c22349e59a267f30bc Mon Sep 17 00:00:00 2001 From: mio Date: Wed, 25 Oct 2023 08:00:52 -0400 Subject: [PATCH 03/11] Implement playlist deletion (logic). --- .../playlist/IndividualPlaylist.tsx | 33 +++++++++++---- .../src/components/playlist/PlaylistEntry.tsx | 18 ++++++-- .../react/src/services/playlist.service.ts | 42 +++++++++++++++++++ 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/packages/react/src/components/playlist/IndividualPlaylist.tsx b/packages/react/src/components/playlist/IndividualPlaylist.tsx index c74922e0c..cfe1df69d 100644 --- a/packages/react/src/components/playlist/IndividualPlaylist.tsx +++ b/packages/react/src/components/playlist/IndividualPlaylist.tsx @@ -1,10 +1,15 @@ -import { usePlaylist } from "@/services/playlist.service"; +import { + usePlaylist, + usePlaylistDeleteMutation, +} from "@/services/playlist.service"; import { VideoCard } from "@/components/video/VideoCard"; import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { TypographyH3, TypographyP } from "@/shadcn/ui/typography"; import { Button } from "@/shadcn/ui/button"; import { Separator } from "@/shadcn/ui/separator"; +import { useAtomValue } from "jotai"; +import { userAtom } from "@/store/auth"; export default function IndividualPlaylist() { const { id } = useParams(); @@ -13,6 +18,10 @@ export default function IndividualPlaylist() { const { t } = useTranslation(); + const user = useAtomValue(userAtom); + + const deleteMutation = usePlaylistDeleteMutation(); + if (status === "pending") { return
Loading...
; } @@ -30,21 +39,29 @@ export default function IndividualPlaylist() { {playlist.videos.length} Videos -
+
- + {user?.id === playlist.user_id ? ( + + ) : null}
- + {playlist.videos.map((video, index) => { return (
-
+
@@ -52,7 +69,7 @@ export default function IndividualPlaylist() {
-
+
@@ -36,9 +42,15 @@ export default function PlaylistEntry({ > - + {user?.id === user_id ? ( + + ) : null}
diff --git a/packages/react/src/services/playlist.service.ts b/packages/react/src/services/playlist.service.ts index 27abdd79c..228f3ea29 100644 --- a/packages/react/src/services/playlist.service.ts +++ b/packages/react/src/services/playlist.service.ts @@ -4,7 +4,9 @@ import { UseQueryOptions, useMutation, useQuery, + useQueryClient, } from "@tanstack/react-query"; +import { useLocation, useNavigate } from "react-router-dom"; export function usePlaylists(options?: UseQueryOptions) { const client = useClient(); @@ -54,3 +56,43 @@ export function usePlaylistVideoMutation( ...options, }); } + +export function usePlaylistDeleteMutation( + options?: UseMutationOptions, +) { + const client = useClient(); + const queryClient = useQueryClient(); + + const location = useLocation(); + const navigate = useNavigate(); + + return useMutation({ + mutationFn: async ({ playlistId }) => + await client.delete(`/api/v2/playlist/${playlistId}`), + ...options, + onSuccess: async (_, { playlistId }) => { + const onPlaylistPage = location.pathname === `/playlist/${playlistId}`; + + await queryClient.invalidateQueries({ + queryKey: ["playlists"], + refetchType: onPlaylistPage ? "all" : "active", + }); + if (onPlaylistPage) { + navigate("/playlists"); + } + }, + }); +} + +export function usePlaylistSaveMutation( + options?: UseMutationOptions, +) { + const client = useClient(); + + return useMutation({ + mutationFn: async ({ playlist }) => { + await client.post("/api/v2/playlist/", playlist); + }, + ...options, + }); +} From 91af184fce71fdb576352a02b0999f706280e6a2 Mon Sep 17 00:00:00 2001 From: mio Date: Sat, 28 Oct 2023 14:56:26 -0400 Subject: [PATCH 04/11] Fix seemingly broken dialog code. --- packages/react/src/shadcn/ui/dialog.tsx | 2 +- packages/react/src/shadcn/ui/dialog.variants.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react/src/shadcn/ui/dialog.tsx b/packages/react/src/shadcn/ui/dialog.tsx index a10a9b45d..ccced5e98 100644 --- a/packages/react/src/shadcn/ui/dialog.tsx +++ b/packages/react/src/shadcn/ui/dialog.tsx @@ -37,7 +37,7 @@ const DialogContent = React.forwardRef< {children} diff --git a/packages/react/src/shadcn/ui/dialog.variants.ts b/packages/react/src/shadcn/ui/dialog.variants.ts index ffaa3500e..7c91f07cc 100644 --- a/packages/react/src/shadcn/ui/dialog.variants.ts +++ b/packages/react/src/shadcn/ui/dialog.variants.ts @@ -10,5 +10,8 @@ export const dialogVariants = cva( secondary: "border-secondary-7 bg-secondary-1", }, }, + defaultVariants: { + variant: "default", + }, }, ); From cc375871b4613cc6822aa4318874597d0d6fc0b6 Mon Sep 17 00:00:00 2001 From: mio Date: Sat, 28 Oct 2023 18:34:05 -0400 Subject: [PATCH 05/11] Implement new playlist creation. --- .../components/playlist/NewPlaylistDialog.tsx | 98 +++++++++++++++++++ .../react/src/components/video/VideoMenu.tsx | 11 +++ packages/react/src/routes/playlists.tsx | 5 +- .../react/src/services/playlist.service.ts | 26 +++-- 4 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 packages/react/src/components/playlist/NewPlaylistDialog.tsx diff --git a/packages/react/src/components/playlist/NewPlaylistDialog.tsx b/packages/react/src/components/playlist/NewPlaylistDialog.tsx new file mode 100644 index 000000000..38920268a --- /dev/null +++ b/packages/react/src/components/playlist/NewPlaylistDialog.tsx @@ -0,0 +1,98 @@ +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shadcn/ui/dialog"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useForm } from "react-hook-form"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shadcn/ui/form"; +import { Input } from "@/shadcn/ui/input"; +import { usePlaylistSaveMutation } from "@/services/playlist.service"; +import { Button } from "@/shadcn/ui/button"; +import { useAtomValue } from "jotai"; +import { userAtom } from "@/store/auth"; + +interface Props { + triggerElement: React.ReactNode; + videoIds: string[]; +} + +export default function NewPlaylistDialog({ triggerElement, videoIds }: Props) { + const [open, setOpen] = useState(false); + + const { t } = useTranslation(); + + const form = useForm({ + defaultValues: { + name: "", // name of the new playlist + }, + }); + + const saveMutation = usePlaylistSaveMutation({ + onSuccess: () => { + setOpen(false); + }, + }); + + const user = useAtomValue(userAtom); + + const onSubmit = (data: { name: string }) => { + saveMutation.mutate({ + name: data.name, + video_ids: videoIds, + user_id: user?.id, + }); + }; + + return ( + + {triggerElement} + + + + {t("views.playlist.new-playlist-btn-label")} + + +
+ + { + return ( + + Playlist Name + + + + + + ); + }} + /> + + + + + +
+
+ ); +} diff --git a/packages/react/src/components/video/VideoMenu.tsx b/packages/react/src/components/video/VideoMenu.tsx index 0b6439a14..3a139cda6 100644 --- a/packages/react/src/components/video/VideoMenu.tsx +++ b/packages/react/src/components/video/VideoMenu.tsx @@ -16,6 +16,7 @@ import { import { ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; +import NewPlaylistDialog from "@/components/playlist/NewPlaylistDialog"; interface VideoMenuProps extends Pick { children: ReactNode; @@ -87,6 +88,16 @@ export function VideoMenu({
)} + event.preventDefault()} + > + {t("component.playlist.menu.new-playlist")} + + } + videoIds={[videoId]} + /> diff --git a/packages/react/src/routes/playlists.tsx b/packages/react/src/routes/playlists.tsx index e5c61d8d7..85fe875b8 100644 --- a/packages/react/src/routes/playlists.tsx +++ b/packages/react/src/routes/playlists.tsx @@ -1,13 +1,16 @@ import { usePlaylists } from "@/services/playlist.service"; import PlaylistEntry from "@/components/playlist/PlaylistEntry"; import { TypographyH3 } from "@/shadcn/ui/typography"; +import { useTranslation } from "react-i18next"; export default function Playlists() { const { data: playlists } = usePlaylists(); + const { t } = useTranslation(); + return (
- Your Playlists + {t("views.playlist.page-heading")} {playlists?.map((playlist) => { return ; })} diff --git a/packages/react/src/services/playlist.service.ts b/packages/react/src/services/playlist.service.ts index 228f3ea29..cfd0eb2be 100644 --- a/packages/react/src/services/playlist.service.ts +++ b/packages/react/src/services/playlist.service.ts @@ -70,13 +70,16 @@ export function usePlaylistDeleteMutation( mutationFn: async ({ playlistId }) => await client.delete(`/api/v2/playlist/${playlistId}`), ...options, - onSuccess: async (_, { playlistId }) => { + onSuccess: (_, { playlistId }) => { const onPlaylistPage = location.pathname === `/playlist/${playlistId}`; - await queryClient.invalidateQueries({ - queryKey: ["playlists"], - refetchType: onPlaylistPage ? "all" : "active", + queryClient.setQueryData(["playlists"], (oldData) => { + if (oldData) { + return oldData.filter((playlist) => playlist.id === playlistId); + } + return oldData; }); + if (onPlaylistPage) { navigate("/playlists"); } @@ -85,14 +88,23 @@ export function usePlaylistDeleteMutation( } export function usePlaylistSaveMutation( - options?: UseMutationOptions, + options?: UseMutationOptions>, ) { const client = useClient(); + const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ playlist }) => { - await client.post("/api/v2/playlist/", playlist); + mutationFn: async (playlist) => { + await client.post>( + "/api/v2/playlist/", + playlist, + ); }, ...options, + onSuccess: (...args) => { + queryClient.invalidateQueries({ queryKey: ["playlists"] }); + + if (options?.onSuccess) options.onSuccess(...args); + }, }); } From eced09c7b79997d9b88d915e679af7121fe770a8 Mon Sep 17 00:00:00 2001 From: mio Date: Mon, 30 Oct 2023 17:00:51 -0400 Subject: [PATCH 06/11] Implement modifying videos in an existing playlist. --- .../react/src/components/layout/Frame.scss | 3 +- .../playlist/IndividualPlaylist.tsx | 160 ++++++++++++++---- .../components/playlist/NewPlaylistDialog.tsx | 6 + packages/react/src/routes/playlist.tsx | 8 + packages/react/src/routes/router.tsx | 6 +- .../react/src/services/playlist.service.ts | 33 +++- packages/react/src/shadcn/ui/dialog.tsx | 1 - packages/react/src/shadcn/ui/typography.tsx | 8 +- 8 files changed, 179 insertions(+), 46 deletions(-) create mode 100644 packages/react/src/routes/playlist.tsx diff --git a/packages/react/src/components/layout/Frame.scss b/packages/react/src/components/layout/Frame.scss index 7fef19f3b..1a01a7cdd 100644 --- a/packages/react/src/components/layout/Frame.scss +++ b/packages/react/src/components/layout/Frame.scss @@ -76,7 +76,8 @@ $responsive-breakpoint: 768px; > main { grid-area: content; - overflow-y: auto; + // disabling for now so we can use position sticky + // overflow-y: auto; } > footer { diff --git a/packages/react/src/components/playlist/IndividualPlaylist.tsx b/packages/react/src/components/playlist/IndividualPlaylist.tsx index cfe1df69d..edc53eab1 100644 --- a/packages/react/src/components/playlist/IndividualPlaylist.tsx +++ b/packages/react/src/components/playlist/IndividualPlaylist.tsx @@ -1,26 +1,42 @@ import { usePlaylist, usePlaylistDeleteMutation, + usePlaylistSaveMutation, } from "@/services/playlist.service"; import { VideoCard } from "@/components/video/VideoCard"; -import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { TypographyH3, TypographyP } from "@/shadcn/ui/typography"; import { Button } from "@/shadcn/ui/button"; import { Separator } from "@/shadcn/ui/separator"; import { useAtomValue } from "jotai"; import { userAtom } from "@/store/auth"; +import { useState } from "react"; +import { useToast } from "@/shadcn/ui/use-toast"; -export default function IndividualPlaylist() { - const { id } = useParams(); +interface Props { + playlistId?: string; +} - const { data: playlist, status } = usePlaylist(parseInt(id!)); +export default function IndividualPlaylist({ playlistId }: Props) { + const { data: playlist, status } = usePlaylist(parseInt(playlistId!)); const { t } = useTranslation(); + const { toast } = useToast(); + const user = useAtomValue(userAtom); const deleteMutation = usePlaylistDeleteMutation(); + const saveMutation = usePlaylistSaveMutation({ + onSuccess: () => { + setEditedPlaylist(null); + toast({ + title: "Playlist saved.", + }); + }, + }); + + const [editedPlaylist, setEditedPlaylist] = useState(null); if (status === "pending") { return
Loading...
; @@ -30,42 +46,126 @@ export default function IndividualPlaylist() { return
{t("component.apiError.title")}
; } + const shiftVideo = (origIdx: number, shift: number) => + setEditedPlaylist((prevState) => { + const newVideoArr = prevState + ? [...prevState.videos] + : [...playlist.videos]; + + const video = newVideoArr.splice(origIdx, 1)[0]; + + newVideoArr.splice(origIdx + shift, 0, video); + + return prevState + ? { + ...prevState, + videos: newVideoArr, + } + : { + ...playlist, + videos: newVideoArr, + }; + }); + + const deleteVideo = (origIdx: number) => + setEditedPlaylist((prevState) => { + const newVideoArr = prevState + ? [...prevState.videos] + : [...playlist.videos]; + + newVideoArr.splice(origIdx, 1); + + return prevState + ? { + ...prevState, + videos: newVideoArr, + } + : { + ...playlist, + videos: newVideoArr, + }; + }); + + const playlistToRender = editedPlaylist ? editedPlaylist : playlist; + return (
-
- -
- {playlist.name} - - {playlist.videos.length} Videos - -
- - {user?.id === playlist.user_id ? ( - - ) : null} + {user?.id === playlist.user_id ? ( + <> + + + {editedPlaylist !== null ? ( + + ) : null} + + ) : null} +
+
- - {playlist.videos.map((video, index) => { + {playlistToRender.videos.map((video, index) => { return (
-
- - +
diff --git a/packages/react/src/components/playlist/NewPlaylistDialog.tsx b/packages/react/src/components/playlist/NewPlaylistDialog.tsx index 38920268a..b176c2f5f 100644 --- a/packages/react/src/components/playlist/NewPlaylistDialog.tsx +++ b/packages/react/src/components/playlist/NewPlaylistDialog.tsx @@ -22,6 +22,7 @@ import { usePlaylistSaveMutation } from "@/services/playlist.service"; import { Button } from "@/shadcn/ui/button"; import { useAtomValue } from "jotai"; import { userAtom } from "@/store/auth"; +import { useToast } from "@/shadcn/ui/use-toast"; interface Props { triggerElement: React.ReactNode; @@ -32,6 +33,7 @@ export default function NewPlaylistDialog({ triggerElement, videoIds }: Props) { const [open, setOpen] = useState(false); const { t } = useTranslation(); + const { toast } = useToast(); const form = useForm({ defaultValues: { @@ -42,6 +44,10 @@ export default function NewPlaylistDialog({ triggerElement, videoIds }: Props) { const saveMutation = usePlaylistSaveMutation({ onSuccess: () => { setOpen(false); + toast({ + // todo: intl this + title: "Playlist created.", + }); }, }); diff --git a/packages/react/src/routes/playlist.tsx b/packages/react/src/routes/playlist.tsx new file mode 100644 index 000000000..910921765 --- /dev/null +++ b/packages/react/src/routes/playlist.tsx @@ -0,0 +1,8 @@ +import { useParams } from "react-router-dom"; +import IndividualPlaylist from "@/components/playlist/IndividualPlaylist"; + +export default function Playlist() { + const { id } = useParams(); + + return ; +} diff --git a/packages/react/src/routes/router.tsx b/packages/react/src/routes/router.tsx index 0a85570c0..da1ae016c 100644 --- a/packages/react/src/routes/router.tsx +++ b/packages/react/src/routes/router.tsx @@ -22,9 +22,7 @@ const ChannelsOrg = React.lazy(() => import("./channelsOrg")); const Channel = React.lazy(() => import("./channel")); const Kitchensink = React.lazy(() => import("@/Kitchensink")); const Playlists = React.lazy(() => import("./playlists")); -const IndividualPlaylist = React.lazy( - () => import("@/components/playlist/IndividualPlaylist"), -); +const Playlist = React.lazy(() => import("./playlist")); const store = getDefaultStore(); @@ -100,7 +98,7 @@ const router = createBrowserRouter([ }, { path: "playlist/:id", - element: , + element: , }, { path: "about", diff --git a/packages/react/src/services/playlist.service.ts b/packages/react/src/services/playlist.service.ts index cfd0eb2be..68cd8784f 100644 --- a/packages/react/src/services/playlist.service.ts +++ b/packages/react/src/services/playlist.service.ts @@ -75,7 +75,7 @@ export function usePlaylistDeleteMutation( queryClient.setQueryData(["playlists"], (oldData) => { if (oldData) { - return oldData.filter((playlist) => playlist.id === playlistId); + return oldData.filter((playlist) => playlist.id !== playlistId); } return oldData; }); @@ -88,23 +88,44 @@ export function usePlaylistDeleteMutation( } export function usePlaylistSaveMutation( - options?: UseMutationOptions>, + options?: UseMutationOptions>, ) { const client = useClient(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async (playlist) => { - await client.post>( + return client.post>( "/api/v2/playlist/", playlist, ); }, ...options, - onSuccess: (...args) => { - queryClient.invalidateQueries({ queryKey: ["playlists"] }); + onSuccess: (data, variables, context) => { + // new playlist + if (!variables.id) { + queryClient.setQueryData( + ["playlist", "include", variables.video_ids![0]], + (includesArr) => { + const includesElement = [ + { id: data, name: variables.name!, contains: true }, + ]; + return includesArr + ? includesArr.concat(includesElement) + : includesElement; + }, + ); + } + + // grab new updated_at, video objects + queryClient.invalidateQueries({ + queryKey: ["playlists"], + }); + queryClient.invalidateQueries({ + queryKey: ["playlist", variables.id], + }); - if (options?.onSuccess) options.onSuccess(...args); + if (options?.onSuccess) options.onSuccess(data, variables, context); }, }); } diff --git a/packages/react/src/shadcn/ui/dialog.tsx b/packages/react/src/shadcn/ui/dialog.tsx index ccced5e98..936f83700 100644 --- a/packages/react/src/shadcn/ui/dialog.tsx +++ b/packages/react/src/shadcn/ui/dialog.tsx @@ -4,7 +4,6 @@ import { Cross2Icon } from "@radix-ui/react-icons"; import { cn } from "@/lib/utils"; import { type VariantProps } from "class-variance-authority"; -import { badgeVariants } from "./badge"; import { dialogVariants } from "./dialog.variants"; const Dialog = DialogPrimitive.Root; diff --git a/packages/react/src/shadcn/ui/typography.tsx b/packages/react/src/shadcn/ui/typography.tsx index 49ffe65fc..b3a9fbf98 100644 --- a/packages/react/src/shadcn/ui/typography.tsx +++ b/packages/react/src/shadcn/ui/typography.tsx @@ -1,7 +1,7 @@ import React from "react"; import { cn } from "@/lib/utils"; -export interface H3Props extends React.ComponentPropsWithRef<"h3"> {} +export interface H3Props extends React.ComponentPropsWithoutRef<"h3"> {} const TypographyH3 = React.forwardRef( ({ className, ...props }, ref) => { @@ -18,7 +18,7 @@ const TypographyH3 = React.forwardRef( }, ); -export interface H4Props extends React.ComponentPropsWithRef<"h4"> {} +export interface H4Props extends React.ComponentPropsWithoutRef<"h4"> {} const TypographyH4 = React.forwardRef( ({ className, ...props }, ref) => { @@ -35,7 +35,7 @@ const TypographyH4 = React.forwardRef( }, ); -export interface LargeProps extends React.ComponentPropsWithRef<"div"> {} +export interface LargeProps extends React.ComponentPropsWithoutRef<"div"> {} const TypographyLarge = React.forwardRef( ({ className, ...props }, ref) => { @@ -49,7 +49,7 @@ const TypographyLarge = React.forwardRef( }, ); -export interface ParagraphProps extends React.ComponentPropsWithRef<"p"> {} +export interface ParagraphProps extends React.ComponentPropsWithoutRef<"p"> {} const TypographyP = React.forwardRef( ({ className, ...props }, ref) => { From f4d31b022fda71a5e60f3ec8bc0c4a47fcd7236d Mon Sep 17 00:00:00 2001 From: mio Date: Mon, 30 Oct 2023 17:33:11 -0400 Subject: [PATCH 07/11] Add renaming of playlists. --- .../playlist/IndividualPlaylist.tsx | 40 +++++++++++++++++-- .../react/src/services/playlist.service.ts | 1 + 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/playlist/IndividualPlaylist.tsx b/packages/react/src/components/playlist/IndividualPlaylist.tsx index edc53eab1..9419df817 100644 --- a/packages/react/src/components/playlist/IndividualPlaylist.tsx +++ b/packages/react/src/components/playlist/IndividualPlaylist.tsx @@ -12,6 +12,7 @@ import { useAtomValue } from "jotai"; import { userAtom } from "@/store/auth"; import { useState } from "react"; import { useToast } from "@/shadcn/ui/use-toast"; +import { Input } from "@/shadcn/ui/input"; interface Props { playlistId?: string; @@ -37,6 +38,7 @@ export default function IndividualPlaylist({ playlistId }: Props) { }); const [editedPlaylist, setEditedPlaylist] = useState(null); + const [renaming, setRenaming] = useState(false); if (status === "pending") { return
Loading...
; @@ -86,15 +88,47 @@ export default function IndividualPlaylist({ playlistId }: Props) { }; }); + const rename = (newName: string) => + setEditedPlaylist((prevState) => { + return prevState + ? { + ...prevState, + name: newName, + } + : { + ...playlist, + name: newName, + }; + }); + const playlistToRender = editedPlaylist ? editedPlaylist : playlist; + const userOwnsPlaylist = playlist.user_id === user?.id; return (
- +
- {playlist.name} +
+ {renaming ? ( + rename(e.target.value)} + > + ) : ( + {playlistToRender.name} + )} + {userOwnsPlaylist ? ( + + ) : null} +
{playlist.videos.length} Videos @@ -102,7 +136,7 @@ export default function IndividualPlaylist({ playlistId }: Props) { - {user?.id === playlist.user_id ? ( + {userOwnsPlaylist ? ( <> {editedPlaylist !== null ? ( diff --git a/packages/react/src/components/video/VideoMenu.tsx b/packages/react/src/components/video/VideoMenu.tsx index 8d9df2a8a..38b8de578 100644 --- a/packages/react/src/components/video/VideoMenu.tsx +++ b/packages/react/src/components/video/VideoMenu.tsx @@ -8,6 +8,7 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuPortal, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -80,6 +81,7 @@ export function VideoMenu({ {name} ))} + {data?.length ? : null} {isLoading && (
From d31f2c106c70e0b56069efc45fe47eb2d6dd56c3 Mon Sep 17 00:00:00 2001 From: mio Date: Mon, 30 Oct 2023 17:49:21 -0400 Subject: [PATCH 09/11] Hide reordering controls for non-owned playlists. --- .../playlist/IndividualPlaylist.tsx | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/react/src/components/playlist/IndividualPlaylist.tsx b/packages/react/src/components/playlist/IndividualPlaylist.tsx index cd6050320..42e30c790 100644 --- a/packages/react/src/components/playlist/IndividualPlaylist.tsx +++ b/packages/react/src/components/playlist/IndividualPlaylist.tsx @@ -180,29 +180,31 @@ export default function IndividualPlaylist({ playlistId }: Props) { {playlistToRender.videos.map((video, index) => { return (
-
- - - -
+ {userOwnsPlaylist ? ( +
+ + + +
+ ) : null}
Date: Mon, 30 Oct 2023 18:10:47 -0400 Subject: [PATCH 10/11] Add playlist creation error msg for non logged in users. --- .../react/src/components/playlist/NewPlaylistDialog.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react/src/components/playlist/NewPlaylistDialog.tsx b/packages/react/src/components/playlist/NewPlaylistDialog.tsx index b176c2f5f..681e3a336 100644 --- a/packages/react/src/components/playlist/NewPlaylistDialog.tsx +++ b/packages/react/src/components/playlist/NewPlaylistDialog.tsx @@ -54,6 +54,14 @@ export default function NewPlaylistDialog({ triggerElement, videoIds }: Props) { const user = useAtomValue(userAtom); const onSubmit = (data: { name: string }) => { + if (!user) { + setOpen(false); + toast({ + title: t("views.playlist.login-prompt"), + }); + return; + } + saveMutation.mutate({ name: data.name, video_ids: videoIds, From d5298e93e1d2355f460a86fa0081dbaae56826ef Mon Sep 17 00:00:00 2001 From: mio Date: Wed, 1 Nov 2023 12:03:39 -0400 Subject: [PATCH 11/11] Move playlist deletion into an alert dialog. --- packages/react/package-lock.json | 29 ++++ packages/react/package.json | 1 + .../playlist/DeletePlaylistDialog.tsx | 56 +++++++ .../playlist/IndividualPlaylist.tsx | 19 ++- .../components/playlist/NewPlaylistDialog.tsx | 2 +- .../src/components/playlist/PlaylistEntry.tsx | 20 +-- packages/react/src/shadcn/ui/alert-dialog.tsx | 137 ++++++++++++++++++ 7 files changed, 243 insertions(+), 21 deletions(-) create mode 100644 packages/react/src/components/playlist/DeletePlaylistDialog.tsx create mode 100644 packages/react/src/shadcn/ui/alert-dialog.tsx diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index db00ca4cf..ef9dcf0e9 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -11,6 +11,7 @@ "@hookform/resolvers": "^3.3.2", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", @@ -1352,6 +1353,34 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.0.5.tgz", + "integrity": "sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", diff --git a/packages/react/package.json b/packages/react/package.json index 34ff37ecf..cfeb6dc8f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -18,6 +18,7 @@ "@hookform/resolvers": "^3.3.2", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", diff --git a/packages/react/src/components/playlist/DeletePlaylistDialog.tsx b/packages/react/src/components/playlist/DeletePlaylistDialog.tsx new file mode 100644 index 000000000..91f6f501c --- /dev/null +++ b/packages/react/src/components/playlist/DeletePlaylistDialog.tsx @@ -0,0 +1,56 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/shadcn/ui/alert-dialog"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { usePlaylistDeleteMutation } from "@/services/playlist.service"; + +interface Props { + triggerElement: React.ReactNode; + playlistId: number; + playlistName: string; +} + +export default function DeletePlaylistDialog({ + triggerElement, + playlistId, + playlistName, +}: Props) { + const { t } = useTranslation(); + const deleteMutation = usePlaylistDeleteMutation(); + + return ( + + {triggerElement} + + + + {t("component.playlist.menu.delete-playlist")} + + + {/* todo: intl this */} + Are you sure you want to delete playlist {playlistName}? + + + + + {t("views.library.deleteConfirmationCancel")} + + deleteMutation.mutate({ playlistId })} + > + {t("views.library.deleteConfirmationOK")} + + + + + ); +} diff --git a/packages/react/src/components/playlist/IndividualPlaylist.tsx b/packages/react/src/components/playlist/IndividualPlaylist.tsx index 42e30c790..4639570fe 100644 --- a/packages/react/src/components/playlist/IndividualPlaylist.tsx +++ b/packages/react/src/components/playlist/IndividualPlaylist.tsx @@ -1,6 +1,5 @@ import { usePlaylist, - usePlaylistDeleteMutation, usePlaylistSaveMutation, } from "@/services/playlist.service"; import { VideoCard } from "@/components/video/VideoCard"; @@ -13,6 +12,7 @@ import { userAtom } from "@/store/auth"; import { useState } from "react"; import { useToast } from "@/shadcn/ui/use-toast"; import { Input } from "@/shadcn/ui/input"; +import DeletePlaylistDialog from "@/components/playlist/DeletePlaylistDialog"; interface Props { playlistId?: string; @@ -27,7 +27,6 @@ export default function IndividualPlaylist({ playlistId }: Props) { const user = useAtomValue(userAtom); - const deleteMutation = usePlaylistDeleteMutation(); const saveMutation = usePlaylistSaveMutation({ onSuccess: () => { setEditedPlaylist(null); @@ -138,15 +137,15 @@ export default function IndividualPlaylist({ playlistId }: Props) { {userOwnsPlaylist ? ( <> - } - > - - + playlistId={playlist.id} + playlistName={playlist.name} + /> + + + + } + playlistId={id} + playlistName={name} + /> ) : null}
diff --git a/packages/react/src/shadcn/ui/alert-dialog.tsx b/packages/react/src/shadcn/ui/alert-dialog.tsx new file mode 100644 index 000000000..4f8f496b7 --- /dev/null +++ b/packages/react/src/shadcn/ui/alert-dialog.tsx @@ -0,0 +1,137 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/shadcn/ui/button.variants"; +import { dialogVariants } from "@/shadcn/ui/dialog.variants"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +};