mirror of
https://github.com/Nezumi-2711/next-gdrive-index.git
synced 2026-09-22 13:38:38 +00:00
233 lines
7.0 KiB
TypeScript
233 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { icons } from "lucide-react";
|
|
|
|
import { ButtonLoading, Icon } from "~/components/Global";
|
|
import { Input } from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
|
import { Separator } from "~/components/ui/separator";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
|
|
|
import { cn } from "~/utils/cn";
|
|
|
|
import { ButtonState, ConfigurationCategory, ConfigurationKeys, ConfigurationValue } from "~/types/schema";
|
|
|
|
export type ConfigInputs<
|
|
T extends ConfigurationCategory = ConfigurationCategory,
|
|
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
|
> = ConfigInputProps<T, K> | ConfigInputGroupProps<T, K> | ConfigInputSeparatorProps;
|
|
|
|
type ConfigInputProps<
|
|
T extends ConfigurationCategory = ConfigurationCategory,
|
|
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
|
> = {
|
|
inputKey: K;
|
|
title: string;
|
|
type: "text" | "password" | "number" | "select";
|
|
description?: string;
|
|
required?: boolean;
|
|
action?: {
|
|
label: string;
|
|
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
state: ButtonState;
|
|
icon?: keyof typeof icons;
|
|
};
|
|
value: ConfigurationValue<T, K>;
|
|
onValueChange: (key: K, value: any) => void;
|
|
error?: string;
|
|
onError?: (error: string) => void;
|
|
validation?: (value: string) =>
|
|
| { success: boolean; message: string }
|
|
| Promise<{
|
|
success: boolean;
|
|
message: string;
|
|
}>;
|
|
readOnly?: boolean;
|
|
minMax?: { min?: number; max?: number };
|
|
disabled?: boolean;
|
|
placeholder?: string;
|
|
};
|
|
|
|
type ConfigInputGroupProps<
|
|
T extends ConfigurationCategory = ConfigurationCategory,
|
|
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
|
> = {
|
|
inputKey: string;
|
|
type: "group";
|
|
columns: number;
|
|
children: (ConfigInputProps<T, K> & {
|
|
columnSpan?: number;
|
|
})[];
|
|
};
|
|
type ConfigInputSeparatorProps = {
|
|
inputKey: string;
|
|
type: "separator";
|
|
};
|
|
export default function ConfigurationInput<
|
|
T extends ConfigurationCategory = ConfigurationCategory,
|
|
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
|
>(props: ConfigInputProps<T, K> | ConfigInputGroupProps<T, K> | ConfigInputSeparatorProps) {
|
|
const key = String(props.inputKey);
|
|
return (
|
|
<>
|
|
{props.type !== "separator" && props.type !== "group" && (
|
|
<>
|
|
<Inputs<T, K> {...props} />
|
|
</>
|
|
)}
|
|
|
|
{props.type === "separator" && <Separator />}
|
|
|
|
{props.type === "group" && (
|
|
<div
|
|
slot='group'
|
|
className='grid w-full gap-1.5'
|
|
style={{
|
|
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
|
}}
|
|
>
|
|
{props.children.map((child, index) => (
|
|
<Inputs<T, K>
|
|
key={`${child.inputKey as string}@${index}`}
|
|
columnSpan={child.columnSpan}
|
|
{...child}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Inputs<
|
|
T extends ConfigurationCategory = ConfigurationCategory,
|
|
K extends ConfigurationKeys<T> = ConfigurationKeys<T>,
|
|
>(
|
|
props: ConfigInputProps<T, K> & {
|
|
columnSpan?: number;
|
|
},
|
|
) {
|
|
const key = String(props.inputKey);
|
|
return (
|
|
<div
|
|
id={key}
|
|
slot={`input-${key}`}
|
|
className='flex w-full flex-col gap-1.5'
|
|
style={{
|
|
gridColumn: `span ${props.columnSpan || 1} / span ${props.columnSpan || 1}`,
|
|
}}
|
|
>
|
|
<div
|
|
slot='label'
|
|
className='flex items-center gap-1.5'
|
|
>
|
|
<Label
|
|
htmlFor={key}
|
|
aria-required={props.required}
|
|
>
|
|
{props.title} <span className='text-sm text-muted-foreground'>{!props.required ? "(optional)" : ""}</span>
|
|
</Label>
|
|
{props.description && (
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
onClick={(e) => e.preventDefault()}
|
|
className='cursor-default'
|
|
>
|
|
<Icon
|
|
name='Info'
|
|
size={"1rem"}
|
|
className='text-muted-foreground'
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side='right'
|
|
className='max-w-screen-sm'
|
|
>
|
|
<p className='max-w-screen-sm !whitespace-pre-wrap'>{props.description}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
slot='input'
|
|
className='grid w-full grid-cols-6 gap-1.5'
|
|
>
|
|
<div className={cn("w-full", props.action ? "col-span-5" : "col-span-full")}>
|
|
{props.type === "text" || props.type === "password" || props.type === "number" ? (
|
|
<Input
|
|
id={key}
|
|
name={key}
|
|
type={props.type}
|
|
value={props.value as string}
|
|
readOnly={props.readOnly || false}
|
|
min={props.minMax?.min}
|
|
max={props.minMax?.max}
|
|
disabled={props.disabled}
|
|
placeholder={props.placeholder}
|
|
onChange={(e) => {
|
|
if (props.error) {
|
|
props.onError?.("");
|
|
}
|
|
props.onValueChange(props.inputKey, e.target.value as ConfigurationValue<T, K>);
|
|
}}
|
|
onBlur={async () => {
|
|
try {
|
|
props.onError?.("");
|
|
|
|
if (props.required && !(props.value as string).trim()) {
|
|
throw new Error(`${props.title} is required.`);
|
|
}
|
|
|
|
if (props.validation) {
|
|
const result = await props.validation(props.value as string);
|
|
if (!result.success) throw new Error(result.message);
|
|
}
|
|
} catch (error) {
|
|
const e = error as Error;
|
|
props.onError?.(e.message);
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
props.type === "select" && (
|
|
<Select
|
|
disabled={props.disabled}
|
|
value={String(props.value)}
|
|
onValueChange={(value) => {
|
|
props.onValueChange(props.inputKey, (value as string) === "true");
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder='Select an option' />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value='true'>Enabled</SelectItem>
|
|
<SelectItem value='false'>Disabled</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)
|
|
)}
|
|
</div>
|
|
{props.action && (
|
|
<ButtonLoading
|
|
variant={"secondary"}
|
|
onClick={props.action.onClick}
|
|
state={props.action.state}
|
|
icon={props.action.icon}
|
|
>
|
|
{props.action.label}
|
|
</ButtonLoading>
|
|
)}
|
|
</div>
|
|
|
|
<div slot='message'>
|
|
<span className={cn("block text-sm text-destructive", props.error ? "opacity-100" : "select-none opacity-0")}>
|
|
{props.error}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|