-
-
{t("component.apiError.title")}
-
-
- Twitter
-
- ),
- discord: (
-
- Discord
-
- ),
- }}
- />
-
-
-
-
+
+ Debug Information
+
+
+
+
+ {error?.message}
+
+
+ {error?.stack}
+
+
+
+
+
+
+
+
+
+
+ © Holodex
+
+
);
}
diff --git a/packages/react/src/components/common/TwitterFeed.tsx b/packages/react/src/components/common/TwitterFeed.tsx
index 2458bc7d3..822989f47 100644
--- a/packages/react/src/components/common/TwitterFeed.tsx
+++ b/packages/react/src/components/common/TwitterFeed.tsx
@@ -1,4 +1,11 @@
-import { DetailedHTMLProps, HTMLAttributes, useEffect, useRef } from "react";
+import { cn } from "@/lib/utils";
+import {
+ DetailedHTMLProps,
+ HTMLAttributes,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
import { useScript } from "usehooks-ts";
declare global {
@@ -6,17 +13,13 @@ declare global {
var twttr: any;
}
-const html = ``;
-
export function TwitterFeed(
props: DetailedHTMLProps
, HTMLDivElement>,
) {
const ref = useRef(null);
const status = useScript("https://platform.twitter.com/widgets.js");
+ const html = ``;
useEffect(() => {
if (status === "ready") window.twttr?.widgets.load();
}, [status]);
@@ -25,3 +28,67 @@ export function TwitterFeed(
);
}
+
+export function StatusTweetEmbed({
+ ...props
+}: DetailedHTMLProps, HTMLDivElement>) {
+ const ref = useRef(null);
+ const [tweetUrl, setTweetUrl] = useState(null);
+ const [error, setError] = useState(null);
+ const status = useScript("https://platform.twitter.com/widgets.js");
+
+ // Fetch the latest status
+ useEffect(() => {
+ fetch("https://ext.holodex.net/api/status")
+ .then((res) => res.text())
+ .then((url) => setTweetUrl(url.trim()))
+ .catch((err) => setError("Failed to load status"));
+ }, []);
+
+ // Create tweet embed when both tweet URL and Twitter script are ready
+ useEffect(() => {
+ if (status === "ready" && tweetUrl && window.twttr && ref.current) {
+ // Clear previous content
+ ref.current.innerHTML = "";
+
+ window.twttr.widgets
+ .createTweet(
+ // Extract tweet ID from URL
+ tweetUrl.split("/").pop()!,
+ ref.current,
+ {
+ theme: "light",
+ width: 550,
+ align: "center",
+ conversation: "none", // Hide replies
+ },
+ )
+ .catch(() => setError("Failed to load tweet"));
+ }
+ }, [status, tweetUrl]);
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ // if (!tweetUrl || status !== "ready") {
+ // return (
+ //
+ // Loading status...
+ //
+ // );
+ // }
+
+ return (
+
+ Loading...
+
+ );
+}
diff --git a/packages/react/src/components/layout/Frame.tsx b/packages/react/src/components/layout/Frame.tsx
index 98aed96f0..c8402fcd2 100644
--- a/packages/react/src/components/layout/Frame.tsx
+++ b/packages/react/src/components/layout/Frame.tsx
@@ -15,7 +15,7 @@ import { Toaster } from "@/shadcn/ui/toaster";
import { orgAtom } from "@/store/org";
import { miniPlayerAtom } from "@/store/player";
import clsx from "clsx";
-import { useAtomValue, useSetAtom } from "jotai";
+import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Suspense, useEffect } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
@@ -25,6 +25,8 @@ import { MiniPlayer } from "../player/MiniPlayer";
import { Footer } from "./Footer";
import SelectionFooter from "./SelectionFooter";
import { selectionModeAtom } from "@/hooks/useVideoSelection";
+import { videoReportAtom } from "@/store/video";
+import React from "react";
export function LocationAwareReactivity() {
const location = useLocation();
@@ -51,6 +53,8 @@ export function LocationAwareReactivity() {
return <>>;
}
+const LazyVideoReportDialog = React.lazy(() => import("../video/VideoReport"));
+
export function Frame() {
console.log("rerendered frame!");
const resize = useSetAtom(onResizeAtom);
@@ -79,6 +83,8 @@ export function Frame() {
return () => window.removeEventListener("resize", resize);
}, []);
+ const [reportedVideo, setReportedVideo] = useAtom(videoReportAtom);
+
return (
@@ -92,6 +98,14 @@ export function Frame() {
+ {reportedVideo && (
+
setReportedVideo(null)}
+ video={reportedVideo}
+ />
+ )}
+
{isMobile && }
{miniPlayer && }
diff --git a/packages/react/src/components/video/VideoMenu.tsx b/packages/react/src/components/video/VideoMenu.tsx
index d8439304f..67e59b971 100644
--- a/packages/react/src/components/video/VideoMenu.tsx
+++ b/packages/react/src/components/video/VideoMenu.tsx
@@ -19,7 +19,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useCopyToClipboard } from "usehooks-ts";
import { useToast } from "@/shadcn/ui/use-toast";
-import { useAtom, useAtomValue } from "jotai";
+import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { queueAtom } from "@/store/queue";
import { VideoCardType } from "./VideoCard";
import "./VideoMenu.css";
@@ -29,6 +29,7 @@ import {
} from "@/hooks/useVideoSelection";
import { TLDexLogo } from "../common/TLDexLogo";
import { userAtom } from "@/store/auth";
+import { videoReportAtom } from "@/store/video";
const LazyNewPlaylistDialog = lazy(
() => import("@/components/playlist/NewPlaylistDialog"),
@@ -61,6 +62,7 @@ export function VideoMenu({ children, video, url }: VideoMenuProps) {
);
const [, copy] = useCopyToClipboard();
+ const setReportedVideo = useSetAtom(videoReportAtom);
return (
@@ -71,21 +73,25 @@ export function VideoMenu({ children, video, url }: VideoMenuProps) {
// className="border-base-5"
className="tracking-tight"
>
-
- setQueue((q) =>
- isQueued ? q.filter(({ id }) => videoId !== id) : [...q, video],
- )
- }
- >
-
- {isQueued
- ? t("views.watch.removeFromQueue")
- : t("views.watch.addToQueue")}
-
+ {isQueued ? (
+
+ setQueue((q) => q.filter(({ id }) => videoId !== id))
+ }
+ >
+
+ {t("views.watch.removeFromQueue")}
+
+ ) : (
+ setQueue((q) => [...q, video])}
+ >
+
+ {t("views.watch.addToQueue")}
+
+ )}
{url && (
@@ -138,20 +144,27 @@ export function VideoMenu({ children, video, url }: VideoMenuProps) {
)}
- {
- if (!isSelected) {
+ {!isSelected && (
+ {
addVideo(video as unknown as PlaceholderVideo);
setSelectionMode(true);
- } else {
- removeVideo(videoId);
- }
- }}
- >
-
- {isSelected ? "Remove from Selection" : "Add to Selection"}
-
+ }}
+ >
+
+ Add to Selection
+
+ )}
+ {isSelected && (
+ removeVideo(videoId)}
+ >
+
+ Remove from Selections
+
+ )}
{video.status === "upcoming" && (
@@ -170,7 +183,12 @@ export function VideoMenu({ children, video, url }: VideoMenuProps) {
{t("component.videoCard.uploadScript")}
)}
- {}}>
+ {
+ setReportedVideo(video as unknown as Video);
+ }}
+ >
{t("component.reportDialog.title")}
diff --git a/packages/react/src/components/video/VideoReport.tsx b/packages/react/src/components/video/VideoReport.tsx
new file mode 100644
index 000000000..c02f5ddc0
--- /dev/null
+++ b/packages/react/src/components/video/VideoReport.tsx
@@ -0,0 +1,380 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useVideoReportMutation } from "@/services/reports.service";
+import {
+ Credenza,
+ CredenzaContent,
+ CredenzaHeader,
+ CredenzaBody,
+ CredenzaTitle,
+ CredenzaFooter,
+ CredenzaClose,
+} from "@/shadcn/ui/dialog-drawer";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/shadcn/ui/form";
+import { TopicPicker } from "@/components/topic/TopicPicker";
+import { ChannelPicker } from "@/components/channel/ChannelPicker";
+import { Checkbox } from "@/shadcn/ui/checkbox";
+import { Textarea } from "@/shadcn/ui/textarea";
+import { Button } from "@/shadcn/ui/button";
+import { Alert, AlertDescription } from "@/shadcn/ui/alert";
+import { useToast } from "@/shadcn/ui/use-toast";
+import { useAtomValue } from "jotai";
+import { orgAtom } from "@/store/org";
+
+const formSchema = z.object({
+ reasons: z.array(z.string()).min(1, {
+ message: "Please select at least one reason",
+ }),
+ comments: z.string().min(20, {
+ message: "Comments must be at least 20 characters long",
+ }),
+ suggestedTopic: z.string().optional(),
+ mentionedChannels: z
+ .array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ }),
+ )
+ .optional(),
+});
+
+type FormValues = z.infer;
+
+interface ReportDialogMenuProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ video: VideoBase;
+}
+
+export function ReportDialogMenu({
+ open,
+ onOpenChange,
+ video,
+}: ReportDialogMenuProps) {
+ const { t } = useTranslation();
+ const { toast } = useToast();
+ const currentOrg = useAtomValue(orgAtom);
+ const [selectedChannels, setSelectedChannels] = useState<
+ SearchAutoCompleteChannel[]
+ >([]);
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ reasons: [],
+ comments: "",
+ suggestedTopic: "",
+ mentionedChannels: [],
+ },
+ });
+
+ const { mutate: submitReport, isPending } = useVideoReportMutation(video.id, {
+ onSuccess: () => {
+ toast({
+ title: t("component.reportDialog.success"),
+ duration: 3000,
+ });
+ onOpenChange(false);
+ form.reset();
+ setSelectedChannels([]);
+ },
+ onError: () => {
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to submit report",
+ });
+ },
+ });
+
+ const reasons = [
+ {
+ value: "Incorrect video topic",
+ label: t("component.reportDialog.reasons.4"),
+ allowedTypes: ["stream", "placeholder"],
+ orgRequired: false,
+ },
+ {
+ value: "Incorrect channel mentions",
+ label: t("component.reportDialog.reasons.5"),
+ allowedTypes: null,
+ orgRequired: false,
+ },
+ {
+ value: "This video does not belong to the org",
+ label: t("component.reportDialog.reasons.6", {
+ type: video.type,
+ org: currentOrg,
+ }),
+ allowedTypes: null,
+ orgRequired: true,
+ },
+ {
+ value: "Low Quality/Misleading Content",
+ label: t("component.reportDialog.reasons.1"),
+ allowedTypes: ["clip"],
+ orgRequired: false,
+ },
+ {
+ value: "Violates the org's derivative work guidelines or inappropriate",
+ label: t("component.reportDialog.reasons.2"),
+ allowedTypes: ["clip"],
+ orgRequired: false,
+ },
+ {
+ value: "Other",
+ label: t("component.reportDialog.reasons.3"),
+ allowedTypes: null,
+ orgRequired: false,
+ },
+ ].filter((reason) => {
+ if (
+ reason.orgRequired &&
+ (!currentOrg ||
+ currentOrg === "All Vtubers" ||
+ currentOrg === video.channel.org)
+ ) {
+ return false;
+ }
+ if (reason.allowedTypes && !reason.allowedTypes.includes(video.type)) {
+ return false;
+ }
+ return true;
+ });
+
+ const onSubmit = (data: FormValues) => {
+ const fields = [
+ {
+ name: "Reason",
+ value: data.reasons.join("\n"),
+ },
+ {
+ name: "Comments",
+ value: data.comments,
+ },
+ ];
+
+ if (data.reasons.includes("Incorrect video topic")) {
+ fields.push(
+ {
+ name: "Original Topic",
+ value: video.topic_id ? `\`${video.topic_id}\`` : "None",
+ },
+ {
+ name: "Suggested Topic",
+ value: data.suggestedTopic ? `\`${data.suggestedTopic}\`` : "None",
+ },
+ );
+ }
+
+ if (data.reasons.includes("Incorrect channel mentions")) {
+ fields.push({
+ name: "Suggested Mentions",
+ value:
+ selectedChannels.length > 0
+ ? selectedChannels.map((channel) => `\`${channel.id}\``).join("\n")
+ : "None",
+ });
+ }
+
+ submitReport({ fields });
+ };
+
+ const handleChannelSelect = (channel: SearchAutoCompleteChannel) => {
+ if (!selectedChannels.find((c) => c.id === channel.id)) {
+ setSelectedChannels([...selectedChannels, channel]);
+ }
+ };
+
+ const handleChannelRemove = (channelId: string) => {
+ setSelectedChannels(selectedChannels.filter((c) => c.id !== channelId));
+ };
+
+ return (
+
+
+
+
+
+
+
+ {t("component.reportDialog.title")}
+
+
+
+
+
+
+
+ );
+}
+
+export default ReportDialogMenu;
diff --git a/packages/react/src/locales/en/ui.yml b/packages/react/src/locales/en/ui.yml
index 4d144d90e..f2d6dfabc 100644
--- a/packages/react/src/locales/en/ui.yml
+++ b/packages/react/src/locales/en/ui.yml
@@ -6,8 +6,9 @@ component:
aboutPage: About page
afterAboutPageHyperlink: .
text: >-
- There was an error retrieving content, please check or
- report an error through the .
+ There was an error retrieving content, please check
+ twitter or report an error through the
+ discord.
reload: Reload
logoutAndClearCache: Logout / Clear cache
channelList:
@@ -261,13 +262,13 @@ views:
official: Archive
subber: Clips
noLiveStreams:
- - "*Tumbleweed rolls across the screen*"
- - "Nobody is live right now"
- - "Seems like all the VTubers are busy touching grass"
- - "Time to watch the backlog, because no one's streaming"
- - "Vtubers are sleeping, time to watch clips!"
- - "Vtubers doko?"
- - "No one is live right now, go listen to some bangers on Musicdex!"
+ - '*Tumbleweed rolls across the screen*'
+ - Nobody is live right now
+ - Seems like all the VTubers are busy touching grass
+ - Time to watch the backlog, because no one's streaming
+ - Vtubers are sleeping, time to watch clips!
+ - Vtubers doko?
+ - No one is live right now, go listen to some bangers on Musicdex!
library:
savedVideosTitle: Saved Videos
createYtPlaylistButton: Create YT Playlist ({0})
diff --git a/packages/react/src/routes/about/general.tsx b/packages/react/src/routes/about/general.tsx
index 856d5bbbc..5b9c8c2b5 100644
--- a/packages/react/src/routes/about/general.tsx
+++ b/packages/react/src/routes/about/general.tsx
@@ -2,12 +2,16 @@ import { AboutDescription } from "@/components/about/Description";
import { AboutHeading } from "@/components/about/Heading";
import StatComponent from "@/components/about/Stats";
import { Loading } from "@/components/common/Loading";
+import { darkAtom } from "@/hooks/useTheme";
import { useQuery } from "@tanstack/react-query";
+import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
export function AboutGeneral() {
const { t } = useTranslation();
+ const dark = useAtomValue(darkAtom);
+
return (
{t("about.quicklinks")}
@@ -31,6 +35,19 @@ export function AboutGeneral() {
{t("about.general.credits.addRequest")}
+
+ {dark ? (
+
+ ) : (
+
+ )}
+
);
}
diff --git a/packages/react/src/services/reports.service.ts b/packages/react/src/services/reports.service.ts
index 47d346f37..2321a8747 100644
--- a/packages/react/src/services/reports.service.ts
+++ b/packages/react/src/services/reports.service.ts
@@ -1,63 +1,76 @@
+import { useClient } from "@/hooks/useClient";
import { HTTPError } from "@/lib/fetch";
import { UseMutationOptions, useMutation } from "@tanstack/react-query";
-type ReportOptions =
- | {
- type: "video";
- videoId: string;
- }
- | {
- type: "channel";
- videoId?: never;
- }
- | {
- type: "contact";
- videoId?: never;
- };
-
-export function useReportMutation(
- { type, videoId }: ReportOptions,
- options?: UseMutationOptions<
- unknown,
- HTTPError,
- HolodexReportBody
- >,
+// Video report types
+export type VideoReportReason =
+ | "Incorrect video topic"
+ | "Incorrect channel mentions"
+ | "This video does not belong to the org"
+ | "Low Quality/Misleading Content"
+ | "Violates the org's derivative work guidelines or inappropriate"
+ | "Other";
+
+export interface VideoReportEmbed {
+ fields: {
+ name: string;
+ value: string;
+ }[];
+}
+
+// Channel report types
+export interface ChannelReportEmbed {
+ fields: { name: string; value: string }[];
+}
+
+export interface ChannelReportBody {
+ embeds: ChannelReportEmbed[];
+}
+
+// Contact report types
+export interface ContactReportField {
+ name: string;
+ value: string;
+}
+
+export type ContactReportBody = ContactReportField[];
+
+// Function to create a video report mutation
+export function useVideoReportMutation(
+ videoId: string,
+ options?: UseMutationOptions,
+) {
+ const client = useClient();
+ return useMutation({
+ mutationFn: async (body) => {
+ return await client.post(`/api/v2/reports/video/${videoId}`, body);
+ },
+ ...options,
+ });
+}
+
+// Function to create a channel report mutation
+export function useChannelReportMutation(
+ options?: UseMutationOptions,
+) {
+ const client = useClient();
+ return useMutation({
+ mutationFn: async (body) => {
+ return client.post("/api/v2/reports/channel", body);
+ },
+ ...options,
+ });
+}
+
+// Function to create a contact report mutation
+export function useContactReportMutation(
+ options?: UseMutationOptions,
) {
- let endpoint: string;
-
- switch (type) {
- case "video":
- endpoint = `/api/v2/reports/video/${videoId}`;
- break;
-
- case "channel":
- endpoint = "/api/v2/reports/channel";
- break;
-
- case "contact":
- endpoint = "/api/v2/reports/contact";
- break;
-
- default:
- throw new Error("Report type is not specified");
- }
-
- return useMutation>({
- mutationFn: async (body) =>
- // use plain fetch cuz response is not json
- {
- const res = await fetch(endpoint, {
- method: "POST",
- body: JSON.stringify(body),
- });
- if (!res.ok)
- return Promise.reject({
- data: await res.text(),
- statusText: res.statusText,
- statusCode: res.status,
- response: res,
- });
- },
+ const client = useClient();
+ return useMutation({
+ mutationFn: async (body) => {
+ return await client.post("/api/v2/reports/contact", body);
+ },
...options,
});
}
diff --git a/packages/react/src/shadcn/ui/dialog-drawer.tsx b/packages/react/src/shadcn/ui/dialog-drawer.tsx
new file mode 100644
index 000000000..c2be09fa0
--- /dev/null
+++ b/packages/react/src/shadcn/ui/dialog-drawer.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "./dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "./drawer"
+import { useAtomValue } from "jotai"
+import { isMobileAtom } from "@/hooks/useFrame"
+
+interface BaseProps {
+ children: React.ReactNode
+}
+
+interface RootCredenzaProps extends BaseProps {
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}
+
+interface CredenzaProps extends BaseProps {
+ className?: string
+ asChild?: true
+}
+
+const Credenza = ({ children, ...props }: RootCredenzaProps) => {
+ const isDesktop = !useAtomValue(isMobileAtom)
+ const Credenza = isDesktop ? Dialog : Drawer
+
+ return {children}
+}
+
+const CredenzaTrigger = ({ className, children, ...props }: CredenzaProps) => {
+ const isDesktop = !useAtomValue(isMobileAtom)
+ const CredenzaTrigger = isDesktop ? DialogTrigger : DrawerTrigger
+
+ return (
+
+ {children}
+
+ )
+}
+
+const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
+ const isDesktop = !useAtomValue(isMobileAtom)
+ const CredenzaClose = isDesktop ? DialogClose : DrawerClose
+
+ return (
+
+ {children}
+
+ )
+}
+
+const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
+ const isDesktop = !useAtomValue(isMobileAtom)
+ const CredenzaContent = isDesktop ? DialogContent : DrawerContent
+
+ return (
+
+ {children}
+
+ )
+}
+
+const CredenzaDescription = ({
+ className,
+ children,
+ ...props
+}: CredenzaProps) => {
+ const isDesktop = !useAtomValue(isMobileAtom)
+ const CredenzaDescription = isDesktop ? DialogDescription : DrawerDescription
+
+ return (
+
+ {children}
+
+ )
+}
+
+const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => {
+ const isDesktop = !useAtomValue(isMobileAtom)
+ const CredenzaHeader = isDesktop ? DialogHeader : DrawerHeader
+
+ return (
+
+ {children}
+
+ )
+}
+
+const CredenzaTitle = ({ className, children, ...props }: CredenzaProps) => {
+ const isDesktop = !useAtomValue(isMobileAtom)
+ const CredenzaTitle = isDesktop ? DialogTitle : DrawerTitle
+
+ return (
+
+ {children}
+
+ )
+}
+
+const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
+ return (
+
+ {children}
+
+ )
+}
+
+const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
+ const isDesktop = !useAtomValue(isMobileAtom)
+ const CredenzaFooter = isDesktop ? DialogFooter : DrawerFooter
+
+ return (
+
+ {children}
+
+ )
+}
+
+export {
+ Credenza,
+ CredenzaTrigger,
+ CredenzaClose,
+ CredenzaContent,
+ CredenzaDescription,
+ CredenzaHeader,
+ CredenzaTitle,
+ CredenzaBody,
+ CredenzaFooter,
+}
diff --git a/packages/react/src/shadcn/ui/dialog.tsx b/packages/react/src/shadcn/ui/dialog.tsx
index d94bf1f47..d0c8a3ff4 100644
--- a/packages/react/src/shadcn/ui/dialog.tsx
+++ b/packages/react/src/shadcn/ui/dialog.tsx
@@ -11,6 +11,29 @@ const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
+const DialogClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children ? (
+ children
+ ) : (
+ <>
+
+ Close
+ >
+ )}
+
+))
+
const DialogOverlay = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
@@ -39,10 +62,6 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
-
-
- Close
-
))
@@ -113,4 +132,5 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
+ DialogClose,
}
diff --git a/packages/react/src/store/video.ts b/packages/react/src/store/video.ts
index 58aca368a..0245960ba 100644
--- a/packages/react/src/store/video.ts
+++ b/packages/react/src/store/video.ts
@@ -1,4 +1,5 @@
import { GET_ON_INIT } from "@/lib/consts";
+import { atom } from "jotai";
import { useAtom } from "jotai/react";
import { atomWithStorage } from "jotai/utils";
@@ -26,3 +27,6 @@ export function useVideoCardSizes(allowedCardSizes: VideoCardSize[]) {
return { size, setSize, nextSize, setNextSize };
}
+
+// also acts as a Report is open indicator.
+export const videoReportAtom = atom