From d2cef1f072ae129e3c830132b7deb208ce9b0300 Mon Sep 17 00:00:00 2001 From: Guus van Meerveld Date: Fri, 5 Apr 2024 20:25:24 +0200 Subject: [PATCH] improved context menu and video component responsiveness --- src/app/providers/ContextMenuProvider.tsx | 100 +++++++--- src/app/watch/Player/index.tsx | 6 +- src/app/watch/Watch.tsx | 2 +- src/components/Video.tsx | 220 +++++++++++++++------- src/constants.ts | 2 + src/typings/contextMenu.ts | 23 ++- src/utils/urls.ts | 16 ++ 7 files changed, 272 insertions(+), 97 deletions(-) diff --git a/src/app/providers/ContextMenuProvider.tsx b/src/app/providers/ContextMenuProvider.tsx index da57e5b..5a94c16 100644 --- a/src/app/providers/ContextMenuProvider.tsx +++ b/src/app/providers/ContextMenuProvider.tsx @@ -1,29 +1,58 @@ -import { useCallback, useEffect } from "react"; +import { FC, useCallback, useEffect } from "react"; -import { Listbox, ListboxItem } from "@nextui-org/listbox"; +import { Listbox, ListboxItem, ListboxSection } from "@nextui-org/listbox"; import useContextMenuStore from "@/hooks/useContextMenuStore"; import { Component } from "@/typings/component"; +import { + ContextMenuAction as ContextMenuActionProps, + ContextMenuItemType +} from "@/typings/contextMenu"; -const Menu: Component = () => { +const ContextMenuActionComponent: FC<{ + item: ContextMenuActionProps; + hideContextMenu: () => void; +}> = ({ item, hideContextMenu }) => { + return ( + { + if (item.onClick) { + item.onClick(); + hideContextMenu(); + } + }} + description={item.description} + startContent={item.icon} + showDivider={item.showDivider} + key={item.key} + href={item.href} + > + {item.title} + + ); +}; + +const Menu: FC = () => { const shouldShow = useContextMenuStore((state) => state.show); const menu = useContextMenuStore((state) => state.items); const hide = useContextMenuStore((state) => state.hide); const location = useContextMenuStore((state) => state.location); - const handleClick = useCallback(() => { + const hideIfShown = useCallback(() => { if (shouldShow) hide(); }, [hide, shouldShow]); useEffect(() => { - window.addEventListener("click", handleClick); + window.addEventListener("click", hideIfShown); + window.addEventListener("scroll", hideIfShown); return () => { - window.removeEventListener("click", handleClick); + window.removeEventListener("click", hideIfShown); + window.removeEventListener("scroll", hideIfShown); }; - }, [handleClick]); + }, [hideIfShown]); return (
{ }} className="bg-background border-small max-w-xs rounded-small border-default-200 absolute z-10" > - - {menu.map((item) => ( - { - if (item.onClick) { - item.onClick(); - hide(); - } - }} - showDivider={item.showDivider} - key={item.key} - href={item.href} - > - {item.title} - - ))} + + {(item) => { + switch (item.type) { + case ContextMenuItemType.Action: + return ( + + ); + + case ContextMenuItemType.Category: + const category = item; + return ( + + {category.items.map((item) => ( + { + if (item.onClick) { + item.onClick(); + hide(); + } + }} + description={item.description} + startContent={item.icon} + showDivider={item.showDivider} + key={item.key} + href={item.href} + > + {item.title} + + ))} + + ); + } + }}
); diff --git a/src/app/watch/Player/index.tsx b/src/app/watch/Player/index.tsx index 8ad0527..6897c17 100644 --- a/src/app/watch/Player/index.tsx +++ b/src/app/watch/Player/index.tsx @@ -263,11 +263,7 @@ export const Player: Component<{ streams: Stream[] }> = ({ streams }) => {
- diff --git a/src/app/watch/Watch.tsx b/src/app/watch/Watch.tsx index c5690f5..a9fabd1 100644 --- a/src/app/watch/Watch.tsx +++ b/src/app/watch/Watch.tsx @@ -68,7 +68,7 @@ export const Watch: Component = () => {
-
+

{data.video.title}

diff --git a/src/components/Video.tsx b/src/components/Video.tsx index 384a7d8..328c5ef 100644 --- a/src/components/Video.tsx +++ b/src/components/Video.tsx @@ -1,122 +1,210 @@ import NextImage from "next/image"; -import Link from "next/link"; +import NextLink from "next/link"; import { useMemo } from "react"; +import { + FiCopy as CopyIcon, + FiLink as LinkIcon, + FiYoutube as YoutubeIcon +} from "react-icons/fi"; import { Card, CardBody, CardFooter } from "@nextui-org/card"; import { Divider } from "@nextui-org/divider"; import { Image } from "@nextui-org/image"; +import { Link } from "@nextui-org/link"; import { Tooltip } from "@nextui-org/tooltip"; import { Video as VideoProps } from "@/client/typings/video"; import formatBigNumber from "@/utils/formatBigNumber"; import formatDuration from "@/utils/formatDuration"; import formatUploadedTime from "@/utils/formatUploadedTime"; -import { channelUrl, videoUrl } from "@/utils/urls"; +import { + channelUrl, + videoUrl, + youtubeChannelUrl, + youtubeVideoUrl +} from "@/utils/urls"; import { videoSize } from "@/utils/videoSize"; import { ContextMenu } from "./ContextMenu"; import { Component } from "@/typings/component"; -import { ContextMenuItem } from "@/typings/contextMenu"; +import { ContextMenuItem, ContextMenuItemType } from "@/typings/contextMenu"; export const Video: Component<{ data: VideoProps; size?: number }> = ({ data, size = 40 }) => { const url = videoUrl(data.id); + const channel = data.author.id ? channelUrl(data.author.id) : "#"; const [width, height] = videoSize(size); const menuItems = useMemo(() => { + const hasAuthor = !!data.author.id; + const items: ContextMenuItem[] = [ - { title: "Go to video", key: "gotoVideo", href: url }, { - title: "Copy video id", - key: "videoId", - onClick: (): void => { - navigator.clipboard.writeText(data.id); - }, - showDivider: true + type: ContextMenuItemType.Category, + title: "Video", + showDivider: true, + key: "video", + items: [ + { + type: ContextMenuItemType.Action, + title: "Go to video", + description: "Opens in this tab", + icon: , + key: "goToVideo", + href: url + }, + { + type: ContextMenuItemType.Action, + title: "Copy video id", + icon: , + key: "videoId", + onClick: (): void => { + navigator.clipboard.writeText(data.id); + } + }, + { + type: ContextMenuItemType.Action, + title: "Copy YouTube video url", + icon: , + key: "youtubeUrl", + onClick: (): void => { + const url = youtubeVideoUrl(data.id); + + navigator.clipboard.writeText(url.toString()); + } + } + ] }, { - title: "Open thumbnail", + type: ContextMenuItemType.Category, + title: "Thumbnail", + showDivider: hasAuthor, key: "thumbnail", - href: data.thumbnail - }, - { - title: "Copy thumnail url", - key: "thumbnailUrl", - onClick: (): void => { - navigator.clipboard.writeText(data.thumbnail); - }, - showDivider: true + items: [ + { + type: ContextMenuItemType.Action, + title: "Open thumbnail", + description: "Opens in this tab", + icon: , + key: "thumbnail", + href: data.thumbnail + }, + { + type: ContextMenuItemType.Action, + title: "Copy thumnail url", + icon: , + key: "thumbnailUrl", + onClick: (): void => { + navigator.clipboard.writeText(data.thumbnail); + } + } + ] } ]; - if (data.author.id) { + if (data.author.id) items.push({ - title: "Go to channel", - key: "gotoChannel", - href: channelUrl(data.author.id) - }); + type: ContextMenuItemType.Category, + title: "Channel", + key: "channel", + items: [ + { + type: ContextMenuItemType.Action, + title: "Go to channel", + description: "Opens in this tab", + icon: , + key: "goToChannel", + href: channel + }, + { + type: ContextMenuItemType.Action, + title: "Copy channel id", + icon: , + key: "channelId", + onClick: (): void => { + if (data.author.id) navigator.clipboard.writeText(data.author.id); + } + }, + { + type: ContextMenuItemType.Action, + title: "Copy YouTube channel url", + icon: , + key: "youtubeUrl", + onClick: (): void => { + if (!data.author.id) return; - items.push({ - title: "Copy channel id", - key: "channelId", - onClick: (): void => { - navigator.clipboard.writeText(data.author.id ?? ""); - } + const url = youtubeChannelUrl(data.author.id); + + navigator.clipboard.writeText(url.toString()); + } + } + ] }); - } return items; - }, [data, url]); + }, [data, channel, url]); return ( - - - - + + + + {data.title} + -

- {formatDuration(data.duration)} -

-
- - -
-

+

+ {formatDuration(data.duration)} +

+ + + +
+
+ {data.title} + +
+
+ + {data.author.name} + + + {data.uploaded && ( + +

+ {formatUploadedTime(data.uploaded)} +

+
+ )} + +

+ Views: {formatBigNumber(data.views)}

-
-

- {data.author.name} -

- {data.uploaded && ( - -

- {formatUploadedTime(data.uploaded)} -

-
- )} - -

- Views: {formatBigNumber(data.views)} -

-
- - - - +
+
+ + ); }; diff --git a/src/constants.ts b/src/constants.ts index b302473..ff410cf 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1,3 @@ export const defaultRegion = "US" as const; + +export const youtubeUrl = new URL("https://youtube.com"); diff --git a/src/typings/contextMenu.ts b/src/typings/contextMenu.ts index 65859d1..e87b47a 100644 --- a/src/typings/contextMenu.ts +++ b/src/typings/contextMenu.ts @@ -1,7 +1,26 @@ -export interface ContextMenuItem { - title: string; +export interface BaseContextMenuItem { + type: ContextMenuItemType; key: string; + title: string; showDivider?: boolean; +} + +export enum ContextMenuItemType { + Action, + Category +} + +export interface ContextMenuAction extends BaseContextMenuItem { + type: ContextMenuItemType.Action; + description?: string; href?: string; + icon?: React.JSX.Element; onClick?: () => unknown; } + +export interface ContextMenuCategory extends BaseContextMenuItem { + type: ContextMenuItemType.Category; + items: ContextMenuAction[]; +} + +export type ContextMenuItem = ContextMenuAction | ContextMenuCategory; diff --git a/src/utils/urls.ts b/src/utils/urls.ts index 6c43eb2..78d39fc 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -1,4 +1,20 @@ +import path from "path"; + +import { youtubeUrl } from "@/constants"; + export const videoUrl = (videoId: string): string => `/watch?v=${videoId}`; export const channelUrl = (channelId: string): string => `/channel/${channelId}`; + +export const youtubeVideoUrl = (videoId: string): URL => { + const url = new URL("watch", youtubeUrl); + + url.searchParams.append("v", videoId); + + return url; +}; + +export const youtubeChannelUrl = (channelId: string): URL => { + return new URL(path.join("channel", channelId), youtubeUrl); +};