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
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>
|
|
);
|
|
}
|