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

nextui
Guus van Meerveld 7 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 { 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 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 (
<div
@ -34,22 +63,47 @@ const Menu: Component = () => {
}}
className="bg-background border-small max-w-xs rounded-small border-default-200 absolute z-10"
>
<Listbox aria-label="Context Menu">
{menu.map((item) => (
<ListboxItem
onClick={() => {
if (item.onClick) {
item.onClick();
hide();
}
}}
showDivider={item.showDivider}
key={item.key}
href={item.href}
>
{item.title}
</ListboxItem>
))}
<Listbox aria-label="Context Menu" items={menu}>
{(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
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>
</div>
);

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

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

@ -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: <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",
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: <LinkIcon />,
key: "thumbnail",
href: data.thumbnail
},
{
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({
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: <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({
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 (
<Link href={url}>
<ContextMenu menu={menuItems}>
<Card radius="lg">
<CardBody>
<ContextMenu menu={menuItems}>
<Card radius="lg" style={{ maxWidth: `${width}px` }}>
<CardBody>
<NextLink href={url}>
<Image
as={NextImage}
height={height}
width={width}
unoptimized
alt={data.title}
className="object-contain aspect"
className="object-contain"
src={data.thumbnail}
/>
</NextLink>
<p className="text-small rounded-md z-10 absolute bottom-5 right-5 bg-content2 p-1">
{formatDuration(data.duration)}
</p>
</CardBody>
<Divider />
<CardFooter>
<div style={{ width }} className="flex flex-col">
<p title={data.title} className="text-ellipsis overflow-hidden">
<p className="text-small rounded-md z-10 absolute bottom-5 right-5 bg-content2 p-1">
{formatDuration(data.duration)}
</p>
</CardBody>
<Divider />
<CardFooter>
<div className="flex flex-col">
<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}
</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>
<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>
</CardFooter>
</Card>
</ContextMenu>
</Link>
</div>
</CardFooter>
</Card>
</ContextMenu>
);
};

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

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

@ -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);
};

Loading…
Cancel
Save