import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { toast } from "sonner"
import { Avatar, AvatarFallback, AvatarImage } from "./components/ui/avatar"
import { Button } from "./components/ui/button"
import { Input } from "./components/ui/input"
import { Label } from "./components/ui/label"
import { Toaster } from "./components/ui/sonner"
const schema = z.object({
name: z.string().min(1, "Enter your name"),
username: z.string().regex(/^[a-z0-9_]+$/, "Use only lowercase letters, numbers, and _"),
bio: z.string().max(160, "Must be 160 characters or fewer"),
})
type Profile = z.infer<typeof schema>
const current: Profile = {
name: "John Smith",
username: "john",
bio: "Learning React.",
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
function Field({ id, label, error, children }: { id: string; label: string; error?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label>
{children}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)
}
export default function App() {
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const { register, handleSubmit, watch, reset, formState: { errors, isDirty, isSubmitting } } = useForm<Profile>({
resolver: zodResolver(schema),
defaultValues: current,
})
const name = watch("name")
const bio = watch("bio")
async function onSubmit(values: Profile) {
await wait(800)
reset(values)
toast.success("Saved")
}
function onPickImage(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (file) setAvatarUrl(URL.createObjectURL(file))
}
return (
<div className="mx-auto my-6 w-full max-w-md rounded-xl border bg-card p-6">
<p className="text-lg font-semibold">Profile</p>
<p className="mt-1 text-sm text-muted-foreground">This is visible to other people</p>
<form onSubmit={handleSubmit(onSubmit)} className="mt-6 flex flex-col gap-4">
<div className="flex items-center gap-4">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback className="bg-linear-to-br from-sky-400 to-indigo-500 text-xl font-semibold text-white">{name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div>
{}
<Button type="button" variant="outline" size="sm" asChild>
<label htmlFor="avatar" className="cursor-pointer">Change photo</label>
</Button>
<input id="avatar" type="file" accept="image/*" className="hidden" onChange={onPickImage} />
<p className="mt-1 text-xs text-muted-foreground">JPG or PNG, up to 2MB</p>
</div>
</div>
<Field id="name" label="Name" error={errors.name?.message}>
<Input id="name" {...register("name")} />
</Field>
<Field id="username" label="Username" error={errors.username?.message}>
{}
<div className="flex rounded-md border">
<span className="flex items-center border-r bg-muted px-3 text-sm text-muted-foreground">@</span>
<Input id="username" className="rounded-l-none border-0 shadow-none" {...register("username")} />
</div>
</Field>
<Field id="bio" label="Bio" error={errors.bio?.message}>
<textarea
id="bio"
rows={3}
className="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
{...register("bio")}
/>
<p className="text-right text-xs text-muted-foreground">{bio.length} / 160</p>
</Field>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" disabled={!isDirty} onClick={() => reset()}>
Reset
</Button>
<Button type="submit" disabled={!isDirty || isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
</form>
<Toaster position="bottom-right" />
</div>
)
}