watch: added simple comments section
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
parent
927fca314e
commit
6b5a7d1727
@ -1,40 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import NextLink from "next/link";
|
|
||||||
|
|
||||||
import { Avatar } from "@nextui-org/avatar";
|
|
||||||
import { Link } from "@nextui-org/link";
|
|
||||||
|
|
||||||
import { Author } from "@/client/typings/author";
|
|
||||||
import formatBigNumber from "@/utils/formatBigNumber";
|
|
||||||
import { channelUrl } from "@/utils/urls";
|
|
||||||
|
|
||||||
import { Component } from "@/typings/component";
|
|
||||||
|
|
||||||
export const Channel: Component<{
|
|
||||||
data: Author;
|
|
||||||
}> = ({ data }) => {
|
|
||||||
const url = data?.id ? channelUrl(data.id) : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link as={NextLink} href={url}>
|
|
||||||
<div className="flex flex-row gap-4 items-center">
|
|
||||||
<Avatar
|
|
||||||
isBordered
|
|
||||||
size="lg"
|
|
||||||
src={data.avatar}
|
|
||||||
showFallback
|
|
||||||
name={data.name}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h1 className="text-lg text-default-foreground">{data.name}</h1>
|
|
||||||
{data.subscribers && (
|
|
||||||
<h2 className="text-md tracking-tight text-default-500">
|
|
||||||
{formatBigNumber(data.subscribers)} subscribers
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
@ -0,0 +1,227 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import NextLink from "next/link";
|
||||||
|
import { FC, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
FiHeart as HeartIcon,
|
||||||
|
FiThumbsUp as LikeIcon,
|
||||||
|
FiLock as PinnedIcon,
|
||||||
|
FiCornerDownRight as ShowRepliesIcon,
|
||||||
|
FiSlash as SlashIcon,
|
||||||
|
FiCheck as UploaderIcon
|
||||||
|
} from "react-icons/fi";
|
||||||
|
|
||||||
|
import { Avatar } from "@nextui-org/avatar";
|
||||||
|
import { Button } from "@nextui-org/button";
|
||||||
|
import { Chip } from "@nextui-org/chip";
|
||||||
|
import { Divider } from "@nextui-org/divider";
|
||||||
|
import { Link } from "@nextui-org/link";
|
||||||
|
import { CircularProgress } from "@nextui-org/progress";
|
||||||
|
import { Tooltip } from "@nextui-org/tooltip";
|
||||||
|
|
||||||
|
import { useClient } from "@/hooks/useClient";
|
||||||
|
|
||||||
|
import { Author } from "@/client/typings/author";
|
||||||
|
import {
|
||||||
|
Comment as CommentProps,
|
||||||
|
Comments as CommentsProps
|
||||||
|
} from "@/client/typings/comment";
|
||||||
|
import formatBigNumber from "@/utils/formatBigNumber";
|
||||||
|
import formatUploadedTime from "@/utils/formatUploadedTime";
|
||||||
|
import { highlight } from "@/utils/highlight";
|
||||||
|
import { channelUrl } from "@/utils/urls";
|
||||||
|
|
||||||
|
import { HighlightRenderer } from "./HighlightRenderer";
|
||||||
|
|
||||||
|
const Comment: FC<{
|
||||||
|
data: CommentProps;
|
||||||
|
videoUploader: Author;
|
||||||
|
videoId: string;
|
||||||
|
}> = ({ data, videoUploader, videoId }) => {
|
||||||
|
const message = useMemo(() => highlight(data.message), [data.message]);
|
||||||
|
|
||||||
|
const client = useClient();
|
||||||
|
|
||||||
|
const [showReplies, setShowReplies] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: replies,
|
||||||
|
error: repliesError,
|
||||||
|
refetch: refetchReplies,
|
||||||
|
isLoading: isLoadingReplies
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["replies", videoId, data.repliesToken],
|
||||||
|
queryFn: () => {
|
||||||
|
return client.getComments(videoId, data.repliesToken);
|
||||||
|
},
|
||||||
|
enabled: showReplies && !!data.repliesToken
|
||||||
|
});
|
||||||
|
|
||||||
|
const userUrl = data.author.id ? channelUrl(data.author.id) : "#";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<div>
|
||||||
|
<Link as={NextLink} href={userUrl}>
|
||||||
|
<Avatar
|
||||||
|
isBordered
|
||||||
|
size="lg"
|
||||||
|
showFallback
|
||||||
|
src={data.author.avatar}
|
||||||
|
name={data.author.name}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<Link as={NextLink} href={userUrl}>
|
||||||
|
<p className="font-semibold text-default-foreground">
|
||||||
|
{data.author.name}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
{data.author.id === videoUploader.id && (
|
||||||
|
<Chip
|
||||||
|
className="pl-2"
|
||||||
|
startContent={<UploaderIcon />}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Uploader
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
{data.pinned && (
|
||||||
|
<Chip
|
||||||
|
className="pl-2"
|
||||||
|
startContent={<PinnedIcon />}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Pinned
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<HighlightRenderer highlighted={message} />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-4 items-center">
|
||||||
|
<div className="flex flex-row tracking-tight text-default-500 items-center gap-1">
|
||||||
|
<LikeIcon />
|
||||||
|
<p>{formatBigNumber(data.likes)} likes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tracking-tight text-default-500">
|
||||||
|
<p>{formatUploadedTime(data.written)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.videoUploaderLiked && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Tooltip content="Uploader liked" showArrow>
|
||||||
|
<p className="text-danger text-xl">
|
||||||
|
<HeartIcon />
|
||||||
|
</p>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.videoUploaderReplied && (
|
||||||
|
<div>
|
||||||
|
<Avatar
|
||||||
|
size="sm"
|
||||||
|
src={videoUploader.avatar}
|
||||||
|
name={videoUploader.name}
|
||||||
|
showFallback
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.edited && (
|
||||||
|
<p className="tracking-tight text-default-500">(edited)</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.repliesToken && (
|
||||||
|
<Button
|
||||||
|
startContent={<ShowRepliesIcon />}
|
||||||
|
variant="light"
|
||||||
|
onClick={() => setShowReplies((state) => !state)}
|
||||||
|
>
|
||||||
|
{showReplies ? "Hide replies" : "Show replies"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showReplies && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Comments
|
||||||
|
data={replies}
|
||||||
|
isLoading={isLoadingReplies}
|
||||||
|
error={repliesError}
|
||||||
|
refetch={refetchReplies}
|
||||||
|
videoUploader={videoUploader}
|
||||||
|
videoId={videoId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Comments: FC<{
|
||||||
|
data?: CommentsProps;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: () => void;
|
||||||
|
videoUploader: Author;
|
||||||
|
videoId: string;
|
||||||
|
}> = ({ data, isLoading, error, refetch, videoUploader, videoId }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<p className="text-xl">
|
||||||
|
{data.count && formatBigNumber(data.count)} Comments
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Divider orientation="horizontal" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{data.enabled && (
|
||||||
|
<>
|
||||||
|
{data.data.map((comment) => (
|
||||||
|
<Comment
|
||||||
|
key={comment.id}
|
||||||
|
data={comment}
|
||||||
|
videoUploader={videoUploader}
|
||||||
|
videoId={videoId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!data.enabled && (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<SlashIcon />
|
||||||
|
<p>Comments on this video are disabled</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!data && isLoading && (
|
||||||
|
<div className="h-24 w-full justify-center items-center flex">
|
||||||
|
<CircularProgress aria-label="Loading comments..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-lg font-semibold">Failed to load comments:</p>
|
||||||
|
{error.toString()}
|
||||||
|
<div>
|
||||||
|
<Button color="primary" onClick={() => refetch()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,38 @@
|
|||||||
|
import { FC, Fragment } from "react";
|
||||||
|
|
||||||
|
import { Link } from "@nextui-org/link";
|
||||||
|
|
||||||
|
import formatDuration from "@/utils/formatDuration";
|
||||||
|
import { Item, ItemType } from "@/utils/highlight";
|
||||||
|
|
||||||
|
export const HighlightRenderer: FC<{ highlighted: Item[] }> = ({
|
||||||
|
highlighted
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{highlighted.map((item) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case ItemType.Tokens:
|
||||||
|
return <Fragment key={item.id}>{item.content}</Fragment>;
|
||||||
|
|
||||||
|
case ItemType.Link:
|
||||||
|
return (
|
||||||
|
<Link key={item.id} href={item.href}>
|
||||||
|
{item.text ?? item.href}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
case ItemType.Timestamp:
|
||||||
|
return (
|
||||||
|
<Link key={item.id} href="">
|
||||||
|
{formatDuration(item.duration * 1000)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
case ItemType.Linebreak:
|
||||||
|
return <br key={item.id} />;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,44 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
import { AuthorThumbnailModel } from "./thumbnail";
|
||||||
|
|
||||||
|
export const CommentModel = z.object({
|
||||||
|
author: z.string(),
|
||||||
|
authorThumbnails: AuthorThumbnailModel.array(),
|
||||||
|
authorId: z.string(),
|
||||||
|
authorUrl: z.string(),
|
||||||
|
|
||||||
|
isEdited: z.boolean(),
|
||||||
|
isPinned: z.boolean(),
|
||||||
|
|
||||||
|
content: z.string(),
|
||||||
|
contentHtml: z.string(),
|
||||||
|
published: z.number(),
|
||||||
|
publishedText: z.string(),
|
||||||
|
likeCount: z.number(),
|
||||||
|
commentId: z.string(),
|
||||||
|
authorIsChannelOwner: z.boolean(),
|
||||||
|
creatorHeart: z
|
||||||
|
.object({
|
||||||
|
creatorThumbnail: z.string(),
|
||||||
|
creatorName: z.string()
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
replies: z
|
||||||
|
.object({
|
||||||
|
replyCount: z.number(),
|
||||||
|
continuation: z.string()
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CommentsModel = z.object({
|
||||||
|
commentCount: z.number().optional(),
|
||||||
|
videoId: z.string(),
|
||||||
|
comments: CommentModel.array(),
|
||||||
|
continuation: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
type Comments = z.infer<typeof CommentsModel>;
|
||||||
|
|
||||||
|
export default Comments;
|
@ -0,0 +1,45 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const CommentModel = z.object({
|
||||||
|
author: z.string().describe("The name of the author of the comment"),
|
||||||
|
commentId: z.string().describe("The comment ID"),
|
||||||
|
commentText: z.string().describe("The text of the comment"),
|
||||||
|
commentedTime: z.string().describe("The time the comment was made"),
|
||||||
|
commentorUrl: z.string().describe("The URL of the channel of the comment"),
|
||||||
|
hearted: z.boolean().describe("Whether or not the comment has been hearted"),
|
||||||
|
likeCount: z.number().describe("The number of likes the comment has"),
|
||||||
|
pinned: z.boolean().describe("Whether or not the comment is pinned"),
|
||||||
|
thumbnail: z.string().url().describe("The thumbnail of the comment"),
|
||||||
|
verified: z
|
||||||
|
.boolean()
|
||||||
|
.describe("Whether or not the author of the comment is verified"),
|
||||||
|
replyCount: z
|
||||||
|
.number()
|
||||||
|
.transform((number) => (number < 0 ? 0 : number))
|
||||||
|
.optional()
|
||||||
|
.describe("The amount of replies this comment has"),
|
||||||
|
repliesPage: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe("The token needed to fetch the replies"),
|
||||||
|
creatorReplied: z
|
||||||
|
.boolean()
|
||||||
|
.describe("Whether the creator has replied to the comment")
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CommentsModel = z.object({
|
||||||
|
comments: CommentModel.array(), // A list of comments
|
||||||
|
commentCount: z
|
||||||
|
.number()
|
||||||
|
.transform((number) => (number < 0 ? 0 : number))
|
||||||
|
.optional(),
|
||||||
|
disabled: z.boolean(), // Whether or not the comments are disabled
|
||||||
|
nextpage: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe("A JSON encoded page, which is used for the nextpage endpoint.")
|
||||||
|
});
|
||||||
|
|
||||||
|
type Comments = z.infer<typeof CommentsModel>;
|
||||||
|
|
||||||
|
export default Comments;
|
@ -1,6 +1,8 @@
|
|||||||
export interface Author {
|
export interface Author {
|
||||||
name: string;
|
name: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
handle?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
subscribers?: number;
|
subscribers?: number;
|
||||||
|
verified?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
import { Author } from "./author";
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
likes: number;
|
||||||
|
edited: boolean;
|
||||||
|
|
||||||
|
written: Date;
|
||||||
|
|
||||||
|
author: Author;
|
||||||
|
|
||||||
|
pinned: boolean;
|
||||||
|
videoUploaderLiked: boolean;
|
||||||
|
videoUploaderReplied: boolean;
|
||||||
|
|
||||||
|
repliesToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comments {
|
||||||
|
enabled: boolean;
|
||||||
|
count?: number;
|
||||||
|
data: Comment[];
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
const formatUploadedTime = (uploaded: Date): string => {
|
const formatUploadedTime = (uploaded: Date): string => {
|
||||||
return DateTime.fromJSDate(uploaded).toRelative() ?? "";
|
return DateTime.fromJSDate(uploaded).toRelative() ?? "Unknown time";
|
||||||
};
|
};
|
||||||
|
|
||||||
export default formatUploadedTime;
|
export default formatUploadedTime;
|
||||||
|
@ -0,0 +1,32 @@
|
|||||||
|
import { DateTime, Duration } from "luxon";
|
||||||
|
|
||||||
|
export const parseRelativeTime = (text: string): DateTime => {
|
||||||
|
const parts = text.split(" ");
|
||||||
|
|
||||||
|
const value = parseInt(parts[0]);
|
||||||
|
const unit = parts[1];
|
||||||
|
|
||||||
|
let duration: Duration;
|
||||||
|
|
||||||
|
if (["second", "seconds"].includes(unit)) {
|
||||||
|
duration = Duration.fromObject({ seconds: value });
|
||||||
|
} else if (["minute", "minutes"].includes(unit)) {
|
||||||
|
duration = Duration.fromObject({ minutes: value });
|
||||||
|
} else if (["hour", "hours"].includes(unit)) {
|
||||||
|
duration = Duration.fromObject({ hours: value });
|
||||||
|
} else if (["day", "days"].includes(unit)) {
|
||||||
|
duration = Duration.fromObject({ days: value });
|
||||||
|
} else if (["week", "weeks"].includes(unit)) {
|
||||||
|
duration = Duration.fromObject({ weeks: value });
|
||||||
|
} else if (["month", "months"].includes(unit)) {
|
||||||
|
duration = Duration.fromObject({ months: value });
|
||||||
|
} else if (["year", "years"].includes(unit)) {
|
||||||
|
duration = Duration.fromObject({ years: value });
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown time unit '${unit}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultDate = DateTime.now().minus(duration);
|
||||||
|
|
||||||
|
return resultDate;
|
||||||
|
};
|
@ -0,0 +1,22 @@
|
|||||||
|
const subCountRegex = /([0-9]{1,3}(?:\.[0-9]{1,2})?)([KMB])?/g;
|
||||||
|
|
||||||
|
const sizeMap: Record<string, number> = {
|
||||||
|
K: 1.0e3,
|
||||||
|
M: 1.0e6,
|
||||||
|
B: 1.0e9
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseSubscriberCount = (subCount: string): number => {
|
||||||
|
const matchIterator = subCount.matchAll(subCountRegex);
|
||||||
|
|
||||||
|
const match = matchIterator.next().value;
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const countFloat = parseFloat(match[1]);
|
||||||
|
const size = sizeMap[match[2].toString().toUpperCase()] ?? 1;
|
||||||
|
|
||||||
|
return countFloat * size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 5;
|
||||||
|
};
|
Loading…
Reference in new issue