Merge pull request #15 from mbahArip/v2

Version 2.0.2
This commit is contained in:
Arief Rachmawan
2024-05-04 20:21:03 +07:00
committed by GitHub
55 changed files with 4033 additions and 877 deletions
+4 -4
View File
@@ -1,9 +1,9 @@
GD_SERVICE_B64="Base64 Encoded value from Google Drive Service Account JSON file"
NEXT_PUBLIC_ENCRYPTION_KEY=
NEXT_PUBLIC_SITE_PASSWORD=
ENCRYPTION_KEY=
SITE_PASSWORD=
# Only fill with the domain name, without the protocol and trailing slash
# This will be used as a fallback if VERCEL_URL is not available
# Example: https://drive-demo.mbaharip.com -> drive.mbaharip.com
# This will be used, and will fallback to VERCEL_URL if it's not available
# Example: https://drive-demo.mbaharip.com -> drive-demo.mbaharip.com
NEXT_PUBLIC_DOMAIN=
+4 -1
View File
@@ -45,7 +45,7 @@
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.0.1",
"embla-carousel-react": "^8.0.2",
"googleapis": "^118.0.0",
"input-otp": "^1.2.3",
"jsonwebtoken": "^9.0.0",
@@ -55,6 +55,7 @@
"next-themes": "^0.3.0",
"nextjs-toploader": "^1.6.11",
"react": "^18",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.0",
"react-dom": "^18",
"react-h5-audio-player": "^3.9.1",
@@ -69,6 +70,7 @@
"rehype-katex": "^6.0.3",
"rehype-prism-plus": "^1.6.3",
"rehype-raw": "6.1.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"remark-slug": "^7.0.1",
@@ -76,6 +78,7 @@
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"use-debouncy": "^5.0.1",
"vaul": "^0.9.0",
"zod": "^3.22.4"
},
+18 -6
View File
@@ -260,22 +260,34 @@ export default function FileGrid({ data }: Props) {
{/* 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'>
{data.fileExtension
{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='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
<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}
</span>
{!data.mimeType.includes("folder") && (
<>
<Icon
name='Slash'
size={"0.875rem"}
/>
{config.siteConfig.showFileExtension ? null : (
<Icon
name='Slash'
size={"0.875rem"}
/>
)}
<span className='whitespace-nowrap text-sm'>
{bytesToReadable(data.size || 0)}
</span>
+18 -6
View File
@@ -248,22 +248,34 @@ export default function FileList({ data }: Props) {
{/* File data */}
<div className='flex w-full flex-col'>
<span className='line-clamp-1 whitespace-pre-wrap break-all'>
{data.fileExtension
{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='line-clamp-1 whitespace-pre-wrap break-all text-sm'>
<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}
</span>
{!data.mimeType.includes("folder") && (
<>
<Icon
name='Slash'
size={"0.875rem"}
/>
{config.siteConfig.showFileExtension ? null : (
<Icon
name='Slash'
size={"0.875rem"}
/>
)}
<span className='whitespace-nowrap text-sm'>
{bytesToReadable(data.size || 0)}
</span>
+3 -1
View File
@@ -1,6 +1,7 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkBreaks from "remark-breaks";
type Props = {
content: string;
@@ -9,7 +10,7 @@ export default function Footer({ content }: Props) {
return (
<footer className='w-full pb-3'>
<ReactMarkdown
className='flex w-full select-none flex-col items-center justify-center'
className='flex w-full select-none flex-col items-center justify-center text-center'
components={{
p: ({ node, children, ...props }) => (
<p
@@ -34,6 +35,7 @@ export default function Footer({ content }: Props) {
);
},
}}
remarkPlugins={[remarkBreaks]}
>
{content}
</ReactMarkdown>
+59 -32
View File
@@ -6,10 +6,12 @@ import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypePrism from "rehype-prism-plus";
import rehypeRaw from "rehype-raw";
import remarkBreaks from "remark-breaks";
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";
@@ -17,41 +19,66 @@ import "./highlight.css";
type Props = {
content: string;
view: "markdown" | "raw";
className?: string;
};
export default function Markdown({ content }: Props) {
export default function Markdown({ content, view, className }: Props) {
return (
<div className='markdown w-full rounded-[var(--radius)] p-3'>
<ReactMarkdown
className='w-full'
disallowedElements={["script"]}
remarkPlugins={[remarkGfm, remarkMath, remarkSlug, remarkToc]}
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>
),
}}
<div className={cn("flex flex-col p-3", className)}>
<div
className={cn(
"markdown w-full rounded-[var(--radius)]",
view !== "raw" && "hidden",
)}
>
{content}
</ReactMarkdown>
<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>
);
}
+54 -1
View File
@@ -149,9 +149,34 @@ export default function Navbar() {
<Separator
orientation='vertical'
className='mx-3'
className='mx-3 my-auto h-6'
/>
{config.showDeployGuide && (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={"/deploy"}
className={cn(
"flex flex-col items-center justify-center",
"opacity-80",
"hover:opacity-100",
"cursor-pointer",
"p-1.5",
)}
>
<Icon
name={"Book"}
size={20}
/>
</Link>
</TooltipTrigger>
<TooltipContent side='bottom'>
<p>Deploy Guide</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu
modal={false}
open={themeOpen}
@@ -268,6 +293,11 @@ export default function Navbar() {
</DropdownMenu>
)}
<Separator
orientation='vertical'
className='mx-3 my-auto h-6'
/>
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
@@ -457,6 +487,29 @@ export default function Navbar() {
</Link>
</Button>
))}
{config.showDeployGuide && (
<Button
variant={"ghost"}
size={"sm"}
asChild
>
<Link
href={"/deploy"}
className='flex w-full items-center justify-between gap-3'
onClick={() => setOpen(false)}
>
<div className='flex items-center gap-3'>
<Icon
name={"Book"}
className='text-foreground'
size={18}
/>
Deploy Guide
</div>
</Link>
</Button>
)}
</div>
<div className='flex flex-col'>
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { Card, CardContent } from "~/components/ui/card";
import { getFileType } from "~/utils/previewHelper";
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 (
<>
<Card>
<RichHeader
title={data.name}
view={view}
onViewChange={setView}
/>
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
<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} />
</>
);
}
+25 -12
View File
@@ -13,9 +13,11 @@ import { GetContent } from "./actions";
type Props = {
file: z.infer<typeof Schema_File>;
view: "markdown" | "raw";
code?: boolean;
};
export default function PreviewRich({ file, code }: Props) {
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>("");
@@ -30,11 +32,12 @@ export default function PreviewRich({ file, code }: Props) {
setError("Looks like there is no content to preview");
return;
}
if (code) {
setContent(`\`\`\`${file.fileExtension}\n${text}\`\`\``);
} else {
setContent(text);
}
// setFetchedContent(text);
// if (code) {
// setContent(`\`\`\`${file.fileExtension}\n${text}\`\`\``);
// } else {
setContent(text);
// }
} catch (error) {
const e = error as Error;
console.error(e);
@@ -46,7 +49,7 @@ export default function PreviewRich({ file, code }: Props) {
}, [file, code]);
return (
<div className='flex min-h-[33dvh] w-full items-center justify-center py-3'>
<div className='flex h-fit min-h-[33dvh] w-full items-center justify-center py-3'>
{loading ? (
<div
className={cn(
@@ -72,17 +75,27 @@ export default function PreviewRich({ file, code }: Props) {
</div>
) : (
<div
className={cn("relative w-full", expand ? "h-full" : "max-h-[50dvh]")}
className={cn(
"relative w-full overflow-hidden",
expand ? "h-full" : "max-h-[50dvh]",
)}
>
<div
className={cn(
"h-full w-full",
"w-full overflow-hidden",
expand
? " [mask-image:linear-gradient(180deg,white_65%,white]"
: "max-h-[50dvh] [mask-image:linear-gradient(180deg,white_65%,rgba(255,255,255,0))]",
? "[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={content} />
<Markdown
content={
code && view === "markdown"
? `\`\`\`${file.fileExtension}\n${content}\`\`\``
: content
}
view={view}
/>
</div>
<div
className={cn(
+37
View File
@@ -0,0 +1,37 @@
"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}
/>
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
<Markdown
content={content}
view={view}
/>
</CardContent>
</Card>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Button } from "~/components/ui/button";
import { CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
type Props = {
title: string;
view: "markdown" | "raw";
onViewChange: (value: "markdown" | "raw") => void;
};
export default function RichHeader({ title, view, onViewChange }: Props) {
return (
<CardHeader className='pb-0'>
<div className='flex flex-col gap-3 mobile:flex-row mobile:items-center mobile:justify-between'>
<CardTitle>{title}</CardTitle>
<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>
);
}
@@ -0,0 +1,403 @@
"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";
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",
"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,
);
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>
)}
<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>
</div>
</form>
);
}
@@ -0,0 +1,359 @@
"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";
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>
);
}
+120
View File
@@ -0,0 +1,120 @@
"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>
);
}
@@ -0,0 +1,418 @@
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";
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>
);
}
+777
View File
@@ -0,0 +1,777 @@
"use client";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { z } from "zod";
import {
ConfigState,
ConfigurationCategory,
ConfigurationKeys,
ConfigurationValue,
Schema_App_Configuration,
} from "~/schema";
import { cn } from "~/utils";
import Markdown from "~/app/@markdown";
import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import { encryptData } from "~/utils/encryptionHelper/hash";
import config from "~/config/gIndex.config";
import ApiConfig from "./@form.api-config";
import EnvironmentConfig from "./@form.env-config";
import SiteConfig from "./@form.site-config";
export const getting_started = `Welcome to the deployment guide! This guide will help you to deploy the application to Vercel or similar services.
If you are new to this project, you can follow along from the beginning.
But if you've already deployed the app before and want to upgrade from v1, you can skip to the [Migrating from v1](#migrating) section.
You can also use this guide to [configure the app](#config) and [customize the theme](#theme).
**We recommend you to use this deployment guide on a desktop browser for optimal experience.**
_**Note:** This guide assumes you have a basic understanding of how to deploy a Next.js app on Vercel or other platforms._`;
export const new_user_guide = `Prerequisites:
- A basic understanding of Vercel (or similar services)
- Google Cloud Platform account
### Fork or Clone the repository
It's pretty obvious, but you need to fork the repository to your account.
You can [click here](https://github.com/mbahArip/next-gdrive-index/fork) to fork the repository.
You can choose any repository name, description, and visibility.
But if you want to run it locally, you can clone the repository instead.
### Create a Google Cloud Platform project and enable Google Drive API
We need an access to Google Drive API to get the files from Google Drive.
So you need to create a project in Google Cloud Platform and enable Google Drive API to get your own credentials.
1. Go to [Google Cloud Platform](https://console.cloud.google.com/)
2. Click the \`New Project\` button
3. Enter a project name, and click the \`Create\` button
4. After the project is created, click the \`Enable APIs and Services\` button
5. Search for Google Drive API, and click the \`Enable\` button
### Create a Service Account and get the credentials
After enabling the Google Drive API, we need to create a service account to get the credentials.
The credentials will be used to authenticate the application to access the Google Drive API and get the files.
1. On [APIs & Services](https://console.cloud.google.com/apis/dashboard) page, click the \`Credentials\` menu on the sidebar
2. Click the \`Create Credentials\` button, and choose \`Service account\`
3. Enter your service account name and description, and then click the \`Done\` button
4. You will see the service account you just created on \`Service Account\` table, click the name of the service account to open the details
5. Go to \`Keys\` tab, then click the \`Add Key\` button and choose the \`Create new key\`
6. Pick \`JSON\` as the key type and click the \`Create\` button
7. The JSON file will be downloaded to your computer, and **keep it safe**. We will use it later on the configuration
_**Note:** The JSON file contains sensitive information, don't share it with anyone_
### Create shared folder in Google Drive
Since the service account can't access your Root folder, you need to create a new folder, and share it with the service account.
This folder will be used as the root folder for the application.
> If you're using or the folder you want to share inside Shared Drive, you can skip this step and go to the [Shared Drive Guide](#shared-drive)
1. Go to [Google Drive](https://drive.google.com/)
2. Click the \`New\` button, and choose \`Folder\` to create a new folder, you can name it anything you want
3. Right-click the folder you just created, and choose \`Share\`
4. Enter the email address of the service account you just created (you can find it on the JSON file, or on the service account details page)
5. To allow download files larger than deployment limit, you need to enable \`Link sharing\` and set it to \`Anyone with the link\`
6. Copy the folder ID from the URL, it's the part after \`/folders/\` in the URL (e.g: https://drive.google.com/drive/u/0/folders/ \`<folder_id>\` )
### Configuring the app and Customizing the theme
Now we need to configure the app to use the credentials and folder ID we just created.
You can follow the [App Configuration](#config) and [Customize Theme](#theme) sections to configure the app and customize the theme.
_**Note:** You can skip the theme customization, but you **NEED** to configure the app_
### Deploy the app
On this guide we will use Vercel to deploy the app, but you can use other platforms like Netlify, Heroku, etc.
But don't forget to adjust the \`fileSizeLimit\` on the [configuration](#config) if you use other platforms.
> Before deploying, make sure you have pushed the changes to your repository
1. Go to [Vercel](https://vercel.com/)
2. Click on the \`Add new\` button, and choose \`Project\`
3. Choose the repository you just forked
4. On the \`Environment Variables\` section, copy the whole content from \`.env.local\` you just downloaded from [configuration](#config) section, and paste it on the key fields. It will automatically add all the environment variables needed
5. Click the \`Deploy\` button
6. Wait for the deployment to finish, and open project
7. Go to \`Settings\` tab, and click the \`Functions\` menu, and select your \`Function Region\` to the nearest region to your location for optimal speed
8. Go to \`Deployment\` tab, click the 3 dots on the right side of the latest \`Production\` deployment, and click the \`Redeploy\` button to apply the changes
For other platforms, you can check their own documentation for Next.js deployment guide.
### Done! 🎉
Congratulations! You have successfully deployed the app.`;
export const shared_drive_guide = `I'm separating this guide in case someone who already using v1 can see this guide easily.
As of version 2.0.2 we added support for Shared Drive, and the demo actually using a Shared Drive.
You can follow this guide to use Shared Drive as the root folder for the application.
1. Go to [Google Drive](https://drive.google.com/)
2. Open the \`Shared Drives\` menu from the sidebar
3. Right click on the Shared Drive you want to use, and choose \`Manage members\`
4. Add your service account email address to the members list, and give it at least \`Viewer\` permission
5. Open the \`Shared Drive\`, and copy the ID from the URL, it's the part after \`/drive/u/0/folders/\` in the URL (e.g: https://drive.google.com/drive/u/0/folders/ \`<drive_id>\` )
6. Paste the ID to \`Shared Drive\` field on the [configuration](#config) section, and set the \`is Team Drive\` to \`true\`
7. For the root folder, you can set it to the folder ID inside the Shared Drive, or use the Shared Drive ID as the root folder ID
8. Update the configuration file, and redeploy the app to apply the changes
9. Done! 🎉
If you don't want to use the configuration section below, you can encrypt your \`Shared Drive\` ID using the \`/api/internal/encrypt?q=<drive_id>\` endpoint, and update the config file directly.
`;
export const migration_guide = `If you've already deployed the app before and want to upgrade from v1, you can follow this guide to migrate the app to the latest version.
### Update your environment and configuration
If you still have the \`.env.local file\`, you can go to the [configuration](#config) section, and load the file to update the environment variables.
If you don't have it, go to your deployment platform and copy the environment variables from the platform to the [configuration](#config) section.
You can also load the old \`gindex.config.ts\` file to the [configuration](#config) section to set the default configuration.
### Update the repository
First, you need to update the repository to the latest version.
If you open your forked repository, you will see a notification that the repository is behind the original repository.
You can sync the repository by clicking the \`Sync fork\` button.
After the repository is updated, you can replace the \`gindex.config.ts\` file with the new one.
### Update deployment
Now go to your Vercel project page (or other platforms).
Go to the \`Settings\` tab, and click the \`Environment Variables\` menu.
You can delete all the old environment variables, and copy the new environment variables from the updated \`.env.local\` file.
Now you can redeploy the app to apply the changes.
`;
const initialConfiguration: z.input<typeof Schema_App_Configuration> = {
environment: {
GD_SERVICE_B64: "",
ENCRYPTION_KEY: "",
SITE_PASSWORD: "",
NEXT_PUBLIC_DOMAIN: "",
},
api: {
...config.apiConfig,
rootFolder: "",
isTeamDrive: false,
sharedDrive: "",
proxyThumbnail: true,
allowDownloadProtectedFile: false,
temporaryTokenDuration: 6,
maxFileSize: 4 * 1024 * 1024,
},
site: {
...config.siteConfig,
siteName: "next-gdrive-index",
siteNameTemplate: "%s",
siteDescription: "A simple Google Drive Index 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: [],
},
};
export function Configuration() {
const fileConfigRef = useRef<HTMLInputElement>(null);
const fileEnvRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState<boolean>(true);
const [configuration, setConfiguration] =
useState<z.input<typeof Schema_App_Configuration>>(initialConfiguration);
const [error, setError] = useState<{
environment: Partial<Record<ConfigurationKeys<"environment">, string>>;
api: Partial<Record<ConfigurationKeys<"api">, string>>;
site: Partial<Record<ConfigurationKeys<"site">, string>>;
}>({
environment: {},
api: {},
site: {},
});
const [downloadState, setDownloadState] = useState<ConfigState>("idle");
useEffect(() => {
setLoading(false);
}, []);
// It's been a year I'm learning TS, and this thing still scares me
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 onReset(category: ConfigurationCategory) {
setConfiguration((prev) => ({
...prev,
[category]: initialConfiguration[category],
}));
setError((prev) => ({
...prev,
[category]: {},
}));
}
async function onDownload(
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) {
e.preventDefault();
setDownloadState("loading");
toast.loading("Generating configuration file...", {
id: "download-config",
});
try {
// Check required section first
const requiredEmpty = [
!configuration.environment.ENCRYPTION_KEY.length,
!configuration.environment.GD_SERVICE_B64.length,
!configuration.api.rootFolder.length,
configuration.api.isTeamDrive && !configuration.api.sharedDrive?.length,
!configuration.site.siteName.length,
!configuration.site.siteDescription.length,
configuration.site.privateIndex &&
!configuration.environment.SITE_PASSWORD?.length,
];
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.length) errors.push(err);
}
for (const err of Object.values(error.api)) {
if (err.length) errors.push(err);
}
for (const err of Object.values(error.site)) {
if (err.length) errors.push(err);
}
if (errors.length) {
throw new Error(
"Please fix all the errors before downloading the configuration file.",
);
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
const afterClick = () => {
setTimeout(() => {
URL.revokeObjectURL(url);
removeEventListener("click", afterClick);
}, 100);
};
anchor.addEventListener("click", afterClick, false);
anchor.click();
}
let envContent = Object.entries(configuration.environment)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
envContent +=
"\n\n# Can't name it .env for download, so rename it to .env.local\n# Or you can copy the content to your deployment platform";
const configContent: string = `import { z } from "zod";
import { Schema_Config } from "~/schema";
const config: z.input<typeof Schema_Config> = {
/**
* If possible, please don't change this value
* Even if you're creating a PR, just let me change it myself
*/
version: "${config.version}",
/**
* Base path of the app, used for generating links
*
* If you're using another port for development, you can set it here
*
* @default process.env.NEXT_PUBLIC_DOMAIN
* @fallback process.env.NEXT_PUBLIC_VERCEL_URL
*/
basePath:
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: \`https://\${process.env.NEXT_PUBLIC_DOMAIN || process.env.NEXT_PUBLIC_VERCEL_URL}\`,
/**
* Allow access to the deploy guide
* Will use the \`/deploy\` route, might be overlap with file / folder name
*
* Set this to false on final deployment
*
* I'm using this to show the deploy guide on my own demo deployment
*
* @default false
*/
showDeployGuide: false,
/**
* How long the cache will be stored in the browser
* Used for all pages and api routes
* Default is 5 minutes (300/60 = 5min)
*
* @default "max-age=0, s-maxage=60, stale-while-revalidate"
*/
cacheControl: "max-age=0, s-maxage=60, stale-while-revalidate",
apiConfig: {
/**
* Starting point of the drive.
* Will be used for '/' route.
*
* Since service account can't access 'root' folder
* You need to create a new folder and share it with the service account
* Then, copy the folder id and paste it here
*/
rootFolder:
"${await encryptData(
configuration.api.rootFolder,
configuration.environment.ENCRYPTION_KEY,
)}",
/**
* If your rootfolder inside a shared drive, you NEED to set this to true
* If not, you can set this to false
*
* You also need to set the shared drive ID to make it work
* Make sure you have add your service account to the shared drive since the service account can't access the shared drive by default
*
* Where to get the shared drive id?
* Go to your Shared Drive > Click on the shared drive > copy the ID from the url
* ex: https://drive.google.com/drive/u/0/folders/:shared_drive_id
*
* Then you need to encrypt it using \`/api/internal/encrypt?q=:shared_drive_id\` route
*/
isTeamDrive: ${configuration.api.isTeamDrive ? "true" : "false"},
sharedDrive:
"${
configuration.api.isTeamDrive && configuration.api.sharedDrive
? await encryptData(
configuration.api.sharedDrive,
configuration.environment.ENCRYPTION_KEY,
)
: ""
}",
defaultQuery: [
"trashed = false",
"(not mimeType contains 'google-apps' or mimeType contains 'folder')",
],
defaultField:
"id, name, mimeType, thumbnailLink, fileExtension, modifiedTime, size, imageMediaMetadata, videoMediaMetadata, webContentLink, trashed",
defaultOrder: "folder, name asc, modifiedTime desc",
itemsPerPage: 50,
searchResult: 5,
/**
* By default, the app will use the thumbnail URL from Google Drive
*
* Sometimes, the thumbnail can't be accessed because of CORS policy
* If you're having this issue, you can set this to true
*
* This will make the api fetch the thumbnail and serve it from the server
* instead of using the Google Drive thumbnail
*
* This will increase the server load, so use it wisely
*
* Default: true
*/
proxyThumbnail: ${configuration.api.proxyThumbnail ? "true" : "false"},
/**
* Special file name that will be used for certain purposes
* These files will be ignored when searching for files
* and will be hidden from the files list by default
*/
specialFile: {
password: ".password",
readme: ".readme.md",
/**
* Banner will be used for opengraph image for folder
* By default, all folder will use default og image
*/
banner: ".banner",
},
/**
* Reason why banner has multiple extensions:
* - If I use contains query, it will also match the file or folder that contains the word.
* (e.g: File / folder with the name of "Test Password" will be matched)
* - If I use = query, it will only match the exact name, hence the multiple extensions
*
* You can add more extensions if you want
*/
hiddenFiles: [
".password",
".readme.md",
".banner",
".banner.jpg",
".banner.png",
".banner.webp",
],
/**
* Allow user to download protected file without password.
* If this set to false, download link will have temporary token attached to it
* If this set to true, user can download the file without password as long as they have the link
*
* Default: false
*/
allowDownloadProtectedFile: ${
configuration.api.allowDownloadProtectedFile ? "true" : "false"
},
/**
* Duration in hours.
* In version 2, this will be used for download link expiration.
* If you need it under 1 hour, you can use math expression. (e.g: (5 / 60) * 1 = 5 minutes)
*
* This only affect when the user download the file
* For example if you set it for example 30 minutes (0.5)
* After 30 minutes, and the user still downloading the file, the download will NOT be interrupted
* But if the user refresh the page / trying to download again, the download link will be expired
*
* Default: 6 hours
*/
temporaryTokenDuration: ${configuration.api.temporaryTokenDuration},
/**
* Maximum file size that can be downloaded via api routes
* If it's larger than this, it will be redirected to the file url
*
* If you're using Vercel, they have a limit of ~4 - ~4.5MB response size
* ref: https://vercel.com/docs/platform/limits#serverless-function-payload-size-limit
* If you're using another platform, you can match the limit with your platform
* Or you can set this to 0 to disable the limit
*
* Default: 4MB
*/
maxFileSize: ${configuration.api.maxFileSize},
},
siteConfig: {
/**
* Site Name will be used for default metadata title
* Site Name Template will be used if the page has a title
* %s will be replaced with the page title
*
* You can set it to undefined if you don't want to use it
*/
siteName: "${configuration.site.siteName}",
siteNameTemplate: "${configuration.site.siteNameTemplate}",
siteDescription: "${configuration.site.siteDescription}",
siteIcon: "/logo.svg",
favIcon: "/favicon.png",
siteAuthor: "${configuration.site.siteAuthor}",
twitterHandle: "${configuration.site.twitterHandle}",
/**
* Next.js Metadata robots object
*
* ref: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#robots
*/
robots: "noindex, nofollow",
/**
* Show file extension on the file name
* Example:
* true | false
* file.txt | file
* 100KB | txt / 100KB
*
* Default: false
*/
showFileExtension: ${
configuration.site.showFileExtension ? "true" : "false"
},
/**
* Footer content
* You can also set it to empty array if you don't want to use it
*
* Basic markdown is supported (bold, italic, and link)
* External link will be opened in new tab
*
* Template:
* - {{ year }} will be replaced with the current year
* - {{ repository }} will be replaced with the original repository link
* - {{ author }} will be replaced with author from siteAuthor config above (If it's not set, it will be set to mbaharip)
* - {{ version }} will be replaced with the current version
* - {{ siteName }} will be replaced with the siteName config above
* - {{ handle }} will be replaced with the twitter handle from twitterHandle config above
* - {{ creator }} will be replaced with mbaharip if you want to credit me
*/
footer: [
"{{ siteName }} *v{{ version }}* @ {{ repository }}",
"{{ year }} - Made with ❤️ by **{{ author }}**",
],
/**
* DEPRECATED
* Since we're using shadcn/ui now, please refer to their theming documentation
* https://ui.shadcn.com/docs/theming
*
* Or you can use their themes, and replace the color in /src/app/globals.css
* https://ui.shadcn.com/themes
*
* Tailwind color name.
* Ref: https://tailwindcss.com/docs/customizing-colors
*/
// defaultAccentColor: "teal",
/**
* Site wide password protection
* If this is set, all files and folders will be protected by this password
*
* The site password are set from Environment Variable (NEXT_GDRIVE_INDEX_PASSWORD)
* It's because I don't want to store sensitive data in the code
*/
privateIndex: ${configuration.site.privateIndex ? "true" : "false"},
/**
* Maximum breadcrumb length
* If the breadcrumb is longer than this, it will be shortened
*/
breadcrumbMax: 3,
/**
* Toast notification configuration
*
* position: Self-explanatory
* duration: duration before the toast disappear in milliseconds
*/
toaster: {
position: "bottom-right",
duration: 3000,
},
/**
* This section should have autocomplete for both the object and the icon name
*
* Example item:
* {
* icon: string, // icon name from lucide icons (https://lucide.dev/icons/)
* name: string,
* href: string,
* external?: boolean
* }
*/
navbarItems: [],
/**
* Add support / donation links on the navbar
*
* Example item:
* {
* name: string,
* currency: string,
* href: string,
* }
*/
supports: [],
},
};
export default config;`;
// downloadBlob(new Blob([envContent], { type: "text/plain" }), "env");
// downloadBlob(
// new Blob([configContent], { type: "text/typescript" }),
// "gindex.config.ts",
// );
toast.success("Configuration file downloaded!", {
id: "download-config",
});
} catch (error) {
const e = error as Error;
console.error(e);
toast.error(e.message, {
id: "download-config",
});
} finally {
setDownloadState("idle");
}
}
function onErrorSet<
T extends ConfigurationCategory = ConfigurationCategory,
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
>(category: T, key: K, value: string) {
setError((prev) => ({
...prev,
[category]: {
...prev[category],
[key]: value,
},
}));
}
if (loading)
return (
<Card>
<CardContent className='grid h-[25dvh] w-full place-items-center'>
<Icon
name='LoaderCircle'
className='animate-spin text-primary'
size='2rem'
/>
</CardContent>
</Card>
);
return (
<Card>
<CardHeader className='pb-0'>
<div className='flex w-full items-end justify-between gap-3'>
<CardTitle
className='text-3xl'
id='config'
>
App Configuration
</CardTitle>
<small className='text-muted-foreground'>v{config.version}</small>
</div>
<Separator />
</CardHeader>
<CardContent className='space-y-3 py-3'>
<Markdown
content={`This configuration only covering things you need to get started and basic personalization.
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, 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`}
view='markdown'
/>
<EnvironmentConfig
state={{ get: configuration, set: onConfigurationChange }}
error={{
get: error?.environment,
set: (key, value) => onErrorSet("environment", key, value),
}}
onReset={onReset}
/>
<ApiConfig
state={{ get: configuration, set: onConfigurationChange }}
error={{
get: error?.api,
set: (key, value) => onErrorSet("api", key, value),
}}
onReset={onReset}
/>
<SiteConfig
state={{ get: configuration, set: onConfigurationChange }}
error={{
get: error?.site,
set: (key, value) => onErrorSet("site", key, value),
}}
onReset={onReset}
/>
<div className='flex w-full items-center justify-end gap-3'>
<Button
variant={"destructive"}
size={"sm"}
onClick={(e) => {
e.preventDefault();
setConfiguration(initialConfiguration);
setError({
environment: {},
api: {},
site: {},
});
}}
>
Reset All
</Button>
<Button
size={"sm"}
disabled={downloadState === "loading"}
onClick={onDownload}
>
<div className='relative flex w-full items-center justify-center'>
<span className='relative transition-all duration-300 ease-in-out'>
Download Config
</span>
<Icon
name='LoaderCircle'
className={cn(
"animate-spin transition-all",
downloadState === "loading"
? "ml-1.5 size-4 opacity-100"
: "ml-0 size-0 opacity-0",
)}
/>
</div>
</Button>
</div>
</CardContent>
</Card>
);
}
export function CustomizeTheme() {
const [colors, setColors] = useState({
primary: { h: 200, s: 50, l: 50 },
secondary: { h: 200, s: 50, l: 50 },
});
return (
<Card>
<CardHeader className='pb-0'>
<CardTitle
className='text-3xl'
id='theme'
>
Customize Theme
</CardTitle>
<Separator />
</CardHeader>
<CardContent>
<Markdown
content={`#### Under Construction
While I'm working on the theme customization, you can check [shadcn UI theme](https://ui.shadcn.com/themes) for now`}
view='markdown'
/>
</CardContent>
</Card>
);
}
+299
View File
@@ -0,0 +1,299 @@
"use client";
import { icons } from "lucide-react";
import { useMemo, useState } from "react";
import { z } from "zod";
import { cn } from "~/utils";
import Markdown from "~/app/@markdown";
import Icon from "~/components/Icon";
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 {
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>;
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",
icon: "UserPlus",
},
{
id: "migrating",
title: "Migrating from v1",
icon: "GitBranch",
},
{
id: "shared-drive",
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" });
}}
>
<Icon
name='ChevronUp'
size='1.25rem'
/>
</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>
<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>
<Tabs defaultValue='new-user'>
<TabsList>
{sectionMenu.map((item) => (
<TabsTrigger
key={item.id}
id={item.id}
value={item.id}
className='scroll-m-24'
>
{item.title}
</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'>
<CardTitle className='text-3xl'>New User Guide</CardTitle>
<Separator />
</CardHeader>
<CardContent>
<Markdown
content={new_user_guide}
view='markdown'
className='px-0'
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='shared-drive'>
<Card>
<CardHeader className='pb-0'>
<CardTitle className='text-3xl'>Shared Drive Guide</CardTitle>
<Separator />
</CardHeader>
<CardContent>
<Markdown
content={shared_drive_guide}
view='markdown'
className='px-0'
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='migrating'>
<Card>
<CardHeader className='pb-0'>
<CardTitle className='text-3xl'>Migrating from v1</CardTitle>
<Separator />
</CardHeader>
<CardContent>
<Markdown
content={migration_guide}
view='markdown'
className='px-0'
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Separator />
<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>Heads up!</AlertTitle>
<AlertDescription>
The value of the form will be reset when you switch tabs.
</AlertDescription>
</div>
</div>
</Alert>
<Tabs defaultValue='config'>
<TabsList>
<TabsTrigger
value='config'
id='config'
className='scroll-m-24'
>
Configuration
</TabsTrigger>
<TabsTrigger
value='theme'
id='theme'
className='scroll-m-24'
>
Theme Customization
</TabsTrigger>
</TabsList>
<TabsContent value='config'>
<Configuration />
</TabsContent>
<TabsContent value='theme'>
<CustomizeTheme />
</TabsContent>
</Tabs>
</div>
);
}
+54 -77
View File
@@ -16,16 +16,9 @@ import config from "~/config/gIndex.config";
import FileBrowser from "../@explorer";
import Header from "../@header";
import HeaderButton from "../@header.button";
import Markdown from "../@markdown";
import Password from "../@password";
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 FilePreviewLayout from "../@preview.layout";
import Readme from "../@readme";
import {
CheckPassword,
CheckPaths,
@@ -34,6 +27,7 @@ import {
GetFiles,
GetReadme,
} from "../actions";
import DeployGuidePage from "./deploy";
export const revalidate = 300;
export const dynamic = "force-dynamic";
@@ -48,6 +42,9 @@ export async function generateMetadata(
{ params: { rest } }: Props,
parent: ResolvedMetadata,
): Promise<Metadata> {
if (rest[0] === "deploy" && config.showDeployGuide)
return { title: "Deploy Guide" };
const paths = await CheckPaths(rest);
if (!paths.success) return { title: "Not Found" };
@@ -80,6 +77,9 @@ export async function generateMetadata(
}
export default async function RestPage({ params: { rest } }: Props) {
if (rest[0] === "deploy" && config.showDeployGuide)
return <DeployGuidePage />;
const paths = await CheckPaths(rest);
if (!paths.success) notFound();
const unlocked = await CheckPassword(paths.data);
@@ -87,7 +87,9 @@ 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}`,
`No path returned from password checking${
unlocked.message && `, ${unlocked.message}`
}`,
);
return (
<Password
@@ -146,76 +148,51 @@ export default async function RestPage({ params: { rest } }: Props) {
slot='content'
className='w-full'
>
<Card>
<CardHeader className='pb-0'>
{isFile ? (
<div className='flex w-full gap-3'>
<CardTitle className='line-clamp-2 flex-grow whitespace-pre-wrap break-all'>
{data.name}
</CardTitle>
</div>
) : (
<div className='flex w-full items-center justify-between gap-3'>
<CardTitle className='flex-grow'>Browse files</CardTitle>
<HeaderButton />
</div>
)}
<Separator />
</CardHeader>
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
{isFile ? (
<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
/>
) : fileType === "text" ? (
<PreviewRich file={data} />
) : fileType === "markdown" ? (
<PreviewRich file={data} />
) : fileType === "document" ? (
<PreviewDoc file={data} />
) : fileType === "pdf" ? (
<PreviewDoc file={data} />
) : fileType === "manga" ? (
<PreviewManga file={data} />
) : (
<PreviewUnknown />
)}
</div>
) : (
<FileBrowser
files={data.files}
nextPageToken={data.nextPageToken}
{isFile ? (
<FilePreviewLayout
data={data}
fileType={fileType || "unknown"}
/>
) : (
<>
<Card>
<CardHeader className='pb-0'>
<div className='flex w-full items-center justify-between gap-3'>
<CardTitle className='flex-grow'>Browse files</CardTitle>
<HeaderButton />
</div>
<Separator />
</CardHeader>
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
<FileBrowser
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>
)}
</CardContent>
</Card>
</>
)}
</div>
{readme && (
<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>
)}
{isFile && <PreviewAction file={data} />}
</div>
);
}
+33
View File
@@ -1,5 +1,6 @@
"use server";
import crypto from "crypto";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { z } from "zod";
@@ -763,3 +764,35 @@ export async function CheckDownloadToken(token: string): Promise<{
};
}
}
export async function GenerateAESKey(): Promise<string> {
try {
const key = crypto.randomBytes(8).toString("hex");
return key;
} catch (error) {
const e = error as Error;
console.error(e.message);
throw new Error(e.message);
}
}
export async function VerifyAESKey(
data: string,
key: string,
): Promise<boolean> {
try {
let paddedKey;
if (key.length < 16) {
paddedKey = key.padEnd(16, "0");
} else if (key.length > 16) {
paddedKey = key.slice(0, 16);
} else {
paddedKey = key;
}
const encrypt = await encryptData(data, paddedKey);
const decrypt = await decryptData(encrypt, paddedKey);
return !!encrypt && !!decrypt;
} catch (error) {
return false;
}
}
+7 -15
View File
@@ -33,7 +33,10 @@ export async function GET(
const tokenValidity = await CheckDownloadToken(token);
if (!tokenValidity.success) throw new Error(tokenValidity.message);
if (config.siteConfig.privateIndex) {
if (
config.siteConfig.privateIndex &&
!config.apiConfig.allowDownloadProtectedFile
) {
const unlocked = await CheckSitePassword();
if (!unlocked.success) {
return new NextResponse(
@@ -51,7 +54,7 @@ If you've already entered the password, please make sure your browser is not blo
const _filePaths = RedirectSearchFile(encryptedId);
const _fileMeta = gdrive.files.get({
fileId: decryptedId,
fields: "id, name, mimeType, fileExtension, webContentLink",
fields: "id, name, mimeType, size, fileExtension, webContentLink",
supportsAllDrives: config.apiConfig.isTeamDrive,
});
const _fileContent = gdrive.files.get(
@@ -70,6 +73,7 @@ If you've already entered the password, please make sure your browser is not blo
_fileContent,
_filePaths,
]);
if (!config.apiConfig.allowDownloadProtectedFile) {
const checkPath = await CheckPaths(filePaths.split("/"));
if (!checkPath.success) throw new Error("File not found");
@@ -108,6 +112,7 @@ If you've already entered the password, please make sure your browser is not blo
config.apiConfig.maxFileSize &&
fileSize > config.apiConfig.maxFileSize
) {
console.log("File size is too large, redirecting to webContentLink");
return NextResponse.redirect(fileMeta.data.webContentLink, {
status: 302,
headers: {
@@ -142,19 +147,6 @@ If you've already entered the password, please make sure your browser is not blo
"Cache-Control": config.cacheControl,
},
});
// const data = await GetFile(encryptedId);
// if (data.mimeType?.includes("folder"))
// throw new Error("Can't download folder");
// if (!data.encryptedWebContentLink)
// throw new Error("No download link found");
// const decryptedWebContent = await decryptData(data.encryptedWebContentLink);
// return new NextResponse(null, {
// status: 302,
// headers: {
// Location: decryptedWebContent,
// },
// });
} catch (error) {
const e = error as Error;
console.error(e.message);
+2 -1
View File
@@ -60,7 +60,8 @@ body {
}
@layer base {
:root {
@apply text-[14px] tablet:text-[16px];
/* @apply text-[14px] tablet:text-[16px]; */
@apply text-[100%];
}
* {
@apply border-border;
+11 -19
View File
@@ -1,7 +1,9 @@
import { Metadata } from "next";
import { JetBrains_Mono, Source_Sans_3 } from "next/font/google";
import { JetBrains_Mono, Outfit, Source_Sans_3 } from "next/font/google";
import { cn } from "~/utils";
import { formatFooter } from "~/utils/footerFormatter";
import config from "~/config/gIndex.config";
import Footer from "./@footer";
@@ -19,6 +21,13 @@ const sourceSans3 = Source_Sans_3({
subsets: ["latin", "latin-ext"],
variable: "--font-source-sans-3",
});
const outfit = Outfit({
weight: ["300", "400", "600", "700"],
style: ["normal"],
display: "auto",
subsets: ["latin", "latin-ext"],
variable: "--font-outfit",
});
const jetbrainsMono = JetBrains_Mono({
weight: ["300", "400", "600", "700"],
style: ["normal", "italic"],
@@ -68,24 +77,6 @@ export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const unlocked = await CheckSitePassword();
const formatFooter = (text: string | string[]): string => {
let toFormat: string;
if (Array.isArray(text)) {
toFormat = text.join(`\n\n`);
} else {
toFormat = text;
}
return toFormat
.replaceAll("{{ year }}", new Date().getFullYear().toString())
.replaceAll(
"{{ repository }}",
"[Repository](https://github.com/mbaharip/next-gdrive-index)",
)
.replaceAll("{{ author }}", config.siteConfig.siteAuthor || "mbaharip")
.replaceAll("{{ version }}", config.version || "0.0.0")
.replaceAll("{{ siteName }}", config.siteConfig.siteName)
.replaceAll("{{ creator }}", "mbaharip");
};
return (
<html lang='en'>
@@ -94,6 +85,7 @@ export default async function RootLayout({
"h-full bg-background font-sans text-foreground",
jetbrainsMono.variable,
sourceSans3.variable,
outfit.variable,
)}
>
<ThemeProvider
+3 -3
View File
@@ -51,11 +51,11 @@
}
.markdown ul {
@apply my-1.5 list-disc leading-loose;
@apply my-3 !list-outside list-disc leading-loose;
}
.markdown ol {
@apply my-1.5 list-decimal leading-loose;
@apply my-3 !list-inside list-decimal leading-loose;
}
.markdown li {
@@ -98,7 +98,7 @@
}
.markdown blockquote {
@apply my-2 rounded-r-[var(--radius)] border-l-4 border-l-primary bg-muted py-4 pl-2 text-foreground;
@apply my-2 rounded-r-[var(--radius)] border-l-4 border-l-primary/50 bg-muted/25 py-1.5 pl-3 text-foreground;
}
.markdown hr {
+19 -15
View File
@@ -6,7 +6,7 @@ import { Separator } from "~/components/ui/separator";
import FileBrowser from "./@explorer";
import Header from "./@header";
import HeaderButton from "./@header.button";
import Markdown from "./@markdown";
import Readme from "./@readme";
import { GetFiles, GetReadme } from "./actions";
export const revalidate = 300;
@@ -43,20 +43,24 @@ export default async function RootPage() {
</Card>
</div>
{readme && (
<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>
<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>
);
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/utils"
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",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
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 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 }
+37 -45
View File
@@ -1,21 +1,16 @@
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "~/utils"
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";
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
@@ -24,13 +19,13 @@ const BreadcrumbList = React.forwardRef<
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-zinc-500 dark:text-zinc-400 sm:gap-2.5",
className,
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
@@ -41,29 +36,26 @@ const BreadcrumbItem = React.forwardRef<
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
))
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
ref={ref}
className={cn(
"transition-colors hover:text-zinc-950 dark:hover:text-zinc-50",
className,
)}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
@@ -71,14 +63,14 @@ const BreadcrumbPage = React.forwardRef<
>(({ className, ...props }, ref) => (
<span
ref={ref}
role='link'
aria-disabled='true'
aria-current='page'
className={cn("font-normal text-zinc-950 dark:text-zinc-50", className)}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
@@ -86,31 +78,31 @@ const BreadcrumbSeparator = ({
...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">) => (
<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,
@@ -120,4 +112,4 @@ export {
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
}
+21 -22
View File
@@ -1,24 +1,23 @@
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 * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300",
"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",
{
variants: {
variant: {
default:
"bg-zinc-900 text-zinc-50 hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90",
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-red-500 text-zinc-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-zinc-200 bg-white hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
ghost:
"hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
"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",
},
size: {
default: "h-10 px-4 py-2",
@@ -31,27 +30,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 }
+19 -29
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import { cn } from "~/utils"
const Card = React.forwardRef<
HTMLDivElement,
@@ -9,12 +10,12 @@ const Card = React.forwardRef<
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
className
)}
{...props}
/>
));
Card.displayName = "Card";
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
@@ -25,8 +26,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
@@ -36,12 +37,12 @@ const CardTitle = React.forwardRef<
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
@@ -52,20 +53,16 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
))
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";
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
@@ -76,14 +73,7 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
))
CardFooter.displayName = "CardFooter"
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+89 -92
View File
@@ -1,45 +1,45 @@
"use client";
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { Button } from "~/components/ui/button";
import { cn } from "~/utils"
import { Button } from "~/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
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<
@@ -56,69 +56,69 @@ const Carousel = React.forwardRef<
children,
...props
},
ref,
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
@@ -138,70 +138,67 @@ 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 { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className='overflow-hidden'
>
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
className
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = "CarouselContent";
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
const { orientation } = useCarousel()
return (
<div
ref={ref}
role='group'
aria-roledescription='slide'
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
className
)}
{...props}
/>
);
});
CarouselItem.displayName = "CarouselItem";
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
@@ -213,24 +210,24 @@ const CarouselPrevious = React.forwardRef<
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className='h-4 w-4' />
<span className='sr-only'>Previous slide</span>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = "CarouselPrevious";
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
@@ -242,18 +239,18 @@ const CarouselNext = React.forwardRef<
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className='h-4 w-4' />
<span className='sr-only'>Next slide</span>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = "CarouselNext";
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
@@ -262,4 +259,4 @@ export {
CarouselItem,
CarouselPrevious,
CarouselNext,
};
}
+31 -30
View File
@@ -1,17 +1,18 @@
"use client";
"use client"
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
const Dialog = DialogPrimitive.Root;
import { cn } from "~/utils"
const DialogTrigger = DialogPrimitive.Trigger;
const Dialog = DialogPrimitive.Root
const DialogPortal = DialogPrimitive.Portal;
const DialogTrigger = DialogPrimitive.Trigger
const DialogClose = DialogPrimitive.Close;
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@@ -21,12 +22,12 @@ 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>,
@@ -38,19 +39,19 @@ const DialogContent = React.forwardRef<
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,
@@ -59,12 +60,12 @@ const DialogHeader = ({
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
@@ -73,12 +74,12 @@ const DialogFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@@ -88,12 +89,12 @@ const DialogTitle = React.forwardRef<
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
@@ -104,8 +105,8 @@ 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,
@@ -118,4 +119,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
};
}
+26 -25
View File
@@ -1,8 +1,9 @@
"use client";
"use client"
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "~/utils";
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "~/utils"
const Drawer = ({
shouldScaleBackground = true,
@@ -12,14 +13,14 @@ const Drawer = ({
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>,
@@ -30,8 +31,8 @@ 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>,
@@ -43,16 +44,16 @@ const DrawerContent = React.forwardRef<
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,
@@ -62,8 +63,8 @@ const DrawerHeader = ({
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
@@ -73,8 +74,8 @@ const DrawerFooter = ({
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>,
@@ -84,12 +85,12 @@ const DrawerTitle = React.forwardRef<
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
className
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
@@ -100,8 +101,8 @@ 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,
@@ -114,4 +115,4 @@ export {
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
}
+47 -46
View File
@@ -1,26 +1,27 @@
"use client";
"use client"
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 * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
const DropdownMenu = DropdownMenuPrimitive.Root;
import { cn } from "~/utils"
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
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
@@ -28,16 +29,16 @@ 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;
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -47,13 +48,13 @@ 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;
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -65,18 +66,18 @@ const DropdownMenuContent = React.forwardRef<
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
@@ -84,12 +85,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>,
@@ -99,21 +100,21 @@ 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;
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -123,24 +124,24 @@ 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
@@ -148,12 +149,12 @@ const DropdownMenuLabel = React.forwardRef<
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@@ -164,8 +165,8 @@ 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,
@@ -176,9 +177,9 @@ const DropdownMenuShortcut = ({
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
@@ -196,4 +197,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
}
+9 -8
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import { cn } from "~/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
@@ -11,14 +12,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
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,
className
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
)
}
)
Input.displayName = "Input"
export { Input };
export { Input }
+11 -10
View File
@@ -1,13 +1,14 @@
"use client";
"use client"
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 * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
@@ -19,7 +20,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label };
export { Label }
+55 -54
View File
@@ -1,19 +1,20 @@
"use client";
"use client"
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 * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
const MenubarMenu = MenubarPrimitive.Menu;
import { cn } from "~/utils"
const MenubarGroup = MenubarPrimitive.Group;
const MenubarMenu = MenubarPrimitive.Menu
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarGroup = MenubarPrimitive.Group
const MenubarSub = MenubarPrimitive.Sub;
const MenubarPortal = MenubarPrimitive.Portal
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
@@ -23,12 +24,12 @@ const Menubar = React.forwardRef<
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className,
className
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
@@ -38,17 +39,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
@@ -56,15 +57,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>,
@@ -74,12 +75,12 @@ 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>,
@@ -87,7 +88,7 @@ const MenubarContent = React.forwardRef<
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref,
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
@@ -97,19 +98,19 @@ const MenubarContent = React.forwardRef<
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,
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
),
);
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
)
)
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
@@ -117,12 +118,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>,
@@ -132,20 +133,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>,
@@ -155,24 +156,24 @@ 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
@@ -180,12 +181,12 @@ const MenubarLabel = React.forwardRef<
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
className
)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
@@ -196,8 +197,8 @@ 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,
@@ -207,13 +208,13 @@ const MenubarShortcut = ({
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
className
)}
{...props}
/>
);
};
MenubarShortcut.displayname = "MenubarShortcut";
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
@@ -232,4 +233,4 @@ export {
MenubarGroup,
MenubarSub,
MenubarShortcut,
};
}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "~/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 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",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
+35 -34
View File
@@ -1,15 +1,16 @@
"use client";
"use client"
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
const Select = SelectPrimitive.Root;
import { cn } from "~/utils"
const SelectGroup = SelectPrimitive.Group;
const Select = SelectPrimitive.Root
const SelectValue = SelectPrimitive.Value;
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@@ -19,17 +20,17 @@ const SelectTrigger = React.forwardRef<
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className='h-4 w-4 opacity-50' />
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@@ -39,14 +40,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
className
)}
{...props}
>
<ChevronUp className='h-4 w-4' />
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@@ -56,15 +57,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
className
)}
{...props}
>
<ChevronDown className='h-4 w-4' />
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
@@ -77,7 +78,7 @@ const SelectContent = React.forwardRef<
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
className
)}
position={position}
{...props}
@@ -87,7 +88,7 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
@@ -95,8 +96,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@@ -107,8 +108,8 @@ const SelectLabel = React.forwardRef<
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@@ -118,20 +119,20 @@ const SelectItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex w-full 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">
<SelectPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
@@ -142,8 +143,8 @@ const SelectSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
@@ -156,4 +157,4 @@ export {
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
}
+11 -10
View File
@@ -1,8 +1,9 @@
"use client";
"use client"
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "~/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -10,7 +11,7 @@ const Separator = React.forwardRef<
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
@@ -19,12 +20,12 @@ const Separator = React.forwardRef<
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
className
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator };
export { Separator }
+32 -31
View File
@@ -1,18 +1,19 @@
"use client";
"use client"
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
const Sheet = SheetPrimitive.Root;
import { cn } from "~/utils"
const SheetTrigger = SheetPrimitive.Trigger;
const Sheet = SheetPrimitive.Root
const SheetClose = SheetPrimitive.Close;
const SheetTrigger = SheetPrimitive.Trigger
const SheetPortal = SheetPrimitive.Portal;
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -21,13 +22,13 @@ const SheetOverlay = React.forwardRef<
<SheetPrimitive.Overlay
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}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@@ -45,8 +46,8 @@ const sheetVariants = cva(
defaultVariants: {
side: "right",
},
},
);
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
@@ -64,14 +65,14 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<SheetPrimitive.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-secondary'>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
<SheetPrimitive.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-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
@@ -80,12 +81,12 @@ const SheetHeader = ({
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
className
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
@@ -94,12 +95,12 @@ const SheetFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
className
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
@@ -110,8 +111,8 @@ const SheetTitle = React.forwardRef<
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
@@ -122,8 +123,8 @@ const SheetDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
@@ -136,4 +137,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
};
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { cn } from "~/utils";
import { cn } from "~/utils"
function Skeleton({
className,
@@ -9,7 +9,7 @@ function Skeleton({
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
)
}
export { Skeleton };
export { Skeleton }
+25 -28
View File
@@ -1,31 +1,28 @@
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import { cn } from "~/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className='relative w-full overflow-auto'>
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn("[&_tr]:border-b", className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
@@ -36,8 +33,8 @@ const TableBody = React.forwardRef<
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
@@ -47,12 +44,12 @@ const TableFooter = React.forwardRef<
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
@@ -62,12 +59,12 @@ const TableRow = React.forwardRef<
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
@@ -77,12 +74,12 @@ const TableHead = React.forwardRef<
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
@@ -93,8 +90,8 @@ const TableCell = React.forwardRef<
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
@@ -105,8 +102,8 @@ const TableCaption = React.forwardRef<
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
))
TableCaption.displayName = "TableCaption"
export {
Table,
@@ -117,4 +114,4 @@ export {
TableRow,
TableCell,
TableCaption,
};
}
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "~/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+9 -8
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import { cn } from "~/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
@@ -10,14 +11,14 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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,
className
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
)
}
)
Textarea.displayName = "Textarea"
export { Textarea };
export { Textarea }
+31 -30
View File
@@ -1,12 +1,13 @@
"use client";
"use client"
import * as ToastPrimitives from "@radix-ui/react-toast";
import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
const ToastProvider = ToastPrimitives.Provider;
import { cn } from "~/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
@@ -16,12 +17,12 @@ const ToastViewport = React.forwardRef<
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
@@ -36,8 +37,8 @@ const toastVariants = cva(
defaultVariants: {
variant: "default",
},
},
);
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
@@ -50,9 +51,9 @@ const Toast = React.forwardRef<
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
@@ -62,12 +63,12 @@ const ToastAction = React.forwardRef<
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
@@ -77,15 +78,15 @@ const ToastClose = React.forwardRef<
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
className
)}
toast-close=''
toast-close=""
{...props}
>
<X className='h-4 w-4' />
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
@@ -96,8 +97,8 @@ const ToastTitle = React.forwardRef<
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
@@ -108,12 +109,12 @@ const ToastDescription = React.forwardRef<
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>;
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
@@ -125,4 +126,4 @@ export {
ToastDescription,
ToastClose,
ToastAction,
};
}
+8 -11
View File
@@ -1,4 +1,4 @@
"use client";
"use client"
import {
Toast,
@@ -7,21 +7,18 @@ import {
ToastProvider,
ToastTitle,
ToastViewport,
} from "~/components/ui/toast";
import { useToast } from "~/components/ui/use-toast";
} from "~/components/ui/toast"
import { useToast } from "~/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast();
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast
key={id}
{...props}
>
<div className='grid gap-1'>
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
@@ -30,9 +27,9 @@ export function Toaster() {
{action}
<ToastClose />
</Toast>
);
)
})}
<ToastViewport />
</ToastProvider>
);
)
}
+12 -11
View File
@@ -1,14 +1,15 @@
"use client";
"use client"
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "~/utils";
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
const TooltipProvider = TooltipPrimitive.Provider;
import { cn } from "~/utils"
const Tooltip = TooltipPrimitive.Root;
const TooltipProvider = TooltipPrimitive.Provider
const TooltipTrigger = TooltipPrimitive.Trigger;
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
@@ -19,11 +20,11 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+72 -69
View File
@@ -1,75 +1,78 @@
"use client";
"use client"
// Inspired by react-hot-toast library
import * as React from "react";
import * as React from "react"
import type { ToastActionElement, ToastProps } from "~/components/ui/toast";
import type {
ToastActionElement,
ToastProps,
} from "~/components/ui/toast"
const TOAST_LIMIT = 3;
const TOAST_REMOVE_DELAY = 1000000;
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
} as const
let count = 0;
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes;
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[];
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout);
};
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
@@ -77,27 +80,27 @@ export const reducer = (state: State, action: Action): State => {
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
}
case "DISMISS_TOAST": {
const { toastId } = action;
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
addToRemoveQueue(toast.id)
})
}
return {
@@ -108,46 +111,46 @@ export const reducer = (state: State, action: Action): State => {
...t,
open: false,
}
: t,
: t
),
};
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId();
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
@@ -156,36 +159,36 @@ function toast({ ...props }: Toast) {
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
if (!open) dismiss()
},
},
});
})
return {
id: id,
dismiss,
update,
};
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState);
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState);
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1);
listeners.splice(index, 1)
}
};
}, [state]);
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
}
export { toast, useToast };
export { useToast, toast }
+34 -7
View File
@@ -13,14 +13,27 @@ const config: z.input<typeof Schema_Config> = {
* If you're using another port for development, you can set it here
*
* @default process.env.NEXT_PUBLIC_DOMAIN
* @fallback process.env.NEXT_PUBLIC_VERCEL_URL
*/
basePath:
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: `https://${
process.env.NEXT_PUBLIC_VERCEL_URL || process.env.NEXT_PUBLIC_DOMAIN
process.env.NEXT_PUBLIC_DOMAIN || process.env.NEXT_PUBLIC_VERCEL_URL
}`,
/**
* Allow access to the deploy guide
* Will use the `/deploy` route, might be overlap with file / folder name
*
* Set this to false on final deployment
*
* I'm using this to show the deploy guide on my own demo deployment
*
* @default false
*/
showDeployGuide: true,
/**
* DEPRECATED
* Since in 2.0 we're using server side data fetching, this is not needed anymore.
@@ -52,16 +65,19 @@ const config: z.input<typeof Schema_Config> = {
// "c760fc0eae9990d4accbc2134af21e45a378d412af2c78020070a9f9ac548b98fe61c4f6be953a8d7be6a035e6f7766c",
rootFolder:
"b76c7c22083307a3aa99c28ab7cc69851d682f5a250d995679d4be5276cab16ab6c37f4d5b7ad1a9b93fb9bf768e752c",
/**
* If your root folder inside a shared drive, set this to true
* If not, set this to false
* If your rootfolder inside a shared drive, you NEED to set this to true
* If not, you can set this to false
*
* You also need to set the shared drive ID to make it work
* Make sure you have add your service account to the shared drive since the service account can't access the shared drive by default
*
* You need to set the shared drive id to make it work
* Where to get the shared drive id?
* Go to your Shared Drives -> Click on the shared drive -> Copy the id from the url
* Go to your Shared Drive > Click on the shared drive > copy the ID from the url
* ex: https://drive.google.com/drive/u/0/folders/:shared_drive_id
*
* Then encrypt it using `/api/internal/encrypt?q=:shared_drive_id` route
* Then you need to encrypt it using `/api/internal/encrypt?q=:shared_drive_id` route
*/
isTeamDrive: true,
sharedDrive:
@@ -181,9 +197,19 @@ const config: z.input<typeof Schema_Config> = {
robots: "noindex, nofollow",
twitterHandle: "@mbaharip_",
/**
* Show file extension on the file name
* Example:
* true | false
* file.txt | file
* 100KB | txt / 100KB
*
* Default: false
*/
showFileExtension: false,
/**
* Footer content
* You can use string or array of string for multiple lines
* You can also set it to empty array if you don't want to use it
*
* Basic markdown is supported (bold, italic, and link)
@@ -195,6 +221,7 @@ const config: z.input<typeof Schema_Config> = {
* - {{ author }} will be replaced with author from siteAuthor config above (If it's not set, it will be set to mbaharip)
* - {{ version }} will be replaced with the current version
* - {{ siteName }} will be replaced with the siteName config above
* - {{ handle }} will be replaced with the twitter handle from twitterHandle config above
* - {{ creator }} will be replaced with mbaharip if you want to credit me
*/
footer: [
+1 -1
View File
@@ -1,4 +1,4 @@
import { toast } from "react-toastify";
import { toast } from "react-hot-toast";
export default function useCopyText() {
return (text: string) => {
+172 -58
View File
@@ -34,12 +34,58 @@ export const Schema_File = z.object({
.nullable(),
});
export const Schema_Config = z.object({
version: z.string(),
export const Schema_Old_Config = z.object({
version: z.literal("1.0.0"),
basePath: z.string(),
masterKey: z.string(),
cacheControl: z.string(),
apiConfig: z.object({
rootFolder: z.string(),
isTeamDrive: z.boolean(),
sharedDrive: z.string().optional(),
defaultQuery: z.array(z.string()),
defaultField: z.string(),
defaultOrder: z.string(),
itemsPerPage: z.number().positive(),
searchRsult: z.number().positive(),
specialFile: z.object({
password: z.string(),
readme: z.string(),
banner: z.string(),
}),
hiddenFiles: z.array(z.string()),
allowDownloadProtectedFile: z.boolean(),
temporaryTokenDuration: z.number().positive(),
maxFileSize: z.number().positive(),
}),
siteConfig: z.object({
siteName: z.string(),
siteDescription: z.string(),
siteIcon: z.string(),
favIcon: z.string(),
twitterHandle: z.string().optional().default("@__mbaharip__"),
defaultAccentColor: z.string(),
privateIndex: z.boolean().optional().default(false),
navbarItems: z.array(
z.object({
icon: z.string(),
name: z.string(),
href: z.string(),
external: z.boolean().optional().default(false),
}),
),
}),
});
export const Schema_Config_API = z
.object({
rootFolder: z.string(),
isTeamDrive: z.boolean(),
sharedDrive: z.string().optional(),
@@ -60,64 +106,132 @@ export const Schema_Config = z.object({
allowDownloadProtectedFile: z.boolean(),
temporaryTokenDuration: z.number().positive(),
maxFileSize: z.number().positive(),
}),
})
.refine(
(data) => {
if (data.isTeamDrive && !data.sharedDrive) return false;
},
{ message: "sharedDrive is required when isTeamDrive is true" },
);
export const Schema_Config_Site = z.object({
siteName: z.string(),
siteNameTemplate: z.string().optional().default("%s"),
siteDescription: z.string(),
siteIcon: z.string(),
siteAuthor: z.string().optional().default("mbaharip"),
favIcon: z.string(),
robots: z.string().optional().default("noindex, nofollow"),
twitterHandle: z.string().optional().default("@__mbaharip__"),
siteConfig: z.object({
siteName: z.string(),
siteNameTemplate: z.string().optional().default("%s"),
siteDescription: z.string(),
siteIcon: z.string(),
siteAuthor: z.string().optional().default("mbaharip"),
favIcon: z.string(),
robots: z.string().optional().default("noindex, nofollow"),
twitterHandle: z.string().optional().default("@__mbaharip__"),
showFileExtension: z.boolean().optional().default(false),
footer: z
.string()
.or(z.array(z.string()))
.optional()
.default([
"{{ year }}",
"{{ repository }}",
"{{ author }}",
"{{ version }}",
"{{ siteName }}",
"{{ creator }}",
footer: z.string().array().optional(),
privateIndex: z.boolean().optional().default(false),
breadcrumbMax: z.number(),
toaster: z
.object({
position: z.enum([
"top-left",
"top-right",
"bottom-left",
"bottom-right",
]),
duration: z.number().positive(),
})
.optional()
.default({
position: "top-right",
duration: 5000,
}),
privateIndex: z.boolean().optional().default(false),
breadcrumbMax: z.number(),
toaster: z
.object({
position: z.enum([
"top-left",
"top-right",
"bottom-left",
"bottom-right",
]),
duration: z.number().positive(),
})
.optional()
.default({
position: "top-right",
duration: 5000,
}),
navbarItems: z.array(
z.object({
icon: z.enum(Object.keys(icons) as [keyof typeof icons]),
name: z.string(),
href: z.string(),
external: z.boolean().optional().default(false),
}),
),
supports: z.array(
z.object({
name: z.string(),
currency: z.string(),
href: z.string(),
}),
),
}),
navbarItems: z.array(
z.object({
icon: z.enum(Object.keys(icons) as [keyof typeof icons]),
name: z.string(),
href: z.string(),
external: z.boolean().optional().default(false),
}),
),
supports: z.array(
z.object({
name: z.string(),
currency: z.string(),
href: z.string(),
}),
),
});
export const Schema_App_Configuration_Env = z.object({
GD_SERVICE_B64: z.string(),
ENCRYPTION_KEY: z.string(),
SITE_PASSWORD: z.string().optional(),
NEXT_PUBLIC_DOMAIN: z.string().optional(),
});
export const Schema_Config = z.object({
version: z.string(),
basePath: z.string(),
cacheControl: z.string(),
showDeployGuide: z.boolean(),
apiConfig: Schema_Config_API,
siteConfig: Schema_Config_Site,
});
export const Schema_ServiceAccount = z.object({
type: z.literal("service_account"),
project_id: z.string(),
private_key_id: z.string(),
private_key: z.string(),
client_email: z.string().email("Invalid client_email field"),
client_id: z.string(),
auth_uri: z.string().url(),
token_uri: z.string().url(),
auth_provider_x509_cert_url: z.string().url(),
client_x509_cert_url: z.string().url(),
universe_domain: z.string().optional(),
});
export const Schema_App_Configuration = z.object({
environment: Schema_App_Configuration_Env,
api: Schema_Config_API,
site: Schema_Config_Site,
});
export type ConfigurationCategory = keyof z.infer<
typeof Schema_App_Configuration
>;
export type ConfigurationKeys<
T extends keyof z.infer<typeof Schema_App_Configuration>,
> = keyof z.infer<typeof Schema_App_Configuration>[T];
export type ConfigurationValue<
T extends keyof z.infer<typeof Schema_App_Configuration>,
K extends keyof z.infer<typeof Schema_App_Configuration>[T],
> = z.infer<typeof Schema_App_Configuration>[T][K];
export type ConfigState = "idle" | "loading";
export const Schema_Theme = z.object({
"background": z.string(),
"foreground": z.string(),
"card": z.string(),
"card-foreground": z.string(),
"popover": z.string(),
"popover-foreground": z.string(),
"primary": z.string(),
"primary-foreground": z.string(),
"secondary": z.string(),
"secondary-foreground": z.string(),
"muted": z.string(),
"muted-foreground": z.string(),
"accent": z.string(),
"accent-foreground": z.string(),
"destructive": z.string(),
"destructive-foreground": z.string(),
"border": z.string(),
"input": z.string(),
"ring": z.string(),
"radius": z.string(),
});
+12 -5
View File
@@ -14,11 +14,14 @@ const generateKey = () => {
return data;
};
const key = generateKey();
const iv = Buffer.from(key);
export async function encryptData(data: string): Promise<string> {
export async function encryptData(
data: string,
encryptKey: string = key,
): Promise<string> {
try {
const cipher = crypto.createCipheriv("aes-128-cbc", key, iv);
const ivKey = Buffer.from(key);
const cipher = crypto.createCipheriv("aes-128-cbc", encryptKey, ivKey);
return Buffer.concat([
cipher.update(data, "utf-8"),
cipher.final(),
@@ -30,9 +33,13 @@ export async function encryptData(data: string): Promise<string> {
}
}
export async function decryptData(hash: string): Promise<string> {
export async function decryptData(
hash: string,
encryptKey: string = key,
): Promise<string> {
try {
const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
const ivKey = Buffer.from(key);
const decipher = crypto.createDecipheriv("aes-128-cbc", encryptKey, ivKey);
return Buffer.concat([
decipher.update(hash, "hex"),
+19
View File
@@ -0,0 +1,19 @@
import config from "~/config/gIndex.config";
export function formatFooter(text: string[]): string {
return text
.join("\n")
.replaceAll("{{ year }}", new Date().getFullYear().toString())
.replaceAll(
"{{ repository }}",
"[Repository](https://github.com/mbaharip/next-gdrive-index)",
)
.replaceAll("{{ author }}", config.siteConfig.siteAuthor || "mbaharip")
.replaceAll("{{ version }}", config.version || "0.0.0")
.replaceAll("{{ siteName }}", config.siteConfig.siteName)
.replaceAll(
"{{ handle }}",
config.siteConfig.twitterHandle || "@__mbaharip__",
)
.replaceAll("{{ creator }}", "mbaharip");
}
+65
View File
@@ -0,0 +1,65 @@
// Used for configuration on deploy guide page
import { z } from "zod";
import {
Schema_Config,
Schema_Config_API,
Schema_Config_Site,
Schema_Old_Config,
} from "~/schema";
export function parseConfigFile(config: string):
| {
api: z.infer<typeof Schema_Config_API>;
site: z.infer<typeof Schema_Config_Site>;
}
| {
success: false;
message: string;
} {
try {
// Parse string to JSON
const configuration = config
.split(/const config:\s.*?=\s/g)[1]
.split("export default config;")[0]
.replace(/\\/g, "") // Remove all escape backslashes
.replace(/\/\*[\s\S]*?\*\//g, "") // Remove all multi-line comments
.replace(/,\s\/\/\s.*/g, ",") // Remove comments after values
.replace(/[^,]\/\/\s.*?,/g, "") // Remove single line comments
.replace(/\r\n/g, "")
.replace(/\n/g, "")
.replace(/\t/g, "") // Remove line breaks and tabs
.replace(/basePath:(.*?),/g, 'basePath: "placeholder-domain",') // Replace basePath variable with placeholder
.replace(/maxFileSize:(.*?),/g, "maxFileSize: 4194304,") // Set maxFileSize to 4MB
.replace(/([a-zA-Z]*?):\s/g, '"$1": ') // Add double quotes to keys
.trim()
.slice(0, -1)
.replace(/\s{2,4}|/g, "") // Replace all double+ spaces with single space
.replace(/,(?=[^,]*$)/, "") // Remove trailing comma
.replace(/(,\])/g, "]") // Remove trailing comma before closing bracket
.replace(/(,\})/g, "}"); // Remove trailing comma before closing brace
const parseJSON = JSON.parse(configuration);
const version: string | undefined = parseJSON.version;
if (!version)
throw new Error(
"Version not found, please check your configuration file.",
);
const data = parseJSON as
| z.infer<typeof Schema_Old_Config>
| z.infer<typeof Schema_Config>;
return {
api: data.apiConfig as z.infer<typeof Schema_Config_API>,
site: data.siteConfig as z.infer<typeof Schema_Config_Site>,
};
} catch (error) {
const e = error as Error;
return { success: false, message: e.message };
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ const config: Config = {
},
extend: {
fontFamily: {
sans: ["var(--font-source-sans-3)", ...tw.fontFamily.sans],
sans: ["var(--font-outfit)", ...tw.fontFamily.sans],
mono: ["var(--font-jetbrains-mono)", ...tw.fontFamily.mono],
},
colors: {
+160 -16
View File
@@ -1894,6 +1894,15 @@ __metadata:
languageName: node
linkType: hard
"@types/mdast@npm:^4.0.0":
version: 4.0.3
resolution: "@types/mdast@npm:4.0.3"
dependencies:
"@types/unist": "npm:*"
checksum: 10c0/e6994404f5ce58073aa6c1a37ceac3060326470a464e2d751580a9f89e2dbca3a2a6222b849bdaaa5bffbe89033c50a886d17e49fca3b040a4ffcf970e387a0c
languageName: node
linkType: hard
"@types/mime-types@npm:^2.1.1":
version: 2.1.4
resolution: "@types/mime-types@npm:2.1.4"
@@ -2006,6 +2015,13 @@ __metadata:
languageName: node
linkType: hard
"@types/unist@npm:*, @types/unist@npm:^3.0.0":
version: 3.0.2
resolution: "@types/unist@npm:3.0.2"
checksum: 10c0/39f220ce184a773c55c18a127062bfc4d0d30c987250cd59bab544d97be6cfec93717a49ef96e81f024b575718f798d4d329eb81c452fc57d6d051af8b043ebf
languageName: node
linkType: hard
"@types/unist@npm:^2, @types/unist@npm:^2.0.0":
version: 2.0.10
resolution: "@types/unist@npm:2.0.10"
@@ -3059,6 +3075,15 @@ __metadata:
languageName: node
linkType: hard
"devlop@npm:^1.0.0":
version: 1.1.0
resolution: "devlop@npm:1.1.0"
dependencies:
dequal: "npm:^2.0.0"
checksum: 10c0/e0928ab8f94c59417a2b8389c45c55ce0a02d9ac7fd74ef62d01ba48060129e1d594501b77de01f3eeafc7cb00773819b0df74d96251cf20b31c5b3071f45c0e
languageName: node
linkType: hard
"didyoumean@npm:^1.2.2":
version: 1.2.2
resolution: "didyoumean@npm:1.2.2"
@@ -3130,31 +3155,31 @@ __metadata:
languageName: node
linkType: hard
"embla-carousel-react@npm:^8.0.1":
version: 8.0.1
resolution: "embla-carousel-react@npm:8.0.1"
"embla-carousel-react@npm:^8.0.2":
version: 8.0.2
resolution: "embla-carousel-react@npm:8.0.2"
dependencies:
embla-carousel: "npm:8.0.1"
embla-carousel-reactive-utils: "npm:8.0.1"
embla-carousel: "npm:8.0.2"
embla-carousel-reactive-utils: "npm:8.0.2"
peerDependencies:
react: ^16.8.0 || ^17.0.1 || ^18.0.0
checksum: 10c0/a16af76be911133f00ff38491b0ed12f09571949234b22bfe83cbd2d8b0d3cf43b666ffc4fc6aaf17e04691495fdad69fc1bc33db3eeec9ba7f67fe8db056a25
checksum: 10c0/7e45266d4251a960515a3283e65454b61e54d6f400f2dee7f56a3ac50532f7ce4735d926494cb1570d81de4bf299c3d1417ab4e64467cda139a7f60b49583757
languageName: node
linkType: hard
"embla-carousel-reactive-utils@npm:8.0.1":
version: 8.0.1
resolution: "embla-carousel-reactive-utils@npm:8.0.1"
"embla-carousel-reactive-utils@npm:8.0.2":
version: 8.0.2
resolution: "embla-carousel-reactive-utils@npm:8.0.2"
peerDependencies:
embla-carousel: 8.0.1
checksum: 10c0/c511dbbcd869f11f102e826aea600f1668f8097792b4e185678f44240466fe6a224b68833c408bd9eb6c9b26e40321b6f18f70f45bd19df3cd403d964e1fba95
embla-carousel: 8.0.2
checksum: 10c0/e7e81916971008642700af0b96a59117324214c020759a53feb1d96edb34649329018777dc00b87c631c9d04efd544945f5914dc3cbd5289766a6cdcea253f4e
languageName: node
linkType: hard
"embla-carousel@npm:8.0.1":
version: 8.0.1
resolution: "embla-carousel@npm:8.0.1"
checksum: 10c0/9ce30759a77e75ff4ce490102c429794fd46f03bbcc4a6af4ecefbe55a5de5289b7ac0f7607d1774a9b23c241d5781bf3d45459590768b15679a9da5b56ef6df
"embla-carousel@npm:8.0.2":
version: 8.0.2
resolution: "embla-carousel@npm:8.0.2"
checksum: 10c0/e63ce4e387c0e227ce211a1131f81c74dbe6c4bb32a5d072e0c1e30774305ca9a30fc86ebb042707bf8d6ec8cb57575628dfa1b18b36dd3206d4774cd34b73bc
languageName: node
linkType: hard
@@ -5350,6 +5375,18 @@ __metadata:
languageName: node
linkType: hard
"mdast-util-find-and-replace@npm:^3.0.0":
version: 3.0.1
resolution: "mdast-util-find-and-replace@npm:3.0.1"
dependencies:
"@types/mdast": "npm:^4.0.0"
escape-string-regexp: "npm:^5.0.0"
unist-util-is: "npm:^6.0.0"
unist-util-visit-parents: "npm:^6.0.0"
checksum: 10c0/1faca98c4ee10a919f23b8cc6d818e5bb6953216a71dfd35f51066ed5d51ef86e5063b43dcfdc6061cd946e016a9f0d44a1dccadd58452cf4ed14e39377f00cb
languageName: node
linkType: hard
"mdast-util-from-markdown@npm:^1.0.0":
version: 1.3.1
resolution: "mdast-util-from-markdown@npm:1.3.1"
@@ -5451,6 +5488,16 @@ __metadata:
languageName: node
linkType: hard
"mdast-util-newline-to-break@npm:^2.0.0":
version: 2.0.0
resolution: "mdast-util-newline-to-break@npm:2.0.0"
dependencies:
"@types/mdast": "npm:^4.0.0"
mdast-util-find-and-replace: "npm:^3.0.0"
checksum: 10c0/756a5660b0a821e0d6d6a0b2d9b13ac32e41cc028c485a91bccf6300977e2557236c6cc93dbd55c68b785f1ed6eae69209a4ffe182533cd1cdfda369021bebd2
languageName: node
linkType: hard
"mdast-util-phrasing@npm:^3.0.0":
version: 3.0.1
resolution: "mdast-util-phrasing@npm:3.0.1"
@@ -6170,7 +6217,7 @@ __metadata:
clsx: "npm:^2.1.0"
cmdk: "npm:^1.0.0"
date-fns: "npm:^3.6.0"
embla-carousel-react: "npm:^8.0.1"
embla-carousel-react: "npm:^8.0.2"
encoding: "npm:^0.1.13"
eslint: "npm:8.38.0"
eslint-config-next: "npm:^14.1.4"
@@ -6186,6 +6233,7 @@ __metadata:
prettier: "npm:3.0.0"
prettier-plugin-tailwindcss: "npm:0.5.12"
react: "npm:^18"
react-colorful: "npm:^5.6.1"
react-day-picker: "npm:^8.10.0"
react-dom: "npm:^18"
react-h5-audio-player: "npm:^3.9.1"
@@ -6200,6 +6248,7 @@ __metadata:
rehype-katex: "npm:^6.0.3"
rehype-prism-plus: "npm:^1.6.3"
rehype-raw: "npm:6.1.1"
remark-breaks: "npm:^4.0.0"
remark-gfm: "npm:^3.0.1"
remark-math: "npm:^5.1.1"
remark-slug: "npm:^7.0.1"
@@ -6209,6 +6258,7 @@ __metadata:
tailwindcss: "npm:^3.4.1"
tailwindcss-animate: "npm:^1.0.7"
typescript: "npm:^5"
use-debouncy: "npm:^5.0.1"
vaul: "npm:^0.9.0"
zod: "npm:^3.22.4"
languageName: unknown
@@ -6951,6 +7001,16 @@ __metadata:
languageName: node
linkType: hard
"react-colorful@npm:^5.6.1":
version: 5.6.1
resolution: "react-colorful@npm:5.6.1"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 10c0/48eb73cf71e10841c2a61b6b06ab81da9fffa9876134c239bfdebcf348ce2a47e56b146338e35dfb03512c85966bfc9a53844fc56bc50154e71f8daee59ff6f0
languageName: node
linkType: hard
"react-day-picker@npm:^8.10.0":
version: 8.10.0
resolution: "react-day-picker@npm:8.10.0"
@@ -7330,6 +7390,17 @@ __metadata:
languageName: node
linkType: hard
"remark-breaks@npm:^4.0.0":
version: 4.0.0
resolution: "remark-breaks@npm:4.0.0"
dependencies:
"@types/mdast": "npm:^4.0.0"
mdast-util-newline-to-break: "npm:^2.0.0"
unified: "npm:^11.0.0"
checksum: 10c0/d7b319a7993b54c5d574e9255080c5de68cfa24f993873b0ee296af13f478521c41d4b7ae0fc14b4607ea70c8f6967e998ab7a467de13139141e66a1a34cb6be
languageName: node
linkType: hard
"remark-gfm@npm:^3.0.1":
version: 3.0.1
resolution: "remark-gfm@npm:3.0.1"
@@ -8313,6 +8384,21 @@ __metadata:
languageName: node
linkType: hard
"unified@npm:^11.0.0":
version: 11.0.4
resolution: "unified@npm:11.0.4"
dependencies:
"@types/unist": "npm:^3.0.0"
bail: "npm:^2.0.0"
devlop: "npm:^1.0.0"
extend: "npm:^3.0.0"
is-plain-obj: "npm:^4.0.0"
trough: "npm:^2.0.0"
vfile: "npm:^6.0.0"
checksum: 10c0/b550cdc994d54c84e2e098eb02cfa53535cbc140c148aa3296f235cb43082b499d239110f342fa65eb37ad919472a93cc62f062a83541485a69498084cc87ba1
languageName: node
linkType: hard
"unique-filename@npm:^3.0.0":
version: 3.0.0
resolution: "unique-filename@npm:3.0.0"
@@ -8368,6 +8454,15 @@ __metadata:
languageName: node
linkType: hard
"unist-util-is@npm:^6.0.0":
version: 6.0.0
resolution: "unist-util-is@npm:6.0.0"
dependencies:
"@types/unist": "npm:^3.0.0"
checksum: 10c0/9419352181eaa1da35eca9490634a6df70d2217815bb5938a04af3a662c12c5607a2f1014197ec9c426fbef18834f6371bfdb6f033040fa8aa3e965300d70e7e
languageName: node
linkType: hard
"unist-util-position@npm:^4.0.0":
version: 4.0.4
resolution: "unist-util-position@npm:4.0.4"
@@ -8396,6 +8491,15 @@ __metadata:
languageName: node
linkType: hard
"unist-util-stringify-position@npm:^4.0.0":
version: 4.0.0
resolution: "unist-util-stringify-position@npm:4.0.0"
dependencies:
"@types/unist": "npm:^3.0.0"
checksum: 10c0/dfe1dbe79ba31f589108cb35e523f14029b6675d741a79dea7e5f3d098785045d556d5650ec6a8338af11e9e78d2a30df12b1ee86529cded1098da3f17ee999e
languageName: node
linkType: hard
"unist-util-visit-parents@npm:^5.0.0, unist-util-visit-parents@npm:^5.1.1":
version: 5.1.3
resolution: "unist-util-visit-parents@npm:5.1.3"
@@ -8406,6 +8510,16 @@ __metadata:
languageName: node
linkType: hard
"unist-util-visit-parents@npm:^6.0.0":
version: 6.0.1
resolution: "unist-util-visit-parents@npm:6.0.1"
dependencies:
"@types/unist": "npm:^3.0.0"
unist-util-is: "npm:^6.0.0"
checksum: 10c0/51b1a5b0aa23c97d3e03e7288f0cdf136974df2217d0999d3de573c05001ef04cccd246f51d2ebdfb9e8b0ed2704451ad90ba85ae3f3177cf9772cef67f56206
languageName: node
linkType: hard
"unist-util-visit@npm:^4.0.0":
version: 4.1.2
resolution: "unist-util-visit@npm:4.1.2"
@@ -8462,6 +8576,15 @@ __metadata:
languageName: node
linkType: hard
"use-debouncy@npm:^5.0.1":
version: 5.0.1
resolution: "use-debouncy@npm:5.0.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/d5d2b5f330c161ed6de4045a6f9499b459366c7f6902ed4200c7ee5f88491969e48b5dee180d2a1028a744b360cfdf59ff66b5620a01dc52f01ba106d2577695
languageName: node
linkType: hard
"use-sidecar@npm:^1.1.2":
version: 1.1.2
resolution: "use-sidecar@npm:1.1.2"
@@ -8540,6 +8663,16 @@ __metadata:
languageName: node
linkType: hard
"vfile-message@npm:^4.0.0":
version: 4.0.2
resolution: "vfile-message@npm:4.0.2"
dependencies:
"@types/unist": "npm:^3.0.0"
unist-util-stringify-position: "npm:^4.0.0"
checksum: 10c0/07671d239a075f888b78f318bc1d54de02799db4e9dce322474e67c35d75ac4a5ac0aaf37b18801d91c9f8152974ea39678aa72d7198758b07f3ba04fb7d7514
languageName: node
linkType: hard
"vfile@npm:^5.0.0":
version: 5.3.7
resolution: "vfile@npm:5.3.7"
@@ -8552,6 +8685,17 @@ __metadata:
languageName: node
linkType: hard
"vfile@npm:^6.0.0":
version: 6.0.1
resolution: "vfile@npm:6.0.1"
dependencies:
"@types/unist": "npm:^3.0.0"
unist-util-stringify-position: "npm:^4.0.0"
vfile-message: "npm:^4.0.0"
checksum: 10c0/443bda43e5ad3b73c5976e987dba2b2d761439867ba7d5d7c5f4b01d3c1cb1b976f5f0e6b2399a00dc9b4eaec611bd9984ce9ce8a75a72e60aed518b10a902d2
languageName: node
linkType: hard
"warning@npm:^4.0.2":
version: 4.0.3
resolution: "warning@npm:4.0.3"