wip: Internal page (guide, configurator, embed)

This commit is contained in:
mbaharip
2025-01-21 21:56:45 +07:00
parent 287cb9b903
commit e9db9e4e06
8 changed files with 3690 additions and 105 deletions
+666
View File
@@ -0,0 +1,666 @@
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import Icon from "~/components/ui/icon";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Switch } from "~/components/ui/switch";
import { cn } from "~/lib/utils";
import { FormColumn, FormProps, FormSection } from "./ConfiguratorPage";
export default function ApiForm({ form, onResetField }: FormProps) {
const [inputHiddenFile, setInputHiddenFile] = useState<string>("");
function onHiddenFileSubmit() {
if (!inputHiddenFile) {
toast.error("File name cannot be empty");
setInputHiddenFile("");
return;
}
const prevValue = form.watch("api.hiddenFiles");
const value = inputHiddenFile.trim();
if (prevValue.some((v) => v.trim() === value)) {
toast.error("File name already exists in the hidden files list");
setInputHiddenFile("");
return;
}
form.setValue("api.hiddenFiles", [...form.watch("api.hiddenFiles"), inputHiddenFile], {
shouldDirty: true,
});
setInputHiddenFile("");
}
return (
<FormSection
title='API Configuration'
description='Configure how your index backend works'
>
<FormColumn>
<FormField
control={form.control}
name='api.cache.public'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.cache.public");
}}
>
Public Cache
</FormLabel>
<FormControl>
<Select
value={field.value ? "true" : "false"}
onValueChange={(value) => field.onChange(value === "true")}
>
<SelectTrigger>
<SelectValue placeholder='Select option' />
</SelectTrigger>
<SelectContent>
<SelectItem value='true'>Enable (Recommended)</SelectItem>
<SelectItem value='false'>Disable</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Enable public cache for the index.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api.cache.staleWhileRevalidate'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.cache.staleWhileRevalidate");
}}
>
Stale While Revalidate
</FormLabel>
<FormControl>
<Select
value={field.value ? "true" : "false"}
onValueChange={(value) => field.onChange(value === "true")}
>
<SelectTrigger>
<SelectValue placeholder='Select option' />
</SelectTrigger>
<SelectContent>
<SelectItem value='true'>Enable (Recommended)</SelectItem>
<SelectItem value='false'>Disable</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Return stale data before requesting fresh data.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api.cache.maxAge'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.cache.maxAge");
}}
>
Max Age
</FormLabel>
<FormControl>
<Input
type='number'
{...field}
/>
</FormControl>
<FormDescription>How long should the cache in browser last.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api.cache.sMaxAge'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.cache.sMaxAge");
}}
>
Shared Max Age
</FormLabel>
<FormControl>
<Input
type='number'
{...field}
/>
</FormControl>
<FormDescription>How long should the cache in server last.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</FormColumn>
<FormField
control={form.control}
name='api.rootFolder'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.rootFolder");
}}
>
Root Folder ID
</FormLabel>
<FormControl>
<Input
placeholder='Unencrypted folder ID'
{...field}
/>
</FormControl>
<FormDescription>Google Drive folder ID to be used as the root folder.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api.sharedDrive'
render={({ field, fieldState, formState }) => (
<FormItem>
<div className='inline-flex w-full items-center justify-between gap-4 tablet:justify-start'>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.sharedDrive");
form.setValue("api.isTeamDrive", false);
}}
>
Shared Drive ID
</FormLabel>
<Switch
checked={form.getValues("api.isTeamDrive")}
onCheckedChange={(value) => {
form.setValue("api.isTeamDrive", value);
}}
/>
</div>
<FormControl>
<Input
placeholder={
form.watch("api.isTeamDrive") ? "Unencrypted shared drive ID" : "Switch to use shared drive"
}
disabled={!form.watch("api.isTeamDrive")}
{...field}
/>
</FormControl>
<FormDescription>Google Drive Shared Drive ID.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormColumn>
<FormField
control={form.control}
name='api.itemsPerPage'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.itemsPerPage");
}}
>
Items Per Page
</FormLabel>
<FormControl>
<Input
type='number'
{...field}
/>
</FormControl>
<FormDescription>Number of items to show before pagination.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api.searchResult'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.searchResult");
}}
>
Search Result Limit
</FormLabel>
<FormControl>
<Input
type='number'
{...field}
/>
</FormControl>
<FormDescription>Number of search results to show.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</FormColumn>
<FormColumn column={3}>
<FormField
control={form.control}
name='api.specialFile.banner'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.specialFile.banner");
}}
>
Banner File
</FormLabel>
<FormControl>
<Input
placeholder='.banner.jpg'
{...field}
/>
</FormControl>
<FormDescription>Will be used as the banner if found in the folder.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api.specialFile.password'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.specialFile.password");
}}
>
Password File
</FormLabel>
<FormControl>
<Input
placeholder='.password'
{...field}
/>
</FormControl>
<FormDescription>Will be used to protect the folder.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api.specialFile.readme'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.specialFile.readme");
}}
>
Readme File
</FormLabel>
<FormControl>
<Input
placeholder='.readme.md'
{...field}
/>
</FormControl>
<FormDescription>Will be used as the description if found in the folder.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</FormColumn>
<FormField
control={form.control}
name='api.hiddenFiles'
render={({ field, fieldState }) => {
const watch = form.watch("api.hiddenFiles");
return (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.hiddenFiles");
}}
>
Hidden Files
</FormLabel>
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-2'>
{watch.length ? (
<>
<div className='flex grow flex-wrap gap-1'>
{watch.map((name, index) => (
<Badge
key={`name-${index}`}
className='cursor-pointer'
variant={"secondary"}
onClick={() => {
form.setValue(
"api.hiddenFiles",
watch.filter((_, i) => i !== index),
{
shouldDirty: true,
},
);
}}
>
{name}
</Badge>
))}
<span className='text-[0.8rem] text-muted-foreground'>Click to remove hidden file</span>
</div>
</>
) : (
<span className='text-[0.8rem] text-destructive'>
All files including special files are visible.
</span>
)}
</div>
<div className='flex flex-col items-center gap-2 tablet:flex-row'>
<Input
placeholder='Press enter to add to hidden files'
value={inputHiddenFile}
onChange={(e) => setInputHiddenFile(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
onHiddenFileSubmit();
}
}}
/>
<Button
size='sm'
type='button'
className='w-full tablet:w-fit'
onClick={onHiddenFileSubmit}
>
<Icon name='Plus' />
Add
</Button>
</div>
<div className='flex flex-wrap items-center gap-2'>
<Badge
variant={watch.includes(form.watch("api.specialFile.banner")) ? "outline" : "destructive"}
className={cn(
"inline-flex gap-1",
watch.includes(form.watch("api.specialFile.banner")) ? "cursor-not-allowed" : "cursor-pointer",
)}
onClick={() => {
if (watch.includes(form.watch("api.specialFile.banner"))) {
return;
}
form.setValue("api.hiddenFiles", [...watch, form.watch("api.specialFile.banner")]);
}}
>
<Icon
name={watch.includes(form.watch("api.specialFile.banner")) ? "Check" : "X"}
className={
watch.includes(form.watch("api.specialFile.banner"))
? "stroke-green-600 dark:stroke-green-400"
: "stroke-destructive-foreground"
}
/>
{watch.includes(form.watch("api.specialFile.banner"))
? "Banner file is hidden"
: "Add banner file to the list"}
</Badge>
<Badge
variant={watch.includes(form.watch("api.specialFile.password")) ? "outline" : "destructive"}
className={cn(
"inline-flex gap-1",
watch.includes(form.watch("api.specialFile.password")) ? "cursor-not-allowed" : "cursor-pointer",
)}
onClick={() => {
if (watch.includes(form.watch("api.specialFile.password"))) {
return;
}
form.setValue("api.hiddenFiles", [...watch, form.watch("api.specialFile.password")]);
}}
>
<Icon
name={watch.includes(form.watch("api.specialFile.password")) ? "Check" : "X"}
className={
watch.includes(form.watch("api.specialFile.password"))
? "stroke-green-600 dark:stroke-green-400"
: "stroke-destructive-foreground"
}
/>
{watch.includes(form.watch("api.specialFile.password"))
? "Password file is hidden"
: "Add password file to the list"}
</Badge>
<Badge
variant={watch.includes(form.watch("api.specialFile.readme")) ? "outline" : "destructive"}
className={cn(
"inline-flex gap-1",
watch.includes(form.watch("api.specialFile.readme")) ? "cursor-not-allowed" : "cursor-pointer",
)}
onClick={() => {
if (watch.includes(form.watch("api.specialFile.readme"))) {
return;
}
form.setValue("api.hiddenFiles", [...watch, form.watch("api.specialFile.readme")]);
}}
>
<Icon
name={watch.includes(form.watch("api.specialFile.readme")) ? "Check" : "X"}
className={
watch.includes(form.watch("api.specialFile.readme"))
? "stroke-green-600 dark:stroke-green-400"
: "stroke-destructive-foreground"
}
/>
{watch.includes(form.watch("api.specialFile.readme"))
? "Readme file is hidden"
: "Add readme file to the list"}
</Badge>
</div>
</div>
<FormDescription>Click the badge to add special files to the hidden files list.</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name='api.proxyThumbnail'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.proxyThumbnail");
}}
>
Proxy Thumbnail
</FormLabel>
<FormControl>
<Select
value={field.value ? "true" : "false"}
onValueChange={(value) => field.onChange(value === "true")}
>
<SelectTrigger>
<SelectValue placeholder='Select option' />
</SelectTrigger>
<SelectContent>
<SelectItem value='true'>Proxy Thumbnail (Recommended)</SelectItem>
<SelectItem value='false'>Use GDrive Thumbnail</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Serve thumbnail through API route to avoid CORS issue.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormColumn>
<FormField
control={form.control}
name='api.streamMaxSize'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.streamMaxSize");
}}
>
Preview Max Size
</FormLabel>
<FormControl>
<Input
type='number'
{...field}
min={0}
value={field.value / (1024 * 1024)}
onChange={(e) => {
const value = Number(e.target.value ?? "0");
form.setValue("api.streamMaxSize", value * 1024 * 1024, {
shouldDirty: true,
});
}}
/>
</FormControl>
<FormDescription>
Maximum file size to be previewed in the browser in MB. Larger file won&apos;t be previewed.
<br />
<span className='text-destructive'>Will count towards the deployment bandwidth usage.</span>{" "}
<b>Set to 0 to disable the limit</b>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api.maxFileSize'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.maxFileSize");
}}
>
Max File Size
</FormLabel>
<FormControl>
<Input
type='number'
{...field}
min={0}
value={field.value / (1024 * 1024)}
onChange={(e) => {
const value = Number(e.target.value ?? "0");
form.setValue("api.maxFileSize", value * 1024 * 1024, {
shouldDirty: true,
});
}}
/>
</FormControl>
<FormDescription>
Maximum file size that can be downloaded via API route. Larger file will be using GDrive link.
<br />
<span className='text-destructive'>Will count towards the deployment bandwidth usage.</span>{" "}
<b>Set to 0 to disable the limit</b>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api.allowDownloadProtectedFile'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.allowDownloadProtectedFile");
}}
>
Download Protected File
</FormLabel>
<FormControl>
<Select
value={field.value ? "true" : "false"}
onValueChange={(value) => field.onChange(value === "true")}
>
<SelectTrigger>
<SelectValue placeholder='Select option' />
</SelectTrigger>
<SelectContent>
<SelectItem value='true'>Allow Download</SelectItem>
<SelectItem value='false'>Disallow Download (Recommended)</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Allow download for file inside protected folder.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='api.temporaryTokenDuration'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("api.temporaryTokenDuration");
}}
>
Temporary Token Duration
</FormLabel>
<FormControl>
<Input
type='number'
{...field}
/>
</FormControl>
<FormDescription>
Duration for temporary token in hours. Token will be used for protected folder download.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</FormColumn>
</FormSection>
);
}
@@ -0,0 +1,912 @@
import Link from "next/link";
import { useMemo, useState } from "react";
import { useFieldArray } from "react-hook-form";
import ReactMarkdown from "react-markdown";
import remarkBreaks from "remark-breaks";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import { VirtualizedCombobox } from "~/components/ui/combobox.virtualized";
import {
ResponsiveDialog,
ResponsiveDialogBody,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
} from "~/components/ui/dialog.responsive";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import Icon, { IconNamesArray } from "~/components/ui/icon";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea";
import { useResponsive } from "~/context/responsiveContext";
import { cn, formatFooterContent } from "~/lib/utils";
import { Schema_App_Configuration } from "~/types/schema";
import { FormColumn, FormProps, FormSection } from "./ConfiguratorPage";
export default function SiteForm({ form, onResetField }: FormProps) {
return (
<FormSection
title='Site Configuration'
description='Configure how your site looks and behaves'
>
<FormField
control={form.control}
name='site.privateIndex'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.privateIndex");
}}
>
Private Index
</FormLabel>
<FormControl>
<Select
value={field.value ? "true" : "false"}
onValueChange={(value) => {
field.onChange(value === "true");
}}
>
<SelectTrigger>
<SelectValue placeholder='Select option' />
</SelectTrigger>
<SelectContent>
<SelectItem value='true'>Enable</SelectItem>
<SelectItem value='false'>Disable</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Enable to require a password to access the site.{" "}
<b>Make sure to set a password in the environment section.</b>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='site.guideButton'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.guideButton");
}}
>
Internal Menu
</FormLabel>
<FormControl>
<Select
value={field.value ? "true" : "false"}
onValueChange={(value) => {
field.onChange(value === "true");
}}
>
<SelectTrigger>
<SelectValue placeholder='Select option' />
</SelectTrigger>
<SelectContent>
<SelectItem value='true'>Show</SelectItem>
<SelectItem value='false'>Hide (Recommended)</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Show internal menu on the navbar.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormColumn>
<FormField
control={form.control}
name='site.siteName'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.siteName");
}}
>
Site Name
</FormLabel>
<FormControl>
<Input
placeholder='My Awesome Index'
{...field}
/>
</FormControl>
<FormDescription>Site name for meta and navbar.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='site.siteNameTemplate'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.siteNameTemplate");
}}
>
Site Name Template
</FormLabel>
<FormControl>
<Input
placeholder='%s - %t'
{...field}
/>
</FormControl>
<FormDescription>
Template for the site name. <code className='font-semibold'>%t</code> for site name and{" "}
<code className='font-semibold'>%s</code> for page title.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='col-span-full rounded-lg border p-4 shadow'>
<span className='w-full text-center text-base font-semibold'>
{(form.watch("site.siteNameTemplate") ?? "No template")
.replace("%t", form.watch("site.siteName") ?? "next-gdrive-index")
.replace("%s", "Page Title Goes Here")}
</span>
</div>
</FormColumn>
<FormField
control={form.control}
name='site.siteDescription'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.siteDescription");
}}
>
Site Description
</FormLabel>
<FormControl>
<Textarea
placeholder='A simple file browser for Google Drive with awesome features'
{...field}
/>
</FormControl>
<FormDescription>Site description to be displayed on the metadata.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormColumn>
<FormField
control={form.control}
name='site.siteAuthor'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.siteAuthor");
}}
>
Site Author
</FormLabel>
<FormControl>
<Input
placeholder='mbaharip'
{...field}
/>
</FormControl>
<FormDescription>Site author to be displayed on the metadata, and used for the footer.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='site.twitterHandle'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.twitterHandle");
}}
>
X (Twitter) Handle
</FormLabel>
<FormControl>
<Input
placeholder='mbaharip'
{...field}
/>
</FormControl>
<FormDescription>X (Twitter) handle to be used on the metadata, and for the footer.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</FormColumn>
<FormField
control={form.control}
name='site.robots'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.robots");
}}
>
Robots Meta
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Robots meta tag for search engine.{" "}
<Link
href={"https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag#directives"}
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 dark:text-blue-400'
>
Learn more
</Link>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='site.breadcrumbMax'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.breadcrumbMax");
}}
>
Max Breadcrumbs Item
</FormLabel>
<FormControl>
<Input
type='number'
{...field}
/>
</FormControl>
<FormDescription>Maximum number of breadcrumbs item before it's truncated.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormColumn>
<FormField
control={form.control}
name='site.toaster.duration'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.toaster.duration");
}}
>
Toaster Duration
</FormLabel>
<FormControl>
<Input
type='number'
{...field}
/>
</FormControl>
<FormDescription>Duration in milliseconds for the toaster to be displayed.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='site.toaster.position'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
className='grow tablet:grow-0'
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("site.toaster.position");
}}
>
Toaster Position
</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={(value) => {
field.onChange(value);
}}
>
<SelectTrigger>
<SelectValue placeholder='Select option' />
</SelectTrigger>
<SelectContent>
<SelectItem value='top-right'>Top Right</SelectItem>
<SelectItem value='top-left'>Top Left</SelectItem>
<SelectItem value='bottom-right'>Bottom Right</SelectItem>
<SelectItem value='bottom-left'>Bottom Left</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Position of the toaster.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
className='col-span-full w-full'
variant={"secondary"}
onClick={() => {
toast.info("This is a test toaster", {
position: form.watch("site.toaster.position"),
duration: form.watch("site.toaster.duration"),
});
}}
>
<Icon name='Megaphone' />
Test Toaster
</Button>
</FormColumn>
<NavbarItemsField
form={form}
onResetField={onResetField}
/>
<SupportsField
form={form}
onResetField={onResetField}
/>
<FooterField
form={form}
onResetField={onResetField}
/>
</FormSection>
);
}
function NavbarItemsField({ form, onResetField }: FormProps) {
const { isDesktop } = useResponsive();
const { append, fields, remove } = useFieldArray<z.infer<typeof Schema_App_Configuration>>({
control: form.control,
name: "site.navbarItems",
});
const iconItems = useMemo<{ label: React.ReactNode; value: string }[]>(
() =>
IconNamesArray.map((icon) => ({
label: (
<div className='inline-flex grow items-center justify-between gap-2'>
<span className='line-clamp-1 break-all'>{icon}</span>
<Icon
name={icon}
hideWrapper
className='shrink-0'
/>
</div>
),
value: icon,
})),
[],
);
return (
<>
<div className='space-y-2 rounded-lg border px-4 py-2 shadow'>
<div className='space-y-2'>
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
<Label>Navbar Items</Label>
<Button
variant={"ghost"}
disabled={!form.getFieldState("site.navbarItems").isDirty}
onClick={() => {
onResetField?.(`site.navbarItems`);
}}
size={"icon"}
>
<Icon
name='RefreshCcw'
className='stroke-inherit'
/>
</Button>
</div>
{fields.length === 0 ? (
<div className='w-full py-8 text-center text-sm font-semibold text-muted-foreground'>
No extra navbar items, add one to show extra links on the navbar!
</div>
) : (
<>
{fields.map((field, index) => (
<div
className='flex flex-col gap-4'
key={field.id}
>
<div className='flex w-full flex-col gap-4 tablet:flex-row tablet:items-end'>
<FormField
control={form.control}
name={`site.navbarItems.${index}.icon`}
render={({ field }) => (
<FormItem disableBorder>
<FormLabel>Icon</FormLabel>
<FormControl>
<VirtualizedCombobox
minWidth={isDesktop ? "300px" : "100%"}
maxWidth={isDesktop ? "300px" : "100%"}
options={iconItems}
searchPlaceholder='Search icon...'
selectedOption={field.value}
onSelectOption={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`site.navbarItems.${index}.name`}
render={({ field }) => (
<FormItem
disableBorder
className='grow'
>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder='Navigation name'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='inline-flex w-full flex-col gap-4 tablet:flex-row '>
<FormField
control={form.control}
name={`site.navbarItems.${index}.href`}
render={({ field }) => (
<FormItem
disableBorder
className='grow'
>
<FormLabel>URL</FormLabel>
<FormControl>
<Input
placeholder='/path/to/page'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`site.navbarItems.${index}.external`}
render={({ field }) => (
<FormItem disableBorder>
<FormLabel>External Link</FormLabel>
<FormControl>
<Button
type='button'
variant={field.value ? "default" : "secondary"}
name={field.name}
disabled={field.disabled}
onClick={() => {
field.onChange(!field.value);
}}
onBlur={field.onBlur}
className={cn(
"w-full transition tablet:w-fit",
field.value ? "opacity-100" : "opacity-30",
)}
>
Open in new tab
</Button>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
className='w-full'
type='button'
variant='outline-destructive'
onClick={() => remove(index)}
>
<Icon name='X' />
Delete Item
</Button>
<Separator />
</div>
))}
</>
)}
<Button
className='w-full'
type='button'
onClick={() => append({ icon: "Link", name: "New Item", href: "/new-item", external: false })}
>
<Icon name='Plus' />
Add Item
</Button>
</div>
</div>
</>
);
}
function SupportsField({ form, onResetField }: FormProps) {
const { append, fields, remove } = useFieldArray<z.infer<typeof Schema_App_Configuration>>({
control: form.control,
name: "site.supports",
});
return (
<>
<div className='space-y-2 rounded-lg border px-4 py-2 shadow'>
<div className='w-full space-y-2'>
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
<Label>Supports / Donations</Label>
<Button
variant={"ghost"}
disabled={!form.getFieldState("site.supports").isDirty}
onClick={() => {
onResetField?.(`site.supports`);
}}
size={"icon"}
>
<Icon
name='RefreshCcw'
className='stroke-inherit'
/>
</Button>
</div>
{fields.length === 0 ? (
<div className='w-full py-8 text-center text-sm font-semibold text-muted-foreground'>
No support items, add one to show supports link on the navbar!
</div>
) : (
<>
{fields.map((field, index) => (
<div
className='flex flex-col gap-4'
key={field.id}
>
<div className='flex w-full flex-col gap-4 tablet:flex-row tablet:items-end'>
<FormField
control={form.control}
name={`site.supports.${index}.currency`}
render={({ field }) => (
<FormItem disableBorder>
<FormLabel>Currency</FormLabel>
<FormControl>
<Input
placeholder='Currency'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`site.supports.${index}.name`}
render={({ field }) => (
<FormItem
disableBorder
className='grow'
>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder='Service name'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='inline-flex w-full flex-col gap-4 tablet:flex-row '>
<FormField
control={form.control}
name={`site.supports.${index}.href`}
render={({ field }) => (
<FormItem
disableBorder
className='grow'
>
<FormLabel>URL</FormLabel>
<FormControl>
<Input
placeholder='/path/to/page'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
className='w-full'
type='button'
variant='outline-destructive'
onClick={() => remove(index)}
>
<Icon name='X' />
Delete Item
</Button>
<Separator />
</div>
))}
</>
)}
<Button
className='w-full'
type='button'
onClick={() => append({ currency: "USD", name: "Paypal", href: "https://paypal.me/acme" })}
>
<Icon name='Plus' />
Add Item
</Button>
</div>
</div>
</>
);
}
function FooterField({ form, onResetField }: FormProps) {
const { append, fields, remove } = useFieldArray<z.infer<typeof Schema_App_Configuration>>({
control: form.control,
name: "site.footer",
});
const templates = useMemo(
() => [
{
code: "version",
description: "Show current version",
},
{
code: "poweredBy",
description: 'Show "Powered by next-gdrive-index", linked to the repository',
},
{
code: "year",
description: "Show current year",
},
{
code: "repository",
description: "Original repository (mbaharip/next-gdrive-index)",
},
{
code: "creator",
description: "mbaharip, the creator of next-gdrive-index",
},
{
code: "author",
description: "Site author from configuration",
},
{
code: "siteName",
description: "Site name from configuration",
},
{
code: "handle",
description: "Twitter handle from configuration",
},
],
[],
);
const [content, setContent] = useState<string>(() => {
return formatFooterContent(form.watch("site.footer"), form.getValues("site"));
});
return (
<>
<div className='space-y-2 rounded-lg border px-4 py-2 shadow'>
<div className='w-full space-y-4'>
<div className='flex w-full items-center justify-between gap-2 tablet:w-fit tablet:justify-start'>
<Label>Footer Items</Label>
<Button
variant={"ghost"}
disabled={!form.getFieldState("site.footer").isDirty}
onClick={() => {
onResetField?.(`site.footer`);
}}
size={"icon"}
>
<Icon
name='RefreshCcw'
className='stroke-inherit'
/>
</Button>
</div>
{fields.length === 0 ? (
<div className='w-full py-8 text-center text-sm font-semibold text-muted-foreground'>
No support items, add one to show supports link on the navbar!
</div>
) : (
<div className='flex flex-col gap-4'>
{fields.map((field, index) => (
<FormField
control={form.control}
key={field.id}
name={`site.footer.${index}.value`}
render={({ field }) => (
<FormItem disableBorder>
<div className='flex w-full items-center gap-4'>
<FormControl>
<Input
placeholder='See description for templates'
{...field}
/>
</FormControl>
<Button
type='button'
variant={"outline-destructive"}
size='icon'
onClick={() => {
remove(index);
}}
>
<Icon name='X' />
</Button>
</div>
<FormDescription className={cn(index !== fields.length - 1 && "sr-only")}>
<span className='flex flex-col gap-2 tablet:flex-row tablet:items-center tablet:justify-between'>
<span>
Text to be displayed on the footer. <b>Markdown supported</b>
</span>
<ResponsiveDialog>
<ResponsiveDialogTrigger asChild>
<Button
type='button'
size='sm'
variant='ghost'
className='w-full tablet:ml-auto tablet:w-fit'
>
Click to see templates
</Button>
</ResponsiveDialogTrigger>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Template Guide</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Available templates for dynamic content
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<ResponsiveDialogBody>
<ScrollArea
className='h-fit w-full pr-4'
type='always'
>
<div className='flex max-h-[50dvh] flex-col gap-2'>
{templates.map((template) => (
<div
key={template.code}
className='flex flex-col rounded-lg border bg-background p-2 tablet:px-4'
>
<span className='font-semibold'>{`{{ ${template.code} }}`}</span>
<span className='text-sm text-muted-foreground'>{template.description}</span>
</div>
))}
</div>
</ScrollArea>
</ResponsiveDialogBody>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button>Close</Button>
</ResponsiveDialogClose>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
</span>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
)}
<Button
type='button'
size='sm'
className='mt-2 w-full'
onClick={() => {
append({ value: "" });
}}
>
<Icon name='Plus' />
Add Item
</Button>
<Separator />
<div className='space-y-4'>
<div className='flex w-full flex-col items-center justify-center'>
<ReactMarkdown
className='flex w-full select-none flex-col items-center justify-center text-center'
components={{
p: ({ children, ...props }) => (
<p
{...props}
className='muted text-balance text-sm'
>
{children}
</p>
),
a: ({ children, ...props }) => {
const isExternal = props.href?.startsWith("http");
return (
<a
{...props}
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
>
{children}
</a>
);
},
}}
remarkPlugins={[remarkBreaks]}
>
{content}
</ReactMarkdown>
</div>
<Button
className='w-full'
variant={"outline"}
size={"sm"}
onClick={() => {
setContent(formatFooterContent(form.watch("site.footer"), form.getValues("site")));
}}
>
Reload Preview
</Button>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,178 @@
import { ChangeEvent, useRef, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "~/components/ui/button";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { GenerateServiceAccountB64 } from "~/actions/configuration";
import { FormProps, FormSection } from "./ConfiguratorPage";
export default function EnvironmentForm({ onResetField, form }: FormProps) {
const serviceInputRef = useRef<HTMLInputElement>(null);
const [serviceInputLoading, setServiceInputLoading] = useState<boolean>(false);
function onLoadServiceAccount(e: ChangeEvent<HTMLInputElement>) {
setServiceInputLoading(true);
toast.loading("Processing service account file", {
id: "service-account",
});
try {
const file = e.target.files?.[0];
if (!file) throw new Error("No file selected");
const fr = new FileReader();
fr.onload = async () => {
if (!fr.result) throw new Error("Failed to read file content");
const stringResult = typeof fr.result === "object" ? JSON.stringify(fr.result) : String(fr.result);
const b64 = await GenerateServiceAccountB64(stringResult);
if (!b64.success) throw new Error(b64.error);
form.setValue("environment.GD_SERVICE_B64", b64.data);
toast.success("Service account encoded", {
id: "service-account",
});
};
fr.readAsText(file);
} catch (error) {
const e = error as Error;
console.error(e.message);
toast.error("Failed to load service account file", {
description: e.message,
id: "service-account",
});
} finally {
e.target.value = ""; // Reset file input wether success or not
setServiceInputLoading(false);
}
}
return (
<FormSection
title='Environment'
description='Configure your environment variables'
>
<FormField
control={form.control}
name='environment.GD_SERVICE_B64'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("environment.GD_SERVICE_B64");
}}
>
Service Account
</FormLabel>
<div className='flex flex-col gap-2 tablet:flex-row tablet:items-center tablet:justify-between'>
<FormControl>
<Input
placeholder='Encoded base64 will be here'
readOnly
{...field}
/>
</FormControl>
<input
ref={serviceInputRef}
type='file'
hidden
accept='.json'
onChange={onLoadServiceAccount}
/>
<LoadingButton
loading={serviceInputLoading}
size={"sm"}
onClick={() => serviceInputRef.current?.click()}
>
Load JSON
</LoadingButton>
</div>
<FormDescription>Load your service account JSON file to get the base64 encoded string.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='environment.ENCRYPTION_KEY'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("environment.ENCRYPTION_KEY");
}}
>
Encryption Key
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='jugemu-jugemu-gokō-no-surikire-kaijarisuigyo-no-suigyōmatsu-unraimatsu-fūraimatsu-kūneru-tokoro-ni-sumu-tokoro-yaburakōji-no-burakōji-paipopaipo-paiponoshūringan-shūringanno-gūrindai-gūrindaino-ponpokopīno-ponpokonāno-chōkyūmei-no-chōsuke'
{...field}
/>
</FormControl>
<FormDescription>Secret encryption key to protect sensitive data.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='environment.SITE_PASSWORD'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("environment.SITE_PASSWORD");
}}
>
Private Index Password
</FormLabel>
<FormControl>
<Input
type='password'
placeholder="I swear it's not admin123"
{...field}
/>
</FormControl>
<FormDescription>Will be used if you set the index to private.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='environment.NEXT_PUBLIC_DOMAIN'
render={({ field, fieldState }) => (
<FormItem>
<FormLabel
resetDisabled={!fieldState.isDirty}
onFieldReset={() => {
onResetField?.("environment.NEXT_PUBLIC_DOMAIN");
}}
>
Site Domain
</FormLabel>
<FormControl>
<Input
placeholder='acme.com / hey.acme.com'
{...field}
/>
</FormControl>
<FormDescription>
The domain for the site, without the protocol.
<br />
<b>Needed if you deploy outside of Vercel.</b>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</FormSection>
);
}
+289
View File
@@ -0,0 +1,289 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { type PropsWithChildren, useState } from "react";
import { FieldPath, type UseFormReturn, useForm } from "react-hook-form";
import { toast } from "sonner";
import { type z } from "zod";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import {
ResponsiveDropdownMenu,
ResponsiveDropdownMenuContent,
ResponsiveDropdownMenuItem,
ResponsiveDropdownMenuTrigger,
} from "~/components/ui/dropdown-menu.responsive";
import { Form } from "~/components/ui/form";
import Icon from "~/components/ui/icon";
import { Separator } from "~/components/ui/separator";
import { useResponsive } from "~/context/responsiveContext";
import { type ConfigurationCategory, Schema_App_Configuration } from "~/types/schema";
import ApiForm from "./ConfigurationPage.Api";
import SiteForm from "./ConfigurationPage.Site";
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() {
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const form = useForm<z.infer<typeof Schema_App_Configuration>>({
resolver: zodResolver(Schema_App_Configuration),
defaultValues: initialConfiguration,
});
function onReset(category: ConfigurationCategory | "all") {
if (category === "all") {
form.reset(initialConfiguration);
} else {
form.resetField(category);
}
}
function onFormSubmit(values: z.infer<typeof Schema_App_Configuration>) {
toast.info("Form submitted", {
description: <pre className='w-full overflow-auto'>{JSON.stringify(values, null, 2)}</pre>,
});
}
return (
<>
<Alert variant={"primary"}>
<AlertTitle>Theme customization is removed from the configurator.</AlertTitle>
<AlertDescription>
You can use website like{" "}
<Link
href={"https://themes.fkaya.dev/"}
target='_blank'
rel='noopener noreferrer'
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
>
themes.fkaya.dev
</Link>
,{" "}
<Link
href={"https://themeshadcn.com/"}
target='_blank'
rel='noopener noreferrer'
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
>
themeshadcn.com
</Link>{" "}
or{" "}
<Link
href={"https://ui.jln.dev/"}
target='_blank'
rel='noopener noreferrer'
className='text-balance text-sm text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
>
ui.jln.dev
</Link>{" "}
to generate theme configuration.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Configurator</CardTitle>
<CardDescription>Generate configurator for your index.</CardDescription>
</CardHeader>
<CardContent>
<div className='flex w-full flex-col items-center gap-2 pb-4 tablet:flex-row-reverse'>
<ResponsiveDropdownMenu>
<ResponsiveDropdownMenuTrigger asChild>
<Button className='w-full tablet:w-fit'>
Load Config
<Icon name='ChevronsUpDown' />
</Button>
</ResponsiveDropdownMenuTrigger>
<ResponsiveDropdownMenuContent
header={{
title: "Load Config",
description: "Load configuration from existing file",
}}
>
<ResponsiveDropdownMenuItem closeOnSelect>v2.4 / latest</ResponsiveDropdownMenuItem>
<ResponsiveDropdownMenuItem closeOnSelect>v2.3 / below</ResponsiveDropdownMenuItem>
<ResponsiveDropdownMenuItem closeOnSelect>v1.x / legacy</ResponsiveDropdownMenuItem>
</ResponsiveDropdownMenuContent>
</ResponsiveDropdownMenu>
<ResponsiveDropdownMenu>
<ResponsiveDropdownMenuTrigger asChild>
<Button className='w-full tablet:w-fit'>
Load Env
<Icon name='ChevronsUpDown' />
</Button>
</ResponsiveDropdownMenuTrigger>
<ResponsiveDropdownMenuContent
header={{
title: "Load Env",
description: "Load environment variables from existing file",
}}
>
<ResponsiveDropdownMenuItem closeOnSelect>v2.x / latest</ResponsiveDropdownMenuItem>
<ResponsiveDropdownMenuItem closeOnSelect>v1.x / legacy</ResponsiveDropdownMenuItem>
</ResponsiveDropdownMenuContent>
</ResponsiveDropdownMenu>
<Button
className='w-full tablet:w-fit'
variant={"destructive"}
onClick={() => onReset("all")}
>
Reset All
</Button>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onFormSubmit)}
className='grid grid-cols-1 gap-8 tablet:px-2'
>
<EnvironmentForm
form={form}
onResetField={(field) => form.resetField(field)}
/>
<Separator />
<ApiForm
form={form}
onResetField={(field) => form.resetField(field)}
/>
<Separator />
<SiteForm
form={form}
onResetField={(field) => form.resetField(field)}
/>
</form>
</Form>
</CardContent>
</Card>
</>
);
}
type FormColumnProps = {
column?: number;
};
export function FormColumn({ column = 2, children }: PropsWithChildren<FormColumnProps>) {
return (
<>
<div
className='grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-[--form-column]'
style={
{
"--form-column": `repeat(${column}, minmax(0, 1fr))`,
} as React.CSSProperties
}
>
{children}
</div>
</>
);
}
type FormSectionProps = {
title: string;
description: string;
};
export function FormSection({ title, description, children }: PropsWithChildren<FormSectionProps>) {
const { isDesktop } = useResponsive();
return (
<div
id={title
.toLowerCase()
.replace(/\s/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-")}
className='group grid grid-cols-1 gap-4'
>
<div className='flex flex-col gap-1.5'>
<h2 className='text-lg font-semibold'>{title}</h2>
<p className='text-sm text-muted-foreground'>{description}</p>
</div>
{children}
</div>
);
}
export type FormProps = {
form: UseFormReturn<z.infer<typeof Schema_App_Configuration>>;
onResetField?: (field: FieldPath<z.infer<typeof Schema_App_Configuration>>) => void;
} & Omit<FormSectionProps, "title" | "description">;
+777
View File
@@ -0,0 +1,777 @@
"use client";
import "@vidstack/react/player/styles/base.css";
import "@vidstack/react/player/styles/default/layouts/audio.css";
import "@vidstack/react/player/styles/default/layouts/video.css";
import "@vidstack/react/player/styles/default/theme.css";
import Link from "next/link";
import { Fragment, useState } from "react";
import { type SpecialComponents } from "react-markdown/lib/ast-to-react";
import { type NormalComponents } from "react-markdown/lib/complex-types";
import { Markdown } from "~/components/global";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "~/components/ui/drawer";
import Icon from "~/components/ui/icon";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Separator } from "~/components/ui/separator";
import { useResponsive } from "~/context/responsiveContext";
import { cn } from "~/lib/utils";
export const dynamic = "force-static";
type TocItem = {
id: string;
title: string;
level: number;
};
type GuideSection = "gettingStarted" | "newUser" | "migration" | "sharedDrive";
function generateToc(content: string): TocItem[] {
const headings = content.match(/#{1,6}.+/g) ?? [];
const tocItems = headings
.map((heading) => {
const match = /(#+)\s(.+)/.exec(heading);
if (!match) return null;
if (!match[1] || !match[2]) return null;
const level = match[1].length;
const title = match[2] ?? "";
const id = title.toLowerCase().replace(/[^\w]+/g, "-");
return { id, title, level };
})
.filter((item): item is TocItem => item !== null);
return tocItems;
}
const content: Readonly<Record<GuideSection, string>> = {
gettingStarted: `Welcome to the deployment guide! This guide will assist you in deploying the project to Vercel or similar services.
If you are new, you can follow along from the beginning. However, if you have previously deployed the project and wish to upgrade from v1 or v2.x, you can proceed directly to the [Migration Guide](#migration) section.
You can also utilize the [next-gdrive-index configurator](/_/configurator) to generate configuration for your deployment!
This guide assumes that you have a fundamental understanding of how to deploy a Next.js application on Vercel or other platforms.
---
If you prefer a video tutorial, you can watch the video below to deploy the project.`,
newUser: `For new user, this guide will provide step-by-step instructions on deploying the project to Vercel or similar services.
Before you begin, you'll need to have the following:
- A Github account
- A Google Cloud Platform account
- A Vercel (or similar service) account
- Basic understanding of Next.js and common sense
---
### Fork or clone the repository
As a first step, you'll need to fork or clone the repository to your GitHub account.
**Forking the repository**
- Click the **Fork** button or [click here](https://github.com/mbaharip/next-gdrive-index/fork)
- Fill in the repository name, description, and visibility as you like
**Cloning the repository**
- Click the **Code** button, and copy the repository URL
- Open your terminal, and run the following command:
\`\`\`bash
git clone <repository_url>
\`\`\`
---
### Creating Google Cloud Platform project
Before accessing files from Google Drive, you'll need to create a project in **Google Cloud Platform** and enable the **Google Drive API** (covered in the next step).
You can use your Google account to login to Google Cloud Platform.
1. Go to [Google Cloud Platform](https://console.cloud.google.com/) (if it's your first time, you need to accept the terms and conditions before you can proceed)
2. In the center of the page, click on **Create or select project**
3. In the top right corner, click the **New Project** button
4. Enter a desired name for your project, and click the **Create** button
5. A notification confirming project creation will appear, click on the **Select project** button
Congratulations! You've successfully created a project in Google Cloud Platform.
Next we'll enable the Google Drive API to access your Google Drive files.
---
### Enabling Google Drive API
To allow the project to access files stored in your Google Drive, you need to enable the Google Drive API within your Google Cloud Platform project.
You can access your Google Cloud Platform project by selecting the project on the top left corner of the page. Or clicking **Select Project** button on notification after creating the project.
To enable the Google Drive API, follow these steps:
1. Go to **APIs & Services** product, and then select the **Enabled APIs & services** menu
2. Click on the **+ Enable APIs and Services** button at the top of the page
3. Search for "Google Drive API" on the search bar, and select the first result (It should be named **Google Drive API**)
4. Click the **Enable** button and wait for the process to complete
Now that the Google Drive API is enabled, we need to create a service account to obtain the necessary credentials for accessing the API.
---
### Creating service account
If you're still on the Google Drive API page, you can click on **Credentials** menu on the sidebar.
If you're not, you can go to the **APIs & Services** product, and then select **Credentials** menu.
Now you should be on the Credentials page, you can follow these steps to create a service account:
1. Click on **Create Credentials** button, and choose **Service account**.
2. Fill the service account name and description as you like, and then click the **Create and continue** button.
2.1 You can skip both the **Grant this service account access to project** and **Grant users access to this service account** by clicking the **Done** button.
3. You should see the service account you just created on the **Service Account** table, click on the service account name to open the details.
4. Go to the **Keys** tab, and then click the **Add Key** button, choose the **Create new key**, and then pick the key type as **JSON**.
5. A download prompt will appear, save the JSON file on easy-to-find location, 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.
---
### Preparing root folder in Google Drive
Since the service account can't access your Root folder, you need to create a new folder as the root folder for the index.
You can either set the folder to public or share it with the service account.
1. Open your [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** (read note below)
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>\`)
Save the folder ID on notepad or somewhere safe, we will use it on the configuration.
**Note**
People can't download files larger than the download limit (more information on [configuring section](#configuring-the-project)) if the folder is not set to public.
I'm still looking for a workaround for this.
---
### Configuring the project
There are 2 ways to configure the project, you can either use configurator or manually edit the configuration file (\`/src/config/gIndex.config.ts\`).
While the configurator is easier, you can only configure the basic settings, so I recommend you to manually edit the configuration file.
#### Using the configurator
1. Open the configurator page [here](/_/configurator)
2. Fill the form with the required information
3. Click the **Generate Config** button to download the configuration file
4. Replace the existing configuration file (\`/src/config/gIndex.config.ts\`) with the downloaded file
5. Save the environment file (\`.env.local\`) to be used on the deployment
**Important Note**
Make sure you don't push / upload the environment file to the repository, or share it with anyone.
Since it contains sensitive information, it should be kept secret.
#### Manually editing the configuration file
First, we need create a environment file to store the sensitive information.
You can either store it temporarily on notepad, or create a new file named \`.env.local\` on the root of the project.
The environment file should contain the following information:
- \`GD_SERVICE_B64\` - Base64 encoded service account JSON file, you can encode it using [base64encode.org](https://www.base64encode.org/)
- \`ENCRYPTION_KEY\` - A secret key to encrypt sensitive information, make sure it's a long random string
- \`SITE_PASSWORD\` - Will be used as the index password if you set **private index** to \`true\` on the configuration file
- \`NEXT_PUBLIC_DOMAIN\` (optional) - Domain without protocol and trailing slash, e.g: \`example.com\`, default to your Vercel deployment domain / url (**If you're not using Vercel, you need to set this**)
**Important Note**
Make sure to keep the environment file safe, and don't share it with anyone.
Since it contains sensitive information, it should be kept secret.
After creating the environment file, you can edit the configuration file (\`/src/config/gIndex.config.ts\`) to configure the project.
Before changing the configuration, you need to replace the \`apiConfig.rootFolder\` with the encrypted value of the folder ID you just copied.
To encrypt the value, you can use the following command:
1. Copy this command to your terminal
\`curl "https://drive-demo.mbaharip.com/api/encrypt?key=<ENCRYPTION_KEY>&data=<FOLDER_ID>"\`
2. Replace the \`<ENCRYPTION_KEY>\` with your encryption key from above, and the \`<FOLDER_ID>\` with the folder ID you copied.
3. Run the command, if it's successful, you should get the \`encryptedValue\` and other information on the response.
4. Make sure \`decryptedValue\` is the same as the folder ID you copied, and \`key\` is the same as the encryption key you used. If it's not, you need to recheck the encryption key and folder ID.
5. Replace the \`apiConfig.rootFolder\` with the \`encryptedValue\` you got from the response.
If you're using Shared Drive, please follow the [Shared Drive Guide](#using-shared-drive) before deploying the project.
Now you can change the other configuration as you like, each configuration has a description to help you understand the purpose of the configuration.
And don't forget to save the configuration file after you finish editing it.
---
### Deploying the project
Before deploying the project, you need to make sure these things:
- No environment file is pushed to the repository
- Root folder ID and Shared Drive ID (if you're using Shared Drive) is encrypted
- \`StreamMaxSize\` is set to the maximum file size you want to stream (To avoid excessive bandwidth usage)
- \`maxFileSize\` is set to your deployment limit (Especially if you're using other platforms than Vercel)
After making sure everything is set, you can follow these steps to deploy the project:
(Note: I'm using Vercel as the deployment platform, but other platforms should be similar)
- Go to your deployment platform dashboard (e.g: [Vercel](https://vercel.com/))
- Click on the **Add** button, then select your \`next-gdrive-index\` repository
- Wait for the build process to finish
- Go to the project settings, and search for the **Environment Variables** section
- Add each environment variable from the environment file to the key fields, and the value to the value fields
- Redeploy the project, and wait for the deployment to finish
For other platforms, it's better to check their documentation on how to deploy Next.js project.
It should be similar to Vercel. (New Project > Select Repository > Wait for build > Add Environment Variables > Redeploy)
---
### Done! 🎉
Congratulations! You've successfully deployed your own instance of **next-gdrive-index**`,
migration: `If you've deployed the project before and want to upgrade to the latest version, you can follow this guide to migrate the project.
For v1, you need to refork the repository to get the latest changes.
If you're using v2.3 or below, you can click on **Sync fork** to get the latest changes.
But don't forget to backup the configuration file before syncing the repository.
### Migrating from v1
Here are the things you need to change to migrate from v1 to the latest version.
#### Environment file (v1.x)
- \`NEXT_PUBLIC_ENCRYPTION_KEY\` - Change to \`ENCRYPTION_KEY\`
- \`NEXT_PUBLIC_SITE_PASSWORD\` - Change to \`SITE_PASSWORD\`
- \`NEXT_PUBLIC_VERCEL_URL\` - Change to \`NEXT_PUBLIC_DOMAIN\`
#### Configuration file (v1.x)
- \`masterKey\` - Deprecated, please remove this configuration
- \`apiConfig.rootFolder\` - Need to be encrypted, you can read [this section](#manually-editing-the-configuration-file) to encrypt the folder ID
- \`siteConfig.navbarItems\` - You need to change the Icon value, since now the icon using [Lucide Icons](https://lucide.dev)
The other configuration are new, you might want to read each description to understand the purpose of the configuration before changing it.
---
### Migrating from v2.3 or below
For faster migration, you can use [the configurator](/_/configurator) to load your \`.env.local\` and \`gIndex.config.ts\` file, and generate the new configuration file.
It will automatically set the new configuration based on your old configuration.
But if you want to manually change the configuration, you can follow the steps below
#### Environment file (v2.3 or below)
There are no changes on the environment file, you can use the old environment file without any changes.
But if you want, you can change your \`ENCRYPTION_KEY\` to the new one. There are no limitations like the old version.
#### Configuration file (v2.3 or below)
- \`showGuideButton\` - Previously named as \`showDeployGuide\`
- \`apiConfig.rootFolder\` - Need to be encrypted, you can read [this section](#manually-editing-the-configuration-file) to encrypt the folder ID
- \`apiConfig.sharedDrive\` - Need to be encrypted, you can read [this section](#manually-editing-the-configuration-file) to encrypt the shared drive ID
- \`siteConfig.footer\` - Format changed, it is now an array of object with \`value\` instead of array of string. Also add \`{{ poweredBy }}\` template
- \`siteConfig.experimental_pageLoadTime\` - Currently WIP. you need to add this, but it's not used yet
- \`siteConfig.previewSettings.manga\` - Currently only manga preview is available, you can set the max item / size to load on the preview
---
That's it! You've successfully migrated the project to the latest version.`,
sharedDrive: `It's actually pretty simple to use Shared Drive, here are the steps:
- Open your [Google Drive](https://drive.google.com/)
- On the left sidebar, select **Shared drives**, and then pick the Shared Drive you want to use
- Copy the Shared Drive folder 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/<folder_id>\`) and save it somewhere easy to access (e.g notepad)
- Create a new folder inside the Shared Drive, and share it with the service account email address (you can find it on the JSON file, or on the service account details page)
- Share the folder with **Anyone with the link** to allow download files larger than deployment limit
- 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>\` ), and save it somewhere easy to access (e.g notepad)
Now we need to encrypt both the Root folder ID and Shared Drive ID before we can use it on the configuration file.
To encrypt the value, you can use the following command:
1. Copy this command to your terminal
\`curl "https://drive-demo.mbaharip.com/api/encrypt?key=<ENCRYPTION_KEY>&data=<ID>"\`
2. Replace the \`<ENCRYPTION_KEY>\` with your encryption key from above, and the \`<ID>\` with the ID you copied.
3. Run the command, if it's successful, you should get the \`encryptedValue\` and other information on the response.
4. Make sure \`decryptedValue\` is the same as the ID you copied, and \`key\` is the same as the encryption key you used. If it's not, you need to recheck the encryption key and ID.
Do this for both the Root folder ID and Shared Drive ID, and replace the configuration file with the new encrypted value.
And make sure to set \`apiConfig.isTeamDrive\` to \`true\` to use Shared Drive.`,
} as const;
const guide: Readonly<
Record<
GuideSection,
{
id: string;
title: string;
content: string;
toc: TocItem[];
}
>
> = {
gettingStarted: {
id: "getting-started",
title: "Getting started",
content: content.gettingStarted,
toc: generateToc(content.gettingStarted),
},
newUser: {
id: "new-user",
title: "New deployment guide",
content: content.newUser,
toc: generateToc(content.newUser),
},
migration: {
id: "migration",
title: "Migration guide",
content: content.migration,
toc: generateToc(content.migration),
},
sharedDrive: {
id: "using-shared-drive",
title: "Using shared drive",
content: content.sharedDrive,
toc: generateToc(content.sharedDrive),
},
} as const;
const customComponents: Partial<Omit<NormalComponents, keyof SpecialComponents> & SpecialComponents> = {
h1: ({ className, id, children, ...props }) => (
<Link
href={`#${id}`}
className='group block w-full !text-card-foreground !opacity-100'
>
<h1
id={id}
className={cn("inline-flex items-center gap-2 !border-0", className)}
{...props}
>
{children}
<Icon
name='Hash'
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
/>
</h1>
</Link>
),
h2: ({ className, id, children, ...props }) => (
<Link
href={`#${id}`}
className='group block w-full !text-card-foreground !opacity-100'
>
<h2
id={id}
className={cn("inline-flex items-center gap-2 !border-0", className)}
{...props}
>
{children}
<Icon
name='Hash'
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
/>
</h2>
</Link>
),
h3: ({ className, id, children, ...props }) => (
<Link
href={`#${id}`}
className='group block w-full !text-card-foreground !opacity-100'
>
<h3
id={id}
className={cn("inline-flex items-center gap-2 !border-0", className)}
{...props}
>
{children}
<Icon
name='Hash'
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
/>
</h3>
</Link>
),
h4: ({ className, id, children, ...props }) => (
<Link
href={`#${id}`}
className='group block w-full !text-card-foreground !opacity-100'
>
<h4
id={id}
className={cn("inline-flex items-center gap-2 !border-0", className)}
{...props}
>
{children}
<Icon
name='Hash'
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
/>
</h4>
</Link>
),
h5: ({ className, id, children, ...props }) => (
<Link
href={`#${id}`}
className='group block w-full !text-card-foreground !opacity-100'
>
<h5
id={id}
className={cn("inline-flex items-center gap-2 !border-0", className)}
{...props}
>
{children}
<Icon
name='Hash'
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
/>
</h5>
</Link>
),
h6: ({ className, id, children, ...props }) => (
<Link
href={`#${id}`}
className='group block w-full !text-card-foreground !opacity-100'
>
<h6
id={id}
className={cn("inline-flex items-center gap-2 !border-0", className)}
{...props}
>
{children}
<Icon
name='Hash'
className='size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
/>
</h6>
</Link>
),
};
export default function DeployPage() {
return (
<>
<Alert variant={"warning"}>
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>It is recommended to use desktop to follow along the guide.</AlertDescription>
</Alert>
<TableOfContents guide={guide} />
<Card>
<CardHeader>
<Link
href={`#${guide.gettingStarted.id}`}
className='group'
>
<CardTitle
id={guide.gettingStarted.id}
className='inline-flex scroll-m-20 items-center gap-2 text-3xl font-semibold tracking-tight'
>
{guide.gettingStarted.title}
<Icon
name='Hash'
className='stroke size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
/>
</CardTitle>
</Link>
</CardHeader>
<CardContent className='flex w-full flex-col items-center justify-center gap-2'>
<Markdown
className='w-full'
customComponents={customComponents}
content={guide.gettingStarted.content}
/>
<iframe
src='https://www.youtube-nocookie.com/embed/Wt-w5zWyOlk?si=z6j1Htb_YsaLswGW'
title='next-gdrive-index deployment guide'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
referrerPolicy='strict-origin-when-cross-origin'
className='aspect-video h-full w-full max-w-[640px] rounded-lg'
allowFullScreen
></iframe>
{/* <MediaPlayer
src={isMounted ? "youtube/B0u58LVomTM" : ""}
className='media-player mx-auto w-fit max-w-screen-md overflow-hidden rounded-xl'
playsInline
crossOrigin
preload='auto'
onProviderChange={(provider) => {
if (isYouTubeProvider(provider)) {
console.log("YouTube provider loaded");
} else {
console.log("Other provider loaded");
}
}}
>
<MediaProvider />
<DefaultVideoLayout
icons={MediaPlayerIcons}
colorScheme='default'
showTooltipDelay={150}
slots={{
beforeSettingsMenu: (
<Tooltip.Root showDelay={150}>
<Tooltip.Trigger asChild>
<Link
href={"/"}
className='vds-button'
target='_blank'
rel='noopener noreferrer'
>
<Icon
name='ExternalLink'
className='size-5'
/>
</Link>
</Tooltip.Trigger>
<Tooltip.Content
placement='top'
className='vds-tooltip-content'
>
Watch on YouTube
</Tooltip.Content>
</Tooltip.Root>
),
}}
/>
</MediaPlayer> */}
</CardContent>
</Card>
<Card>
<CardHeader>
<Link
href={`#${guide.newUser.id}`}
className='group'
>
<CardTitle
id={guide.newUser.id}
className='inline-flex scroll-m-20 items-center gap-2 text-3xl font-semibold tracking-tight'
>
{guide.newUser.title}
<Icon
name='Hash'
className='stroke size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
/>
</CardTitle>
</Link>
</CardHeader>
<CardContent className='flex w-full flex-col items-center justify-center gap-2'>
<Markdown
className='w-full'
customComponents={customComponents}
content={guide.newUser.content}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<Link
href={`#${guide.migration.id}`}
className='group'
>
<CardTitle
id={guide.migration.id}
className='inline-flex scroll-m-20 items-center gap-2 text-3xl font-semibold tracking-tight'
>
{guide.migration.title}
<Icon
name='Hash'
className='stroke size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
/>
</CardTitle>
</Link>
</CardHeader>
<CardContent className='flex w-full flex-col items-center justify-center gap-2'>
<Markdown
className='w-full'
customComponents={customComponents}
content={guide.migration.content}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<Link
href={`#${guide.sharedDrive.id}`}
className='group'
>
<CardTitle
id={guide.sharedDrive.id}
className='inline-flex scroll-m-20 items-center gap-2 text-3xl font-semibold tracking-tight'
>
{guide.sharedDrive.title}
<Icon
name='Hash'
className='stroke size-4 stroke-muted-foreground opacity-0 transition group-hover:opacity-100'
/>
</CardTitle>
</Link>
</CardHeader>
<CardContent className='flex w-full flex-col items-center justify-center gap-2'>
<Markdown
className='w-full'
customComponents={customComponents}
content={guide.sharedDrive.content}
/>
</CardContent>
</Card>
</>
);
}
type TableOfContentsProps = {
guide: Record<GuideSection, { id: string; title: string; content: string; toc: TocItem[] }>;
};
function TableOfContents({ guide }: TableOfContentsProps) {
const { isDesktop } = useResponsive();
const [tocShow, setTocShow] = useState<boolean>(false);
return (
<>
{isDesktop ? (
<div
className={cn(
"fixed top-24 z-50 flex items-stretch gap-6 rounded-lg border bg-card p-4 text-card-foreground opacity-100 shadow-md transition-all duration-500 ease-in-out",
tocShow ? "right-3" : "-right-72",
)}
>
<div
className='group flex cursor-pointer flex-col items-center gap-4'
onClick={() => {
setTocShow((prev) => !prev);
}}
>
<Icon
name='ChevronsLeft'
hideWrapper
className={cn(
"size-5 transition-all duration-500 ease-in-out group-hover:scale-110",
tocShow ? "rotate-180" : "rotate-0",
)}
/>
<span
className='font-semibold tracking-wider'
style={{
writingMode: "vertical-lr",
}}
>
Table of Contents
</span>
</div>
<ScrollArea className='h-full max-w-64 pr-4'>
<div className='h-fit max-h-[50dvh] space-y-2'>
{Object.entries(guide).map(([k, v], index, self) => (
<Fragment key={k}>
<div className='flex flex-col gap-1'>
<Link
href={`#${v.id}`}
className='my-0.5 text-lg text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
>
{v.title}
</Link>
{!!v.toc.length && (
<div className='flex flex-col gap-1'>
{v.toc.map((menu) => (
<Link
key={menu.id}
href={`#${menu.id}`}
className='my-0.5 text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
style={{
marginLeft: `${(menu.level - 2) * 1}rem`,
}}
>
{menu.title}
</Link>
))}
</div>
)}
{index !== self.length - 1 && <Separator />}
</div>
</Fragment>
))}
</div>
</ScrollArea>
</div>
) : (
<Drawer>
<DrawerTrigger asChild>
<div className={cn("fixed bottom-16 right-6 z-50 transition")}>
<Button
size={"icon"}
variant={"outline"}
className='h-10 w-10 rounded-full p-0 shadow'
>
<Icon
name='TableOfContents'
className='size-6'
/>
</Button>
</div>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className='text-start'>
<DrawerTitle>Table of Contents</DrawerTitle>
<DrawerDescription>Jump to the section you want to read</DrawerDescription>
</DrawerHeader>
<div className='space-y-2 p-4'>
{Object.entries(guide).map(([k, v], index, self) => (
<Fragment key={`mobile-${k}`}>
<div className='flex flex-col gap-1'>
<DrawerClose asChild>
<Link
href={`#${v.id}`}
className='my-0.5 text-lg text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
>
{v.title}
</Link>
</DrawerClose>
{!!v.toc.length && (
<div className='flex flex-col gap-1'>
{v.toc.map((menu) => (
<DrawerClose
key={`mobile-${menu.id}`}
asChild
>
<Link
href={`#${menu.id}`}
className='my-0.5 text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
style={{
marginLeft: `${(menu.level - 2) * 0.75}rem`,
}}
>
{menu.title}
</Link>
</DrawerClose>
))}
</div>
)}
{index !== self.length - 1 && <Separator />}
</div>
</Fragment>
))}
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant={"secondary"}>Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)}
</>
);
}
+760
View File
@@ -0,0 +1,760 @@
"use client";
import {
type AudioMimeType,
MediaPlayer,
type MediaPlayerInstance,
type MediaPlayerQuery,
MediaProvider,
Menu,
PlayButton,
SeekButton,
Time,
TimeSlider,
type VideoMimeType,
useAudioGainOptions,
usePlaybackRateOptions,
} from "@vidstack/react";
import {
DefaultAudioLayout,
type DefaultLayoutIcons,
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default";
import "@vidstack/react/player/styles/base.css";
import "@vidstack/react/player/styles/default/layouts/audio.css";
import "@vidstack/react/player/styles/default/layouts/video.css";
import "@vidstack/react/player/styles/default/theme.css";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { type z } from "zod";
import { PageLoader } from "~/components/layout";
import Icon from "~/components/ui/icon";
import useLoading from "~/hooks/useLoading";
import { type Schema_File } from "~/types/schema";
import "~/styles/vidstack.css";
type Props = {
file: z.infer<typeof Schema_File>;
type: "audio" | "video";
};
export default function EmbedPage({ file, type }: Props) {
const loading = useLoading();
const player = useRef<MediaPlayerInstance>(null);
const [canPlay, setCanPlay] = useState<boolean>(false);
const [isLoop, setIsLoop] = useState<boolean>(false);
const icons = useMemo<DefaultLayoutIcons>(
() => ({
...defaultLayoutIcons,
AirPlayButton: {
Default: () => (
<Icon
hideWrapper
name='Airplay'
className='vds-icon size-5'
/>
),
Connecting: () => (
<Icon
hideWrapper
name='LoaderCircle'
className='vds-icon size-5 animate-spin'
/>
),
Connected: () => (
<Icon
hideWrapper
name='Airplay'
className='vds-icon size-5'
/>
),
},
GoogleCastButton: {
Default: () => (
<Icon
hideWrapper
name='Cast'
className='vds-icon size-5'
/>
),
Connecting: () => (
<Icon
hideWrapper
name='LoaderCircle'
className='vds-icon size-5 animate-spin'
/>
),
Connected: () => (
<Icon
hideWrapper
name='Cast'
className='vds-icon size-5'
/>
),
},
PlayButton: {
Play: () => (
<Icon
hideWrapper
name='Play'
className='vds-icon size-5 fill-current media-ended:hidden media-playing:hidden'
/>
),
Pause: () => (
<Icon
hideWrapper
name='Pause'
className='vds-icon size-5 fill-current media-paused:hidden'
/>
),
Replay: () => (
<Icon
hideWrapper
name='RotateCcw'
className='vds-icon hidden size-5 media-ended:block'
/>
),
},
MuteButton: {
Mute: () => (
<Icon
hideWrapper
name='VolumeX'
className='vds-icon mute-icon size-5'
/>
),
VolumeLow: () => (
<Icon
hideWrapper
name='Volume1'
className='vds-icon volume-low-icon size-5'
/>
),
VolumeHigh: () => (
<Icon
hideWrapper
name='Volume2'
className='vds-icon volume-high-icon size-5'
/>
),
},
CaptionButton: {
On: () => (
<Icon
hideWrapper
name='Captions'
className='vds-icon size-5'
/>
),
Off: () => (
<Icon
hideWrapper
name='CaptionsOff'
className='vds-icon size-5'
/>
),
},
PIPButton: {
Enter: () => (
<Icon
hideWrapper
name='PictureInPicture'
className='vds-icon size-5'
/>
),
Exit: () => (
<Icon
hideWrapper
name='X'
className='vds-icon size-5'
/>
),
},
FullscreenButton: {
Enter: () => (
<Icon
hideWrapper
name='Maximize2'
className='vds-icon size-5'
/>
),
Exit: () => (
<Icon
hideWrapper
name='Minimize2'
className='vds-icon size-5'
/>
),
},
SeekButton: {
Backward: () => (
<Icon
hideWrapper
name='ChevronsLeft'
className='vds-icon size-5'
/>
),
Forward: () => (
<Icon
hideWrapper
name='ChevronsRight'
className='vds-icon size-5'
/>
),
},
DownloadButton: {
Default: () => (
<Icon
hideWrapper
name='Download'
className='vds-icon size-5'
/>
),
},
Menu: {
Accessibility: () => (
<Icon
hideWrapper
name='PersonStanding'
className='vds-icon mr-2 size-5'
/>
),
ArrowLeft: () => null,
ArrowRight: () => (
<Icon
hideWrapper
name='ChevronRight'
className='vds-icon size-5'
/>
),
Audio: () => (
<Icon
hideWrapper
name='Volume2'
className='vds-icon mr-2 size-5'
/>
),
AudioBoostUp: () => (
<Icon
hideWrapper
name='Volume2'
className='vds-icon size-5'
/>
),
AudioBoostDown: () => (
<Icon
hideWrapper
name='Volume'
className='vds-icon size-5'
/>
),
Chapters: () => (
<Icon
hideWrapper
name='TableOfContents'
className='vds-icon size-5'
/>
),
Captions: () => (
<Icon
hideWrapper
name='Captions'
className='vds-icon size-5'
/>
),
Playback: () => (
<Icon
hideWrapper
name='ListVideo'
className='size5 mr-2 '
/>
),
Settings: () => (
<Icon
hideWrapper
name='Settings'
className='vds-icon vds-rotate-icon size-5'
/>
),
SpeedUp: () => (
<Icon
hideWrapper
name='ChevronsUp'
className='vds-icon size-5'
/>
),
SpeedDown: () => (
<Icon
hideWrapper
name='ChevronsDown'
className='vds-icon size-5'
/>
),
QualityUp: () => null,
QualityDown: () => null,
FontSizeUp: () => null,
FontSizeDown: () => null,
OpacityUp: () => null,
OpacityDown: () => null,
RadioCheck: () => null,
},
KeyboardDisplay: {
Play: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Play'
className='size-6'
/>
</div>
),
Pause: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Pause'
className='size-6'
/>
</div>
),
Mute: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='VolumeX'
className='size-6'
/>
</div>
),
VolumeUp: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Volume2'
className='size-6'
/>
</div>
),
VolumeDown: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Volume1'
className='size-6'
/>
</div>
),
EnterFullscreen: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Maximize2'
className='size-6'
/>
</div>
),
ExitFullscreen: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Minimize2'
className='size-6'
/>
</div>
),
// EnterPiP: () => null,
// ExitPiP: () => null,
CaptionsOn: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='Captions'
className='size-6'
/>
</div>
),
CaptionsOff: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='CaptionsOff'
className='size-6'
/>
</div>
),
SeekForward: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='ChevronsRight'
className='size-6'
/>
</div>
),
SeekBackward: () => (
<div className='grid h-full w-full place-items-center'>
<Icon
hideWrapper
name='ChevronsLeft'
className='size-6'
/>
</div>
),
},
}),
[],
);
const smallAudioLayoutQuery = useCallback<MediaPlayerQuery>(({ width }) => {
return width < 576;
}, []);
const smallVideoLayoutQuery = useCallback<MediaPlayerQuery>(({ width, height }) => {
return width < 576 || height < 380;
}, []);
return (
<>
{loading ? (
<PageLoader message='Preparing media embed...' />
) : (
<MediaPlayer
ref={player}
key={file.encryptedId}
src={{
src: `/api/preview/${file.encryptedId}`,
type: file.mimeType as AudioMimeType | VideoMimeType,
}}
loop={isLoop}
autoPlay
playsInline
crossOrigin
viewType={type === "audio" ? "audio" : "video"}
className='media-player h-fit max-h-screen w-full rounded-xl'
preload='auto'
onLoadedMetadata={() => {
setCanPlay(true);
}}
>
<MediaProvider />
<DefaultAudioLayout
icons={icons}
colorScheme='default'
smallLayoutWhen={smallAudioLayoutQuery}
showTooltipDelay={150}
className='h-fit !px-2 !py-2 shadow-md [&.vds-button>svg]:stroke-[--media-controls-color] [&.vds-play-button>svg]:fill-[--audio-play-button-color] [&.vds-play-button>svg]:stroke-[--audio-play-button-color] [&>.vds-captions]:hidden [&>.vds-controls>.vds-controls-group]:inline-flex [&>.vds-controls>.vds-controls-group]:gap-1 [&>.vds-controls>.vds-controls-group]:md:inline-flex [&>.vds-controls>.vds-controls-group]:md:flex-row'
slots={{
seekBackwardButton: null,
seekForwardButton: null,
playButton: (
<div className='flex w-fit items-center justify-center'>
<SeekButton
className='vds-button'
seconds={-10}
>
<icons.SeekButton.Backward />
</SeekButton>
<PlayButton
className='vds-button vds-play-button aspect-square h-10 w-10'
disabled={canPlay === false}
>
{canPlay ? (
<>
<icons.PlayButton.Play />
<icons.PlayButton.Pause />
<icons.PlayButton.Replay />
</>
) : (
<Icon
hideWrapper
name='LoaderCircle'
className='vds-icon size-5 animate-spin'
/>
)}
</PlayButton>
<SeekButton
className='vds-button'
seconds={10}
>
<icons.SeekButton.Forward />
</SeekButton>
</div>
),
timeSlider: (
<div className='grid w-full grow place-items-center'>
<TimeSlider.Root className='vds-time-slider vds-slider !opacity-100'>
<TimeSlider.Track className='vds-slider-track'>
<TimeSlider.Progress className='vds-slider-progress' />
<TimeSlider.TrackFill className='vds-slider-track-fill vds-slider-track' />
</TimeSlider.Track>
</TimeSlider.Root>
</div>
),
beforeEndTime: <div className='vds-controls-spacer' />,
endTime: (
<div className='inline-flex w-fit items-center justify-between gap-2 md:w-fit'>
<div className='inline-flex items-center text-sm'>
<Time
className='time'
type='current'
/>
<span className='mx-1'>/</span>
<Time
className='time'
type='duration'
/>
</div>
<div className='flex items-center'>
<Menu.Root className='vds-menu'>
<Menu.Button
className='vds-menu-button vds-button'
aria-label='Settings'
>
<icons.Menu.Settings />
</Menu.Button>
<Menu.Items
className='vds-menu-items'
placement={"bottom"}
offset={0}
>
{/* Loop */}
<Menu.Root>
<Menu.Button className='vds-menu-item'>
<ChevronLeft className='vds-menu-close-icon' />
<Icon
name='Repeat'
className='vds-icon'
/>
<span className='vds-menu-item-label'>Loop</span>
<span className='vds-menu-item-hint'>{isLoop ? "On" : "Off"}</span>
<ChevronRight className='vds-menu-open-icon' />
</Menu.Button>
<Menu.Content className='vds-menu-items'>
<Menu.RadioGroup
className='vds-radio-group'
value={String(isLoop)}
>
<Menu.Radio
className='vds-radio'
value='true'
onSelect={() => setIsLoop(true)}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label'>On</span>
</Menu.Radio>
<Menu.Radio
className='vds-radio'
value='false'
onSelect={() => setIsLoop(false)}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label'>Off</span>
</Menu.Radio>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Root>
{/* Playback Speed */}
<PlaybackMenu />
{/* Audio Gain */}
<AudioGain />
</Menu.Items>
</Menu.Root>
</div>
</div>
),
settingsMenu: null,
}}
/>
<DefaultVideoLayout
icons={icons}
colorScheme='default'
smallLayoutWhen={smallVideoLayoutQuery}
showTooltipDelay={150}
slots={{
currentTime: (
<Time
className='text-sm'
type='current'
/>
),
endTime: (
<Time
className='text-sm'
type='duration'
/>
),
settingsMenu: (
<Menu.Root className='vds-menu'>
<Menu.Button
className='vds-menu-button vds-button'
aria-label='Settings'
>
<icons.Menu.Settings />
</Menu.Button>
<Menu.Items
className='vds-menu-items'
placement={"top"}
offset={0}
>
{/* Loop */}
<Menu.Root>
<Menu.Button className='vds-menu-item'>
<ChevronLeft className='vds-menu-close-icon' />
<Icon
name='Repeat'
className='vds-icon'
/>
<span className='vds-menu-item-label'>Loop</span>
<span className='vds-menu-item-hint'>{isLoop ? "On" : "Off"}</span>
<ChevronRight className='vds-menu-open-icon' />
</Menu.Button>
<Menu.Content className='vds-menu-items'>
<Menu.RadioGroup
className='vds-radio-group'
value={String(isLoop)}
>
<Menu.Radio
className='vds-radio'
value='true'
onSelect={() => setIsLoop(true)}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label'>On</span>
</Menu.Radio>
<Menu.Radio
className='vds-radio'
value='false'
onSelect={() => setIsLoop(false)}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label'>Off</span>
</Menu.Radio>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Root>
{/* Playback Speed */}
<PlaybackMenu />
{/* Audio Gain */}
<AudioGain />
</Menu.Items>
</Menu.Root>
),
}}
/>
</MediaPlayer>
)}
</>
);
}
function PlaybackMenu() {
const options = usePlaybackRateOptions();
const hint = options.selectedValue === "1" ? "Normal" : `${options.selectedValue}x`;
return (
<Menu.Root>
<Menu.Button
className='vds-menu-item'
disabled={options.disabled}
>
<ChevronLeft className='vds-menu-close-icon' />
<Icon
name='Gauge'
className='vds-icon'
/>
<span className='vds-menu-item-label'>Playback Speed</span>
<span className='vds-menu-item-hint'>{hint}</span>
<ChevronRight className='vds-menu-open-icon' />
</Menu.Button>
<Menu.Content className='vds-menu-items'>
<Menu.RadioGroup
className='vds-radio-group'
value={options.selectedValue}
>
{/* eslint-disable-next-line @typescript-eslint/unbound-method */}
{options.map(({ value, select, label }) => (
<Menu.Radio
key={value}
className='vds-radio'
value={value}
onSelect={select}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label'>{label}</span>
</Menu.Radio>
))}
</Menu.RadioGroup>
</Menu.Content>
</Menu.Root>
);
}
function AudioGain() {
const options = useAudioGainOptions({
disabledLabel: "100%",
});
const hint = options.selectedValue ? `${Number(options.selectedValue) * 100}%` : "100%";
return (
<Menu.Root>
<Menu.Button
className='vds-menu-item'
disabled={options.disabled}
>
<ChevronLeft className='vds-menu-close-icon' />
<Icon
name='Volume2'
className='vds-icon'
/>
<span className='vds-menu-item-label'>Audio Boost</span>
<span className='vds-menu-item-hint'>{hint}</span>
<ChevronRight className='vds-menu-open-icon' />
</Menu.Button>
<Menu.Content className='vds-menu-items'>
<Menu.RadioGroup
className='vds-radio-group'
value={options.selectedValue}
>
{/* eslint-disable-next-line @typescript-eslint/unbound-method */}
{options.map(({ value, select, label }) => (
<Menu.Radio
key={value}
className='vds-radio'
value={value}
onSelect={select}
>
<Icon
name='Check'
className='vds-icon'
/>
<span className='vds-radio-label capitalize'>{label}</span>
</Menu.Radio>
))}
</Menu.RadioGroup>
</Menu.Content>
</Menu.Root>
);
}
+75 -104
View File
@@ -2,6 +2,7 @@ import { type Metadata, type ResolvedMetadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer"; import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer";
import { Status } from "~/components/global";
import { Password } from "~/components/layout"; import { Password } from "~/components/layout";
import { PreviewLayout } from "~/components/preview"; import { PreviewLayout } from "~/components/preview";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -12,25 +13,46 @@ import { formatPathToBreadcrumb } from "~/lib/utils";
import { GetBanner, GetFile, GetReadme, ListFiles } from "~/actions/files"; import { GetBanner, GetFile, GetReadme, ListFiles } from "~/actions/files";
import { CheckPagePassword } from "~/actions/password"; import { CheckPagePassword } from "~/actions/password";
import { ValidatePaths } from "~/actions/paths"; import { ValidatePaths } from "~/actions/paths";
import { CreateFileToken } from "~/actions/token";
import config from "config";
import ErrorComponent from "../error"; import ErrorComponent from "../error";
import DeployGuidePage from "./deploy"; import ConfiguratorPage from "./ConfiguratorPage";
import DeployPage from "./DeployPage";
import EmbedPage from "./EmbedPage";
export const revalidate = 60; export const revalidate = 60;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const dynamicParams = false;
type Props = { type Props = {
params: Promise<{ params: Promise<{
rest: string[]; rest: string[];
}>; }>;
}; };
const isDeployGuide = (paths: string[]) => paths.length === 1 && paths[0] === "deploy" && config.showDeployGuide; const internal = (paths: string[]) => {
const joinedPaths = (paths.join("/").startsWith("/") ? paths.join("/") : `/${paths.join("/")}`).replace(/\/+/g, "/");
return {
isInternalRoot: joinedPaths === "/_",
isGuidePage: joinedPaths === "/_/deploy",
isConfiguratorPage: joinedPaths === "/_/configurator",
isEmbedRootPage: joinedPaths === "/_/embed",
isEmbedPage: joinedPaths.startsWith("/_/embed/"),
};
};
export async function generateMetadata({ params }: Props, parent: ResolvedMetadata): Promise<Metadata> { export async function generateMetadata({ params }: Props, parent: ResolvedMetadata): Promise<Metadata> {
const { rest } = await params; const p = await params;
if (isDeployGuide(rest)) return { title: "Deploy Guide", description: "Read on how to deploy this project" }; let rest = p.rest;
const { isInternalRoot, isGuidePage, isConfiguratorPage, isEmbedPage, isEmbedRootPage } = internal(p.rest);
if (isInternalRoot)
return { title: "Reserved Internal Path", description: "This path is reserved for internal pages" };
if (isGuidePage) return { title: "Guide", description: "Read on how to deploy your own index" };
if (isConfiguratorPage) return { title: "Configurator", description: "Configure your index" };
if (isEmbedRootPage) return { title: "Not Found" };
if (isEmbedPage) {
rest = rest.slice(2);
}
const paths = await ValidatePaths(rest); const paths = await ValidatePaths(rest);
if (!paths.success) return { title: "Not Found" }; if (!paths.success) return { title: "Not Found" };
@@ -39,10 +61,14 @@ export async function generateMetadata({ params }: Props, parent: ResolvedMetada
if (!currentPath?.id) return { title: "Not Found" }; if (!currentPath?.id) return { title: "Not Found" };
const banner = await GetBanner(currentPath.id); const banner = await GetBanner(currentPath.id);
if (isEmbedPage && !currentPath.mimeType.includes("video") && !currentPath.mimeType.includes("audio"))
return { title: "Not Found" };
return { return {
title: decodeURIComponent(currentPath.path), title: isEmbedPage ? `${decodeURIComponent(currentPath.path)} (embed)` : decodeURIComponent(currentPath.path),
description: currentPath.mimeType.includes("folder") description: isEmbedPage
? `Embed ${currentPath.path}`
: currentPath.mimeType.includes("folder")
? `Browse ${currentPath.path} files` ? `Browse ${currentPath.path} files`
: `View ${currentPath.path}`, : `View ${currentPath.path}`,
openGraph: { openGraph: {
@@ -58,15 +84,30 @@ export async function generateMetadata({ params }: Props, parent: ResolvedMetada
}, },
}; };
} }
export default async function RestPage({ params }: Props) { export default async function RestPage({ params }: Props) {
const { rest } = await params; const p = await params;
if (isDeployGuide(rest)) return <DeployGuidePage />; let rest = p.rest;
const { isInternalRoot, isConfiguratorPage, isEmbedPage, isEmbedRootPage, isGuidePage } = internal(p.rest);
if (isInternalRoot)
return (
<div className='grid grow place-items-center'>
<Status
icon='Lock'
message="This path is preserved for all internal pages, make sure you don't use this path on your google drive"
/>
</div>
);
if (isGuidePage) return <DeployPage />;
if (isConfiguratorPage) return <ConfiguratorPage />;
if (isEmbedRootPage) return notFound();
if (isEmbedPage) rest = rest.slice(2);
const paths = await ValidatePaths(rest); const paths = await ValidatePaths(rest);
if (!paths.success) notFound(); if (!paths.success) notFound();
const unlocked = await CheckPagePassword(paths.data); const unlocked = await CheckPagePassword(paths.data);
if (!unlocked.success && isEmbedPage)
return <ErrorComponent error={new Error("Protected file cannot be embedded")} />;
if (!unlocked.success) { if (!unlocked.success) {
return ( return (
<Password <Password
@@ -96,6 +137,7 @@ export default async function RestPage({ params }: Props) {
); );
if (currentPath.mimeType.includes("folder")) { if (currentPath.mimeType.includes("folder")) {
if (isEmbedPage) return <ErrorComponent error={new Error("Folder cannot be embedded")} />;
const [data, readme] = await Promise.all([ListFiles({ id: currentPath.id }), GetReadme(currentPath.id)]); const [data, readme] = await Promise.all([ListFiles({ id: currentPath.id }), GetReadme(currentPath.id)]);
if (!data.success) return <ErrorComponent error={new Error(data.error)} />; if (!data.success) return <ErrorComponent error={new Error(data.error)} />;
if (!readme.success) return <ErrorComponent error={new Error(readme.error)} />; if (!readme.success) return <ErrorComponent error={new Error(readme.error)} />;
@@ -135,109 +177,38 @@ export default async function RestPage({ params }: Props) {
if (file.error === "NotFound") notFound(); if (file.error === "NotFound") notFound();
return <ErrorComponent error={new Error(file.error)} />; return <ErrorComponent error={new Error(file.error)} />;
} }
if (!file.data) return <ErrorComponent error={new Error("Failed to get file data")} />;
const token = await CreateFileToken(file.data);
if (!token.success) return <ErrorComponent error={new Error(token.error)} />;
if (isEmbedPage) {
if (!currentPath.mimeType.includes("video") && !currentPath.mimeType.includes("audio"))
return (
<div className='max-w-screen fixed left-0 top-0 grid h-full max-h-screen w-full place-items-center bg-transparent p-2'>
<ErrorComponent error={new Error("Only video and audio file can be embedded")} />
</div>
);
return (
<div className='max-w-screen fixed left-0 top-0 grid h-full max-h-screen w-full place-items-center bg-transparent p-2'>
<EmbedPage
file={file.data}
type={currentPath.mimeType.includes("video") ? "video" : "audio"}
/>
</div>
);
}
return ( return (
<Layout> <Layout>
<PreviewLayout <PreviewLayout
data={file.data!} data={file.data}
fileType={ fileType={
file.data?.fileExtension && file.data?.mimeType file.data?.fileExtension && file.data?.mimeType
? getFileType(file.data.fileExtension, file.data.mimeType) ? getFileType(file.data.fileExtension, file.data.mimeType)
: "unknown" : "unknown"
} }
token={token.data}
/> />
</Layout> </Layout>
); );
// const paths = await CheckPaths(rest);
// if (!paths.success) notFound();
// const unlocked = await CheckPassword(paths.data);
// if (!unlocked.success) {
// if (!unlocked.path)
// throw new Error(`No path returned from password checking${unlocked.message && `, ${unlocked.message}`}`);
// return (
// <Password
// path={unlocked.path}
// checkPaths={paths.data}
// errorMessage={unlocked.message}
// />
// );
// }
// const encryptedId = paths.data.pop()?.id;
// if (!encryptedId) throw new Error("Failed to get encrypted ID, try to refresh the page.");
// const promise = [];
// const { data: file } = await gdrive.files.get({
// fileId: await decryptData(encryptedId),
// fields: "mimeType, fileExtension",
// supportsAllDrives: config.apiConfig.isTeamDrive,
// });
// if (!file.mimeType?.includes("folder")) {
// promise.push(GetFile(encryptedId));
// } else {
// promise.push(GetFiles({ id: encryptedId }));
// }
// promise.push(GetReadme(encryptedId));
// const [data, readme] = await Promise.all(promise).then((values) => {
// const file = Schema_File.safeParse(values[0]);
// if (file.success) {
// return values as [z.infer<typeof Schema_File>, string];
// } else {
// return values as [{ files: z.infer<typeof Schema_File>[]; nextPageToken?: string }, string];
// }
// });
// return (
// <div className={cn("h-fit w-full", "flex flex-col gap-4")}>
// <FileBreadcrumb
// data={rest.map((item, index, array) => ({
// label: decodeURIComponent(item),
// href: index === array.length - 1 ? undefined : `${item}`,
// }))}
// />
// <section
// slot='content'
// className='w-full'
// >
// {!("files" in data) ? (
// <PreviewLayout
// data={data}
// fileType={file.fileExtension && file.mimeType ? getFileType(file.fileExtension, file.mimeType) : "unknown"}
// />
// ) : (
// <>
// <Card>
// <CardHeader className='pb-0'>
// <div className='flex w-full items-center justify-between gap-4'>
// <CardTitle className='flex-grow'>Browse files</CardTitle>
// <FileActions />
// </div>
// <Separator />
// </CardHeader>
// <CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
// <FileExplorerLayout
// encryptedId={encryptedId}
// files={data.files}
// nextPageToken={data.nextPageToken}
// />
// </CardContent>
// </Card>
// </>
// )}
// </section>
// {readme && (
// <FileReadme
// content={readme}
// title={"README.md"}
// />
// )}
// </div>
// );
} }
+33 -1
View File
@@ -1,8 +1,40 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
export async function middleware(req: NextRequest) { export async function middleware(req: NextRequest) {
const response = NextResponse.next();
const pathname = req.nextUrl.pathname; const pathname = req.nextUrl.pathname;
const headers = new Headers(req.headers);
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self' *;
block-all-mixed-content;
upgrade-insecure-requests;
`
.replace(/\s+/g, " ")
.trim();
if (pathname.startsWith("/_/embed/")) {
headers.set("Content-Security-Policy", cspHeader);
headers.set("X-Frame-Options", "ALLOWALL");
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
}
const response = NextResponse.next({
request: {
headers,
},
});
response.headers.set("X-Pathname", req.nextUrl.pathname);
if (pathname.startsWith("/_/embed/")) {
response.headers.set("Content-Security-Policy", cspHeader);
return response;
}
// Skip the middleware if the pathname is the root // Skip the middleware if the pathname is the root
if (pathname === "/") return response; if (pathname === "/") return response;