feat: Configurator page finished

This commit is contained in:
mbaharip
2025-01-24 07:59:11 +07:00
parent 0c722c176b
commit f7e56c5023
8 changed files with 1012 additions and 191 deletions
+257 -1
View File
@@ -1,14 +1,32 @@
"use server"; "use server";
import { type AsyncZippable, strToU8, zipSync } from "fflate";
import { type z } from "zod";
import { type ActionResponseSchema } from "~/types"; import { type ActionResponseSchema } from "~/types";
import { base64Decode, base64Encode } from "~/lib/utils.server"; import {
configurationTemplate,
environmentTemplate,
parseEnvironment,
parseVersion1Config,
parseVersion2Config,
} from "~/lib/configurationHelper";
import { base64Decode, base64Encode, encryptionService } from "~/lib/utils.server";
import { type Schema_App_Configuration, type Schema_App_Configuration_Env } from "~/types/schema";
export async function GenerateServiceAccountB64(serviceAccount: string): Promise<ActionResponseSchema<string>> { export async function GenerateServiceAccountB64(serviceAccount: string): Promise<ActionResponseSchema<string>> {
const b64 = base64Encode(serviceAccount, "standard"); const b64 = base64Encode(serviceAccount, "standard");
// Test // Test
const decoded = base64Decode(b64, "standard"); const decoded = base64Decode(b64, "standard");
if (decoded === null) {
return {
success: false,
message: "Failed to decode the service account",
error: "Something went wrong while encoding the service account, please try again",
};
}
if (decoded !== serviceAccount) { if (decoded !== serviceAccount) {
return { return {
success: false, success: false,
@@ -23,3 +41,241 @@ export async function GenerateServiceAccountB64(serviceAccount: string): Promise
data: b64, data: b64,
}; };
} }
export async function ProcessEnvironmentConfig(
configuration: string,
): Promise<ActionResponseSchema<z.infer<typeof Schema_App_Configuration_Env>>> {
const data = parseEnvironment(configuration);
if ("message" in data && "details" in data) {
return {
success: false,
message: data.message,
error: data.details.join("\n"),
};
}
// Verify service account
const serviceAccount = data.GD_SERVICE_B64;
const decoded = base64Decode(serviceAccount, "standard");
if (decoded === null) {
data.GD_SERVICE_B64 = "";
return {
success: true,
data,
message: "Environment loaded, but service account is invalid",
};
}
return {
success: true,
message: "Environment loaded",
data,
};
}
export async function ProcessConfiguration(
configuration: string,
version: "v1" | "v2" | "latest",
): Promise<ActionResponseSchema<Omit<z.infer<typeof Schema_App_Configuration>, "environment">>> {
const data = version === "v1" ? parseVersion1Config(configuration) : parseVersion2Config(configuration);
if ("message" in data && "details" in data) {
return {
success: false,
message: data.message,
error: data.details.join("\n"),
};
}
return {
success: true,
message: "Configuration loaded, but folder ID are not processed",
data,
};
}
export async function GenerateConfiguration(
values: z.infer<typeof Schema_App_Configuration>,
): Promise<ActionResponseSchema<{ configuration: string; env: string; zip: Blob }>> {
const configurationMap: { key: string; value: string }[] = [
{
key: "version",
value: values.version,
},
{
key: "showGuideButton",
value: values.site.guideButton.toString(),
},
{
key: "cacheControl",
value: `${values.api.cache.public ? "public, " : ""}max-age=${values.api.cache.maxAge}, s-maxage=${
values.api.cache.sMaxAge
}${values.api.cache.staleWhileRevalidate ? ", stale-while-revalidate" : ""}`,
},
{
key: "api.rootFolder",
value: await encryptionService.encrypt(values.api.rootFolder, values.environment.ENCRYPTION_KEY),
},
{
key: "api.isTeamDrive",
value: values.api.isTeamDrive.toString(),
},
{
key: "api.sharedDrive",
value: values.api.sharedDrive
? await encryptionService.encrypt(values.api.sharedDrive, values.environment.ENCRYPTION_KEY)
: "",
},
{
key: "api.itemsPerPage",
value: values.api.itemsPerPage.toString(),
},
{
key: "api.searchResult",
value: values.api.searchResult.toString(),
},
{
key: "api.specialFile.password",
value: values.api.specialFile.password,
},
{
key: "api.specialFile.readme",
value: values.api.specialFile.readme,
},
{
key: "api.specialFile.banner",
value: values.api.specialFile.banner,
},
{
key: "api.hiddenFiles",
value: `[${values.api.hiddenFiles.map((file) => `"${file}"`).join(", ")}]`,
},
{
key: "api.proxyThumbnail",
value: values.api.proxyThumbnail.toString(),
},
{
key: "api.streamMaxSize",
value: values.api.streamMaxSize.toString(),
},
{
key: "api.maxFileSize",
value: values.api.maxFileSize.toString(),
},
{
key: "api.allowDownloadProtectedFile",
value: values.api.allowDownloadProtectedFile.toString(),
},
{
key: "api.temporaryTokenDuration",
value: values.api.temporaryTokenDuration.toString(),
},
{
key: "site.siteName",
value: values.site.siteName,
},
{
key: "site.siteNameTemplate",
value: values.site.siteNameTemplate,
},
{
key: "site.siteDescription",
value: values.site.siteDescription,
},
{
key: "site.siteAuthor",
value: values.site.siteAuthor,
},
{
key: "site.robots",
value: values.site.robots,
},
{
key: "site.twitterHandle",
value: values.site.twitterHandle,
},
{
key: "site.showFileExtension",
value: values.site.showFileExtension.toString(),
},
{
key: "site.privateIndex",
value: values.site.privateIndex.toString(),
},
{
key: "site.breadcrumbMax",
value: values.site.breadcrumbMax.toString(),
},
{
key: "site.toaster.position",
value: values.site.toaster.position,
},
{
key: "site.toaster.duration",
value: values.site.toaster.duration.toString(),
},
{
key: "site.navbarItems",
value: JSON.stringify(values.site.navbarItems, null, 2),
},
{
key: "site.supports",
value: JSON.stringify(values.site.supports, null, 2),
},
{
key: "site.footer",
value: JSON.stringify(values.site.footer, null, 2),
},
];
let configuration = configurationTemplate;
for (const { key, value } of configurationMap) {
configuration = configuration.replace(`{{ ${key} }}`, value);
}
const envMap: { key: string; value: string }[] = [
{
key: "serviceAccount",
value: values.environment.GD_SERVICE_B64,
},
{
key: "key",
value: values.environment.ENCRYPTION_KEY,
},
{
key: "password",
value: values.environment.SITE_PASSWORD ?? "",
},
{
key: "domain",
value: values.environment.NEXT_PUBLIC_DOMAIN ?? "",
},
];
let env = environmentTemplate;
for (const { key, value } of envMap) {
env = env.replace(`{{ ${key} }}`, value);
}
const struct: AsyncZippable = {
".env": [
strToU8(env),
{
level: 9,
},
],
"gIndex.config.ts": [
strToU8(configuration),
{
level: 9,
},
],
};
const zip = zipSync(struct);
return {
success: true,
message: "Configuration generated",
data: {
configuration,
env,
zip: new Blob([zip], { type: "application/zip" }),
},
};
}
+3 -3
View File
@@ -11,7 +11,7 @@ import { Switch } from "~/components/ui/switch";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { FormColumn, FormProps, FormSection } from "./ConfiguratorPage"; import { FormColumn, type FormProps, FormSection } from "./ConfiguratorPage";
export default function ApiForm({ form, onResetField }: FormProps) { export default function ApiForm({ form, onResetField }: FormProps) {
const [inputHiddenFile, setInputHiddenFile] = useState<string>(""); const [inputHiddenFile, setInputHiddenFile] = useState<string>("");
@@ -181,7 +181,7 @@ export default function ApiForm({ form, onResetField }: FormProps) {
<FormField <FormField
control={form.control} control={form.control}
name='api.sharedDrive' name='api.sharedDrive'
render={({ field, fieldState, formState }) => ( render={({ field, fieldState }) => (
<FormItem> <FormItem>
<div className='inline-flex w-full items-center justify-between gap-4 tablet:justify-start'> <div className='inline-flex w-full items-center justify-between gap-4 tablet:justify-start'>
<FormLabel <FormLabel
@@ -341,7 +341,7 @@ export default function ApiForm({ form, onResetField }: FormProps) {
<FormField <FormField
control={form.control} control={form.control}
name='api.hiddenFiles' name='api.hiddenFiles'
render={({ field, fieldState }) => { render={({ fieldState }) => {
const watch = form.watch("api.hiddenFiles"); const watch = form.watch("api.hiddenFiles");
return ( return (
+11 -8
View File
@@ -4,7 +4,7 @@ import { useFieldArray } from "react-hook-form";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { type z } from "zod";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { VirtualizedCombobox } from "~/components/ui/combobox.virtualized"; import { VirtualizedCombobox } from "~/components/ui/combobox.virtualized";
@@ -31,9 +31,9 @@ import { Textarea } from "~/components/ui/textarea";
import { useResponsive } from "~/context/responsiveContext"; import { useResponsive } from "~/context/responsiveContext";
import { cn, formatFooterContent } from "~/lib/utils"; import { cn, formatFooterContent } from "~/lib/utils";
import { Schema_App_Configuration } from "~/types/schema"; import { type Schema_App_Configuration } from "~/types/schema";
import { FormColumn, FormProps, FormSection } from "./ConfiguratorPage"; import { FormColumn, type FormProps, FormSection } from "./ConfiguratorPage";
export default function SiteForm({ form, onResetField }: FormProps) { export default function SiteForm({ form, onResetField }: FormProps) {
return ( return (
@@ -297,7 +297,7 @@ export default function SiteForm({ form, onResetField }: FormProps) {
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormDescription>Maximum number of breadcrumbs item before it's truncated.</FormDescription> <FormDescription>Maximum number of breadcrumbs item before it&apos;s truncated.</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -366,6 +366,7 @@ export default function SiteForm({ form, onResetField }: FormProps) {
/> />
<Button <Button
type='button'
className='col-span-full w-full' className='col-span-full w-full'
variant={"secondary"} variant={"secondary"}
onClick={() => { onClick={() => {
@@ -426,6 +427,7 @@ function NavbarItemsField({ form, onResetField }: FormProps) {
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'> <div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
<Label>Navbar Items</Label> <Label>Navbar Items</Label>
<Button <Button
type='button'
variant={"ghost"} variant={"ghost"}
disabled={!form.getFieldState("site.navbarItems").isDirty} disabled={!form.getFieldState("site.navbarItems").isDirty}
onClick={() => { onClick={() => {
@@ -493,7 +495,7 @@ function NavbarItemsField({ form, onResetField }: FormProps) {
)} )}
/> />
</div> </div>
<div className='inline-flex w-full flex-col gap-4 tablet:flex-row '> <div className='inline-flex w-full flex-col gap-4 tablet:flex-row tablet:items-end'>
<FormField <FormField
control={form.control} control={form.control}
name={`site.navbarItems.${index}.href`} name={`site.navbarItems.${index}.href`}
@@ -518,7 +520,6 @@ function NavbarItemsField({ form, onResetField }: FormProps) {
name={`site.navbarItems.${index}.external`} name={`site.navbarItems.${index}.external`}
render={({ field }) => ( render={({ field }) => (
<FormItem disableBorder> <FormItem disableBorder>
<FormLabel>External Link</FormLabel>
<FormControl> <FormControl>
<Button <Button
type='button' type='button'
@@ -534,7 +535,7 @@ function NavbarItemsField({ form, onResetField }: FormProps) {
field.value ? "opacity-100" : "opacity-30", field.value ? "opacity-100" : "opacity-30",
)} )}
> >
Open in new tab External Link
</Button> </Button>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -558,8 +559,8 @@ function NavbarItemsField({ form, onResetField }: FormProps) {
)} )}
<Button <Button
className='w-full'
type='button' type='button'
className='w-full'
onClick={() => append({ icon: "Link", name: "New Item", href: "/new-item", external: false })} onClick={() => append({ icon: "Link", name: "New Item", href: "/new-item", external: false })}
> >
<Icon name='Plus' /> <Icon name='Plus' />
@@ -583,6 +584,7 @@ function SupportsField({ form, onResetField }: FormProps) {
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'> <div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
<Label>Supports / Donations</Label> <Label>Supports / Donations</Label>
<Button <Button
type='button'
variant={"ghost"} variant={"ghost"}
disabled={!form.getFieldState("site.supports").isDirty} disabled={!form.getFieldState("site.supports").isDirty}
onClick={() => { onClick={() => {
@@ -746,6 +748,7 @@ function FooterField({ form, onResetField }: FormProps) {
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'> <div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
<Label>Footer Items</Label> <Label>Footer Items</Label>
<Button <Button
type='button'
variant={"ghost"} variant={"ghost"}
disabled={!form.getFieldState("site.footer").isDirty} disabled={!form.getFieldState("site.footer").isDirty}
onClick={() => { onClick={() => {
@@ -1,4 +1,4 @@
import { ChangeEvent, useRef, useState } from "react"; import { type ChangeEvent, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { LoadingButton } from "~/components/ui/button"; import { LoadingButton } from "~/components/ui/button";
@@ -7,7 +7,7 @@ import { Input } from "~/components/ui/input";
import { GenerateServiceAccountB64 } from "~/actions/configuration"; import { GenerateServiceAccountB64 } from "~/actions/configuration";
import { FormProps, FormSection } from "./ConfiguratorPage"; import { type FormProps, FormSection } from "./ConfiguratorPage";
export default function EnvironmentForm({ onResetField, form }: FormProps) { export default function EnvironmentForm({ onResetField, form }: FormProps) {
const serviceInputRef = useRef<HTMLInputElement>(null); const serviceInputRef = useRef<HTMLInputElement>(null);
@@ -86,6 +86,7 @@ export default function EnvironmentForm({ onResetField, form }: FormProps) {
loading={serviceInputLoading} loading={serviceInputLoading}
size={"sm"} size={"sm"}
onClick={() => serviceInputRef.current?.click()} onClick={() => serviceInputRef.current?.click()}
type='button'
> >
Load JSON Load JSON
</LoadingButton> </LoadingButton>
+209 -141
View File
@@ -3,105 +3,29 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link"; import Link from "next/link";
import { type PropsWithChildren, useState } from "react"; import { type PropsWithChildren, useState } from "react";
import { FieldPath, type UseFormReturn, useForm } from "react-hook-form"; import { type FieldPath, type UseFormReturn, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { type z } from "zod"; import { type z } from "zod";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button"; import { Button, LoadingButton } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
import {
ResponsiveDropdownMenu,
ResponsiveDropdownMenuContent,
ResponsiveDropdownMenuItem,
ResponsiveDropdownMenuTrigger,
} from "~/components/ui/dropdown-menu.responsive";
import { Form } from "~/components/ui/form"; import { Form } from "~/components/ui/form";
import Icon from "~/components/ui/icon";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { useResponsive } from "~/context/responsiveContext"; import { type PickFileResponse, initialConfiguration, pickFile, versionExpectMap } from "~/lib/configurationHelper";
import { type ConfigurationCategory, Schema_App_Configuration } from "~/types/schema"; import { type ConfigurationCategory, Schema_App_Configuration } from "~/types/schema";
import { GenerateConfiguration, ProcessConfiguration, ProcessEnvironmentConfig } from "~/actions/configuration";
import ApiForm from "./ConfigurationPage.Api"; import ApiForm from "./ConfigurationPage.Api";
import SiteForm from "./ConfigurationPage.Site"; import SiteForm from "./ConfigurationPage.Site";
import EnvironmentForm from "./ConfiguratorPage.Environment"; import EnvironmentForm from "./ConfiguratorPage.Environment";
const initialConfiguration: z.input<typeof Schema_App_Configuration> = {
environment: {
ENCRYPTION_KEY: "",
SITE_PASSWORD: "",
GD_SERVICE_B64: "",
NEXT_PUBLIC_DOMAIN: "",
},
api: {
cache: {
public: true,
maxAge: 60,
sMaxAge: 60,
staleWhileRevalidate: true,
},
rootFolder: "",
isTeamDrive: false,
sharedDrive: "",
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,
proxyThumbnail: true,
streamMaxSize: 100 * 1024 * 1024,
specialFile: {
password: ".password",
readme: ".readme.md",
banner: ".banner",
},
hiddenFiles: [".password", ".readme.md", ".banner", ".banner.jpg", ".banner.png", ".banner.webp"],
allowDownloadProtectedFile: false,
temporaryTokenDuration: 6,
maxFileSize: 4 * 1024 * 1024,
},
site: {
guideButton: false,
siteName: "next-gdrive-index",
siteNameTemplate: "%s - %t",
siteDescription: "A simple file browser for Google Drive",
siteIcon: "/logo.svg",
siteAuthor: "mbaharip",
favIcon: "/favicon.png",
robots: "noindex, nofollow",
twitterHandle: "@mbaharip_",
showFileExtension: true,
footer: [
{ value: "{{ poweredBy }}" },
{ value: "Made with ❤️ by [**{{ author }}**](https://github.com/mbaharip)" },
],
experimental_pageLoadTime: false,
privateIndex: false,
breadcrumbMax: 3,
toaster: {
position: "bottom-right",
duration: 3000,
},
navbarItems: [],
supports: [],
previewSettings: {
manga: {
maxSize: 15 * 1024 * 1024,
maxItem: 10,
},
},
},
};
export default function ConfiguratorPage() { export default function ConfiguratorPage() {
const [isDownloading, setIsDownloading] = useState<boolean>(false); const [isLoadingEnv, setIsLoadingEnv] = useState<boolean>(false);
const [isLoadingConfig, setIsLoadingConfig] = useState<boolean>(false);
const form = useForm<z.infer<typeof Schema_App_Configuration>>({ const form = useForm<z.infer<typeof Schema_App_Configuration>>({
resolver: zodResolver(Schema_App_Configuration), resolver: zodResolver(Schema_App_Configuration),
defaultValues: initialConfiguration, defaultValues: initialConfiguration,
@@ -113,11 +37,146 @@ export default function ConfiguratorPage() {
} else { } else {
form.resetField(category); form.resetField(category);
} }
toast.success("Form reverted to initial state");
} }
function onFormSubmit(values: z.infer<typeof Schema_App_Configuration>) { async function onFormSubmit(values: z.infer<typeof Schema_App_Configuration>) {
toast.info("Form submitted", { const id = `download-${Date.now()}`;
description: <pre className='w-full overflow-auto'>{JSON.stringify(values, null, 2)}</pre>, toast.loading("Generating configuration...", {
id,
duration: 0,
}); });
const data = await GenerateConfiguration(values);
if (!data.success) {
toast.error(data.message, {
id,
description: data.error,
});
return;
}
const url = URL.createObjectURL(data.data.zip);
const a = document.createElement("a");
a.href = url;
a.download = `${Date.now()}-gIndex.config.zip`;
a.click();
URL.revokeObjectURL(url);
a.remove();
toast.success("Configuration generated", {
id,
});
}
async function onLoadEnv(response: PickFileResponse) {
const id = `env-latest-${Date.now()}`;
toast.loading("Waiting for file...", {
id,
duration: 0,
});
if (!response.success) {
toast.error(response.message, {
id,
description: response.details.length ? (
<pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>
{response.details.join("\n")}
</pre>
) : undefined,
});
setIsLoadingEnv(false);
return;
}
toast.loading("Processing environment file...", {
id,
duration: 0,
});
const data = await ProcessEnvironmentConfig(response.data);
if (!data.success) {
toast.error(data.message, {
id,
description: <pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>{data.error}</pre>,
});
setIsLoadingEnv(false);
return;
}
form.setValue("environment", data.data);
toast.success(data.message, {
id,
});
setIsLoadingEnv(false);
}
async function onLoadConfig(response: PickFileResponse) {
const id = `config-${Date.now()}`;
toast.loading("Waiting for file...", {
id,
duration: 0,
});
if (!response.success) {
toast.error(response.message, {
id,
description: response.details.length ? (
<pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>
{response.details.join("\n")}
</pre>
) : undefined,
});
setIsLoadingConfig(false);
return;
}
const loadedVersion = /version:\s*["']?(\d+\.\d+\.\d+)["']?/.exec(response.data)?.[1];
if (!loadedVersion) {
toast.error("Version not found in configuration file", {
id,
});
setIsLoadingConfig(false);
return;
}
const versionGroup = Object.entries(versionExpectMap).find(([_, v]) => v.includes(loadedVersion))?.[0];
if (!versionGroup) {
toast.error("Version not recognized", {
id,
description: (
<pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>
{`Loaded version: ${loadedVersion}, not matching any known version`}
</pre>
),
});
setIsLoadingConfig(false);
return;
}
toast.loading(`Version ${loadedVersion} detected, processing...`, {
id,
duration: 0,
});
const data = await ProcessConfiguration(response.data, versionGroup as "v1" | "v2" | "latest");
if (!data.success) {
toast.error(data.message, {
id,
description: <pre className='w-full overflow-auto whitespace-pre-wrap font-mono text-xs'>{data.error}</pre>,
});
setIsLoadingConfig(false);
return;
}
form.setValue("api", data.data.api);
form.setValue("site", data.data.site);
form.setValue("site.navbarItems", data.data.site.navbarItems);
form.setValue("site.supports", data.data.site.supports);
form.setValue("site.footer", data.data.site.footer);
toast.success(data.message, {
id,
});
setIsLoadingConfig(false);
} }
return ( return (
@@ -130,7 +189,7 @@ export default function ConfiguratorPage() {
href={"https://themes.fkaya.dev/"} href={"https://themes.fkaya.dev/"}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400' className='text-balance text-sm font-medium text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
> >
themes.fkaya.dev themes.fkaya.dev
</Link> </Link>
@@ -139,7 +198,7 @@ export default function ConfiguratorPage() {
href={"https://themeshadcn.com/"} href={"https://themeshadcn.com/"}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400' className='text-balance text-sm font-medium text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
> >
themeshadcn.com themeshadcn.com
</Link>{" "} </Link>{" "}
@@ -148,7 +207,7 @@ export default function ConfiguratorPage() {
href={"https://ui.jln.dev/"} href={"https://ui.jln.dev/"}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400' className='text-balance text-sm font-medium text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
> >
ui.jln.dev ui.jln.dev
</Link>{" "} </Link>{" "}
@@ -157,47 +216,44 @@ export default function ConfiguratorPage() {
</Alert> </Alert>
<Card> <Card>
<CardHeader> <CardHeader className='flex w-full flex-col gap-4 tablet:flex-row tablet:items-center tablet:justify-between'>
<CardTitle>Configurator</CardTitle> <div className='flex grow flex-col space-y-1.5'>
<CardDescription>Generate configurator for your index.</CardDescription> <CardTitle>Configurator</CardTitle>
</CardHeader> <CardDescription>Generate configurator for your index.</CardDescription>
<CardContent> </div>
<div className='flex w-full flex-col items-center gap-2 pb-4 tablet:flex-row-reverse'> <div className='flex flex-col items-center gap-2 tablet:flex-row-reverse'>
<ResponsiveDropdownMenu> <LoadingButton
<ResponsiveDropdownMenuTrigger asChild> loading={isLoadingConfig}
<Button className='w-full tablet:w-fit'> className='w-full tablet:w-fit'
Load Config onClick={() => {
<Icon name='ChevronsUpDown' /> setIsLoadingConfig(true);
</Button>
</ResponsiveDropdownMenuTrigger> pickFile({
<ResponsiveDropdownMenuContent accept: ".ts",
header={{ async onLoad(response) {
title: "Load Config", await onLoadConfig(response);
description: "Load configuration from existing file", },
}} });
> }}
<ResponsiveDropdownMenuItem closeOnSelect>v2.4 / latest</ResponsiveDropdownMenuItem> >
<ResponsiveDropdownMenuItem closeOnSelect>v2.3 / below</ResponsiveDropdownMenuItem> Load Config
<ResponsiveDropdownMenuItem closeOnSelect>v1.x / legacy</ResponsiveDropdownMenuItem> </LoadingButton>
</ResponsiveDropdownMenuContent> <LoadingButton
</ResponsiveDropdownMenu> loading={isLoadingEnv}
<ResponsiveDropdownMenu> className='w-full tablet:w-fit'
<ResponsiveDropdownMenuTrigger asChild> onClick={() => {
<Button className='w-full tablet:w-fit'> setIsLoadingEnv(true);
Load Env
<Icon name='ChevronsUpDown' /> pickFile({
</Button> accept: ".env",
</ResponsiveDropdownMenuTrigger> async onLoad(response) {
<ResponsiveDropdownMenuContent await onLoadEnv(response);
header={{ },
title: "Load Env", });
description: "Load environment variables from existing file", }}
}} >
> Load Env
<ResponsiveDropdownMenuItem closeOnSelect>v2.x / latest</ResponsiveDropdownMenuItem> </LoadingButton>
<ResponsiveDropdownMenuItem closeOnSelect>v1.x / legacy</ResponsiveDropdownMenuItem>
</ResponsiveDropdownMenuContent>
</ResponsiveDropdownMenu>
<Button <Button
className='w-full tablet:w-fit' className='w-full tablet:w-fit'
variant={"destructive"} variant={"destructive"}
@@ -206,11 +262,14 @@ export default function ConfiguratorPage() {
Reset All Reset All
</Button> </Button>
</div> </div>
<Form {...form}> </CardHeader>
<form <Separator className='mb-6' />
onSubmit={form.handleSubmit(onFormSubmit)} <Form {...form}>
className='grid grid-cols-1 gap-8 tablet:px-2' <form
> onSubmit={form.handleSubmit(onFormSubmit)}
className='grid grid-cols-1'
>
<CardContent className='grid grid-cols-1 gap-8'>
<EnvironmentForm <EnvironmentForm
form={form} form={form}
onResetField={(field) => form.resetField(field)} onResetField={(field) => form.resetField(field)}
@@ -229,9 +288,20 @@ export default function ConfiguratorPage() {
form={form} form={form}
onResetField={(field) => form.resetField(field)} onResetField={(field) => form.resetField(field)}
/> />
</form> </CardContent>
</Form> <CardFooter>
</CardContent> <LoadingButton
size={"lg"}
loading={form.formState.isSubmitting}
disabled={!form.formState.isValid || !form.formState.isDirty}
type='submit'
className='w-full'
>
Generate Configuration
</LoadingButton>
</CardFooter>
</form>
</Form>
</Card> </Card>
</> </>
); );
@@ -262,8 +332,6 @@ type FormSectionProps = {
description: string; description: string;
}; };
export function FormSection({ title, description, children }: PropsWithChildren<FormSectionProps>) { export function FormSection({ title, description, children }: PropsWithChildren<FormSectionProps>) {
const { isDesktop } = useResponsive();
return ( return (
<div <div
id={title id={title
+441 -15
View File
@@ -1,6 +1,171 @@
import { z } from "zod"; import { type z } from "zod";
const configurationTemplate = `import { type z } from "zod"; import {
Schema_App_Configuration,
Schema_App_Configuration_Env,
Schema_Config,
Schema_v1_Config,
Schema_v2_3_Config,
} from "~/types/schema";
import config from "~/config/gIndex.config";
export const versionExpectMap: Record<"v1" | "v2" | "latest", string[]> = {
v1: ["1.0.0", "1.0.1", "1.0.2", "1.0.3"],
v2: ["2.0.0", "2.0.1", "2.0.2", "2.0.3"],
latest: ["2.0.4"],
};
export type PickFileResponse =
| {
success: true;
data: string;
}
| {
success: false;
message: string;
details: string[];
};
type PickFileProps = {
accept: string;
onLoad: (response: PickFileResponse) => void | Promise<void>;
onCancel?: () => void | Promise<void>;
fileName?: string;
};
export function pickFile({ accept, fileName, onLoad }: PickFileProps): void {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = accept;
fileInput.oncancel = async () => {
await onLoad({
success: false,
message: "File picker canceled",
details: [],
});
};
fileInput.onchange = async (fileEvent) => {
const file = (fileEvent.target as HTMLInputElement).files?.[0];
if (!file) {
await onLoad({
success: false,
message: "No file selected",
details: [],
});
return;
}
if (fileName) {
const { name } = file;
if (name.toLowerCase() !== fileName.toLowerCase()) {
await onLoad({
success: false,
message: `Invalid file name`,
details: [`Expected: ${fileName}`, `Received: ${name}`],
});
return;
}
}
const reader = new FileReader();
reader.onload = async (readerEvent) => {
const result = readerEvent.target?.result as string;
if (!result) {
await onLoad({
success: false,
message: "Failed to read file",
details: [],
});
return;
}
fileInput.value = "";
await onLoad({
success: true,
data: result,
});
};
reader.onloadend = () => {
fileInput.remove();
};
reader.readAsText(file);
};
fileInput.click();
fileInput.remove();
}
export const initialConfiguration: z.input<typeof Schema_App_Configuration> = {
version: config.version,
environment: {
ENCRYPTION_KEY: "",
SITE_PASSWORD: "",
GD_SERVICE_B64: "",
NEXT_PUBLIC_DOMAIN: "",
},
api: {
cache: {
public: true,
maxAge: 60,
sMaxAge: 60,
staleWhileRevalidate: true,
},
rootFolder: "",
isTeamDrive: false,
sharedDrive: "",
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,
proxyThumbnail: true,
streamMaxSize: 100 * 1024 * 1024,
specialFile: {
password: ".password",
readme: ".readme.md",
banner: ".banner",
},
hiddenFiles: [".password", ".readme.md", ".banner", ".banner.jpg", ".banner.png", ".banner.webp"],
allowDownloadProtectedFile: false,
temporaryTokenDuration: 6,
maxFileSize: 4 * 1024 * 1024,
},
site: {
guideButton: false,
siteName: "next-gdrive-index",
siteNameTemplate: "%s - %t",
siteDescription: "A simple file browser for Google Drive",
siteIcon: "/logo.svg",
siteAuthor: "mbaharip",
favIcon: "/favicon.png",
robots: "noindex, nofollow",
twitterHandle: "@mbaharip_",
showFileExtension: true,
footer: [
{ value: "{{ poweredBy }}" },
{ value: "Made with ❤️ by [**{{ author }}**](https://github.com/mbaharip)" },
],
experimental_pageLoadTime: false,
privateIndex: false,
breadcrumbMax: 3,
toaster: {
position: "bottom-right",
duration: 3000,
},
navbarItems: [],
supports: [],
previewSettings: {
manga: {
maxSize: 15 * 1024 * 1024,
maxItem: 10,
},
},
},
};
export const configurationTemplate = `import { type z } from "zod";
import { BASE_URL } from "~/constant"; import { BASE_URL } from "~/constant";
import { type Schema_Config } from "~/types/schema"; import { type Schema_Config } from "~/types/schema";
@@ -293,7 +458,7 @@ const config: z.input<typeof Schema_Config> = {
export default config; export default config;
`; `;
const environmentTemplate = `# Base64 Encoded Service Account JSON export const environmentTemplate = `# Base64 Encoded Service Account JSON
GD_SERVICE_B64={{ serviceAccount }} GD_SERVICE_B64={{ serviceAccount }}
# Secret Key for Encryption # Secret Key for Encryption
ENCRYPTION_KEY={{ key }} ENCRYPTION_KEY={{ key }}
@@ -304,18 +469,279 @@ SITE_PASSWORD={{ password }}
# Needed if you're not using Vercel # Needed if you're not using Vercel
NEXT_PUBLIC_DOMAIN={{ domain }}`; NEXT_PUBLIC_DOMAIN={{ domain }}`;
const version1Schema = z.object({}); const version1Schema = Schema_v1_Config;
const version2Schema = z.object({}); const version2Schema = Schema_v2_3_Config;
const newVersion2Schema = z.object({}); const newVersion2Schema = Schema_Config;
const environmentSchema = z.object({ const formSchema = Schema_App_Configuration.omit({ environment: true });
GD_SERVICE_B64: z.string(), const latestEnvironmentSchema = Schema_App_Configuration_Env;
ENCRYPTION_KEY: z.string(),
SITE_PASSWORD: z.string().optional(),
NEXT_PUBLIC_DOMAIN: z.string().optional(),
});
export function parseVersion1Config(configuration: string) {} // eslint-disable-next-line @typescript-eslint/no-explicit-any
type ConfigurationResponse<T extends z.ZodObject<any>> =
| z.infer<T>
| {
message: string;
details: string[];
};
export function parseVersion2Config(configuration: string) {} export function parseVersion1Config(configuration: string) {
const config = (configuration.split(/const config:\s.*?=\s/g)[1]?.split("export default config;")[0] ?? "")
export function parseEnvironmentConfig(configuration: string) {} .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, (str) => {
if (!str) return "maxFileSize: 4194304,"; // Set maxFileSize to 4MB
const value = str?.split(":")[1]?.split(",")[0]?.trim() ?? "";
const numbers = value.split("*").map((v) => Number(v ?? "1"));
return `maxFileSize: ${numbers.reduce((a, b) => a * b, 1)}`;
})
.replace(/([a-zA-Z]*?):\s/g, '"$1": ') // Add double quotes to keys
.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
.slice(0, -1);
const json = JSON.parse(config) as object;
const parsedJson = version1Schema.safeParse(json);
if (!parsedJson.success) {
return {
message: "Failed to match the version 1 schema",
details: parsedJson.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`),
};
}
const { data } = parsedJson;
const migratedData: z.input<typeof formSchema> = {
version: data.version,
api: {
...initialConfiguration.api,
cache: {
public: data.cacheControl.includes("public"),
maxAge: Number(/max-age=(\d+)/.exec(data.cacheControl)?.[1] ?? 60),
sMaxAge: Number(/s-maxage=(\d+)/.exec(data.cacheControl)?.[1] ?? 60),
staleWhileRevalidate: data.cacheControl.includes("stale-while-revalidate"),
},
rootFolder: data.apiConfig.rootFolder,
isTeamDrive: false,
sharedDrive: "",
itemsPerPage: data.apiConfig.itemsPerPage,
searchResult: data.apiConfig.searchResult,
specialFile: {
password: data.apiConfig.specialFile.password,
readme: data.apiConfig.specialFile.readme,
banner: data.apiConfig.specialFile.banner,
},
hiddenFiles: data.apiConfig.hiddenFiles,
allowDownloadProtectedFile: data.apiConfig.allowDownloadProtectedFile,
temporaryTokenDuration: data.apiConfig.temporaryTokenDuration,
maxFileSize: data.apiConfig.maxFileSize,
},
site: {
...initialConfiguration.site,
guideButton: false,
siteName: data.siteConfig.siteName,
siteDescription: data.siteConfig.siteDescription,
twitterHandle: data.siteConfig.twitterHandle ?? initialConfiguration.site.twitterHandle ?? "@__mbaharip__",
privateIndex: data.siteConfig.privateIndex,
navbarItems: data.siteConfig.navbarItems.map((item) => ({
icon: "File",
name: item.name,
href: item.href,
external: item.external ?? false,
})),
},
};
const parsedData = formSchema.safeParse(migratedData);
if (!parsedData.success) {
return {
message: "Failed to migrate the old configuration to the new schema",
details: parsedData.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`),
};
}
return parsedData.data;
}
export function parseVersion2Config(configuration: string) {
const isLatest = configuration.includes('version: "2.0.4"');
const config = (configuration.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(/streamMaxSize:(.*?),/g, (str) => {
if (!str) return "streamMaxSize: 104857600,"; // Set streamMaxSize to 100MB
const value = str?.split(":")[1]?.split(",")[0]?.trim() ?? "";
const numbers = value.split("*").map((v) => Number(v ?? "1"));
return `streamMaxSize: ${numbers.reduce((a, b) => a * b, 1)},`;
})
.replace(/temporaryTokenDuration:(.*?),/g, (str) => {
if (!str) return "temporaryTokenDuration: 6,"; // Set temporaryTokenDuration to 6 hours
const value = str?.split(":")[1]?.split(",")[0]?.trim() ?? "";
if (value.includes("/")) {
const numbers = value.split("/").map((v) => Number(v ?? "1"));
return `temporaryTokenDuration: ${numbers.reduce((a, b) => a / b, 1)},`;
} else {
return `temporaryTokenDuration: ${value},`;
}
})
.replace(/maxFileSize:(.*?),/g, (str) => {
if (!str) return "maxFileSize: 4194304,"; // Set maxFileSize to 4MB
const value = str?.split(":")[1]?.split(",")[0]?.trim() ?? "";
const numbers = value.split("*").map((v) => Number(v ?? "1"));
return `maxFileSize: ${numbers.reduce((a, b) => a * b, 1)},`;
})
.replace(/maxSize:(.*?),/g, (str) => {
if (!str) return "maxSize: 15728640,"; // Set maxSize to 15MB
const value = str?.split(":")[1]?.split(",")[0]?.trim() ?? "";
const numbers = value.split("*").map((v) => Number(v ?? "1"));
return `maxSize: ${numbers.reduce((a, b) => a * b, 1)},`;
})
.replace(/([a-zA-Z_]*?):\s/g, '"$1": ') // Add double quotes to keys
.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
.slice(0, -1);
const json = JSON.parse(config) as object;
if (isLatest) {
const parsedJson = newVersion2Schema.safeParse(json);
if (!parsedJson.success) {
return {
message: `Failed to match the schema for ${isLatest ? "latest" : "version 2.3 / below"} configuration`,
details: parsedJson.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`),
};
}
const { data } = parsedJson;
const migratedData: z.input<typeof formSchema> = {
version: data.version,
api: {
...initialConfiguration.api,
...data.apiConfig,
rootFolder: "",
isTeamDrive: false,
sharedDrive: "",
cache: {
public: data.cacheControl.includes("public"),
maxAge: Number(/max-age=(\d+)/.exec(data.cacheControl)?.[1] ?? 60),
sMaxAge: Number(/s-maxage=(\d+)/.exec(data.cacheControl)?.[1] ?? 60),
staleWhileRevalidate: data.cacheControl.includes("stale-while-revalidate"),
},
},
site: {
...initialConfiguration.site,
...data.siteConfig,
guideButton: false,
},
};
const parsedData = formSchema.safeParse(migratedData);
if (!parsedData.success) {
return {
message: "Failed to migrate the old configuration to the new schema",
details: parsedData.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`),
};
}
return parsedData.data;
} else {
const parsedJson = version2Schema.safeParse(json);
if (!parsedJson.success) {
return {
message: `Failed to match the schema for ${isLatest ? "latest" : "version 2.3 / below"} configuration`,
details: parsedJson.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`),
};
}
const { data } = parsedJson;
const migratedData: z.input<typeof formSchema> = {
version: data.version,
api: {
...initialConfiguration.api,
rootFolder: "",
isTeamDrive: false,
sharedDrive: "",
cache: {
public: data.cacheControl.includes("public"),
maxAge: Number(/max-age=(\d+)/.exec(data.cacheControl)?.[1] ?? 60),
sMaxAge: Number(/s-maxage=(\d+)/.exec(data.cacheControl)?.[1] ?? 60),
staleWhileRevalidate: data.cacheControl.includes("stale-while-revalidate"),
},
},
site: {
...initialConfiguration.site,
...data.siteConfig,
footer: (data.siteConfig.footer ?? []).map((item) => ({ value: item })),
guideButton: false,
},
};
const parsedData = formSchema.safeParse(migratedData);
if (!parsedData.success) {
return {
message: "Failed to migrate the old configuration to the new schema",
details: parsedData.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`),
};
}
return parsedData.data;
}
}
export function parseEnvironment(configuration: string): ConfigurationResponse<typeof latestEnvironmentSchema> {
const lines = configuration.split("\n");
const result: Record<string, string> = {};
const availableKeys = [
"GD_SERVICE_B64",
"ENCRYPTION_KEY",
"SITE_PASSWORD",
"NEXT_PUBLIC_DOMAIN",
"NEXT_PUBLIC_VERCEL_URL",
"NEXT_PUBLIC_ENCRYPTION_KEY",
"NEXT_PUBLIC_SITE_PASSWORD",
];
for (const line of lines) {
if (!availableKeys.some((key) => line.startsWith(key))) {
continue;
}
const [key, value] = line.split("=");
if (!key || !value) continue;
const formattedValue = value.replace(/"/g, "").trim();
const formattedKey = ["NEXT_PUBLIC_ENCRYPTION_KEY", "NEXT_PUBLIC_SITE_PASSWORD"].includes(key)
? key.replace("NEXT_PUBLIC_", "")
: key;
result[formattedKey] = formattedValue;
}
const validated = latestEnvironmentSchema.safeParse(result);
if (!validated.success)
return {
message: "Failed to match the latest environment schema",
details: validated.error.errors.map((error) => `[${error.path.join(".")}] ${error.message}`),
};
return validated.data;
}
+19 -10
View File
@@ -64,11 +64,17 @@ export const base64Encode = (text: string, type: B64Type = "url") => {
if (type === "standard") return encodeBase64(data); if (type === "standard") return encodeBase64(data);
return encodeBase64url(data); return encodeBase64url(data);
}; };
export const base64Decode = <T = unknown>(encoded: string, type: B64Type = "url"): T => { export const base64Decode = <T = unknown>(encoded: string, type: B64Type = "url"): T | null => {
let decoded: Uint8Array<ArrayBufferLike>; try {
if (type === "standard") decoded = decodeBase64(encoded); let decoded: Uint8Array<ArrayBufferLike>;
else decoded = decodeBase64url(encoded); if (type === "standard") decoded = decodeBase64(encoded);
return new TextDecoder().decode(decoded) as T; else decoded = decodeBase64url(encoded);
return new TextDecoder().decode(decoded) as T;
} catch (error) {
const e = error as Error;
console.error(`[base64Decode] ${e.message}`);
return null;
}
}; };
type ServiceAccount = { type ServiceAccount = {
@@ -90,15 +96,18 @@ class GoogleDriveService {
public gdriveNoCache: drive_v3.Drive; public gdriveNoCache: drive_v3.Drive;
constructor() { constructor() {
const decodedBase64 = JSON.parse(base64Decode<string>(process.env.GD_SERVICE_B64!)) as ServiceAccount; const decodedB64 = base64Decode<string>(process.env.GD_SERVICE_B64!);
if (!decodedB64) throw new Error("Failed to decode GD_SERVICE_B64");
const parsedAuth = JSON.parse(decodedB64) as ServiceAccount;
this.auth = new google.auth.GoogleAuth({ this.auth = new google.auth.GoogleAuth({
credentials: { credentials: {
type: "service_account", type: "service_account",
private_key: decodedBase64.private_key, private_key: parsedAuth.private_key,
client_email: decodedBase64.client_email, client_email: parsedAuth.client_email,
client_id: decodedBase64.client_id, client_id: parsedAuth.client_id,
}, },
projectId: decodedBase64.project_id, projectId: parsedAuth.project_id,
scopes: ["https://www.googleapis.com/auth/drive"], scopes: ["https://www.googleapis.com/auth/drive"],
}); });
if (!this.auth) throw new Error("Failed to initialize Google Auth"); if (!this.auth) throw new Error("Failed to initialize Google Auth");
+69 -11
View File
@@ -40,7 +40,7 @@ export const Schema_File = z.object({
.nullable(), .nullable(),
}); });
export const Schema_Old_Config = z.object({ export const Schema_v1_Config = z.object({
version: z.literal("1.0.0"), version: z.literal("1.0.0"),
basePath: z.string(), basePath: z.string(),
masterKey: z.string(), masterKey: z.string(),
@@ -54,7 +54,7 @@ export const Schema_Old_Config = z.object({
defaultField: z.string(), defaultField: z.string(),
defaultOrder: z.string(), defaultOrder: z.string(),
itemsPerPage: z.number().positive(), itemsPerPage: z.number().positive(),
searchRsult: z.number().positive(), searchResult: z.number().positive(),
specialFile: z.object({ specialFile: z.object({
password: z.string(), password: z.string(),
@@ -116,10 +116,56 @@ export const Schema_Config_API = z
}) })
.refine( .refine(
(data) => { (data) => {
if (data.isTeamDrive && !data.sharedDrive) return false; if (data.isTeamDrive === true && !data.sharedDrive) return false;
return true;
}, },
{ message: "sharedDrive is required when isTeamDrive is true" }, { message: "sharedDrive is required when isTeamDrive is true" },
); );
export const Schema_v2_3_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__"),
showFileExtension: z.boolean().optional().default(false),
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,
}),
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_Config_Site = z.object({ export const Schema_Config_Site = z.object({
siteName: z.string(), siteName: z.string(),
siteNameTemplate: z.string().optional().default("%s"), siteNameTemplate: z.string().optional().default("%s"),
@@ -181,13 +227,17 @@ export const Schema_Config_Site = z.object({
}), }),
}), }),
}); });
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_v2_3_Config = z.object({
version: z.string(),
basePath: z.string(),
cacheControl: z.string(),
showDeployGuide: z.boolean(),
apiConfig: Schema_Config_API,
siteConfig: Schema_v2_3_Config_Site,
});
export const Schema_Config = z.object({ export const Schema_Config = z.object({
version: z.string(), version: z.string(),
basePath: z.string(), basePath: z.string(),
@@ -199,6 +249,13 @@ export const Schema_Config = z.object({
siteConfig: Schema_Config_Site, siteConfig: Schema_Config_Site,
}); });
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_ServiceAccount = z.object({ export const Schema_ServiceAccount = z.object({
type: z.literal("service_account"), type: z.literal("service_account"),
project_id: z.string(), project_id: z.string(),
@@ -214,13 +271,14 @@ export const Schema_ServiceAccount = z.object({
}); });
export const Schema_App_Configuration = z.object({ export const Schema_App_Configuration = z.object({
version: z.string(),
environment: Schema_App_Configuration_Env, environment: Schema_App_Configuration_Env,
api: Schema_Config_API.and( api: Schema_Config_API.and(
z.object({ z.object({
cache: z.object({ cache: z.object({
public: z.coerce.boolean(), public: z.coerce.boolean(),
maxAge: z.coerce.number().positive(), maxAge: z.coerce.number().min(0),
sMaxAge: z.coerce.number().positive(), sMaxAge: z.coerce.number().min(0),
staleWhileRevalidate: z.coerce.boolean(), staleWhileRevalidate: z.coerce.boolean(),
}), }),
}), }),