mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
wip: Internal page (guide, configurator, embed)
This commit is contained in:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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">;
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -2,6 +2,7 @@ import { type Metadata, type ResolvedMetadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer";
|
||||
import { Status } from "~/components/global";
|
||||
import { Password } from "~/components/layout";
|
||||
import { PreviewLayout } from "~/components/preview";
|
||||
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 { CheckPagePassword } from "~/actions/password";
|
||||
import { ValidatePaths } from "~/actions/paths";
|
||||
|
||||
import config from "config";
|
||||
import { CreateFileToken } from "~/actions/token";
|
||||
|
||||
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 dynamic = "force-dynamic";
|
||||
export const dynamicParams = false;
|
||||
|
||||
type Props = {
|
||||
params: Promise<{
|
||||
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> {
|
||||
const { rest } = await params;
|
||||
if (isDeployGuide(rest)) return { title: "Deploy Guide", description: "Read on how to deploy this project" };
|
||||
const p = await params;
|
||||
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);
|
||||
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" };
|
||||
|
||||
const banner = await GetBanner(currentPath.id);
|
||||
if (isEmbedPage && !currentPath.mimeType.includes("video") && !currentPath.mimeType.includes("audio"))
|
||||
return { title: "Not Found" };
|
||||
|
||||
return {
|
||||
title: decodeURIComponent(currentPath.path),
|
||||
description: currentPath.mimeType.includes("folder")
|
||||
title: isEmbedPage ? `${decodeURIComponent(currentPath.path)} (embed)` : decodeURIComponent(currentPath.path),
|
||||
description: isEmbedPage
|
||||
? `Embed ${currentPath.path}`
|
||||
: currentPath.mimeType.includes("folder")
|
||||
? `Browse ${currentPath.path} files`
|
||||
: `View ${currentPath.path}`,
|
||||
openGraph: {
|
||||
@@ -58,15 +84,30 @@ export async function generateMetadata({ params }: Props, parent: ResolvedMetada
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RestPage({ params }: Props) {
|
||||
const { rest } = await params;
|
||||
if (isDeployGuide(rest)) return <DeployGuidePage />;
|
||||
const p = await params;
|
||||
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);
|
||||
if (!paths.success) notFound();
|
||||
|
||||
const unlocked = await CheckPagePassword(paths.data);
|
||||
if (!unlocked.success && isEmbedPage)
|
||||
return <ErrorComponent error={new Error("Protected file cannot be embedded")} />;
|
||||
if (!unlocked.success) {
|
||||
return (
|
||||
<Password
|
||||
@@ -96,6 +137,7 @@ export default async function RestPage({ params }: Props) {
|
||||
);
|
||||
|
||||
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)]);
|
||||
if (!data.success) return <ErrorComponent error={new Error(data.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();
|
||||
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 (
|
||||
<Layout>
|
||||
<PreviewLayout
|
||||
data={file.data!}
|
||||
data={file.data}
|
||||
fileType={
|
||||
file.data?.fileExtension && file.data?.mimeType
|
||||
? getFileType(file.data.fileExtension, file.data.mimeType)
|
||||
: "unknown"
|
||||
}
|
||||
token={token.data}
|
||||
/>
|
||||
</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
@@ -1,8 +1,40 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const response = NextResponse.next();
|
||||
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
|
||||
if (pathname === "/") return response;
|
||||
|
||||
Reference in New Issue
Block a user