mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-19 20:11:31 +02:00
Merge branch 'files' into next
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
"@noble/secp256k1": "^1.7.0",
|
"@noble/secp256k1": "^1.7.0",
|
||||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||||
"bech32": "^2.0.0",
|
"bech32": "^2.0.0",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"chroma-js": "^2.4.2",
|
"chroma-js": "^2.4.2",
|
||||||
"dayjs": "^1.11.9",
|
"dayjs": "^1.11.9",
|
||||||
|
@@ -90,6 +90,7 @@ const StreamView = lazy(() => import("./views/streams/stream"));
|
|||||||
|
|
||||||
const SearchView = lazy(() => import("./views/search"));
|
const SearchView = lazy(() => import("./views/search"));
|
||||||
const MapView = lazy(() => import("./views/map"));
|
const MapView = lazy(() => import("./views/map"));
|
||||||
|
const FilesView = lazy(() => import("./views/files"));
|
||||||
|
|
||||||
const ChannelsHomeView = lazy(() => import("./views/channels"));
|
const ChannelsHomeView = lazy(() => import("./views/channels"));
|
||||||
const ChannelView = lazy(() => import("./views/channels/channel"));
|
const ChannelView = lazy(() => import("./views/channels/channel"));
|
||||||
@@ -224,6 +225,7 @@ const router = createHashRouter([
|
|||||||
element: <ThreadView />,
|
element: <ThreadView />,
|
||||||
},
|
},
|
||||||
{ path: "settings", element: <SettingsView /> },
|
{ path: "settings", element: <SettingsView /> },
|
||||||
|
{ path: "files", element: <FilesView /> },
|
||||||
{
|
{
|
||||||
path: "relays",
|
path: "relays",
|
||||||
children: [
|
children: [
|
||||||
|
23
src/components/blured-image.tsx
Normal file
23
src/components/blured-image.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Box, Image, ImageProps, useDisclosure } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
export default function BlurredImage(props: ImageProps) {
|
||||||
|
const { isOpen, onOpen } = useDisclosure();
|
||||||
|
return (
|
||||||
|
<Box overflow="hidden">
|
||||||
|
<Image
|
||||||
|
onClick={
|
||||||
|
!isOpen
|
||||||
|
? (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
cursor="pointer"
|
||||||
|
filter={isOpen ? "" : "blur(1.5rem)"}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
29
src/components/blurhash-image.tsx
Normal file
29
src/components/blurhash-image.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Box, BoxProps } from "@chakra-ui/react";
|
||||||
|
import { decode } from "blurhash";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export type BlurhashImageProps = {
|
||||||
|
blurhash: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} & Omit<BoxProps, "width" | "height" | "children">;
|
||||||
|
|
||||||
|
export default function BlurhashImage({ blurhash, width, height, ...props }: BlurhashImageProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
const ctx = canvasRef.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.canvas.width = width;
|
||||||
|
ctx.canvas.height = height;
|
||||||
|
|
||||||
|
const imageData = ctx.createImageData(width, height);
|
||||||
|
const pixels = decode(blurhash, width, height);
|
||||||
|
imageData.data.set(pixels);
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}, [blurhash, width, height]);
|
||||||
|
|
||||||
|
return <Box as="canvas" ref={canvasRef} {...props} />;
|
||||||
|
}
|
@@ -63,6 +63,7 @@ import ReverseLeft from "./icons/reverse-left";
|
|||||||
import Pin01 from "./icons/pin-01";
|
import Pin01 from "./icons/pin-01";
|
||||||
import Translate01 from "./icons/translate-01";
|
import Translate01 from "./icons/translate-01";
|
||||||
import MessageChatSquare from "./icons/message-chat-square";
|
import MessageChatSquare from "./icons/message-chat-square";
|
||||||
|
import File01 from "./icons/file-01";
|
||||||
|
|
||||||
const defaultProps: IconProps = { boxSize: 4 };
|
const defaultProps: IconProps = { boxSize: 4 };
|
||||||
|
|
||||||
@@ -232,7 +233,6 @@ export const WalletIcon = Wallet02;
|
|||||||
export const DownloadIcon = Download01;
|
export const DownloadIcon = Download01;
|
||||||
|
|
||||||
export const TranslateIcon = Translate01;
|
export const TranslateIcon = Translate01;
|
||||||
|
|
||||||
export const ChannelsIcon = MessageChatSquare;
|
export const ChannelsIcon = MessageChatSquare;
|
||||||
|
|
||||||
export const ThreadIcon = MessageChatSquare;
|
export const ThreadIcon = MessageChatSquare;
|
||||||
|
export const FileIcon = File01;
|
@@ -20,6 +20,7 @@ import {
|
|||||||
NotesIcon,
|
NotesIcon,
|
||||||
LightningIcon,
|
LightningIcon,
|
||||||
ChannelsIcon,
|
ChannelsIcon,
|
||||||
|
FileIcon,
|
||||||
} from "../icons";
|
} from "../icons";
|
||||||
import useCurrentAccount from "../../hooks/use-current-account";
|
import useCurrentAccount from "../../hooks/use-current-account";
|
||||||
import accountService from "../../services/account";
|
import accountService from "../../services/account";
|
||||||
@@ -53,6 +54,7 @@ export default function NavItems() {
|
|||||||
else if (location.pathname.startsWith("/c/")) active = "communities";
|
else if (location.pathname.startsWith("/c/")) active = "communities";
|
||||||
else if (location.pathname.startsWith("/goals")) active = "goals";
|
else if (location.pathname.startsWith("/goals")) active = "goals";
|
||||||
else if (location.pathname.startsWith("/badges")) active = "badges";
|
else if (location.pathname.startsWith("/badges")) active = "badges";
|
||||||
|
else if (location.pathname.startsWith("/files")) active = "files";
|
||||||
else if (location.pathname.startsWith("/emojis")) active = "emojis";
|
else if (location.pathname.startsWith("/emojis")) active = "emojis";
|
||||||
else if (location.pathname.startsWith("/settings")) active = "settings";
|
else if (location.pathname.startsWith("/settings")) active = "settings";
|
||||||
else if (location.pathname.startsWith("/tools")) active = "tools";
|
else if (location.pathname.startsWith("/tools")) active = "tools";
|
||||||
@@ -179,6 +181,15 @@ export default function NavItems() {
|
|||||||
>
|
>
|
||||||
Lists
|
Lists
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
as={RouterLink}
|
||||||
|
to="/files"
|
||||||
|
leftIcon={<FileIcon boxSize={6} />}
|
||||||
|
colorScheme={active === "files" ? "primary" : undefined}
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
Files
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
as={RouterLink}
|
as={RouterLink}
|
||||||
to="/goals"
|
to="/goals"
|
||||||
|
55
src/helpers/nostr/files.ts
Normal file
55
src/helpers/nostr/files.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
|
|
||||||
|
export const FILE_KIND = 1063;
|
||||||
|
export const VIDEO_TYPES = ["video/mp4", "video/webm"];
|
||||||
|
export const IMAGE_TYPES = ["image/png", "image/jpeg", "image/svg+xml", "image/webp", "image/gif"];
|
||||||
|
export const AUDIO_TYPES = ["audio/webm", "audio/wav", "audio/ogg"];
|
||||||
|
export const TEXT_TYPES = ["text/plain"];
|
||||||
|
|
||||||
|
export type ParsedImageFile = {
|
||||||
|
url: string;
|
||||||
|
mimeType: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
size?: number;
|
||||||
|
magnet?: string;
|
||||||
|
sha256Hash?: string;
|
||||||
|
infoHash?: string;
|
||||||
|
blurhash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getFileUrl(event: NostrEvent) {
|
||||||
|
const url = event.tags.find((t) => t[0] === "url" && t[1])?.[1];
|
||||||
|
if (!url) throw new Error("Missing url");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseImageFile(event: NostrEvent): ParsedImageFile {
|
||||||
|
const url = getFileUrl(event);
|
||||||
|
const mimeType = event.tags.find((t) => t[0] === "m" && t[1])?.[1];
|
||||||
|
const magnet = event.tags.find((t) => t[0] === "magnet" && t[1])?.[1];
|
||||||
|
const infoHash = event.tags.find((t) => t[0] === "i" && t[1])?.[1];
|
||||||
|
const size = event.tags.find((t) => t[0] === "i" && t[1])?.[1];
|
||||||
|
const sha256Hash = event.tags.find((t) => t[0] === "x" && t[1])?.[1];
|
||||||
|
const blurhash = event.tags.find((t) => t[0] === "blurhash" && t[1])?.[1];
|
||||||
|
|
||||||
|
const dimensions = event.tags.find((t) => t[0] === "dim" && t[1])?.[1];
|
||||||
|
const [width, height] = dimensions?.split("x").map((v) => parseInt(v)) ?? [];
|
||||||
|
|
||||||
|
if (!mimeType) throw new Error("missing MIME Type");
|
||||||
|
if (width !== undefined && height !== undefined) {
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height)) throw new Error("bad dimensions");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
mimeType,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
magnet,
|
||||||
|
infoHash,
|
||||||
|
sha256Hash,
|
||||||
|
blurhash,
|
||||||
|
size: size ? parseInt(size) : undefined,
|
||||||
|
};
|
||||||
|
}
|
@@ -21,6 +21,10 @@ export type NostrQuery = {
|
|||||||
"#p"?: string[];
|
"#p"?: string[];
|
||||||
"#r"?: string[];
|
"#r"?: string[];
|
||||||
"#t"?: string[];
|
"#t"?: string[];
|
||||||
|
"#r"?: string[];
|
||||||
|
"#l"?: string[];
|
||||||
|
"#g"?: string[];
|
||||||
|
"#m"?: string[];
|
||||||
since?: number;
|
since?: number;
|
||||||
until?: number;
|
until?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
158
src/views/files/index.tsx
Normal file
158
src/views/files/index.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Box, Flex, Image, SimpleGrid, SkipNavContent, Spacer, Text } from "@chakra-ui/react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||||
|
import useSubject from "../../hooks/use-subject";
|
||||||
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
|
import { FILE_KIND, IMAGE_TYPES, VIDEO_TYPES, getFileUrl, parseImageFile } from "../../helpers/nostr/files";
|
||||||
|
import BlurhashImage from "../../components/blurhash-image";
|
||||||
|
import { ErrorBoundary } from "../../components/error-boundary";
|
||||||
|
import useAppSettings from "../../hooks/use-app-settings";
|
||||||
|
import { TrustProvider, useTrusted } from "../../providers/trust";
|
||||||
|
import BlurredImage from "../../components/blured-image";
|
||||||
|
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
|
||||||
|
import RelaySelectionProvider, { useRelaySelectionContext } from "../../providers/relay-selection-provider";
|
||||||
|
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
|
||||||
|
import RelaySelectionButton from "../../components/relay-selection/relay-selection-button";
|
||||||
|
import { UserAvatarLink } from "../../components/user-avatar-link";
|
||||||
|
import { UserLink } from "../../components/user-link";
|
||||||
|
import MimeTypePicker from "./mime-type-picker";
|
||||||
|
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
|
||||||
|
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||||
|
import Timestamp from "../../components/timestamp";
|
||||||
|
import NoteZapButton from "../../components/note/note-zap-button";
|
||||||
|
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer";
|
||||||
|
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||||
|
|
||||||
|
function ImageFile({ event }: { event: NostrEvent }) {
|
||||||
|
const parsed = parseImageFile(event);
|
||||||
|
const settings = useAppSettings();
|
||||||
|
const trust = useTrusted();
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useRegisterIntersectionEntity(ref, event.id);
|
||||||
|
|
||||||
|
const shouldBlur = settings.blurImages && !trust;
|
||||||
|
|
||||||
|
// const showImage = useDisclosure();
|
||||||
|
// if (shouldBlur && parsed.blurhash && parsed.width && parsed.height && !showImage.isOpen) {
|
||||||
|
// const aspect = parsed.width / parsed.height;
|
||||||
|
// return (
|
||||||
|
// <BlurhashImage
|
||||||
|
// blurhash={parsed.blurhash}
|
||||||
|
// width={64 * aspect}
|
||||||
|
// height={64}
|
||||||
|
// onClick={showImage.onOpen}
|
||||||
|
// cursor="pointer"
|
||||||
|
// w="full"
|
||||||
|
// />
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
const ImageComponent = shouldBlur ? BlurredImage : Image;
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
gap="2"
|
||||||
|
aspectRatio={1}
|
||||||
|
backgroundImage={parsed.url}
|
||||||
|
backgroundPosition="center"
|
||||||
|
backgroundSize="cover"
|
||||||
|
backgroundRepeat="no-repeat"
|
||||||
|
borderRadius="lg"
|
||||||
|
overflow="hidden"
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Flex gap="2" alignItems="center" backgroundColor="blackAlpha.500" mt="auto" p="2">
|
||||||
|
<UserAvatarLink pubkey={event.pubkey} size="sm" />
|
||||||
|
<UserLink pubkey={event.pubkey} fontWeight="bold" isTruncated />
|
||||||
|
<Timestamp timestamp={event.created_at} />
|
||||||
|
<Spacer />
|
||||||
|
<NoteZapButton event={event} size="sm" colorScheme="yellow" variant="outline" />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoFile({ event }: { event: NostrEvent }) {
|
||||||
|
const url = getFileUrl(event);
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useRegisterIntersectionEntity(ref, event.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap="2" ref={ref}>
|
||||||
|
<Flex gap="2" alignItems="center">
|
||||||
|
<UserAvatarLink pubkey={event.pubkey} size="sm" />
|
||||||
|
<UserLink pubkey={event.pubkey} fontWeight="bold" />
|
||||||
|
</Flex>
|
||||||
|
<video src={url} controls />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileType({ event }: { event: NostrEvent }) {
|
||||||
|
const mimeType = event.tags.find((t) => t[0] === "m" && t[1])?.[1];
|
||||||
|
|
||||||
|
if (!mimeType) throw new Error("Missing MIME type");
|
||||||
|
|
||||||
|
if (IMAGE_TYPES.includes(mimeType)) {
|
||||||
|
return (
|
||||||
|
<TrustProvider trust>
|
||||||
|
<ImageFile event={event} />
|
||||||
|
</TrustProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (VIDEO_TYPES.includes(mimeType)) {
|
||||||
|
return <VideoFile event={event} />;
|
||||||
|
}
|
||||||
|
return <Text>Unknown mine type {mimeType}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilesPage() {
|
||||||
|
const { listId, filter } = usePeopleListContext();
|
||||||
|
const { relays } = useRelaySelectionContext();
|
||||||
|
|
||||||
|
const [selectedTypes, setSelectedTypes] = useState<string[]>(IMAGE_TYPES);
|
||||||
|
|
||||||
|
const timeline = useTimelineLoader(
|
||||||
|
`${listId}-files`,
|
||||||
|
relays,
|
||||||
|
{ kinds: [FILE_KIND], "#m": selectedTypes, ...filter },
|
||||||
|
{ enabled: selectedTypes.length > 0 && !!filter },
|
||||||
|
);
|
||||||
|
|
||||||
|
const events = useSubject(timeline.timeline);
|
||||||
|
const callback = useTimelineCurserIntersectionCallback(timeline);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VerticalPageLayout>
|
||||||
|
<Flex gap="2">
|
||||||
|
<PeopleListSelection />
|
||||||
|
<MimeTypePicker selected={selectedTypes} onChange={(v) => setSelectedTypes(v)} />
|
||||||
|
<RelaySelectionButton ml="auto" />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<IntersectionObserverProvider callback={callback}>
|
||||||
|
<SimpleGrid minChildWidth="20rem" spacing="2">
|
||||||
|
{events.map((event) => (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<FileType key={event.id} event={event} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</IntersectionObserverProvider>
|
||||||
|
<TimelineActionAndStatus timeline={timeline} />
|
||||||
|
</VerticalPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilesView() {
|
||||||
|
return (
|
||||||
|
<PeopleListProvider>
|
||||||
|
<RelaySelectionProvider>
|
||||||
|
<FilesPage />
|
||||||
|
</RelaySelectionProvider>
|
||||||
|
</PeopleListProvider>
|
||||||
|
);
|
||||||
|
}
|
123
src/views/files/mime-type-picker.tsx
Normal file
123
src/views/files/mime-type-picker.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Flex,
|
||||||
|
Popover,
|
||||||
|
PopoverArrow,
|
||||||
|
PopoverBody,
|
||||||
|
PopoverCloseButton,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import { AUDIO_TYPES, IMAGE_TYPES, TEXT_TYPES, VIDEO_TYPES } from "../../helpers/nostr/files";
|
||||||
|
import { unique } from "../../helpers/array";
|
||||||
|
|
||||||
|
export default function MimeTypePicker({
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
selected: string[];
|
||||||
|
onChange: (selected: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const toggleType = useCallback(
|
||||||
|
(type: string) => {
|
||||||
|
if (selected.includes(type)) {
|
||||||
|
onChange(selected.filter((t) => t !== type));
|
||||||
|
} else onChange(selected.concat(type));
|
||||||
|
},
|
||||||
|
[selected, onChange],
|
||||||
|
);
|
||||||
|
const toggleCategory = useCallback(
|
||||||
|
(types: string[]) => {
|
||||||
|
const selectedTypes = selected.filter((t) => types.includes(t));
|
||||||
|
|
||||||
|
if (selectedTypes.length !== types.length) onChange(unique([...selected, ...types]));
|
||||||
|
else onChange(selected.filter((t) => !types.includes(t)));
|
||||||
|
},
|
||||||
|
[selected, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedImageTypes = selected.filter((t) => IMAGE_TYPES.includes(t));
|
||||||
|
const selectedVideoTypes = selected.filter((t) => VIDEO_TYPES.includes(t));
|
||||||
|
const selectedAudioTypes = selected.filter((t) => AUDIO_TYPES.includes(t));
|
||||||
|
const selectedTextTypes = selected.filter((t) => TEXT_TYPES.includes(t));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button>{selected.length} Selected types</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent w="xl">
|
||||||
|
<PopoverArrow />
|
||||||
|
<PopoverCloseButton />
|
||||||
|
<PopoverBody>
|
||||||
|
<Flex gap="4">
|
||||||
|
<Flex gap="2" direction="column">
|
||||||
|
<Checkbox
|
||||||
|
isIndeterminate={selectedImageTypes.length > 0 && selectedImageTypes.length !== IMAGE_TYPES.length}
|
||||||
|
isChecked={selectedImageTypes.length === IMAGE_TYPES.length}
|
||||||
|
onChange={() => toggleCategory(IMAGE_TYPES)}
|
||||||
|
>
|
||||||
|
Images
|
||||||
|
</Checkbox>
|
||||||
|
<Divider />
|
||||||
|
{IMAGE_TYPES.map((type) => (
|
||||||
|
<Checkbox key={type} isChecked={selected.includes(type)} onChange={() => toggleType(type)}>
|
||||||
|
{type}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
<Flex gap="2" direction="column">
|
||||||
|
<Checkbox
|
||||||
|
isIndeterminate={selectedVideoTypes.length > 0 && selectedVideoTypes.length !== VIDEO_TYPES.length}
|
||||||
|
isChecked={selectedVideoTypes.length === VIDEO_TYPES.length}
|
||||||
|
onChange={() => toggleCategory(VIDEO_TYPES)}
|
||||||
|
>
|
||||||
|
Videos
|
||||||
|
</Checkbox>
|
||||||
|
<Divider />
|
||||||
|
{VIDEO_TYPES.map((type) => (
|
||||||
|
<Checkbox key={type} isChecked={selected.includes(type)} onChange={() => toggleType(type)}>
|
||||||
|
{type}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
<Flex gap="2" direction="column">
|
||||||
|
<Checkbox
|
||||||
|
isIndeterminate={selectedAudioTypes.length > 0 && selectedAudioTypes.length !== AUDIO_TYPES.length}
|
||||||
|
isChecked={selectedAudioTypes.length === AUDIO_TYPES.length}
|
||||||
|
onChange={() => toggleCategory(AUDIO_TYPES)}
|
||||||
|
>
|
||||||
|
Audio
|
||||||
|
</Checkbox>
|
||||||
|
<Divider />
|
||||||
|
{AUDIO_TYPES.map((type) => (
|
||||||
|
<Checkbox key={type} isChecked={selected.includes(type)} onChange={() => toggleType(type)}>
|
||||||
|
{type}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
<Flex gap="2" direction="column">
|
||||||
|
<Checkbox
|
||||||
|
isIndeterminate={selectedTextTypes.length > 0 && selectedTextTypes.length !== TEXT_TYPES.length}
|
||||||
|
isChecked={selectedTextTypes.length === TEXT_TYPES.length}
|
||||||
|
onChange={() => toggleCategory(TEXT_TYPES)}
|
||||||
|
>
|
||||||
|
Text
|
||||||
|
</Checkbox>
|
||||||
|
<Divider />
|
||||||
|
{TEXT_TYPES.map((type) => (
|
||||||
|
<Checkbox key={type} isChecked={selected.includes(type)} onChange={() => toggleType(type)}>
|
||||||
|
{type}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
@@ -3056,6 +3056,11 @@ bn.js@^4.11.8:
|
|||||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
|
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
|
||||||
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
|
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
|
||||||
|
|
||||||
|
blurhash@^2.0.5:
|
||||||
|
version "2.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-2.0.5.tgz#efde729fc14a2f03571a6aa91b49cba80d1abe4b"
|
||||||
|
integrity sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==
|
||||||
|
|
||||||
boolbase@^1.0.0:
|
boolbase@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
||||||
|
Reference in New Issue
Block a user