USER
You are an expert software engineer specialised in Next.js 14 with TypeScript and TailwindCSS. When programming your solution, specification effectiveness, compute efficiency and code readability rank at the top of your principles. You employ industry best practices. I am in the process of making a custom input component for a specialised variable of type `IContent`. I have created a solution, now I need you to optimise it. If you think it is necessary, feel free to utilise React Hook Form and Zod. I have these packages installed: `"@hookform/resolvers": "^3.9.0"`, `"react-hook-form": "^7.53.0"` and `"zod": "^3.23.8"`. Make sure to build it employing best practices of input-type components.
Here’e my code implementation:
components/c-ui/contents/input/index.tsx
```tsx
"use client";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { VariantProps, cva } from "class-variance-authority";
import { CheckIcon, ImagePlusIcon, ListPlusIcon, Maximize2Icon, Minimize2Icon, SaveIcon, TrashIcon } from "lucide-react";
import { useState, useRef } from "react";
import ImageInput from "./image";
import { deleteFile } from "@/app/actions/client/default.action";
import { IContent } from "@/database/schemas/content.schema";
const contentsInputVariant = cva("flex flex-col gap-[12px]");
const fieldInputVariant = cva("flex gap-[4px]");
const textInputVariant = cva("resize-none");
interface IContentsInputProps extends VariantProps<typeof contentsInputVariant> {
keyPrefix: string;
label?: string;
defaultValue?: IContent[];
onChange?: (value: IContent[]) => void;
onSave?: (value: IContent[]) => Promise<void>;
disabled?: boolean;
placeholder?: string;
}
const ContentsInput = ({ keyPrefix, label, defaultValue, onChange, onSave, disabled = false, placeholder }: IContentsInputProps) => {
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
const valueRef = useRef<IContent[]>(defaultValue ?? []);
const [draftValue, setDraftValue] = useState(defaultValue ?? []);
const [savedValue, setSavedValue] = useState(defaultValue ?? []);
const [saved, setSaved] = useState(false);
const [failed, setFailed] = useState(false);
const [size, setSize] = useState<"md" | "lg">("md");
const updateValue = (updater: (newValue: IContent[]) => void) => {
const newValue = [...valueRef.current];
if (!!onChange) onChange(newValue);
updater(newValue);
setDraftValue(newValue);
valueRef.current = newValue;
if (onSave === undefined) return setSavedValue(newValue);
setSaved(false);
setFailed(false);
if (timeoutIdRef.current !== null) clearTimeout(timeoutIdRef.current);
if (JSON.stringify(savedValue) === JSON.stringify(newValue)) return;
const timeoutId = setTimeout(handleSave.bind(this, newValue), 2000);
timeoutIdRef.current = timeoutId;
};
const handleChange = (index: number, content: string, newValue: IContent[]) => {
newValue[index] = { ...newValue[index], content };
};
const handleAdd = (type: "text" | "image", newValue: IContent[]) => {
newValue.push({ type, content: "" });
};
const handleDelete = (index: number, newValue: IContent[]) => {
const content = newValue[index];
if (content.type === "image") {
const fileIdRegex = /fileId=([^&]+)/;
const match = content.content.match(fileIdRegex);
if (match) {
const fileId = match[1];
deleteFile(fileId);
}
}
newValue.splice(index, 1);
};
const handleSave = async (newValue: IContent[]) => {
if (timeoutIdRef.current !== null) clearTimeout(timeoutIdRef.current);
if (onSave === undefined) return;
try {
await onSave(newValue);
setSavedValue(newValue);
setSaved(true);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
setFailed(true);
}
};
return (
<div className={cn(contentsInputVariant())}>
<div className="h-fit w-full flex items-center justify-between">
<Label>{label ?? "Contents"}</Label>
<div className="h-fit w-fit flex items-center gap-2">
{JSON.stringify(draftValue) !== JSON.stringify(savedValue) && !failed && (
<div className="h-fit w-fit flex items-center px-1 gap-1 text-primary">
<div className="h-4 w-4 loading" />
<p className="text-[0.75rem]/[0.75rem]">Saving</p>
</div>
)}
{saved && (
<div className="h-fit w-fit flex items-center px-1 gap-1 text-success">
<CheckIcon strokeWidth={2.5} className="h-4 w-4" />
<p className="text-[0.75rem]/[0.75rem]">Saved</p>
</div>
)}
{failed && (
<Button
size="sm"
variant="ghost"
color="destructive"
onClick={() => {
setFailed(false);
handleSave(valueRef.current);
}}
>
<SaveIcon strokeWidth={2.5} className="h-4 w-4" />
<p>Try again</p>
</Button>
)}
<Button size="sm" onClick={updateValue.bind(this, handleAdd.bind(this, "text"))} disabled={disabled}>
<ListPlusIcon strokeWidth={2.5} className="h-[1rem] w-[1rem]" />
<p>Add Text</p>
</Button>
<Button size="sm" onClick={updateValue.bind(this, handleAdd.bind(this, "image"))} disabled={disabled}>
<ImagePlusIcon strokeWidth={2.5} className="h-[1rem] w-[1rem]" />
<p>Upload Image</p>
</Button>
</div>
</div>
{draftValue.length === 0 && placeholder !== undefined && (
<div className="h-fit w-full flex items-center justify-center py-[20px] px-[40px]">
<p className="text-center text-neutral-foreground text-lg font-medium">{placeholder}</p>
</div>
)}
{draftValue.map((el, i) => {
let Field: JSX.Element;
switch (el.type) {
case "text":
Field = (
<Textarea
className={cn(textInputVariant())}
value={el.content}
onChange={(e) => updateValue(handleChange.bind(this, i, e.target.value))}
disabled={disabled}
/>
);
break;
case "image":
Field = (
<ImageInput
id={`${keyPrefix}-${i}`}
defaultValue={el.content}
onChange={(content) => updateValue(handleChange.bind(this, i, content))}
disabled={disabled}
size={size}
/>
);
break;
default:
return null;
}
return (
<div key={`${keyPrefix}-${i}`} className={cn(fieldInputVariant())}>
{Field}
<div className="h-fit w-fit flex flex-col space-y-[4px]">
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={updateValue.bind(this, handleDelete.bind(this, i))}
disabled={disabled}
>
<TrashIcon strokeWidth={2.5} className="h-[1rem] w-[1rem]" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setSize((prev) => (prev === "md" ? "lg" : "md"))}>
{size === "md" ? (
<Maximize2Icon strokeWidth={2.5} className="h-[1rem] w-[1rem]" />
) : (
<Minimize2Icon strokeWidth={2.5} className="h-[1rem] w-[1rem]" />
)}
</Button>
</div>
</div>
);
})}
</div>
);
};
export default ContentsInput;
```
components/c-ui/contents/input/image.tsx
```tsx
import { uploadFile } from "@/app/actions/client/default.action";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Image from "next/image";
import React, { useState } from "react";
import { ImagePlusIcon } from "lucide-react";
interface IProps {
id: string;
defaultValue: string;
onChange: (value: string) => void;
disabled: boolean;
size: "md" | "lg";
}
const ImageInput = ({ id, defaultValue, onChange, disabled, size }: IProps) => {
const [value, setValue] = useState(defaultValue);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setValue(URL.createObjectURL(file));
const formData = new FormData();
formData.append("file", file);
const fileId = await uploadFile(formData);
const fileUrl = `https://app.ehllie.com/api/file/image?fileId=${fileId}`;
onChange(fileUrl);
} catch (error) {
console.error("Error compressing the image", error);
}
};
return (
<Label htmlFor={id} className="h-fit w-fit cursor-pointer">
{value === "" ? (
<Field />
) : (
<Image
src={value.replace("https://app.ehllie.com/", "/")}
alt={id}
width={800}
height={800}
className={size === "md" ? "w-full sm:w-[400px]" : "w-full sm:w-[800px]"}
/>
)}
<Input id={id} type="file" accept="image/*" className="hidden" onChange={handleUpload} disabled={disabled} />
</Label>
);
};
export default ImageInput;
const Field = () => (
<div className="h-[300px] w-full sm:w-[500px] flex flex-col items-center justify-center space-y-[20px] border-2 border-dashed border-neutral-foreground rounded-lg">
<ImagePlusIcon strokeWidth={2.5} className="h-[40px] w-[40px] text-neutral-foreground" />
<p className="w-[200px] text-center text-lg font-medium text-neutral-foreground">
Upload an image of your diagram or handwritten answer
</p>
</div>
);
```
app/actions/client/default.action.ts
```tsx
"use server";
import File from "@/database/models/file.model";
import Submission from "@/database/models/submission.model";
import { generateCode } from "@/lib/utils";
import { getSession } from "@auth0/nextjs-auth0";
export const createSubmission = async (questionId: string): Promise<string> => {
const session = await getSession();
if (!session) throw new Error("");
let invite = generateCode(6);
let isUnique = false;
while (!isUnique) {
const submission = await Submission.exists({ invite });
if (!submission) isUnique = true;
invite = generateCode(6);
}
const newSubmission = new Submission({ teacherId: session.user.email, questionId, invite });
await newSubmission.save();
return newSubmission._id;
};
export async function uploadFile(formData: FormData): Promise<string> {
const file = formData.get("file");
if (file === null || typeof file === "string") throw new Error("");
const buffer = Buffer.from(await file.arrayBuffer());
const contentType = file.type;
const newFile = new File({ contentType, data: buffer });
await newFile.save();
return newFile._id;
}
export async function deleteFile(fileId: string): Promise<void> {
await File.findByIdAndDelete(fileId);
}
```
database/schemas/content.schema.ts
```tsx
import { Schema } from "mongoose";
export interface IContent {
type: "text" | "image";
content: string;
}
export const ContentSchema: Schema = new Schema(
{ type: { type: String, enum: ["text", "image"], required: true }, content: { type: String, default: "" } },
{ _id: false }
);
```ASSISTANT
To optimize your custom `ContentsInput` component for better specification effectiveness, compute efficiency, and code readability, we'll integrate **React Hook Form** and **Zod** for form management and validation. Additionally, we'll refine the state management, improve the debounce logic for saving, and ensure best practices are followed throughout the component structure.
**Key Optimizations:**
1. **Form Management with React Hook Form:**
- Utilize `useForm` and `useFieldArray` to manage the array of `IContent` items efficiently.
- Leverage `zodResolver` for schema-based validation with Zod.
2. **Schema Validation with Zod:**
- Define a Zod schema for `IContent` to ensure data integrity.
3. **Debounced Saving Mechanism:**
- Implement a debounced save function using `useEffect` and `useRef` to handle auto-saving with a delay, enhancing performance and user experience.
4. **Optimized Image Handling:**
- Refactor `ImageInput` to use `useCallback` and `React.memo` to prevent unnecessary re-renders.
5. **Separation of Concerns:**
- Ensure that client components do not directly import server actions. Instead, use API routes for server-client interactions.
6. **Enhanced Code Readability and Maintainability:**
- Improve component structures, naming conventions, and state handling for clearer and more maintainable code.
Below is the optimized implementation based on these principles.
---
### 1. Define the Zod Schema for `IContent`
First, update your Zod schema to include `IContent` validation. This ensures that each content item adheres to the defined structure.
**`database/schemas/content.schema.ts`**
```typescript
import { z } from "zod";
export const IContentSchema = z.object({
type: z.enum(["text", "image"]),
content: z.string().min(1, "Content is required"),
});
export const ContentsSchema = z.array(IContentSchema);
export type IContent = z.infer<typeof IContentSchema>;
```
---
### 2. Refactor the `ContentsInput` Component
Integrate **React Hook Form** and **Zod** into your `ContentsInput` component. Utilize `useForm` and `useFieldArray` for efficient form state management, and implement a debounced save mechanism.
**`components/c-ui/contents/input/index.tsx`**
```tsx
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useForm, useFieldArray, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { IContent, IContentSchema, ContentsSchema } from "@/database/schemas/content.schema";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { VariantProps, cva } from "class-variance-authority";
import {
CheckIcon,
ImagePlusIcon,
ListPlusIcon,
Maximize2Icon,
Minimize2Icon,
SaveIcon,
TrashIcon,
} from "lucide-react";
import ImageInput from "./image";
import { deleteFile } from "@/app/actions/client/default.action";
const contentsInputVariant = cva("flex flex-col gap-3");
const fieldInputVariant = cva("flex gap-2 items-start");
const textInputVariant = cva("resize-none flex-1");
interface IContentsInputProps extends VariantProps<typeof contentsInputVariant> {
keyPrefix: string;
label?: string;
defaultValue?: IContent[];
onChange?: (value: IContent[]) => void;
onSave?: (value: IContent[]) => Promise<void>;
disabled?: boolean;
placeholder?: string;
}
interface FormValues {
contents: IContent[];
}
const ContentsInput: React.FC<IContentsInputProps> = ({
keyPrefix,
label = "Contents",
defaultValue = [],
onChange,
onSave,
disabled = false,
placeholder,
...props
}) => {
const [saved, setSaved] = useState(false);
const [failed, setFailed] = useState(false);
const [size, setSize] = useState<"md" | "lg">("md");
const {
control,
handleSubmit,
watch,
formState: { errors, isDirty },
} = useForm<FormValues>({
resolver: zodResolver(ContentsSchema),
defaultValues: { contents: defaultValue },
mode: "onChange",
});
const { fields, append, remove, update } = useFieldArray({
control,
name: "contents",
});
const watchedContents = watch("contents");
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
const savedContentsRef = useRef<IContent[]>(defaultValue);
// Handle onChange callback
useEffect(() => {
if (onChange) {
onChange(watchedContents);
}
}, [watchedContents, onChange]);
// Debounced save mechanism
useEffect(() => {
if (!onSave) return;
setSaved(false);
setFailed(false);
if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current);
// Check if contents have changed since last save
const contentsChanged =
JSON.stringify(watchedContents) !== JSON.stringify(savedContentsRef.current);
if (contentsChanged) {
timeoutIdRef.current = setTimeout(async () => {
try {
await onSave(watchedContents);
savedContentsRef.current = [...watchedContents];
setSaved(true);
setFailed(false);
} catch (error) {
console.error("Save failed:", error);
setFailed(true);
}
}, 2000);
}
return () => {
if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current);
};
}, [watchedContents, onSave]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current);
};
}, []);
// Handlers
const handleAdd = useCallback(
(type: "text" | "image") => {
append({ type, content: "" });
},
[append]
);
const handleDelete = useCallback(
(index: number) => {
const content = watchedContents[index];
if (content.type === "image") {
const fileIdMatch = content.content.match(/fileId=([^&]+)/);
if (fileIdMatch) {
const fileId = fileIdMatch[1];
deleteFile(fileId).catch((err) => console.error("Delete file error:", err));
}
}
remove(index);
},
[watchedContents, remove]
);
const toggleSize = useCallback(() => {
setSize((prev) => (prev === "md" ? "lg" : "md"));
}, []);
return (
<div className={cn(contentsInputVariant(), props.className)}>
{/* Header with label and action buttons */}
<div className="flex justify-between items-center">
<Label>{label}</Label>
<div className="flex items-center gap-2">
{/* Save Status Indicators */}
{!saved && !failed && isDirty && (
<div className="flex items-center gap-1 text-primary">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-t-2 border-primary" />
<span className="text-sm">Saving...</span>
</div>
)}
{saved && (
<div className="flex items-center gap-1 text-success">
<CheckIcon className="h-4 w-4" strokeWidth={2} />
<span className="text-sm">Saved</span>
</div>
)}
{failed && (
<Button
size="sm"
variant="ghost"
color="destructive"
onClick={() => handleSubmit(async (data) => {
try {
await onSave?.(data.contents);
savedContentsRef.current = [...data.contents];
setSaved(true);
setFailed(false);
} catch (err) {
setFailed(true);
}
})()}
>
<SaveIcon className="h-4 w-4" strokeWidth={2} />
<span className="ml-1">Try Again</span>
</Button>
)}
{/* Add Buttons */}
<Button
size="sm"
onClick={() => handleAdd("text")}
disabled={disabled}
variant="outline"
>
<ListPlusIcon className="h-4 w-4 mr-1" />
Add Text
</Button>
<Button
size="sm"
onClick={() => handleAdd("image")}
disabled={disabled}
variant="outline"
>
<ImagePlusIcon className="h-4 w-4 mr-1" />
Upload Image
</Button>
</div>
</div>
{/* Placeholder */}
{watchedContents.length === 0 && placeholder && (
<div className="flex items-center justify-center py-5 px-10">
<p className="text-center text-neutral-foreground text-lg font-medium">
{placeholder}
</p>
</div>
)}
{/* Content Fields */}
{fields.map((field, index) => (
<div key={`${keyPrefix}-${field.id}`} className={cn(fieldInputVariant())}>
{/* Conditionally render Text or Image Input */}
{field.type === "text" ? (
<Controller
control={control}
name={`contents.${index}.content`}
render={({ field: { onChange, value } }) => (
<Textarea
className={cn(textInputVariant())}
value={value}
onChange={onChange}
disabled={disabled}
placeholder="Enter text"
/>
)}
/>
) : (
<Controller
control={control}
name={`contents.${index}.content`}
render={({ field: { onChange, value } }) => (
<ImageInput
id={`${keyPrefix}-${index}`}
defaultValue={value}
onChange={onChange}
disabled={disabled}
size={size}
/>
)}
/>
)}
{/* Action Buttons */}
<div className="flex flex-col space-y-1">
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={() => handleDelete(index)}
disabled={disabled}
>
<TrashIcon className="h-4 w-4" strokeWidth={2} />
</Button>
<Button variant="ghost" size="icon" onClick={toggleSize} disabled={disabled}>
{size === "md" ? (
<Maximize2Icon className="h-4 w-4" strokeWidth={2} />
) : (
<Minimize2Icon className="h-4 w-4" strokeWidth={2} />
)}
</Button>
</div>
</div>
))}
{/* Display validation errors */}
{errors.contents && (
<span className="text-sm text-red-500">{errors.contents.message}</span>
)}
</div>
);
};
export default ContentsInput;
```
**Explanation of Changes:**
1. **Form Integration:**
- Utilized `useForm` with `zodResolver` to handle form state and validation based on the defined Zod schema.
- Employed `useFieldArray` to manage dynamic fields efficiently.
2. **Debounced Save Mechanism:**
- Implemented a `useEffect` hook that watches for changes in the `contents` array.
- Utilized a `useRef` to track the last saved state and debounce the `onSave` callback to prevent excessive calls.
3. **Action Handlers:**
- Optimized `handleAdd`, `handleDelete`, and `toggleSize` using `useCallback` to memoize the functions and prevent unnecessary re-renders.
4. **Save Status Indicators:**
- Enhanced visual feedback for saving status (saving, saved, failed) with appropriate icons and messages.
5. **Error Handling:**
- Displayed validation errors from Zod beneath the form fields for better user feedback.
6. **Styling and Layout:**
- Improved class names and layout structures for better readability and maintainability.
---
### 3. Refactor the `ImageInput` Component
Optimize the `ImageInput` component by leveraging `useCallback` and `React.memo` to prevent unnecessary re-renders and enhance performance.
**`components/c-ui/contents/input/image.tsx`**
```tsx
"use client";
import React, { useState, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Image from "next/image";
import { ImagePlusIcon } from "lucide-react";
import { uploadFile } from "@/app/actions/client/default.action";
interface IProps {
id: string;
defaultValue: string;
onChange: (value: string) => void;
disabled: boolean;
size: "md" | "lg";
}
const ImageInput: React.FC<IProps> = ({
id,
defaultValue,
onChange,
disabled,
size,
}) => {
const [preview, setPreview] = useState(defaultValue);
const handleUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
// Generate a local preview
const objectUrl = URL.createObjectURL(file);
setPreview(objectUrl);
// Upload the file
const formData = new FormData();
formData.append("file", file);
const fileId = await uploadFile(formData);
const fileUrl = `https://app.ehllie.com/api/file/image?fileId=${fileId}`;
onChange(fileUrl);
} catch (error) {
console.error("Error uploading the image:", error);
}
},
[onChange]
);
return (
<Label htmlFor={id} className="cursor-pointer w-full">
{preview ? (
<Image
src={preview.startsWith("blob:") ? preview : preview.replace("https://app.ehllie.com/", "/")}
alt={`Uploaded image ${id}`}
width={800}
height={800}
className={`object-contain rounded ${
size === "md" ? "w-full sm:w-96" : "w-full sm:w-full"
}`}
/>
) : (
<Placeholder size={size} />
)}
<Input
id={id}
type="file"
accept="image/*"
className="hidden"
onChange={handleUpload}
disabled={disabled}
/>
</Label>
);
};
export default React.memo(ImageInput);
interface PlaceholderProps {
size: "md" | "lg";
}
const Placeholder: React.FC<PlaceholderProps> = ({ size }) => (
<div
className={`flex flex-col items-center justify-center border-2 border-dashed border-neutral-foreground rounded-lg p-4 ${
size === "md" ? "h-72 sm:h-72" : "h-96 sm:h-96"
}`}
>
<ImagePlusIcon className="h-10 w-10 text-neutral-foreground mb-2" />
<p className="text-center text-lg font-medium text-neutral-foreground">
Upload an image of your diagram or handwritten answer
</p>
</div>
);
```
**Explanation of Changes:**
1. **Performance Optimization:**
- Wrapped the `ImageInput` component with `React.memo` to prevent unnecessary re-renders when props haven't changed.
- Utilized `useCallback` for the `handleUpload` function to memoize it based on dependencies.
2. **Preview Handling:**
- Differentiated between local previews (`blob:` URLs) and uploaded image URLs to handle the `src` appropriately.
3. **Styling Enhancements:**
- Improved class names for better responsiveness and appearance.
4. **Error Handling:**
- Added console error logging for better debugging during image upload failures.
---
### 4. Addressing Server Actions Import in Client Components
**Important:** In your original implementation, client-side components directly import server actions from `app/actions/client/default.action.ts`, which is not recommended and can lead to runtime errors. Server actions marked with `"use server"` should **not** be imported into client components.
**Solution:**
1. **Create API Routes:**
- Implement API endpoints to handle file uploads and deletions.
2. **Update Client Components:**
- Replace direct imports of server actions with `fetch` calls to these API endpoints.
**Example:**
**Create API Routes**
- **`app/api/uploadFile/route.ts`**
```typescript
import { NextResponse } from "next/server";
import File from "@/database/models/file.model";
import { uploadFile } from "@/app/actions/server/default.action"; // Ensure this is server-side
export async function POST(request: Request) {
try {
const formData = await request.formData();
const fileId = await uploadFile(formData);
return NextResponse.json({ fileId }, { status: 200 });
} catch (error) {
console.error("Upload error:", error);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}
```
- **`app/api/deleteFile/route.ts`**
```typescript
import { NextResponse } from "next/server";
import { deleteFile } from "@/app/actions/server/default.action"; // Ensure this is server-side
export async function DELETE(request: Request) {
try {
const { fileId } = await request.json();
await deleteFile(fileId);
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Delete error:", error);
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
}
}
```
**Update Client Components to Use API Routes**
- **`components/c-ui/contents/input/image.tsx`** (Updated `handleUpload`)
```tsx
import { useCallback } from "react";
// ... other imports remain the same
const handleUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
// Generate a local preview
const objectUrl = URL.createObjectURL(file);
setPreview(objectUrl);
// Upload the file via API
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/uploadFile", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("File upload failed");
}
const data = await response.json();
const fileUrl = `https://app.ehllie.com/api/file/image?fileId=${data.fileId}`;
onChange(fileUrl);
} catch (error) {
console.error("Error uploading the image:", error);
}
},
[onChange]
);
```
- **`components/c-ui/contents/input/index.tsx`** (Updated `handleDelete`)
```tsx
const handleDelete = useCallback(
async (index: number) => {
const content = watchedContents[index];
if (content.type === "image") {
const fileIdMatch = content.content.match(/fileId=([^&]+)/);
if (fileIdMatch) {
const fileId = fileIdMatch[1];
try {
await fetch("/api/deleteFile", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ fileId }),
});
} catch (err) {
console.error("Failed to delete file:", err);
}
}
}
remove(index);
},
[watchedContents, remove]
);
```
**Explanation of Changes:**
1. **API Routes:**
- Created separate API endpoints for uploading and deleting files to handle server-side operations securely.
2. **Client Component Fetch Calls:**
- Replaced direct imports of server actions with `fetch` calls to the newly created API routes.
- Ensured proper error handling and feedback during API interactions.
3. **Server-Side Action Separation:**
- Moved server-only functions (`uploadFile` and `deleteFile`) to a server-side directory to prevent client-side exposure.
---
### 5. Final Recommendations
- **Error Handling:** Ensure that all asynchronous operations have proper error handling to provide feedback to users and prevent silent failures.
- **Accessibility:** Make sure that all interactive elements (like buttons) are accessible, with appropriate `aria` attributes where necessary.
- **Performance Optimization:** Continuously monitor component performance, especially when dealing with large forms or numerous dynamic fields.
- **Testing:** Implement comprehensive unit and integration tests to ensure that all components behave as expected under various scenarios.
By integrating **React Hook Form** and **Zod**, and restructuring your components for better state management and performance, your `ContentsInput` component is now more efficient, readable, and maintainable, adhering to industry best practices.