feat(donate): add donate UI
- rework form core and migrate all old usages
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
// oxlint-disable next/no-img-element
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Cropper,
|
||||
CropperCropArea,
|
||||
CropperDescription,
|
||||
CropperImage,
|
||||
} from "@/components/ui/cropper";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
|
||||
// Define type for pixel crop area
|
||||
type Area = { x: number; y: number; width: number; height: number };
|
||||
|
||||
// Helper function to create a cropped image blob
|
||||
const createImage = (url: string): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener("load", () => resolve(image));
|
||||
image.addEventListener("error", (error) => reject(error));
|
||||
image.setAttribute("crossOrigin", "anonymous"); // Needed for canvas Tainted check
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
async function getCroppedImg(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
outputWidth: number = pixelCrop.width, // Optional: specify output size
|
||||
outputHeight: number = pixelCrop.height,
|
||||
): Promise<Blob | null> {
|
||||
try {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set canvas size to desired output size
|
||||
canvas.width = outputWidth;
|
||||
canvas.height = outputHeight;
|
||||
|
||||
// Draw the cropped image onto the canvas
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
outputWidth, // Draw onto the output size
|
||||
outputHeight,
|
||||
);
|
||||
|
||||
// Convert canvas to blob
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
}, "image/jpeg"); // Specify format and quality if needed
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in getCroppedImg:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Component() {
|
||||
const [
|
||||
{ files, isDragging },
|
||||
{
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
openFileDialog,
|
||||
removeFile,
|
||||
getInputProps,
|
||||
},
|
||||
] = useFileUpload({
|
||||
accept: "image/*",
|
||||
});
|
||||
|
||||
const previewUrl = files[0]?.preview || null;
|
||||
const fileId = files[0]?.id;
|
||||
|
||||
const [finalImageUrl, setFinalImageUrl] = useState<string | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// Ref to track the previous file ID to detect new uploads
|
||||
const previousFileIdRef = useRef<string | undefined | null>(null);
|
||||
|
||||
// State to store the desired crop area in pixels
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
// State for zoom level
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
// Callback for Cropper to provide crop data - Wrap with useCallback
|
||||
const handleCropChange = useCallback((pixels: Area | null) => {
|
||||
setCroppedAreaPixels(pixels);
|
||||
}, []);
|
||||
|
||||
const handleApply = async () => {
|
||||
// Check if we have the necessary data
|
||||
if (!previewUrl || !fileId || !croppedAreaPixels) {
|
||||
console.error("Missing data for apply:", {
|
||||
croppedAreaPixels,
|
||||
fileId,
|
||||
previewUrl,
|
||||
});
|
||||
// Remove file if apply is clicked without crop data?
|
||||
if (fileId) {
|
||||
removeFile(fileId);
|
||||
setCroppedAreaPixels(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get the cropped image blob using the helper
|
||||
const croppedBlob = await getCroppedImg(previewUrl, croppedAreaPixels);
|
||||
|
||||
if (!croppedBlob) {
|
||||
throw new Error("Failed to generate cropped image blob.");
|
||||
}
|
||||
|
||||
// 2. Create a NEW object URL from the cropped blob
|
||||
const newFinalUrl = URL.createObjectURL(croppedBlob);
|
||||
|
||||
// 3. Revoke the OLD finalImageUrl if it exists
|
||||
if (finalImageUrl) {
|
||||
URL.revokeObjectURL(finalImageUrl);
|
||||
}
|
||||
|
||||
// 4. Set the final avatar state to the NEW URL
|
||||
setFinalImageUrl(newFinalUrl);
|
||||
|
||||
// 5. Close the dialog (don't remove the file yet)
|
||||
setIsDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error during apply:", error);
|
||||
// Close the dialog even if cropping fails
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFinalImage = () => {
|
||||
if (finalImageUrl) {
|
||||
URL.revokeObjectURL(finalImageUrl);
|
||||
}
|
||||
setFinalImageUrl(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentFinalUrl = finalImageUrl;
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (currentFinalUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(currentFinalUrl);
|
||||
}
|
||||
};
|
||||
}, [finalImageUrl]);
|
||||
|
||||
// Effect to open dialog when a *new* file is ready
|
||||
useEffect(() => {
|
||||
// Check if fileId exists and is different from the previous one
|
||||
if (fileId && fileId !== previousFileIdRef.current) {
|
||||
setIsDialogOpen(true); // Open dialog for the new file
|
||||
setCroppedAreaPixels(null); // Reset crop area for the new file
|
||||
setZoom(1); // Reset zoom for the new file
|
||||
}
|
||||
// Update the ref to the current fileId for the next render
|
||||
previousFileIdRef.current = fileId;
|
||||
}, [fileId]); // Depend only on fileId
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative inline-flex">
|
||||
{/* Drop area - uses finalImageUrl */}
|
||||
<button
|
||||
aria-label={finalImageUrl ? "Change image" : "Upload image"}
|
||||
className="relative flex size-32 items-center justify-center overflow-hidden rounded-lg border border-input border-dashed outline-none transition-colors hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-disabled:pointer-events-none has-[img]:border-none has-disabled:opacity-50 data-[dragging=true]:bg-accent/50"
|
||||
data-dragging={isDragging || undefined}
|
||||
onClick={openFileDialog}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
type="button"
|
||||
>
|
||||
{finalImageUrl ? (
|
||||
<img
|
||||
alt="User avatar"
|
||||
className="size-full object-cover"
|
||||
height={128}
|
||||
src={finalImageUrl}
|
||||
style={{ objectFit: "cover" }}
|
||||
width={128}
|
||||
/>
|
||||
) : (
|
||||
<div aria-hidden="true">
|
||||
<PlusIcon className="size-8 opacity-60" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{/* Remove button - depends on finalImageUrl */}
|
||||
{finalImageUrl && (
|
||||
<Button
|
||||
aria-label="Remove image"
|
||||
className="-top-1 -right-1 absolute size-6 rounded-full border-2 border-background shadow-none focus-visible:border-background"
|
||||
onClick={handleRemoveFinalImage}
|
||||
size="icon"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<input
|
||||
{...getInputProps()}
|
||||
aria-label="Upload image file"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cropper Dialog - Use isDialogOpen for open prop */}
|
||||
<Dialog onOpenChange={setIsDialogOpen} open={isDialogOpen}>
|
||||
<DialogContent className="gap-0 p-0 sm:max-w-140 *:[button]:hidden">
|
||||
<DialogDescription className="sr-only">
|
||||
Crop image dialog
|
||||
</DialogDescription>
|
||||
<DialogHeader className="contents space-y-0 text-left">
|
||||
<DialogTitle className="flex items-center justify-between border-b p-4 text-base">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
aria-label="Cancel"
|
||||
className="-my-1 opacity-60"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeftIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<span>Crop image</span>
|
||||
</div>
|
||||
<Button
|
||||
autoFocus
|
||||
className="-my-1"
|
||||
disabled={!previewUrl}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{previewUrl && (
|
||||
<Cropper
|
||||
className="h-96 sm:h-120"
|
||||
image={previewUrl}
|
||||
onCropChange={handleCropChange}
|
||||
onZoomChange={setZoom}
|
||||
zoom={zoom}
|
||||
>
|
||||
<CropperDescription />
|
||||
<CropperImage />
|
||||
<CropperCropArea />
|
||||
</Cropper>
|
||||
)}
|
||||
<DialogFooter className="border-t px-4 py-6">
|
||||
<div className="mx-auto flex w-full max-w-80 items-center gap-4">
|
||||
<ZoomOutIcon
|
||||
aria-hidden="true"
|
||||
className="shrink-0 opacity-60"
|
||||
size={16}
|
||||
/>
|
||||
<Slider
|
||||
aria-label="Zoom slider"
|
||||
defaultValue={[1]}
|
||||
max={3}
|
||||
min={1}
|
||||
onValueChange={(value) => setZoom(value[0])}
|
||||
step={0.1}
|
||||
value={[zoom]}
|
||||
/>
|
||||
<ZoomInIcon
|
||||
aria-hidden="true"
|
||||
className="shrink-0 opacity-60"
|
||||
size={16}
|
||||
/>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
export type FormSubmitResult =
|
||||
| {
|
||||
toast?: string;
|
||||
close?: boolean;
|
||||
error?:
|
||||
| string
|
||||
| { what: string; where: string }
|
||||
| { what: string; where: string }[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
export function defineFormHandler<TSchema extends z.ZodType>(
|
||||
schema: TSchema,
|
||||
action: (input: z.output<TSchema>) => Promise<FormSubmitResult>,
|
||||
): (form: FormData) => Promise<FormSubmitResult>;
|
||||
export function defineFormHandler<
|
||||
TAction extends (form: FormData) => Promise<FormSubmitResult>,
|
||||
>(action: TAction): TAction;
|
||||
export function defineFormHandler(
|
||||
schemaOrAction: z.ZodType | ((form: FormData) => Promise<FormSubmitResult>),
|
||||
action?: (input: unknown) => Promise<FormSubmitResult>,
|
||||
): (form: FormData) => Promise<FormSubmitResult> {
|
||||
if (!action)
|
||||
return schemaOrAction as (form: FormData) => Promise<FormSubmitResult>;
|
||||
const schema = schemaOrAction as z.ZodType;
|
||||
return async (form: FormData) => {
|
||||
// "use server";
|
||||
const raw = Object.fromEntries([...form.entries()]);
|
||||
const parsed = schema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
error: parsed.error.issues.map((issue) => ({
|
||||
what: issue.message,
|
||||
where: issue.path.join("."),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return action(parsed.data);
|
||||
};
|
||||
}
|
||||
|
||||
export function formParse<TSchema extends z.ZodType>(
|
||||
schema: TSchema,
|
||||
data: FormData,
|
||||
): { $: z.output<TSchema>; error: { what: string; where: string }[] | string } {
|
||||
const raw = Object.fromEntries([...data.entries()]);
|
||||
const parsed = schema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
if (
|
||||
parsed.error.issues.length === 1 &&
|
||||
parsed.error.issues[0].path.length === 0
|
||||
)
|
||||
return { error: parsed.error.issues[0].message, $: undefined as any };
|
||||
return {
|
||||
error: parsed.error.issues.map((issue) => ({
|
||||
what: issue.message,
|
||||
where: issue.path.join("."),
|
||||
})),
|
||||
$: undefined as any,
|
||||
};
|
||||
}
|
||||
return { $: parsed.data, error: undefined as any };
|
||||
}
|
||||
+120
-43
@@ -2,14 +2,12 @@
|
||||
|
||||
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";
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { Divide } from "lucide-react";
|
||||
import type { FormSubmitResult } from "./form-submit";
|
||||
|
||||
const AUTOSAVE_TTL_MS = 10 * 60 * 1000;
|
||||
const STORAGE_PREFIX = "form:autosave:";
|
||||
@@ -19,12 +17,14 @@ type FormValues = Record<string, unknown>;
|
||||
type FormContextValue = {
|
||||
id: string;
|
||||
values: FormValues;
|
||||
errors: Record<string, string>;
|
||||
loading: boolean;
|
||||
setValue: (name: string, value: unknown) => void;
|
||||
updateValues: (updater: (prev: FormValues) => FormValues) => void;
|
||||
clear: () => void;
|
||||
submit: () => Promise<void>;
|
||||
closeDialog: () => void;
|
||||
setErrors: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
};
|
||||
|
||||
const FormContext = React.createContext<FormContextValue | null>(null);
|
||||
@@ -87,19 +87,6 @@ function appendFormValue(form: FormData, key: string, value: unknown) {
|
||||
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
|
||||
@@ -118,35 +105,25 @@ function getInputValue(eventOrValue: unknown) {
|
||||
return eventOrValue;
|
||||
}
|
||||
|
||||
type SubmitFnResult =
|
||||
| {
|
||||
toast?: string;
|
||||
close?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
type FormProviderProps<T extends TypedFormDataShape> = Omit<
|
||||
React.ComponentProps<"form">,
|
||||
"id" | "onSubmit"
|
||||
> & {
|
||||
type FormProviderProps = Omit<React.ComponentProps<"form">, "id" | "onSubmit"> & {
|
||||
// Unique Autosave Key
|
||||
id: string;
|
||||
onSubmit?: (
|
||||
form: TypedFormData<T>,
|
||||
) => SubmitFnResult | Promise<SubmitFnResult>;
|
||||
onSubmit?: (form: FormData) => FormSubmitResult | Promise<FormSubmitResult>;
|
||||
values?: FormValues;
|
||||
inDialog?: boolean;
|
||||
};
|
||||
|
||||
export function FormProvider<T extends TypedFormDataShape>({
|
||||
export function FormProvider({
|
||||
id,
|
||||
onSubmit,
|
||||
className,
|
||||
children,
|
||||
inDialog = true,
|
||||
values: defaultValues = {},
|
||||
...props
|
||||
}: FormProviderProps<T>) {
|
||||
}: FormProviderProps) {
|
||||
const [values, setValues] = React.useState<FormValues>(defaultValues);
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const formRef = React.useRef<HTMLFormElement | null>(null);
|
||||
const [storageLock, setStorageLock] = React.useState<"r" | "w" | null>("r");
|
||||
@@ -180,6 +157,12 @@ export function FormProvider<T extends TypedFormDataShape>({
|
||||
|
||||
const setValue = React.useCallback((name: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
setErrors((prev) => {
|
||||
if (!prev[name]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const updateValues = React.useCallback(
|
||||
(updater: (prev: FormValues) => FormValues) => {
|
||||
@@ -190,6 +173,7 @@ export function FormProvider<T extends TypedFormDataShape>({
|
||||
|
||||
const clear = React.useCallback(() => {
|
||||
setValues({});
|
||||
setErrors({});
|
||||
localStorage.removeItem(storageKey(id));
|
||||
formRef.current?.reset();
|
||||
}, [id]);
|
||||
@@ -204,12 +188,25 @@ export function FormProvider<T extends TypedFormDataShape>({
|
||||
appendFormValue(formData, key, value);
|
||||
}
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
try {
|
||||
const res = await onSubmit?.(toTypedFormData(formData));
|
||||
const res = await onSubmit?.(formData);
|
||||
if (res && typeof res === "object") {
|
||||
if ("error" in res) {
|
||||
const { error } = res;
|
||||
if (typeof error === "string") throw new Error(error);
|
||||
if (typeof error === "string") {
|
||||
toast.error(error);
|
||||
} else if (Array.isArray(error)) {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
for (const e of error) {
|
||||
fieldErrors[e.where] = e.what;
|
||||
}
|
||||
setErrors(fieldErrors);
|
||||
} else if (error) {
|
||||
setErrors({ [error.where]: error.what });
|
||||
}
|
||||
} else {
|
||||
setErrors({});
|
||||
}
|
||||
if ("toast" in res) {
|
||||
const { toast: toastMsg } = res;
|
||||
@@ -239,14 +236,16 @@ export function FormProvider<T extends TypedFormDataShape>({
|
||||
() => ({
|
||||
id,
|
||||
values,
|
||||
errors,
|
||||
loading,
|
||||
setValue,
|
||||
updateValues,
|
||||
clear,
|
||||
submit,
|
||||
closeDialog,
|
||||
setErrors,
|
||||
}),
|
||||
[id, values, loading, setValue, updateValues, clear, submit, closeDialog],
|
||||
[id, values, errors, loading, setValue, updateValues, clear, submit, closeDialog, setErrors],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -263,7 +262,7 @@ export function FormProvider<T extends TypedFormDataShape>({
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
<DialogClose ref={closerRef} />
|
||||
{inDialog && <DialogClose ref={closerRef} />}
|
||||
</FormContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -286,7 +285,7 @@ export function FormInput({
|
||||
subLabel,
|
||||
className,
|
||||
}: FormInputProps) {
|
||||
const { values, setValue, updateValues } = useFormContext();
|
||||
const { values, setValue, updateValues, errors } = useFormContext();
|
||||
const childProps = (children.props ?? {}) as Record<string, unknown>;
|
||||
const value = values[name] ?? childProps?.defaultValue ?? "";
|
||||
const nextProps: Record<string, unknown> = { name, defaultValue: undefined };
|
||||
@@ -356,9 +355,17 @@ export function FormInput({
|
||||
)}
|
||||
</Label>
|
||||
{React.cloneElement(children || Divide, nextProps)}
|
||||
{errors[name] && (
|
||||
<span className="text-destructive text-sm">{errors[name]}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
React.cloneElement(children || Divide, nextProps)
|
||||
<div className={cn(className)}>
|
||||
{React.cloneElement(children || Divide, nextProps)}
|
||||
{errors[name] && (
|
||||
<span className="text-destructive text-sm">{errors[name]}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -370,7 +377,7 @@ type FormActionProps = Omit<
|
||||
> & {
|
||||
type: "clear" | "submit" | "action";
|
||||
|
||||
action?: () => SubmitFnResult | Promise<SubmitFnResult>;
|
||||
action?: () => FormSubmitResult | Promise<FormSubmitResult>;
|
||||
children?: React.ReactNode;
|
||||
loading?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
@@ -384,7 +391,7 @@ export function FormAction({
|
||||
loading: loadingComponent,
|
||||
...props
|
||||
}: FormActionProps) {
|
||||
const { loading: formLoading, clear, closeDialog } = useFormContext();
|
||||
const { loading: formLoading, clear, closeDialog, setErrors } = useFormContext();
|
||||
const [actionLoading, setActionLoading] = React.useState(false);
|
||||
const loading = formLoading || actionLoading;
|
||||
|
||||
@@ -401,7 +408,19 @@ export function FormAction({
|
||||
if (res && typeof res === "object") {
|
||||
if ("error" in res) {
|
||||
const { error } = res;
|
||||
if (typeof error === "string") throw new Error(error);
|
||||
if (typeof error === "string") {
|
||||
toast.error(error);
|
||||
} else if (Array.isArray(error)) {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
for (const e of error) {
|
||||
fieldErrors[e.where] = e.what;
|
||||
}
|
||||
setErrors(fieldErrors);
|
||||
} else if (error) {
|
||||
setErrors({ [error.where]: error.what });
|
||||
}
|
||||
} else {
|
||||
setErrors({});
|
||||
}
|
||||
if ("toast" in res) {
|
||||
const { toast: toastMsg } = res;
|
||||
@@ -452,3 +471,61 @@ export function FormRow({ className, ...props }: React.ComponentProps<"div">) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormTab({
|
||||
label,
|
||||
name,
|
||||
tabs,
|
||||
children,
|
||||
}: {
|
||||
label?: React.ReactNode;
|
||||
name: string;
|
||||
tabs: { label: React.ReactNode; value: string }[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { values, setValue } = useFormContext();
|
||||
const active = (values[name] as string | undefined) ?? tabs[0]?.value;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (values[name] === undefined && tabs[0]) {
|
||||
setValue(name, tabs[0].value);
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount to seed default
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full justify-between items-end">
|
||||
{label}
|
||||
<Tabs value={active} onValueChange={(v) => setValue(name, v)}>
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="bg-muted-foreground/10 p-3 rounded-md">
|
||||
{React.Children.map(children, (child) => {
|
||||
if (!React.isValidElement(child)) return null;
|
||||
const childValue = (child.props as Record<string, unknown>).value;
|
||||
return childValue === active ? child : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormChoice({
|
||||
children,
|
||||
}: {
|
||||
// biome-ignore lint/style/useExportType: not a type
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Cropper as CropperPrimitive } from "@origin-space/image-cropper";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Cropper({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CropperPrimitive.Root>) {
|
||||
return (
|
||||
<CropperPrimitive.Root
|
||||
className={cn(
|
||||
"relative flex w-full cursor-move touch-none items-center justify-center overflow-hidden focus:outline-none",
|
||||
className,
|
||||
)}
|
||||
data-slot="cropper"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CropperDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CropperPrimitive.Description>) {
|
||||
return (
|
||||
<CropperPrimitive.Description
|
||||
className={cn("sr-only", className)}
|
||||
data-slot="cropper-description"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CropperImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CropperPrimitive.Image>) {
|
||||
return (
|
||||
<CropperPrimitive.Image
|
||||
className={cn(
|
||||
"pointer-events-none h-full w-full object-cover",
|
||||
className,
|
||||
)}
|
||||
data-slot="cropper-image"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CropperCropArea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CropperPrimitive.CropArea>) {
|
||||
return (
|
||||
<CropperPrimitive.CropArea
|
||||
className={cn(
|
||||
"pointer-events-none absolute border-3 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.3)] in-[[data-slot=cropper]:focus-visible]:ring-[3px] in-[[data-slot=cropper]:focus-visible]:ring-white/50",
|
||||
className,
|
||||
)}
|
||||
data-slot="cropper-crop-area"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Cropper, CropperDescription, CropperImage, CropperCropArea };
|
||||
Reference in New Issue
Block a user