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 {
|
||||
name: string;
|
||||
id?: string;
|
||||
handle?: string;
|
||||
avatar?: string;
|
||||
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";
|
||||
|
||||
const formatUploadedTime = (uploaded: Date): string => {
|
||||
return DateTime.fromJSDate(uploaded).toRelative() ?? "";
|
||||
return DateTime.fromJSDate(uploaded).toRelative() ?? "Unknown time";
|
||||
};
|
||||
|
||||
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