380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
// * Packages
|
|
import { ChangeEvent, DragEvent, useRef, useState } from "react";
|
|
import {
|
|
JsonEditor,
|
|
IconReplacements,
|
|
LocalisedStrings,
|
|
} from "json-edit-react";
|
|
import {
|
|
CheckIcon,
|
|
ChevronDownIcon,
|
|
CopyIcon,
|
|
PencilIcon,
|
|
PlusIcon,
|
|
Trash2Icon,
|
|
UploadIcon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import z from "zod";
|
|
// * Components shadcn
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
// * Other
|
|
import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
|
|
import InputError from "@/back/Components/InputError";
|
|
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
|
import { cn } from "@/lib/utils";
|
|
import str from "@/hooks/use-string";
|
|
import useTrans from "@/hooks/use-translations";
|
|
|
|
type JsonPrimitive = string | number | boolean | null;
|
|
|
|
type JsonObject = {
|
|
[key: string]: JsonPrimitive | JsonObject | JsonObject[];
|
|
};
|
|
|
|
type ImportConfigJsonProps = {
|
|
// eslint-disable-next-line no-unused-vars
|
|
onConfirm: (data: JsonObject[], overwrite: boolean) => void;
|
|
};
|
|
|
|
// Zod schemas.
|
|
const jsonObjectSchema: z.ZodType<JsonObject> = z.lazy(() =>
|
|
z.record(
|
|
z.string(),
|
|
z.union([
|
|
z.string(),
|
|
z.number(),
|
|
z.boolean(),
|
|
z.null(),
|
|
jsonObjectSchema,
|
|
z.array(jsonObjectSchema),
|
|
]),
|
|
),
|
|
);
|
|
const jsonArraySchema = z.array(jsonObjectSchema);
|
|
|
|
export default function ImportConfigJson({ onConfirm }: ImportConfigJsonProps) {
|
|
// Json content.
|
|
const [jsonText, setJsonText] = useState<JsonObject[]>([]);
|
|
|
|
// Modale state.
|
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
|
// Event state.
|
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
|
// Overwrite state.
|
|
const [overwrite, setOverwrite] = useState<boolean>(false);
|
|
|
|
// Local errors.
|
|
const [globalErrors, setGlobalErrors] = useState<Record<string, string>>(
|
|
{},
|
|
);
|
|
const [editorErrors, setEditorErrors] = useState<Record<string, string>>(
|
|
{},
|
|
);
|
|
|
|
// Input file reference.
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Maximum file size accepted (2MB).
|
|
const maxSize = 2 * 1024 * 1024;
|
|
|
|
/**
|
|
* Get translation of specific key.
|
|
*
|
|
* @param key string
|
|
* @return string
|
|
*/
|
|
const useGetTranslation = (key: string): string =>
|
|
str(useTrans(key)).ucFirst().value();
|
|
|
|
const transJsonEditorMaxSize = useTrans(
|
|
"back.json_editor.validation.max_size",
|
|
{
|
|
params: { value: "2MB" },
|
|
},
|
|
);
|
|
const transJsonEditorMimes = useTrans("back.json_editor.validation.mimes", {
|
|
params: { values: ".json" },
|
|
});
|
|
const transJsonEditorInvalidFileJson = useTrans(
|
|
"back.json_editor.validation.invalid_file_json",
|
|
);
|
|
const transInvalidContentJson = useTrans(
|
|
"back.json_editor.validation.invalid_content_json",
|
|
);
|
|
|
|
// Custom icons.
|
|
const icons: IconReplacements = {
|
|
add: <PlusIcon className="w-4 h-4" />,
|
|
edit: <PencilIcon className="w-4 h-4" />,
|
|
delete: <Trash2Icon className="w-4 h-4" />,
|
|
copy: <CopyIcon className="w-4 h-4" />,
|
|
ok: <CheckIcon className="w-4 h-4" />,
|
|
cancel: <XIcon className="w-4 h-4" />,
|
|
chevron: <ChevronDownIcon className="cursor-pointer w-4 h-4" />,
|
|
};
|
|
|
|
// Custom translations.
|
|
const translations: LocalisedStrings = {
|
|
ITEM_SINGLE: useGetTranslation("back.json_editor.item_single"),
|
|
ITEMS_MULTIPLE: useGetTranslation("back.json_editor.item_multiple"),
|
|
KEY_NEW: useGetTranslation("back.json_editor.key_new"),
|
|
KEY_SELECT: useGetTranslation("back.json_editor.key_select"),
|
|
NO_KEY_OPTIONS: useGetTranslation("back.json_editor.no_key_options"),
|
|
ERROR_KEY_EXISTS: useGetTranslation(
|
|
"back.json_editor.error_key_exists",
|
|
),
|
|
ERROR_INVALID_JSON: useGetTranslation(
|
|
"back.json_editor.error_invalid_json",
|
|
),
|
|
ERROR_UPDATE: useGetTranslation("back.json_editor.error_update"),
|
|
ERROR_DELETE: useGetTranslation("back.json_editor.error_delete"),
|
|
ERROR_ADD: useGetTranslation("back.json_editor.error_add"),
|
|
DEFAULT_STRING: useGetTranslation("back.json_editor.default_string"),
|
|
DEFAULT_NEW_KEY: useGetTranslation("back.json_editor.default_new_key"),
|
|
SHOW_LESS: useGetTranslation("back.json_editor.show_less"),
|
|
EMPTY_STRING: useGetTranslation("back.json_editor.empty_string"),
|
|
TOOLTIP_COPY: useGetTranslation("back.json_editor.copy"),
|
|
TOOLTIP_EDIT: useGetTranslation("back.json_editor.edit"),
|
|
TOOLTIP_DELETE: useGetTranslation("back.json_editor.delete"),
|
|
TOOLTIP_ADD: useGetTranslation("back.json_editor.add"),
|
|
};
|
|
|
|
/**
|
|
* Simulates click on input file.
|
|
*
|
|
* @return void
|
|
*/
|
|
const openFilePicker = (): void => {
|
|
inputRef.current?.click();
|
|
};
|
|
/**
|
|
* Set json from content file.
|
|
*
|
|
* @param file File
|
|
* @return Promise<void>
|
|
*/
|
|
const processFile = async (file: File): Promise<void> => {
|
|
if (file.size > maxSize) {
|
|
setGlobalErrors((prev) => ({
|
|
...prev,
|
|
"global-error": transJsonEditorMaxSize,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (!file.name.endsWith(".json")) {
|
|
setGlobalErrors((prev) => ({
|
|
...prev,
|
|
"global-error": transJsonEditorMimes,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const text = await file.text();
|
|
const parsed = JSON.parse(text);
|
|
|
|
// Validate json with zod.
|
|
jsonArraySchema.parse(parsed);
|
|
|
|
setJsonText(parsed);
|
|
setGlobalErrors({});
|
|
setIsOpen(true);
|
|
} catch {
|
|
setGlobalErrors((prev) => ({
|
|
...prev,
|
|
"global-error": transJsonEditorInvalidFileJson,
|
|
}));
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Reset component.
|
|
*
|
|
* @return void
|
|
*/
|
|
const resetComponent = (): void => {
|
|
setIsOpen(false);
|
|
setTimeout(() => {
|
|
setJsonText([]);
|
|
setGlobalErrors({});
|
|
setEditorErrors({});
|
|
setOverwrite(false);
|
|
}, 200);
|
|
};
|
|
|
|
/**
|
|
* Action when changing file.
|
|
*
|
|
* @param event
|
|
* @return void
|
|
*/
|
|
const handleFileChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
|
const file = e.target.files?.[0];
|
|
if (file) processFile(file);
|
|
};
|
|
|
|
/**
|
|
* Action when droping file.
|
|
*
|
|
* @param event
|
|
* @return void
|
|
*/
|
|
const handleDrop = (event: DragEvent<HTMLDivElement>): void => {
|
|
event.preventDefault();
|
|
setIsDragging(false);
|
|
|
|
const file = event.dataTransfer.files?.[0];
|
|
if (file) processFile(file);
|
|
};
|
|
|
|
/**
|
|
* Confirm the json.
|
|
*
|
|
* @return void
|
|
*/
|
|
const handleConfirm = (): void => {
|
|
try {
|
|
// Validate json with zod.
|
|
jsonArraySchema.parse(jsonText);
|
|
onConfirm(jsonText, overwrite);
|
|
resetComponent();
|
|
} catch {
|
|
setEditorErrors((prev) => ({
|
|
...prev,
|
|
"editor-error": transInvalidContentJson,
|
|
}));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Dropzone */}
|
|
<Button
|
|
variant="ghost"
|
|
type="button"
|
|
className={cn(
|
|
"flex border border-dashed overflow-hidden whitespace-normal [&_svg]:!pointer-events-auto [&_svg]:!size-auto [&_svg]:!shrink text-muted-foreground w-full h-36",
|
|
{ "text-accent-foreground bg-accent": isDragging },
|
|
{
|
|
"border-destructive mb-1.5":
|
|
Object.values(globalErrors).length,
|
|
},
|
|
)}
|
|
onClick={openFilePicker}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
}}
|
|
onDragLeave={() => setIsDragging(false)}
|
|
// @ts-expect-error function return are not compatible for ts
|
|
onDrop={handleDrop}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept=".json,application/json"
|
|
className="hidden"
|
|
onChange={handleFileChange}
|
|
/>
|
|
<div className="flex flex-col justify-center items-center">
|
|
<UploadIcon />
|
|
<p className="text-center break-normal w-full mt-2">
|
|
{useTrans("back.json_editor.select-file")}
|
|
</p>
|
|
<small>
|
|
{str(
|
|
useTrans("back.json_editor.allowed_format", {
|
|
params: { format: ".json" },
|
|
}),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
.{" "}
|
|
{str(
|
|
useTrans("back.json_editor.rules_weight", {
|
|
params: { size: "2MB" },
|
|
}),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</small>
|
|
</div>
|
|
</Button>
|
|
<InputError message={globalErrors["global-error"]} />
|
|
{/* Modal */}
|
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{useGetTranslation("back.json_editor.modal_title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{useGetTranslation(
|
|
"back.json_editor.modal_description",
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="rounded-md w-full max-h-[350px] overflow-scroll">
|
|
{/* JSON editor */}
|
|
<JsonEditor
|
|
data={jsonText}
|
|
setData={(val) => setJsonText(val as JsonObject[])}
|
|
icons={icons}
|
|
translations={translations}
|
|
showIconTooltips
|
|
collapseAnimationTime={200}
|
|
maxWidth="100%"
|
|
searchFilter="all"
|
|
/>
|
|
</div>
|
|
<InputError message={editorErrors["editor-error"]} />
|
|
<DialogFooter className="sm:justify-between">
|
|
<SimpleSwitch
|
|
label={useGetTranslation(
|
|
"back.json_editor.overwrite",
|
|
)}
|
|
name="overwrite"
|
|
checked={overwrite}
|
|
setData={(_, value) => setOverwrite(value)}
|
|
/>
|
|
<div className="flex justify-center items-center gap-1.5">
|
|
<BtnOptionnalLinkWithTooltip
|
|
content={useGetTranslation(
|
|
"back.json_editor.cancel",
|
|
)}
|
|
tooltipContent={useGetTranslation(
|
|
"back.json_editor.tooltip_cancel",
|
|
)}
|
|
type="button"
|
|
variant="secondary"
|
|
size="default"
|
|
onClick={resetComponent}
|
|
/>
|
|
<BtnOptionnalLinkWithTooltip
|
|
content={useGetTranslation(
|
|
"back.json_editor.validate",
|
|
)}
|
|
tooltipContent={useGetTranslation(
|
|
"back.json_editor.tooltip_validate",
|
|
)}
|
|
type="button"
|
|
size="default"
|
|
onClick={handleConfirm}
|
|
/>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|