mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
v2.4.2
v2.4.2
This commit is contained in:
+7
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-gdrive-index",
|
||||
"version": "2.0.4",
|
||||
"version": "2.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
@@ -10,7 +10,8 @@
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json}\" --cache",
|
||||
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json}\" --cache"
|
||||
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json}\" --cache",
|
||||
"cli": "node ./scripts/cli.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
@@ -88,12 +89,16 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"chalk": "^5.4.1",
|
||||
"commander": "^13.1.0",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15.0.1",
|
||||
"ora": "^8.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "3.0.0",
|
||||
"prettier-plugin-tailwindcss": "0.5.12",
|
||||
"prompts": "^2.4.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { encryptionService } from "~/lib/utils.server";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
throw new Error("This route is only available in development environment");
|
||||
}
|
||||
|
||||
const rootId = await encryptionService.decrypt(config.apiConfig.rootFolder);
|
||||
const sharedDriveId = config.apiConfig.sharedDrive
|
||||
? await encryptionService.decrypt(config.apiConfig.sharedDrive)
|
||||
: undefined;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
rootId,
|
||||
sharedDriveId,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
return new Response(e.message, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import stream from "stream";
|
||||
import { promisify } from "util";
|
||||
|
||||
import { gdrive } from "~/lib/utils.server";
|
||||
|
||||
const pipeline = promisify(stream.pipeline);
|
||||
const fileId = "1KvMU9sy8fQKKmVKU5xf_u3Y2JZefe0kt";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const start = Date.now();
|
||||
console.log("Fetching metadata", Date.now() - start);
|
||||
const meta = await gdrive.files.get({
|
||||
fileId,
|
||||
fields: "size,mimeType,name",
|
||||
supportsAllDrives: true,
|
||||
});
|
||||
const { data: metadata } = meta;
|
||||
|
||||
console.log("Fetching file", Date.now() - start);
|
||||
// const data = await gdrive.files.get(
|
||||
// {
|
||||
// fileId: fileId,
|
||||
// alt: "media",
|
||||
// acknowledgeAbuse: true,
|
||||
// },
|
||||
// {
|
||||
// responseType: "stream",
|
||||
// },
|
||||
// );
|
||||
|
||||
console.log("Setting headers", Date.now() - start);
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", metadata.mimeType ?? "application/octet-stream");
|
||||
if (metadata.size) headers.set("Content-Length", metadata.size.toString());
|
||||
headers.set("Content-Disposition", `attachment; filename="${metadata.name}"`);
|
||||
|
||||
// console.log("Streaming file", Date.now() - start);
|
||||
// const contentStream = data.data;
|
||||
// const webStream = new ReadableStream({
|
||||
// async start(controller) {
|
||||
// contentStream.on("data", (chunk) => {
|
||||
// controller.enqueue(chunk);
|
||||
// });
|
||||
// contentStream.on("end", () => {
|
||||
// controller.close();
|
||||
// });
|
||||
// contentStream.on("error", (err) => {
|
||||
// console.error(err);
|
||||
// controller.error(err);
|
||||
// });
|
||||
// },
|
||||
// cancel() {
|
||||
// contentStream.destroy();
|
||||
// },
|
||||
// });
|
||||
|
||||
// console.log("Returning response", Date.now() - start);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
gdrive.files.get(
|
||||
{
|
||||
fileId: fileId,
|
||||
alt: "media",
|
||||
acknowledgeAbuse: true,
|
||||
supportsAllDrives: true,
|
||||
},
|
||||
{
|
||||
responseType: "stream",
|
||||
},
|
||||
(err, res) => {
|
||||
if (err) throw err;
|
||||
res?.data
|
||||
.on("data", (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
})
|
||||
.on("end", () => {
|
||||
controller.close();
|
||||
})
|
||||
.on("error", (err) => {
|
||||
console.error(err);
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: e.message,
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -906,6 +906,7 @@ function FooterField({ form, onResetField }: FormProps) {
|
||||
onClick={() => {
|
||||
setContent(formatFooterContent(form.watch("site.footer"), form.getValues("site")));
|
||||
}}
|
||||
type='button'
|
||||
>
|
||||
Reload Preview
|
||||
</Button>
|
||||
|
||||
@@ -256,7 +256,7 @@ export default function Navbar() {
|
||||
className='mx-2 my-auto h-6'
|
||||
/>
|
||||
|
||||
{!!config.showGuideButton && (
|
||||
{!!config.showGuideButton ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -291,6 +291,8 @@ export default function Navbar() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Button
|
||||
|
||||
@@ -21,6 +21,7 @@ import "@vidstack/react/player/styles/default/layouts/audio.css";
|
||||
import "@vidstack/react/player/styles/default/layouts/video.css";
|
||||
import "@vidstack/react/player/styles/default/theme.css";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { type z } from "zod";
|
||||
|
||||
@@ -28,7 +29,8 @@ import { PageLoader } from "~/components/layout";
|
||||
import Icon from "~/components/ui/icon";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { MediaPlayerIcons } from "~/lib/previewHelper";
|
||||
import { MediaPlayerIcons, getPreviewIcon } from "~/lib/previewHelper";
|
||||
import { cn, formatDate } from "~/lib/utils";
|
||||
|
||||
import { type Schema_File } from "~/types/schema";
|
||||
|
||||
@@ -37,8 +39,9 @@ import "~/styles/vidstack.css";
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
type: "video" | "audio";
|
||||
playlist: z.infer<typeof Schema_File>[];
|
||||
};
|
||||
export default function PreviewMedia({ file, type }: Props) {
|
||||
export default function PreviewMedia({ file, type, playlist }: Props) {
|
||||
const player = useRef<MediaPlayerInstance>(null);
|
||||
const [canPlay, setCanPlay] = useState<boolean>(false);
|
||||
|
||||
@@ -85,7 +88,7 @@ export default function PreviewMedia({ file, type }: Props) {
|
||||
seekBackwardButton: null,
|
||||
seekForwardButton: null,
|
||||
playButton: (
|
||||
<div className='flex w-full items-center justify-center gap-2 md:w-fit'>
|
||||
<div className='flex w-full items-center justify-center gap-1 md:w-fit'>
|
||||
<SeekButton
|
||||
className='vds-button'
|
||||
seconds={-10}
|
||||
@@ -93,7 +96,7 @@ export default function PreviewMedia({ file, type }: Props) {
|
||||
<MediaPlayerIcons.SeekButton.Backward />
|
||||
</SeekButton>
|
||||
<PlayButton
|
||||
className='vds-button vds-play-button'
|
||||
className='vds-button vds-play-button aspect-square'
|
||||
disabled={canPlay === false}
|
||||
>
|
||||
{canPlay ? (
|
||||
@@ -144,6 +147,12 @@ export default function PreviewMedia({ file, type }: Props) {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center'>
|
||||
<PlaylistMenu
|
||||
playlist={playlist}
|
||||
file={file}
|
||||
placement={"bottom end"}
|
||||
/>
|
||||
|
||||
<Menu.Root className='vds-menu'>
|
||||
<Menu.Button
|
||||
className='vds-menu-button vds-button'
|
||||
@@ -216,7 +225,7 @@ export default function PreviewMedia({ file, type }: Props) {
|
||||
icons={MediaPlayerIcons}
|
||||
colorScheme='default'
|
||||
smallLayoutWhen={smallVideoLayoutQuery}
|
||||
showTooltipDelay={150}
|
||||
showTooltipDelay={200}
|
||||
slots={{
|
||||
currentTime: (
|
||||
<Time
|
||||
@@ -230,69 +239,77 @@ export default function PreviewMedia({ file, type }: Props) {
|
||||
type='duration'
|
||||
/>
|
||||
),
|
||||
beforeSettingsMenu: (
|
||||
<PlaylistMenu
|
||||
playlist={playlist}
|
||||
file={file}
|
||||
/>
|
||||
),
|
||||
settingsMenu: (
|
||||
<Menu.Root className='vds-menu'>
|
||||
<Menu.Button
|
||||
className='vds-menu-button vds-button'
|
||||
aria-label='Settings'
|
||||
>
|
||||
<MediaPlayerIcons.Menu.Settings />
|
||||
</Menu.Button>
|
||||
<Menu.Items
|
||||
className='vds-menu-items'
|
||||
placement={"top"}
|
||||
offset={0}
|
||||
>
|
||||
{/* Loop */}
|
||||
<Menu.Root>
|
||||
<Menu.Button className='vds-menu-item'>
|
||||
<ChevronLeft className='vds-menu-close-icon' />
|
||||
<Icon
|
||||
name='Repeat'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-menu-item-label'>Loop</span>
|
||||
<span className='vds-menu-item-hint'>{isLoop ? "On" : "Off"}</span>
|
||||
<ChevronRight className='vds-menu-open-icon' />
|
||||
</Menu.Button>
|
||||
<Menu.Content className='vds-menu-items'>
|
||||
<Menu.RadioGroup
|
||||
className='vds-radio-group'
|
||||
value={String(isLoop)}
|
||||
>
|
||||
<Menu.Radio
|
||||
className='vds-radio'
|
||||
value='true'
|
||||
onSelect={() => setIsLoop(true)}
|
||||
<>
|
||||
<Menu.Root className='vds-menu'>
|
||||
<Menu.Button
|
||||
className='vds-menu-button vds-button'
|
||||
aria-label='Settings'
|
||||
>
|
||||
<MediaPlayerIcons.Menu.Settings />
|
||||
</Menu.Button>
|
||||
<Menu.Items
|
||||
className='vds-menu-items'
|
||||
placement={"top"}
|
||||
offset={0}
|
||||
>
|
||||
{/* Loop */}
|
||||
<Menu.Root>
|
||||
<Menu.Button className='vds-menu-item'>
|
||||
<ChevronLeft className='vds-menu-close-icon' />
|
||||
<Icon
|
||||
name='Repeat'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-menu-item-label'>Loop</span>
|
||||
<span className='vds-menu-item-hint'>{isLoop ? "On" : "Off"}</span>
|
||||
<ChevronRight className='vds-menu-open-icon' />
|
||||
</Menu.Button>
|
||||
<Menu.Content className='vds-menu-items'>
|
||||
<Menu.RadioGroup
|
||||
className='vds-radio-group'
|
||||
value={String(isLoop)}
|
||||
>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-radio-label'>On</span>
|
||||
</Menu.Radio>
|
||||
<Menu.Radio
|
||||
className='vds-radio'
|
||||
value='false'
|
||||
onSelect={() => setIsLoop(false)}
|
||||
>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-radio-label'>Off</span>
|
||||
</Menu.Radio>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
<Menu.Radio
|
||||
className='vds-radio'
|
||||
value='true'
|
||||
onSelect={() => setIsLoop(true)}
|
||||
>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-radio-label'>On</span>
|
||||
</Menu.Radio>
|
||||
<Menu.Radio
|
||||
className='vds-radio'
|
||||
value='false'
|
||||
onSelect={() => setIsLoop(false)}
|
||||
>
|
||||
<Icon
|
||||
name='Check'
|
||||
className='vds-icon'
|
||||
/>
|
||||
<span className='vds-radio-label'>Off</span>
|
||||
</Menu.Radio>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
|
||||
{/* Playback Speed */}
|
||||
<PlaybackMenu />
|
||||
{/* Playback Speed */}
|
||||
<PlaybackMenu />
|
||||
|
||||
{/* Audio Gain */}
|
||||
<AudioGain />
|
||||
</Menu.Items>
|
||||
</Menu.Root>
|
||||
{/* Audio Gain */}
|
||||
<AudioGain />
|
||||
</Menu.Items>
|
||||
</Menu.Root>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -392,3 +409,87 @@ function AudioGain() {
|
||||
</Menu.Root>
|
||||
);
|
||||
}
|
||||
function PlaylistMenu({
|
||||
playlist,
|
||||
file,
|
||||
placement = "top end",
|
||||
}: {
|
||||
playlist: z.infer<typeof Schema_File>[];
|
||||
file: z.infer<typeof Schema_File>;
|
||||
placement?: Menu.ItemsProps["placement"];
|
||||
}) {
|
||||
return (
|
||||
<Menu.Root className='vds-menu'>
|
||||
<Menu.Button
|
||||
className='vds-menu-button vds-button'
|
||||
aria-label='Playlist'
|
||||
>
|
||||
<MediaPlayerIcons.Menu.Chapters />
|
||||
</Menu.Button>
|
||||
<Menu.Items
|
||||
className='vds-menu-items group space-y-1'
|
||||
placement={placement}
|
||||
>
|
||||
{playlist.map((item) => {
|
||||
const isCurrent = `${item.name}#${item.size}` === `${file.name}#${file.size}`;
|
||||
|
||||
const Wrapper = ({ children, className }: React.PropsWithChildren<{ className?: string }>) =>
|
||||
isCurrent ? (
|
||||
<div
|
||||
className={className}
|
||||
title={item.name}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={`${item.name}`}
|
||||
className={className}
|
||||
title={item.name}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
key={`playlist-${item.encryptedId}`}
|
||||
className={cn(
|
||||
"flex w-full max-w-96 grid-cols-4 place-items-center gap-2 rounded-lg",
|
||||
isCurrent ? "bg-primary/10" : "hover:bg-primary/5",
|
||||
)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
className={cn(
|
||||
"aspect-video h-16 grow-0 overflow-hidden rounded-lg bg-black",
|
||||
isCurrent && "border border-primary",
|
||||
)}
|
||||
>
|
||||
{item.mimeType.includes("video") ? (
|
||||
<img
|
||||
src={`/api/thumb/${item.encryptedId}`}
|
||||
alt={`Thumbnail for ${item.name}`}
|
||||
className='aspect-video w-full object-contain'
|
||||
/>
|
||||
) : (
|
||||
<div className={"grid aspect-video w-full place-items-center"}>
|
||||
<Icon
|
||||
name={getPreviewIcon(item.fileExtension ?? "", item.mimeType)}
|
||||
className={"size-6"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={"col-span-3 w-full flex-1 p-2"}>
|
||||
<p className={"line-clamp-1 break-all"}>{item.name}</p>
|
||||
<span className={"text-sm text-muted-foreground"}>{formatDate(item.modifiedTime)}</span>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
})}
|
||||
</Menu.Items>
|
||||
</Menu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
PreviewUnknown,
|
||||
} from "~/components/preview";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
import { type getFileType } from "~/lib/previewHelper";
|
||||
import { cn } from "~/lib/utils";
|
||||
@@ -23,8 +23,6 @@ import { type Schema_File } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
import MediaPlaylistLayout from "./MediaPlaylistLayout";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
fileType: "unknown" | ReturnType<typeof getFileType>;
|
||||
@@ -32,7 +30,7 @@ type Props = {
|
||||
playlist: z.infer<typeof Schema_File>[];
|
||||
paths: string[];
|
||||
};
|
||||
export default function PreviewLayout({ data, paths, fileType, token, playlist }: Props) {
|
||||
export default function PreviewLayout({ data, fileType, token, playlist }: Props) {
|
||||
const [view, setView] = useState<"markdown" | "raw">("markdown");
|
||||
const PreviewComponent = useMemo(() => {
|
||||
switch (fileType) {
|
||||
@@ -44,6 +42,7 @@ export default function PreviewLayout({ data, paths, fileType, token, playlist }
|
||||
<PreviewMedia
|
||||
file={data}
|
||||
type={fileType}
|
||||
playlist={playlist}
|
||||
/>
|
||||
);
|
||||
case "code":
|
||||
@@ -82,7 +81,7 @@ export default function PreviewLayout({ data, paths, fileType, token, playlist }
|
||||
default:
|
||||
return <PreviewUnknown />;
|
||||
}
|
||||
}, [fileType, data, view, token]);
|
||||
}, [fileType, data, view, token, playlist]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -134,14 +133,14 @@ export default function PreviewLayout({ data, paths, fileType, token, playlist }
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
{/* <CardFooter>
|
||||
<MediaPlaylistLayout
|
||||
type={"inside"}
|
||||
paths={paths}
|
||||
currentItem={data}
|
||||
playlist={playlist}
|
||||
/>
|
||||
</CardFooter>
|
||||
</CardFooter> */}
|
||||
</Card>
|
||||
|
||||
<PreviewInformation
|
||||
|
||||
@@ -8,7 +8,7 @@ const config: z.input<typeof Schema_Config> = {
|
||||
* If possible, please don't change this value
|
||||
* Even if you're creating a PR, just let me change it myself
|
||||
*/
|
||||
version: "2.0.4",
|
||||
version: "2.4.2",
|
||||
/**
|
||||
* Base path of the app, used for generating links
|
||||
*
|
||||
|
||||
@@ -13,7 +13,7 @@ import config from "~/config/gIndex.config";
|
||||
export const versionExpectMap: Record<"v1" | "v2" | "latest", string[]> = {
|
||||
v1: ["1.0.0", "1.0.1", "1.0.2", "1.0.3"],
|
||||
v2: ["2.0.0", "2.0.1", "2.0.2", "2.0.3"],
|
||||
latest: ["2.0.4"],
|
||||
latest: ["2.0.4", "2.4.0", "2.4.1", "2.4.2"],
|
||||
};
|
||||
export type PickFileResponse =
|
||||
| {
|
||||
@@ -166,7 +166,7 @@ export const initialConfiguration: z.input<typeof Schema_App_Configuration> = {
|
||||
};
|
||||
|
||||
export const configurationTemplate = `import { type z } from "zod";
|
||||
import { BASE_URL } from "~/constant";
|
||||
import { BASE_URL, IS_DEV } from "~/constant";
|
||||
|
||||
import { type Schema_Config } from "~/types/schema";
|
||||
|
||||
@@ -184,7 +184,7 @@ const config: z.input<typeof Schema_Config> = {
|
||||
* @default process.env.NEXT_PUBLIC_DOMAIN
|
||||
* @fallback process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
*/
|
||||
basePath: \`https://\${BASE_URL}\`,
|
||||
basePath: IS_DEV ? "http://localhost:3000" : \`https://\${BASE_URL}\`,
|
||||
|
||||
/**
|
||||
* Show deploy guide dropdown on navbar
|
||||
|
||||
@@ -346,7 +346,7 @@ export const MediaPlayerIcons = {
|
||||
Chapters: () => (
|
||||
<Icon
|
||||
hideWrapper
|
||||
name='TableOfContents'
|
||||
name='List'
|
||||
className='vds-icon size-5'
|
||||
/>
|
||||
),
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ const Schema_Config_API = z
|
||||
|
||||
allowDownloadProtectedFile: z.coerce.boolean(),
|
||||
temporaryTokenDuration: z.coerce.number().positive(),
|
||||
maxFileSize: z.coerce.number().positive(),
|
||||
maxFileSize: z.coerce.number(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
|
||||
Reference in New Issue
Block a user