improved context menu and video component responsiveness
continuous-integration/drone/push Build is passing Details

nextui
Guus van Meerveld 8 months ago
parent 0674d6893d
commit d2cef1f072

@ -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 useContextMenuStore from "@/hooks/useContextMenuStore";
import { Component } from "@/typings/component"; 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 (
<ListboxItem
onClick={() => {
if (item.onClick) {
item.onClick();
hideContextMenu();
}
}}
description={item.description}
startContent={item.icon}
showDivider={item.showDivider}
key={item.key}
href={item.href}
>
{item.title}
</ListboxItem>
);
};
const Menu: FC = () => {
const shouldShow = useContextMenuStore((state) => state.show); const shouldShow = useContextMenuStore((state) => state.show);
const menu = useContextMenuStore((state) => state.items); const menu = useContextMenuStore((state) => state.items);
const hide = useContextMenuStore((state) => state.hide); const hide = useContextMenuStore((state) => state.hide);
const location = useContextMenuStore((state) => state.location); const location = useContextMenuStore((state) => state.location);
const handleClick = useCallback(() => { const hideIfShown = useCallback(() => {
if (shouldShow) hide(); if (shouldShow) hide();
}, [hide, shouldShow]); }, [hide, shouldShow]);
useEffect(() => { useEffect(() => {
window.addEventListener("click", handleClick); window.addEventListener("click", hideIfShown);
window.addEventListener("scroll", hideIfShown);
return () => { return () => {
window.removeEventListener("click", handleClick); window.removeEventListener("click", hideIfShown);
window.removeEventListener("scroll", hideIfShown);
}; };
}, [handleClick]); }, [hideIfShown]);
return ( return (
<div <div
@ -34,8 +63,27 @@ const Menu: Component = () => {
}} }}
className="bg-background border-small max-w-xs rounded-small border-default-200 absolute z-10" className="bg-background border-small max-w-xs rounded-small border-default-200 absolute z-10"
> >
<Listbox aria-label="Context Menu"> <Listbox aria-label="Context Menu" items={menu}>
{menu.map((item) => ( {(item) => {
switch (item.type) {
case ContextMenuItemType.Action:
return (
<ContextMenuActionComponent
item={item}
hideContextMenu={hide}
key={item.key}
/>
);
case ContextMenuItemType.Category:
const category = item;
return (
<ListboxSection
title={category.title}
key={category.key}
showDivider={category.showDivider}
>
{category.items.map((item) => (
<ListboxItem <ListboxItem
onClick={() => { onClick={() => {
if (item.onClick) { if (item.onClick) {
@ -43,6 +91,8 @@ const Menu: Component = () => {
hide(); hide();
} }
}} }}
description={item.description}
startContent={item.icon}
showDivider={item.showDivider} showDivider={item.showDivider}
key={item.key} key={item.key}
href={item.href} href={item.href}
@ -50,6 +100,10 @@ const Menu: Component = () => {
{item.title} {item.title}
</ListboxItem> </ListboxItem>
))} ))}
</ListboxSection>
);
}
}}
</Listbox> </Listbox>
</div> </div>
); );

@ -263,11 +263,7 @@ export const Player: Component<{ streams: Stream[] }> = ({ streams }) => {
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger>
<Button <Button className="text-xl" variant="light">
className="text-xl"
// startContent={<PlaybackRateIcon />}
variant="light"
>
{playbackRate}x {playbackRate}x
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>

@ -68,7 +68,7 @@ export const Watch: Component = () => {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Player streams={data.streams} /> <Player streams={data.streams} />
<div className="flex flex-col xl:flex-row gap-4"> <div className="flex flex-col xl:flex-row gap-4">
<div className=" flex flex-col gap-4"> <div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="text-2xl">{data.video.title}</h1> <h1 className="text-2xl">{data.video.title}</h1>
<div className="flex flex-row gap-4 text-lg tracking-tight text-default-500"> <div className="flex flex-row gap-4 text-lg tracking-tight text-default-500">

@ -1,91 +1,168 @@
import NextImage from "next/image"; import NextImage from "next/image";
import Link from "next/link"; import NextLink from "next/link";
import { useMemo } from "react"; 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 { Card, CardBody, CardFooter } from "@nextui-org/card";
import { Divider } from "@nextui-org/divider"; import { Divider } from "@nextui-org/divider";
import { Image } from "@nextui-org/image"; import { Image } from "@nextui-org/image";
import { Link } from "@nextui-org/link";
import { Tooltip } from "@nextui-org/tooltip"; import { Tooltip } from "@nextui-org/tooltip";
import { Video as VideoProps } from "@/client/typings/video"; import { Video as VideoProps } from "@/client/typings/video";
import formatBigNumber from "@/utils/formatBigNumber"; import formatBigNumber from "@/utils/formatBigNumber";
import formatDuration from "@/utils/formatDuration"; import formatDuration from "@/utils/formatDuration";
import formatUploadedTime from "@/utils/formatUploadedTime"; 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 { videoSize } from "@/utils/videoSize";
import { ContextMenu } from "./ContextMenu"; import { ContextMenu } from "./ContextMenu";
import { Component } from "@/typings/component"; import { Component } from "@/typings/component";
import { ContextMenuItem } from "@/typings/contextMenu"; import { ContextMenuItem, ContextMenuItemType } from "@/typings/contextMenu";
export const Video: Component<{ data: VideoProps; size?: number }> = ({ export const Video: Component<{ data: VideoProps; size?: number }> = ({
data, data,
size = 40 size = 40
}) => { }) => {
const url = videoUrl(data.id); const url = videoUrl(data.id);
const channel = data.author.id ? channelUrl(data.author.id) : "#";
const [width, height] = videoSize(size); const [width, height] = videoSize(size);
const menuItems = useMemo(() => { const menuItems = useMemo(() => {
const hasAuthor = !!data.author.id;
const items: ContextMenuItem[] = [ const items: ContextMenuItem[] = [
{ title: "Go to video", key: "gotoVideo", href: url },
{ {
type: ContextMenuItemType.Category,
title: "Video",
showDivider: true,
key: "video",
items: [
{
type: ContextMenuItemType.Action,
title: "Go to video",
description: "Opens in this tab",
icon: <LinkIcon />,
key: "goToVideo",
href: url
},
{
type: ContextMenuItemType.Action,
title: "Copy video id", title: "Copy video id",
icon: <CopyIcon />,
key: "videoId", key: "videoId",
onClick: (): void => { onClick: (): void => {
navigator.clipboard.writeText(data.id); navigator.clipboard.writeText(data.id);
}
}, },
showDivider: true {
type: ContextMenuItemType.Action,
title: "Copy YouTube video url",
icon: <YoutubeIcon />,
key: "youtubeUrl",
onClick: (): void => {
const url = youtubeVideoUrl(data.id);
navigator.clipboard.writeText(url.toString());
}
}
]
}, },
{ {
type: ContextMenuItemType.Category,
title: "Thumbnail",
showDivider: hasAuthor,
key: "thumbnail",
items: [
{
type: ContextMenuItemType.Action,
title: "Open thumbnail", title: "Open thumbnail",
description: "Opens in this tab",
icon: <LinkIcon />,
key: "thumbnail", key: "thumbnail",
href: data.thumbnail href: data.thumbnail
}, },
{ {
type: ContextMenuItemType.Action,
title: "Copy thumnail url", title: "Copy thumnail url",
icon: <CopyIcon />,
key: "thumbnailUrl", key: "thumbnailUrl",
onClick: (): void => { onClick: (): void => {
navigator.clipboard.writeText(data.thumbnail); navigator.clipboard.writeText(data.thumbnail);
}, }
showDivider: true }
]
} }
]; ];
if (data.author.id) { if (data.author.id)
items.push({ items.push({
type: ContextMenuItemType.Category,
title: "Channel",
key: "channel",
items: [
{
type: ContextMenuItemType.Action,
title: "Go to channel", title: "Go to channel",
key: "gotoChannel", description: "Opens in this tab",
href: channelUrl(data.author.id) icon: <LinkIcon />,
}); key: "goToChannel",
href: channel
items.push({ },
{
type: ContextMenuItemType.Action,
title: "Copy channel id", title: "Copy channel id",
icon: <CopyIcon />,
key: "channelId", key: "channelId",
onClick: (): void => { onClick: (): void => {
navigator.clipboard.writeText(data.author.id ?? ""); if (data.author.id) navigator.clipboard.writeText(data.author.id);
} }
}); },
{
type: ContextMenuItemType.Action,
title: "Copy YouTube channel url",
icon: <YoutubeIcon />,
key: "youtubeUrl",
onClick: (): void => {
if (!data.author.id) return;
const url = youtubeChannelUrl(data.author.id);
navigator.clipboard.writeText(url.toString());
} }
}
]
});
return items; return items;
}, [data, url]); }, [data, channel, url]);
return ( return (
<Link href={url}>
<ContextMenu menu={menuItems}> <ContextMenu menu={menuItems}>
<Card radius="lg"> <Card radius="lg" style={{ maxWidth: `${width}px` }}>
<CardBody> <CardBody>
<NextLink href={url}>
<Image <Image
as={NextImage} as={NextImage}
height={height} height={height}
width={width} width={width}
unoptimized unoptimized
alt={data.title} alt={data.title}
className="object-contain aspect" className="object-contain"
src={data.thumbnail} src={data.thumbnail}
/> />
</NextLink>
<p className="text-small rounded-md z-10 absolute bottom-5 right-5 bg-content2 p-1"> <p className="text-small rounded-md z-10 absolute bottom-5 right-5 bg-content2 p-1">
{formatDuration(data.duration)} {formatDuration(data.duration)}
@ -93,23 +170,35 @@ export const Video: Component<{ data: VideoProps; size?: number }> = ({
</CardBody> </CardBody>
<Divider /> <Divider />
<CardFooter> <CardFooter>
<div style={{ width }} className="flex flex-col"> <div className="flex flex-col">
<p title={data.title} className="text-ellipsis overflow-hidden"> <div className="flex min-w-0">
<Link
as={NextLink}
href={url}
title={data.title}
className="text-ellipsis overflow-hidden whitespace-nowrap text-foreground"
>
{data.title} {data.title}
</p> </Link>
<div className="flex flex-row gap-2 justify-start text-ellipsis overflow-hidden"> </div>
<p className="text-small font-semibold tracking-tight text-default-400"> <div className="flex flex-row gap-2 min-w-0">
<Link
as={NextLink}
href={channel}
className="overflow-ellipsis overflow-hidden whitespace-nowrap text-small font-semibold tracking-tight text-default-400 shrink"
>
{data.author.name} {data.author.name}
</p> </Link>
{data.uploaded && ( {data.uploaded && (
<Tooltip showArrow content={data.uploaded.toLocaleString()}> <Tooltip showArrow content={data.uploaded.toLocaleString()}>
<p className="text-small tracking-tight text-default-400"> <p className="text-small tracking-tight text-default-400 shrink-0">
{formatUploadedTime(data.uploaded)} {formatUploadedTime(data.uploaded)}
</p> </p>
</Tooltip> </Tooltip>
)} )}
<p className="text-small tracking-tight text-default-400"> <p className="text-small tracking-tight text-default-400 shrink-0">
Views: {formatBigNumber(data.views)} Views: {formatBigNumber(data.views)}
</p> </p>
</div> </div>
@ -117,6 +206,5 @@ export const Video: Component<{ data: VideoProps; size?: number }> = ({
</CardFooter> </CardFooter>
</Card> </Card>
</ContextMenu> </ContextMenu>
</Link>
); );
}; };

@ -1 +1,3 @@
export const defaultRegion = "US" as const; export const defaultRegion = "US" as const;
export const youtubeUrl = new URL("https://youtube.com");

@ -1,7 +1,26 @@
export interface ContextMenuItem { export interface BaseContextMenuItem {
title: string; type: ContextMenuItemType;
key: string; key: string;
title: string;
showDivider?: boolean; showDivider?: boolean;
}
export enum ContextMenuItemType {
Action,
Category
}
export interface ContextMenuAction extends BaseContextMenuItem {
type: ContextMenuItemType.Action;
description?: string;
href?: string; href?: string;
icon?: React.JSX.Element;
onClick?: () => unknown; onClick?: () => unknown;
} }
export interface ContextMenuCategory extends BaseContextMenuItem {
type: ContextMenuItemType.Category;
items: ContextMenuAction[];
}
export type ContextMenuItem = ContextMenuAction | ContextMenuCategory;

@ -1,4 +1,20 @@
import path from "path";
import { youtubeUrl } from "@/constants";
export const videoUrl = (videoId: string): string => `/watch?v=${videoId}`; export const videoUrl = (videoId: string): string => `/watch?v=${videoId}`;
export const channelUrl = (channelId: string): string => export const channelUrl = (channelId: string): string =>
`/channel/${channelId}`; `/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);
};

Loading…
Cancel
Save