nnuer
강희태@gmlxo9917

아바타 변경과 읽기 전용 이메일이 있는 프로필 설정 폼

기타 블록

원형 아바타와 사진 변경 버튼, 이름·이메일·역할·부서·직책 입력 칸, 취소/저장 버튼으로 구성된 흰 카드형 프로필 설정 폼입니다. 사진 형식·용량 검사와 변경 사항 되돌리기를 지원합니다.

블록 신고

운영자가 확인한 뒤 필요하면 블록을 숨깁니다.

사유
Claude Code · 모름 · React (TSX)
결과 코드펼치기
import { useEffect, useRef, useState } from "react";

const FONT_URL = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap";

const INITIAL = {
  name: "Jordan Rivera",
  email: "jordan.rivera@example.com",
  role: "Member",
  department: "Product Design",
  position: "Senior Designer",
};

const fieldClass =
  "h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-[14px] leading-normal text-[#0f172a] outline-none transition-colors placeholder:text-[#94a3b8] focus:border-[#4f46e5] focus:ring-2 focus:ring-[#4f46e5]/15";

export default function ProfileSettingsForm() {
  const [form, setForm] = useState(INITIAL);
  const [avatar, setAvatar] = useState<string | null>(null);
  const [avatarError, setAvatarError] = useState("");
  const [saved, setSaved] = useState(false);
  const fileRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    return () => {
      if (avatar) URL.revokeObjectURL(avatar);
    };
  }, [avatar]);

  const update = (key: keyof typeof INITIAL) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
    setSaved(false);
    setForm({ ...form, [key]: e.target.value });
  };

  const onFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    if (!["image/jpeg", "image/png"].includes(file.type)) {
      setAvatarError("JPG 또는 PNG 파일만 올릴 수 있습니다.");
      return;
    }
    if (file.size > 2 * 1024 * 1024) {
      setAvatarError("2MB 이하 파일만 올릴 수 있습니다.");
      return;
    }
    setAvatarError("");
    setSaved(false);
    setAvatar(URL.createObjectURL(file));
  };

  const initials = form.name
    .split(" ")
    .filter(Boolean)
    .slice(0, 2)
    .map((w) => w[0]?.toUpperCase())
    .join("");

  const dirty = JSON.stringify(form) !== JSON.stringify(INITIAL) || avatar !== null;

  return (
    <section className="w-full bg-[#f5f5f5] px-4 py-10 font-['Inter',sans-serif] sm:px-6 sm:py-16">
      <link rel="stylesheet" href={FONT_URL} precedence="default" />
      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (!form.name.trim()) return;
          setSaved(true);
        }}
        className="mx-auto flex w-full max-w-[640px] flex-col gap-8 rounded-2xl bg-white p-6 drop-shadow-[0px_4px_10px_rgba(0,0,0,0.03)] sm:p-10"
      >
        <div className="flex flex-wrap items-center gap-6">
          <div className="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-[40px] bg-gradient-to-br from-[#c7d2fe] to-[#4f46e5] text-[24px] font-semibold text-white">
            {avatar ? <img src={avatar} alt="프로필 사진 미리보기" className="size-full object-cover" /> : <span aria-hidden="true">{initials}</span>}
          </div>
          <div className="flex flex-col gap-1">
            <button
              type="button"
              onClick={() => fileRef.current?.click()}
              className="flex items-center justify-center gap-2 self-start rounded-lg border border-[#e2e8f0] bg-white px-4 py-2.5 text-[14px] font-semibold leading-normal text-[#0f172a] transition-colors hover:bg-[#f8fafc] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]"
            >
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
                <circle cx="12" cy="13" r="4" />
              </svg>
              Change Avatar
            </button>
            <input ref={fileRef} type="file" accept="image/jpeg,image/png" onChange={onFile} className="sr-only" aria-label="프로필 사진 선택" tabIndex={-1} />
            <p className={`text-[12px] leading-normal ${avatarError ? "text-[#dc2626]" : "text-[#94a3b8]"}`} role={avatarError ? "alert" : undefined}>
              {avatarError || "JPG or PNG. Max size 2MB."}
            </p>
          </div>
        </div>

        <div className="flex flex-col gap-5">
          <div className="flex flex-col gap-2">
            <label htmlFor="pf-name" className="text-[14px] font-semibold leading-normal text-[#0f172a]">
              Name
            </label>
            <input id="pf-name" required value={form.name} onChange={update("name")} className={fieldClass} />
          </div>

          <div className="flex flex-col gap-2">
            <label htmlFor="pf-email" className="text-[14px] font-semibold leading-normal text-[#0f172a]">
              Email
            </label>
            <input
              id="pf-email"
              value={form.email}
              readOnly
              aria-describedby="pf-email-hint"
              className="h-11 w-full cursor-not-allowed rounded-lg border border-[#e2e8f0] bg-[#f1f5f9] px-3 text-[14px] leading-normal text-[#94a3b8] outline-none"
            />
            <p id="pf-email-hint" className="text-[12px] leading-normal text-[#94a3b8]">
              Email address cannot be changed for security reasons.
            </p>
          </div>

          <div className="flex flex-col gap-2">
            <label htmlFor="pf-role" className="text-[14px] font-semibold leading-normal text-[#0f172a]">
              Role
            </label>
            <div className="relative">
              <select id="pf-role" value={form.role} onChange={update("role")} className={`${fieldClass} cursor-pointer appearance-none pr-9`}>
                <option>Member</option>
                <option>Editor</option>
                <option>Admin</option>
              </select>
              <svg className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[#0f172a]" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="m6 9 6 6 6-6" />
              </svg>
            </div>
          </div>

          <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
            <div className="flex flex-col gap-2">
              <label htmlFor="pf-dept" className="text-[14px] font-semibold leading-normal text-[#0f172a]">
                Department
              </label>
              <input id="pf-dept" value={form.department} onChange={update("department")} className={fieldClass} />
            </div>
            <div className="flex flex-col gap-2">
              <label htmlFor="pf-pos" className="text-[14px] font-semibold leading-normal text-[#0f172a]">
                Position
              </label>
              <input id="pf-pos" value={form.position} onChange={update("position")} className={fieldClass} />
            </div>
          </div>
        </div>

        <div className="flex flex-wrap items-center justify-end gap-3">
          <p className="mr-auto text-[13px] text-[#16a34a]" aria-live="polite">
            {saved ? "변경 사항을 저장했습니다." : ""}
          </p>
          <button
            type="button"
            onClick={() => {
              setForm(INITIAL);
              setAvatar(null);
              setAvatarError("");
              setSaved(false);
            }}
            disabled={!dirty}
            className="rounded-lg border border-[#e2e8f0] bg-white px-4 py-2.5 text-[14px] font-semibold leading-normal text-[#0f172a] transition-colors hover:bg-[#f8fafc] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5] disabled:cursor-not-allowed disabled:opacity-50"
          >
            Cancel
          </button>
          <button
            type="submit"
            className="rounded-lg bg-[#4f46e5] px-4 py-2.5 text-[14px] font-semibold leading-normal text-white transition-colors hover:bg-[#4338ca] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]"
          >
            Save Changes
          </button>
        </div>
      </form>
    </section>
  );
}

적용 사이트 0

이 블록이 실제로 쓰인 사이트입니다.

로그인하고 내 사이트 추가

아직 등록된 사이트가 없습니다. 이 블록을 쓴 사이트가 있다면 알려 주세요.

로그인하고 재현 결과 올리기

재현 후기

v1 · 0개

아직 이 버전의 프롬프트로 직접 만들어 본 사람이 없습니다. 첫 번째로 재현해 보세요.