← 모든 템플릿

설정·프로필

입력 검증, 실시간 미리보기, 변경 감지, 저장 실패 복구와 변경 취소.

로컬 작동 예제입니다. 새로고침하면 초기화됩니다. 실제 저장은 onSave에 연결하세요.

WORKSPACE / PREFERENCES

나에게 맞는 작업 공간.

프로필을 정리하고 필요한 알림만 받아보세요.

공개 프로필

팀원이 알아볼 수 있는 이름을 사용하세요.

계정과 작업 공간의 안내를 받습니다.

240자 이내로 나를 소개하세요.

57/240

알림 설정

멘션, 할당 및 중요한 변경을 알립니다.

매일의 알림 대신 한 번의 요약을 받습니다.

모든 변경 사항 저장됨

설치

기본 테마와 Pretendard는 설치 안내에 따라 한 번 설정하세요. 이 템플릿은 기존 테마·전역 CSS·페이지 경로를 덮어쓰지 않습니다.

설치: template-settingsnpx shadcn@latest add @neumorphism-ui/template-settings

사용 예제

사용 예제
"use client";
import { SettingsPanel, type ProfileSettings } from "@/components/blocks/settings-panel";

export function Example({ profile, saveProfile }: {
  profile: ProfileSettings;
  saveProfile: (values: ProfileSettings) => Promise<void>;
}) {
  return <SettingsPanel initialValues={profile} onSave={saveProfile} locale="ko" />;
}

연결 방식

아래 데모는 메모리에서만 동작하며 새로고침하면 초기화됩니다. 실제 컴포넌트의 onSave·onDelete 콜백에 인증된 서비스를 연결하세요. initialValues·initialRecords는 최초 값이므로 다른 데이터로 전환할 때 key를 바꾸세요.

실제 저장 처리는 아래 Example의 콜백에 연결합니다.

설치되는 소스

@components/blocks/settings-panel.tsx
@components/blocks/settings-panel.tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel, FieldControl, FieldDescription, FieldError } from "@/components/ui/field";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { ToastProvider, Toaster, useToast } from "@/components/ui/toast";

export type ProfileSettings = { displayName: string; email: string; bio: string; notifications: boolean; weeklyDigest: boolean };
export type SettingsPanelProps = { initialValues: ProfileSettings; onSave: (values: ProfileSettings) => Promise<void>; locale?: "ko" | "en" | "ja" | "zh" };
const copy = {
  en: { kicker: "WORKSPACE / PREFERENCES", title: "Make space for your best work.", intro: "Keep your profile current and choose what deserves your attention.", profile: "Public profile", preference: "Notifications", name: "Display name", nameHint: "Use the name your teammates recognize.", email: "Email address", emailHint: "Used for account and workspace updates.", bio: "A little about you", bioHint: "A short introduction. Up to 240 characters.", required: "Enter a name, not only spaces.", emailError: "Enter a valid email address.", notificationTitle: "Project updates", notificationBody: "Mentions, assignments and important changes.", digestTitle: "Weekly digest", digestBody: "One summary, without the daily noise.", save: "Save changes", saving: "Saving…", discard: "Discard changes", dirty: "Unsaved changes", clean: "All changes saved", failure: "Changes were not saved", recovery: "Your edits are still here. Try saving again, or discard to restore the saved version.", saved: "Profile updated", savedBody: "The saved version now includes your changes.", about: "Your workspace identity", preview: "Live preview", navigation: "Settings sections", demo: "Interactive local example. Changes reset on reload; connect onSave to your own service.", fail: "Fail the next save", error: "Simulated save failure", dismiss: "Dismiss notification" },
  ko: { kicker: "WORKSPACE / PREFERENCES", title: "나에게 맞는 작업 공간.", intro: "프로필을 정리하고 필요한 알림만 받아보세요.", profile: "공개 프로필", preference: "알림 설정", name: "표시 이름", nameHint: "팀원이 알아볼 수 있는 이름을 사용하세요.", email: "이메일 주소", emailHint: "계정과 작업 공간의 안내를 받습니다.", bio: "간단한 소개", bioHint: "240자 이내로 나를 소개하세요.", required: "공백이 아닌 이름을 입력하세요.", emailError: "올바른 이메일 주소를 입력하세요.", notificationTitle: "프로젝트 업데이트", notificationBody: "멘션, 할당 및 중요한 변경을 알립니다.", digestTitle: "주간 요약", digestBody: "매일의 알림 대신 한 번의 요약을 받습니다.", save: "변경 저장", saving: "저장 중…", discard: "변경 취소", dirty: "저장하지 않은 변경 사항", clean: "모든 변경 사항 저장됨", failure: "저장하지 못했습니다", recovery: "입력한 내용은 유지됩니다. 다시 저장하거나 취소해 저장된 상태로 돌아가세요.", saved: "프로필을 저장했습니다", savedBody: "변경 사항을 저장된 상태에 반영했습니다.", about: "작업 공간의 내 프로필", preview: "실시간 미리보기", navigation: "설정 섹션", demo: "로컬 작동 예제입니다. 새로고침하면 초기화됩니다. 실제 저장은 onSave에 연결하세요.", fail: "다음 저장 실패 재현", error: "저장 실패 예제", dismiss: "알림 닫기" },
  ja: { kicker: "WORKSPACE / PREFERENCES", title: "自分らしいワークスペース。", intro: "プロフィールを整え、必要な通知だけを受け取りましょう。", profile: "公開プロフィール", preference: "通知設定", name: "表示名", nameHint: "チームにわかりやすい名前を使ってください。", email: "メールアドレス", emailHint: "アカウントとワークスペースのお知らせに使います。", bio: "自己紹介", bioHint: "240文字以内で紹介してください。", required: "空白以外の名前を入力してください。", emailError: "有効なメールアドレスを入力してください。", notificationTitle: "プロジェクトの更新", notificationBody: "メンション、担当と重要な変更を通知します。", digestTitle: "週間まとめ", digestBody: "日々の通知の代わりにまとめを受け取ります。", save: "変更を保存", saving: "保存中…", discard: "変更を取り消す", dirty: "未保存の変更", clean: "すべて保存済み", failure: "保存できませんでした", recovery: "編集内容は残っています。再試行するか、保存済みの内容に戻してください。", saved: "プロフィールを保存しました", savedBody: "変更を保存済みの内容に反映しました。", about: "ワークスペースのプロフィール", preview: "ライブプレビュー", navigation: "設定セクション", demo: "ローカル動作例です。再読み込みで初期化されます。実際の保存先を onSave に接続してください。", fail: "次の保存を失敗させる", error: "保存失敗の例", dismiss: "通知を閉じる" },
  zh: { kicker: "WORKSPACE / PREFERENCES", title: "让工作空间更适合你。", intro: "完善个人资料,只接收值得关注的通知。", profile: "公开资料", preference: "通知设置", name: "显示名称", nameHint: "使用团队成员能认出的名称。", email: "电子邮箱", emailHint: "用于账户和工作空间通知。", bio: "个人简介", bioHint: "用不超过240个字符介绍自己。", required: "请输入非空白名称。", emailError: "请输入有效的邮箱地址。", notificationTitle: "项目更新", notificationBody: "接收提及、任务分配和重要变更。", digestTitle: "每周摘要", digestBody: "每周汇总,减少日常打扰。", save: "保存更改", saving: "正在保存…", discard: "放弃更改", dirty: "有未保存的更改", clean: "所有更改已保存", failure: "未能保存", recovery: "编辑内容仍然保留。请重试,或放弃更改以恢复已保存的版本。", saved: "资料已更新", savedBody: "更改已反映在保存的版本中。", about: "工作空间中的个人资料", preview: "实时预览", navigation: "设置区域", demo: "这是本地交互示例,刷新后重置。请将 onSave 连接到实际服务。", fail: "模拟下次保存失败", error: "模拟保存失败", dismiss: "关闭通知" },
} as const;

function SettingsEditor({ initialValues, onSave, locale = "en" }: SettingsPanelProps) {
  const t = copy[locale];
  const id = React.useId();
  const toast = useToast();
  const [saved, setSaved] = React.useState(() => ({ ...initialValues }));
  const [draft, setDraft] = React.useState(() => ({ ...initialValues }));
  const [pending, setPending] = React.useState(false);
  const busy = React.useRef(false);
  const [failed, setFailed] = React.useState(false);
  const [revision, setRevision] = React.useState(0);
  const dirty = Object.keys(saved).some(key => saved[key as keyof ProfileSettings] !== draft[key as keyof ProfileSettings]);
  const update = <K extends keyof ProfileSettings>(key: K, value: ProfileSettings[K]) => { setDraft(current => ({ ...current, [key]: value })); setFailed(false); };
  const discard = () => { if (busy.current) return; setDraft({ ...saved }); setFailed(false); setRevision(value => value + 1); };
  async function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (busy.current || !dirty) return;
    busy.current = true; setPending(true); setFailed(false);
    const snapshot = { ...draft, displayName: draft.displayName.trim(), email: draft.email.trim() };
    try { await onSave(snapshot); setSaved(snapshot); setDraft(snapshot); toast.add({ title: t.saved, description: t.savedBody, type: "success" }); }
    catch { setFailed(true); }
    finally { busy.current = false; setPending(false); }
  }
  return <div data-slot="settings-panel" className="mx-auto grid w-full max-w-5xl gap-8 font-sans text-[var(--foreground)]">
    <header className="grid gap-3 border-b border-[var(--border)] pb-7"><p className="text-xs font-semibold tracking-[0.16em] text-[var(--muted-foreground)]">{t.kicker}</p><h2 className="max-w-xl text-3xl font-semibold leading-tight tracking-tight sm:text-4xl">{t.title}</h2><p className="max-w-xl text-sm leading-relaxed text-[var(--muted-foreground)]">{t.intro}</p></header>
    <div className="grid min-w-0 gap-8 lg:grid-cols-[15rem_minmax(0,1fr)]">
      <aside className="grid content-start gap-6"><nav aria-label={t.navigation} className="flex gap-2 lg:grid"><a href={`#${id}-profile`} className="rounded-[var(--neu-radius-control)] border border-transparent bg-[var(--neu-surface)] text-[var(--neu-accent-ink)] shadow-[var(--neu-shadow-inset)] px-4 py-3 text-sm font-semibold no-underline outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]">{t.profile}</a><a href={`#${id}-notifications`} className="rounded-[var(--neu-radius-control)] px-4 py-3 text-sm text-[var(--muted-foreground)] no-underline outline-none hover:bg-[var(--neu-surface-soft)] focus-visible:ring-2 focus-visible:ring-[var(--ring)]">{t.preference}</a></nav>
        <section aria-label={t.preview} className="grid gap-4 rounded-[var(--neu-radius-surface)] border border-[var(--border)] bg-[var(--neu-surface)] p-5"><span aria-hidden="true" className="grid size-14 place-items-center rounded-[var(--neu-radius-control)] bg-[var(--neu-surface)] text-xl font-semibold [box-shadow:var(--neu-shadow-raised-sm)]">{draft.displayName.trim().slice(0, 2).toUpperCase() || "—"}</span><div className="grid min-w-0 gap-1"><p className="font-semibold [overflow-wrap:anywhere]">{draft.displayName || t.name}</p><p className="text-xs leading-relaxed text-[var(--muted-foreground)] [overflow-wrap:anywhere]">{draft.email}</p></div><p className="text-xs text-[var(--muted-foreground)]">{t.about}</p></section>
      </aside>
      <Form key={revision} onSubmit={submit} aria-busy={pending} className="gap-7">
        <fieldset disabled={pending} className="m-0 grid min-w-0 gap-7 border-0 p-0">
          <section id={`${id}-profile`} className="grid scroll-mt-24 gap-5 rounded-[var(--neu-radius-surface)] border border-[color:var(--neu-edge)] bg-[var(--neu-surface)] p-5 [box-shadow:var(--neu-shadow-raised-sm)]"><h3 className="text-lg font-semibold">{t.profile}</h3>
            <Field name="displayName" validate={value => String(value).trim() ? null : t.required}><FieldLabel>{t.name}</FieldLabel><FieldControl render={<Input />} value={draft.displayName} onValueChange={value => update("displayName", value)} required maxLength={60} autoComplete="name" /><FieldDescription>{t.nameHint}</FieldDescription><FieldError>{t.required}</FieldError></Field>
            <Field name="email"><FieldLabel>{t.email}</FieldLabel><FieldControl render={<Input />} type="email" value={draft.email} onValueChange={value => update("email", value)} required autoComplete="email" /><FieldDescription>{t.emailHint}</FieldDescription><FieldError>{t.emailError}</FieldError></Field>
            <div className="grid gap-2"><label htmlFor={`${id}-bio`} className="text-sm font-semibold">{t.bio}</label><Textarea id={`${id}-bio`} name="bio" value={draft.bio} onChange={event => update("bio", event.target.value)} maxLength={240} rows={4} aria-describedby={`${id}-bio-hint`} /><div className="flex justify-between gap-4 text-xs text-[var(--muted-foreground)]"><p id={`${id}-bio-hint`}>{t.bioHint}</p><span className="tabular-nums">{draft.bio.length}/240</span></div></div>
          </section>
          <section id={`${id}-notifications`} className="grid scroll-mt-24 gap-5 rounded-[var(--neu-radius-surface)] border border-[var(--border)] bg-transparent p-5"><h3 className="text-lg font-semibold">{t.preference}</h3>
            {([['notifications', t.notificationTitle, t.notificationBody], ['weeklyDigest', t.digestTitle, t.digestBody]] as const).map(([key, label, description]) => <div key={key} className="flex items-center justify-between gap-5"><div className="grid gap-1"><label htmlFor={`${id}-${key}`} className="text-sm font-semibold">{label}</label><p id={`${id}-${key}-hint`} className="text-sm leading-relaxed text-[var(--muted-foreground)]">{description}</p></div><Switch id={`${id}-${key}`} name={key} checked={draft[key]} onCheckedChange={value => update(key, value)} aria-describedby={`${id}-${key}-hint`} /></div>)}
          </section>
        </fieldset>
        {failed && <Alert variant="destructive"><AlertTitle>{t.failure}</AlertTitle><AlertDescription>{t.recovery}</AlertDescription></Alert>}
        <footer className="flex flex-wrap items-center justify-between gap-4 border-t border-[var(--border)] pt-5"><p role="status" className="text-sm text-[var(--muted-foreground)]">{pending ? t.saving : dirty ? t.dirty : t.clean}</p><div className="flex flex-wrap gap-3"><Button onClick={discard} disabled={!dirty || pending}>{t.discard}</Button><Button type="submit" variant="primary" disabled={!dirty || pending}>{pending ? t.saving : t.save}</Button></div></footer>
      </Form>
    </div>
  </div>;
}

export function SettingsPanel(props: SettingsPanelProps) {
  const t = copy[props.locale ?? "en"];
  return <ToastProvider><SettingsEditor {...props} /><Toaster label={t.preference} closeLabel={t.dismiss} /></ToastProvider>;
}

const exampleValues: ProfileSettings = { displayName: "Alex Kim", email: "alex@example.com", bio: "Designing useful things, one thoughtful detail at a time.", notifications: true, weeklyDigest: false };
export function SettingsExample({ locale = "en" }: { locale?: SettingsPanelProps["locale"] }) {
  const [failNext, setFailNext] = React.useState(false);
  const t = copy[locale];
  return <div className="grid gap-6"><div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-dashed border-[var(--border)] p-4 text-xs leading-relaxed text-[var(--muted-foreground)]"><p className="max-w-xl">{t.demo}</p><label className="flex items-center gap-2"><input type="checkbox" checked={failNext} onChange={event => setFailNext(event.target.checked)} />{t.fail}</label></div><SettingsPanel initialValues={exampleValues} locale={locale} onSave={async () => { await new Promise(resolve => setTimeout(resolve, 350)); if (failNext) { setFailNext(false); throw new Error(t.error); } }} /></div>;
}