"use client";
import { useEffect, useRef, useState } from "react";
import {
Bell,
ChevronDown,
ChevronRight,
CircleHelp,
Database,
Download,
Fingerprint,
Laptop,
Smartphone,
Monitor,
Moon,
ShieldCheck,
SlidersHorizontal,
Sun,
UserRound,
X,
} from "lucide-react";
import {
DialogRoot,
DialogPanel,
DialogHeading,
DialogDescription,
DialogDismiss,
Switch,
useOptTheme,
} from "@reopt-ai/opt-ui";
import { ShellProvider, ShellAppShell } from "@reopt-ai/opt-shell";
import { createShellApp } from "@reopt-ai/opt-shell/core";
import { Link } from "@/i18n/routing";
import { ProfileSettings } from "./settings-profile";
import {
Avatar,
Field,
SectionHeading,
SettingRow,
SavedPill,
SettingsSelect,
downloadSettings,
} from "./settings-ui";
import {
DEFAULT_SETTINGS,
SETTINGS_STORAGE_KEY,
parseSettings,
cleanProfile,
validateProfile,
isProfileDirty,
formatPreviewDate,
type SettingsPage,
type SettingsState,
} from "./settings-model";
import s from "./settings.module.css";
const manifest = createShellApp({
id: "forma-settings-example",
label: "Forma",
defaults: {
density: "comfortable",
navigationMode: "stacked",
contentWidth: "full",
},
});
const pages = [
{ id: "profile", label: "내 프로필", icon: UserRound },
{ id: "security", label: "로그인 및 보안", icon: ShieldCheck },
{ id: "devices", label: "기기", icon: Laptop },
{ id: "notifications", label: "알림", icon: Bell },
{ id: "preferences", label: "환경설정", icon: SlidersHorizontal },
{ id: "data", label: "데이터 관리", icon: Database },
{ id: "help", label: "도움말", icon: CircleHelp },
] as const;
function SettingsApp() {
const content = useRef<HTMLDivElement>(null);
const [model, setModel] = useState<SettingsState>(() =>
structuredClone(DEFAULT_SETTINGS),
);
const [draft, setDraft] = useState(DEFAULT_SETTINGS.profile);
const [ready, setReady] = useState(false);
const [active, setActive] = useState<SettingsPage>("profile");
const [notice, setNotice] = useState("");
const [modal, setModal] = useState<"reset" | "factor" | "passkey" | null>(
null,
);
const [entry, setEntry] = useState("");
const { mode, setMode, resolvedMode } = useOptTheme();
useEffect(() => {
try {
const next = parseSettings(localStorage.getItem(SETTINGS_STORAGE_KEY));
setModel(next);
setDraft(next.profile);
} catch {
setNotice("브라우저에 저장된 설정을 불러올 수 없습니다.");
}
setReady(true);
}, []);
useEffect(() => {
if (!notice) return;
const timeout = setTimeout(() => setNotice(""), 4500);
return () => clearTimeout(timeout);
}, [notice]);
const dirty = isProfileDirty(draft, model.profile);
useEffect(() => {
content.current?.parentElement?.scrollTo({ top: 0 });
}, [active]);
const current = pages.find((p) => p.id === active)!;
function persist(next: SettingsState, message = "설정을 저장했어요.") {
setModel(next);
try {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(next));
setNotice(message);
} catch {
setNotice(
"브라우저 저장 공간을 사용할 수 없어요. 이번 방문에만 적용됩니다.",
);
}
}
function saveProfile() {
if (Object.keys(validateProfile(draft)).length) {
setActive("profile");
setNotice("이름, 이메일, 소개를 확인해 주세요.");
return;
}
const profile = cleanProfile(draft);
persist({ ...model, profile }, "프로필을 저장했어요.");
setDraft(profile);
}
const pref = (patch: Partial<SettingsState["preferences"]>) =>
persist({ ...model, preferences: { ...model.preferences, ...patch } });
const notification = (patch: Partial<SettingsState["notifications"]>) =>
persist({ ...model, notifications: { ...model.notifications, ...patch } });
const security = (patch: Partial<SettingsState["security"]>) =>
persist({ ...model, security: { ...model.security, ...patch } });
function showModal(next: typeof modal) {
setEntry("");
setModal(next);
}
return (
<div
className={s.app}
data-appearance={resolvedMode}
data-density={model.preferences.density}
data-reduce-motion={model.preferences.reduceMotion}
data-ready={ready}
>
<ShellAppShell
className={s.shell}
contentClassName={s.main}
stackedNavigation={
<div className={s.navigation}>
<nav aria-label="설정 메뉴" className={s.tabs}>
{pages.map((p) => (
<button
type="button"
key={p.id}
aria-current={active === p.id ? "page" : undefined}
onClick={() => setActive(p.id)}
>
{p.label}
</button>
))}
</nav>
</div>
}
header={
<header className={s.topbar}>
<div>
<span className={s.wordmark}>forma</span>
</div>
<div>
<button
type="button"
className={s.iconButton}
aria-label={
resolvedMode === "dark"
? "라이트 모드로 전환"
: "다크 모드로 전환"
}
onClick={() =>
setMode(resolvedMode === "dark" ? "light" : "dark")
}
>
{resolvedMode === "dark" ? (
<Sun size={17} />
) : (
<Moon size={17} />
)}
</button>
<Avatar profile={model.profile} size="sm" />
</div>
</header>
}
>
<div ref={content} className={s.content}>
<div className={active === "profile" ? s.srOnly : s.pageHeading}>
<h1>{current.label}</h1>
</div>
{active === "profile" && (
<ProfileSettings
profile={model.profile}
twoFactor={model.security.twoFactor}
onSecurity={() => setActive("security")}
draft={draft}
dirty={dirty}
ready={ready}
setDraft={setDraft}
onSave={saveProfile}
onDiscard={() => setDraft(model.profile)}
/>
)}
{active === "preferences" && (
<div className={s.panel}>
<SectionHeading title="화면 테마" />
<div className={s.themeChoices}>
{(
[
{ id: "light", label: "라이트", icon: Sun },
{ id: "dark", label: "다크", icon: Moon },
{ id: "system", label: "시스템", icon: Monitor },
] as const
).map((t) => (
<button
type="button"
key={t.id}
aria-pressed={mode === t.id}
onClick={() => setMode(t.id)}
className={s.themeChoice}
>
<span className={s.themePreview} data-theme-preview={t.id}>
<i />
<span>
<b />
<b />
<b />
</span>
</span>
<span>
<t.icon size={15} />
{t.label}
<span className={s.radioDot} />
</span>
</button>
))}
</div>
<div className={s.formSection}>
<SectionHeading title="작업 환경" />
<SettingRow title="화면 밀도">
<SettingsSelect
aria-label="화면 밀도"
value={model.preferences.density}
onChange={(e) =>
pref({
density: e.target.value as "comfortable" | "compact",
})
}
>
<option value="comfortable">여유롭게</option>
<option value="compact">간결하게</option>
</SettingsSelect>
</SettingRow>
<SettingRow title="움직임 줄이기">
<Switch
aria-label="움직임 줄이기"
checked={model.preferences.reduceMotion}
onChange={(value) => pref({ reduceMotion: value })}
/>
</SettingRow>
</div>
<div className={s.formSection}>
<SectionHeading title="날짜와 시간" />
<div className={s.fieldGrid}>
<Field id="timezone" label="시간대">
<SettingsSelect
id="timezone"
value={model.preferences.timezone}
onChange={(e) =>
pref({
timezone: e.target
.value as SettingsState["preferences"]["timezone"],
})
}
>
<option value="Asia/Seoul">서울 (GMT+09:00)</option>
<option value="Asia/Tokyo">도쿄 (GMT+09:00)</option>
<option value="America/New_York">뉴욕</option>
<option value="Europe/London">런던</option>
</SettingsSelect>
</Field>
<Field id="date-format" label="날짜 형식">
<SettingsSelect
id="date-format"
value={model.preferences.dateFormat}
onChange={(e) =>
pref({
dateFormat: e.target
.value as SettingsState["preferences"]["dateFormat"],
})
}
>
<option value="ko-KR">한국어</option>
<option value="en-US">English (US)</option>
<option value="en-GB">English (UK)</option>
</SettingsSelect>
</Field>
</div>
<div className={s.infoStrip}>
미리보기{" "}
<strong>{formatPreviewDate(model.preferences)}</strong>
</div>
</div>
</div>
)}
{active === "notifications" && (
<div className={s.panel}>
<section className={s.formSection}>
<SectionHeading title="워크스페이스 활동" />
{(
[
{
id: "mentions",
title: "멘션 및 댓글",
},
{
id: "assignments",
title: "업무 배정",
},
{
id: "digest",
title: "주간 요약",
},
] as const
).map((n) => (
<SettingRow key={n.id} title={n.title}>
<Switch
aria-label={n.title}
checked={model.notifications[n.id]}
onChange={(v) => notification({ [n.id]: v })}
/>
</SettingRow>
))}
</section>
<section className={s.formSection}>
<SectionHeading title="Forma 소식" />
{(
[
{
id: "product",
title: "제품 업데이트",
},
{
id: "marketing",
title: "팁과 이벤트",
},
] as const
).map((n) => (
<SettingRow key={n.id} title={n.title}>
<Switch
aria-label={n.title}
checked={model.notifications[n.id]}
onChange={(v) => notification({ [n.id]: v })}
/>
</SettingRow>
))}
</section>
<section className={s.formSection}>
<SettingRow title="방해 금지 시간">
<Switch
aria-label="방해 금지 시간"
checked={model.notifications.quietHours}
onChange={(v) => notification({ quietHours: v })}
/>
</SettingRow>
<div className={s.timeRange}>
<label>
시작
<input
type="time"
aria-label="방해 금지 시작"
disabled={!model.notifications.quietHours}
value={model.notifications.quietFrom}
onChange={(e) => {
if (e.target.value)
notification({ quietFrom: e.target.value });
}}
/>
</label>
<span>—</span>
<label>
종료
<input
type="time"
aria-label="방해 금지 종료"
disabled={!model.notifications.quietHours}
value={model.notifications.quietTo}
onChange={(e) => {
if (e.target.value)
notification({ quietTo: e.target.value });
}}
/>
</label>
<small>{model.preferences.timezone}</small>
</div>
</section>
<div className={s.footnote}>
<ShieldCheck size={15} />
보안 알림은 항상 켜져 있습니다.
</div>
</div>
)}
{active === "security" && (
<div className={s.panelStack}>
<section className={s.activityPanel}>
<SectionHeading title="최근 계정 활동" />
<div className={s.securitySymbol}>
<ShieldCheck size={70} strokeWidth={1.2} />
</div>
<button
type="button"
className={s.activityLink}
onClick={() => setActive("devices")}
>
<span>
<strong>MacBook Pro에서 로그인</strong>
<small>서울 · 현재 기기</small>
</span>
<ChevronRight size={20} />
</button>
</section>
<section className={s.formSection}>
<SectionHeading title="인증 및 보안" />
<div className={s.securitySymbol}>
<Fingerprint size={70} strokeWidth={1.2} />
</div>
<SettingRow title="2단계 인증" icon={<ShieldCheck size={20} />}>
<button
type="button"
className={s.secondaryButton}
onClick={() => showModal("factor")}
>
{model.security.twoFactor ? "사용 중 · 변경" : "설정하기"}
</button>
</SettingRow>
<SettingRow title="패스키" icon={<Fingerprint size={20} />}>
<button
type="button"
className={s.secondaryButton}
onClick={() => showModal("passkey")}
>
샘플 패스키 추가
</button>
</SettingRow>
<div className={s.passkeys}>
{model.security.passkeys.map((k) => (
<div key={k.id}>
<Fingerprint size={15} />
<span>
{k.name}
<small>{k.created} 등록</small>
</span>
<button
type="button"
className={s.textButton}
aria-label={`${k.name} 패스키 삭제`}
onClick={() =>
security({
passkeys: model.security.passkeys.filter(
(p) => p.id !== k.id,
),
})
}
>
삭제
</button>
</div>
))}
</div>
</section>
<p className={s.footnote}>샘플 계정에만 적용됩니다.</p>
</div>
)}
{active === "devices" && (
<div className={s.panelStack}>
{" "}
<section className={s.devicePanel}>
<SectionHeading title="로그인된 기기">
<span className={s.countBadge}>
{model.security.sessions.length}개
</span>
</SectionHeading>
{model.security.sessions.map((session) => (
<SettingRow
key={session.id}
title={session.device}
description={`${session.browser} · ${session.location} · ${session.lastActive}`}
icon={
session.id === "iphone" ? (
<Smartphone size={23} />
) : (
<Laptop size={25} />
)
}
>
<span>
{session.current ? (
<SavedPill>현재 기기</SavedPill>
) : (
<button
type="button"
className={s.textButton}
aria-label={`${session.device} 세션 종료`}
onClick={() =>
security({
sessions: model.security.sessions.filter(
(x) => x.id !== session.id,
),
})
}
>
세션 종료
</button>
)}
</span>
</SettingRow>
))}
</section>
<p className={s.footnote}>샘플 로그인 기록입니다.</p>
</div>
)}
{active === "data" && (
<div className={s.panel}>
<SectionHeading title="내 설정 내보내기" />
<div className={s.exportCard}>
<span className={s.rowIcon}>
<Database size={25} />
</span>
<div>
<h3>내 워크스페이스 설정</h3>
</div>
<button
type="button"
className={s.secondaryButton}
onClick={() => downloadSettings("forma-settings.json", model)}
>
<Download size={15} />
JSON 다운로드
</button>
</div>
<section className={s.formSection}>
<SectionHeading title="처음부터 다시 시작" />
<button
type="button"
className={s.dangerButton}
onClick={() => showModal("reset")}
>
데모 설정 초기화
</button>
</section>
</div>
)}
{active === "help" && (
<div className={s.panel}>
{[
[
"변경한 설정은 어디에 저장되나요?",
"설정은 현재 브라우저의 로컬 저장소에만 남습니다. 같은 브라우저에서 다시 방문하면 불러오며, 다른 기기와는 동기화되지 않습니다.",
],
[
"프로필 변경은 언제 반영되나요?",
"프로필은 ‘변경사항 저장’을 눌러야 반영됩니다. 환경설정과 알림은 변경하는 즉시 저장됩니다.",
],
[
"실제로 알림이 발송되거나 인증이 바뀌나요?",
"아니요. Forma는 동작을 체험하는 예제입니다. 이메일 발송, 실제 패스키 등록, 계정 인증 변경은 수행하지 않습니다.",
],
[
"설정을 초기화할 수 있나요?",
"데이터 관리에서 JSON으로 설정을 내려받거나 데모 설정을 초기화할 수 있습니다. 다른 앱의 데이터는 삭제하지 않습니다.",
],
].map(([q, a]) => (
<details className={s.faq} key={q}>
<summary>
{q}
<ChevronDown size={16} />
</summary>
<p>{a}</p>
</details>
))}
<Link href="/explore" className={s.exploreLink}>
더 많은 예제 둘러보기 <ChevronRight size={15} />
</Link>
</div>
)}
{active !== "profile" && dirty && (
<div className={s.unsaved}>
<span>프로필에 저장하지 않은 변경사항이 있어요.</span>
<button
type="button"
className={s.textButton}
onClick={() => setActive("profile")}
>
돌아가기
</button>
<button
type="button"
className={s.primaryButton}
onClick={saveProfile}
>
변경사항 저장
</button>
</div>
)}
</div>
</ShellAppShell>
<div role="status" className={notice ? s.toast : s.srOnly}>
{notice}
{notice && (
<button
type="button"
aria-label="알림 닫기"
onClick={() => setNotice("")}
>
<X size={14} />
</button>
)}
</div>
<DialogRoot
open={modal !== null}
onOpenChange={(open) => {
if (!open) setModal(null);
}}
>
<DialogPanel size="sm" className={s.dialog}>
<DialogDismiss className={s.dialogClose} />
<DialogHeading>
{modal === "reset"
? "데모 설정을 초기화할까요?"
: modal === "factor"
? "2단계 인증 설정"
: "샘플 패스키 추가"}
</DialogHeading>
<DialogDescription>
{modal === "reset"
? "저장한 프로필과 설정이 초기 상태로 돌아갑니다. 확인하려면 ‘초기화’를 입력하세요."
: modal === "factor"
? "샘플 계정의 인증 상태를 변경합니다. 실제 로그인 방식은 바뀌지 않습니다."
: "패스키 관리 흐름을 체험합니다. 실제 생체 인증 정보는 등록하지 않습니다."}
</DialogDescription>
<div className={s.modalBody}>
{modal !== "factor" && (
<Field
id="confirm-entry"
label={modal === "reset" ? "확인 문구" : "기기 이름"}
>
<input
id="confirm-entry"
value={entry}
maxLength={60}
onChange={(e) => setEntry(e.target.value)}
placeholder={
modal === "reset" ? "초기화" : "예: 개인 MacBook"
}
/>
</Field>
)}
<div className={s.modalActions}>
<button
type="button"
className={s.secondaryButton}
onClick={() => setModal(null)}
>
취소
</button>
<button
type="button"
className={modal === "reset" ? s.dangerButton : s.primaryButton}
disabled={
modal === "reset"
? entry !== "초기화"
: modal === "passkey"
? !entry.trim()
: false
}
onClick={() => {
if (modal === "reset") {
const next = structuredClone(DEFAULT_SETTINGS);
persist(next, "데모 설정을 초기화했어요.");
setDraft(next.profile);
} else if (modal === "factor")
security({ twoFactor: !model.security.twoFactor });
else
security({
passkeys: [
...model.security.passkeys,
{
id: crypto.randomUUID(),
name: entry.trim(),
created: new Intl.DateTimeFormat("ko-KR").format(
new Date(),
),
},
],
});
setModal(null);
}}
>
{modal === "reset"
? "초기화"
: modal === "factor"
? model.security.twoFactor
? "사용 안 함"
: "사용하기"
: "추가하기"}
</button>
</div>
</div>
</DialogPanel>
</DialogRoot>
</div>
);
}
export default function SettingsPage() {
return (
<ShellProvider manifest={manifest} applyDocumentPolicy={false}>
<SettingsApp />
</ShellProvider>
);
}