Refactor CdnTable and related components for improved type safety and functionality; implement character creation and editing modals; enhance form handling with autosave; update Sentry configuration for production; clean up unused files and code.
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
TypedFormData,
|
||||
TypedFormDataShape,
|
||||
} from "@/app/(ui)/rubgram/type";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DialogClose } from "./ui/dialog";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
const AUTOSAVE_TTL_MS = 10 * 60 * 1000;
|
||||
const STORAGE_PREFIX = "form:autosave:";
|
||||
|
||||
type FormValues = Record<string, unknown>;
|
||||
|
||||
type FormContextValue = {
|
||||
id: string;
|
||||
values: FormValues;
|
||||
loading: boolean;
|
||||
setValue: (name: string, value: unknown) => void;
|
||||
updateValues: (updater: (prev: FormValues) => FormValues) => void;
|
||||
clear: () => void;
|
||||
submit: () => Promise<void>;
|
||||
};
|
||||
|
||||
const FormContext = React.createContext<FormContextValue | null>(null);
|
||||
|
||||
function useFormContext() {
|
||||
const ctx = React.useContext(FormContext);
|
||||
if (!ctx) throw new Error("FormInput/FormAction must be inside FormProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function storageKey(id: string) {
|
||||
return `${STORAGE_PREFIX}${id}`;
|
||||
}
|
||||
|
||||
function safeParseAutosave(value: string | null) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as { savedAt: number; values: FormValues };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneAutosaveValues(values: FormValues) {
|
||||
const pruned: FormValues = {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value == null) continue;
|
||||
if (value instanceof Blob) continue;
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.filter(
|
||||
(item) =>
|
||||
typeof item === "string" ||
|
||||
typeof item === "number" ||
|
||||
typeof item === "boolean",
|
||||
);
|
||||
if (items.length) pruned[key] = items;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
pruned[key] = value;
|
||||
}
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
function appendFormValue(form: FormData, key: string, value: unknown) {
|
||||
if (value == null) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) appendFormValue(form, key, item);
|
||||
return;
|
||||
}
|
||||
if (value instanceof Blob) {
|
||||
form.append(key, value);
|
||||
return;
|
||||
}
|
||||
form.append(key, typeof value === "string" ? value : String(value));
|
||||
}
|
||||
|
||||
function toTypedFormData<T extends TypedFormDataShape>(
|
||||
formData: FormData,
|
||||
): TypedFormData<T> {
|
||||
const base = formData as unknown as TypedFormData<T>;
|
||||
if (!("raw" in base)) {
|
||||
Object.defineProperty(base, "raw", {
|
||||
value: formData,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function getInputValue(eventOrValue: unknown) {
|
||||
const event = eventOrValue as React.ChangeEvent<HTMLInputElement>;
|
||||
const target = event?.target as
|
||||
| (HTMLInputElement & { files?: FileList | null })
|
||||
| undefined;
|
||||
if (target) {
|
||||
if (target.type === "checkbox") return target.checked;
|
||||
if (target.files)
|
||||
return target.multiple
|
||||
? Array.from(target.files)
|
||||
: target.files[0] || null;
|
||||
if (target instanceof HTMLSelectElement && target.multiple)
|
||||
return Array.from(target.selectedOptions).map((opt) => opt.value);
|
||||
return target.value;
|
||||
}
|
||||
return eventOrValue;
|
||||
}
|
||||
|
||||
type SubmitFnResult =
|
||||
| {
|
||||
toast?: string;
|
||||
close?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
type FormProviderProps<T extends TypedFormDataShape> = Omit<
|
||||
React.ComponentProps<"form">,
|
||||
"id" | "onSubmit"
|
||||
> & {
|
||||
// Unique Autosave Key
|
||||
id: string;
|
||||
onSubmit?: (
|
||||
form: TypedFormData<T>,
|
||||
) => SubmitFnResult | Promise<SubmitFnResult>;
|
||||
values?: FormValues;
|
||||
};
|
||||
|
||||
export function FormProvider<T extends TypedFormDataShape>({
|
||||
id,
|
||||
onSubmit,
|
||||
className,
|
||||
children,
|
||||
values: defaultValues = {},
|
||||
...props
|
||||
}: FormProviderProps<T>) {
|
||||
const [values, setValues] = React.useState<FormValues>(defaultValues);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const formRef = React.useRef<HTMLFormElement | null>(null);
|
||||
const [storageLock, setStorageLock] = React.useState<"r" | "w" | null>("r");
|
||||
const closerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const stored = safeParseAutosave(localStorage.getItem(storageKey(id)));
|
||||
if (!stored) return;
|
||||
if (Date.now() - stored.savedAt > AUTOSAVE_TTL_MS) {
|
||||
localStorage.removeItem(storageKey(id));
|
||||
return;
|
||||
}
|
||||
setValues(stored.values || {});
|
||||
setStorageLock(null);
|
||||
}, [id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (storageLock === "r") return;
|
||||
setStorageLock("w");
|
||||
const pruned = pruneAutosaveValues(values);
|
||||
localStorage.setItem(
|
||||
storageKey(id),
|
||||
JSON.stringify({ savedAt: Date.now(), values: pruned }),
|
||||
);
|
||||
setStorageLock(null);
|
||||
}, [id, values, storageLock]);
|
||||
|
||||
const setValue = React.useCallback((name: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
}, []);
|
||||
const updateValues = React.useCallback(
|
||||
(updater: (prev: FormValues) => FormValues) => {
|
||||
setValues((prev) => updater(prev));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clear = React.useCallback(() => {
|
||||
setValues({});
|
||||
localStorage.removeItem(storageKey(id));
|
||||
formRef.current?.reset();
|
||||
}, [id]);
|
||||
|
||||
const submit = React.useCallback(async () => {
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
appendFormValue(formData, key, value);
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await onSubmit?.(toTypedFormData(formData));
|
||||
if (res && typeof res === "object") {
|
||||
if ("error" in res) {
|
||||
const { error } = res;
|
||||
if (typeof error === "string") throw new Error(error);
|
||||
}
|
||||
if ("toast" in res) {
|
||||
const { toast: toastMsg } = res;
|
||||
if (typeof toastMsg === "string") toast(toastMsg);
|
||||
}
|
||||
if ("close" in res) {
|
||||
const { close } = res;
|
||||
if (typeof close === "boolean") {
|
||||
if (close) closerRef.current?.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "Something went wrong";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onSubmit, values]);
|
||||
|
||||
const contextValue = React.useMemo<FormContextValue>(
|
||||
() => ({ id, values, loading, setValue, updateValues, clear, submit }),
|
||||
[id, values, loading, setValue, updateValues, clear, submit],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormContext.Provider value={contextValue}>
|
||||
<form
|
||||
ref={formRef}
|
||||
id={id}
|
||||
{...props}
|
||||
className={cn("grid gap-4 [&>label]:-mb-2", className)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
<DialogClose ref={closerRef} />
|
||||
</FormContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type FormInputProps = {
|
||||
name: string;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: typescript too strict :<
|
||||
override?: (original: any) => Partial<FormValues> | void;
|
||||
label?: string;
|
||||
subLabel?: string;
|
||||
children: React.ReactElement;
|
||||
};
|
||||
|
||||
export function FormInput({
|
||||
name,
|
||||
override,
|
||||
children,
|
||||
label,
|
||||
subLabel,
|
||||
}: FormInputProps) {
|
||||
const { values, setValue, updateValues } = useFormContext();
|
||||
const childProps = children.props as Record<string, unknown>;
|
||||
const value = (values[name] ?? childProps.defaultValue) || "";
|
||||
const nextProps: Record<string, unknown> = { name, defaultValue: undefined };
|
||||
const applyValue = React.useCallback(
|
||||
(nextValue: unknown) => {
|
||||
if (!override) {
|
||||
setValue(name, nextValue);
|
||||
return;
|
||||
}
|
||||
updateValues((prev) => {
|
||||
const nextValues = { ...prev, [name]: nextValue };
|
||||
const overrides = override(nextValues);
|
||||
if (!overrides || typeof overrides !== "object") return nextValues;
|
||||
return { ...nextValues, ...overrides };
|
||||
});
|
||||
},
|
||||
[name, override, setValue, updateValues],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: must run only on initial render
|
||||
React.useEffect(() => {
|
||||
if (childProps.defaultValue !== undefined)
|
||||
applyValue(childProps.defaultValue);
|
||||
}, []);
|
||||
|
||||
nextProps.onCheckedChange = (checked: boolean) => {
|
||||
applyValue(checked);
|
||||
(childProps.onCheckedChange as ((value: boolean) => void) | undefined)?.(
|
||||
checked,
|
||||
);
|
||||
};
|
||||
nextProps.onValueChange = (nextValue: unknown) => {
|
||||
applyValue(nextValue);
|
||||
(childProps.onValueChange as ((value: unknown) => void) | undefined)?.(
|
||||
nextValue,
|
||||
);
|
||||
};
|
||||
nextProps.onChange = (event: unknown) => {
|
||||
const nextValue = getInputValue(event);
|
||||
applyValue(nextValue);
|
||||
(childProps.onChange as ((value: unknown) => void) | undefined)?.(event);
|
||||
};
|
||||
if (value !== undefined) {
|
||||
if (typeof value === "boolean") nextProps.checked = value;
|
||||
else {
|
||||
nextProps.value = value;
|
||||
nextProps.checked = Boolean(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (label) {
|
||||
nextProps.id = `forminput-${name}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{label && (
|
||||
<Label htmlFor={`forminput-${name}`}>
|
||||
{label}{" "}
|
||||
{subLabel && (
|
||||
<span className="font-normal opacity-70">{subLabel}</span>
|
||||
)}
|
||||
</Label>
|
||||
)}
|
||||
{React.cloneElement(children, nextProps)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type FormActionProps = Omit<
|
||||
React.ComponentProps<"button">,
|
||||
"type" | "children"
|
||||
> & {
|
||||
type: "clear" | "submit";
|
||||
children?: React.ReactNode;
|
||||
loading?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
export function FormAction({
|
||||
type,
|
||||
children,
|
||||
asChild,
|
||||
loading: loadingComponent,
|
||||
...props
|
||||
}: FormActionProps) {
|
||||
const { loading, clear } = useFormContext();
|
||||
const isSubmit = type === "submit";
|
||||
|
||||
const buttonProps = {
|
||||
...props,
|
||||
type: (isSubmit ? "submit" : "button") as "submit" | "button",
|
||||
disabled: isSubmit && loading,
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
props.onClick?.(event);
|
||||
if (!event.defaultPrevented && type === "clear") clear();
|
||||
},
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(
|
||||
children as React.ReactElement<unknown>,
|
||||
buttonProps,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button {...buttonProps}>
|
||||
{loadingComponent && loading ? loadingComponent : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user