mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
Clean up and redo Config & Theme
This commit is contained in:
@@ -1,70 +0,0 @@
|
||||
import { IGDriveFiles } from "types/api/files";
|
||||
import { APIFilesResponse } from "types/api/response";
|
||||
|
||||
const mockFiles: APIFilesResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: 0,
|
||||
folders: [],
|
||||
files: [],
|
||||
nextPageToken: "nextpageToken",
|
||||
readmeExists: true,
|
||||
bannerExists: true,
|
||||
passwordExists: false,
|
||||
};
|
||||
const mockFilesProtected: APIFilesResponse = {
|
||||
timestamp: Date.now(),
|
||||
responseTime: 0,
|
||||
folders: [],
|
||||
files: [],
|
||||
nextPageToken: "nextpageToken",
|
||||
readmeExists: true,
|
||||
bannerExists: true,
|
||||
passwordExists: true,
|
||||
};
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const folder: IGDriveFiles = {
|
||||
mimeType: "application/vnd.google-apps.folder",
|
||||
encryptedId: `encryptedId${i + 1}`,
|
||||
name: `folder${i + 1}`,
|
||||
trashed: false,
|
||||
modifiedTime: new Date().toISOString(),
|
||||
};
|
||||
const file: IGDriveFiles = {
|
||||
mimeType: "application/vnd.google-apps.file",
|
||||
encryptedId: `encryptedId${i + 1}`,
|
||||
name: `file${i + 1}`,
|
||||
trashed: false,
|
||||
modifiedTime: new Date().toISOString(),
|
||||
fileExtension: "txt",
|
||||
encryptedWebContentLink: "encryptedWebContentLink",
|
||||
size: Math.floor(Math.random() * 1000000000),
|
||||
thumbnailLink: "/og.png",
|
||||
imageMediaMetadata:
|
||||
Math.random() > 0.5
|
||||
? {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rotation: 0,
|
||||
}
|
||||
: null,
|
||||
videoMediaMetadata:
|
||||
Math.random() > 0.5
|
||||
? {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
durationMillis: 100000,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
mockFiles.folders.push(folder);
|
||||
mockFiles.files.push(file);
|
||||
|
||||
folder.name = `folder${i + 1}protected`;
|
||||
file.name = `file${i + 1}protected`;
|
||||
mockFilesProtected.folders.push(folder);
|
||||
mockFilesProtected.files.push(file);
|
||||
}
|
||||
|
||||
export { mockFiles, mockFilesProtected };
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
Before Width: | Height: | Size: 629 B |
@@ -1,189 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import FileGrid from "./@file.grid";
|
||||
import FileList from "./@file.list";
|
||||
import { GetFiles } from "./actions";
|
||||
|
||||
type Props = {
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
root?: boolean;
|
||||
};
|
||||
export default function FileBrowser({ files, nextPageToken, root }: Props) {
|
||||
const { layout } = useContext(LayoutContext);
|
||||
const pathname = usePathname();
|
||||
const prevPath = useMemo<string>(() => {
|
||||
const path = pathname
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.join("/")
|
||||
.replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [pathname]);
|
||||
|
||||
const [fileList, setFileList] =
|
||||
useState<z.infer<typeof Schema_File>[]>(files);
|
||||
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
|
||||
const [loadMoreLoading, setLoadMoreLoading] = useState<boolean>(false);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const onLoadMore = async () => {
|
||||
setLoadMoreLoading(true);
|
||||
try {
|
||||
if (!nextToken) throw new Error("No more files to load");
|
||||
const data = await GetFiles({ pageToken: nextToken });
|
||||
const uniqueData = [...fileList, ...data.files].filter(
|
||||
(item, index, array) =>
|
||||
index === array.findIndex((i) => i.encryptedId === item.encryptedId),
|
||||
);
|
||||
setFileList(uniqueData);
|
||||
// const uniqueData = new Set([...fileList, ...data.files]);
|
||||
// setFileList([...uniqueData]);
|
||||
setNextToken(data.nextPageToken);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoadMoreLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-[50dvh] w-full max-w-screen-desktop",
|
||||
"rounded-[var(--radius)] bg-card",
|
||||
"gap-3 p-3",
|
||||
"flex-grow-0",
|
||||
"flex flex-col items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin'
|
||||
/>
|
||||
<p>Wait a moment while we load your files...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-screen-desktop",
|
||||
"gap-3 p-3",
|
||||
"flex-grow-0",
|
||||
"flex flex-col",
|
||||
)}
|
||||
>
|
||||
{!root && (
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
className='justify-start'
|
||||
asChild
|
||||
>
|
||||
<Link href={prevPath}>
|
||||
<Icon
|
||||
name='CornerLeftUp'
|
||||
size={14}
|
||||
className='mr-3'
|
||||
/>
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{!fileList.length && (
|
||||
<div className='col-span-full flex h-full min-h-[25vh] flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
There are no files in this folder
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{layout === "list" && (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
{fileList.map((file) => (
|
||||
<div
|
||||
key={file.encryptedId}
|
||||
className='group'
|
||||
>
|
||||
<FileList
|
||||
key={file.encryptedId}
|
||||
data={file}
|
||||
/>
|
||||
<Separator className='mt-1.5 group-last:hidden' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{layout === "grid" && (
|
||||
<div className='grid grid-cols-1 gap-3 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4'>
|
||||
{fileList.map((file) => (
|
||||
<div
|
||||
key={file.encryptedId}
|
||||
className='group'
|
||||
>
|
||||
<FileGrid
|
||||
key={file.encryptedId}
|
||||
data={file}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextToken && (
|
||||
<Button
|
||||
className='col-span-full gap-3'
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
disabled={loadMoreLoading}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
{loadMoreLoading ? (
|
||||
<>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={16}
|
||||
className='animate-spin'
|
||||
/>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>Load More</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export default function FileGrid({ data, disabled }: Props) {
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
// const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`;
|
||||
// Set to pathname to remove the /e prefix
|
||||
const path = [pathname, encodeURIComponent(data.name)]
|
||||
.join("/")
|
||||
.replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(
|
||||
`/api/thumb/${data.encryptedId}?size=2`,
|
||||
);
|
||||
const [actionOpen, setActionOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.promise(
|
||||
navigator.clipboard.writeText(
|
||||
new URL(filePath, config.basePath).toString(),
|
||||
),
|
||||
{
|
||||
loading: "Copying link...",
|
||||
success: "Link copied!",
|
||||
error: "Failed to copy link",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${data.encryptedId}?token=${token}`);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative h-full w-full'>
|
||||
{isDesktop ? (
|
||||
<DropdownMenu
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"secondary"}
|
||||
className='absolute right-1 top-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
onClick={onCopy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</DropdownMenuItem>
|
||||
{data.mimeType.includes("folder") ? null : (
|
||||
<DropdownMenuItem
|
||||
onClick={onDownload}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Drawer
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"secondary"}
|
||||
className='absolute right-1 top-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-left'>
|
||||
<DrawerTitle>Actions</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
What would you like to do with this file?
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='grid gap-1.5 px-4'>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
onClick={onCopy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
{data.mimeType.includes("folder") ? null : (
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
onClick={onDownload}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
<Link
|
||||
onClick={async (e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
nProgress.done(true);
|
||||
}
|
||||
}}
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
"rounded-[var(--radius)]",
|
||||
"flex flex-col content-stretch justify-stretch gap-3",
|
||||
"hover:bg-muted/50",
|
||||
"transition",
|
||||
"border border-border",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink flex-grow",
|
||||
"flex flex-col items-center gap-3",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative grid h-32 w-full flex-shrink-0 place-items-center overflow-hidden bg-muted/25'>
|
||||
{data.thumbnailLink &&
|
||||
(data.mimeType.startsWith("video") ||
|
||||
data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='rounded-top-[var(--radius)] absolute -z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-cover opacity-50'
|
||||
/>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='relative z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-contain backdrop-blur'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={32}
|
||||
/>
|
||||
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-xs text-foreground'>
|
||||
{durationToReadable(
|
||||
data.videoMediaMetadata?.durationMillis || 0,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder")
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex h-full w-full flex-col justify-between gap-1.5 px-3 py-1.5'>
|
||||
<span className='line-clamp-2 h-full whitespace-pre-wrap text-pretty break-all'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 whitespace-pre-wrap break-all text-sm",
|
||||
config.siteConfig.showFileExtension &&
|
||||
!data.mimeType.includes("folder")
|
||||
? "hidden"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{data.mimeType.includes("folder")
|
||||
? "folder"
|
||||
: data.fileExtension || "unknown"}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
{config.siteConfig.showFileExtension ? null : (
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
)}
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{bytesToReadable(data.size || 0)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{new Date(data.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export default function FileList({ data, disabled }: Props) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const filePath = useMemo<string>(() => {
|
||||
const path = [pathname, encodeURIComponent(data.name)]
|
||||
.join("/")
|
||||
.replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(
|
||||
`/api/thumb/${data.encryptedId}?size=2`,
|
||||
);
|
||||
const [actionOpen, setActionOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
toast.promise(
|
||||
navigator.clipboard.writeText(
|
||||
new URL(filePath, config.basePath).toString(),
|
||||
),
|
||||
{
|
||||
loading: "Copying link...",
|
||||
success: "Link copied!",
|
||||
error: "Failed to copy link",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${data.encryptedId}?token=${token}`);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full w-full flex-row-reverse items-center'>
|
||||
<div className='absolute bottom-auto right-0 top-auto grid flex-grow place-items-center'>
|
||||
{isDesktop ? (
|
||||
<DropdownMenu
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"ghost"}
|
||||
// className='size-8'
|
||||
className='absolute right-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
onClick={onCopy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</DropdownMenuItem>
|
||||
{data.mimeType.includes("folder") ? null : (
|
||||
<DropdownMenuItem
|
||||
onClick={onDownload}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Drawer
|
||||
open={actionOpen}
|
||||
onOpenChange={setActionOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"ghost"}
|
||||
// className='size-8'
|
||||
className='z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-left'>
|
||||
<DrawerTitle>Actions</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
What would you like to do with this file?
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='grid gap-1.5 px-4'>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
onClick={onCopy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
{data.mimeType.includes("folder") ? null : (
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
onClick={onDownload}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
onClick={async (e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
nProgress.done(true);
|
||||
}
|
||||
}}
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"relative",
|
||||
// "w-full",
|
||||
"px-1.5 py-1 pr-10", // since action button is size-8
|
||||
"rounded-[var(--radius)]",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
"hover:bg-muted/50",
|
||||
"transition",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex-shrink flex-grow", "flex items-center gap-3")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative flex-shrink-0 rounded-[var(--radius)] bg-muted/25'>
|
||||
{data.thumbnailLink &&
|
||||
(data.mimeType.startsWith("video") ||
|
||||
data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-20'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={24}
|
||||
/>
|
||||
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-[10px] text-foreground tablet:text-xs'>
|
||||
{durationToReadable(
|
||||
data.videoMediaMetadata?.durationMillis || 0,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder")
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex flex-grow flex-col'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 whitespace-pre-wrap break-all text-sm",
|
||||
config.siteConfig.showFileExtension &&
|
||||
!data.mimeType.includes("folder")
|
||||
? "hidden"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{data.mimeType.includes("folder")
|
||||
? "folder"
|
||||
: data.fileExtension || "unknown"}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
{config.siteConfig.showFileExtension ? null : (
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
)}
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{bytesToReadable(data.size || 0)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{new Date(data.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_Breadcrumb } from "~/schema";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/components/ui/breadcrumb";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_Breadcrumb>[];
|
||||
loading?: boolean;
|
||||
};
|
||||
export default function HeaderBreadcrumb({ data, loading }: Props) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
if (loading) return <Skeleton className='h-5 w-1/2' />;
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-nowrap items-center'>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={"/"}>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon
|
||||
name='FolderRoot'
|
||||
size={16}
|
||||
/>
|
||||
~
|
||||
</div>
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{!!data.length ? (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
|
||||
{data.length > config.siteConfig.breadcrumbMax ? (
|
||||
<>
|
||||
{isDesktop ? (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger>
|
||||
<BreadcrumbEllipsis className='w-4' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
{data
|
||||
.slice(0, -config.siteConfig.breadcrumbMax + 1)
|
||||
.map((item, _, array) => (
|
||||
<DropdownMenuItem
|
||||
key={`${item.label}/${item.href}`}
|
||||
>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`}
|
||||
className='w-full'
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DrawerTrigger>
|
||||
<BreadcrumbEllipsis className='w-4' />
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-left'>
|
||||
<DrawerTitle>
|
||||
Navigate to parent directories
|
||||
</DrawerTitle>
|
||||
<Separator />
|
||||
</DrawerHeader>
|
||||
<div className='grid gap-1.5 px-4'>
|
||||
{data
|
||||
.slice(0, -config.siteConfig.breadcrumbMax + 1)
|
||||
.map((item, _, array) => (
|
||||
<BreadcrumbItem
|
||||
key={`${item.label}/${item.href}`}
|
||||
>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`}
|
||||
className='w-full py-1.5'
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<BreadcrumbSeparator />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
|
||||
<Fragment key={`${item.label}/${item.href}`}>
|
||||
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.href ? (
|
||||
<>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${data
|
||||
.map((item) => item.href)
|
||||
.join("/")
|
||||
.replace(/\/\//g, "/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</>
|
||||
) : (
|
||||
<BreadcrumbPage className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.label}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{item.href && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
|
||||
export default function HeaderTitle({
|
||||
children,
|
||||
loading,
|
||||
}: PropsWithChildren<{ loading?: boolean }>) {
|
||||
if (loading) return <Skeleton className='my-0.5 h-8 w-full' />;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h2 className='line-clamp-1'>{children}</h2>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{children}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_Breadcrumb } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import HeaderBreadcrumb from "./@header.breadcrumb";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
breadcrumb?: z.infer<typeof Schema_Breadcrumb>[];
|
||||
};
|
||||
export default function Header({ name, breadcrumb }: Props) {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
"flex flex-col items-center justify-between gap-3",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
)}
|
||||
>
|
||||
{/* <HeaderTitle loading={loading}>{name}</HeaderTitle> */}
|
||||
|
||||
<HeaderBreadcrumb
|
||||
data={breadcrumb || []}
|
||||
loading={loading}
|
||||
/>
|
||||
{/* <HeaderButton loading={loading} /> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
export default function NotFoundComponent() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-md",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
The file you are looking for does not exist
|
||||
</span>
|
||||
|
||||
<div className='flex w-full flex-col items-center gap-3 pt-8'>
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Back to previous page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
// import "./r5-style.css";
|
||||
|
||||
const Plyr = dynamic(() => import("plyr-react"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading player...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewAudio({ file }: Props) {
|
||||
const [audioSrc, setAudioSrc] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No audio to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setAudioSrc(`/api/stream/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading player...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='w-full'>
|
||||
<Plyr
|
||||
source={{
|
||||
type: "audio",
|
||||
sources: [
|
||||
{
|
||||
src: audioSrc,
|
||||
type: file.mimeType,
|
||||
size: file.size,
|
||||
},
|
||||
],
|
||||
title: file.name,
|
||||
}}
|
||||
options={{
|
||||
controls: [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"duration",
|
||||
"mute",
|
||||
"volume",
|
||||
"settings",
|
||||
"fullscreen",
|
||||
],
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
loop: {
|
||||
active: false,
|
||||
},
|
||||
speed: {
|
||||
selected: 1,
|
||||
options: [0.5, 1, 1.5, 2],
|
||||
},
|
||||
keyboard: {
|
||||
focused: true,
|
||||
global: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewImage({ file }: Props) {
|
||||
const [imgSrc, setImgSrc] = useState<string>(
|
||||
`/api/thumb/${file.encryptedId}?size=4`,
|
||||
);
|
||||
const [imgLoaded, setImgLoaded] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No image to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
|
||||
const streamURL = new URL(
|
||||
`/api/thumb/${file.encryptedId}?size=1000`,
|
||||
config.basePath,
|
||||
);
|
||||
streamURL.searchParams.set("token", token);
|
||||
fetch(streamURL, {
|
||||
headers: {
|
||||
Range: `bytes=0-${(file.size || 1) - 1}`,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error("Could not load image");
|
||||
}
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const urlobject = URL.createObjectURL(blob);
|
||||
setImgSrc(urlobject);
|
||||
const timeout = setTimeout(() => {
|
||||
setImgLoaded(true);
|
||||
clearTimeout(timeout);
|
||||
}, 150); // Add a delay to show the image
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading image...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='h-fit w-full space-y-3 overflow-hidden rounded-[var(--radius)]'>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={file.name}
|
||||
className={cn(
|
||||
"h-full max-h-[70dvh] w-full bg-muted object-contain object-center transition",
|
||||
imgLoaded ? "blur-none" : "animate-pulse blur",
|
||||
)}
|
||||
onError={(e) => {
|
||||
console.error(e);
|
||||
setError(
|
||||
"Could not preview this image, try downloading the file",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
This image is a preview and may not be the full resolution.
|
||||
Please download the file for the full resolution.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import PreviewAction from "./@preview.action";
|
||||
import PreviewAudio from "./@preview.audio";
|
||||
import PreviewDoc from "./@preview.doc";
|
||||
import PreviewImage from "./@preview.image";
|
||||
import PreviewManga from "./@preview.manga";
|
||||
import PreviewRich from "./@preview.rich";
|
||||
import PreviewUnknown from "./@preview.unknown";
|
||||
import PreviewVideo from "./@preview.video";
|
||||
import RichHeader from "./@rich-header";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
fileType: "unknown" | ReturnType<typeof getFileType>;
|
||||
};
|
||||
export default function FilePreviewLayout({ data, fileType }: Props) {
|
||||
const [view, setView] = useState<"markdown" | "raw">("markdown");
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Card>
|
||||
<RichHeader
|
||||
title={data.name}
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
fileType={fileType}
|
||||
/>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
{config.apiConfig.streamMaxSize &&
|
||||
Number(data.size || 0) > config.apiConfig.streamMaxSize ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[33dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<h4 className='text-muted-foreground'>Preview not available</h4>
|
||||
<p className='text-center text-muted-foreground tablet:text-sm'>
|
||||
Looks like this file size exceed the preview size limit
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/**
|
||||
* TODO: Might need a better way to handle large files preview
|
||||
* like manga, pdf, etc
|
||||
*
|
||||
* For now it's downloading the whole file and then previewing it
|
||||
* which is not a good implementation
|
||||
*/
|
||||
<div className='px-3'>
|
||||
{fileType === "image" ? (
|
||||
<PreviewImage file={data} />
|
||||
) : fileType === "audio" ? (
|
||||
<PreviewAudio file={data} />
|
||||
) : fileType === "video" ? (
|
||||
<PreviewVideo file={data} />
|
||||
) : fileType === "code" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
code
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "text" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "markdown" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "document" ? (
|
||||
<PreviewDoc file={data} />
|
||||
) : fileType === "pdf" ? (
|
||||
<PreviewDoc file={data} />
|
||||
) : fileType === "manga" ? (
|
||||
<PreviewManga file={data} />
|
||||
) : (
|
||||
<PreviewUnknown />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PreviewAction file={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import Markdown from "./@markdown";
|
||||
import { GetContent } from "./actions";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
view: "markdown" | "raw";
|
||||
code?: boolean;
|
||||
};
|
||||
export default function PreviewRich({ file, code, view }: Props) {
|
||||
const [fetchedContent, setFetchedContent] = useState<string>("");
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [expand, setExpand] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const text = await GetContent(file.encryptedId);
|
||||
if (!text) {
|
||||
setError("Looks like there is no content to preview");
|
||||
return;
|
||||
}
|
||||
// setFetchedContent(text);
|
||||
// if (code) {
|
||||
// setContent(`\`\`\`${file.fileExtension}\n${text}\`\`\``);
|
||||
// } else {
|
||||
setContent(text.trim());
|
||||
// }
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file, code]);
|
||||
|
||||
return (
|
||||
<div className='flex h-fit min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading content...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full overflow-hidden",
|
||||
expand ? "h-full" : "max-h-[50dvh]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full overflow-hidden",
|
||||
expand
|
||||
? "[mask-image:linear-gradient(180deg,white_65%,white] h-full"
|
||||
: "h-[50dvh] max-h-[50dvh] [mask-image:linear-gradient(180deg,white_65%,rgba(255,255,255,0))]",
|
||||
)}
|
||||
>
|
||||
<Markdown
|
||||
content={
|
||||
code && view === "markdown"
|
||||
? `\`\`\`${file.fileExtension}\n${content}`
|
||||
: content
|
||||
}
|
||||
view={view}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full items-center justify-center py-3 transition",
|
||||
expand ? "relative" : "absolute",
|
||||
// expand
|
||||
// ? "pointer-events-none opacity-0"
|
||||
// : "pointer-events-auto opacity-100",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
className='gap-1.5'
|
||||
onClick={() => {
|
||||
setExpand((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={expand ? "ChevronUp" : "ChevronDown"}
|
||||
size={16}
|
||||
/>
|
||||
{expand ? "Collapse" : "Expand"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
export default function PreviewUnknown() {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[33dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading content...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[33dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<h4 className='text-muted-foreground'>Preview not available</h4>
|
||||
<p className='text-center text-muted-foreground tablet:text-sm'>
|
||||
This file type is not supported for preview, try downloading the
|
||||
file instead.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
|
||||
const Plyr = dynamic(() => import("plyr-react"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading player...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewVideo({ file }: Props) {
|
||||
const [videoSrc, setVideoSrc] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No video to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setVideoSrc(`/api/stream/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading video...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='h-full w-full'>
|
||||
<Plyr
|
||||
source={{
|
||||
type: "video",
|
||||
sources: [
|
||||
{
|
||||
src: videoSrc,
|
||||
type: file.mimeType,
|
||||
size: file.size,
|
||||
},
|
||||
],
|
||||
poster: `/api/thumb/${file.encryptedId}?size=1000`,
|
||||
}}
|
||||
// crossOrigin='anonymous'
|
||||
options={{
|
||||
toggleInvert: true,
|
||||
settings: ["quality", "speed"],
|
||||
ratio: "16:9",
|
||||
controls: [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"duration",
|
||||
"mute",
|
||||
"volume",
|
||||
"settings",
|
||||
"pip",
|
||||
"download",
|
||||
"fullscreen",
|
||||
],
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
loop: { active: false },
|
||||
speed: { selected: 1, options: [0.5, 1, 1.5, 2] },
|
||||
keyboard: {
|
||||
focused: true,
|
||||
global: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
|
||||
import Markdown from "./@markdown";
|
||||
import RichHeader from "./@rich-header";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
title: string;
|
||||
};
|
||||
export default function Readme({ content, title }: Props) {
|
||||
const [view, setView] = useState<"markdown" | "raw">("markdown");
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='readme'
|
||||
className='w-full'
|
||||
>
|
||||
<Card>
|
||||
<RichHeader
|
||||
title={title}
|
||||
view={view}
|
||||
onViewChange={setView}
|
||||
fileType={"markdown"}
|
||||
/>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<Markdown
|
||||
content={content}
|
||||
view={view}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { CardHeader } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
view: "markdown" | "raw";
|
||||
onViewChange: (value: "markdown" | "raw") => void;
|
||||
fileType: ReturnType<typeof getFileType> | "unknown";
|
||||
};
|
||||
export default function RichHeader({
|
||||
title,
|
||||
view,
|
||||
onViewChange,
|
||||
fileType,
|
||||
}: Props) {
|
||||
return (
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex flex-col gap-3 overflow-hidden mobile:flex-row mobile:items-center mobile:justify-between'>
|
||||
{/* <CardTitle> */}
|
||||
<h3 className='line-clamp-1 flex-grow whitespace-pre-wrap break-all'>
|
||||
{title}
|
||||
</h3>
|
||||
{/* </CardTitle> */}
|
||||
{["markdown", "code", "text"].includes(fileType) && (
|
||||
<div className='flex w-full items-center mobile:w-fit'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "markdown" ? "default" : "secondary"}
|
||||
onClick={() => onViewChange("markdown")}
|
||||
className='w-full rounded-r-none mobile:w-fit'
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "raw" ? "default" : "secondary"}
|
||||
onClick={() => onViewChange("raw")}
|
||||
className='w-full rounded-l-none mobile:w-fit'
|
||||
>
|
||||
Raw
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ConfigurationCategory,
|
||||
ConfigurationKeys,
|
||||
ConfigurationValue,
|
||||
Schema_App_Configuration,
|
||||
} from "~/schema";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import { parseConfigFile } from "~/utils/parseConfigFile";
|
||||
|
||||
import ConfigInput from "./@form.input-config";
|
||||
|
||||
type Props = {
|
||||
state: {
|
||||
get: z.input<typeof Schema_App_Configuration>;
|
||||
set: <
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
category: T,
|
||||
key: K,
|
||||
value: ConfigurationValue<T, K>,
|
||||
) => void;
|
||||
};
|
||||
error: {
|
||||
get: Partial<Record<ConfigurationKeys<"api">, string>>;
|
||||
set: <T extends ConfigurationKeys<"api">>(key: T, value: string) => void;
|
||||
};
|
||||
onReset: (category: ConfigurationCategory) => void;
|
||||
};
|
||||
export default function ApiConfig({
|
||||
state: { get, set },
|
||||
error,
|
||||
onReset,
|
||||
}: Props) {
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
onReset={(e) => {
|
||||
e.preventDefault();
|
||||
onReset("api");
|
||||
}}
|
||||
>
|
||||
<div
|
||||
slot='header'
|
||||
className='flex flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-between'
|
||||
>
|
||||
<h4>API</h4>
|
||||
<div className='flex w-full items-center gap-3 tablet:w-fit'>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = ".config.ts";
|
||||
fileInput.onchange = async (fileEvent) => {
|
||||
const file = (fileEvent.target as HTMLInputElement)
|
||||
.files?.[0];
|
||||
if (!file) return toast.error("No file selected");
|
||||
const fileName = file.name;
|
||||
if (fileName.toLowerCase() !== "gindex.config.ts")
|
||||
return toast.error("Please select a config file");
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (read) => {
|
||||
const result = read.target?.result as string;
|
||||
if (!result) return toast.error("Failed to read file");
|
||||
|
||||
const config = parseConfigFile(result);
|
||||
if ("success" in config) return toast.error(config.message);
|
||||
|
||||
if (get.environment.ENCRYPTION_KEY) {
|
||||
try {
|
||||
const root = config.api.rootFolder;
|
||||
const shared = config.api.sharedDrive || null;
|
||||
if (root && typeof root === "string") {
|
||||
const decrypted = await decryptData(
|
||||
root,
|
||||
get.environment.ENCRYPTION_KEY,
|
||||
);
|
||||
if (decrypted) {
|
||||
set("api", "rootFolder", decrypted);
|
||||
}
|
||||
}
|
||||
if (shared && typeof shared === "string") {
|
||||
const decrypted = await decryptData(
|
||||
shared,
|
||||
get.environment.ENCRYPTION_KEY,
|
||||
);
|
||||
if (decrypted) {
|
||||
set("api", "sharedDrive", decrypted);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(
|
||||
"Skipping Root Folder / Shared Drive ID, can't decrypt with current Encryption Key",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
"Can't decrypt Root Folder / Shared Drive ID without Encryption Key",
|
||||
);
|
||||
}
|
||||
set("api", "isTeamDrive", config.api.isTeamDrive || false);
|
||||
set("api", "itemsPerPage", config.api.itemsPerPage || 50);
|
||||
set("api", "searchResult", config.api.searchResult || 5);
|
||||
set(
|
||||
"api",
|
||||
"proxyThumbnail",
|
||||
config.api.proxyThumbnail || true,
|
||||
);
|
||||
set(
|
||||
"api",
|
||||
"allowDownloadProtectedFile",
|
||||
config.api.allowDownloadProtectedFile || false,
|
||||
);
|
||||
set(
|
||||
"api",
|
||||
"temporaryTokenDuration",
|
||||
config.api.temporaryTokenDuration || 6,
|
||||
);
|
||||
set(
|
||||
"api",
|
||||
"maxFileSize",
|
||||
config.api.maxFileSize || 4 * 1024 * 1024,
|
||||
);
|
||||
set(
|
||||
"api",
|
||||
"streamMaxSize",
|
||||
config.api.streamMaxSize || 100 * 1024 * 1024,
|
||||
);
|
||||
|
||||
fileInput.value = "";
|
||||
toast.success("Config loaded from file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
fileInput.click();
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Load from file
|
||||
</Button>
|
||||
<Button
|
||||
type='reset'
|
||||
variant={"destructive"}
|
||||
size={"sm"}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
<ConfigInput<ConfigurationKeys<"api">>
|
||||
key='rootFolder'
|
||||
title='Root Folder ID'
|
||||
description={`Starting point of the drive, will be used as the root folder to display files and folders.
|
||||
This ID will be encrypted in the config file.`}
|
||||
error={error.get.rootFolder}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id='rootFolder'
|
||||
name='rootFolder'
|
||||
value={get.api.rootFolder}
|
||||
onChange={(e) => {
|
||||
if (error.get.rootFolder) {
|
||||
error.set("rootFolder", "");
|
||||
}
|
||||
set("api", "rootFolder", e.target.value);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.api.rootFolder;
|
||||
error.set("rootFolder", "");
|
||||
|
||||
if (!value) throw new Error("Root Folder ID is required");
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("rootFolder", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"api">>
|
||||
key='isTeamDrive'
|
||||
title='Use Team Drive'
|
||||
description={`If you are using Shared Drive, you NEED to enable this option.`}
|
||||
error={error.get.isTeamDrive}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
value={get.api.isTeamDrive.toString()}
|
||||
onValueChange={(value) => {
|
||||
set("api", "isTeamDrive", value === "true");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"Select an option"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"true"}>Enable</SelectItem>
|
||||
<SelectItem value={"false"}>Disable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ConfigInput>
|
||||
|
||||
{get.api.isTeamDrive && (
|
||||
<ConfigInput<ConfigurationKeys<"api">>
|
||||
key='sharedDrive'
|
||||
title='Shared Drive ID'
|
||||
description={`The Drive ID of the Shared Drive.
|
||||
This ID will be encrypted in the config file`}
|
||||
error={error.get.sharedDrive}
|
||||
required={get.api.isTeamDrive}
|
||||
>
|
||||
<Input
|
||||
id='sharedDrive'
|
||||
name='sharedDrive'
|
||||
value={get.api.sharedDrive}
|
||||
onChange={(e) => {
|
||||
if (error.get.sharedDrive) {
|
||||
error.set("sharedDrive", "");
|
||||
}
|
||||
set("api", "sharedDrive", e.target.value);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.api.sharedDrive;
|
||||
const isTeamDrive = get.api.isTeamDrive;
|
||||
error.set("sharedDrive", "");
|
||||
|
||||
if (!isTeamDrive) return;
|
||||
if (!value)
|
||||
throw new Error(
|
||||
"Shared Drive ID is required if using Team Drive",
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("sharedDrive", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
)}
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 tablet:grid-cols-2'>
|
||||
<ConfigInput<ConfigurationKeys<"api">>
|
||||
key='itemsPerPage'
|
||||
title='Items Per Page'
|
||||
description={`Set how many items to display per page in the file list.
|
||||
It's recommended to set this to a reasonable number, since it will affect the load time of the page.
|
||||
|
||||
Default is 50.`}
|
||||
error={error.get.itemsPerPage}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id='itemsPerPage'
|
||||
name='itemsPerPage'
|
||||
type='number'
|
||||
value={get.api.itemsPerPage}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
if (error.get.itemsPerPage) {
|
||||
error.set("itemsPerPage", "");
|
||||
}
|
||||
set("api", "itemsPerPage", parseInt(e.target.value));
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.api.itemsPerPage;
|
||||
error.set("itemsPerPage", "");
|
||||
|
||||
if (value <= 0)
|
||||
throw new Error("Items Per Page must be more than 0");
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("itemsPerPage", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"api">>
|
||||
key='searchResult'
|
||||
title='Search Result'
|
||||
description={`Set how many items to display in the search result.
|
||||
It's recommended to set this to a small number, since it will affect the load time of the page.
|
||||
|
||||
Default is 5.`}
|
||||
error={error.get.searchResult}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id='searchResult'
|
||||
name='searchResult'
|
||||
type='number'
|
||||
value={get.api.searchResult}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
if (error.get.searchResult) {
|
||||
error.set("searchResult", "");
|
||||
}
|
||||
set("api", "searchResult", parseInt(e.target.value));
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.api.searchResult;
|
||||
error.set("searchResult", "");
|
||||
|
||||
if (value <= 0)
|
||||
throw new Error("Search Result must be more than 0");
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("searchResult", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
</div>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"api">>
|
||||
key='proxyThumbnail'
|
||||
title='Proxy Thumbnail'
|
||||
description={`Proxy the thumbnail image via API route.
|
||||
|
||||
If your files thumbnail are not accessible, you can set this to true.
|
||||
This will fetch the thumbnail image via API route, but it will increase the load on your server.`}
|
||||
error={error.get.proxyThumbnail}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
value={get.api.proxyThumbnail.toString()}
|
||||
onValueChange={(value) => {
|
||||
set("api", "proxyThumbnail", value === "true");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"Select an option"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"true"}>Enable</SelectItem>
|
||||
<SelectItem value={"false"}>Disable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"api">>
|
||||
key='allowDownloadProtectedFile'
|
||||
title='Allow Download Protected File'
|
||||
description={`Allow users to download password protected files.
|
||||
|
||||
If set to true, users will be able to download the file without entering password as long as they have the link
|
||||
If set to false, the download link will have a temporary token attached to it, the token will expire after certain duration (default is 6 hours)`}
|
||||
error={error.get.allowDownloadProtectedFile}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
value={get.api.allowDownloadProtectedFile.toString()}
|
||||
onValueChange={(value) => {
|
||||
set("api", "allowDownloadProtectedFile", value === "true");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"Select an option"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"true"}>Enable</SelectItem>
|
||||
<SelectItem value={"false"}>Disable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"api">>
|
||||
key='temporaryTokenDuration'
|
||||
title='Temporary Token Duration (in hours)'
|
||||
description={`Duration of the temporary token used for protected files download link.`}
|
||||
error={error.get.temporaryTokenDuration}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id='temporaryTokenDuration'
|
||||
name='temporaryTokenDuration'
|
||||
type='number'
|
||||
value={get.api.temporaryTokenDuration}
|
||||
min={1}
|
||||
max={24 * 7}
|
||||
onChange={(e) => {
|
||||
if (error.get.temporaryTokenDuration) {
|
||||
error.set("temporaryTokenDuration", "");
|
||||
}
|
||||
set("api", "temporaryTokenDuration", parseInt(e.target.value));
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.api.temporaryTokenDuration;
|
||||
error.set("temporaryTokenDuration", "");
|
||||
|
||||
if (value <= 0)
|
||||
throw new Error(
|
||||
"Temporary Token Duration must be more than 0",
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("temporaryTokenDuration", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"api">>
|
||||
key='maxFileSize'
|
||||
title='Max Direct Download Size (in MB)'
|
||||
description={`Max file size that can be downloaded directly from the server, instead of download link from Google Drive.
|
||||
Please refer to your deploy platform for the maximum response size limit.
|
||||
If you are using Vercel, the maximum response size is around 4 - 4.5MB.
|
||||
|
||||
Set to 0 to disable the limit.`}
|
||||
error={error.get.maxFileSize}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id='maxFileSize'
|
||||
name='maxFileSize'
|
||||
type='number'
|
||||
value={get.api.maxFileSize / 1024 / 1024}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
if (error.get.maxFileSize) {
|
||||
error.set("maxFileSize", "");
|
||||
}
|
||||
set("api", "maxFileSize", parseInt(e.target.value) * 1024 * 1024);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.api.maxFileSize;
|
||||
error.set("maxFileSize", "");
|
||||
|
||||
if (value < 0)
|
||||
throw new Error(
|
||||
"Max file size must be more than or equal to 0",
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("maxFileSize", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"api">>
|
||||
key='streamMaxSize'
|
||||
title='Max Stream Size (in MB)'
|
||||
description={`For previewing large files, the file will be streamed in chunks.
|
||||
There are response limit in some deploy platform like Vercel, also Google Drive will return 403 error if we tried using fetch to download large file on client.
|
||||
|
||||
Make sure it's within reasonable size, since it will count towards your server bandwidth usage.
|
||||
This will also affect the maximum file size that can be previewed. Especially for media files like video, audio, and images.
|
||||
(Manga preview will automatically limited to the first 5MB)
|
||||
|
||||
Default is 100MB, set to 0 to disable the limit.`}
|
||||
error={error.get.streamMaxSize}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id='streamMaxSize'
|
||||
name='streamMaxSize'
|
||||
type='number'
|
||||
value={get.api.streamMaxSize / 1024 / 1024}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
if (error.get.maxFileSize) {
|
||||
error.set("streamMaxSize", "");
|
||||
}
|
||||
set(
|
||||
"api",
|
||||
"streamMaxSize",
|
||||
parseInt(e.target.value) * 1024 * 1024,
|
||||
);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.api.streamMaxSize;
|
||||
error.set("streamMaxSize", "");
|
||||
|
||||
if (value < 0)
|
||||
throw new Error(
|
||||
"Max stream size must be more than or equal to 0",
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("streamMaxSize", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ConfigState,
|
||||
ConfigurationCategory,
|
||||
ConfigurationKeys,
|
||||
ConfigurationValue,
|
||||
Schema_App_Configuration,
|
||||
Schema_ServiceAccount,
|
||||
} from "~/schema";
|
||||
|
||||
import { GenerateAESKey, VerifyAESKey } from "~/app/actions";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import ConfigInput from "./@form.input-config";
|
||||
|
||||
type Props = {
|
||||
state: {
|
||||
get: z.input<typeof Schema_App_Configuration>;
|
||||
set: <
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
category: T,
|
||||
key: K,
|
||||
value: ConfigurationValue<T, K>,
|
||||
) => void;
|
||||
};
|
||||
error: {
|
||||
get: Partial<Record<ConfigurationKeys<"environment">, string>>;
|
||||
set: <T extends ConfigurationKeys<"environment">>(
|
||||
key: T,
|
||||
value: string,
|
||||
) => void;
|
||||
};
|
||||
onReset: (category: ConfigurationCategory) => void;
|
||||
};
|
||||
export default function EnvironmentConfig({
|
||||
state: { get, set },
|
||||
error,
|
||||
onReset,
|
||||
}: Props) {
|
||||
const [encryptionState, setEncryptionState] = useState<ConfigState>("idle");
|
||||
const [gdServiceState, setGdServiceState] = useState<ConfigState>("idle");
|
||||
const [revealPassword, setRevealPassword] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
onReset={(e) => {
|
||||
e.preventDefault();
|
||||
onReset("environment");
|
||||
}}
|
||||
>
|
||||
<div
|
||||
slot='header'
|
||||
className='flex flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-between'
|
||||
>
|
||||
<h4>Environment</h4>
|
||||
<div className='flex w-full items-center gap-3 tablet:w-fit'>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept =
|
||||
".env, .env.local, .env.development, .env.production";
|
||||
fileInput.onchange = async (fileEvent) => {
|
||||
const file = (fileEvent.target as HTMLInputElement)
|
||||
.files?.[0];
|
||||
if (!file) return toast.error("No file selected");
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (read) => {
|
||||
const result = read.target?.result as string;
|
||||
if (!result) return toast.error("Failed to read file");
|
||||
const lines = result.split("\n");
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of lines) {
|
||||
const [key, value] = line.split("=");
|
||||
if (!key || !value) continue;
|
||||
const formattedValue = value
|
||||
?.replace(/"/g, "")
|
||||
.replace(/'/g, "")
|
||||
.replace(/\\/g, "")
|
||||
.replace(/\r/g, "")
|
||||
.trim();
|
||||
env[key] = formattedValue;
|
||||
}
|
||||
set(
|
||||
"environment",
|
||||
"GD_SERVICE_B64",
|
||||
env.GD_SERVICE_B64 || "",
|
||||
);
|
||||
set(
|
||||
"environment",
|
||||
"ENCRYPTION_KEY",
|
||||
env.ENCRYPTION_KEY || "",
|
||||
);
|
||||
set(
|
||||
"environment",
|
||||
"SITE_PASSWORD",
|
||||
env.SITE_PASSWORD || "",
|
||||
);
|
||||
set(
|
||||
"environment",
|
||||
"NEXT_PUBLIC_DOMAIN",
|
||||
env.NEXT_PUBLIC_DOMAIN || "",
|
||||
);
|
||||
fileInput.value = "";
|
||||
toast.success("Environment loaded from file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
fileInput.click();
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Load from file
|
||||
</Button>
|
||||
<Button
|
||||
type='reset'
|
||||
variant={"destructive"}
|
||||
size={"sm"}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
<ConfigInput<ConfigurationKeys<"environment">>
|
||||
key='ENCRYPTION_KEY'
|
||||
title='Encryption Key'
|
||||
description='The encryption key for the site, must be a alphanumeric string without spaces'
|
||||
error={error.get.ENCRYPTION_KEY}
|
||||
required
|
||||
action={{
|
||||
label: "Generate",
|
||||
async onClick(e) {
|
||||
e.preventDefault();
|
||||
setEncryptionState("loading");
|
||||
error.set("ENCRYPTION_KEY", "");
|
||||
|
||||
try {
|
||||
const keyStr = await GenerateAESKey();
|
||||
const valid = await VerifyAESKey("This is a test", keyStr);
|
||||
if (!valid)
|
||||
throw new Error("Invalid key generated, please try again");
|
||||
|
||||
set("environment", "ENCRYPTION_KEY", keyStr);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
console.error(e);
|
||||
error.set("ENCRYPTION_KEY", e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setEncryptionState("idle");
|
||||
}
|
||||
},
|
||||
state: encryptionState,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
id='ENCRYPTION_KEY'
|
||||
name='ENCRYPTION_KEY'
|
||||
value={get.environment.ENCRYPTION_KEY}
|
||||
onChange={(e) => {
|
||||
if (error.get.ENCRYPTION_KEY) {
|
||||
error.set("ENCRYPTION_KEY", "");
|
||||
}
|
||||
set("environment", "ENCRYPTION_KEY", e.target.value);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.environment.ENCRYPTION_KEY;
|
||||
error.set("ENCRYPTION_KEY", "");
|
||||
|
||||
if (!value) throw new Error("Encryption key is required");
|
||||
|
||||
if (value.includes(" "))
|
||||
throw new Error("Encryption key must not contain spaces");
|
||||
|
||||
const valid = await VerifyAESKey("This is a test", value);
|
||||
if (!valid)
|
||||
throw new Error(
|
||||
"The encryption key is invalid, please generate a new one",
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("ENCRYPTION_KEY", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"environment">>
|
||||
key='GD_SERVICE_B64'
|
||||
title='Google Drive Service Account'
|
||||
description={`The base64 encoded Google Drive Service Account JSON file
|
||||
To avoid error when inputting, please use the "Load JSON" button to load the file directly`}
|
||||
error={error.get.GD_SERVICE_B64}
|
||||
required
|
||||
action={{
|
||||
label: "Load JSON",
|
||||
async onClick(e) {
|
||||
e.preventDefault();
|
||||
setGdServiceState("loading");
|
||||
|
||||
try {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = ".json";
|
||||
fileInput.onchange = async (fileEvent) => {
|
||||
const file = (fileEvent.target as HTMLInputElement)
|
||||
.files?.[0];
|
||||
if (!file) return toast.error("No file selected");
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (readerEvent) => {
|
||||
const result = readerEvent.target?.result as string;
|
||||
if (!result) return toast.error("Failed to read file");
|
||||
|
||||
const objectFile = JSON.parse(result);
|
||||
const parse = Schema_ServiceAccount.safeParse(objectFile);
|
||||
if (!parse.success)
|
||||
return toast.error(
|
||||
"Invalid Service Account JSON, please select a valid Google Drive Service Account JSON file",
|
||||
);
|
||||
|
||||
set("environment", "GD_SERVICE_B64", btoa(result));
|
||||
error.set("GD_SERVICE_B64", "");
|
||||
|
||||
toast.success(
|
||||
"Google Drive Service Account JSON file loaded",
|
||||
);
|
||||
fileInput.value = "";
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
fileInput.click();
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
console.error(e);
|
||||
error.set("GD_SERVICE_B64", e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setGdServiceState("idle");
|
||||
}
|
||||
},
|
||||
state: gdServiceState,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
id='GD_SERVICE_B64'
|
||||
name='GD_SERVICE_B64'
|
||||
value={get.environment.GD_SERVICE_B64}
|
||||
readOnly
|
||||
onChange={(e) => {
|
||||
if (error.get.GD_SERVICE_B64) {
|
||||
error.set("GD_SERVICE_B64", "");
|
||||
}
|
||||
set("environment", "GD_SERVICE_B64", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"environment">>
|
||||
key='NEXT_PUBLIC_DOMAIN'
|
||||
title='Domain'
|
||||
description={`The domain for the site, without the protocol
|
||||
(e.g. drive-demo.mbaharip.com or mbaharip.com)`}
|
||||
error={error.get.NEXT_PUBLIC_DOMAIN}
|
||||
>
|
||||
<Input
|
||||
id='NEXT_PUBLIC_DOMAIN'
|
||||
name='NEXT_PUBLIC_DOMAIN'
|
||||
value={get.environment.NEXT_PUBLIC_DOMAIN}
|
||||
onChange={(e) => {
|
||||
if (error.get.NEXT_PUBLIC_DOMAIN) {
|
||||
error.set("NEXT_PUBLIC_DOMAIN", "");
|
||||
}
|
||||
set("environment", "NEXT_PUBLIC_DOMAIN", e.target.value);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
error.set("NEXT_PUBLIC_DOMAIN", "");
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("NEXT_PUBLIC_DOMAIN", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"environment">>
|
||||
key='SITE_PASSWORD'
|
||||
title='Site Password'
|
||||
description='The password to access the site'
|
||||
error={error.get.SITE_PASSWORD}
|
||||
required={get.site.privateIndex}
|
||||
action={{
|
||||
label: revealPassword ? "Hide" : "Reveal",
|
||||
onClick(e) {
|
||||
e.preventDefault();
|
||||
setRevealPassword((prev) => !prev);
|
||||
},
|
||||
state: "idle",
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
id='SITE_PASSWORD'
|
||||
name='SITE_PASSWORD'
|
||||
type={revealPassword ? "text" : "password"}
|
||||
value={get.environment.SITE_PASSWORD}
|
||||
onChange={(e) => {
|
||||
if (error.get.SITE_PASSWORD) {
|
||||
error.set("SITE_PASSWORD", "");
|
||||
}
|
||||
set("environment", "SITE_PASSWORD", e.target.value);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
error.set("SITE_PASSWORD", "");
|
||||
if (get.site.privateIndex && !get.environment.SITE_PASSWORD) {
|
||||
throw new Error(
|
||||
"Site Password is required when Private Index is enabled",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
console.error(e);
|
||||
error.set("SITE_PASSWORD", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren } from "react";
|
||||
import { ConfigState } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
|
||||
type Props<T> = {
|
||||
key: T extends string ? T : string;
|
||||
title: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
state: ConfigState;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
export default function ConfigInput<T>(props: PropsWithChildren<Props<T>>) {
|
||||
return (
|
||||
<div
|
||||
id={props.key}
|
||||
slot={`input-${props.key}`}
|
||||
className='flex w-full flex-col gap-1.5'
|
||||
>
|
||||
<div
|
||||
slot='label'
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
<Label
|
||||
htmlFor={props.key}
|
||||
aria-required={props.required}
|
||||
>
|
||||
{props.title}
|
||||
</Label>
|
||||
{!props.required && (
|
||||
<span className='text-sm text-muted-foreground'>(optional)</span>
|
||||
)}
|
||||
{props.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className='cursor-default'
|
||||
>
|
||||
<Icon
|
||||
name='Info'
|
||||
size={"1rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
className='max-w-screen-sm'
|
||||
>
|
||||
<p className='max-w-screen-sm !whitespace-pre-wrap'>
|
||||
{props.description}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
slot='input'
|
||||
className='grid w-full grid-cols-6 gap-1.5'
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
props.action ? "col-span-5" : "col-span-full",
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
{props.action && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={props.action.onClick}
|
||||
disabled={props.action.state === "loading"}
|
||||
>
|
||||
<div className='relative flex w-full items-center justify-center'>
|
||||
<span className='relative transition-all duration-300 ease-in-out'>
|
||||
{props.action.label}
|
||||
</span>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
className={cn(
|
||||
"animate-spin transition-all",
|
||||
props.action.state === "loading"
|
||||
? "ml-1.5 size-4 opacity-100"
|
||||
: "ml-0 size-0 opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div slot='message'>
|
||||
<span
|
||||
className={cn(
|
||||
"block text-sm text-destructive",
|
||||
props.error ? "opacity-100" : "select-none opacity-0",
|
||||
)}
|
||||
>
|
||||
{props.error}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PopoverTrigger } from "@radix-ui/react-popover";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { HslColor, HslColorPicker } from "react-colorful";
|
||||
import { z } from "zod";
|
||||
import { Schema_Theme } from "~/schema";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Popover, PopoverContent } from "~/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
|
||||
type Props = {
|
||||
key: keyof z.infer<typeof Schema_Theme>;
|
||||
title: string;
|
||||
description?: string;
|
||||
value: HslColor;
|
||||
onChange: (color: HslColor) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export default function ThemeInput(props: PropsWithChildren<Props>) {
|
||||
// const [value, setValue] = useState<HslColor>(props.value);
|
||||
// const [debouncedValue, setDebouncedValue] = useState<HslColor>(props.value);
|
||||
|
||||
// useEffect(() => {
|
||||
// setValue(props.value);
|
||||
|
||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// }, [props.value]);
|
||||
|
||||
// useEffect(() => {
|
||||
// const timeout = setTimeout(() => {
|
||||
// setDebouncedValue(value);
|
||||
// }, 250);
|
||||
// return () => clearTimeout(timeout);
|
||||
|
||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// }, [value]);
|
||||
|
||||
// useEffect(() => {
|
||||
// props.onChange(debouncedValue);
|
||||
|
||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// }, [debouncedValue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.key}
|
||||
slot={`input-${props.key}`}
|
||||
// className='flex w-full items-center justify-between gap-6'
|
||||
className='grid w-full grid-cols-3 gap-6'
|
||||
>
|
||||
<div
|
||||
slot='label'
|
||||
className='flex items-center gap-3'
|
||||
>
|
||||
<span className='text-base'>{props.title}</span>
|
||||
{props.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className='cursor-default'
|
||||
>
|
||||
<Icon
|
||||
name='Info'
|
||||
size='1rem'
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
className='max-w-screen-sm'
|
||||
>
|
||||
<p className='max-w-screen-sm !whitespace-pre-wrap'>
|
||||
{props.description}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
slot='input'
|
||||
className='col-span-2 flex flex-grow items-center justify-end gap-3'
|
||||
>
|
||||
{props.children ? (
|
||||
props.children
|
||||
) : (
|
||||
<>
|
||||
<span className='flex-grow text-sm text-muted-foreground'>
|
||||
hsl({props.value.h}, {props.value.s}%, {props.value.l}%)
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div className='size-8 rounded-[var(--radius)] border border-border p-0.5'>
|
||||
<div
|
||||
className='size-full rounded-[calc(var(--radius)-0.125rem)]'
|
||||
style={{
|
||||
background: `hsl(${props.value.h}, ${props.value.s}%, ${props.value.l}%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='flex gap-6'>
|
||||
<HslColorPicker
|
||||
color={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<div className='flex flex-grow flex-col items-center justify-between'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor={`${props.key}-h`}>Hue</Label>
|
||||
<Input
|
||||
id={`${props.key}-h`}
|
||||
name={`${props.key}-h`}
|
||||
type='number'
|
||||
value={props.value.h}
|
||||
min={0}
|
||||
max={360}
|
||||
onChange={(e) => {
|
||||
const value = props.value;
|
||||
props.onChange({
|
||||
h: Number(e.target.value),
|
||||
s: value.s,
|
||||
l: value.l,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor={`${props.key}-s`}>Saturation</Label>
|
||||
<Input
|
||||
id={`${props.key}-s`}
|
||||
name={`${props.key}-s`}
|
||||
type='number'
|
||||
value={props.value.s}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => {
|
||||
const value = props.value;
|
||||
props.onChange({
|
||||
h: value.h,
|
||||
s: Number(e.target.value),
|
||||
l: value.l,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor={`${props.key}-l`}>Lightness</Label>
|
||||
<Input
|
||||
id={`${props.key}-l`}
|
||||
name={`${props.key}-l`}
|
||||
type='number'
|
||||
value={props.value.l}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => {
|
||||
const value = props.value;
|
||||
props.onChange({
|
||||
h: value.h,
|
||||
s: value.s,
|
||||
l: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ConfigurationCategory,
|
||||
ConfigurationKeys,
|
||||
ConfigurationValue,
|
||||
Schema_App_Configuration,
|
||||
} from "~/schema";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { parseConfigFile } from "~/utils/parseConfigFile";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import ConfigInput from "./@form.input-config";
|
||||
|
||||
type Props = {
|
||||
state: {
|
||||
get: z.input<typeof Schema_App_Configuration>;
|
||||
set: <
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
category: T,
|
||||
key: K,
|
||||
value: ConfigurationValue<T, K>,
|
||||
) => void;
|
||||
};
|
||||
error: {
|
||||
get: Partial<Record<ConfigurationKeys<"site">, string>>;
|
||||
set: <T extends ConfigurationKeys<"site">>(key: T, value: string) => void;
|
||||
};
|
||||
onReset: (category: ConfigurationCategory) => void;
|
||||
};
|
||||
export default function SiteConfig({
|
||||
state: { get, set },
|
||||
error,
|
||||
onReset,
|
||||
}: Props) {
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
onReset={(e) => {
|
||||
e.preventDefault();
|
||||
onReset("site");
|
||||
}}
|
||||
>
|
||||
<div
|
||||
slot='header'
|
||||
className='flex flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-between'
|
||||
>
|
||||
<h4>Site</h4>
|
||||
<div className='flex w-full items-center gap-3 tablet:w-fit'>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = ".config.ts";
|
||||
fileInput.onchange = async (fileEvent) => {
|
||||
const file = (fileEvent.target as HTMLInputElement)
|
||||
.files?.[0];
|
||||
if (!file) return toast.error("No file selected");
|
||||
const fileName = file.name;
|
||||
if (fileName.toLowerCase() !== "gindex.config.ts")
|
||||
return toast.error("Please select a config file");
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (read) => {
|
||||
const result = read.target?.result as string;
|
||||
if (!result) return toast.error("Failed to read file");
|
||||
|
||||
const config = parseConfigFile(result);
|
||||
if ("success" in config) return toast.error(config.message);
|
||||
|
||||
set(
|
||||
"site",
|
||||
"siteName",
|
||||
config.site.siteName || get.site.siteName,
|
||||
);
|
||||
set(
|
||||
"site",
|
||||
"siteNameTemplate",
|
||||
config.site.siteNameTemplate ||
|
||||
(get.site.siteNameTemplate as string),
|
||||
);
|
||||
set(
|
||||
"site",
|
||||
"siteDescription",
|
||||
config.site.siteDescription || get.site.siteDescription,
|
||||
);
|
||||
set(
|
||||
"site",
|
||||
"siteAuthor",
|
||||
config.site.siteAuthor || (get.site.siteAuthor as string),
|
||||
);
|
||||
set(
|
||||
"site",
|
||||
"twitterHandle",
|
||||
config.site.twitterHandle ||
|
||||
(get.site.twitterHandle as string),
|
||||
);
|
||||
|
||||
set(
|
||||
"site",
|
||||
"showFileExtension",
|
||||
config.site.showFileExtension ||
|
||||
(get.site.showFileExtension as boolean),
|
||||
);
|
||||
|
||||
set(
|
||||
"site",
|
||||
"privateIndex",
|
||||
config.site.privateIndex ||
|
||||
(get.site.privateIndex as boolean),
|
||||
);
|
||||
|
||||
fileInput.value = "";
|
||||
toast.success("Config loaded from file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
fileInput.click();
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Load from file
|
||||
</Button>
|
||||
<Button
|
||||
type='reset'
|
||||
variant={"destructive"}
|
||||
size={"sm"}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center gap-1.5 px-1.5 pt-3'>
|
||||
<div className='flex select-none items-center gap-1.5 rounded-t-lg border border-b-0 border-border px-3 py-1.5'>
|
||||
<img
|
||||
src={config.siteConfig.favIcon}
|
||||
className='size-4'
|
||||
alt='favicon'
|
||||
/>
|
||||
<span className='text-sm'>{get.site.siteName}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex select-none items-center gap-1.5 rounded-t-lg border border-b-0 border-border px-3 py-1.5'>
|
||||
<img
|
||||
src={config.siteConfig.favIcon}
|
||||
className='size-4'
|
||||
alt='favicon'
|
||||
/>
|
||||
<span className='text-sm'>
|
||||
{get.site
|
||||
.siteNameTemplate!.replace("%s", "Page Title")
|
||||
.replace("%t", get.site.siteName)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className='mt-0' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-6'>
|
||||
<div className='col-span-2 flex flex-col gap-3'>
|
||||
<ConfigInput<ConfigurationKeys<"site">>
|
||||
key='siteName'
|
||||
title='Index Site Name'
|
||||
error={error.get.siteName}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id='siteName'
|
||||
name='siteName'
|
||||
value={get.site.siteName}
|
||||
onChange={(e) => {
|
||||
if (error.get.siteName) {
|
||||
error.set("siteName", "");
|
||||
}
|
||||
set("site", "siteName", e.target.value);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.site.siteName;
|
||||
error.set("siteName", "");
|
||||
|
||||
if (!value) throw new Error("Site name is required");
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("siteName", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"site">>
|
||||
key='siteNameTemplate'
|
||||
title='Site Name Template'
|
||||
description={`The template for the site name.
|
||||
|
||||
Usable variables:
|
||||
%s - Page Title
|
||||
%t - Site Name`}
|
||||
error={error.get.siteNameTemplate}
|
||||
>
|
||||
<Input
|
||||
id='siteNameTemplate'
|
||||
name='siteNameTemplate'
|
||||
value={get.site.siteNameTemplate}
|
||||
onChange={(e) => {
|
||||
if (error.get.siteNameTemplate) {
|
||||
error.set("siteNameTemplate", "");
|
||||
}
|
||||
set("site", "siteNameTemplate", e.target.value);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.site.siteNameTemplate;
|
||||
error.set("siteNameTemplate", "");
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("siteNameTemplate", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"site">>
|
||||
key='siteDescription'
|
||||
title='Site Description'
|
||||
error={error.get.siteDescription}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id='siteDescription'
|
||||
name='siteDescription'
|
||||
value={get.site.siteDescription}
|
||||
onChange={(e) => {
|
||||
if (error.get.siteDescription) {
|
||||
error.set("siteDescription", "");
|
||||
}
|
||||
set("site", "siteDescription", e.target.value);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.site.siteDescription;
|
||||
error.set("siteDescription", "");
|
||||
|
||||
if (!value) throw new Error("Site description is required");
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("siteDescription", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<ConfigInput<ConfigurationKeys<"site">>
|
||||
key='siteAuthor'
|
||||
title='Site Author'
|
||||
error={error.get.siteAuthor}
|
||||
description={`Will be used for metadata, and also affect the footer variable`}
|
||||
>
|
||||
<Input
|
||||
id='siteAuthor'
|
||||
name='siteAuthor'
|
||||
value={get.site.siteAuthor}
|
||||
onChange={(e) => {
|
||||
if (error.get.siteAuthor) {
|
||||
error.set("siteAuthor", "");
|
||||
}
|
||||
set("site", "siteAuthor", e.target.value);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.site.siteAuthor;
|
||||
error.set("siteAuthor", "");
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("siteAuthor", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"site">>
|
||||
key='twitterHandle'
|
||||
title='Twitter Handle'
|
||||
error={error.get.twitterHandle}
|
||||
description={`Will be used for metadata, and also affect the footer variable`}
|
||||
>
|
||||
<Input
|
||||
id='twitterHandle'
|
||||
name='twitterHandle'
|
||||
value={get.site.twitterHandle}
|
||||
onChange={(e) => {
|
||||
if (error.get.twitterHandle) {
|
||||
error.set("twitterHandle", "");
|
||||
}
|
||||
set("site", "twitterHandle", e.target.value);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
const value = get.site.twitterHandle;
|
||||
error.set("twitterHandle", "");
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
error.set("twitterHandle", e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='h-fit select-none overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'>
|
||||
<img
|
||||
src={"/og.png"}
|
||||
alt='Opengraph Preview'
|
||||
className='w-full'
|
||||
/>
|
||||
<div className='flex flex-col px-3 py-1.5'>
|
||||
<span className='line-clamp-1 w-full text-lg font-medium'>
|
||||
{(get.site.siteNameTemplate || "%s")
|
||||
.replace("%s", "Page Title")
|
||||
.replace("%t", get.site.siteName)}
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{get.environment.NEXT_PUBLIC_DOMAIN || "http://localhost:3000"}
|
||||
</span>
|
||||
<span className='line-clamp-2 text-sm text-muted-foreground'>
|
||||
{get.site.siteDescription}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"site">>
|
||||
key='privateIndex'
|
||||
title='Private Index'
|
||||
description={`Lock the whole site behind a password
|
||||
Will use the site password set in the "Environment" category`}
|
||||
error={error.get.privateIndex}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
value={(get.site.privateIndex || false).toString()}
|
||||
onValueChange={(value) => {
|
||||
set("site", "privateIndex", value === "true");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"Select an option"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"true"}>Enable</SelectItem>
|
||||
<SelectItem value={"false"}>Disable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ConfigInput>
|
||||
|
||||
<ConfigInput<ConfigurationKeys<"site">>
|
||||
key='showFileExtension'
|
||||
title='Show File Extension'
|
||||
description={`Show file extension in file explorer
|
||||
|
||||
e.g. "file.mp4" instead of "file"`}
|
||||
error={error.get.showFileExtension}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
value={(get.site.showFileExtension || false).toString()}
|
||||
onValueChange={(value) => {
|
||||
set("api", "isTeamDrive", value === "true");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"Select an option"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"true"}>Enable</SelectItem>
|
||||
<SelectItem value={"false"}>Disable</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ConfigInput>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { Schema_Theme, ThemeKeys } from "~/schema";
|
||||
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Slider } from "~/components/ui/slider";
|
||||
|
||||
import { parseThemeValue } from "~/utils/parseConfigFile";
|
||||
|
||||
import ThemeInput from "./@form.input-theme";
|
||||
|
||||
type Props = {
|
||||
currentTheme: "light" | "dark";
|
||||
state: {
|
||||
get: {
|
||||
light: z.input<typeof Schema_Theme>;
|
||||
dark: z.input<typeof Schema_Theme>;
|
||||
};
|
||||
set: (theme: "light" | "dark", key: ThemeKeys, value: string) => void;
|
||||
};
|
||||
};
|
||||
export default function ThemeForm({
|
||||
currentTheme,
|
||||
state: { get, set },
|
||||
}: Props) {
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<ThemeInput
|
||||
key='background'
|
||||
title='Background'
|
||||
value={parseThemeValue(get[currentTheme].background)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "background", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='foreground'
|
||||
title='Text'
|
||||
value={parseThemeValue(get[currentTheme].foreground)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "foreground", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Separator className='my-1.5' />
|
||||
|
||||
<ThemeInput
|
||||
key='card'
|
||||
title='Card'
|
||||
value={parseThemeValue(get[currentTheme].card)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "card", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='card-foreground'
|
||||
title='Card Text'
|
||||
value={parseThemeValue(get[currentTheme]["card-foreground"])}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "card-foreground", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ThemeInput
|
||||
key='popover'
|
||||
title='Popover'
|
||||
value={parseThemeValue(get[currentTheme].popover)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "popover", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='popover-foreground'
|
||||
title='Popover Text'
|
||||
value={parseThemeValue(get[currentTheme]["popover-foreground"])}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "popover-foreground", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Separator className='my-1.5' />
|
||||
|
||||
<ThemeInput
|
||||
key='primary'
|
||||
title='Primary'
|
||||
value={parseThemeValue(get[currentTheme].primary)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "primary", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='primary-foreground'
|
||||
title='Primary Text'
|
||||
value={parseThemeValue(get[currentTheme]["primary-foreground"])}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "primary-foreground", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='secondary'
|
||||
title='Secondary'
|
||||
value={parseThemeValue(get[currentTheme].secondary)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "secondary", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='secondary-foreground'
|
||||
title='Secondary Text'
|
||||
value={parseThemeValue(get[currentTheme]["secondary-foreground"])}
|
||||
onChange={(val) => {
|
||||
set(
|
||||
currentTheme,
|
||||
"secondary-foreground",
|
||||
`${val.h} ${val.s} ${val.l}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='accent'
|
||||
title='Accent'
|
||||
value={parseThemeValue(get[currentTheme].accent)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "accent", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='accent-foreground'
|
||||
title='Accent Text'
|
||||
value={parseThemeValue(get[currentTheme]["accent-foreground"])}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "accent-foreground", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='muted'
|
||||
title='Muted'
|
||||
value={parseThemeValue(get[currentTheme].muted)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "muted", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='muted-foreground'
|
||||
title='Muted Text'
|
||||
value={parseThemeValue(get[currentTheme]["muted-foreground"])}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "muted-foreground", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='destructive'
|
||||
title='Destructive'
|
||||
value={parseThemeValue(get[currentTheme].destructive)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "destructive", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='destructive-foreground'
|
||||
title='Destructive Text'
|
||||
value={parseThemeValue(get[currentTheme]["destructive-foreground"])}
|
||||
onChange={(val) => {
|
||||
set(
|
||||
currentTheme,
|
||||
"destructive-foreground",
|
||||
`${val.h} ${val.s} ${val.l}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Separator className='my-1.5' />
|
||||
|
||||
<ThemeInput
|
||||
key='border'
|
||||
title='Element Border'
|
||||
value={parseThemeValue(get[currentTheme].border)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "border", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='input'
|
||||
title='Input Border'
|
||||
value={parseThemeValue(get[currentTheme].input)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "input", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='ring'
|
||||
title='Focus Ring'
|
||||
value={parseThemeValue(get[currentTheme].ring)}
|
||||
onChange={(val) => {
|
||||
set(currentTheme, "ring", `${val.h} ${val.s} ${val.l}`);
|
||||
}}
|
||||
/>
|
||||
<ThemeInput
|
||||
key='radius'
|
||||
title='Roundness'
|
||||
value={{ h: 0, s: 0, l: 0 }}
|
||||
onChange={(val) => {}}
|
||||
>
|
||||
<div className='flex w-full items-center gap-3'>
|
||||
<Slider
|
||||
className='w-full'
|
||||
defaultValue={[parseFloat(get[currentTheme].radius) * 16 || 0]}
|
||||
min={0}
|
||||
max={24}
|
||||
value={[parseFloat(get[currentTheme].radius) * 16 || 0]}
|
||||
onValueChange={(val) => {
|
||||
const value = val[0] / 16;
|
||||
set("light", "radius", `${value}rem`);
|
||||
set("dark", "radius", `${value}rem`);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={parseFloat(get[currentTheme].radius) * 16 || 0}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.currentTarget.value) / 16;
|
||||
set("light", "radius", `${value}rem`);
|
||||
set("dark", "radius", `${value}rem`);
|
||||
}}
|
||||
type='number'
|
||||
className='w-16 min-w-0 '
|
||||
min={0}
|
||||
max={24}
|
||||
/>
|
||||
</div>
|
||||
</ThemeInput>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import nProgress from "nprogress";
|
||||
import { CSSProperties, PropsWithChildren } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_Theme } from "~/schema";
|
||||
|
||||
import FileGrid from "~/app/@file.grid";
|
||||
import FileList from "~/app/@file.list";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "~/components/ui/sheet";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
|
||||
type Props = {
|
||||
theme: z.input<typeof Schema_Theme>;
|
||||
};
|
||||
export default function ThemePreview({ theme }: Props) {
|
||||
return (
|
||||
<div
|
||||
className='flex flex-col gap-3 rounded-[var(--radius)] border border-border bg-background p-3 text-foreground'
|
||||
style={
|
||||
{
|
||||
"--background": theme.background,
|
||||
"--foreground": theme.foreground,
|
||||
"--card": theme.card,
|
||||
"--card-foreground": theme["card-foreground"],
|
||||
"--popover": theme.popover,
|
||||
"--popover-foreground": theme["popover-foreground"],
|
||||
"--primary": theme.primary,
|
||||
"--primary-foreground": theme["primary-foreground"],
|
||||
"--secondary": theme.secondary,
|
||||
"--secondary-foreground": theme["secondary-foreground"],
|
||||
"--muted": theme.muted,
|
||||
"--muted-foreground": theme["muted-foreground"],
|
||||
"--accent": theme.accent,
|
||||
"--accent-foreground": theme["accent-foreground"],
|
||||
"--destructive": theme.destructive,
|
||||
"--destructive-foreground": theme["destructive-foreground"],
|
||||
"--border": theme.border,
|
||||
"--input": theme.input,
|
||||
"--ring": theme.ring,
|
||||
"--radius": theme.radius,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<Wrapper title='Button'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"default"}
|
||||
>
|
||||
Default
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Secondary
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"destructive"}
|
||||
>
|
||||
Destructive
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
>
|
||||
Outline
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
>
|
||||
Ghost
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"link"}
|
||||
>
|
||||
Link
|
||||
</Button>
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper title='Card'>
|
||||
<Card className='w-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>Title</CardTitle>
|
||||
<CardDescription>Description</CardDescription>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent>Content</CardContent>
|
||||
<CardFooter>Footer</CardFooter>
|
||||
</Card>
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper title='Input'>
|
||||
<Input placeholder='Input' />
|
||||
<Textarea placeholder='Textarea' />
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper title='Popover'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
>
|
||||
Hover me
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Tooltip content</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
>
|
||||
Click me
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p>Popover content</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
>
|
||||
Click me
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
<DropdownMenuItem>Item 2</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>Item 3</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
onClick={async () => {
|
||||
toast.loading("Loading...", {
|
||||
duration: 3000,
|
||||
});
|
||||
toast.success("Success", {
|
||||
duration: 3000,
|
||||
});
|
||||
toast.error("Error", {
|
||||
duration: 3000,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Show toast
|
||||
</Button>
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper title='Overlay'>
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
>
|
||||
Open Dialog
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Title</DialogTitle>
|
||||
<DialogDescription>Description</DialogDescription>
|
||||
<Separator />
|
||||
</DialogHeader>
|
||||
<div>Content</div>
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Drawer>
|
||||
<DrawerTrigger>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
>
|
||||
Open Drawer
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Title</DrawerTitle>
|
||||
<DrawerDescription>Description</DrawerDescription>
|
||||
<Separator />
|
||||
</DrawerHeader>
|
||||
<div className='p-6'>Content</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
className='w-full'
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
>
|
||||
Open Sheet
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Title</SheetTitle>
|
||||
<SheetDescription>Description</SheetDescription>
|
||||
<Separator />
|
||||
</SheetHeader>
|
||||
<div>Content</div>
|
||||
<SheetFooter>
|
||||
<SheetClose>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
className='w-full'
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
onClick={async () => {
|
||||
nProgress.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
nProgress.done();
|
||||
}}
|
||||
>
|
||||
Test Loading
|
||||
</Button>
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper title='File'>
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
<FileList
|
||||
data={{
|
||||
encryptedId: "encryptedId",
|
||||
name: "File Name",
|
||||
modifiedTime: "2022-01-01T00:00:00Z",
|
||||
mimeType: "text/plain",
|
||||
trashed: false,
|
||||
size: 1024 * 1024 * 100,
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
<div className='grid w-full'>
|
||||
<FileGrid
|
||||
data={{
|
||||
encryptedId: "encryptedId",
|
||||
name: "File Name",
|
||||
modifiedTime: "2022-01-01T00:00:00Z",
|
||||
mimeType: "text/plain",
|
||||
trashed: false,
|
||||
size: 1024 * 1024 * 100,
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type WrapperProps = {
|
||||
title: string;
|
||||
};
|
||||
function Wrapper({ title, children }: PropsWithChildren<WrapperProps>) {
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
<h2 className='text-lg font-semibold'>{title}</h2>
|
||||
<div className={"flex w-full flex-wrap items-center gap-1.5"}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,64 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "~/utils";
|
||||
import { useMemo } from "react";
|
||||
import { getting_started, migration, new_user, shared_drive } from "~/data/docs";
|
||||
|
||||
import Markdown from "~/app/@markdown";
|
||||
import Icon from "~/components/Icon";
|
||||
import { Icon, Markdown } from "~/components/Global";
|
||||
import { ConfigurationForm } from "~/components/Guide/Configuration";
|
||||
import { ThemeForm } from "~/components/Guide/Theme";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import {
|
||||
Configuration,
|
||||
CustomizeTheme,
|
||||
getting_started,
|
||||
migration_guide,
|
||||
new_user_guide,
|
||||
shared_drive_guide,
|
||||
} from "./docs";
|
||||
|
||||
// type Section =
|
||||
// | "start"
|
||||
// | "new-user"
|
||||
// | "shared-drive"
|
||||
// | "migrating"
|
||||
// | "config"
|
||||
// | "theme";
|
||||
const SchemaSection = z.enum([
|
||||
"start",
|
||||
"new-user",
|
||||
"shared-drive",
|
||||
"migrating",
|
||||
"config",
|
||||
"theme",
|
||||
]);
|
||||
type Section = z.infer<typeof SchemaSection>;
|
||||
const Sections = ["start", "new-user", "shared-drive", "migrating", "config", "theme"] as const;
|
||||
type Section = (typeof Sections)[number];
|
||||
type SectionItem = {
|
||||
id: Section;
|
||||
title: string;
|
||||
icon: keyof typeof icons;
|
||||
};
|
||||
|
||||
export default function DeployGuidePage() {
|
||||
const sectionMenu = useMemo<SectionItem[]>(
|
||||
() => [
|
||||
// {
|
||||
// id: "start",
|
||||
// title: "Getting Started",
|
||||
// icon: "NotebookText",
|
||||
// },
|
||||
{
|
||||
id: "new-user",
|
||||
title: "New User Guide",
|
||||
@@ -74,95 +40,27 @@ export default function DeployGuidePage() {
|
||||
title: "Shared Drive Guide",
|
||||
icon: "Database",
|
||||
},
|
||||
// {
|
||||
// id: "config",
|
||||
// title: "App Configuration",
|
||||
// icon: "Settings",
|
||||
// },
|
||||
// {
|
||||
// id: "theme",
|
||||
// title: "Theme Customization",
|
||||
// icon: "PaintRoller",
|
||||
// },
|
||||
],
|
||||
[],
|
||||
);
|
||||
const [sectionOpen, setSectionOpen] = useState<boolean>(false);
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto w-full max-w-screen-desktop",
|
||||
"gap-3 p-3",
|
||||
"flex-grow-0",
|
||||
"flex flex-col",
|
||||
)}
|
||||
>
|
||||
<div className='fixed bottom-6 right-6 z-10'>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
<div className={cn("relative mx-auto w-full max-w-screen-desktop", "gap-3 p-3", "flex-grow-0", "flex flex-col")}>
|
||||
<Alert className='bg-blue-50 text-blue-600 dark:bg-blue-950/25 dark:text-blue-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='ChevronUp'
|
||||
size='1.25rem'
|
||||
name='Info'
|
||||
className='size-5'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
id='fab'
|
||||
className={cn("bottom-6 right-6 z-10 hidden")}
|
||||
>
|
||||
<DropdownMenu
|
||||
open={sectionOpen}
|
||||
onOpenChange={setSectionOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
>
|
||||
<Icon
|
||||
name='SquareMenu'
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
side='left'
|
||||
>
|
||||
{sectionMenu.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
asChild
|
||||
>
|
||||
<div
|
||||
// href={`#${item.id}`}
|
||||
className='flex w-full items-center justify-between gap-6'
|
||||
onClick={() => {
|
||||
const target = document.getElementById(item.id);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
<Icon
|
||||
name={item.icon}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>About this guide</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className='w-full whitespace-pre-wrap text-pretty'>{`If you can see this guide on your deployment.
|
||||
Please set the \`showDeployGuide\` to false on the config files. Since it will take up the \`deploy\` route.`}</p>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
@@ -172,25 +70,8 @@ export default function DeployGuidePage() {
|
||||
<CardContent>
|
||||
<Markdown
|
||||
content={getting_started}
|
||||
view='markdown'
|
||||
className='px-0'
|
||||
/>
|
||||
|
||||
<Alert className='bg-blue-50 text-blue-600 dark:bg-blue-950/25 dark:text-blue-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='Info'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>About this guide</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className='w-full whitespace-pre-wrap text-pretty'>{`If you're from previous version, your deployment might have this deployment guide.
|
||||
You can disable it from the config files, there a new setting called 'showDeployGuide' you need to set to false.`}</p>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -207,21 +88,7 @@ You can disable it from the config files, there a new setting called 'showDeploy
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{/* <TabsContent value='start'>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<CardTitle className='text-3xl'>Deployment Guide</CardTitle>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown
|
||||
content={getting_started}
|
||||
view='markdown'
|
||||
className='px-0'
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent> */}
|
||||
|
||||
<TabsContent value='new-user'>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
@@ -230,8 +97,7 @@ You can disable it from the config files, there a new setting called 'showDeploy
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown
|
||||
content={new_user_guide}
|
||||
view='markdown'
|
||||
content={new_user}
|
||||
className='px-0'
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -245,7 +111,7 @@ You can disable it from the config files, there a new setting called 'showDeploy
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown
|
||||
content={shared_drive_guide}
|
||||
content={shared_drive}
|
||||
view='markdown'
|
||||
className='px-0'
|
||||
/>
|
||||
@@ -260,8 +126,7 @@ You can disable it from the config files, there a new setting called 'showDeploy
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown
|
||||
content={migration_guide}
|
||||
view='markdown'
|
||||
content={migration}
|
||||
className='px-0'
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -279,9 +144,7 @@ You can disable it from the config files, there a new setting called 'showDeploy
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>
|
||||
The value of the form will be reset when you switch tabs.
|
||||
</AlertDescription>
|
||||
<AlertDescription>The value of the form will be reset when you switch tabs.</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
@@ -303,11 +166,12 @@ You can disable it from the config files, there a new setting called 'showDeploy
|
||||
Theme Customization
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='config'>
|
||||
<Configuration />
|
||||
<ConfigurationForm />
|
||||
</TabsContent>
|
||||
<TabsContent value='theme'>
|
||||
<CustomizeTheme />
|
||||
<ThemeForm />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
+42
-79
@@ -1,35 +1,26 @@
|
||||
import { Metadata, ResolvedMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { Actions, Explorer, FilePath, Readme } from "~/components/Explorer";
|
||||
import { Password } from "~/components/Layout";
|
||||
import { PreviewLayout } from "~/components/Preview";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CheckPassword, CheckPaths, GetBanner, GetFile, GetFiles, GetReadme } from "actions";
|
||||
import config from "config";
|
||||
|
||||
import FileBrowser from "../@explorer";
|
||||
import Header from "../@header";
|
||||
import HeaderButton from "../@header.button";
|
||||
import Password from "../@password";
|
||||
import FilePreviewLayout from "../@preview.layout";
|
||||
import Readme from "../@readme";
|
||||
import {
|
||||
CheckPassword,
|
||||
CheckPaths,
|
||||
GetBanner,
|
||||
GetFile,
|
||||
GetFiles,
|
||||
GetReadme,
|
||||
} from "../actions";
|
||||
import DeployGuidePage from "./deploy";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const revalidate = 60;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
@@ -38,12 +29,8 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params: { rest } }: Props,
|
||||
parent: ResolvedMetadata,
|
||||
): Promise<Metadata> {
|
||||
if (rest[0] === "deploy" && config.showDeployGuide)
|
||||
return { title: "Deploy Guide" };
|
||||
export async function generateMetadata({ params: { rest } }: Props, parent: ResolvedMetadata): Promise<Metadata> {
|
||||
if (rest.length === 1 && rest[0] === "deploy" && config.showDeployGuide) return { title: "Deploy Guide" };
|
||||
|
||||
const paths = await CheckPaths(rest);
|
||||
if (!paths.success) return { title: "Not Found" };
|
||||
@@ -56,17 +43,12 @@ export async function generateMetadata(
|
||||
|
||||
return {
|
||||
title: data.name,
|
||||
description: data.mimeType?.includes("folder")
|
||||
? `Browse ${data.name} files`
|
||||
: `View ${data.name}`,
|
||||
description: data.mimeType?.includes("folder") ? `Browse ${data.name} files` : `View ${data.name}`,
|
||||
openGraph: {
|
||||
images: banner
|
||||
? [
|
||||
{
|
||||
url: `/api/og/${banner}`,
|
||||
// url: `/api/og/${
|
||||
// data.mimeType.startsWith("image") ? data.encryptedId : banner
|
||||
// }`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
@@ -77,8 +59,7 @@ export async function generateMetadata(
|
||||
}
|
||||
|
||||
export default async function RestPage({ params: { rest } }: Props) {
|
||||
if (rest[0] === "deploy" && config.showDeployGuide)
|
||||
return <DeployGuidePage />;
|
||||
if (rest.length === 1 && rest[0] === "deploy" && config.showDeployGuide) return <DeployGuidePage />;
|
||||
|
||||
const paths = await CheckPaths(rest);
|
||||
if (!paths.success) notFound();
|
||||
@@ -86,11 +67,7 @@ export default async function RestPage({ params: { rest } }: Props) {
|
||||
|
||||
if (!unlocked.success) {
|
||||
if (!unlocked.path)
|
||||
throw new Error(
|
||||
`No path returned from password checking${
|
||||
unlocked.message && `, ${unlocked.message}`
|
||||
}`,
|
||||
);
|
||||
throw new Error(`No path returned from password checking${unlocked.message && `, ${unlocked.message}`}`);
|
||||
return (
|
||||
<Password
|
||||
path={unlocked.path}
|
||||
@@ -101,10 +78,10 @@ export default async function RestPage({ params: { rest } }: Props) {
|
||||
}
|
||||
|
||||
const encryptedId = paths.data.pop()?.id;
|
||||
if (!encryptedId)
|
||||
throw new Error("Failed to get encrypted ID, try to refresh the page.");
|
||||
if (!encryptedId) throw new Error("Failed to get encrypted ID, try to refresh the page.");
|
||||
|
||||
const promise = [];
|
||||
|
||||
const { data: file } = await gdrive.files.get({
|
||||
fileId: await decryptData(encryptedId),
|
||||
fields: "mimeType, fileExtension",
|
||||
@@ -123,35 +100,33 @@ export default async function RestPage({ params: { rest } }: Props) {
|
||||
if (file.success) {
|
||||
return values as [z.infer<typeof Schema_File>, string];
|
||||
} else {
|
||||
return values as [
|
||||
{ files: z.infer<typeof Schema_File>[]; nextPageToken?: string },
|
||||
string,
|
||||
];
|
||||
return values as [{ files: z.infer<typeof Schema_File>[]; nextPageToken?: string }, string];
|
||||
}
|
||||
});
|
||||
let fileType;
|
||||
if (file.fileExtension && file.mimeType) {
|
||||
fileType = getFileType(file.fileExtension, file.mimeType);
|
||||
}
|
||||
const isFile = !("files" in data);
|
||||
console.log(
|
||||
rest.map((item, index, array) => ({
|
||||
label: decodeURIComponent(item),
|
||||
href: index === array.length - 1 ? undefined : `${item}`,
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("h-fit w-full", "flex flex-col gap-3")}>
|
||||
<Header
|
||||
name='Root'
|
||||
breadcrumb={rest.map((item, index, array) => ({
|
||||
<FilePath
|
||||
data={rest.map((item, index, array) => ({
|
||||
label: decodeURIComponent(item),
|
||||
href: index === array.length - 1 ? undefined : `${item}`,
|
||||
}))}
|
||||
/>
|
||||
<div
|
||||
|
||||
<section
|
||||
slot='content'
|
||||
className='w-full'
|
||||
>
|
||||
{isFile ? (
|
||||
<FilePreviewLayout
|
||||
{!("files" in data) ? (
|
||||
<PreviewLayout
|
||||
data={data}
|
||||
fileType={fileType || "unknown"}
|
||||
fileType={file.fileExtension && file.mimeType ? getFileType(file.fileExtension, file.mimeType) : "unknown"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -159,40 +134,28 @@ export default async function RestPage({ params: { rest } }: Props) {
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
<HeaderButton />
|
||||
<Actions />
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<FileBrowser
|
||||
<Explorer
|
||||
encryptedId={encryptedId}
|
||||
files={data.files}
|
||||
nextPageToken={data.nextPageToken}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{readme && (
|
||||
<Readme
|
||||
content={readme}
|
||||
title={"README.md"}
|
||||
/>
|
||||
// <div
|
||||
// slot='readme'
|
||||
// className='w-full'
|
||||
// >
|
||||
// <Card>
|
||||
// <CardHeader className='pb-0'>
|
||||
// <CardTitle>README.md</CardTitle>
|
||||
// <Separator />
|
||||
// </CardHeader>
|
||||
// <CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
// <Markdown content={readme} />
|
||||
// </CardContent>
|
||||
// </Card>
|
||||
// </div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{readme && (
|
||||
<Readme
|
||||
content={readme}
|
||||
title={"README.md"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
CheckDownloadToken,
|
||||
CheckPassword,
|
||||
CheckPaths,
|
||||
CheckSitePassword,
|
||||
RedirectSearchFile,
|
||||
} from "~/app/actions";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import { CheckDownloadToken, CheckPassword, CheckPaths, CheckSitePassword, RedirectSearchFile } from "actions";
|
||||
import config from "config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -33,10 +26,7 @@ export async function GET(
|
||||
const tokenValidity = await CheckDownloadToken(token);
|
||||
if (!tokenValidity.success) throw new Error(tokenValidity.message);
|
||||
|
||||
if (
|
||||
config.siteConfig.privateIndex &&
|
||||
!config.apiConfig.allowDownloadProtectedFile
|
||||
) {
|
||||
if (config.siteConfig.privateIndex && !config.apiConfig.allowDownloadProtectedFile) {
|
||||
const unlocked = await CheckSitePassword();
|
||||
if (!unlocked.success) {
|
||||
return new NextResponse(
|
||||
@@ -65,12 +55,9 @@ If you've already entered the password, please make sure your browser is not blo
|
||||
if (!checkPath.success) throw new Error("File not found");
|
||||
const unlocked = await CheckPassword(checkPath.data);
|
||||
if (!unlocked.success) {
|
||||
if (!unlocked.path)
|
||||
throw new Error("No path returned from password checking");
|
||||
if (!unlocked.path) throw new Error("No path returned from password checking");
|
||||
|
||||
const lockedIndex = checkPath.data.findIndex(
|
||||
(path) => path.id === unlocked.path,
|
||||
);
|
||||
const lockedIndex = checkPath.data.findIndex((path) => path.id === unlocked.path);
|
||||
// Get all path until the locked index, then join them
|
||||
const path = checkPath.data
|
||||
.slice(0, lockedIndex + 1)
|
||||
@@ -91,13 +78,9 @@ If you've already entered the password, please make sure your browser is not blo
|
||||
}
|
||||
|
||||
const fileSize = Number(fileMeta.data.size || 0);
|
||||
if (!fileMeta.data.webContentLink)
|
||||
throw new Error("No download link found");
|
||||
if (!fileMeta.data.webContentLink) throw new Error("No download link found");
|
||||
|
||||
if (
|
||||
config.apiConfig.maxFileSize &&
|
||||
fileSize > config.apiConfig.maxFileSize
|
||||
) {
|
||||
if (config.apiConfig.maxFileSize && fileSize > config.apiConfig.maxFileSize) {
|
||||
const contentUrl = new URL(fileMeta.data.webContentLink);
|
||||
contentUrl.searchParams.set("confirm", "1");
|
||||
return NextResponse.redirect(contentUrl, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { encryptData } from "~/utils/encryptionHelper/hash";
|
||||
import { encryptData } from "~/utils/encryptionHelper";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -8,11 +8,7 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const sp = new URL(request.nextUrl).searchParams;
|
||||
const query = sp.get("q");
|
||||
if (!query)
|
||||
return new NextResponse(
|
||||
"Add query parameter 'q' with the value to encrypt",
|
||||
{ status: 400 },
|
||||
);
|
||||
if (!query) return new NextResponse("Add query parameter 'q' with the value to encrypt", { status: 400 });
|
||||
|
||||
const encrypted = await encryptData(query);
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import gdrive from "~/utils/gdriveInstance";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
@@ -11,10 +11,7 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params: { encryptedId } }: Props,
|
||||
) {
|
||||
export async function GET(request: NextRequest, { params: { encryptedId } }: Props) {
|
||||
try {
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
const { data } = await gdrive.files.get({
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { CheckPassword, CheckPaths, GetFile } from "~/app/actions";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import { CheckPassword, CheckPaths, GetFile } from "actions";
|
||||
import config from "config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params: { rest } }: { params: { rest: string[] } },
|
||||
) {
|
||||
export async function GET(request: NextRequest, { params: { rest } }: { params: { rest: string[] } }) {
|
||||
try {
|
||||
const sp = new URL(request.nextUrl).searchParams;
|
||||
const token = sp.get("token");
|
||||
@@ -22,31 +19,18 @@ export async function GET(
|
||||
if (!config.apiConfig.allowDownloadProtectedFile) {
|
||||
const unlocked = await CheckPassword(paths.data);
|
||||
if (!unlocked.success)
|
||||
throw new Error(
|
||||
unlocked.path
|
||||
? unlocked.message
|
||||
: "No path returned from password checking",
|
||||
);
|
||||
throw new Error(unlocked.path ? unlocked.message : "No path returned from password checking");
|
||||
}
|
||||
|
||||
const encryptedId = paths.data.pop()?.id;
|
||||
if (!encryptedId)
|
||||
throw new Error("Failed to get encrypted ID, try to refresh the page.");
|
||||
if (!encryptedId) throw new Error("Failed to get encrypted ID, try to refresh the page.");
|
||||
if (token !== encryptedId) throw new Error("Invalid token");
|
||||
|
||||
const data = await GetFile(encryptedId);
|
||||
if (data.mimeType?.includes("folder"))
|
||||
throw new Error("Can't download folder");
|
||||
if (
|
||||
!data.mimeType.includes("video") &&
|
||||
!data.mimeType.includes("image") &&
|
||||
!data.mimeType.includes("audio")
|
||||
)
|
||||
throw new Error(
|
||||
"Raw link only available for video, image, and audio files",
|
||||
);
|
||||
if (!data.encryptedWebContentLink)
|
||||
throw new Error("No download link found");
|
||||
if (data.mimeType?.includes("folder")) throw new Error("Can't download folder");
|
||||
if (!data.mimeType.includes("video") && !data.mimeType.includes("image") && !data.mimeType.includes("audio"))
|
||||
throw new Error("Raw link only available for video, image, and audio files");
|
||||
if (!data.encryptedWebContentLink) throw new Error("No download link found");
|
||||
|
||||
const decryptedWebContent = await decryptData(data.encryptedWebContentLink);
|
||||
return new NextResponse(null, {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Readable } from "stream";
|
||||
|
||||
async function* nodeStreamToIterator(stream: any) {
|
||||
for await (const chunk of stream) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
function iteratorToStream(iterator: AsyncGenerator<any>) {
|
||||
return new ReadableStream({
|
||||
async pull(controller) {
|
||||
const { done, value } = await iterator.next();
|
||||
if (done) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
export function streamFile(body: Readable): ReadableStream {
|
||||
const data: ReadableStream = iteratorToStream(nodeStreamToIterator(body));
|
||||
return data;
|
||||
}
|
||||
@@ -1,17 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
CheckDownloadToken,
|
||||
CheckPassword,
|
||||
CheckPaths,
|
||||
RedirectSearchFile,
|
||||
} from "~/app/actions";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { gdriveNoCache as gdrive } from "~/utils/gdriveInstance";
|
||||
import isDev from "~/utils/isDev";
|
||||
import { isDev } from "~/utils/isDev";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import { CheckDownloadToken, CheckPassword, CheckPaths, RedirectSearchFile } from "actions";
|
||||
import config from "config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -57,18 +51,12 @@ export async function GET(
|
||||
|
||||
const [filePaths, fileMeta] = await Promise.all([_filePaths, _fileMeta]);
|
||||
|
||||
const isFull =
|
||||
Number(request.headers.get("Range")?.split("-")[1] || 0) ===
|
||||
Number(fileMeta.data.size || "1") - 1;
|
||||
const isFull = Number(request.headers.get("Range")?.split("-")[1] || 0) === Number(fileMeta.data.size || "1") - 1;
|
||||
|
||||
const fileSize = Number(fileMeta.data.size || 0);
|
||||
if (!fileMeta.data.webContentLink)
|
||||
throw new Error("No download link found");
|
||||
if (!fileMeta.data.webContentLink) throw new Error("No download link found");
|
||||
|
||||
if (
|
||||
config.apiConfig.streamMaxSize &&
|
||||
fileSize > config.apiConfig.streamMaxSize
|
||||
) {
|
||||
if (config.apiConfig.streamMaxSize && fileSize > config.apiConfig.streamMaxSize) {
|
||||
throw new Error("File is too large to stream");
|
||||
}
|
||||
|
||||
@@ -77,12 +65,9 @@ export async function GET(
|
||||
if (!checkPath.success) throw new Error("File not found");
|
||||
const unlocked = await CheckPassword(checkPath.data);
|
||||
if (!unlocked.success) {
|
||||
if (!unlocked.path)
|
||||
throw new Error("No path returned from password checking");
|
||||
if (!unlocked.path) throw new Error("No path returned from password checking");
|
||||
|
||||
const lockedIndex = checkPath.data.findIndex(
|
||||
(path) => path.id === unlocked.path,
|
||||
);
|
||||
const lockedIndex = checkPath.data.findIndex((path) => path.id === unlocked.path);
|
||||
// Get all path until the locked index, then join them
|
||||
const path = checkPath.data
|
||||
.slice(0, lockedIndex + 1)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper/hash";
|
||||
import isDev from "~/utils/isDev";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { isDev } from "~/utils/isDev";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
@@ -12,10 +12,7 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params: { encryptedId } }: Props,
|
||||
) {
|
||||
export async function GET(request: NextRequest, { params: { encryptedId } }: Props) {
|
||||
try {
|
||||
const searchParams = new URL(request.nextUrl).searchParams;
|
||||
const size = searchParams.get("size") || "512";
|
||||
@@ -30,12 +27,9 @@ export async function GET(
|
||||
throw new Error("Invalid size");
|
||||
}
|
||||
|
||||
const defaultImage = NextResponse.redirect(
|
||||
new URL("/og.png", config.basePath),
|
||||
{
|
||||
status: 302,
|
||||
},
|
||||
);
|
||||
const defaultImage = NextResponse.redirect(new URL("/og.png", config.basePath), {
|
||||
status: 302,
|
||||
});
|
||||
const decryptedId = await decryptData(encryptedId);
|
||||
|
||||
const url = `https://drive.google.com/thumbnail?id=${decryptedId}&sz=w${size}`;
|
||||
|
||||
+8
-22
@@ -2,42 +2,28 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-md",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-3")}>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={32}
|
||||
size={"2rem"}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-center text-destructive'>
|
||||
Something went wrong
|
||||
</span>
|
||||
<span className='text-center text-sm text-destructive'>
|
||||
More details can be found in the console
|
||||
</span>
|
||||
<span className='text-center text-destructive'>Something went wrong</span>
|
||||
<span className='text-center text-sm text-destructive'>More details can be found in the console</span>
|
||||
</div>
|
||||
<code className='w-full whitespace-pre-wrap rounded-[var(--radius)] bg-muted px-3 py-1.5 text-sm text-muted-foreground'>
|
||||
{error.message}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Version: ${pkg.version} */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -50,6 +51,10 @@
|
||||
--ring: 0 0% 14.9%;
|
||||
/* --ring: 0 0% 83.1%; */
|
||||
}
|
||||
|
||||
div.preview * {
|
||||
@apply !transition-none;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
+17
-19
@@ -1,19 +1,18 @@
|
||||
import { Metadata } from "next";
|
||||
import { JetBrains_Mono, Outfit, Source_Sans_3 } from "next/font/google";
|
||||
import "plyr-react/plyr.css";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { Footer, Navbar, Password, ThemeProvider } from "~/components/Layout";
|
||||
import ToTop from "~/components/Layout/ToTop";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatFooter } from "~/utils/footerFormatter";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import { CheckSitePassword } from "actions";
|
||||
import config from "config";
|
||||
|
||||
import Footer from "./@footer";
|
||||
import Navbar from "./@navbar";
|
||||
import Password from "./@password";
|
||||
import { CheckSitePassword } from "./actions";
|
||||
import "./globals.css";
|
||||
import "./markdown.css";
|
||||
import ThemeProvider from "./theme-provider";
|
||||
|
||||
const sourceSans3 = Source_Sans_3({
|
||||
weight: ["300", "400", "600", "700"],
|
||||
@@ -41,11 +40,7 @@ export const metadata: Metadata = {
|
||||
metadataBase: new URL(config.basePath),
|
||||
title: {
|
||||
default: config.siteConfig.siteName,
|
||||
template:
|
||||
config.siteConfig.siteNameTemplate?.replace(
|
||||
"%t",
|
||||
config.siteConfig.siteName,
|
||||
) || "%s",
|
||||
template: config.siteConfig.siteNameTemplate?.replace("%t", config.siteConfig.siteName) || "%s",
|
||||
},
|
||||
description: config.siteConfig.siteDescription,
|
||||
authors: config.siteConfig.siteAuthor
|
||||
@@ -78,10 +73,14 @@ export const metadata: Metadata = {
|
||||
robots: config.siteConfig.robots,
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const unlocked = await CheckSitePassword();
|
||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
let unlocked: Awaited<ReturnType<typeof CheckSitePassword>> = {
|
||||
success: true,
|
||||
message: "",
|
||||
};
|
||||
if (config.siteConfig.privateIndex) {
|
||||
unlocked = await CheckSitePassword();
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang='en'>
|
||||
@@ -118,9 +117,8 @@ export default async function RootLayout({
|
||||
<>{children}</>
|
||||
)}
|
||||
</main>
|
||||
{config.siteConfig.footer && (
|
||||
<Footer content={formatFooter(config.siteConfig.footer)} />
|
||||
)}
|
||||
{config.siteConfig.footer && <Footer content={formatFooter(config.siteConfig.footer)} />}
|
||||
<ToTop />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-18
@@ -1,23 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Loader } from "~/components/Global";
|
||||
|
||||
export default function RootLoading() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading data...</p>
|
||||
</div>
|
||||
);
|
||||
return <Loader message='Loading data...' />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import NotFoundComponent from "./@not-found";
|
||||
import { NotFound } from "~/components/Layout";
|
||||
|
||||
export default function NotFound() {
|
||||
return <NotFoundComponent />;
|
||||
export default function NotFoundPage() {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
+16
-32
@@ -1,27 +1,23 @@
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { Actions, Explorer, FilePath, Readme } from "~/components/Explorer";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import FileBrowser from "./@explorer";
|
||||
import Header from "./@header";
|
||||
import HeaderButton from "./@header.button";
|
||||
import Readme from "./@readme";
|
||||
import { GetFiles, GetReadme } from "./actions";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
import { GetFiles, GetReadme } from "actions";
|
||||
import config from "config";
|
||||
|
||||
export const revalidate = 60;
|
||||
// export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RootPage() {
|
||||
const [data, readme] = await Promise.all([
|
||||
GetFiles({}),
|
||||
GetReadme(undefined),
|
||||
]);
|
||||
const [data, readme] = await Promise.all([GetFiles({}), GetReadme(undefined)]);
|
||||
|
||||
return (
|
||||
<div className={cn("h-fit w-full", "flex flex-col gap-3")}>
|
||||
<Header name='Root' />
|
||||
<div
|
||||
<FilePath data={[]} />
|
||||
|
||||
<section
|
||||
slot='content'
|
||||
className='w-full'
|
||||
>
|
||||
@@ -29,38 +25,26 @@ export default async function RootPage() {
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<CardTitle className='flex-grow'>Browse files</CardTitle>
|
||||
<HeaderButton />
|
||||
<Actions />
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<FileBrowser
|
||||
<Explorer
|
||||
encryptedId={config.apiConfig.rootFolder}
|
||||
files={data.files}
|
||||
nextPageToken={data.nextPageToken}
|
||||
root
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{readme && (
|
||||
<Readme
|
||||
content={readme}
|
||||
title={"README.md"}
|
||||
/>
|
||||
// <div
|
||||
// slot='readme'
|
||||
// className='w-full'
|
||||
// >
|
||||
// <Card>
|
||||
// <CardHeader className='pb-0'>
|
||||
// <CardTitle>README.md</CardTitle>
|
||||
// <Separator />
|
||||
// </CardHeader>
|
||||
// <CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
// <Markdown content={readme} />
|
||||
// </CardContent>
|
||||
// </Card>
|
||||
// </div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren, useContext, useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,52 +21,43 @@ import {
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import { RedirectSearchFile, SearchFile } from "./actions";
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
export default function HeaderButton({ children }: PropsWithChildren) {
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { RedirectSearchFile, SearchFile } from "actions";
|
||||
|
||||
export default function Actions() {
|
||||
const loading = useLoading();
|
||||
const { layout, setLayout } = useContext(LayoutContext);
|
||||
|
||||
const [layoutOpen, setLayoutOpen] = useState<boolean>(false);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [snap, setSnap] = useState<number | string | null>(0.3);
|
||||
const [searchOpen, setSearchOpen] = useState<boolean>(false);
|
||||
const [searchInput, setSearchInput] = useState<string>("");
|
||||
const [debouncedSearchInput, setDebouncedSearchInput] = useState<string>("");
|
||||
const [searchLoading, setSearchLoading] = useState<boolean>(false);
|
||||
const [searchError, setSearchError] = useState<string>("");
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
z.infer<typeof Schema_File>[]
|
||||
>([]);
|
||||
const [searchResults, setSearchResults] = useState<z.infer<typeof Schema_File>[]>([]);
|
||||
const [nextPageToken, setNextPageToken] = useState<string>();
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInput) {
|
||||
setSearchLoading(true);
|
||||
@@ -106,148 +96,110 @@ export default function HeaderButton({ children }: PropsWithChildren) {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("w-fit", "flex items-center justify-end gap-1.5")}>
|
||||
<Skeleton className='my-0.5 h-8 w-16 mobile:w-24' />{" "}
|
||||
<Skeleton className='my-0.5 h-8 w-8' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading)
|
||||
<div className={cn("w-fit", "flex items-center justify-end gap-1.5")}>
|
||||
<Skeleton className='my-0.5 h-8 w-16 mobile:w-24' /> <Skeleton className='my-0.5 h-8 w-8' />
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// "mx-auto max-w-screen-desktop flex-grow",
|
||||
"w-fit",
|
||||
"flex items-center justify-end gap-1.5",
|
||||
)}
|
||||
slot='actions'
|
||||
className={cn("w-fit", "flex items-center justify-end gap-1.5")}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<DropdownMenu
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='flex w-24 items-center justify-between gap-1.5'
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Icon
|
||||
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
|
||||
size={16}
|
||||
/>
|
||||
<span>{layout === "grid" ? "Grid" : "List"}</span>
|
||||
</div>
|
||||
<ResponsiveDropdown
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
trigger={
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='flex w-24 items-center justify-between gap-1.5'
|
||||
>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Icon
|
||||
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
<span>{layout === "grid" ? "Grid" : "List"}</span>
|
||||
</div>
|
||||
{isDesktop && (
|
||||
<Icon
|
||||
name='ChevronDown'
|
||||
className={cn("transition", layoutOpen && "rotate-180")}
|
||||
size={14}
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"gap-1.5",
|
||||
layout === "grid" && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
onClick={() => setLayout("grid")}
|
||||
>
|
||||
<Icon
|
||||
name={layout === "grid" ? "Check" : "LayoutGrid"}
|
||||
size={16}
|
||||
/>
|
||||
<span>Grid</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"gap-1.5",
|
||||
layout === "list" && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
onClick={() => setLayout("list")}
|
||||
>
|
||||
<Icon
|
||||
name={layout === "list" ? "Check" : "LayoutList"}
|
||||
size={16}
|
||||
/>
|
||||
<span>List</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Drawer
|
||||
open={layoutOpen}
|
||||
onOpenChange={setLayoutOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
>
|
||||
<Icon
|
||||
name={layout === "grid" ? "LayoutGrid" : "LayoutList"}
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Layout</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Choose a layout for the files explorer
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='flex h-full flex-col gap-3 px-4'>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
mobile={{
|
||||
title: "File Layout",
|
||||
description: "Choose a layout for the files explorer",
|
||||
content: (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"justify-start gap-3",
|
||||
layout === "grid" && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
variant={layout === "grid" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setLayout("grid");
|
||||
setLayoutOpen(false);
|
||||
}}
|
||||
className='justify-start gap-3'
|
||||
>
|
||||
<Icon
|
||||
name={layout === "grid" ? "Check" : "LayoutGrid"}
|
||||
size={16}
|
||||
size={"1rem"}
|
||||
/>
|
||||
<span>Grid</span>
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className={cn(
|
||||
"justify-start gap-3",
|
||||
layout === "list" && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
variant={layout === "list" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setLayout("list");
|
||||
setLayoutOpen(false);
|
||||
}}
|
||||
className='justify-start gap-3'
|
||||
>
|
||||
<Icon
|
||||
name={layout === "list" ? "Check" : "LayoutList"}
|
||||
size={16}
|
||||
size={"1rem"}
|
||||
/>
|
||||
<span>List</span>
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button variant={"secondary"}>Close</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
desktop={{
|
||||
align: "end",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
key: "grid",
|
||||
icon: "LayoutGrid",
|
||||
title: "Grid",
|
||||
onClick: () => {
|
||||
setLayout("grid");
|
||||
setLayoutOpen(false);
|
||||
},
|
||||
className: cn(layout === "grid" && "bg-primary text-primary-foreground"),
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
key: "list",
|
||||
icon: "LayoutList",
|
||||
title: "List",
|
||||
onClick: () => {
|
||||
setLayout("list");
|
||||
setLayoutOpen(false);
|
||||
},
|
||||
className: cn(layout === "list" && "bg-primary text-primary-foreground"),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
{isDesktop ? (
|
||||
<Dialog
|
||||
open={searchOpen}
|
||||
@@ -267,16 +219,14 @@ export default function HeaderButton({ children }: PropsWithChildren) {
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={16}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search for files in your drive
|
||||
</DialogDescription>
|
||||
<DialogDescription>Search for files in your drive</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
<div
|
||||
@@ -310,52 +260,26 @@ export default function HeaderButton({ children }: PropsWithChildren) {
|
||||
<Separator />
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-full max-h-96 flex-grow flex-col gap-1.5 overflow-y-auto'
|
||||
className='flex h-full max-h-[66dvh] flex-grow flex-col gap-1.5 overflow-y-auto'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={24}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
Start typing to search
|
||||
</span>
|
||||
</div>
|
||||
<Status
|
||||
icon='Search'
|
||||
message='Start typing to search'
|
||||
/>
|
||||
) : searchLoading ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={24}
|
||||
className='animate-spin text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
Searching...
|
||||
</span>
|
||||
</div>
|
||||
<Loader message='Searching...' />
|
||||
) : searchError ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>
|
||||
{searchError}
|
||||
</span>
|
||||
</div>
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message={searchError}
|
||||
destructive
|
||||
/>
|
||||
) : !searchResults.length ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={24}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
We couldn't find any results
|
||||
</span>
|
||||
</div>
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="We couldn't find any results"
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
{searchResults.map((result) => (
|
||||
@@ -394,17 +318,17 @@ export default function HeaderButton({ children }: PropsWithChildren) {
|
||||
>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={16}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
|
||||
<DrawerContent className='h-full max-h-[95%]'>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Search</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Search for files in your drive
|
||||
</DrawerDescription>
|
||||
<DrawerDescription>Search for files in your drive</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
<div
|
||||
slot='search-input'
|
||||
@@ -434,55 +358,31 @@ export default function HeaderButton({ children }: PropsWithChildren) {
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='search-result'
|
||||
className='flex h-fit flex-grow flex-col gap-1.5 overflow-y-auto px-4'
|
||||
>
|
||||
{!debouncedSearchInput ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='Search'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
Start typing to search
|
||||
</span>
|
||||
</div>
|
||||
<Status
|
||||
icon='Search'
|
||||
message='Start typing to search'
|
||||
/>
|
||||
) : searchLoading ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
Searching...
|
||||
</span>
|
||||
</div>
|
||||
<Loader message='Searching...' />
|
||||
) : searchError ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={32}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>
|
||||
{searchError}
|
||||
</span>
|
||||
</div>
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message={searchError}
|
||||
destructive
|
||||
/>
|
||||
) : !searchResults.length ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='Frown'
|
||||
size={32}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
<span className='text-center text-muted-foreground'>
|
||||
We couldn't find any results
|
||||
</span>
|
||||
</div>
|
||||
<Status
|
||||
icon='Frown'
|
||||
message="We couldn't find any results"
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col gap-1.5'>
|
||||
{searchResults.map((result) => (
|
||||
@@ -503,15 +403,13 @@ export default function HeaderButton({ children }: PropsWithChildren) {
|
||||
}
|
||||
|
||||
function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(
|
||||
`/api/thumb/${data.encryptedId}?size=2`,
|
||||
);
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
// "w-full",
|
||||
"px-1.5 py-1 pr-10", // since action button is size-8
|
||||
"rounded-[var(--radius)]",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
@@ -540,9 +438,7 @@ function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
<div className={cn("flex-shrink flex-grow", "flex items-center gap-3")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative flex-shrink-0 rounded-[var(--radius)] bg-muted/25'>
|
||||
{data.thumbnailLink &&
|
||||
(data.mimeType.startsWith("video") ||
|
||||
data.mimeType.startsWith("image")) ? (
|
||||
{data.thumbnailLink && (data.mimeType.startsWith("video") || data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
@@ -560,7 +456,7 @@ function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={24}
|
||||
size={"1.5rem"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -568,9 +464,7 @@ function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder")
|
||||
? "Folder"
|
||||
: getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
|
||||
/>
|
||||
@@ -580,9 +474,7 @@ function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
{/* File data */}
|
||||
<div className='flex w-full flex-col'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
{data.fileExtension ? data.name.replace(new RegExp(`.${data.fileExtension}$`), "") : data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
|
||||
@@ -594,16 +486,12 @@ function SearchResultItem({ data }: { data: z.infer<typeof Schema_File> }) {
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{bytesToReadable(data.size || 0)}
|
||||
</span>
|
||||
<span className='whitespace-nowrap text-sm'>{bytesToReadable(data.size || 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>
|
||||
{new Date(data.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
<span className='whitespace-nowrap text-sm'>{new Date(data.modifiedTime).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "~/components/ui/breadcrumb";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
import { Schema_Breadcrumb } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_Breadcrumb>[];
|
||||
};
|
||||
export default function FilePath({ data }: Props) {
|
||||
const loading = useLoading();
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
if (loading) return <Skeleton className='my-2 h-5 w-1/2' />;
|
||||
|
||||
return (
|
||||
<section
|
||||
slot='file-path'
|
||||
className='flex w-full flex-nowrap items-center'
|
||||
>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{/* Root */}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={"/"}>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon
|
||||
name='FolderRoot'
|
||||
size={"1rem"}
|
||||
/>
|
||||
~
|
||||
</div>
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{!!data.length ? (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
|
||||
{data.length >= config.siteConfig.breadcrumbMax && (
|
||||
<>
|
||||
<ResponsiveDropdown
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<div>
|
||||
<BreadcrumbLink asChild>
|
||||
<BreadcrumbEllipsis className='w-4 cursor-pointer' />
|
||||
</BreadcrumbLink>
|
||||
</div>
|
||||
}
|
||||
mobile={{
|
||||
title: "Navigate",
|
||||
description: "Navigate to parent directories",
|
||||
content: data.slice(0, -config.siteConfig.breadcrumbMax + 1).map((item, idx, array) => (
|
||||
<BreadcrumbItem key={`mobile-${item.label}/${item.href}`}>
|
||||
<BreadcrumbLink asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='w-full justify-start'
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
)),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
desktop={{
|
||||
align: "start",
|
||||
items: data.slice(0, -config.siteConfig.breadcrumbMax + 1).map((item, idx, array) => ({
|
||||
type: "item",
|
||||
key: `desktop-${item.label}/${item.href}`,
|
||||
title: item.label,
|
||||
onClick: () => {
|
||||
router.push(
|
||||
`/${array
|
||||
.slice(0, array.indexOf(item) + 1)
|
||||
.map((item) => item.href)
|
||||
.join("/")}`,
|
||||
);
|
||||
},
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
|
||||
<BreadcrumbSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.slice(-config.siteConfig.breadcrumbMax + 1).map((item) => (
|
||||
<Fragment key={`${item.label}/${item.href}`}>
|
||||
<BreadcrumbItem className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.href ? (
|
||||
<>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/${data
|
||||
.map((item) => item.href)
|
||||
.join("/")
|
||||
.replace(/\/\//g, "/")}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</>
|
||||
) : (
|
||||
<BreadcrumbPage className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{item.label}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{item.href && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DrawerClose } from "~/components/ui/drawer";
|
||||
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export default function FileGrid({ data, disabled }: Props) {
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
const path = [pathname, encodeURIComponent(data.name)].join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Copying link...", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(filePath, config.basePath).toString());
|
||||
|
||||
toast.success("Link copied!", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Preparing download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${data.encryptedId}?token=${token}`);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative h-full w-full'>
|
||||
<ResponsiveDropdown
|
||||
slot='actions'
|
||||
open={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
trigger={
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"secondary"}
|
||||
className='absolute right-1 top-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
desktop={{
|
||||
align: "end",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
key: "copy",
|
||||
title: "Copy link",
|
||||
icon: "Link",
|
||||
onClick: onCopy,
|
||||
disabled: disabled,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
key: "download",
|
||||
title: "Download",
|
||||
icon: "Download",
|
||||
onClick: onDownload,
|
||||
disabled: data.mimeType.includes("folder") || disabled,
|
||||
},
|
||||
],
|
||||
}}
|
||||
mobile={{
|
||||
title: "Actions",
|
||||
description: "What would you like to do with this file?",
|
||||
content: (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onCopy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onDownload}
|
||||
disabled={disabled || data.mimeType.includes("folder")}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</>
|
||||
),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Link
|
||||
slot='data'
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"h-full w-full rounded-[var(--radius)]",
|
||||
"border border-border transition",
|
||||
"flex flex-col content-stretch justify-stretch gap-3",
|
||||
"hover:bg-muted/50",
|
||||
)}
|
||||
onClick={async (e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
nProgress.done(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex flex-shrink flex-grow flex-col items-center gap-3", "relative")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative grid h-32 w-full flex-shrink-0 place-items-center overflow-hidden bg-muted/25'>
|
||||
{data.thumbnailLink && (data.mimeType.startsWith("video") || data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='rounded-top-[var(--radius)] absolute -z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-cover opacity-50'
|
||||
/>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='relative z-0 h-32 w-full flex-shrink-0 flex-grow-0 object-contain backdrop-blur'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={"2rem"}
|
||||
/>
|
||||
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-xs text-foreground'>
|
||||
{durationToReadable(data.videoMediaMetadata?.durationMillis || 0)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex h-full w-full flex-col justify-between gap-1.5 px-3 py-1.5'>
|
||||
<span className='line-clamp-2 h-full whitespace-pre-wrap text-pretty break-all'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 whitespace-pre-wrap break-all text-sm",
|
||||
config.siteConfig.showFileExtension && !data.mimeType.includes("folder") ? "hidden" : "",
|
||||
)}
|
||||
>
|
||||
{data.mimeType.includes("folder") ? "folder" : data.fileExtension || "unknown"}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
{config.siteConfig.showFileExtension ? null : (
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
)}
|
||||
<span className='whitespace-nowrap text-sm'>{bytesToReadable(data.size || 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>{new Date(data.modifiedTime).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Grid, List } from "~/components/Explorer";
|
||||
import { ButtonLoading, Icon, Loader, Status } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
|
||||
import { LayoutContext } from "~/context/layoutContext";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import { ButtonState, Schema_File } from "~/types/schema";
|
||||
|
||||
import { GetFiles } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
encryptedId: string;
|
||||
files: z.infer<typeof Schema_File>[];
|
||||
nextPageToken?: string;
|
||||
root?: boolean;
|
||||
};
|
||||
export default function Explorer({ encryptedId, files, nextPageToken, root }: Props) {
|
||||
const { layout } = useContext(LayoutContext);
|
||||
const loading = useLoading();
|
||||
const pathname = usePathname();
|
||||
const prevPath = useMemo<string>(() => {
|
||||
const path = pathname.split("/").slice(0, -1).join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [pathname]);
|
||||
|
||||
const [fileList, setFileList] = useState<z.infer<typeof Schema_File>[]>(files);
|
||||
const [nextToken, setNextToken] = useState<string | undefined>(nextPageToken);
|
||||
const [loadMoreState, setLoadMoreState] = useState<ButtonState>("idle");
|
||||
|
||||
const onLoadMore = async () => {
|
||||
setLoadMoreState("loading");
|
||||
try {
|
||||
if (!nextToken) throw new Error("No more files to load");
|
||||
const id = await decryptData(encryptedId);
|
||||
const data = await GetFiles({ id, pageToken: nextToken });
|
||||
const uniqueData = [...fileList, ...data.files].filter(
|
||||
(item, index, array) => index === array.findIndex((i) => i.encryptedId === item.encryptedId),
|
||||
);
|
||||
|
||||
setFileList(uniqueData);
|
||||
setNextToken(data.nextPageToken);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoadMoreState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loader message='Loading files...' />;
|
||||
|
||||
return (
|
||||
<section
|
||||
slot='explorer'
|
||||
className={cn("mx-auto w-full max-w-screen-desktop", "flex flex-grow-0 flex-col gap-3 p-3")}
|
||||
>
|
||||
{!root && (
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"ghost"}
|
||||
className='justify-start'
|
||||
asChild
|
||||
>
|
||||
<Link href={prevPath}>
|
||||
<Icon
|
||||
name='CornerLeftUp'
|
||||
size={"1rem"}
|
||||
className='mr-3'
|
||||
/>
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!fileList.length ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='There are no files here'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
slot='file-container'
|
||||
className={cn(
|
||||
"w-full",
|
||||
layout === "list"
|
||||
? "flex flex-col gap-1.5"
|
||||
: "grid grid-cols-1 gap-3 mobile:grid-cols-2 tablet:grid-cols-3 desktop:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{fileList.map((file) => (
|
||||
<div
|
||||
slot='item'
|
||||
key={file.encryptedId}
|
||||
className='group'
|
||||
>
|
||||
{layout === "list" ? <List data={file} /> : <Grid data={file} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextToken && (
|
||||
<ButtonLoading
|
||||
className='w-full'
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
state={loadMoreState}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
Load more files
|
||||
</ButtonLoading>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { ResponsiveDropdown } from "~/components/Layout";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DrawerClose } from "~/components/ui/drawer";
|
||||
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getPreviewIcon } from "~/utils/previewHelper";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export default function FileList({ data, disabled }: Props) {
|
||||
const pathname = usePathname();
|
||||
const filePath = useMemo<string>(() => {
|
||||
const path = [pathname, encodeURIComponent(data.name)].join("/").replace(/\/+/g, "/");
|
||||
|
||||
return new URL(path, config.basePath).pathname;
|
||||
}, [data, pathname]);
|
||||
|
||||
const [thumbnailURL, setThumbnailURL] = useState<string>(`/api/thumb/${data.encryptedId}?size=2`);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Copying link...", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(new URL(filePath, config.basePath).toString());
|
||||
|
||||
toast.success("Link copied!", {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `copy-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toast.loading("Preparing download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
|
||||
toast.success("Opening download link...", {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${data.encryptedId}?token=${token}`);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${data.encryptedId}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full w-full flex-row-reverse items-center'>
|
||||
<ResponsiveDropdown
|
||||
slot='actions'
|
||||
open={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
trigger={
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"secondary"}
|
||||
className='absolute right-1 top-1 z-20 size-8'
|
||||
>
|
||||
<Icon
|
||||
name='EllipsisVertical'
|
||||
size={"1rem"}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
desktop={{
|
||||
align: "end",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
key: "copy",
|
||||
title: "Copy link",
|
||||
icon: "Link",
|
||||
onClick: onCopy,
|
||||
disabled: disabled,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
key: "download",
|
||||
title: "Download",
|
||||
icon: "Download",
|
||||
onClick: onDownload,
|
||||
disabled: data.mimeType.includes("folder") || disabled,
|
||||
},
|
||||
],
|
||||
}}
|
||||
mobile={{
|
||||
title: "Actions",
|
||||
description: "What would you like to do with this file?",
|
||||
content: (
|
||||
<>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onCopy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
name='Link'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Copy link
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={onDownload}
|
||||
disabled={disabled || data.mimeType.includes("folder")}
|
||||
>
|
||||
<Icon
|
||||
name='Download'
|
||||
className='mr-3'
|
||||
size={"1rem"}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</>
|
||||
),
|
||||
closeOnFooter: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Link
|
||||
slot='data'
|
||||
href={filePath}
|
||||
className={cn(
|
||||
"relative rounded-[var(--radius)] transition",
|
||||
"px-1.5 py-1 pr-10",
|
||||
"flex flex-grow items-center justify-between gap-3",
|
||||
"hover:bg-muted/50",
|
||||
)}
|
||||
onClick={async (e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
nProgress.done(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("flex-shrink flex-grow", "flex items-center gap-3")}>
|
||||
{/* If it's media, show thumbnail */}
|
||||
<div className='relative flex-shrink-0 rounded-[var(--radius)] bg-muted/25'>
|
||||
{data.thumbnailLink && (data.mimeType.startsWith("video") || data.mimeType.startsWith("image")) ? (
|
||||
<>
|
||||
<img
|
||||
src={thumbnailURL}
|
||||
alt={data.name}
|
||||
onLoad={(e) => {
|
||||
if (thumbnailURL.includes("size=2")) {
|
||||
setThumbnailURL(`/api/thumb/${data.encryptedId}`);
|
||||
}
|
||||
}}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 rounded-[var(--radius)] object-cover tablet:size-20'
|
||||
/>
|
||||
|
||||
{data.mimeType.startsWith("video") && (
|
||||
<>
|
||||
<Icon
|
||||
name='Play'
|
||||
className='absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground fill-muted p-1.5 text-muted opacity-75'
|
||||
size={"1.5rem"}
|
||||
/>
|
||||
<div className='absolute bottom-0 right-0 z-10 bg-background px-1 py-0.5 text-[10px] text-foreground tablet:text-xs'>
|
||||
{durationToReadable(data.videoMediaMetadata?.durationMillis || 0)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Icon
|
||||
name={
|
||||
data.mimeType.includes("folder") ? "Folder" : getPreviewIcon(data.fileExtension || "", data.mimeType)
|
||||
}
|
||||
className='size-16 flex-shrink-0 flex-grow-0 p-3 tablet:size-20'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File data */}
|
||||
<div className='flex flex-grow flex-col'>
|
||||
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
|
||||
{config.siteConfig.showFileExtension
|
||||
? data.name
|
||||
: data.fileExtension
|
||||
? data.name.replace(new RegExp(`.${data.fileExtension}$`), "")
|
||||
: data.name}
|
||||
</span>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 whitespace-pre-wrap break-all text-sm",
|
||||
config.siteConfig.showFileExtension && !data.mimeType.includes("folder") ? "hidden" : "",
|
||||
)}
|
||||
>
|
||||
{data.mimeType.includes("folder") ? "folder" : data.fileExtension || "unknown"}
|
||||
</span>
|
||||
{!data.mimeType.includes("folder") && (
|
||||
<>
|
||||
{config.siteConfig.showFileExtension ? null : (
|
||||
<Icon
|
||||
name='Slash'
|
||||
size={"0.875rem"}
|
||||
/>
|
||||
)}
|
||||
<span className='whitespace-nowrap text-sm'>{bytesToReadable(data.size || 0)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className='muted flex items-center gap-1'>
|
||||
<span className='whitespace-nowrap text-sm'>{new Date(data.modifiedTime).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Markdown } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
title: string;
|
||||
};
|
||||
export default function Readme({ content, title }: Props) {
|
||||
const [view, setView] = useState<"markdown" | "raw">("markdown");
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='readme'
|
||||
className='w-full'
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 overflow-hidden",
|
||||
"mobile:flex-row mobile:items-center mobile:justify-between",
|
||||
)}
|
||||
>
|
||||
<h3 className='line-clamp-1 flex-grow whitespace-pre-wrap break-all'>{title}</h3>
|
||||
<div className='flex w-full items-center mobile:w-fit'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "markdown" ? "default" : "outline"}
|
||||
onClick={() => setView("markdown")}
|
||||
className='w-full rounded-r-none mobile:w-fit'
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "raw" ? "default" : "outline"}
|
||||
onClick={() => setView("raw")}
|
||||
className='w-full rounded-l-none mobile:w-fit'
|
||||
>
|
||||
Raw
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
<Markdown
|
||||
content={content}
|
||||
view={view}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Actions } from "./Actions";
|
||||
export { default as FilePath } from "./FilePath";
|
||||
export { default as Grid } from "./Grid";
|
||||
export { default as Explorer } from "./Layout";
|
||||
export { default as List } from "./List";
|
||||
export { default as Readme } from "./Readme";
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Button, ButtonProps } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { ButtonState } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
state?: ButtonState;
|
||||
icon?: keyof typeof icons;
|
||||
} & ButtonProps;
|
||||
|
||||
export default function ButtonLoading({ state = "idle", icon, ...props }: Props) {
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
disabled={state === "loading"}
|
||||
>
|
||||
<div className='relative flex w-full items-center justify-center'>
|
||||
{icon && (
|
||||
<Icon
|
||||
name={icon}
|
||||
size={"1rem"}
|
||||
className={cn(
|
||||
"mr-1.5 transition-all duration-300 ease-in-out",
|
||||
state === "loading" ? "size-0 opacity-0" : "size-4 opacity-100",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={"1rem"}
|
||||
className={cn(
|
||||
"animate-spin transition-all",
|
||||
state === "loading" ? "mr-1.5 size-4 opacity-100" : "mr-0 size-0 opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className='relative transition-all duration-300 ease-in-out'>{props.children}</span>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type Props = {
|
||||
message?: string;
|
||||
extra?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function Loader({ message, extra }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
"text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={"2rem"}
|
||||
className='animate-spin'
|
||||
/>
|
||||
<p>{message || "Loading..."}</p>
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
@@ -11,74 +11,66 @@ import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkSlug from "remark-slug";
|
||||
import remarkToc from "remark-toc";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import "~/app/highlight.css";
|
||||
import { Icon } from "~/components/Global";
|
||||
|
||||
import "./highlight.css";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
view: "markdown" | "raw";
|
||||
view?: "markdown" | "raw";
|
||||
className?: string;
|
||||
};
|
||||
export default function Markdown({ content, view, className }: Props) {
|
||||
const [viewState, setViewState] = useState<"markdown" | "raw">(view || "markdown");
|
||||
|
||||
useEffect(() => {
|
||||
if (view) setViewState(view);
|
||||
}, [view]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col p-3", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"markdown w-full rounded-[var(--radius)]",
|
||||
view !== "raw" && "hidden",
|
||||
)}
|
||||
>
|
||||
<pre className='w-full whitespace-pre-wrap break-words bg-transparent !p-0 shadow shadow-background'>
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"markdown w-full rounded-[var(--radius)]",
|
||||
view !== "markdown" && "hidden",
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
className='w-full'
|
||||
disallowedElements={["script"]}
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
remarkMath,
|
||||
remarkSlug,
|
||||
remarkToc,
|
||||
remarkBreaks,
|
||||
]}
|
||||
rehypePlugins={[
|
||||
rehypeKatex,
|
||||
rehypeRaw,
|
||||
[rehypePrism, { ignoreMissing: true }],
|
||||
]}
|
||||
components={{
|
||||
p: ({ node, children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='paragraph text-balance'
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
pre: PreComponent,
|
||||
code: ({ node, inline, className, children, ...props }) => (
|
||||
<code
|
||||
className={`${className} font-mono !text-sm`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div
|
||||
slot='markdown-container'
|
||||
className={cn("flex flex-col p-3", className)}
|
||||
>
|
||||
{viewState === "raw" ? (
|
||||
<div className={cn("markdown w-full rounded-[var(--radius)]")}>
|
||||
<pre className='w-full whitespace-pre-wrap break-words bg-transparent !p-0 font-sans shadow shadow-background'>
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("markdown w-full rounded-[var(--radius)]")}>
|
||||
<ReactMarkdown
|
||||
className='w-full'
|
||||
disallowedElements={["script"]}
|
||||
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc, remarkBreaks]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw, [rehypePrism, { ignoreMissing: true }]]}
|
||||
components={{
|
||||
p: ({ node, children, ...props }) => (
|
||||
<p
|
||||
{...props}
|
||||
className='paragraph text-balance'
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
pre: PreComponent,
|
||||
code: ({ node, inline, className, children, ...props }) => (
|
||||
<code
|
||||
className={`${className} font-mono !text-sm`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,14 +138,8 @@ function PreComponent(props: PreProps) {
|
||||
>
|
||||
<Icon
|
||||
name={isCopied ? "Check" : isError ? "X" : "Copy"}
|
||||
className={
|
||||
isCopied
|
||||
? "text-green-500"
|
||||
: isError
|
||||
? "text-red-500"
|
||||
: "text-foreground"
|
||||
}
|
||||
size={16}
|
||||
className={isCopied ? "text-green-500" : isError ? "text-red-500" : "text-foreground"}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type Props = {
|
||||
icon: keyof typeof icons;
|
||||
message?: string;
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
export default function Status({ icon, message, destructive }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[33dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
destructive ? "text-destructive" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={icon}
|
||||
size={"1.5rem"}
|
||||
/>
|
||||
<p>{message || "Something went wrong"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as ButtonLoading } from "./ButtonLoading";
|
||||
export { default as Icon } from "./Icon";
|
||||
export { default as Loader } from "./Loader";
|
||||
export { default as Markdown } from "./Markdown";
|
||||
export { default as Status } from "./Status";
|
||||
@@ -0,0 +1,342 @@
|
||||
import { FormEvent } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type ConfigInputs, ConfigurationHeader, ConfigurationInput } from "~/components/Guide/Configuration";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { decryptData } from "~/utils/encryptionHelper";
|
||||
import { fileLoadHelper } from "~/utils/fileLoadHelper";
|
||||
import { parseConfigFile } from "~/utils/parseConfigFile";
|
||||
|
||||
import { ConfigurationCategory, ConfigurationKeys, ConfigurationValue, Schema_App_Configuration } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
state: {
|
||||
get: z.input<typeof Schema_App_Configuration>;
|
||||
set: <
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
category: T,
|
||||
key: K,
|
||||
value: ConfigurationValue<T, K>,
|
||||
) => void;
|
||||
};
|
||||
error: {
|
||||
get: Partial<Record<ConfigurationKeys<"api">, string>>;
|
||||
set: <T extends ConfigurationKeys<"api">>(key: T, value: string) => void;
|
||||
};
|
||||
onReset: (category: ConfigurationCategory | "all") => void;
|
||||
};
|
||||
|
||||
export default function ConfigApi({ state: { get, set }, error, onReset }: Props) {
|
||||
const inputs: ConfigInputs<"api", ConfigurationKeys<"api">>[] = [
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "rootFolder",
|
||||
title: "Root Folder ID",
|
||||
description: `starting point of the drive, will be used as the root folder for the explorer
|
||||
This ID will be encrypted in the config file`,
|
||||
required: true,
|
||||
value: get.api.rootFolder,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.rootFolder,
|
||||
onError: (err) => error.set("rootFolder", err),
|
||||
placeholder: "Starting point of the drive",
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
inputKey: "sharedGroup",
|
||||
columns: 3,
|
||||
children: [
|
||||
{
|
||||
type: "select",
|
||||
inputKey: "isTeamDrive",
|
||||
title: "Use Team Drive",
|
||||
description: "Enable this option if you are using a Team Drive instead of a personal drive",
|
||||
required: true,
|
||||
value: get.api.isTeamDrive,
|
||||
onValueChange: (key, value) => {
|
||||
set("api", key, value);
|
||||
if (value === false) {
|
||||
set("api", "sharedDrive", "");
|
||||
error.set("sharedDrive", "");
|
||||
}
|
||||
},
|
||||
error: error.get.isTeamDrive,
|
||||
onError: (err) => error.set("isTeamDrive", err),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "sharedDrive",
|
||||
title: "Shared Drive ID",
|
||||
description: `The ID of the Shared Drive
|
||||
This ID will be encrypted in the config file`,
|
||||
required: true,
|
||||
value: get.api.sharedDrive,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.sharedDrive,
|
||||
onError: (err) => error.set("sharedDrive", err),
|
||||
disabled: !get.api.isTeamDrive,
|
||||
placeholder: get.api.isTeamDrive ? "Make sure to copy the Drive ID" : "You need to enable Team Drive first",
|
||||
columnSpan: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
inputKey: "pagination",
|
||||
columns: 2,
|
||||
children: [
|
||||
{
|
||||
type: "number",
|
||||
inputKey: "itemsPerPage",
|
||||
title: "Items per page",
|
||||
description: `Set how many items to display per page in the file explorer
|
||||
It's recommended to keep this value to a reasonable number, since it will affect the load time of the page
|
||||
|
||||
Default: 50`,
|
||||
required: true,
|
||||
value: get.api.itemsPerPage,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.itemsPerPage,
|
||||
onError: (err) => error.set("itemsPerPage", err),
|
||||
minMax: { min: 1 },
|
||||
validation: async (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return { success: false, message: "Value must be a number" };
|
||||
if (num < 1) return { success: false, message: "Items per page must be greater than 0" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
inputKey: "searchResult",
|
||||
title: "Search Results",
|
||||
description: `Set how many search results to display
|
||||
It's recommended to set this to a small number, since it will affect search performance
|
||||
|
||||
Default: 5`,
|
||||
required: true,
|
||||
value: get.api.searchResult,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.searchResult,
|
||||
onError: (err) => error.set("searchResult", err),
|
||||
minMax: { min: 1 },
|
||||
validation: async (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return { success: false, message: "Value must be a number" };
|
||||
if (num < 1) return { success: false, message: "Search result must be greater than 0" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
inputKey: "proxyThumbnail",
|
||||
title: "Allow Proxy Thumbnail",
|
||||
description: `Use API route instead of Google Drive thumbnail link
|
||||
|
||||
If your files thumbnail are not accessible, you can enable this option
|
||||
This will fetch the thumbnail image via API route`,
|
||||
required: true,
|
||||
value: get.api.proxyThumbnail,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.proxyThumbnail,
|
||||
onError: (err) => error.set("proxyThumbnail", err),
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
inputKey: "protectedFile",
|
||||
columns: 2,
|
||||
children: [
|
||||
{
|
||||
type: "select",
|
||||
inputKey: "allowDownloadProtectedFile",
|
||||
title: "Allow Download Protected File",
|
||||
description: `Allow users to download files inside the protected folder
|
||||
|
||||
If set to true, users will be able to download the file without entering the password as long as they have the direct link
|
||||
If set to false, users will need to enter the password before downloading the file, even if they have the direct link`,
|
||||
required: true,
|
||||
value: get.api.allowDownloadProtectedFile,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.allowDownloadProtectedFile,
|
||||
onError: (err) => error.set("allowDownloadProtectedFile", err),
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
inputKey: "temporaryTokenDuration",
|
||||
title: "Temporary Token Duration (in hours)",
|
||||
description: `Set how long the download token will be valid
|
||||
If the token is expired, users will need to re-open the file page to get a new token`,
|
||||
required: true,
|
||||
value: get.api.temporaryTokenDuration,
|
||||
onValueChange: (key, value) => set("api", key, value),
|
||||
error: error.get.temporaryTokenDuration,
|
||||
minMax: { min: 1, max: 24 * 30 },
|
||||
validation: async (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return { success: false, message: "Value must be a number" };
|
||||
if (num < 1) return { success: false, message: "Token duration must be greater than 0" };
|
||||
if (num > 24 * 30)
|
||||
return { success: false, message: "Token duration can't be more than 720 hours (30 days)" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
inputKey: "fileSize",
|
||||
columns: 2,
|
||||
children: [
|
||||
{
|
||||
type: "number",
|
||||
inputKey: "maxFileSize",
|
||||
title: "Max Direct Download Size (in MB)",
|
||||
description: `Max file size that can be downloaded directly from the server
|
||||
Please check your deploy platform for the maximum response size limit
|
||||
If you're using Vercel, the maximum size is around 4 - 4.5MB, any file larger than this size will return an error
|
||||
|
||||
Set to 0 to disable the limit`,
|
||||
required: true,
|
||||
value: get.api.maxFileSize / 1024 / 1024,
|
||||
onValueChange: (key, value) => set("api", key, value * 1024 * 1024),
|
||||
error: error.get.maxFileSize,
|
||||
minMax: { min: 0 },
|
||||
validation: async (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return { success: false, message: "Value must be a number" };
|
||||
if (num < 0) return { success: false, message: "Size must be greater than or equal to 0" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
inputKey: "streamMaxSize",
|
||||
title: "Max Stream Size (in MB)",
|
||||
description: `For previewing large files, the file will be streamed in chunks
|
||||
Make sure to set this value to a reasonable number, since it will count towards your server bandwidth usage
|
||||
|
||||
This will also affect the maximum file size that can be previewed. Especially for media files like video, audio, and images
|
||||
(Manga preview will automatically limited to the first 5MB)
|
||||
|
||||
Set to 0 to disable the limit
|
||||
Default: 100`,
|
||||
required: true,
|
||||
value: get.api.streamMaxSize / 1024 / 1024,
|
||||
onValueChange: (key, value) => set("api", key, value * 1024 * 1024),
|
||||
error: error.get.streamMaxSize,
|
||||
minMax: { min: 0 },
|
||||
validation: async (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return { success: false, message: "Value must be a number" };
|
||||
if (num < 0) return { success: false, message: "Size must be greater than or equal to 0" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function onFileLoad() {
|
||||
fileLoadHelper({
|
||||
accept: ".config.ts",
|
||||
fileName: "gindex.config.ts",
|
||||
async onLoad(result) {
|
||||
const config = parseConfigFile(result);
|
||||
if ("success" in config) return toast.error(config.message);
|
||||
|
||||
if (get.environment.ENCRYPTION_KEY) {
|
||||
try {
|
||||
const root = config.api.rootFolder;
|
||||
const shared = config.api.sharedDrive || null;
|
||||
if (root) {
|
||||
const decrypted = await decryptData(root, get.environment.ENCRYPTION_KEY);
|
||||
if (decrypted) set("api", "rootFolder", decrypted);
|
||||
}
|
||||
if (shared) {
|
||||
const decrypted = await decryptData(shared, get.environment.ENCRYPTION_KEY);
|
||||
if (decrypted) set("api", "sharedDrive", decrypted);
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error("Skipping decryption of folder ID, please check your Encryption Key and try again.");
|
||||
}
|
||||
} else {
|
||||
toast.error("Can't decrypt folder ID without Encryption Key");
|
||||
}
|
||||
config.api.isTeamDrive && set("api", "isTeamDrive", config.api.isTeamDrive);
|
||||
config.api.itemsPerPage && set("api", "itemsPerPage", config.api.itemsPerPage);
|
||||
config.api.searchResult && set("api", "searchResult", config.api.searchResult);
|
||||
config.api.proxyThumbnail && set("api", "proxyThumbnail", config.api.proxyThumbnail);
|
||||
config.api.allowDownloadProtectedFile &&
|
||||
set("api", "allowDownloadProtectedFile", config.api.allowDownloadProtectedFile);
|
||||
config.api.temporaryTokenDuration && set("api", "temporaryTokenDuration", config.api.temporaryTokenDuration);
|
||||
config.api.maxFileSize && set("api", "maxFileSize", config.api.maxFileSize);
|
||||
config.api.streamMaxSize && set("api", "streamMaxSize", config.api.streamMaxSize);
|
||||
|
||||
// Other config
|
||||
config.api.defaultQuery && set("api", "defaultQuery", config.api.defaultQuery);
|
||||
config.api.defaultField && set("api", "defaultField", config.api.defaultField);
|
||||
config.api.defaultOrder && set("api", "defaultOrder", config.api.defaultOrder);
|
||||
config.api.specialFile && set("api", "specialFile", config.api.specialFile);
|
||||
config.api.hiddenFiles && set("api", "hiddenFiles", config.api.hiddenFiles);
|
||||
|
||||
// Site config
|
||||
config.site.siteName && set("site", "siteName", config.site.siteName);
|
||||
config.site.siteNameTemplate && set("site", "siteNameTemplate", config.site.siteNameTemplate);
|
||||
config.site.siteDescription && set("site", "siteDescription", config.site.siteDescription);
|
||||
config.site.siteIcon && set("site", "siteIcon", config.site.siteIcon);
|
||||
config.site.favIcon && set("site", "favIcon", config.site.favIcon);
|
||||
config.site.siteAuthor && set("site", "siteAuthor", config.site.siteAuthor);
|
||||
config.site.twitterHandle && set("site", "twitterHandle", config.site.twitterHandle);
|
||||
config.site.robots && set("site", "robots", config.site.robots);
|
||||
|
||||
config.site.showFileExtension && set("site", "showFileExtension", config.site.showFileExtension);
|
||||
config.site.footer && set("site", "footer", config.site.footer);
|
||||
config.site.privateIndex && set("site", "privateIndex", config.site.privateIndex);
|
||||
config.site.breadcrumbMax && set("site", "breadcrumbMax", config.site.breadcrumbMax);
|
||||
config.site.toaster && set("site", "toaster", config.site.toaster);
|
||||
config.site.navbarItems && set("site", "navbarItems", config.site.navbarItems);
|
||||
config.site.supports && set("site", "supports", config.site.supports);
|
||||
|
||||
toast.success("API configuration has been loaded.");
|
||||
},
|
||||
});
|
||||
}
|
||||
function onFormReset(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
onReset("api");
|
||||
toast.success("API section has been reset to default values.");
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
onReset={onFormReset}
|
||||
>
|
||||
<ConfigurationHeader
|
||||
title='API Configuration'
|
||||
onLoad={onFileLoad}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
{inputs.map((props, index) => (
|
||||
<ConfigurationInput<"api", ConfigurationKeys<"api">>
|
||||
key={String(props.inputKey)}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type ConfigInputs, ConfigurationHeader, ConfigurationInput } from "~/components/Guide/Configuration";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { fileLoadHelper } from "~/utils/fileLoadHelper";
|
||||
|
||||
import {
|
||||
ButtonState,
|
||||
ConfigurationCategory,
|
||||
ConfigurationKeys,
|
||||
ConfigurationValue,
|
||||
Schema_App_Configuration,
|
||||
Schema_ServiceAccount,
|
||||
} from "~/types/schema";
|
||||
|
||||
import { GenerateAESKey, VerifyAESKey } from "actions";
|
||||
|
||||
type Props = {
|
||||
state: {
|
||||
get: z.input<typeof Schema_App_Configuration>;
|
||||
set: <
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
category: T,
|
||||
key: K,
|
||||
value: ConfigurationValue<T, K>,
|
||||
) => void;
|
||||
};
|
||||
error: {
|
||||
get: Partial<Record<ConfigurationKeys<"environment">, string>>;
|
||||
set: <T extends ConfigurationKeys<"environment">>(key: T, value: string) => void;
|
||||
};
|
||||
onReset: (category: ConfigurationCategory | "all") => void;
|
||||
};
|
||||
|
||||
export default function ConfigEnvironment({ state: { get, set }, error, onReset }: Props) {
|
||||
const [encryptionState, setEncryptionState] = useState<ButtonState>("idle");
|
||||
const [serviceAuthState, setServiceAuthState] = useState<ButtonState>("idle");
|
||||
const [isRevealPassword, setIsRevealPassword] = useState<boolean>(false);
|
||||
|
||||
const inputs: ConfigInputs<"environment", ConfigurationKeys<"environment">>[] = [
|
||||
{
|
||||
inputKey: "ENCRYPTION_KEY",
|
||||
title: "Encryption Key",
|
||||
description:
|
||||
"The key used to encrypt and decrypt sensitive data. Must be a alphanumeric string of 16 characters.",
|
||||
type: "text",
|
||||
required: true,
|
||||
action: {
|
||||
label: "Generate",
|
||||
state: encryptionState,
|
||||
icon: "RefreshCw",
|
||||
async onClick(e) {
|
||||
e.preventDefault();
|
||||
setEncryptionState("loading");
|
||||
error.set("ENCRYPTION_KEY", "");
|
||||
|
||||
try {
|
||||
const key = await GenerateAESKey();
|
||||
const isValid = await VerifyAESKey("This is a test", key);
|
||||
if (!isValid) throw new Error("Invalid key generated, please try again.");
|
||||
|
||||
set("environment", "ENCRYPTION_KEY", key);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message);
|
||||
error.set("ENCRYPTION_KEY", e.message);
|
||||
} finally {
|
||||
setEncryptionState("idle");
|
||||
}
|
||||
},
|
||||
},
|
||||
value: get.environment.ENCRYPTION_KEY,
|
||||
onValueChange: (key, value) => set("environment", key, value),
|
||||
error: error.get.ENCRYPTION_KEY,
|
||||
onError: (err) => error.set("ENCRYPTION_KEY", err),
|
||||
validation: async (value) => {
|
||||
if (!value) return { success: false, message: "Encryption key is required" };
|
||||
if (value.length !== 16) return { success: false, message: "Encryption key must be 16 characters long" };
|
||||
|
||||
const valid = await VerifyAESKey("This is a test", value);
|
||||
if (!valid) return { success: false, message: "Invalid encryption key" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
placeholder: "16 alphanumeric characters secret key",
|
||||
},
|
||||
{
|
||||
inputKey: "GD_SERVICE_B64",
|
||||
title: "Google Drive Service Account",
|
||||
description: `The base64 encoded Google Drive Service Account JSON file.
|
||||
To avoid error when input, please use the "Load JSON" button to load the file.`,
|
||||
type: "text",
|
||||
required: true,
|
||||
readOnly: true,
|
||||
action: {
|
||||
label: "Load JSON",
|
||||
state: serviceAuthState,
|
||||
icon: "Upload",
|
||||
async onClick(e) {
|
||||
e.preventDefault();
|
||||
setServiceAuthState("loading");
|
||||
|
||||
try {
|
||||
fileLoadHelper({
|
||||
accept: ".json",
|
||||
onLoad(result) {
|
||||
const objectFile = JSON.parse(result);
|
||||
const parse = Schema_ServiceAccount.safeParse(objectFile);
|
||||
if (!parse.success)
|
||||
return toast.error("Invalid Service Account JSON file. Please make sure the file is correct.");
|
||||
|
||||
set("environment", "GD_SERVICE_B64", btoa(result));
|
||||
error.set("GD_SERVICE_B64", "");
|
||||
|
||||
toast.success("Service Account JSON file has been loaded successfully.");
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
console.error(e.message);
|
||||
error.set("GD_SERVICE_B64", e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setServiceAuthState("idle");
|
||||
}
|
||||
},
|
||||
},
|
||||
value: get.environment.GD_SERVICE_B64,
|
||||
onValueChange: (key, value) => set("environment", key, value),
|
||||
error: error.get.GD_SERVICE_B64,
|
||||
onError: (err) => error.set("GD_SERVICE_B64", err),
|
||||
placeholder: "Base64 encoded JSON file",
|
||||
},
|
||||
{
|
||||
inputKey: "NEXT_PUBLIC_DOMAIN",
|
||||
title: "Site Domain",
|
||||
description: `The domain for the site, without the protocol`,
|
||||
type: "text",
|
||||
value: get.environment.NEXT_PUBLIC_DOMAIN,
|
||||
onValueChange: (key, value) => set("environment", key, value),
|
||||
error: error.get.NEXT_PUBLIC_DOMAIN,
|
||||
onError: (err) => error.set("NEXT_PUBLIC_DOMAIN", err),
|
||||
placeholder: "e.g. drive-demo.mbaharip.com or mbaharip.com",
|
||||
},
|
||||
{
|
||||
inputKey: "SITE_PASSWORD",
|
||||
title: "Site Password",
|
||||
description: `The password to access the site
|
||||
Only required if the site is set to private`,
|
||||
type: isRevealPassword ? "text" : "password",
|
||||
action: {
|
||||
label: isRevealPassword ? "Hide" : "Reveal",
|
||||
icon: isRevealPassword ? "EyeOff" : "Eye",
|
||||
onClick(e) {
|
||||
e.preventDefault();
|
||||
setIsRevealPassword((prev) => !prev);
|
||||
},
|
||||
state: "idle",
|
||||
},
|
||||
value: get.environment.SITE_PASSWORD,
|
||||
onValueChange: (key, value) => set("environment", key, value),
|
||||
error: error.get.SITE_PASSWORD,
|
||||
onError: (err) => error.set("SITE_PASSWORD", err),
|
||||
validation: async (value) => {
|
||||
if (get.site.privateIndex && !value) return { success: false, message: "Site password is required" };
|
||||
return { success: true, message: "" };
|
||||
},
|
||||
placeholder: "Not required if the site is public",
|
||||
},
|
||||
];
|
||||
|
||||
function onFileLoad() {
|
||||
fileLoadHelper({
|
||||
accept: ".env, .env.local, .env.development, .env.production",
|
||||
onLoad(result) {
|
||||
const lines = result.split("\n");
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of lines) {
|
||||
const [key, value] = line.split("=");
|
||||
if (!key || !value) continue;
|
||||
const formattedValue = value
|
||||
?.replace(/"/g, "")
|
||||
.replace(/'/g, "")
|
||||
.replace(/\\/g, "")
|
||||
.replace(/\r/g, "")
|
||||
.trim();
|
||||
env[key] = formattedValue;
|
||||
}
|
||||
set("environment", "ENCRYPTION_KEY", env.ENCRYPTION_KEY);
|
||||
set("environment", "GD_SERVICE_B64", env.GD_SERVICE_B64);
|
||||
set("environment", "NEXT_PUBLIC_DOMAIN", env.NEXT_PUBLIC_DOMAIN);
|
||||
set("environment", "SITE_PASSWORD", env.SITE_PASSWORD);
|
||||
toast.success("Environment section has been loaded successfully.");
|
||||
},
|
||||
});
|
||||
}
|
||||
function onFormReset(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
onReset("environment");
|
||||
toast.success("Environment section has been reset to default values.");
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
onReset={onFormReset}
|
||||
>
|
||||
<ConfigurationHeader
|
||||
title='Environment Configuration'
|
||||
onLoad={onFileLoad}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
{inputs.map((props, index) => (
|
||||
<ConfigurationInput<"environment", ConfigurationKeys<"environment">>
|
||||
key={props.inputKey}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ButtonLoading } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { ButtonState } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
onLoad?: () => void;
|
||||
};
|
||||
export default function ConfigurationHeader({ title, onLoad }: Props) {
|
||||
const [loadState, setLoadState] = useState<ButtonState>("idle");
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='header'
|
||||
className='flex flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-between'
|
||||
>
|
||||
<h4>{title}</h4>
|
||||
|
||||
<div
|
||||
slot='actions'
|
||||
className='flex w-full items-center gap-3 tablet:w-fit'
|
||||
>
|
||||
{onLoad && (
|
||||
<ButtonLoading
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
state={loadState}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setLoadState("loading");
|
||||
|
||||
try {
|
||||
onLoad();
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoadState("idle");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Load File
|
||||
</ButtonLoading>
|
||||
)}
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
type='reset'
|
||||
size={"sm"}
|
||||
>
|
||||
Reset Section
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
|
||||
import { ButtonLoading, Icon } from "~/components/Global";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { ButtonState, ConfigurationCategory, ConfigurationKeys, ConfigurationValue } from "~/types/schema";
|
||||
|
||||
export type ConfigInputs<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
> = ConfigInputProps<T, K> | ConfigInputGroupProps<T, K> | ConfigInputSeparatorProps;
|
||||
|
||||
type ConfigInputProps<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
> = {
|
||||
inputKey: K;
|
||||
title: string;
|
||||
type: "text" | "password" | "number" | "select";
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
state: ButtonState;
|
||||
icon?: keyof typeof icons;
|
||||
};
|
||||
value: ConfigurationValue<T, K>;
|
||||
onValueChange: (key: K, value: any) => void;
|
||||
error?: string;
|
||||
onError?: (error: string) => void;
|
||||
validation?: (value: string) =>
|
||||
| { success: boolean; message: string }
|
||||
| Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
readOnly?: boolean;
|
||||
minMax?: { min?: number; max?: number };
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type ConfigInputGroupProps<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
> = {
|
||||
inputKey: string;
|
||||
type: "group";
|
||||
columns: number;
|
||||
children: (ConfigInputProps<T, K> & {
|
||||
columnSpan?: number;
|
||||
})[];
|
||||
};
|
||||
type ConfigInputSeparatorProps = {
|
||||
inputKey: string;
|
||||
type: "separator";
|
||||
};
|
||||
export default function ConfigurationInput<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(props: ConfigInputProps<T, K> | ConfigInputGroupProps<T, K> | ConfigInputSeparatorProps) {
|
||||
const key = String(props.inputKey);
|
||||
return (
|
||||
<>
|
||||
{props.type !== "separator" && props.type !== "group" && (
|
||||
<>
|
||||
<Inputs<T, K> {...props} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.type === "separator" && <Separator />}
|
||||
|
||||
{props.type === "group" && (
|
||||
<div
|
||||
slot='group'
|
||||
className='grid w-full gap-1.5'
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{props.children.map((child, index) => (
|
||||
<Inputs<T, K>
|
||||
key={`${child.inputKey as string}@${index}`}
|
||||
columnSpan={child.columnSpan}
|
||||
{...child}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Inputs<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
props: ConfigInputProps<T, K> & {
|
||||
columnSpan?: number;
|
||||
},
|
||||
) {
|
||||
const key = String(props.inputKey);
|
||||
return (
|
||||
<div
|
||||
id={key}
|
||||
slot={`input-${key}`}
|
||||
className='flex w-full flex-col gap-1.5'
|
||||
style={{
|
||||
gridColumn: `span ${props.columnSpan || 1} / span ${props.columnSpan || 1}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
slot='label'
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
aria-required={props.required}
|
||||
>
|
||||
{props.title} <span className='text-sm text-muted-foreground'>{!props.required ? "(optional)" : ""}</span>
|
||||
</Label>
|
||||
{props.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className='cursor-default'
|
||||
>
|
||||
<Icon
|
||||
name='Info'
|
||||
size={"1rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
className='max-w-screen-sm'
|
||||
>
|
||||
<p className='max-w-screen-sm !whitespace-pre-wrap'>{props.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
slot='input'
|
||||
className='grid w-full grid-cols-6 gap-1.5'
|
||||
>
|
||||
<div className={cn("w-full", props.action ? "col-span-5" : "col-span-full")}>
|
||||
{props.type === "text" || props.type === "password" || props.type === "number" ? (
|
||||
<Input
|
||||
id={key}
|
||||
name={key}
|
||||
type={props.type}
|
||||
value={props.value as string}
|
||||
readOnly={props.readOnly || false}
|
||||
min={props.minMax?.min}
|
||||
max={props.minMax?.max}
|
||||
disabled={props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
onChange={(e) => {
|
||||
if (props.error) {
|
||||
props.onError?.("");
|
||||
}
|
||||
props.onValueChange(props.inputKey, e.target.value as ConfigurationValue<T, K>);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
try {
|
||||
props.onError?.("");
|
||||
|
||||
if (props.required && !(props.value as string).trim()) {
|
||||
throw new Error(`${props.title} is required.`);
|
||||
}
|
||||
|
||||
if (props.validation) {
|
||||
const result = await props.validation(props.value as string);
|
||||
if (!result.success) throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
props.onError?.(e.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
props.type === "select" && (
|
||||
<Select
|
||||
disabled={props.disabled}
|
||||
value={String(props.value)}
|
||||
onValueChange={(value) => {
|
||||
props.onValueChange(props.inputKey, (value as string) === "true");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select an option' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='true'>Enabled</SelectItem>
|
||||
<SelectItem value='false'>Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{props.action && (
|
||||
<ButtonLoading
|
||||
variant={"secondary"}
|
||||
onClick={props.action.onClick}
|
||||
state={props.action.state}
|
||||
icon={props.action.icon}
|
||||
>
|
||||
{props.action.label}
|
||||
</ButtonLoading>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div slot='message'>
|
||||
<span className={cn("block text-sm text-destructive", props.error ? "opacity-100" : "select-none opacity-0")}>
|
||||
{props.error}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { AsyncZippable, strToU8, zip } from "fflate";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { generateConfig } from "~/data/template";
|
||||
|
||||
import { ButtonLoading, Loader, Markdown } from "~/components/Global";
|
||||
import { ConfigAPI, ConfigEnvironment, ConfigSite } from "~/components/Guide/Configuration";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { downloadBlob } from "~/utils/downloadBlob";
|
||||
|
||||
import {
|
||||
ButtonState,
|
||||
ConfigurationCategory,
|
||||
ConfigurationKeys,
|
||||
ConfigurationValue,
|
||||
Schema_App_Configuration,
|
||||
} from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
export default function ConfigurationForm() {
|
||||
const loading = useLoading();
|
||||
const initialConfiguration = useMemo<z.input<typeof Schema_App_Configuration>>(() => {
|
||||
return {
|
||||
environment: {
|
||||
GD_SERVICE_B64: "",
|
||||
ENCRYPTION_KEY: "",
|
||||
SITE_PASSWORD: "",
|
||||
NEXT_PUBLIC_DOMAIN: "",
|
||||
},
|
||||
api: {
|
||||
...config.apiConfig,
|
||||
rootFolder: "",
|
||||
isTeamDrive: false,
|
||||
sharedDrive: "",
|
||||
itemsPerPage: 50,
|
||||
searchResult: 5,
|
||||
|
||||
proxyThumbnail: true,
|
||||
streamMaxSize: 100 * 1024 * 1024,
|
||||
|
||||
allowDownloadProtectedFile: false,
|
||||
temporaryTokenDuration: 6,
|
||||
maxFileSize: 4 * 1024 * 1024,
|
||||
},
|
||||
site: {
|
||||
...config.siteConfig,
|
||||
siteName: "next-gdrive-index",
|
||||
siteNameTemplate: "%s - %t",
|
||||
siteDescription: "A Google Drive Index built using Next.js",
|
||||
siteAuthor: "mbahArip",
|
||||
twitterHandle: "@mbahArip",
|
||||
|
||||
showFileExtension: false,
|
||||
footer: ["{{ siteName }} *v{{ version }}* @ {{ repository }}", "{{ year }} - Made with ❤️ by **{{ author }}**"],
|
||||
|
||||
privateIndex: false,
|
||||
breadcrumbMax: 3,
|
||||
|
||||
toaster: {
|
||||
position: "bottom-right",
|
||||
duration: 3000,
|
||||
},
|
||||
|
||||
navbarItems: [],
|
||||
supports: [],
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
const [configuration, setConfiguration] = useState<z.input<typeof Schema_App_Configuration>>(initialConfiguration);
|
||||
const [error, setError] = useState<Schema_App_Configuration_Error>({
|
||||
environment: {},
|
||||
api: {},
|
||||
site: {},
|
||||
});
|
||||
|
||||
const [downloadState, setDownloadState] = useState<ButtonState>("idle");
|
||||
|
||||
function onConfigurationChange<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(category: T, key: K, value: ConfigurationValue<T, K>) {
|
||||
setConfiguration((prev) => ({
|
||||
...prev,
|
||||
[category]: {
|
||||
...prev[category],
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
function onErrorChange<
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(category: T, key: K, value: string) {
|
||||
setError((prev) => ({
|
||||
...prev,
|
||||
[category]: {
|
||||
...prev[category],
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function onReset(category: ConfigurationCategory | "all") {
|
||||
if (category === "all") {
|
||||
setConfiguration(initialConfiguration);
|
||||
setError({
|
||||
environment: {},
|
||||
api: {},
|
||||
site: {},
|
||||
});
|
||||
} else {
|
||||
setConfiguration((prev) => ({
|
||||
...prev,
|
||||
[category]: initialConfiguration[category],
|
||||
}));
|
||||
setError((prev) => ({
|
||||
...prev,
|
||||
[category]: {},
|
||||
}));
|
||||
}
|
||||
}
|
||||
async function onDownload(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
setDownloadState("loading");
|
||||
toast.loading("Generating configuration file...", {
|
||||
id: "generate-config",
|
||||
});
|
||||
|
||||
try {
|
||||
// Check required fields
|
||||
const requiredEmpty = [
|
||||
!configuration.environment.ENCRYPTION_KEY.length, // Encryption Key are required
|
||||
!configuration.environment.GD_SERVICE_B64.length, // Service Account are required
|
||||
!configuration.api.rootFolder.length, // Root Folder are required
|
||||
configuration.api.isTeamDrive && !configuration.api.sharedDrive?.length, // If isTeamDrive, sharedDrive are required
|
||||
!configuration.site.siteName.length, // Site Name are required
|
||||
!configuration.site.siteDescription.length, // Site Description are required
|
||||
configuration.site.privateIndex && !configuration.environment.SITE_PASSWORD?.length, // If privateIndex, SITE_PASSWORD are required
|
||||
];
|
||||
if (requiredEmpty.filter((v) => v).length) {
|
||||
throw new Error("Looks like you missed some required fields.");
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
for (const err of Object.values(error.environment)) {
|
||||
if (err) errors.push(err);
|
||||
}
|
||||
for (const err of Object.values(error.api)) {
|
||||
if (err) errors.push(err);
|
||||
}
|
||||
for (const err of Object.values(error.site)) {
|
||||
if (err) errors.push(err);
|
||||
}
|
||||
if (errors.length) {
|
||||
throw new Error("Errors found in the configuration form. Please fix them first");
|
||||
}
|
||||
|
||||
// Create string content
|
||||
const envContent = Object.entries(configuration.environment)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n");
|
||||
const configContent = await generateConfig(configuration);
|
||||
|
||||
// Create zip structure
|
||||
const structure: AsyncZippable = {
|
||||
".env": [
|
||||
strToU8(envContent),
|
||||
{
|
||||
level: 6,
|
||||
},
|
||||
],
|
||||
"gIndex.config.ts": [
|
||||
strToU8(configContent),
|
||||
{
|
||||
level: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
zip(structure, {}, (err, data) => {
|
||||
if (err) throw err;
|
||||
|
||||
const zipBlob = new Blob([data], { type: "application/zip" });
|
||||
downloadBlob(zipBlob, `${Date.now()}__gIndex-config.zip`);
|
||||
toast.success("Configuration file generated successfully", {
|
||||
id: "generate-config",
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message, {
|
||||
id: "generate-config",
|
||||
});
|
||||
} finally {
|
||||
setDownloadState("idle");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Loader message='Loading configuration...' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex items-end justify-between gap-3'>
|
||||
<CardTitle id='config'>App Configuration</CardTitle>
|
||||
<small className='text-muted-foreground'>v{config.version}</small>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='space-y-3 py-3'>
|
||||
<Markdown
|
||||
content={`This configuration only covering the most basic things you need to get started.
|
||||
You can check the configuration file itself to see all the available configuration. _(Each configuration has a description to help you understand it)_
|
||||
If you're migrating from previous version, you can load your old environment and config file to update the configuration.
|
||||
|
||||
> If you found any bugs or issues with this configuration, please report it to the [issue tracker](https://github.com/mbahArip/next-gdrive-index/issues)
|
||||
> I'll try to fix it as soon as possible`}
|
||||
/>
|
||||
|
||||
<ConfigEnvironment
|
||||
state={{
|
||||
get: configuration,
|
||||
set: onConfigurationChange,
|
||||
}}
|
||||
error={{
|
||||
get: error.environment,
|
||||
set: (key, value) => onErrorChange("environment", key, value),
|
||||
}}
|
||||
onReset={onReset}
|
||||
/>
|
||||
|
||||
<ConfigAPI
|
||||
state={{
|
||||
get: configuration,
|
||||
set: onConfigurationChange,
|
||||
}}
|
||||
error={{
|
||||
get: error.api,
|
||||
set: (key, value) => onErrorChange("api", key, value),
|
||||
}}
|
||||
onReset={onReset}
|
||||
/>
|
||||
|
||||
<ConfigSite
|
||||
state={{
|
||||
get: configuration,
|
||||
set: onConfigurationChange,
|
||||
}}
|
||||
error={{
|
||||
get: error.site,
|
||||
set: (key, value) => onErrorChange("site", key, value),
|
||||
}}
|
||||
onReset={onReset}
|
||||
/>
|
||||
|
||||
<div className='flex w-full flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-end'>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
size={"sm"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onReset("all");
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</Button>
|
||||
<ButtonLoading
|
||||
state={downloadState}
|
||||
size={"sm"}
|
||||
icon={"Save"}
|
||||
onClick={onDownload}
|
||||
>
|
||||
Download Config
|
||||
</ButtonLoading>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type Schema_App_Configuration_Error = {
|
||||
environment: Partial<Record<ConfigurationKeys<"environment">, string>>;
|
||||
api: Partial<Record<ConfigurationKeys<"api">, string>>;
|
||||
site: Partial<Record<ConfigurationKeys<"site">, string>>;
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import { FormEvent } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { type ConfigInputs, ConfigurationHeader, ConfigurationInput } from "~/components/Guide/Configuration";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { ConfigurationCategory, ConfigurationKeys, ConfigurationValue, Schema_App_Configuration } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
state: {
|
||||
get: z.input<typeof Schema_App_Configuration>;
|
||||
set: <
|
||||
T extends ConfigurationCategory = ConfigurationCategory,
|
||||
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
||||
>(
|
||||
category: T,
|
||||
key: K,
|
||||
value: ConfigurationValue<T, K>,
|
||||
) => void;
|
||||
};
|
||||
error: {
|
||||
get: Partial<Record<ConfigurationKeys<"site">, string>>;
|
||||
set: <T extends ConfigurationKeys<"site">>(key: T, value: string) => void;
|
||||
};
|
||||
onReset: (category: ConfigurationCategory | "all") => void;
|
||||
};
|
||||
|
||||
export default function ConfigSite({ state: { get, set }, error, onReset }: Props) {
|
||||
const metadataInputs: ConfigInputs<"site", ConfigurationKeys<"site">>[] = [
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "siteName",
|
||||
title: "Site Name",
|
||||
required: true,
|
||||
value: get.site.siteName,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.siteName,
|
||||
onError: (err) => error.set("siteName", err),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "siteNameTemplate",
|
||||
title: "Site Name Template",
|
||||
description: `The template used to generate the site name
|
||||
|
||||
Available variables:
|
||||
%s - Page title
|
||||
%t - Site name`,
|
||||
required: true,
|
||||
value: get.site.siteNameTemplate,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.siteNameTemplate,
|
||||
onError: (err) => error.set("siteNameTemplate", err),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "siteDescription",
|
||||
title: "Site Description",
|
||||
required: true,
|
||||
value: get.site.siteDescription,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.siteDescription,
|
||||
onError: (err) => error.set("siteDescription", err),
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
inputKey: "author",
|
||||
columns: 2,
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "siteAuthor",
|
||||
title: "Site Author",
|
||||
description: `Will be used for metadata, also affect the footer variable`,
|
||||
value: get.site.siteAuthor,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.siteAuthor,
|
||||
onError: (err) => error.set("siteAuthor", err),
|
||||
placeholder: "Your username here",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "twitterHandle",
|
||||
title: "Twitter Handle",
|
||||
description: `Will be used for metadata, also affect the footer variable`,
|
||||
value: get.site.twitterHandle,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.twitterHandle,
|
||||
onError: (err) => error.set("twitterHandle", err),
|
||||
placeholder: "e.g. @__mbaharip__",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const inputs: ConfigInputs<"site", ConfigurationKeys<"site">>[] = [
|
||||
{
|
||||
type: "select",
|
||||
inputKey: "privateIndex",
|
||||
title: "Private Index",
|
||||
description: `Lock the whole site behind a password
|
||||
Will use the Site Password in the Environment section`,
|
||||
required: true,
|
||||
value: get.site.privateIndex,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.privateIndex,
|
||||
onError: (err) => error.set("privateIndex", err),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
inputKey: "showFileExtension",
|
||||
title: "Show File Extension",
|
||||
description: `Show file extension in file explorer
|
||||
|
||||
e.g. "file.mp4" instead of "file"`,
|
||||
required: true,
|
||||
value: get.site.showFileExtension,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.showFileExtension,
|
||||
onError: (err) => error.set("showFileExtension", err),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
inputKey: "favIcon",
|
||||
title: "Favicon",
|
||||
description: `The favicon of the site`,
|
||||
required: true,
|
||||
value: get.site.favIcon,
|
||||
onValueChange: (key, value) => set("site", key, value),
|
||||
error: error.get.favIcon,
|
||||
onError: (err) => error.set("favIcon", err),
|
||||
},
|
||||
];
|
||||
|
||||
function onFormReset(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
onReset("site");
|
||||
toast.success("Site section has been reset to default values.");
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className='flex w-full flex-col gap-3 py-3'
|
||||
onReset={onFormReset}
|
||||
>
|
||||
<ConfigurationHeader title='Site Configuration' />
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
slot='inputs'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
<div className='grid grid-cols-3 gap-3'>
|
||||
<div className='col-span-2 flex flex-col gap-3'>
|
||||
{metadataInputs.map((props, index) => (
|
||||
<ConfigurationInput<"site", ConfigurationKeys<"site">>
|
||||
key={String(props.inputKey)}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex flex-col items-start gap-1.5 pt-3'>
|
||||
<div className='flex select-none items-center gap-1.5 rounded-t-lg border border-border px-3 py-1.5'>
|
||||
<img
|
||||
src={get.site.favIcon}
|
||||
className='size-4'
|
||||
alt='favicon'
|
||||
/>
|
||||
<span className='text-sm'>{get.site.siteName}</span>
|
||||
<Icon
|
||||
name='X'
|
||||
size={"0.75rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex select-none items-center gap-1.5 rounded-t-lg border border-border px-3 py-1.5'>
|
||||
<img
|
||||
src={get.site.favIcon}
|
||||
className='size-4'
|
||||
alt='favicon'
|
||||
/>
|
||||
<span className='text-sm'>
|
||||
{get.site.siteNameTemplate!.replace("%s", "Page Title").replace("%t", get.site.siteName)}
|
||||
</span>
|
||||
<Icon
|
||||
name='X'
|
||||
size={"0.75rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='h-fit select-none overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'>
|
||||
<img
|
||||
src={"/og.png"}
|
||||
alt='Opengraph Preview'
|
||||
className='w-full'
|
||||
/>
|
||||
<div className='flex flex-col px-3 py-1.5'>
|
||||
<span className='line-clamp-1 w-full text-lg font-medium'>
|
||||
{(get.site.siteNameTemplate || "%s").replace("%s", "Page Title").replace("%t", get.site.siteName)}
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{get.environment.NEXT_PUBLIC_DOMAIN || "http://localhost:3000"}
|
||||
</span>
|
||||
<span className='line-clamp-2 text-sm text-muted-foreground'>{get.site.siteDescription}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inputs.map((props, index) => (
|
||||
<ConfigurationInput<"site", ConfigurationKeys<"site">>
|
||||
key={String(props.inputKey)}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default as ConfigurationForm } from "./Layout";
|
||||
|
||||
export { default as ConfigAPI } from "./ApiForm";
|
||||
export { default as ConfigEnvironment } from "./EnvironmentForm";
|
||||
export { default as ConfigSite } from "./SiteForm";
|
||||
|
||||
export { default as ConfigurationHeader } from "./Header";
|
||||
export { default as ConfigurationInput } from "./Input";
|
||||
export type { ConfigInputs } from "./Input";
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Fragment } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ThemeInput, type ThemeInputProps } from "~/components/Guide/Theme";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Slider } from "~/components/ui/slider";
|
||||
|
||||
import { parseThemeValue } from "~/utils/parseConfigFile";
|
||||
|
||||
import { Schema_Theme, ThemeKeys } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
currentTheme: "light" | "dark";
|
||||
state: {
|
||||
get: {
|
||||
light: z.input<typeof Schema_Theme>;
|
||||
dark: z.input<typeof Schema_Theme>;
|
||||
};
|
||||
set: (theme: "light" | "dark", key: ThemeKeys, value: string) => void;
|
||||
};
|
||||
};
|
||||
export default function ThemeFormInputs({ currentTheme, state: { get, set } }: Props) {
|
||||
const inputs: ThemeInputProps[][] = [
|
||||
[
|
||||
{
|
||||
inputKey: "background",
|
||||
title: "Background",
|
||||
value: parseThemeValue(get[currentTheme].background),
|
||||
onChange: (val) => set(currentTheme, "background", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "foreground",
|
||||
title: "Text",
|
||||
value: parseThemeValue(get[currentTheme].foreground),
|
||||
onChange: (val) => set(currentTheme, "foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
inputKey: "card",
|
||||
title: "Card",
|
||||
value: parseThemeValue(get[currentTheme].card),
|
||||
onChange: (val) => set(currentTheme, "card", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "card-foreground",
|
||||
title: "Card Text",
|
||||
value: parseThemeValue(get[currentTheme]["card-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "card-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "popover",
|
||||
title: "Popover",
|
||||
value: parseThemeValue(get[currentTheme].popover),
|
||||
onChange: (val) => set(currentTheme, "popover", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "popover-foreground",
|
||||
title: "Popover Text",
|
||||
value: parseThemeValue(get[currentTheme]["popover-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "popover-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "accent",
|
||||
title: "Accent",
|
||||
value: parseThemeValue(get[currentTheme].accent),
|
||||
onChange: (val) => set(currentTheme, "accent", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "accent-foreground",
|
||||
title: "Accent Text",
|
||||
value: parseThemeValue(get[currentTheme]["accent-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "accent-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
inputKey: "primary",
|
||||
title: "Primary",
|
||||
value: parseThemeValue(get[currentTheme].primary),
|
||||
onChange: (val) => set(currentTheme, "primary", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "primary-foreground",
|
||||
title: "Primary Text",
|
||||
value: parseThemeValue(get[currentTheme]["primary-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "primary-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "secondary",
|
||||
title: "Secondary",
|
||||
value: parseThemeValue(get[currentTheme].secondary),
|
||||
onChange: (val) => set(currentTheme, "secondary", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "secondary-foreground",
|
||||
title: "Secondary Text",
|
||||
value: parseThemeValue(get[currentTheme]["secondary-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "secondary-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "muted",
|
||||
title: "Muted",
|
||||
value: parseThemeValue(get[currentTheme].muted),
|
||||
onChange: (val) => set(currentTheme, "muted", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "muted-foreground",
|
||||
title: "Muted Text",
|
||||
value: parseThemeValue(get[currentTheme]["muted-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "muted-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "destructive",
|
||||
title: "Destructive",
|
||||
value: parseThemeValue(get[currentTheme].destructive),
|
||||
onChange: (val) => set(currentTheme, "destructive", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "destructive-foreground",
|
||||
title: "Destructive Text",
|
||||
value: parseThemeValue(get[currentTheme]["destructive-foreground"]),
|
||||
onChange: (val) => set(currentTheme, "destructive-foreground", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
inputKey: "border",
|
||||
title: "Element Border",
|
||||
value: parseThemeValue(get[currentTheme].border),
|
||||
onChange: (val) => set(currentTheme, "border", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "input",
|
||||
title: "Input Border",
|
||||
value: parseThemeValue(get[currentTheme].input),
|
||||
onChange: (val) => set(currentTheme, "input", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "ring",
|
||||
title: "Focus Ring",
|
||||
value: parseThemeValue(get[currentTheme].ring),
|
||||
onChange: (val) => set(currentTheme, "ring", `${val.h} ${val.s} ${val.l}`),
|
||||
},
|
||||
{
|
||||
inputKey: "radius",
|
||||
title: "Roundness",
|
||||
value: { h: 0, s: 0, l: 0 },
|
||||
onChange: (val) => {},
|
||||
children: (
|
||||
<div className='flex w-full items-center gap-3'>
|
||||
<Slider
|
||||
className='w-full'
|
||||
defaultValue={[parseFloat(get[currentTheme].radius) * 16 || 0]}
|
||||
min={0}
|
||||
max={24}
|
||||
value={[parseFloat(get[currentTheme].radius) * 16 || 0]}
|
||||
onValueChange={(val) => {
|
||||
const value = val[0] / 16;
|
||||
set(currentTheme, "radius", `${value}rem`);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={parseFloat(get[currentTheme].radius) * 16 || 0}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.currentTarget.value) / 16;
|
||||
set(currentTheme, "radius", `${value}rem`);
|
||||
}}
|
||||
type='number'
|
||||
className='w-16 min-w-0 '
|
||||
min={0}
|
||||
max={24}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='flex h-auto flex-grow flex-col justify-between gap-3'>
|
||||
<div
|
||||
slot='theme-form'
|
||||
className='flex flex-col gap-1.5'
|
||||
>
|
||||
{inputs.map((item, i) => (
|
||||
<Fragment key={`input-group-${i}`}>
|
||||
{item.map((input) => (
|
||||
<ThemeInput
|
||||
key={input.inputKey}
|
||||
{...input}
|
||||
/>
|
||||
))}
|
||||
{i !== inputs.length - 1 && <Separator className='my-1.5' />}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { PopoverTrigger } from "@radix-ui/react-popover";
|
||||
import { HslColor, HslColorPicker } from "react-colorful";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ButtonLoading, Icon } from "~/components/Global";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Popover, PopoverContent } from "~/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import { Schema_Theme } from "~/types/schema";
|
||||
|
||||
export type ThemeInputProps = {
|
||||
inputKey: keyof z.infer<typeof Schema_Theme>;
|
||||
title: string;
|
||||
description?: string;
|
||||
value: HslColor;
|
||||
onChange: (color: HslColor) => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export default function ThemeInput(props: ThemeInputProps) {
|
||||
return (
|
||||
<div
|
||||
id={props.inputKey}
|
||||
slot={`input-${props.inputKey}`}
|
||||
className='grid w-full grid-cols-3 gap-6'
|
||||
>
|
||||
<div
|
||||
slot='label'
|
||||
className='flex items-center gap-3'
|
||||
>
|
||||
<span className='text-base'>{props.title}</span>
|
||||
{props.description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className='cursor-default'
|
||||
>
|
||||
<Icon
|
||||
name='Info'
|
||||
size='1rem'
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
className='max-w-screen-sm'
|
||||
>
|
||||
<p className='max-w-screen-sm !whitespace-pre-wrap'>{props.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
slot='input'
|
||||
className='col-span-2 flex flex-grow items-center justify-end gap-3'
|
||||
>
|
||||
{props.children ? (
|
||||
props.children
|
||||
) : (
|
||||
<>
|
||||
<span className='select-none text-sm text-muted-foreground'>
|
||||
hsl({props.value.h}, {props.value.s}%, {props.value.l}%)
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div className='size-8 rounded-[var(--radius)] border border-border p-0.5'>
|
||||
<div
|
||||
className='size-full rounded-[calc(var(--radius)-0.125rem)]'
|
||||
style={{
|
||||
background: `hsl(${props.value.h}, ${props.value.s}%, ${props.value.l}%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='flex flex-col gap-3'>
|
||||
<div className='flex gap-6'>
|
||||
<HslColorPicker
|
||||
color={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<div className='flex flex-grow flex-col items-center justify-between'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor={`${props.inputKey}-h`}>Hue</Label>
|
||||
<Input
|
||||
id={`${props.inputKey}-h`}
|
||||
name={`${props.inputKey}-h`}
|
||||
type='number'
|
||||
value={props.value.h}
|
||||
min={0}
|
||||
max={360}
|
||||
onChange={(e) => {
|
||||
const value = props.value;
|
||||
props.onChange({
|
||||
h: Number(e.target.value),
|
||||
s: value.s,
|
||||
l: value.l,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor={`${props.inputKey}-s`}>Saturation</Label>
|
||||
<Input
|
||||
id={`${props.inputKey}-s`}
|
||||
name={`${props.inputKey}-s`}
|
||||
type='number'
|
||||
value={props.value.s}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => {
|
||||
const value = props.value;
|
||||
props.onChange({
|
||||
h: value.h,
|
||||
s: Number(e.target.value),
|
||||
l: value.l,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label htmlFor={`${props.inputKey}-l`}>Lightness</Label>
|
||||
<Input
|
||||
id={`${props.inputKey}-l`}
|
||||
name={`${props.inputKey}-l`}
|
||||
type='number'
|
||||
value={props.value.l}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => {
|
||||
const value = props.value;
|
||||
props.onChange({
|
||||
h: value.h,
|
||||
s: value.s,
|
||||
l: Number(e.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full items-center gap-3'>
|
||||
<ButtonLoading
|
||||
className='w-full'
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
state='idle'
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const text = `hsl(${props.value.h}, ${props.value.s}%, ${props.value.l}%)`;
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(`${props.title} color copied!`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ButtonLoading>
|
||||
<ButtonLoading
|
||||
className='w-full'
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
state='idle'
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const color = text.match(
|
||||
/\bhsl\(\s*(\d+)\s*,\s*(\d+(?:\.\d+)?)\s*%\s*,\s*(\d+(?:\.\d+)?)\s*%\s*\)/,
|
||||
);
|
||||
if (!color) {
|
||||
throw new Error("Color does not match the format!");
|
||||
}
|
||||
const h = Number(color[1]);
|
||||
const s = Number(color[2]);
|
||||
const l = Number(color[3]);
|
||||
if (isNaN(h) || isNaN(s) || isNaN(l)) {
|
||||
throw new Error("HSL values are not numbers!");
|
||||
}
|
||||
props.onChange({ h, s, l });
|
||||
toast.success(`Color pasted!`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Paste
|
||||
</ButtonLoading>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { generateTheme } from "~/data/template";
|
||||
|
||||
import { ButtonLoading, Icon, Loader, Markdown } from "~/components/Global";
|
||||
import { FormInputs, ThemePreview } from "~/components/Guide/Theme";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
import { downloadBlob } from "~/utils/downloadBlob";
|
||||
import { fileLoadHelper } from "~/utils/fileLoadHelper";
|
||||
import { parseThemeValue } from "~/utils/parseConfigFile";
|
||||
|
||||
import { ButtonState, Schema_Theme, ThemeKeys } from "~/types/schema";
|
||||
|
||||
export default function ThemeForm() {
|
||||
const loading = useLoading();
|
||||
|
||||
const initialTheme = useMemo<Record<"light" | "dark", z.input<typeof Schema_Theme>>>(
|
||||
() => ({
|
||||
light: {
|
||||
"background": "0 0% 100%",
|
||||
"foreground": "0 0% 3.9%",
|
||||
"card": "0 0% 100%",
|
||||
"card-foreground": "0 0% 3.9%",
|
||||
"popover": "0 0% 100%",
|
||||
"popover-foreground": "0 0% 3.9%",
|
||||
"primary": "0 0% 9%",
|
||||
"primary-foreground": "0 0% 98%",
|
||||
"secondary": "0 0% 96.1%",
|
||||
"secondary-foreground": "0 0% 9%",
|
||||
"muted": "0 0% 96.1%",
|
||||
"muted-foreground": "0 0% 45.1%",
|
||||
"accent": "0 0% 96.1%",
|
||||
"accent-foreground": "0 0% 9%",
|
||||
"destructive": "0 84.2% 60.2%",
|
||||
"destructive-foreground": "0 0% 98%",
|
||||
"border": "0 0% 89.8%",
|
||||
"input": "0 0% 89.8%",
|
||||
"ring": "0 0% 89.8%",
|
||||
"radius": "0.5rem",
|
||||
},
|
||||
dark: {
|
||||
"background": "0 0% 3.9%",
|
||||
"foreground": "0 0% 98%",
|
||||
"card": "0 0% 3.9%",
|
||||
"card-foreground": "0 0% 98%",
|
||||
"popover": "0 0% 3.9%",
|
||||
"popover-foreground": "0 0% 98%",
|
||||
"primary": "0 0% 98%",
|
||||
"primary-foreground": "0 0% 9%",
|
||||
"secondary": "0 0% 14.9%",
|
||||
"secondary-foreground": "0 0% 98%",
|
||||
"muted": "0 0% 14.9%",
|
||||
"muted-foreground": "0 0% 63.9%",
|
||||
"accent": "0 0% 14.9%",
|
||||
"accent-foreground": "0 0% 98%",
|
||||
"destructive": "0 62.8% 30.6%",
|
||||
"destructive-foreground": "0 0% 98%",
|
||||
"border": "0 0% 14.9%",
|
||||
"input": "0 0% 14.9%",
|
||||
"ring": "0 0% 14.9%",
|
||||
"radius": "0.5rem",
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const [currentTheme, setCurrentTheme] = useState<"light" | "dark">("light");
|
||||
const [theme, setTheme] = useState<Record<"light" | "dark", z.input<typeof Schema_Theme>>>(initialTheme);
|
||||
|
||||
const [codeValue, setCodeValue] = useState<string>("");
|
||||
const [pasteDialog, setPasteDialog] = useState<boolean>(false);
|
||||
const [pasteState, setPasteState] = useState<ButtonState>("idle");
|
||||
|
||||
const [downloadState, setDownloadState] = useState<ButtonState>("idle");
|
||||
const [resetState, setResetState] = useState<ButtonState>("idle");
|
||||
|
||||
function assignTheme(theme: "light" | "dark", values: string[]) {
|
||||
for (const value of values) {
|
||||
const [k, v] = value.split(":").map((v) => v.replace("--", "").trim());
|
||||
if (k === "radius") {
|
||||
onThemeChange(theme, k as ThemeKeys, v.replace(";", ""));
|
||||
} else {
|
||||
const color = parseThemeValue(v);
|
||||
if (!color) continue;
|
||||
onThemeChange(theme, k as ThemeKeys, `${color.h} ${color.s}% ${color.l}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function onLoadCSS() {
|
||||
fileLoadHelper({
|
||||
accept: ".css",
|
||||
async onLoad(result) {
|
||||
const themeRegex = /\/\* Shadcn\/ui theme \*\/.*?\:root \{(.*?)\}.*?.dark \{(.*?) \}/gm;
|
||||
const themeMatch = themeRegex.exec(result.replace(/\r?\n?/g, ""));
|
||||
if (!themeMatch) return toast.error("Can't find theme, are you sure it's the right file?");
|
||||
|
||||
const light = themeMatch[1]
|
||||
.replace(/\s{2,4}/g, "\n")
|
||||
.split("\n")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length && (v.startsWith("/*") && v.endsWith("*/") ? false : true));
|
||||
const dark = themeMatch[2]
|
||||
.replace(/\s{2,4}/g, "\n")
|
||||
.split("\n")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length && (v.startsWith("/*") && v.endsWith("*/") ? false : true));
|
||||
|
||||
if (!light || !dark)
|
||||
return toast.error("File loaded doesn't seem like shadcn theme, are you sure it's the right file?");
|
||||
|
||||
assignTheme("light", light);
|
||||
assignTheme("dark", dark);
|
||||
|
||||
toast.success("Theme loaded successfully");
|
||||
},
|
||||
});
|
||||
}
|
||||
function onLoadCode(value: string) {
|
||||
const themeRegex = /\@layer base.*?\:root \{(.*?)\}.*?.dark \{(.*?) \}/gm;
|
||||
const themeMatch = themeRegex.exec(value.replace(/\r\n/g, ""));
|
||||
if (!themeMatch) throw new Error("Unknown format, make sure it's satisfies the shadcn/ui theme format");
|
||||
|
||||
const light = themeMatch[1]
|
||||
.replace(/\s{2,4}/g, "\n")
|
||||
.split("\n")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length && (v.startsWith("/*") && v.endsWith("*/") ? false : true));
|
||||
const dark = themeMatch[2]
|
||||
.replace(/\s{2,4}/g, "\n")
|
||||
.split("\n")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length && (v.startsWith("/*") && v.endsWith("*/") ? false : true));
|
||||
|
||||
if (!light || !dark) throw new Error("It doesn't seem like shadcn theme, are you sure it's the right format?");
|
||||
|
||||
assignTheme("light", light);
|
||||
assignTheme("dark", dark);
|
||||
|
||||
toast.success("Theme loaded successfully");
|
||||
}
|
||||
function onThemeChange(theme: "light" | "dark", key: ThemeKeys, value: string) {
|
||||
setTheme((prev) => ({
|
||||
...prev,
|
||||
[theme]: {
|
||||
...prev[theme],
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function onDownload(copy?: boolean) {
|
||||
setDownloadState("loading");
|
||||
try {
|
||||
const generated = generateTheme(theme);
|
||||
if (copy) {
|
||||
await navigator.clipboard.writeText(generated);
|
||||
return toast.success("Theme copied to clipboard");
|
||||
}
|
||||
|
||||
const blob = new Blob([generated], { type: "text/css" });
|
||||
downloadBlob(blob, "globals.css");
|
||||
toast.success("Theme downloaded successfully");
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setDownloadState("idle");
|
||||
}
|
||||
}
|
||||
async function onReset() {
|
||||
setResetState("loading");
|
||||
try {
|
||||
setTheme(initialTheme);
|
||||
toast.success("Theme reset to default");
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setResetState("idle");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Loader message='Loading theme...' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<CardTitle id='theme'>Theme Customization</CardTitle>
|
||||
<div
|
||||
slot='actions'
|
||||
className='flex w-full items-center gap-3 tablet:w-fit'
|
||||
>
|
||||
<Dialog
|
||||
open={pasteDialog}
|
||||
onOpenChange={setPasteDialog}
|
||||
>
|
||||
<DialogTrigger>
|
||||
<></>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Paste CSS Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
Paste the CSS code from your theme file
|
||||
<br />
|
||||
<Link
|
||||
href={"https://ui.shadcn.com/themes"}
|
||||
target='_blank'
|
||||
className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
|
||||
>
|
||||
Get themes from shadcn/ui
|
||||
</Link>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className='space-y-3'
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setPasteState("loading");
|
||||
|
||||
try {
|
||||
if (!codeValue) throw new Error("No CSS code provided");
|
||||
|
||||
const flatten = codeValue.replace(/\r\n/g, "");
|
||||
onLoadCode(flatten);
|
||||
setPasteDialog(false);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setPasteState("idle");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
id='css-paste'
|
||||
name='css-paste'
|
||||
className='w-full'
|
||||
rows={10}
|
||||
placeholder='Paste your CSS code here'
|
||||
value={codeValue}
|
||||
onChange={(e) => setCodeValue(e.target.value)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"destructive"}
|
||||
type='reset'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<ButtonLoading
|
||||
state={pasteState}
|
||||
size={"sm"}
|
||||
type='submit'
|
||||
icon='Save'
|
||||
>
|
||||
Load Theme
|
||||
</ButtonLoading>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
>
|
||||
<Icon
|
||||
name='Import'
|
||||
size={"1rem"}
|
||||
className='mr-2'
|
||||
/>
|
||||
Import Theme
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={onLoadCSS}>
|
||||
<Icon
|
||||
name='Upload'
|
||||
size={"1rem"}
|
||||
className='mr-2'
|
||||
/>
|
||||
Load from file
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
// setPasteDialog(true);
|
||||
const themeRegex = /\@layer base \{\s+\:root \{\s+.*\} \.dark \{\s+.*\}/gm;
|
||||
const clipboard = await navigator.clipboard.readText();
|
||||
if (clipboard) {
|
||||
const flattenClipboard = clipboard.replace(/\r\n/g, "").trim();
|
||||
const match = themeRegex.exec(flattenClipboard);
|
||||
if (match) {
|
||||
setCodeValue(clipboard.trim());
|
||||
toast.success("Theme found in clipboard! Click 'Load Theme' to apply it");
|
||||
}
|
||||
}
|
||||
|
||||
setPasteDialog(true);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name='Code'
|
||||
size={"1rem"}
|
||||
className='mr-2'
|
||||
/>
|
||||
Paste CSS code
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='space-y-3 py-3'>
|
||||
<Markdown
|
||||
content={`You can personalize the theme of your index by changing the color of the elements.
|
||||
You can either use default colors from [shadcn/ui](https://ui.shadcn.com/themes) or create your own theme by changing the color code of each element.`}
|
||||
/>
|
||||
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<div className='flex items-center'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={currentTheme === "light" ? "default" : "outline"}
|
||||
className='rounded-r-none'
|
||||
onClick={() => setCurrentTheme("light")}
|
||||
>
|
||||
<Icon
|
||||
name='Sun'
|
||||
size='1rem'
|
||||
className='mr-2'
|
||||
/>
|
||||
Light theme
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={currentTheme === "dark" ? "default" : "outline"}
|
||||
className='rounded-l-none'
|
||||
onClick={() => setCurrentTheme("dark")}
|
||||
>
|
||||
<Icon
|
||||
name='Moon'
|
||||
size='1rem'
|
||||
className='mr-2'
|
||||
/>
|
||||
Dark theme
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
state={downloadState}
|
||||
icon={"Save"}
|
||||
onClick={() => onDownload()}
|
||||
>
|
||||
Download theme
|
||||
</ButtonLoading>
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
state={resetState}
|
||||
variant={"destructive"}
|
||||
icon='History'
|
||||
onClick={onReset}
|
||||
>
|
||||
Reset theme
|
||||
</ButtonLoading>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-3 rounded-[var(--radius)] border border-border p-4 tablet:grid-cols-2'>
|
||||
<FormInputs
|
||||
currentTheme={currentTheme}
|
||||
state={{
|
||||
get: theme,
|
||||
set: onThemeChange,
|
||||
}}
|
||||
/>
|
||||
<ThemePreview theme={theme[currentTheme]} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { PopoverTrigger } from "@radix-ui/react-popover";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import nProgress from "nprogress";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Grid as FileGrid, List as FileList } from "~/components/Explorer";
|
||||
import { ButtonLoading } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Popover, PopoverContent } from "~/components/ui/popover";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { ButtonState, Schema_File, Schema_Theme } from "~/types/schema";
|
||||
|
||||
type Props = {
|
||||
theme: z.input<typeof Schema_Theme>;
|
||||
};
|
||||
export default function ThemePreview({ theme }: Props) {
|
||||
const fileMockData = useMemo<z.infer<typeof Schema_File>>(
|
||||
() => ({
|
||||
encryptedId: "1",
|
||||
name: "File Name",
|
||||
modifiedTime: "2021-09-01T00:00:00.000Z",
|
||||
mimeType: "text/plain",
|
||||
trashed: false,
|
||||
size: 1024 * 1024 * 100,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
||||
const [toastState, setToastState] = useState<ButtonState>("idle");
|
||||
const [loadingState, setLoadingState] = useState<ButtonState>("idle");
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='container'
|
||||
className='overflow-hidden rounded-[var(--radius)] border border-border'
|
||||
>
|
||||
<div
|
||||
slot='preview'
|
||||
ref={setPortalContainer}
|
||||
className='preview flex flex-col gap-3 bg-background p-3 text-foreground'
|
||||
style={
|
||||
{
|
||||
"--background": theme.background,
|
||||
"--foreground": theme.foreground,
|
||||
"--card": theme.card,
|
||||
"--card-foreground": theme["card-foreground"],
|
||||
"--popover": theme.popover,
|
||||
"--popover-foreground": theme["popover-foreground"],
|
||||
"--primary": theme.primary,
|
||||
"--primary-foreground": theme["primary-foreground"],
|
||||
"--secondary": theme.secondary,
|
||||
"--secondary-foreground": theme["secondary-foreground"],
|
||||
"--muted": theme.muted,
|
||||
"--muted-foreground": theme["muted-foreground"],
|
||||
"--accent": theme.accent,
|
||||
"--accent-foreground": theme["accent-foreground"],
|
||||
"--destructive": theme.destructive,
|
||||
"--destructive-foreground": theme["destructive-foreground"],
|
||||
"--border": theme.border,
|
||||
"--input": theme.input,
|
||||
"--ring": theme.ring,
|
||||
"--radius": theme.radius,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Wrapper
|
||||
title='Button'
|
||||
extra={`Primary, Secondary, Accent, Destructive, Input Border`}
|
||||
className='grid grid-cols-3 gap-1.5'
|
||||
>
|
||||
{["default", "secondary", "destructive", "outline", "ghost", "link"].map((btn) => (
|
||||
<Button
|
||||
key={btn}
|
||||
size={"sm"}
|
||||
variant={btn as any}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className='capitalize'
|
||||
>
|
||||
{btn}
|
||||
</Button>
|
||||
))}
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper
|
||||
title='Input'
|
||||
extra={`Muted, Input Border`}
|
||||
className='space-y-1.5'
|
||||
>
|
||||
<Input placeholder='Input' />
|
||||
<Textarea placeholder='Textarea' />
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper
|
||||
title='Popover'
|
||||
extra={`Popover, Element Border`}
|
||||
className='grid grid-cols-3 gap-1.5'
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
>
|
||||
Hover me
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Hello</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
>
|
||||
Click me
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent portal={portalContainer}>
|
||||
<p>Hello</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
>
|
||||
Dropdown
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent portal={portalContainer}>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
<DropdownMenuItem>Item 2</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>Item 3</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper
|
||||
title='Overlay'
|
||||
extra={`Background, Primary, Muted, Element Border`}
|
||||
className='grid grid-cols-3 gap-1.5'
|
||||
>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
Open Dialog
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent portal={portalContainer}>Dialog Content</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
Open Drawer
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent portal={portalContainer}>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Drawer Title</DrawerTitle>
|
||||
<DrawerDescription>Description</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='p-6'>Drawer only used for mobile devices</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
Open Sheet
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent portal={portalContainer}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
<SheetDescription>Description</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div>Sheet only used for mobile devices</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ButtonLoading
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
state={toastState}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setToastState("loading");
|
||||
const style = {
|
||||
background: `hsl(${theme.background})`,
|
||||
color: `hsl(${theme.foreground})`,
|
||||
borderRadius: `var(${theme.radius})`,
|
||||
border: `1px solid hsl(${theme.border})`,
|
||||
};
|
||||
toast.loading("Toast!", { duration: 1500, style });
|
||||
toast.success("Toast!", { duration: 1500, style });
|
||||
toast.error("Toast!", { duration: 1500, style });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
setToastState("idle");
|
||||
}}
|
||||
>
|
||||
Toast!
|
||||
</ButtonLoading>
|
||||
<NextTopLoader color={`hsl(${theme.primary})`} />
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className={"w-full"}
|
||||
state={loadingState}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setLoadingState("loading");
|
||||
|
||||
nProgress.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
nProgress.done();
|
||||
setLoadingState("idle");
|
||||
}}
|
||||
>
|
||||
Loading bar
|
||||
</ButtonLoading>
|
||||
</Wrapper>
|
||||
|
||||
<Wrapper
|
||||
title='File'
|
||||
className='grid grid-cols-1 gap-1.5'
|
||||
>
|
||||
<FileList
|
||||
data={fileMockData}
|
||||
disabled
|
||||
/>
|
||||
<FileGrid
|
||||
data={fileMockData}
|
||||
disabled
|
||||
/>
|
||||
</Wrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type WrapperProps = {
|
||||
title: string;
|
||||
extra?: string;
|
||||
className?: string;
|
||||
};
|
||||
function Wrapper({ title, extra, className, children }: React.PropsWithChildren<WrapperProps>) {
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
<div className='flex flex-col'>
|
||||
<h3>{title}</h3>
|
||||
{extra && <p className='text-xs opacity-50'>{extra}</p>}
|
||||
</div>
|
||||
<div className={cn("w-full", className)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as FormInputs } from "./FormInputs";
|
||||
export { default as ThemeInput } from "./Input";
|
||||
export type { ThemeInputProps } from "./Input";
|
||||
export { default as ThemeForm } from "./Layout";
|
||||
export { default as ThemePreview } from "./Preview";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./Configuration";
|
||||
export * from "./Theme";
|
||||
@@ -3,7 +3,7 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
|
||||
import isDev from "~/utils/isDev";
|
||||
import { isDev } from "~/utils/isDev";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
@@ -1,14 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { TooltipTrigger } from "@radix-ui/react-tooltip";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -36,19 +34,15 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetTrigger,
|
||||
} from "~/components/ui/sheet";
|
||||
import { Tooltip, TooltipContent } from "~/components/ui/tooltip";
|
||||
import { Sheet, SheetContent, SheetFooter, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
|
||||
import { ClearPassword } from "./actions";
|
||||
import { ClearPassword } from "actions";
|
||||
import config from "config";
|
||||
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname();
|
||||
@@ -86,19 +80,13 @@ export default function Navbar() {
|
||||
slot='nav'
|
||||
className={cn(
|
||||
"bg-background",
|
||||
// "sticky left-0 top-0",
|
||||
"h-12 w-full",
|
||||
"px-6 py-3",
|
||||
"flex flex-grow flex-row items-center justify-between",
|
||||
"border-b border-border",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center justify-between",
|
||||
"mx-auto w-full max-w-screen-desktop",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-row items-center justify-between", "mx-auto w-full max-w-screen-desktop")}>
|
||||
<Link
|
||||
href={"/"}
|
||||
onClick={() => setOpen(false)}
|
||||
@@ -110,9 +98,7 @@ export default function Navbar() {
|
||||
className={cn("h-10 w-10")}
|
||||
loading='eager'
|
||||
/>
|
||||
<span className={cn("large", "hidden", "tablet:block")}>
|
||||
{config.siteConfig.siteName}
|
||||
</span>
|
||||
<span className={cn("large", "hidden", "tablet:block")}>{config.siteConfig.siteName}</span>
|
||||
</Link>
|
||||
|
||||
{isDesktop ? (
|
||||
@@ -128,16 +114,14 @@ export default function Navbar() {
|
||||
"flex flex-col items-center justify-center",
|
||||
"opacity-80",
|
||||
"hover:opacity-100",
|
||||
pathname === item.href
|
||||
? "cursor-default opacity-100"
|
||||
: "cursor-pointer",
|
||||
pathname === item.href ? "cursor-default opacity-100" : "cursor-pointer",
|
||||
"p-1.5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
size={20}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
@@ -170,14 +154,8 @@ export default function Navbar() {
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
theme === "system"
|
||||
? "LaptopMinimal"
|
||||
: theme === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
size={20}
|
||||
name={theme === "system" ? "LaptopMinimal" : theme === "light" ? "Sun" : "Moon"}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -186,7 +164,7 @@ export default function Navbar() {
|
||||
<p>Site theme</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuContent align='start'>
|
||||
{themes.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item}
|
||||
@@ -195,14 +173,7 @@ export default function Navbar() {
|
||||
onClick={() => setTheme(item)}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<span
|
||||
className={cn(
|
||||
"capitalize",
|
||||
// item === theme && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<span className={cn("capitalize")}>{item}</span>
|
||||
<Icon
|
||||
name={
|
||||
item === theme
|
||||
@@ -213,11 +184,7 @@ export default function Navbar() {
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
className={
|
||||
cn()
|
||||
// item === theme && "text-muted-foreground",
|
||||
}
|
||||
size={16}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
@@ -241,7 +208,7 @@ export default function Navbar() {
|
||||
>
|
||||
<Icon
|
||||
name='Heart'
|
||||
size={20}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -250,7 +217,7 @@ export default function Navbar() {
|
||||
<p>Support</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuContent align='center'>
|
||||
{config.siteConfig.supports.map((item) => (
|
||||
<DropdownMenuItem key={item.name}>
|
||||
<Link
|
||||
@@ -283,12 +250,12 @@ export default function Navbar() {
|
||||
>
|
||||
<Icon
|
||||
name={"Book"}
|
||||
size={20}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
<span className='text-sm'>Guide</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<TooltipContent>
|
||||
<p>Deploy Guide</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -315,7 +282,7 @@ export default function Navbar() {
|
||||
<Icon
|
||||
name='LogOut'
|
||||
className='text-red-500'
|
||||
size={20}
|
||||
size={"1.25rem"}
|
||||
/>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
@@ -328,8 +295,7 @@ export default function Navbar() {
|
||||
<DialogContent>
|
||||
<DialogTitle>Clear all saved password?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You will need to re-enter the password to access the
|
||||
protected content.
|
||||
You will need to re-enter the password to access the protected content.
|
||||
<br />
|
||||
<b>Are you sure you want to continue?</b>
|
||||
</DialogDescription>
|
||||
@@ -354,6 +320,7 @@ export default function Navbar() {
|
||||
<Drawer
|
||||
open={themeOpen}
|
||||
onOpenChange={setThemeOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
@@ -361,14 +328,8 @@ export default function Navbar() {
|
||||
size={"icon"}
|
||||
>
|
||||
<Icon
|
||||
name={
|
||||
theme === "system"
|
||||
? "LaptopMinimal"
|
||||
: theme === "light"
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
size={18}
|
||||
name={theme === "system" ? "LaptopMinimal" : theme === "light" ? "Sun" : "Moon"}
|
||||
size={"1.125rem"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
@@ -376,16 +337,14 @@ export default function Navbar() {
|
||||
<DrawerContent>
|
||||
<DrawerHeader className='text-start'>
|
||||
<DrawerTitle>Theme</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Choose your preferred theme
|
||||
</DrawerDescription>
|
||||
<DrawerDescription>Choose your preferred theme</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className='flex flex-col px-4'>
|
||||
<div className='grid gap-1.5 px-4'>
|
||||
{themes.map((item) => (
|
||||
<Button
|
||||
key={item}
|
||||
variant={"ghost"}
|
||||
variant={theme === item ? "default" : "outline"}
|
||||
size={"sm"}
|
||||
className='w-full'
|
||||
disabled={item === theme}
|
||||
@@ -412,11 +371,7 @@ export default function Navbar() {
|
||||
? "Sun"
|
||||
: "Moon"
|
||||
}
|
||||
className={
|
||||
cn()
|
||||
// item === theme && "text-muted-foreground",
|
||||
}
|
||||
size={16}
|
||||
size={"1rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -446,7 +401,7 @@ export default function Navbar() {
|
||||
>
|
||||
<Icon
|
||||
name='Menu'
|
||||
size={18}
|
||||
size={"1.125rem"}
|
||||
className='text-foreground'
|
||||
/>
|
||||
</Button>
|
||||
@@ -464,9 +419,7 @@ export default function Navbar() {
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={
|
||||
item.external ? "noopener noreferrer" : undefined
|
||||
}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
className='flex w-full items-center justify-between gap-3'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
@@ -474,14 +427,14 @@ export default function Navbar() {
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className='text-foreground'
|
||||
size={18}
|
||||
size={"1.125rem"}
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.external && (
|
||||
<Icon
|
||||
name='ExternalLink'
|
||||
size={12}
|
||||
size={"0.75rem"}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
)}
|
||||
@@ -504,7 +457,7 @@ export default function Navbar() {
|
||||
<Icon
|
||||
name={"Book"}
|
||||
className='text-foreground'
|
||||
size={18}
|
||||
size={"1.125rem"}
|
||||
/>
|
||||
Deploy Guide
|
||||
</div>
|
||||
@@ -513,7 +466,7 @@ export default function Navbar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<div className='hidden flex-col'>
|
||||
<p className='large'>Theme</p>
|
||||
<Separator className='my-1.5' />
|
||||
{themes.map((item) => (
|
||||
@@ -550,7 +503,7 @@ export default function Navbar() {
|
||||
cn()
|
||||
// item === theme && "text-muted-foreground",
|
||||
}
|
||||
size={18}
|
||||
size={"1.125rem"}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -599,8 +552,7 @@ export default function Navbar() {
|
||||
<DrawerHeader className='text-left'>
|
||||
<DrawerTitle>Clear all saved password?</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
You will need to re-enter the password to access the
|
||||
protected content.
|
||||
You will need to re-enter the password to access the protected content.
|
||||
<br />
|
||||
<b>Are you sure you want to continue?</b>
|
||||
</DrawerDescription>
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Status } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import useRouter from "~/hooks/usePRouter";
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-12")}>
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='The file you are looking for does not exist'
|
||||
/>
|
||||
|
||||
<div className={cn("w-full", "flex flex-col items-center gap-3")}>
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className='w-full'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Back to Previous Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Icon, Loader } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
import {
|
||||
CheckPassword,
|
||||
CheckSitePassword,
|
||||
SetPassword,
|
||||
SetSitePassword,
|
||||
} from "./actions";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import { CheckPassword, CheckSitePassword, SetPassword, SetSitePassword } from "actions";
|
||||
|
||||
type Props = {
|
||||
path: string;
|
||||
@@ -23,10 +21,10 @@ type Props = {
|
||||
};
|
||||
export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
const router = useRouter();
|
||||
const loading = useLoading();
|
||||
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [submitLoading, setSubmitLoading] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -44,8 +42,7 @@ export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
const check = await CheckSitePassword();
|
||||
if (!check.success) throw new Error(check.message);
|
||||
} else {
|
||||
if (!checkPaths)
|
||||
throw new Error("No path found, try to refresh the page.");
|
||||
if (!checkPaths) throw new Error("No path found, try to refresh the page.");
|
||||
const set = await SetPassword(path, input);
|
||||
if (!set.success) throw new Error(set.message);
|
||||
|
||||
@@ -70,34 +67,10 @@ export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-md",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Checking password...</p>
|
||||
</div>
|
||||
);
|
||||
if (loading) return <Loader message='Checking password...' />;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-full max-w-md",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<div className={cn("mx-auto h-full w-full max-w-md", "flex flex-grow flex-col items-center justify-center gap-3")}>
|
||||
<img
|
||||
src='/images/undraw_vault.svg'
|
||||
alt='Password illustration'
|
||||
@@ -110,9 +83,6 @@ export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
? "This site are password protected"
|
||||
: "The folder or file you are trying to access is password protected"}
|
||||
</h3>
|
||||
{/* <span className='muted text-pretty text-center'>
|
||||
Please enter the password to access the content
|
||||
</span> */}
|
||||
</div>
|
||||
|
||||
<form
|
||||
@@ -136,7 +106,7 @@ export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
<>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={16}
|
||||
size={"1rem"}
|
||||
className='animate-spin'
|
||||
/>
|
||||
Loading...
|
||||
@@ -146,12 +116,7 @@ export default function Password({ path, checkPaths, errorMessage }: Props) {
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<span
|
||||
className={cn(
|
||||
"muted text-pretty text-center text-red-500",
|
||||
errorMessage ? "visible" : "invisible",
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-pretty text-center text-destructive", errorMessage ? "visible" : "invisible")}>
|
||||
{errorMessage || "Nothing wrong here, just a password wall"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { icons } from "lucide-react";
|
||||
import { HTMLProps } from "react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
type DropdownItem =
|
||||
| {
|
||||
type: "label";
|
||||
key: string;
|
||||
title: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
| {
|
||||
type: "separator";
|
||||
key: string;
|
||||
className?: string;
|
||||
}
|
||||
| {
|
||||
type: "item";
|
||||
key: string;
|
||||
title: React.ReactNode;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
icon?: keyof typeof icons;
|
||||
className?: string;
|
||||
}
|
||||
| {
|
||||
type: "checkbox";
|
||||
key: string;
|
||||
title: React.ReactNode;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
| {
|
||||
type: "radio";
|
||||
key: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
items: {
|
||||
key: string;
|
||||
value: string;
|
||||
title: string;
|
||||
className?: string;
|
||||
}[];
|
||||
};
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
trigger: React.ReactNode;
|
||||
mobile: {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
closeOnFooter?: boolean;
|
||||
footer?: React.ReactNode;
|
||||
};
|
||||
desktop: {
|
||||
align?: "start" | "center" | "end";
|
||||
alignOffset?: number;
|
||||
placement?: "top" | "right" | "bottom" | "left";
|
||||
placementOffset?: number;
|
||||
items: DropdownItem[];
|
||||
};
|
||||
} & HTMLProps<HTMLDivElement>;
|
||||
export default function ResponsiveDropdown({ open, onOpenChange, trigger, mobile, desktop, ...rest }: Props) {
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='responsive-menu'
|
||||
{...rest}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={desktop.align}
|
||||
alignOffset={desktop.alignOffset}
|
||||
side={desktop.placement}
|
||||
sideOffset={desktop.placementOffset}
|
||||
>
|
||||
{desktop.items.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case "label":
|
||||
return (
|
||||
<DropdownMenuLabel
|
||||
key={item.key}
|
||||
className={item.className}
|
||||
>
|
||||
{item.title}
|
||||
</DropdownMenuLabel>
|
||||
);
|
||||
case "separator":
|
||||
return (
|
||||
<DropdownMenuSeparator
|
||||
key={item.key}
|
||||
className={item.className}
|
||||
/>
|
||||
);
|
||||
case "checkbox":
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.key}
|
||||
checked={item.checked}
|
||||
onCheckedChange={item.onCheckedChange}
|
||||
disabled={item.disabled}
|
||||
className={item.className}
|
||||
>
|
||||
{item.title}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
case "radio":
|
||||
return (
|
||||
<DropdownMenuRadioGroup
|
||||
value={item.value}
|
||||
onValueChange={item.onValueChange}
|
||||
>
|
||||
{item.items.map((radio) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={radio.key}
|
||||
value={radio.value}
|
||||
className={radio.className}
|
||||
>
|
||||
{radio.title}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.key}
|
||||
disabled={item.disabled}
|
||||
onClick={item.onClick}
|
||||
className={item.className}
|
||||
>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
name={item.icon}
|
||||
size={"1rem"}
|
||||
className='mr-2'
|
||||
/>
|
||||
)}
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
{(mobile.title || mobile.description) && (
|
||||
<DrawerHeader className='text-start'>
|
||||
{mobile.title && <DrawerTitle>{mobile.title}</DrawerTitle>}
|
||||
{mobile.description && <DrawerDescription>{mobile.description}</DrawerDescription>}
|
||||
</DrawerHeader>
|
||||
)}
|
||||
|
||||
<div className='grid gap-1.5 px-4'>{mobile.content}</div>
|
||||
|
||||
{(mobile.footer || mobile.closeOnFooter) && (
|
||||
<DrawerFooter>
|
||||
<Separator />
|
||||
{mobile.footer}
|
||||
|
||||
{mobile.closeOnFooter && (
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant='secondary'
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
)}
|
||||
</DrawerFooter>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,18 +4,15 @@ import { ThemeProvider as NextThemeProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import { TooltipProvider } from "~/components/ui/tooltip";
|
||||
|
||||
import LayoutProvider from "~/context/layoutContext";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import config from "config";
|
||||
|
||||
export default function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<NextTopLoader color='hsl(var(--primary))' />
|
||||
@@ -39,8 +36,8 @@ export default function ThemeProvider({
|
||||
position={config.siteConfig.toaster?.position}
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "hsl(var(--accent))",
|
||||
color: "hsl(var(--accent-foreground))",
|
||||
background: "hsl(var(--background))",
|
||||
color: "hsl(var(--foreground))",
|
||||
borderRadius: "var(--radius)",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
},
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Icon } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export default function ToTop() {
|
||||
const [show, setShow] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setShow(window.scrollY > 100);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
slot='to-top-fab'
|
||||
className={cn(
|
||||
"fixed bottom-6 right-6 z-10 transition",
|
||||
show ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size={"icon"}
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name='ChevronUp'
|
||||
size='1rem'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Footer } from "./Footer";
|
||||
export { default as Navbar } from "./Navbar";
|
||||
export { default as NotFound } from "./NotFound";
|
||||
export { default as Password } from "./Password";
|
||||
export { default as ResponsiveDropdown } from "./ResponsiveDropdown";
|
||||
export { default as ThemeProvider } from "./ThemeProvider";
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Loader, Status } from "~/components/Global";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
|
||||
const Plyr = dynamic(() => import("plyr-react"), {
|
||||
ssr: false,
|
||||
loading: () => <Loader message='Loading player...' />,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewAudio({ file }: Props) {
|
||||
const [audioSrc, setAudioSrc] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loading = useLoading(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No audio to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setAudioSrc(`/api/stream/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<Loader message='Loading audio...' />
|
||||
) : error ? (
|
||||
<Status
|
||||
icon='TriangleAlert'
|
||||
message={error}
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full'>
|
||||
<Plyr
|
||||
source={{
|
||||
type: "audio",
|
||||
sources: [
|
||||
{
|
||||
src: audioSrc,
|
||||
type: file.mimeType,
|
||||
size: file.size,
|
||||
},
|
||||
],
|
||||
title: file.name,
|
||||
}}
|
||||
options={{
|
||||
controls: [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"duration",
|
||||
"mute",
|
||||
"volume",
|
||||
"settings",
|
||||
"fullscreen",
|
||||
],
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
loop: {
|
||||
active: false,
|
||||
},
|
||||
speed: {
|
||||
selected: 1,
|
||||
options: [0.5, 1, 1.5, 2],
|
||||
},
|
||||
keyboard: {
|
||||
focused: true,
|
||||
global: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import DocViewer, { DocRenderer } from "@cyntler/react-doc-viewer";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
const GoogleDocsViewerRenderer: DocRenderer = ({
|
||||
mainState: { currentDocument, documentLoading },
|
||||
}) => {
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
const GoogleDocsViewerRenderer: DocRenderer = ({ mainState: { currentDocument, documentLoading } }) => {
|
||||
if (!currentDocument || !currentDocument.uri) return null;
|
||||
|
||||
const viewerUrl = new URL(`/gview`, "https://docs.google.com");
|
||||
@@ -23,21 +24,7 @@ const GoogleDocsViewerRenderer: DocRenderer = ({
|
||||
viewerUrl.searchParams.set("embedded", "true");
|
||||
|
||||
if (documentLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading document...</p>
|
||||
</div>
|
||||
);
|
||||
return <Loader message='Loading document...' />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -70,60 +57,53 @@ type Props = {
|
||||
|
||||
export default function PreviewDoc({ file }: Props) {
|
||||
const [docSrc, setDocSrc] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("Nothing to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
const streamURL = new URL(
|
||||
`/api/stream/${file.encryptedId}`,
|
||||
config.basePath,
|
||||
);
|
||||
streamURL.searchParams.set("token", token);
|
||||
setDocSrc(streamURL.toString());
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const loading = useLoading(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("Nothing to preview");
|
||||
return;
|
||||
}
|
||||
})();
|
||||
const token = await CreateDownloadToken();
|
||||
const streamURL = new URL(`/api/stream/${file.encryptedId}`, config.basePath);
|
||||
streamURL.searchParams.set("token", token);
|
||||
setDocSrc(streamURL.toString());
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading document...</p>
|
||||
</div>
|
||||
<Loader message='Loading document...' />
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
<Status
|
||||
icon='TriangleAlert'
|
||||
message={error}
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className='h-fit w-full space-y-3 overflow-hidden rounded-[var(--radius)]'>
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
It might failed to load the document, you can try to refresh the page or download the document to view
|
||||
it.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<DocViewer
|
||||
key={file.encryptedId}
|
||||
documents={[
|
||||
@@ -139,35 +119,16 @@ export default function PreviewDoc({ file }: Props) {
|
||||
disableHeader: true,
|
||||
},
|
||||
loadingRenderer: {
|
||||
overrideComponent: () => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Loading document...</p>
|
||||
</div>
|
||||
),
|
||||
overrideComponent: () => <Loader message='Loading document...' />,
|
||||
showLoadingTimeout: 10000,
|
||||
},
|
||||
noRenderer: {
|
||||
overrideComponent: () => (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>
|
||||
Error loading document
|
||||
</span>
|
||||
</div>
|
||||
<Status
|
||||
icon='CircleX'
|
||||
message='Failed to load document'
|
||||
destructive
|
||||
/>
|
||||
),
|
||||
},
|
||||
}}
|
||||
@@ -179,22 +140,6 @@ export default function PreviewDoc({ file }: Props) {
|
||||
disableThemeScrollbar: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
It might failed to load the document, you can try to refresh
|
||||
the page or download the document to view it.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewImage({ file }: Props) {
|
||||
const [imgSrc, setImgSrc] = useState<string>(`/api/thumb/${file.encryptedId}?size=4`);
|
||||
const [imgLoaded, setImgLoaded] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loading = useLoading(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No image to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
|
||||
const streamURL = new URL(`/api/thumb/${file.encryptedId}?size=1000`, config.basePath);
|
||||
streamURL.searchParams.set("token", token);
|
||||
fetch(streamURL, {
|
||||
headers: {
|
||||
Range: `bytes=0-${(file.size || 1) - 1}`,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error("Could not load image");
|
||||
}
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const urlobject = URL.createObjectURL(blob);
|
||||
setImgSrc(urlobject);
|
||||
const timeout = setTimeout(() => {
|
||||
setImgLoaded(true);
|
||||
clearTimeout(timeout);
|
||||
}, 150); // Add a delay to show the image
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<Loader message='Loading image...' />
|
||||
) : error ? (
|
||||
<Status
|
||||
icon='TriangleAlert'
|
||||
message={error}
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className='h-fit w-full space-y-3 overflow-hidden'>
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
This image is a preview and may not be the full resolution. Please download the file for the full
|
||||
resolution.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={file.name}
|
||||
className={cn(
|
||||
"h-full max-h-[70dvh] w-full rounded-[var(--radius)] bg-muted object-contain object-center transition",
|
||||
imgLoaded ? "blur-none" : "animate-pulse blur",
|
||||
)}
|
||||
onError={(e) => {
|
||||
console.error(e);
|
||||
setError("Could not preview this image, try downloading the file");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,43 +5,34 @@ import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { ButtonLoading } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTrigger,
|
||||
} from "~/components/ui/drawer";
|
||||
import { Drawer, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTrigger } from "~/components/ui/drawer";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import bytesToReadable from "~/utils/bytesFormat";
|
||||
import { bytesToReadable } from "~/utils/bytesFormat";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { durationToReadable } from "~/utils/durationFormat";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
import { ButtonState, Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewAction({ file }: Props) {
|
||||
export default function PreviewInformation({ file }: Props) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const showRaw = useMemo<boolean>(() => {
|
||||
return (
|
||||
file.mimeType.startsWith("image") ||
|
||||
file.mimeType.startsWith("video") ||
|
||||
file.mimeType.startsWith("audio")
|
||||
);
|
||||
return file.mimeType.startsWith("image") || file.mimeType.startsWith("video") || file.mimeType.startsWith("audio");
|
||||
}, [file]);
|
||||
const showViewDoc = useMemo<boolean>(() => {
|
||||
const fileExtensionFallback = file.name.split(".").pop();
|
||||
@@ -63,7 +54,7 @@ export default function PreviewAction({ file }: Props) {
|
||||
},
|
||||
{
|
||||
label: "Size",
|
||||
value: bytesToReadable(file.size || 0),
|
||||
value: `${bytesToReadable(file.size || 0)} (${file.size || 0} bytes)`,
|
||||
},
|
||||
{
|
||||
label: "Last Modified",
|
||||
@@ -90,29 +81,33 @@ export default function PreviewAction({ file }: Props) {
|
||||
return value;
|
||||
}, [file]);
|
||||
|
||||
const [downloading, setDownloading] = useState<boolean>(false);
|
||||
const [diffOpen, setDiffOpen] = useState<boolean>(false);
|
||||
const [viewerState, setViewerState] = useState<ButtonState>("idle");
|
||||
const [copyRawState, setCopyRawState] = useState<ButtonState>("idle");
|
||||
const [copyDownloadState, setCopyDownloadState] = useState<ButtonState>("idle");
|
||||
const [downloadState, setDownloadState] = useState<ButtonState>("idle");
|
||||
const [isRawExplanationOpen, setIsRawExplanationOpen] = useState<boolean>(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const onCopyRaw = async () => {
|
||||
setCopyRawState("loading");
|
||||
try {
|
||||
const rawURL = new URL(
|
||||
`/api/raw/${pathname}`.replace(/\/+/g, "/"),
|
||||
config.basePath,
|
||||
);
|
||||
const rawURL = new URL(`/api/raw/${pathname}`.replace(/\/+/g, "/"), config.basePath);
|
||||
rawURL.searchParams.append("token", file.encryptedId);
|
||||
toast.promise(navigator.clipboard.writeText(rawURL.toString()), {
|
||||
loading: "Copying link...",
|
||||
success: "Link copied!",
|
||||
success: "Raw link copied!",
|
||||
error: "Failed to copy link",
|
||||
});
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
} finally {
|
||||
setCopyRawState("idle");
|
||||
}
|
||||
};
|
||||
const onCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCopyDownloadState("loading");
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
@@ -120,12 +115,9 @@ export default function PreviewAction({ file }: Props) {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create download token");
|
||||
await navigator.clipboard.writeText(
|
||||
new URL(
|
||||
`/api/download/${file.encryptedId}?token=${token}`,
|
||||
config.basePath,
|
||||
).toString(),
|
||||
new URL(`/api/download/${file.encryptedId}?token=${token}`, config.basePath).toString(),
|
||||
);
|
||||
toast.success("Link copied...", {
|
||||
toast.success("Download link copied!", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -134,11 +126,13 @@ export default function PreviewAction({ file }: Props) {
|
||||
toast.error(e.message, {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
} finally {
|
||||
setCopyDownloadState("idle");
|
||||
}
|
||||
};
|
||||
const onDownload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDownloading(true);
|
||||
setDownloadState("loading");
|
||||
toast.loading("Creating download token...", {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
@@ -152,15 +146,15 @@ export default function PreviewAction({ file }: Props) {
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
window.open(`/api/download/${file.encryptedId}?token=${token}`);
|
||||
setDownloading(false);
|
||||
}, 1000);
|
||||
}, 250);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `download-${file.encryptedId}`,
|
||||
});
|
||||
setDownloading(false);
|
||||
} finally {
|
||||
setDownloadState("idle");
|
||||
}
|
||||
};
|
||||
const onOpenViewer = async (e: React.MouseEvent) => {
|
||||
@@ -168,13 +162,11 @@ export default function PreviewAction({ file }: Props) {
|
||||
toast.loading("Creating view token...", {
|
||||
id: `view-${file.encryptedId}`,
|
||||
});
|
||||
setViewerState("loading");
|
||||
try {
|
||||
const token = await CreateDownloadToken();
|
||||
if (!token) throw new Error("Failed to create view token");
|
||||
const streamURL = new URL(
|
||||
`/api/stream/${file.encryptedId}`,
|
||||
config.basePath,
|
||||
);
|
||||
const streamURL = new URL(`/api/stream/${file.encryptedId}`, config.basePath);
|
||||
streamURL.searchParams.set("token", token);
|
||||
toast.success("Opening viewer link...", {
|
||||
id: `view-${file.encryptedId}`,
|
||||
@@ -186,13 +178,15 @@ export default function PreviewAction({ file }: Props) {
|
||||
viewerUrl.searchParams.set("url", streamURL.toString());
|
||||
viewerUrl.searchParams.set("embedded", "true");
|
||||
window.open(viewerUrl.toString(), "_blank");
|
||||
}, 150);
|
||||
}, 250);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e.message);
|
||||
toast.error(e.message, {
|
||||
id: `view-${file.encryptedId}`,
|
||||
});
|
||||
} finally {
|
||||
setViewerState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -200,11 +194,14 @@ export default function PreviewAction({ file }: Props) {
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
<Card slot='file-actions'>
|
||||
<div className='flex flex-col-reverse gap-3 p-6 tablet:flex-row tablet:items-center tablet:justify-between'>
|
||||
<div className={cn("flex gap-3", !showRaw && "invisible")}>
|
||||
<div
|
||||
slot='raw-explanation'
|
||||
className={cn("flex gap-3", !showRaw && "invisible")}
|
||||
>
|
||||
{isDesktop ? (
|
||||
<Dialog
|
||||
open={diffOpen}
|
||||
onOpenChange={setDiffOpen}
|
||||
open={isRawExplanationOpen}
|
||||
onOpenChange={setIsRawExplanationOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<span className='cursor-pointer text-center text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400 tablet:text-start'>
|
||||
@@ -215,26 +212,20 @@ export default function PreviewAction({ file }: Props) {
|
||||
<div className='flex h-fit flex-col gap-3'>
|
||||
<h4>Why?</h4>
|
||||
<p>
|
||||
Some services need the file extension to be present in the
|
||||
URL to properly embed the file.
|
||||
Some services need the file extension to be present in the URL to properly embed the file.
|
||||
<br />
|
||||
- The download link only have encrypted file id.
|
||||
<br />
|
||||
- The raw link will have the whole path includes the file
|
||||
name and extension.
|
||||
- The raw link will have the whole path includes the file name and extension.
|
||||
<br />
|
||||
<br />
|
||||
So if you want to embed the file, it's recommended to
|
||||
use the raw link instead of the download link.
|
||||
</p>
|
||||
<p className='muted'>
|
||||
Note: This is only applicable for video, audio, and image
|
||||
files.
|
||||
So if you want to embed the file, it's recommended to use the raw link instead of the
|
||||
download link.
|
||||
</p>
|
||||
<p className='muted'>Note: This is only applicable for video, audio, and image files.</p>
|
||||
<h4>Ref</h4>
|
||||
<p>
|
||||
This information is based on the onedrive-vercel-index
|
||||
project documentation.
|
||||
This information is based on the onedrive-vercel-index project documentation.
|
||||
<br />
|
||||
<Link
|
||||
href='https://ovi.swo.moe/docs/features/customise-direct-link'
|
||||
@@ -250,8 +241,8 @@ export default function PreviewAction({ file }: Props) {
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer
|
||||
open={diffOpen}
|
||||
onOpenChange={setDiffOpen}
|
||||
open={isRawExplanationOpen}
|
||||
onOpenChange={setIsRawExplanationOpen}
|
||||
shouldScaleBackground
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
@@ -264,22 +255,19 @@ export default function PreviewAction({ file }: Props) {
|
||||
<div className='flex h-fit flex-col gap-3 px-4'>
|
||||
<h4>Why?</h4>
|
||||
<p>
|
||||
Some services need the file extension to be present in the
|
||||
URL to properly embed the file.
|
||||
Some services need the file extension to be present in the URL to properly embed the file.
|
||||
<br />
|
||||
- The download link only have encrypted file id.
|
||||
<br />
|
||||
- The raw link will have the whole path includes the file
|
||||
name and extension.
|
||||
- The raw link will have the whole path includes the file name and extension.
|
||||
<br />
|
||||
<br />
|
||||
So if you want to embed the file, it's recommended to
|
||||
use the raw link instead of the download link.
|
||||
So if you want to embed the file, it's recommended to use the raw link instead of the
|
||||
download link.
|
||||
</p>
|
||||
<h4>Ref</h4>
|
||||
<p>
|
||||
This information is based on the onedrive-vercel-index
|
||||
project documentation.
|
||||
This information is based on the onedrive-vercel-index project documentation.
|
||||
<br />
|
||||
<Link
|
||||
href='https://ovi.swo.moe/docs/features/customise-direct-link'
|
||||
@@ -305,78 +293,66 @@ export default function PreviewAction({ file }: Props) {
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col-reverse gap-3 tablet:flex-row tablet:items-center'>
|
||||
{showRaw ? (
|
||||
<Button
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='gap-3'
|
||||
icon='Copy'
|
||||
onClick={onCopyRaw}
|
||||
state={copyRawState}
|
||||
>
|
||||
<Icon
|
||||
name='Copy'
|
||||
size={16}
|
||||
/>
|
||||
Raw Link
|
||||
</Button>
|
||||
</ButtonLoading>
|
||||
) : null}
|
||||
{showViewDoc ? (
|
||||
<Button
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='gap-3'
|
||||
icon='ExternalLink'
|
||||
onClick={onOpenViewer}
|
||||
state={viewerState}
|
||||
>
|
||||
<Icon
|
||||
name='ExternalLink'
|
||||
size={16}
|
||||
/>
|
||||
Open in Viewer
|
||||
</Button>
|
||||
</ButtonLoading>
|
||||
) : null}
|
||||
<Button
|
||||
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
className='gap-3'
|
||||
onClick={onCopy}
|
||||
state={copyDownloadState}
|
||||
icon={"Copy"}
|
||||
>
|
||||
<Icon
|
||||
name='Copy'
|
||||
size={16}
|
||||
/>
|
||||
Download Link
|
||||
</Button>
|
||||
<Button
|
||||
</ButtonLoading>
|
||||
|
||||
<ButtonLoading
|
||||
size={"sm"}
|
||||
className='gap-3'
|
||||
onClick={onDownload}
|
||||
disabled={downloading}
|
||||
state={downloadState}
|
||||
icon='Download'
|
||||
>
|
||||
<Icon
|
||||
name={downloading ? "LoaderCircle" : "Download"}
|
||||
size={16}
|
||||
className={cn(downloading && "animate-spin")}
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</ButtonLoading>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card slot='file-info'>
|
||||
<CardHeader>
|
||||
<CardTitle>File Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
{fileInfo.map((info) => (
|
||||
<div
|
||||
key={info.label}
|
||||
className='group flex flex-col'
|
||||
>
|
||||
<span className='font-semibold'>{info.label}:</span>
|
||||
<span className='col-span-3 whitespace-pre-wrap break-all'>
|
||||
{info.value}
|
||||
</span>
|
||||
<span className='text-sm font-semibold'>{info.label}:</span>
|
||||
<span className='col-span-3 whitespace-pre-wrap break-all'>{info.value}</span>
|
||||
<Separator className='my-1.5 group-last-of-type:hidden' />
|
||||
</div>
|
||||
))}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Status } from "~/components/Global";
|
||||
import {
|
||||
PreviewAudio,
|
||||
PreviewDocument,
|
||||
PreviewImage,
|
||||
PreviewInformation,
|
||||
PreviewManga,
|
||||
PreviewRich,
|
||||
PreviewUnknown,
|
||||
PreviewVideo,
|
||||
} from "~/components/Preview";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getFileType } from "~/utils/previewHelper";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
data: z.infer<typeof Schema_File>;
|
||||
fileType: "unknown" | ReturnType<typeof getFileType>;
|
||||
};
|
||||
export default function PreviewLayout({ data, fileType }: Props) {
|
||||
const [view, setView] = useState<"markdown" | "raw">("markdown");
|
||||
return (
|
||||
<div
|
||||
slot='preview-container'
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className='pb-0'>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 overflow-hidden",
|
||||
"mobile:flex-row mobile:items-center mobile:justify-between",
|
||||
)}
|
||||
>
|
||||
<h3 className='line-clamp-1 flex-grow whitespace-pre-wrap break-all'>{data.name}</h3>
|
||||
{["text", "markdown", "code"].includes(fileType) && (
|
||||
<div className='flex w-full items-center mobile:w-fit'>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "markdown" ? "default" : "outline"}
|
||||
onClick={() => setView("markdown")}
|
||||
className='w-full rounded-r-none mobile:w-fit'
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={view === "raw" ? "default" : "outline"}
|
||||
onClick={() => setView("raw")}
|
||||
className='w-full rounded-l-none mobile:w-fit'
|
||||
>
|
||||
Raw
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
|
||||
{config.apiConfig.streamMaxSize && Number(data.size || 0) > config.apiConfig.streamMaxSize ? (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='File is too large to preview'
|
||||
/>
|
||||
) : (
|
||||
<div className='px-3'>
|
||||
{fileType === "image" ? (
|
||||
<PreviewImage file={data} />
|
||||
) : fileType === "audio" ? (
|
||||
<PreviewAudio file={data} />
|
||||
) : fileType === "video" ? (
|
||||
<PreviewVideo file={data} />
|
||||
) : fileType === "code" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
code
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "text" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "markdown" ? (
|
||||
<PreviewRich
|
||||
file={data}
|
||||
view={view}
|
||||
/>
|
||||
) : fileType === "document" ? (
|
||||
<PreviewDocument file={data} />
|
||||
) : fileType === "pdf" ? (
|
||||
<PreviewDocument file={data} />
|
||||
) : fileType === "manga" ? (
|
||||
<PreviewManga file={data} />
|
||||
) : (
|
||||
<PreviewUnknown />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<PreviewInformation file={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,8 @@
|
||||
import { AsyncUnzipInflate, Unzip } from "fflate";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Schema_File } from "~/schema";
|
||||
import { cn } from "~/utils";
|
||||
|
||||
import Icon from "~/components/Icon";
|
||||
import { Icon, Loader, Status } from "~/components/Global";
|
||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
@@ -19,11 +17,14 @@ import {
|
||||
} from "~/components/ui/carousel";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
import config from "~/config/gIndex.config";
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "./actions";
|
||||
import { CreateDownloadToken } from "actions";
|
||||
import config from "config";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
@@ -38,7 +39,7 @@ export default function PreviewManga({ file }: Props) {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
const [loadedPercent, setLoadedPercent] = useState<number>(0);
|
||||
const [loadFirstX, setLoadFirstX] = useState<number>(5); // 5MB
|
||||
const [loadFirstX] = useState<number>(5); // 5MB
|
||||
|
||||
const abortController = useRef<AbortController>(new AbortController());
|
||||
|
||||
@@ -55,18 +56,13 @@ export default function PreviewManga({ file }: Props) {
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
const streamURL = new URL(
|
||||
`/api/stream/${file.encryptedId}`,
|
||||
config.basePath,
|
||||
);
|
||||
const streamURL = new URL(`/api/stream/${file.encryptedId}`, config.basePath);
|
||||
streamURL.searchParams.set("token", token);
|
||||
|
||||
const bufferStream = await fetch(streamURL, {
|
||||
signal: abortController.current.signal,
|
||||
headers: {
|
||||
Range: `bytes=0-${
|
||||
Math.min(Number(file.size || 1), loadFirstX * 1024 * 1024) - 1
|
||||
}`,
|
||||
Range: `bytes=0-${Math.min(Number(file.size || 1), loadFirstX * 1024 * 1024) - 1}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,12 +101,7 @@ export default function PreviewManga({ file }: Props) {
|
||||
file.ondata = (err, data, final) => {
|
||||
if (err) throw err;
|
||||
// Check if the file is an image
|
||||
if (
|
||||
!file.name
|
||||
.toLowerCase()
|
||||
.includes(".jpg" || ".jpeg" || ".png" || ".gif" || ".webp")
|
||||
)
|
||||
return;
|
||||
if (!file.name.toLowerCase().includes(".jpg" || ".jpeg" || ".png" || ".gif" || ".webp")) return;
|
||||
if (!final) return; // Skip if not fully loaded
|
||||
|
||||
const blob = new Blob([data]);
|
||||
@@ -119,10 +110,7 @@ export default function PreviewManga({ file }: Props) {
|
||||
setImages((prev) => {
|
||||
const exist = prev.find((p) => p.name === file.name);
|
||||
if (exist) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{ name: file.name, blob: reader.result as string },
|
||||
];
|
||||
return [...prev, { name: file.name, blob: reader.result as string }];
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
@@ -140,10 +128,6 @@ export default function PreviewManga({ file }: Props) {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
// return () => {
|
||||
// abortController.current.abort("Cancelled by user");
|
||||
// };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -159,60 +143,62 @@ export default function PreviewManga({ file }: Props) {
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-auto min-h-[50dvh] w-full",
|
||||
"flex flex-grow flex-col items-center justify-center gap-3",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name='LoaderCircle'
|
||||
size={32}
|
||||
className='animate-spin text-foreground'
|
||||
/>
|
||||
<p>Please wait while we downloading the content for preview</p>
|
||||
<div className='flex w-full max-w-sm flex-col gap-1'>
|
||||
<Progress
|
||||
className='h-2 w-full'
|
||||
value={loadedPercent}
|
||||
/>
|
||||
<span className='text-center text-xs text-muted-foreground'>
|
||||
{Math.round(loadedPercent)}% loaded
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
onClick={async () => {
|
||||
abortController.current.abort("Cancelled by user");
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setLoading(false);
|
||||
setError("Preview cancelled by user");
|
||||
clearTimeout(timeout);
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
Cancel Preview
|
||||
</Button>
|
||||
</div>
|
||||
<Loader
|
||||
message='Please wait while we downloading the content for preview'
|
||||
extra={
|
||||
<div className='flex w-full max-w-sm flex-col items-center gap-1'>
|
||||
<Progress
|
||||
className='h-2 w-full'
|
||||
value={loadedPercent}
|
||||
/>
|
||||
<span className='text-center text-xs text-muted-foreground'>{Math.round(loadedPercent)}% loaded</span>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
onClick={async () => {
|
||||
abortController.current.abort("Cancelled by user");
|
||||
const timeout = setTimeout(() => {
|
||||
setLoading(false);
|
||||
setError("Preview cancelled by user");
|
||||
clearTimeout(timeout);
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
Cancel Preview
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3'>
|
||||
<Icon
|
||||
name='CircleX'
|
||||
size={24}
|
||||
className='text-destructive'
|
||||
/>
|
||||
<span className='text-center text-destructive'>{error}</span>
|
||||
</div>
|
||||
<Status
|
||||
icon='TriangleAlert'
|
||||
message={error}
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className='flex w-full flex-col items-center justify-center gap-3'>
|
||||
<div className='h-full w-full tablet:px-8'>
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
We only load the first {loadFirstX}MB of the file for preview. Please download the file for full
|
||||
content.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div
|
||||
slot='manga-content'
|
||||
className='h-full w-full tablet:px-8'
|
||||
>
|
||||
<Carousel
|
||||
className={cn(
|
||||
"w-full",
|
||||
viewSize === "fit" ? "h-full max-h-[70dvh]" : "h-full",
|
||||
)}
|
||||
className={cn("w-full", viewSize === "fit" ? "h-full max-h-[70dvh]" : "h-full")}
|
||||
opts={{
|
||||
loop: false,
|
||||
}}
|
||||
@@ -229,16 +215,9 @@ export default function PreviewManga({ file }: Props) {
|
||||
<img
|
||||
src={image.blob}
|
||||
alt={`${image.name} - Page ${index + 1}`}
|
||||
className={cn(
|
||||
"w-full object-contain",
|
||||
viewSize === "fit"
|
||||
? "h-full max-h-[70dvh]"
|
||||
: "h-full",
|
||||
)}
|
||||
className={cn("w-full object-contain", viewSize === "fit" ? "h-full max-h-[70dvh]" : "h-full")}
|
||||
/>
|
||||
<span className='muted text-center text-xs'>
|
||||
{image.name}
|
||||
</span>
|
||||
<span className='muted text-center text-xs'>{image.name}</span>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
@@ -250,8 +229,11 @@ export default function PreviewManga({ file }: Props) {
|
||||
) : null}
|
||||
</Carousel>
|
||||
</div>
|
||||
<div className='flex w-full items-center justify-end gap-3'>
|
||||
<span className='text-primary-foreground'>
|
||||
<div
|
||||
slot='manga-info'
|
||||
className='flex w-full items-center justify-end gap-3'
|
||||
>
|
||||
<span className='text-muted-foreground'>
|
||||
{currentImage}/{images.length}
|
||||
</span>
|
||||
<Button
|
||||
@@ -270,32 +252,7 @@ export default function PreviewManga({ file }: Props) {
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
{/* <div
|
||||
className='relative z-10 cursor-pointer p-0.5 text-foreground/80 transition hover:text-foreground'
|
||||
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
|
||||
>
|
||||
<Icon
|
||||
name={viewSize === "fit" ? "Maximize" : "Minimize"}
|
||||
size={14}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<Icon
|
||||
name='TriangleAlert'
|
||||
className='size-5'
|
||||
/>
|
||||
<div className='flex flex-col'>
|
||||
<AlertTitle>Preview Only</AlertTitle>
|
||||
<AlertDescription>
|
||||
We only load the first {loadFirstX}MB of the file for preview.
|
||||
Please download the file for full content.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Icon, Loader, Markdown, Status } from "~/components/Global";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { GetContent } from "actions";
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
view: "markdown" | "raw";
|
||||
code?: boolean;
|
||||
};
|
||||
export default function PreviewRich({ file, code, view }: Props) {
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [expand, setExpand] = useState<boolean>(false);
|
||||
|
||||
const loading = useLoading(async () => {
|
||||
try {
|
||||
const text = await GetContent(file.encryptedId);
|
||||
if (!text) {
|
||||
setError("Looks like there is no content to preview");
|
||||
return;
|
||||
}
|
||||
setContent(text.trim());
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-fit min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<Loader message='Loading content...' />
|
||||
) : error ? (
|
||||
<Status
|
||||
icon='TriangleAlert'
|
||||
message={error}
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className={cn("relative w-full overflow-hidden", expand ? "h-full" : "max-h-[50dvh]")}>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full overflow-hidden",
|
||||
expand
|
||||
? "[mask-image:linear-gradient(180deg,white_65%,white] h-full"
|
||||
: "h-[50dvh] max-h-[50dvh] [mask-image:linear-gradient(180deg,white_65%,rgba(255,255,255,0))]",
|
||||
)}
|
||||
>
|
||||
<Markdown
|
||||
content={code && view === "markdown" ? `\`\`\`${file.fileExtension}\n${content}` : content}
|
||||
view={view}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full items-center justify-center py-3 transition",
|
||||
expand ? "relative" : "absolute",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size={"sm"}
|
||||
variant={"secondary"}
|
||||
className='gap-1.5'
|
||||
onClick={() => {
|
||||
setExpand((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={expand ? "ChevronUp" : "ChevronDown"}
|
||||
size={16}
|
||||
/>
|
||||
{expand ? "Collapse" : "Expand"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Loader, Status } from "~/components/Global";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
export default function PreviewUnknown() {
|
||||
const loading = useLoading();
|
||||
|
||||
if (loading) return <Loader message='Loading content...' />;
|
||||
|
||||
return (
|
||||
<Status
|
||||
icon='Frown'
|
||||
message='This file type is not supported for preview, try downloading the file instead.'
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Loader, Status } from "~/components/Global";
|
||||
|
||||
import useLoading from "~/hooks/useLoading";
|
||||
|
||||
import { Schema_File } from "~/types/schema";
|
||||
|
||||
import { CreateDownloadToken } from "actions";
|
||||
|
||||
const Plyr = dynamic(() => import("plyr-react"), {
|
||||
ssr: false,
|
||||
loading: () => <Loader message='Loading player...' />,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
file: z.infer<typeof Schema_File>;
|
||||
};
|
||||
export default function PreviewVideo({ file }: Props) {
|
||||
const [videoSrc, setVideoSrc] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const loading = useLoading(async () => {
|
||||
try {
|
||||
if (!file.encryptedWebContentLink) {
|
||||
setError("No video to preview");
|
||||
return;
|
||||
}
|
||||
const token = await CreateDownloadToken();
|
||||
setVideoSrc(`/api/stream/${file.encryptedId}?token=${token}`);
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
console.error(e);
|
||||
setError(e.message);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[33dvh] w-full items-center justify-center py-3'>
|
||||
{loading ? (
|
||||
<Loader message='Loading video...' />
|
||||
) : error ? (
|
||||
<Status
|
||||
icon='TriangleAlert'
|
||||
message={error}
|
||||
destructive
|
||||
/>
|
||||
) : (
|
||||
<div className='h-full w-full'>
|
||||
<Plyr
|
||||
source={{
|
||||
type: "video",
|
||||
sources: [
|
||||
{
|
||||
src: videoSrc,
|
||||
type: file.mimeType,
|
||||
size: file.size,
|
||||
},
|
||||
],
|
||||
poster: `/api/thumb/${file.encryptedId}?size=1000`,
|
||||
}}
|
||||
options={{
|
||||
toggleInvert: true,
|
||||
settings: ["quality", "speed"],
|
||||
ratio: "16:9",
|
||||
controls: [
|
||||
"play-large",
|
||||
"play",
|
||||
"progress",
|
||||
"current-time",
|
||||
"duration",
|
||||
"mute",
|
||||
"volume",
|
||||
"settings",
|
||||
"pip",
|
||||
"download",
|
||||
"fullscreen",
|
||||
],
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
loop: { active: false },
|
||||
speed: { selected: 1, options: [0.5, 1, 1.5, 2] },
|
||||
keyboard: {
|
||||
focused: true,
|
||||
global: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default as PreviewInformation } from "./Information";
|
||||
export { default as PreviewLayout } from "./Layout";
|
||||
|
||||
export { default as PreviewAudio } from "./Audio";
|
||||
export { default as PreviewDocument } from "./Document";
|
||||
export { default as PreviewImage } from "./Image";
|
||||
export { default as PreviewManga } from "./Manga";
|
||||
export { default as PreviewRich } from "./Rich";
|
||||
export { default as PreviewUnknown } from "./Unknown";
|
||||
export { default as PreviewVideo } from "./Video";
|
||||
+30
-33
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
@@ -9,15 +9,14 @@ const alertVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -25,35 +24,33 @@ const Alert = React.forwardRef<
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
role='alert'
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
|
||||
@@ -1,51 +1,55 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
>(({ ...props }, ref) => (
|
||||
<nav
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
aria-label='breadcrumb'
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbList.displayName = "BreadcrumbList"
|
||||
));
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbList.displayName = "BreadcrumbList";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -53,63 +57,55 @@ const BreadcrumbLink = React.forwardRef<
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role='link'
|
||||
aria-disabled='true'
|
||||
aria-current='page'
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
role='presentation'
|
||||
aria-hidden='true'
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||
);
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
role='presentation'
|
||||
aria-hidden='true'
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>More</span>
|
||||
</span>
|
||||
)
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
@@ -10,12 +10,9 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
@@ -30,27 +27,27 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
+57
-67
@@ -1,79 +1,69 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
|
||||
+148
-180
@@ -1,124 +1,109 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
|
||||
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
@@ -126,8 +111,7 @@ const Carousel = React.forwardRef<
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
@@ -138,125 +122,109 @@ const Carousel = React.forwardRef<
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
role='region'
|
||||
aria-roledescription='carousel'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Carousel.displayName = "Carousel"
|
||||
);
|
||||
},
|
||||
);
|
||||
Carousel.displayName = "Carousel";
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className='overflow-hidden'
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
CarouselContent.displayName = "CarouselContent";
|
||||
|
||||
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
role='group'
|
||||
aria-roledescription='slide'
|
||||
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
);
|
||||
},
|
||||
);
|
||||
CarouselItem.displayName = "CarouselItem";
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className='h-4 w-4' />
|
||||
<span className='sr-only'>Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
CarouselPrevious.displayName = "CarouselPrevious";
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className='h-4 w-4' />
|
||||
<span className='sr-only'>Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
CarouselNext.displayName = "CarouselNext";
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
export { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi };
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
@@ -22,64 +22,52 @@ const DialogOverlay = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { portal?: HTMLElement | null }
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogPortal container={props.portal}>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
<DialogPrimitive.Close className='absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground'>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
@@ -87,14 +75,11 @@ const DialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
@@ -105,18 +90,18 @@ const DialogDescription = React.forwardRef<
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
@@ -31,51 +28,45 @@ const DrawerOverlay = React.forwardRef<
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & { portal?: HTMLElement | null }
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerPortal container={props.portal}>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
<div className='mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted' />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
@@ -83,14 +74,11 @@ const DrawerTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
@@ -101,18 +89,18 @@ const DrawerDescription = React.forwardRef<
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
};
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -29,16 +29,15 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className='ml-auto h-4 w-4' />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
@@ -48,36 +47,35 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & { portal?: HTMLElement | null }
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Portal container={props.portal}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -85,12 +83,12 @@ const DropdownMenuItem = React.forwardRef<
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
@@ -100,21 +98,20 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
<Check className='h-4 w-4' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
@@ -124,37 +121,33 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
<Circle className='h-2 w-2 fill-current' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
@@ -165,36 +158,33 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
+18
-21
@@ -1,25 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
+10
-13
@@ -1,26 +1,23 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/utils"
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const MenubarMenu = MenubarPrimitive.Menu
|
||||
const MenubarMenu = MenubarPrimitive.Menu;
|
||||
|
||||
const MenubarGroup = MenubarPrimitive.Group
|
||||
const MenubarGroup = MenubarPrimitive.Group;
|
||||
|
||||
const MenubarPortal = MenubarPrimitive.Portal
|
||||
const MenubarPortal = MenubarPrimitive.Portal;
|
||||
|
||||
const MenubarSub = MenubarPrimitive.Sub
|
||||
const MenubarSub = MenubarPrimitive.Sub;
|
||||
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
@@ -22,14 +22,11 @@ const Menubar = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
|
||||
className
|
||||
)}
|
||||
className={cn("flex h-10 items-center space-x-1 rounded-md border bg-background p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||
));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
@@ -39,17 +36,17 @@ const MenubarTrigger = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||
));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
@@ -57,15 +54,15 @@ const MenubarSubTrigger = React.forwardRef<
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className='ml-auto h-4 w-4' />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
))
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
@@ -75,42 +72,37 @@ const MenubarSubContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||
));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
)
|
||||
)
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
));
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
@@ -118,12 +110,12 @@ const MenubarItem = React.forwardRef<
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
@@ -133,20 +125,20 @@ const MenubarCheckboxItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
<Check className='h-4 w-4' />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
))
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||
));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
@@ -156,37 +148,33 @@ const MenubarRadioItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
<Circle className='h-2 w-2 fill-current' />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
))
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||
));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||
));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
@@ -197,40 +185,34 @@ const MenubarSeparator = React.forwardRef<
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
MenubarShortcut.displayname = "MenubarShortcut"
|
||||
);
|
||||
};
|
||||
MenubarShortcut.displayname = "MenubarShortcut";
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarItem,
|
||||
MenubarLabel,
|
||||
MenubarMenu,
|
||||
MenubarPortal,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
}
|
||||
MenubarTrigger,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user