Add visible feedback and error handling to commission requests
This commit is contained in:
@ -67,22 +67,32 @@ export async function submitCommissionRequest(input: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const raw = await res.text().catch(() => "");
|
||||||
let parsed: any = null;
|
const statusLine = `${res.status} ${res.statusText || ""}`.trim();
|
||||||
|
|
||||||
|
// Show something useful even if raw is empty
|
||||||
|
let message = `Admin API error: ${statusLine}`;
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
try {
|
try {
|
||||||
parsed = text ? JSON.parse(text) : null;
|
const parsed = JSON.parse(raw);
|
||||||
|
message =
|
||||||
|
parsed?.error
|
||||||
|
? `Admin API error: ${statusLine} — ${parsed.error}`
|
||||||
|
: parsed?.message
|
||||||
|
? `Admin API error: ${statusLine} — ${parsed.message}`
|
||||||
|
: `Admin API error: ${statusLine} — ${raw}`;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
message = `Admin API error: ${statusLine} — ${raw}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message =
|
|
||||||
parsed?.error ??
|
|
||||||
parsed?.message ??
|
|
||||||
(text ? text.slice(0, 300) : `Request failed (${res.status})`);
|
|
||||||
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log full body server-side for debugging (safe; this is server-only)
|
||||||
|
console.error("[submitCommissionRequest] upstream error", { statusLine, raw });
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
// Expected response: { id: string; createdAt: string }
|
// Expected response: { id: string; createdAt: string }
|
||||||
return (await res.json()) as { id: string; createdAt: string };
|
return (await res.json()) as { id: string; createdAt: string };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { submitCommissionRequest } from "@/actions/commissions/submitCommissionRequest"
|
import { submitCommissionRequest } from "@/actions/commissions/submitCommissionRequest";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -9,29 +9,37 @@ import {
|
|||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form"
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"
|
import {
|
||||||
import { commissionOrderSchema } from "@/schemas/commissionOrder"
|
CommissionCustomInput,
|
||||||
import { calculatePriceRange } from "@/utils/calculatePrice"
|
CommissionExtra,
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
CommissionOption,
|
||||||
import "dotenv/config"
|
CommissionType,
|
||||||
import Link from "next/link"
|
CommissionTypeCustomInput,
|
||||||
import { useMemo, useState } from "react"
|
CommissionTypeExtra,
|
||||||
import { useForm, useWatch } from "react-hook-form"
|
CommissionTypeOption,
|
||||||
import * as z from "zod/v4"
|
} from "@/generated/prisma/client";
|
||||||
import { FileDropzone } from "./FileDropzone"
|
import { commissionOrderSchema } from "@/schemas/commissionOrder";
|
||||||
|
import { calculatePriceRange } from "@/utils/calculatePrice";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
import { FileDropzone } from "./FileDropzone";
|
||||||
|
|
||||||
type CommissionTypeWithRelations = CommissionType & {
|
type CommissionTypeWithRelations = CommissionType & {
|
||||||
options: (CommissionTypeOption & { option: CommissionOption })[]
|
options: (CommissionTypeOption & { option: CommissionOption })[];
|
||||||
extras: (CommissionTypeExtra & { extra: CommissionExtra })[]
|
extras: (CommissionTypeExtra & { extra: CommissionExtra })[];
|
||||||
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[]
|
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[];
|
||||||
}
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
types: CommissionTypeWithRelations[]
|
types: CommissionTypeWithRelations[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export function CommissionOrderForm({ types }: Props) {
|
export function CommissionOrderForm({ types }: Props) {
|
||||||
const form = useForm<z.infer<typeof commissionOrderSchema>>({
|
const form = useForm<z.infer<typeof commissionOrderSchema>>({
|
||||||
@ -45,50 +53,71 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
customerSocials: "",
|
customerSocials: "",
|
||||||
message: "",
|
message: "",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const [files, setFiles] = useState<File[]>([])
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const typeId = useWatch({ control: form.control, name: "typeId" })
|
const typeId = useWatch({ control: form.control, name: "typeId" });
|
||||||
const optionId = useWatch({ control: form.control, name: "optionId" })
|
const optionId = useWatch({ control: form.control, name: "optionId" });
|
||||||
const extraIds = useWatch({ control: form.control, name: "extraIds" })
|
const extraIds = useWatch({ control: form.control, name: "extraIds" });
|
||||||
|
|
||||||
const selectedType = useMemo(
|
const selectedType = useMemo(() => types.find((t) => t.id === typeId), [types, typeId]);
|
||||||
() => types.find((t) => t.id === typeId),
|
|
||||||
[types, typeId]
|
|
||||||
)
|
|
||||||
|
|
||||||
const selectedOption = useMemo(
|
const selectedOption = useMemo(
|
||||||
() => selectedType?.options.find((o) => o.optionId === optionId),
|
() => selectedType?.options.find((o) => o.optionId === optionId),
|
||||||
[selectedType, optionId]
|
[selectedType, optionId]
|
||||||
)
|
);
|
||||||
|
|
||||||
const selectedExtras = useMemo(
|
const selectedExtras = useMemo(
|
||||||
() => selectedType?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
|
() => selectedType?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
|
||||||
[selectedType, extraIds]
|
[selectedType, extraIds]
|
||||||
)
|
);
|
||||||
|
|
||||||
const [minPrice, maxPrice] = useMemo(() => {
|
const [minPrice, maxPrice] = useMemo(() => {
|
||||||
return calculatePriceRange(selectedOption, selectedExtras)
|
return calculatePriceRange(selectedOption, selectedExtras);
|
||||||
}, [selectedOption, selectedExtras])
|
}, [selectedOption, selectedExtras]);
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof commissionOrderSchema>) {
|
async function onSubmit(values: z.infer<typeof commissionOrderSchema>) {
|
||||||
const payload = {
|
setIsSubmitting(true);
|
||||||
typeId: values.typeId || null,
|
|
||||||
optionId: values.optionId || null,
|
|
||||||
customerName: values.customerName,
|
|
||||||
customerEmail: values.customerEmail,
|
|
||||||
customerSocials: values.customerSocials ?? null,
|
|
||||||
message: values.message,
|
|
||||||
extraIds: values.extraIds ?? [], // <-- normalize
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await submitCommissionRequest({
|
try {
|
||||||
payload,
|
const payload = {
|
||||||
files,
|
typeId: values.typeId || null,
|
||||||
});
|
optionId: values.optionId || null,
|
||||||
|
extraIds: values.extraIds ?? [],
|
||||||
|
customerName: values.customerName,
|
||||||
|
customerEmail: values.customerEmail,
|
||||||
|
customerSocials: values.customerSocials ?? null,
|
||||||
|
message: values.message,
|
||||||
|
};
|
||||||
|
|
||||||
console.log("Created request:", res);
|
await submitCommissionRequest({ payload, files });
|
||||||
|
|
||||||
|
toast.success("Request submitted", {
|
||||||
|
description: "Thanks! I’ll get back to you as soon as possible.",
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
typeId: "",
|
||||||
|
optionId: "",
|
||||||
|
extraIds: [],
|
||||||
|
customerName: "",
|
||||||
|
customerEmail: "",
|
||||||
|
customerSocials: "",
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
setFiles([]);
|
||||||
|
form.clearErrors();
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Submission failed. Please try again.";
|
||||||
|
|
||||||
|
toast.error("Submission failed", { description: message });
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -108,6 +137,7 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant={field.value === type.id ? "default" : "outline"}
|
variant={field.value === type.id ? "default" : "outline"}
|
||||||
onClick={() => field.onChange(type.id)}
|
onClick={() => field.onChange(type.id)}
|
||||||
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{type.name}
|
{type.name}
|
||||||
</Button>
|
</Button>
|
||||||
@ -136,6 +166,7 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
checked={field.value === opt.optionId}
|
checked={field.value === opt.optionId}
|
||||||
value={opt.optionId}
|
value={opt.optionId}
|
||||||
onChange={() => field.onChange(opt.optionId)}
|
onChange={() => field.onChange(opt.optionId)}
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
{opt.option.name}
|
{opt.option.name}
|
||||||
</label>
|
</label>
|
||||||
@ -155,21 +186,19 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
<FormLabel>Extras</FormLabel>
|
<FormLabel>Extras</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{selectedType?.extras.map((ext) => (
|
{selectedType.extras.map((ext) => (
|
||||||
<label key={ext.id} className="flex items-center gap-2">
|
<label key={ext.id} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.value?.includes(ext.extraId) ?? false}
|
checked={field.value?.includes(ext.extraId) ?? false}
|
||||||
value={ext.extraId}
|
value={ext.extraId}
|
||||||
|
disabled={isSubmitting}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const checked = e.target.checked
|
const checked = e.target.checked;
|
||||||
const newSet = new Set(field.value ?? [])
|
const newSet = new Set(field.value ?? []);
|
||||||
if (checked) {
|
if (checked) newSet.add(ext.extraId);
|
||||||
newSet.add(ext.extraId)
|
else newSet.delete(ext.extraId);
|
||||||
} else {
|
field.onChange(Array.from(newSet));
|
||||||
newSet.delete(ext.extraId)
|
|
||||||
}
|
|
||||||
field.onChange(Array.from(newSet))
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{ext.extra.name}
|
{ext.extra.name}
|
||||||
@ -184,8 +213,6 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@ -194,12 +221,13 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Your Name</FormLabel>
|
<FormLabel>Your Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Jane Doe" {...field} />
|
<Input placeholder="Jane Doe" {...field} disabled={isSubmitting} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="customerEmail"
|
name="customerEmail"
|
||||||
@ -207,12 +235,17 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="E-Mail address for invoice and/or contact" {...field} />
|
<Input
|
||||||
|
placeholder="E-Mail address for invoice and/or contact"
|
||||||
|
{...field}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="customerSocials"
|
name="customerSocials"
|
||||||
@ -220,7 +253,11 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Socials</FormLabel>
|
<FormLabel>Socials</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Alternative for contact (telegram, bsky, fediverse/mastodon)" {...field} />
|
<Input
|
||||||
|
placeholder="Alternative for contact (telegram, bsky, fediverse/mastodon)"
|
||||||
|
{...field}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -239,6 +276,7 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
placeholder="Describe what you’d like drawn..."
|
placeholder="Describe what you’d like drawn..."
|
||||||
rows={4}
|
rows={4}
|
||||||
{...field}
|
{...field}
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -246,65 +284,32 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedType && selectedType.customInputs.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{selectedType.customInputs.map((input) => {
|
|
||||||
const name = `customFields.${input.customInput.name}`
|
|
||||||
return (
|
|
||||||
<FormField
|
|
||||||
key={input.id}
|
|
||||||
control={form.control}
|
|
||||||
name={name as `customFields.${string}`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{input.label}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
{input.inputType === "textarea" ? (
|
|
||||||
<Textarea {...field} rows={3} />
|
|
||||||
) : input.inputType === "number" ? (
|
|
||||||
<Input type="number" {...field} />
|
|
||||||
) : input.inputType === "checkbox" ? (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
/>
|
|
||||||
) : input.inputType === "date" ? (
|
|
||||||
<Input type="date" {...field} />
|
|
||||||
) : input.inputType === "select" ? (
|
|
||||||
// Placeholder select – populate with options if needed
|
|
||||||
<select
|
|
||||||
{...field}
|
|
||||||
className="border rounded px-2 py-1 w-full"
|
|
||||||
>
|
|
||||||
<option value="">Please select</option>
|
|
||||||
<option value="example1">Example 1</option>
|
|
||||||
<option value="example2">Example 2</option>
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<Input {...field} />
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Reference Images</FormLabel>
|
<FormLabel>Reference Images</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FileDropzone onFilesSelected={setFiles} />
|
<FileDropzone files={files} onFilesSelected={setFiles} />
|
||||||
|
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<ul className="list-disc pl-4 text-sm text-muted-foreground">
|
<div className="space-y-2">
|
||||||
{files.map((file, i) => (
|
<ul className="list-disc pl-4 text-sm text-muted-foreground">
|
||||||
<li key={i}>{file.name}</li>
|
{files.map((file, i) => (
|
||||||
))}
|
<li key={`${file.name}-${file.size}-${file.lastModified}-${i}`}>
|
||||||
</ul>
|
{file.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-9"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => setFiles([])}
|
||||||
|
>
|
||||||
|
Clear files
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -325,10 +330,10 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
.
|
.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={!form.formState.isValid}>
|
<Button type="submit" disabled={!form.formState.isValid || isSubmitting}>
|
||||||
Submit Request
|
{isSubmitting ? "Submitting…" : "Submit Request"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,47 +1,87 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { useCallback, useRef, useState } from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Current selected files (controlled) */
|
||||||
|
files: File[];
|
||||||
|
/** Called with the full next list of files */
|
||||||
|
onFilesSelected: (files: File[]) => void;
|
||||||
|
/** If true, newly added files are appended to existing files */
|
||||||
|
append?: boolean;
|
||||||
|
/** Optional limits */
|
||||||
|
accept?: string;
|
||||||
|
maxFiles?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultFileKey(f: File) {
|
||||||
|
// Good-enough de-dupe key for user selections
|
||||||
|
return `${f.name}__${f.size}__${f.lastModified}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function FileDropzone({
|
export function FileDropzone({
|
||||||
|
files,
|
||||||
onFilesSelected,
|
onFilesSelected,
|
||||||
}: {
|
append = true,
|
||||||
onFilesSelected: (files: File[]) => void
|
accept = "image/*",
|
||||||
}) {
|
maxFiles = 10,
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
}: Props) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const handleFiles = (files: FileList | null) => {
|
const mergeFiles = React.useCallback(
|
||||||
if (files) {
|
(incoming: File[]) => {
|
||||||
onFilesSelected(Array.from(files))
|
const next = append ? [...files, ...incoming] : [...incoming];
|
||||||
}
|
|
||||||
}
|
// de-dupe (same name/size/lastModified)
|
||||||
|
const map = new Map<string, File>();
|
||||||
|
for (const f of next) map.set(defaultFileKey(f), f);
|
||||||
|
|
||||||
|
const deduped = Array.from(map.values()).slice(0, maxFiles);
|
||||||
|
onFilesSelected(deduped);
|
||||||
|
|
||||||
|
// Allow selecting the same file again later (if user removes and re-adds)
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
},
|
||||||
|
[append, files, maxFiles, onFilesSelected]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFiles = React.useCallback(
|
||||||
|
(list: FileList | null) => {
|
||||||
|
if (!list) return;
|
||||||
|
const incoming = Array.from(list);
|
||||||
|
if (incoming.length === 0) return;
|
||||||
|
mergeFiles(incoming);
|
||||||
|
},
|
||||||
|
[mergeFiles]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setIsDragging(false)
|
setIsDragging(false);
|
||||||
handleFiles(e.dataTransfer.files)
|
handleFiles(e.dataTransfer.files);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setIsDragging(true)
|
setIsDragging(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
const handleDragLeave = () => setIsDragging(false);
|
||||||
setIsDragging(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
handleFiles(e.target.files);
|
||||||
handleFiles(e.target.files)
|
};
|
||||||
},
|
|
||||||
[onFilesSelected]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
|
||||||
|
}}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@ -54,12 +94,19 @@ export function FileDropzone({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
|
accept={accept}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Drag & drop images here or click to upload
|
Drag & drop images here or click to upload
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{files.length > 0 ? (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Selected: {files.length} / {maxFiles}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user