ldc-ovoteam/resources/ts/back/Components/PageConfigs/ImportConfigJson.tsx
Jérémy Munsch cd2d8ed397
refactor(i18n): replace GetTranslation with trans function
- Updated translation utility to improve consistency and reduce redundancy.
- Removed deprecated useTrans hook and related code.
- Introduced a new Zustand store for managing translations and locale settings.
- Cleaned up unused hooks and files related to localization.
2026-10-19 17:43:33 +02:00

438 lines
12 KiB
TypeScript

import {
JsonEditor,
type IconReplacements,
type LocalisedStrings,
} from "json-edit-react";
import {
CheckIcon,
ChevronDownIcon,
CopyIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
UploadIcon,
XIcon,
} from "lucide-react";
import { useRef, useState, type ChangeEvent, type DragEvent } from "react";
import z from "zod";
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import InputError from "@/back/Components/InputError";
import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import str from "@/hooks/use-string";
import trans from "@/hooks/use-translations";
import { cn } from "@/lib/utils";
type JsonPrimitive = string | number | boolean | null;
interface JsonObject {
[key: string]: JsonPrimitive | JsonObject | JsonObject[];
}
interface ImportConfigJsonProps {
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(trans(key)).ucFirst().value();
const transJsonEditorMaxSize = trans(
"uwil-dynamic-pages::trans.json_editor.validation.max_size",
{
params: { value: "2MB" },
},
);
const transJsonEditorMimes = trans(
"uwil-dynamic-pages::trans.json_editor.validation.mimes",
{
params: { values: ".json" },
},
);
const transJsonEditorInvalidFileJson = trans(
"uwil-dynamic-pages::trans.json_editor.validation.invalid_file_json",
);
const transInvalidContentJson = trans(
"uwil-dynamic-pages::trans.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(
"uwil-dynamic-pages::trans.json_editor.item_single",
),
ITEMS_MULTIPLE: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.item_multiple",
),
KEY_NEW: useGetTranslation("uwil-dynamic-pages::trans.json_editor.key_new"),
KEY_SELECT: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.key_select",
),
NO_KEY_OPTIONS: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.no_key_options",
),
ERROR_KEY_EXISTS: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.error_key_exists",
),
ERROR_INVALID_JSON: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.error_invalid_json",
),
ERROR_UPDATE: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.error_update",
),
ERROR_DELETE: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.error_delete",
),
ERROR_ADD: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.error_add",
),
DEFAULT_STRING: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.default_string",
),
DEFAULT_NEW_KEY: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.default_new_key",
),
SHOW_LESS: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.show_less",
),
EMPTY_STRING: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.empty_string",
),
TOOLTIP_COPY: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.copy",
),
TOOLTIP_EDIT: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.edit",
),
TOOLTIP_DELETE: useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.delete",
),
TOOLTIP_ADD: useGetTranslation("uwil-dynamic-pages::trans.json_editor.add"),
};
const hsl = (varName: string) => `hsl(var(${varName}))`;
const jsonEditorTheme = {
displayName: false,
container: {
backgroundColor: hsl("--background"),
color: hsl("--foreground"),
},
property: hsl("--primary"),
bracket: hsl("--muted-foreground"),
itemCount: hsl("--muted-foreground"),
string: hsl("--foreground"),
number: hsl("--informative"),
boolean: hsl("--preventive"),
null: hsl("--destructive"),
input: [hsl("--foreground"), { backgroundColor: hsl("--input") }],
error: hsl("--destructive-foreground"),
iconCollection: hsl("--muted-foreground"),
iconEdit: hsl("--muted-foreground"),
iconDelete: hsl("--destructive"),
iconAdd: hsl("--successive"),
iconCopy: hsl("--muted-foreground"),
iconOk: hsl("--successive"),
iconCancel: hsl("--destructive"),
};
/**
* 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) as JsonObject[];
// 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 = (event: ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
if (file) void 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] as File | null;
if (file) void 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">
{trans("uwil-dynamic-pages::trans.json_editor.select-file")}
</p>
<small>
{str(
trans("uwil-dynamic-pages::trans.json_editor.allowed_format", {
params: { format: ".json" },
}),
)
.ucFirst()
.value()}
.{" "}
{str(
trans("uwil-dynamic-pages::trans.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(
"uwil-dynamic-pages::trans.json_editor.modal_title",
)}
</DialogTitle>
<DialogDescription>
{useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.modal_description",
)}
</DialogDescription>
</DialogHeader>
<div className="rounded-md w-full max-h-[350px] overflow-scroll border">
{/* JSON editor */}
<JsonEditor
data={jsonText}
setData={(val) => {
setJsonText(val as JsonObject[]);
}}
icons={icons}
translations={translations}
showIconTooltips
collapseAnimationTime={200}
maxWidth="100%"
searchFilter="all"
theme={jsonEditorTheme}
/>
</div>
<InputError message={editorErrors["editor-error"]} />
<DialogFooter className="sm:justify-between">
<SimpleSwitch
label={useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.overwrite",
)}
name="overwrite"
checked={overwrite}
setData={(_, value) => {
setOverwrite(value);
}}
/>
<div className="flex justify-center items-center gap-1.5">
<BtnOptionnalLinkWithTooltip
content={useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.cancel",
)}
tooltipContent={useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.tooltip_cancel",
)}
type="button"
variant="secondary"
size="default"
onClick={resetComponent}
/>
<BtnOptionnalLinkWithTooltip
content={useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.validate",
)}
tooltipContent={useGetTranslation(
"uwil-dynamic-pages::trans.json_editor.tooltip_validate",
)}
type="button"
size="default"
onClick={handleConfirm}
/>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}