You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

151 lines
5.3 KiB
TypeScript

// frontend/components/settings/SettingsClient.tsx — 설정 오케스트레이터 (좌측 탭 + 메인 + 토스트)
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Icon } from "@/components/Icon";
import type { IconName } from "@/components/icons/paths";
import { AccountTab } from "./AccountTab";
import { MailTab } from "./MailTab";
import { ConnectionsTab } from "./ConnectionsTab";
import { LlmTab } from "./LlmTab";
import { AppearanceTab } from "./AppearanceTab";
import { DataTab } from "./DataTab";
export type TabId = "account" | "mail" | "connect" | "llm" | "appearance" | "data";
type Tab = { id: TabId; label: string; desc: string; icon: IconName };
const TABS: Tab[] = [
{ id: "account", label: "계정", desc: "프로필 · 로그인", icon: "user" },
{ id: "mail", label: "메일", desc: "메일 계정 연결", icon: "mail" },
{ id: "connect", label: "연결", desc: "일정 · 금융 · 건강 · 지식", icon: "link" },
{ id: "llm", label: "AI · LLM", desc: "모델 연결 설정", icon: "brain" },
{ id: "appearance", label: "외관", desc: "테마", icon: "sun" },
{ id: "data", label: "데이터 · 보안", desc: "내보내기 · 토큰 · 시스템", icon: "shield" },
];
const TAB_IDS = TABS.map((t) => t.id);
function isTab(v: string): v is TabId {
return (TAB_IDS as string[]).includes(v);
}
export type ToastFn = (msg: string, tone?: string) => void;
export function SettingsClient() {
const [tab, setTab] = useState<TabId>("account");
const [hydrated, setHydrated] = useState(false);
const [toasts, setToasts] = useState<{ id: number; msg: string; tone: string }[]>([]);
const shellRef = useRef<HTMLDivElement>(null);
const tid = useRef(0);
// localStorage + ?tab= 쿼리 복원
useEffect(() => {
if (typeof window === "undefined") return;
try {
const q = new URLSearchParams(window.location.search).get("tab");
if (q && isTab(q)) {
setTab(q);
} else {
const v = localStorage.getItem("ariS.tab");
if (v && isTab(v)) setTab(v);
}
} catch {
/* ignore */
}
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated || typeof window === "undefined") return;
try {
localStorage.setItem("ariS.tab", tab);
} catch {
/* ignore */
}
}, [tab, hydrated]);
useEffect(() => {
const el = shellRef.current;
const id = requestAnimationFrame(() => el && el.classList.add("entered"));
return () => cancelAnimationFrame(id);
}, [tab]);
const toast = useCallback<ToastFn>((msg, tone = "blue") => {
const id = ++tid.current;
setToasts((t) => [...t, { id, msg, tone }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
}, []);
// OAuth 콜백 복귀(?connect=ok|error) → 토스트 후 쿼리 정리(tab 은 보존)
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const connect = params.get("connect");
if (!connect) return;
const labels: Record<string, string> = {
gmail: "Gmail",
outlook: "Outlook",
google_calendar: "Google 캘린더",
outlook_calendar: "Outlook 캘린더",
};
const name = labels[params.get("provider") ?? ""] ?? "계정";
if (connect === "ok") toast(`${name}을(를) 연결했어요`, "green");
else toast("연결에 실패했어요. 다시 시도해 주세요", "coral");
for (const k of ["connect", "domain", "provider"]) params.delete(k);
const qs = params.toString();
window.history.replaceState(null, "", window.location.pathname + (qs ? `?${qs}` : ""));
}, [toast]);
return (
<div className="settingspage" ref={shellRef}>
<div className="set-head">
<div className="sh-ic" aria-hidden>
<Icon name="gear" />
</div>
<div className="sh-txt">
<h1></h1>
<p>, , AI , .</p>
</div>
</div>
<div className="set-work">
<nav className="set-rail" aria-label="설정 메뉴">
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={"set-tab" + (tab === t.id ? " active" : "")}
aria-current={tab === t.id ? "page" : undefined}
onClick={() => setTab(t.id)}
>
<span className="st-ic" aria-hidden>
<Icon name={t.icon} />
</span>
<span className="st-txt">
<b>{t.label}</b>
<small>{t.desc}</small>
</span>
</button>
))}
</nav>
<main className="set-main" aria-live="polite">
{tab === "account" && <AccountTab toast={toast} />}
{tab === "mail" && <MailTab toast={toast} />}
{tab === "connect" && <ConnectionsTab toast={toast} />}
{tab === "llm" && <LlmTab toast={toast} />}
{tab === "appearance" && <AppearanceTab toast={toast} />}
{tab === "data" && <DataTab toast={toast} />}
</main>
</div>
<div className="toast-wrap">
{toasts.map((t) => (
<div className="au-toast" key={t.id}>
<span className="t-dot" style={{ background: `var(--${t.tone})` }} />
{t.msg}
</div>
))}
</div>
</div>
);
}