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

nextui
Guus van Meerveld 1 month 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,22 +63,47 @@ 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) => {
<ListboxItem switch (item.type) {
onClick={() => { case ContextMenuItemType.Action:
if (item.onClick) { return (
item.onClick(); <ContextMenuActionComponent
hide(); item={item}
} hideContextMenu={hide}
}} key={item.key}
showDivider={item.showDivider} />
key={item.key} );
href={item.href}
> case ContextMenuItemType.Category:
{item.title} const category = item;
</ListboxItem> return (
))} <ListboxSection
title={category.title}
key={category.key}
showDivider={category.showDivider}
>
{category.items.map((item) => (
<ListboxItem
onClick={() => {
if (item.onClick) {
item.onClick();
hide();
}
}}
description={item.description}
startContent={item.icon}
showDivider={item.showDivider}
key={item.key}
href={item.href}
>
{item.title}
</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,122 +1,210 @@
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 },
{ {
title: "Copy video id", type: ContextMenuItemType.Category,
key: "videoId", title: "Video",
onClick: (): void => { showDivider: true,
navigator.clipboard.writeText(data.id); key: "video",
}, items: [
showDivider: true {
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",
icon: <CopyIcon />,
key: "videoId",
onClick: (): void => {
navigator.clipboard.writeText(data.id);
}
},
{
type: ContextMenuItemType.Action,
title: "Copy YouTube video url",
icon: <YoutubeIcon />,
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", key: "thumbnail",
href: data.thumbnail items: [
}, {
{ type: ContextMenuItemType.Action,
title: "Copy thumnail url", title: "Open thumbnail",
key: "thumbnailUrl", description: "Opens in this tab",
onClick: (): void => { icon: <LinkIcon />,
navigator.clipboard.writeText(data.thumbnail); key: "thumbnail",
}, href: data.thumbnail
showDivider: true },
{
type: ContextMenuItemType.Action,
title: "Copy thumnail url",
icon: <CopyIcon />,
key: "thumbnailUrl",
onClick: (): void => {
navigator.clipboard.writeText(data.thumbnail);
}
}
]
} }
]; ];
if (data.author.id) { if (data.author.id)
items.push({ items.push({
title: "Go to channel", type: ContextMenuItemType.Category,
key: "gotoChannel", title: "Channel",
href: channelUrl(data.author.id) key: "channel",
}); items: [
{
type: ContextMenuItemType.Action,
title: "Go to channel",
description: "Opens in this tab",
icon: <LinkIcon />,
key: "goToChannel",
href: channel
},
{
type: ContextMenuItemType.Action,
title: "Copy channel id",
icon: <CopyIcon />,
key: "channelId",
onClick: (): void => {
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;
items.push({ const url = youtubeChannelUrl(data.author.id);
title: "Copy channel id",
key: "channelId", navigator.clipboard.writeText(url.toString());
onClick: (): void => { }
navigator.clipboard.writeText(data.author.id ?? ""); }
} ]
}); });
}
return items; return items;
}, [data, url]); }, [data, channel, url]);
return ( return (
<Link href={url}> <ContextMenu menu={menuItems}>
<ContextMenu menu={menuItems}> <Card radius="lg" style={{ maxWidth: `${width}px` }}>
<Card radius="lg"> <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)}
</p> </p>
</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}
</Link>
</div>
<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}
</Link>
{data.uploaded && (
<Tooltip showArrow content={data.uploaded.toLocaleString()}>
<p className="text-small tracking-tight text-default-400 shrink-0">
{formatUploadedTime(data.uploaded)}
</p>
</Tooltip>
)}
<p className="text-small tracking-tight text-default-400 shrink-0">
Views: {formatBigNumber(data.views)}
</p> </p>
<div className="flex flex-row gap-2 justify-start text-ellipsis overflow-hidden">
<p className="text-small font-semibold tracking-tight text-default-400">
{data.author.name}
</p>
{data.uploaded && (
<Tooltip showArrow content={data.uploaded.toLocaleString()}>
<p className="text-small tracking-tight text-default-400">
{formatUploadedTime(data.uploaded)}
</p>
</Tooltip>
)}
<p className="text-small tracking-tight text-default-400">
Views: {formatBigNumber(data.views)}
</p>
</div>
</div> </div>
</CardFooter> </div>
</Card> </CardFooter>
</ContextMenu> </Card>
</Link> </ContextMenu>
); );
}; };

@ -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