watch: implemented basic page with info
continuous-integration/drone/push Build is failing Details

nextui
Guus van Meerveld 8 months ago
parent 7db6022749
commit 323699cbc7

@ -23,6 +23,7 @@
"react-dom": "^18", "react-dom": "^18",
"react-icons": "^5.0.1", "react-icons": "^5.0.1",
"reactjs-visibility": "^0.1.4", "reactjs-visibility": "^0.1.4",
"sanitize-html": "^2.13.0",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.0",
"zod": "^3.22.4", "zod": "^3.22.4",
"zustand": "^4.5.2" "zustand": "^4.5.2"
@ -36,6 +37,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/sanitize-html": "^2.11.0",
"@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1", "@typescript-eslint/parser": "^7.3.1",
"autoprefixer": "^10.0.1", "autoprefixer": "^10.0.1",

@ -0,0 +1,40 @@
"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,80 @@
import sanitizeHtml from "sanitize-html";
import { Fragment, useMemo, useState } from "react";
import {
FiChevronUp as CollapseIcon,
FiChevronDown as ExpandIcon
} from "react-icons/fi";
import { Button } from "@nextui-org/button";
import { Link } from "@nextui-org/link";
import formatDuration from "@/utils/formatDuration";
import { highlight, ItemType } from "@/utils/highlight";
import { Component } from "@/typings/component";
export const Description: Component<{ data: string }> = ({ data }) => {
const [expandedDescription, setExpandedDescription] = useState(false);
const sanitizedDescription = useMemo(
() =>
sanitizeHtml(data, {
allowedTags: ["a", "br"],
allowedAttributes: {
a: ["href"]
}
}),
[data]
);
const descriptionCut = useMemo(
() =>
expandedDescription
? sanitizedDescription
: sanitizedDescription?.substring(0, 200) + "...",
[sanitizedDescription, expandedDescription]
);
const description = useMemo(
() => highlight(descriptionCut),
[descriptionCut]
);
return (
<div>
<h2 className="text-ellipsis overflow-y-hidden">
{description.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} />;
}
})}
</h2>
<Button
startContent={expandedDescription ? <CollapseIcon /> : <ExpandIcon />}
variant="light"
onClick={() => setExpandedDescription((state) => !state)}
>
{expandedDescription ? "Show less" : "Show more"}
</Button>
</div>
);
};

@ -0,0 +1,11 @@
"use client";
import { Component } from "@/typings/component";
export const Player: Component = () => {
return (
<>
<video src="" className="w-full" />
</>
);
};

@ -0,0 +1,23 @@
"use client";
import { Item } from "@/client/typings/item";
import { Video } from "@/components/Video";
import { Component } from "@/typings/component";
export const Related: Component<{ data: Item[] }> = ({ data }) => {
return (
<div className="flex flex-col gap-4">
{data.map((item) => {
switch (item.type) {
case "video":
return <Video key={item.id} data={item} size={25} />;
default:
return <></>;
}
})}
</div>
);
};

@ -4,8 +4,20 @@ import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useMemo } from "react"; import { useMemo } from "react";
import { Chip } from "@nextui-org/chip";
import { useClient } from "@/hooks/useClient"; import { useClient } from "@/hooks/useClient";
import formatBigNumber from "@/utils/formatBigNumber";
import { Container } from "@/components/Container";
import { LoadingPage } from "@/components/LoadingPage";
import { Channel } from "./Channel";
import { Description } from "./Description";
import { Player } from "./Player";
import { Related } from "./Related";
import { Component } from "@/typings/component"; import { Component } from "@/typings/component";
export const Watch: Component = () => { export const Watch: Component = () => {
@ -17,7 +29,7 @@ export const Watch: Component = () => {
const videoIdIsInvalid = useMemo(() => videoId === null, [videoId]); const videoIdIsInvalid = useMemo(() => videoId === null, [videoId]);
const { data, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["watch", videoId], queryKey: ["watch", videoId],
queryFn: () => { queryFn: () => {
return client.getStream(videoId); return client.getStream(videoId);
@ -25,7 +37,50 @@ export const Watch: Component = () => {
enabled: !videoIdIsInvalid enabled: !videoIdIsInvalid
}); });
console.log(data, error); if (error) console.log(error);
return (
<Container>
{isLoading && <LoadingPage />}
{data && !isLoading && (
<div className="flex flex-col">
<Player />
<div className="flex flex-col xl:flex-row gap-4">
<div className="flex-1 flex 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">
<h2>{formatBigNumber(data.video.views)} views</h2>
<h2>{formatBigNumber(data.likes)} likes</h2>
<h2>{formatBigNumber(data.dislikes)} dislikes</h2>
</div>
</div>
<Channel data={data.video.author} />
<Description data={data.video.description ?? ""} />
<div className="flex flex-row gap-2">
<h1>Category:</h1>
<h1 className="font-semibold">{data.category}</h1>
</div>
{data.keywords.length !== 0 && (
<div className="flex flex-row gap-2 items-center flex-wrap">
<h1>Keywords:</h1>
{data.keywords.map((keyword) => (
<Chip key={keyword}>{keyword}</Chip>
))}
</div>
)}
return <></>; <h1 className="text-xl">Comments</h1>
</div>
<Related data={data.related} />
</div>
</div>
)}
</Container>
);
}; };

@ -41,7 +41,6 @@ export default class Transformer {
return { return {
type: "video", type: "video",
uploaded: new Date(),
author: { id: data.authorId, name: data.author }, author: { id: data.authorId, name: data.author },
duration: data.lengthSeconds * 1000, duration: data.lengthSeconds * 1000,
live: data.liveNow, live: data.liveNow,
@ -154,7 +153,11 @@ export default class Transformer {
keywords: stream.keywords, keywords: stream.keywords,
related: stream.recommendedVideos.map(Transformer.recommendedVideo), related: stream.recommendedVideos.map(Transformer.recommendedVideo),
video: { video: {
author: { id: stream.authorId, name: stream.author }, author: {
id: stream.authorId,
name: stream.author,
avatar: stream.authorThumbnails[0].url
},
description: stream.description, description: stream.description,
duration: stream.lengthSeconds * 1000, duration: stream.lengthSeconds * 1000,
id: stream.videoId, id: stream.videoId,

@ -46,16 +46,16 @@ export default class Transformer {
return channel; return channel;
case "playlist": case "playlist":
const channelId = parseChannelIdFromUrl(data.uploaderUrl); const channelId = data.uploaderUrl
? parseChannelIdFromUrl(data.uploaderUrl)
if (channelId === null) throw new Error("Piped: Missing channelId"); : null;
const playlist: PlaylistItem = { const playlist: PlaylistItem = {
type: "playlist", type: "playlist",
title: data.name, title: data.name,
author: { author: {
name: data.uploaderName, name: data.uploaderName,
id: channelId id: channelId ?? undefined
}, },
thumbnail: data.thumbnail, thumbnail: data.thumbnail,
id: data.url, id: data.url,
@ -116,7 +116,8 @@ export default class Transformer {
author: { author: {
id: channelId, id: channelId,
name: data.uploader, name: data.uploader,
avatar: data.uploaderAvatar avatar: data.uploaderAvatar,
subscribers: data.uploaderSubscriberCount
}, },
description: data.description, description: data.description,
duration: data.duration * 1000, duration: data.duration * 1000,

@ -25,7 +25,7 @@ export const PlaylistItemModel = z.object({
name: z.string(), name: z.string(),
thumbnail: z.string().url(), thumbnail: z.string().url(),
uploaderName: z.string(), uploaderName: z.string(),
uploaderUrl: z.string(), uploaderUrl: z.string().nullable(),
uploaderVerified: z.boolean(), uploaderVerified: z.boolean(),
playlistType: z.string(), playlistType: z.string(),
videos: z.number() videos: z.number()

@ -8,10 +8,10 @@ export const AudioStreamModel = z.object({
quality: z.string(), quality: z.string(),
mimeType: z.string(), mimeType: z.string(),
codec: z.string().nullable(), codec: z.string().nullable(),
audioTrackId: z.null(), audioTrackId: z.string().nullable(),
audioTrackName: z.null(), audioTrackName: z.string().nullable(),
audioTrackType: z.null(), audioTrackType: z.string().nullable(),
audioTrackLocale: z.null(), audioTrackLocale: z.string().nullable(),
videoOnly: z.boolean(), videoOnly: z.boolean(),
itag: z.number(), itag: z.number(),
bitrate: z.number(), bitrate: z.number(),

@ -0,0 +1,6 @@
export interface Author {
name: string;
id?: string;
avatar?: string;
subscribers?: number;
}

@ -1,3 +1,4 @@
import { Author } from "./author";
import { Video } from "./video"; import { Video } from "./video";
export type VideoItem = Video & { type: "video" }; export type VideoItem = Video & { type: "video" };
@ -16,10 +17,7 @@ export interface PlaylistItem {
type: "playlist"; type: "playlist";
title: string; title: string;
id: string; id: string;
author: { author: Author;
name: string;
id: string;
};
numberOfVideos: number; numberOfVideos: number;
thumbnail: string; thumbnail: string;
videos?: { videos?: {

@ -1,11 +1,9 @@
import { Author } from "./author";
export interface Video { export interface Video {
title: string; title: string;
id: string; id: string;
author: { author: Author;
name: string;
id: string;
avatar?: string;
};
thumbnail: string; thumbnail: string;
description?: string; description?: string;
/* /*
@ -13,6 +11,6 @@ export interface Video {
*/ */
duration: number; duration: number;
views: number; views: number;
uploaded: Date; uploaded?: Date;
live: boolean; live: boolean;
} }

@ -1,5 +1,6 @@
import NextImage from "next/image"; import NextImage from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMemo } from "react";
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";
@ -18,12 +19,16 @@ import { ContextMenu } from "./ContextMenu";
import { Component } from "@/typings/component"; import { Component } from "@/typings/component";
import { ContextMenuItem } from "@/typings/contextMenu"; import { ContextMenuItem } from "@/typings/contextMenu";
export const Video: Component<{ data: VideoProps }> = ({ data }) => { export const Video: Component<{ data: VideoProps; size?: number }> = ({
data,
size = 40
}) => {
const url = videoUrl(data.id); const url = videoUrl(data.id);
const [width, height] = videoSize([16, 9], 40); const [width, height] = videoSize([16, 9], size);
const menuItems: ContextMenuItem[] = [ const menuItems = useMemo(() => {
const items: ContextMenuItem[] = [
{ title: "Go to video", key: "gotoVideo", href: url }, { title: "Go to video", key: "gotoVideo", href: url },
{ {
title: "Copy video id", title: "Copy video id",
@ -45,20 +50,27 @@ export const Video: Component<{ data: VideoProps }> = ({ data }) => {
navigator.clipboard.writeText(data.thumbnail); navigator.clipboard.writeText(data.thumbnail);
}, },
showDivider: true showDivider: true
}, }
{ ];
if (data.author.id) {
items.push({
title: "Go to channel", title: "Go to channel",
key: "gotoChannel", key: "gotoChannel",
href: channelUrl(data.author.id) href: channelUrl(data.author.id)
}, });
{
items.push({
title: "Copy channel id", title: "Copy channel id",
key: "channelId", key: "channelId",
onClick: (): void => { onClick: (): void => {
navigator.clipboard.writeText(data.author.id); navigator.clipboard.writeText(data.author.id ?? "");
} }
});
} }
];
return items;
}, [data, url]);
return ( return (
<Link href={url}> <Link href={url}>
@ -81,19 +93,21 @@ export const Video: Component<{ data: VideoProps }> = ({ data }) => {
</CardBody> </CardBody>
<Divider /> <Divider />
<CardFooter> <CardFooter>
<div className="max-w-full"> <div style={{ width }} className="flex flex-col">
<p title={data.title} className="truncate"> <p title={data.title} className="text-ellipsis overflow-hidden">
{data.title} {data.title}
</p> </p>
<div className="flex flex-row gap-2 justify-start overflow-scroll"> <div className="flex flex-row gap-2 justify-start text-ellipsis overflow-hidden">
<p className="text-small font-semibold tracking-tight text-default-400"> <p className="text-small font-semibold tracking-tight text-default-400">
{data.author.name} {data.author.name}
</p> </p>
{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">
{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">
Views: {formatBigNumber(data.views)} Views: {formatBigNumber(data.views)}

@ -0,0 +1,126 @@
const itemPatterns: ItemPattern[] = [
{
regex: /(?:([0-9]{1,2}):)?([0-9]{1,2}):([0-9]{2})/g,
convert: (match): Timestamp => {
const hours = parseInt(match[1]) || 0;
const minutes = parseInt(match[2]) || 0;
const seconds = parseInt(match[3]) || 0;
const duration = hours * 3600 + minutes * 60 + seconds;
return {
type: ItemType.Timestamp,
duration,
id: Math.random().toString()
};
}
},
{
regex:
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g,
convert: (match): Link => {
console.log(match);
return {
type: ItemType.Link,
href: match[0],
id: Math.random().toString()
};
}
},
{
regex: /(\n)|(<br \/>)/g,
convert: () => ({ type: ItemType.Linebreak, id: Math.random().toString() })
}
];
interface BaseItem {
type: ItemType;
id: string;
}
interface Timestamp extends BaseItem {
type: ItemType.Timestamp;
duration: number;
}
interface Tokens extends BaseItem {
type: ItemType.Tokens;
content: string;
}
interface Link extends BaseItem {
type: ItemType.Link;
href: string;
text?: string;
}
interface Linebreak extends BaseItem {
type: ItemType.Linebreak;
}
export enum ItemType {
Linebreak,
Link,
Tokens,
Timestamp
}
type Item = Timestamp | Link | Tokens | Linebreak;
export interface ItemPattern {
regex: RegExp;
convert: (match: RegExpMatchArray) => Item;
}
export const highlight = (
input: string,
patterns: ItemPattern[] = itemPatterns
): Item[] => {
const items: Item[] = [];
const matches = patterns
.map((pattern) =>
Array.from(input.matchAll(pattern.regex)).map((match) => ({
match,
pattern
}))
)
.flat()
.map(({ match, pattern }) => ({
index: match.index ?? 0,
length: match[0].length,
item: pattern.convert(match)
}))
.sort((a, b) => a.index - b.index);
let lastIndex = 0;
for (const match of matches) {
if (match.index !== lastIndex) {
const content = input.substring(lastIndex, match.index);
items.push({
type: ItemType.Tokens,
content,
id: Math.random().toString()
});
}
items.push(match.item);
lastIndex = match.index + match.length;
}
if (lastIndex < input.length) {
const content = input.substring(lastIndex);
items.push({
type: ItemType.Tokens,
content,
id: Math.random().toString()
});
}
return items;
};

@ -3137,6 +3137,13 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/sanitize-html@^2.11.0":
version "2.11.0"
resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.11.0.tgz#582d8c72215c0228e3af2be136e40e0b531addf2"
integrity sha512-7oxPGNQHXLHE48r/r/qjn7q0hlrs3kL7oZnGj0Wf/h9tj/6ibFyRkNbsDxaBBZ4XUZ0Dx5LGCyDJ04ytSofacQ==
dependencies:
htmlparser2 "^8.0.0"
"@types/scheduler@*": "@types/scheduler@*":
version "0.16.8" version "0.16.8"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff"
@ -3968,6 +3975,36 @@ doctrine@^3.0.0:
dependencies: dependencies:
esutils "^2.0.2" esutils "^2.0.2"
dom-serializer@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.2"
entities "^4.2.0"
domelementtype@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
domutils@^3.0.1:
version "3.1.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e"
integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==
dependencies:
dom-serializer "^2.0.0"
domelementtype "^2.3.0"
domhandler "^5.0.3"
eastasianwidth@^0.2.0: eastasianwidth@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
@ -4008,6 +4045,11 @@ enhanced-resolve@^5.12.0:
graceful-fs "^4.2.4" graceful-fs "^4.2.4"
tapable "^2.2.0" tapable "^2.2.0"
entities@^4.2.0, entities@^4.4.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.22.4: es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.22.4:
version "1.22.5" version "1.22.5"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.5.tgz#1417df4e97cc55f09bf7e58d1e614bc61cb8df46" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.5.tgz#1417df4e97cc55f09bf7e58d1e614bc61cb8df46"
@ -4733,6 +4775,16 @@ hasown@^2.0.0, hasown@^2.0.1:
dependencies: dependencies:
function-bind "^1.1.2" function-bind "^1.1.2"
htmlparser2@^8.0.0:
version "8.0.2"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.0.1"
entities "^4.4.0"
idb@^7.0.1: idb@^7.0.1:
version "7.1.1" version "7.1.1"
resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
@ -4943,6 +4995,11 @@ is-path-inside@^3.0.3:
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
is-plain-object@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
is-regex@^1.1.4: is-regex@^1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
@ -5623,6 +5680,11 @@ parent-module@^1.0.0:
dependencies: dependencies:
callsites "^3.0.0" callsites "^3.0.0"
parse-srcset@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1"
integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==
path-exists@^4.0.0: path-exists@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
@ -5772,6 +5834,15 @@ postcss@^8, postcss@^8.4.23:
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.0.2" source-map-js "^1.0.2"
postcss@^8.3.11:
version "8.4.38"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e"
integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==
dependencies:
nanoid "^3.3.7"
picocolors "^1.0.0"
source-map-js "^1.2.0"
prelude-ls@^1.2.1: prelude-ls@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@ -6067,6 +6138,18 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0" es-errors "^1.3.0"
is-regex "^1.1.4" is-regex "^1.1.4"
sanitize-html@^2.13.0:
version "2.13.0"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.13.0.tgz#71aedcdb777897985a4ea1877bf4f895a1170dae"
integrity sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==
dependencies:
deepmerge "^4.2.2"
escape-string-regexp "^4.0.0"
htmlparser2 "^8.0.0"
is-plain-object "^5.0.0"
parse-srcset "^1.0.2"
postcss "^8.3.11"
scheduler@^0.23.0: scheduler@^0.23.0:
version "0.23.0" version "0.23.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
@ -6196,6 +6279,11 @@ source-map-js@^1.0.2:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
source-map-js@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"
integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
source-map-support@~0.5.20: source-map-support@~0.5.20:
version "0.5.21" version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"

Loading…
Cancel
Save