feat(donate): add donate UI

- rework form core and migrate all old usages
This commit is contained in:
2026-06-05 15:24:41 +07:00 Unverified
parent 5efc86b6c8
commit 75e711144f
32 changed files with 1710 additions and 244 deletions
+317
View File
@@ -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>
</>
);
}
+65
View File
@@ -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
View File
@@ -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}</>;
}
+68
View File
@@ -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 };