diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 67a4cfa..e528b2b 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -29,5 +29,8 @@ }, "peerDependencies": { "react": ">=18" + }, + "devDependencies": { + "@types/react": "^19.0.0" } } diff --git a/packages/api-client/src/api.ts b/packages/api-client/src/api.ts new file mode 100644 index 0000000..5f70024 --- /dev/null +++ b/packages/api-client/src/api.ts @@ -0,0 +1,79 @@ +// 클라이언트 fetch 래퍼 — 봉투 {data}/{error} 를 해제하고 오류는 ApiClientError 로 던진다. +// ⛔ 서버코드(respond/serialize)·prisma·next 유입 금지. web 표준 fetch 만 사용. + +export class ApiClientError extends Error { + code: string; + status: number; + detail?: unknown; + constructor(code: string, message: string, status: number, detail?: unknown) { + super(message); + this.code = code; + this.status = status; + this.detail = detail; + } +} + +async function unwrap(res: Response): Promise { + let body: unknown = null; + try { + body = await res.json(); + } catch { + /* 비 JSON 응답 */ + } + if (!res.ok) { + const e = (body as { error?: { code?: string; message?: string; detail?: unknown } })?.error; + throw new ApiClientError( + e?.code ?? "HTTP_ERROR", + e?.message ?? `요청이 실패했습니다 (${res.status}).`, + res.status, + e?.detail, + ); + } + return (body as { data: T }).data; +} + +export function apiGet(path: string): Promise { + return fetch(path, { headers: { accept: "application/json" }, cache: "no-store" }).then((r) => + unwrap(r), + ); +} + +export function apiSend( + path: string, + method: "POST" | "PUT" | "PATCH" | "DELETE", + body?: unknown, +): Promise { + return fetch(path, { + method, + headers: { "content-type": "application/json", accept: "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + cache: "no-store", + }).then((r) => unwrap(r)); +} + +// kubeconfig 등 파일 다운로드 (봉투 아님) — Blob 을 받아 브라우저 저장. +export async function apiDownload(path: string, fallbackName: string, body?: unknown): Promise { + const res = await fetch(path, { + method: "POST", + cache: "no-store", + ...(body === undefined + ? {} + : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }), + }); + if (!res.ok) { + await unwrap(res); // 오류 봉투를 ApiClientError 로 던짐 + return; + } + const blob = await res.blob(); + const cd = res.headers.get("content-disposition") ?? ""; + const m = /filename="?([^"]+)"?/.exec(cd); + const name = m?.[1] ?? fallbackName; // noUncheckedIndexedAccess: m[1] 은 string|undefined + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/packages/api-client/src/hooks.ts b/packages/api-client/src/hooks.ts new file mode 100644 index 0000000..2db8013 --- /dev/null +++ b/packages/api-client/src/hooks.ts @@ -0,0 +1,113 @@ +"use client"; + +// 폴링 + SWR 캐시 (클라 전용) — usePoll(path, intervalMs). +// path-고정 편의 훅(useMe/useClusters/…)은 콘솔앱 전용이라 패키지에서 제외; 소비자가 +// usePoll(path, ms) 로 직접 감싼다. T 는 ds-sdk DTO(import type)로 소비 의도. +// ⛔ 서버코드(respond/serialize)·prisma·next 유입 금지. ds-sdk 는 type-only. + +import { useCallback, useEffect, useRef, useState } from "react"; +import { apiGet, ApiClientError } from "./api"; + +export interface PollState { + data: T | undefined; + error: ApiClientError | null; + loading: boolean; // 최초 로딩 (아직 data 없음) + refresh: () => void; +} + +function toErr(e: unknown): ApiClientError { + return e instanceof ApiClientError ? e : new ApiClientError("UNKNOWN", String(e), 0); +} + +// path 별 마지막 성공 응답 캐시 — 탭 전환·페이지 재방문(remount) 시 이전 데이터를 즉시 렌더해 +// 스켈레톤 깜빡임을 없애고, 백그라운드로 갱신한다(stale-while-revalidate). 캐시 키가 path(예: +// /clusters//usage) 이므로 클러스터/리소스별로 분리 보관되어 서로 섞이지 않는다. +// 클라 전용: SSR 중엔 effect 가 실행되지 않아 set 되지 않으므로 요청 간 오염이 없다. +const POLL_CACHE_MAX = 200; // 장기 세션에서 무한 증가 방지(간이 LRU). +const pollCache = new Map(); + +function cacheSet(path: string, d: unknown): void { + pollCache.delete(path); // 재삽입으로 최근 사용 순서를 맨 뒤로(간이 LRU) + pollCache.set(path, d); + if (pollCache.size > POLL_CACHE_MAX) { + const oldest = pollCache.keys().next().value; // 가장 오래된 키 제거 + if (oldest !== undefined) pollCache.delete(oldest); + } +} + +export function usePoll(path: string | null, intervalMs = 0): PollState { + const [data, setData] = useState( + () => (path ? (pollCache.get(path) as T | undefined) : undefined), + ); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(() => (path ? pollCache.get(path) === undefined : false)); + const genRef = useRef(0); // 요청 세대 — 늦게 도착한 옛 응답이 최신 응답을 덮어쓰지 않게(순서 역전 방지) + const aliveRef = useRef(true); + + useEffect(() => { + aliveRef.current = true; + return () => { + aliveRef.current = false; + }; + }, []); + + // 응답 반영/오류 처리 — 세대·마운트 가드로 stale·언마운트 후 커밋을 차단한다. + // 401/403 은 세션 무효로 보고 표시 데이터·캐시를 비운다(만료된 권한 데이터를 계속 노출하지 않도록). + const settle = useCallback( + (p: string, my: number) => ({ + ok: (d: T) => { + if (!aliveRef.current || my !== genRef.current) return; + cacheSet(p, d); + setData(d); + setError(null); + setLoading(false); + }, + fail: (e: unknown) => { + if (!aliveRef.current || my !== genRef.current) return; + const err = toErr(e); + setError(err); + if (err.status === 401 || err.status === 403) { + pollCache.delete(p); + setData(undefined); + } + setLoading(false); + }, + }), + [], + ); + + // 수동 새로고침 (오류 재시도 버튼·변이 후 갱신 등). 세대 가드를 공유해 stale 커밋 차단. + const refresh = useCallback(() => { + if (!path) return; + const my = ++genRef.current; + const s = settle(path, my); + apiGet(path).then(s.ok).catch(s.fail); + }, [path, settle]); + + useEffect(() => { + if (!path) { + // 비활성화: 폴링만 멈추고 마지막 데이터는 유지(같은 리소스 재활성 시 즉시 렌더). + setLoading(false); + return; + } + const cached = pollCache.get(path) as T | undefined; + // 캐시가 있으면(같은 리소스 재방문) 즉시 stale 렌더 + 백그라운드 갱신, + // 없으면(첫 방문·다른 리소스) 로딩 스켈레톤부터 — 리소스 간 stale 오염 방지. + // path 가 바뀌었으니 이전 리소스의 error 는 초기화(다음 리소스 화면을 가리지 않게). + setData(cached); + setLoading(cached === undefined); + setError(null); + const run = () => { + const my = ++genRef.current; + const s = settle(path, my); + return apiGet(path).then(s.ok).catch(s.fail); + }; + run(); + const t = intervalMs > 0 ? setInterval(run, intervalMs) : null; + return () => { + if (t) clearInterval(t); + }; + }, [path, intervalMs, settle]); + + return { data, error, loading, refresh }; +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index f8d23f3..9240138 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,4 +1,13 @@ // @yakcloud/api-client // HTTP·폴링(클라 전용): apiGet/apiSend/apiDownload·usePoll. ⛔ 서버코드(respond) 유입 금지. -// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조). -export const __package = "@yakcloud/api-client"; +// [DAG] ⛔ @yakcloud/ui 참조 금지. ds-sdk 는 type-only. ⛔ prisma/next/next-auth 금지. + +// ── fetch 래퍼 ── +export { apiGet, apiSend, apiDownload, ApiClientError } from "./api"; + +// ── 폴링 훅 ('use client') ── +export { usePoll } from "./hooks"; +export type { PollState } from "./hooks"; + +// ── ApiCode 계약 (서버↔클라 공유, ds-sdk SSOT) ── +export type { ApiCode } from "@yakcloud/ds-sdk"; diff --git a/packages/ui/package.json b/packages/ui/package.json index dab212f..21bb798 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -10,10 +10,14 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - } + }, + "./tokens.css": "./src/tokens.css", + "./components.css": "./src/components.css", + "./sheet.css": "./src/sheet.css" }, "files": [ - "dist" + "dist", + "src/*.css" ], "publishConfig": { "registry": "https://gitea.yakenator.io/api/packages/yakcloud/npm/" @@ -30,5 +34,9 @@ "peerDependencies": { "react": ">=18", "react-dom": ">=18" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0" } } diff --git a/packages/ui/src/CardSheet.tsx b/packages/ui/src/CardSheet.tsx new file mode 100644 index 0000000..181c8a5 --- /dev/null +++ b/packages/ui/src/CardSheet.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import { DEFAULT_SHEET_WIDTH } from "./constants"; + +// ── 재사용 시트 ────────────────────────────────────────────── +// 패널 헤더↔본문 경계(seam)에서 아래로 슬라이드(clip-path 리빌), 본문에 반투명 딤. 닫으면 위로 말림. +// 딤 클릭 / ✕(우하단) / Esc 로 닫힘. 부모는 반드시 position:relative 인 .sheet-host 안에서 렌더. +// 콘텐츠는 children 으로 주입(이 컴포넌트는 데이터 fetch 를 하지 않음 — DAG: api-client·server 참조 금지). + +// 시트가 열리면 하단 콘텐츠 스크롤을 잠근다. 스크롤 컨테이너는 호출자만 알므로 파라미터로 주입. +// container 미지정 시 no-op(ui 패키지는 소비자 DOM 구조에 불투명). 중첩 대비 ref-count. +let sheetScrollLocks = 0; +export function lockPageScroll(container?: HTMLElement | null): () => void { + const el = container ?? null; + if (!el) return () => {}; + if (sheetScrollLocks === 0) { + el.dataset.sheetPrevOverflowY = el.style.overflowY; + el.style.overflowY = "hidden"; + } + sheetScrollLocks += 1; + return () => { + sheetScrollLocks = Math.max(0, sheetScrollLocks - 1); + if (sheetScrollLocks === 0) { + el.style.overflowY = el.dataset.sheetPrevOverflowY ?? ""; + delete el.dataset.sheetPrevOverflowY; + } + }; +} + +export function CardSheet({ + open, + title, + subtitle, + onClose, + children, + width, + footer, + scrollContainer, +}: { + open: boolean; + title: ReactNode; + subtitle?: ReactNode; // 제목 아래 회색 설명(선택) + onClose: () => void; + children: ReactNode; + width?: number | null; // 시트 너비(px). 없으면 기본(DEFAULT_SHEET_WIDTH). + footer?: ReactNode; // 지정 시 하단 푸터 대체. 없으면 기본 ✕ 닫기 아이콘. + scrollContainer?: HTMLElement | null; // 열림 동안 스크롤 잠글 컨테이너(선택). 없으면 잠금 no-op. +}) { + const [render, setRender] = useState(open); // 퇴장 애니메이션 동안 유지 + const [closing, setClosing] = useState(false); + useEffect(() => { + if (open) { + setRender(true); + setClosing(false); + } else if (render) { + setClosing(true); // 위로 말려 닫힘 + const t = window.setTimeout(() => { + setRender(false); + setClosing(false); + }, 280); + return () => clearTimeout(t); + } + return undefined; + }, [open, render]); + useEffect(() => { + if (!open) return undefined; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + // 열리면 하단 스크롤 잠금(닫히면 해제). + useEffect(() => { + if (!open) return undefined; + return lockPageScroll(scrollContainer); + }, [open, scrollContainer]); + if (!render) return null; + const cls = closing ? " closing" : ""; + return ( + <> +
+ {/* clip 래퍼: 상단(seam) 위로 밀려난 부분을 잘라 '헤더 밑에서 미끄러져 나오는' 마스킹 효과 */} +
+
+
+ {title} + {subtitle ? {subtitle} : null} +
+
{children}
+
+ {footer ?? ( + + )} +
+
+
+ + ); +} + +// 확인(삭제·재배포 등) 시트용 푸터 — [아이콘 예] [아니오] …간격… [닫기]. +export function SheetConfirmFooter({ + onYes, + onClose, + yesLabel = "예", + danger = true, + primary = false, + icon, +}: { + onYes: () => void; + onClose: () => void; + yesLabel?: string; + danger?: boolean; + primary?: boolean; // 긍정 확정(파랑 primary) — danger 보다 우선 + icon?: ReactNode; +}) { + return ( +
+ + + + +
+ ); +} + +// 패널 헤더에 두는 도움말(?) 트리거 — SVG 아이콘. +export function SheetHelpButton({ + onOpen, + label = "도움말", + plain = false, +}: { + onOpen: () => void; + label?: string; + plain?: boolean; +}) { + return ( + + ); +} diff --git a/packages/ui/src/PanelSheets.tsx b/packages/ui/src/PanelSheets.tsx new file mode 100644 index 0000000..5cb6d14 --- /dev/null +++ b/packages/ui/src/PanelSheets.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { ReactNode } from "react"; +import { CardSheet, SheetConfirmFooter } from "./CardSheet"; +import type { PanelSheetsCtl } from "./usePanelSheets"; + +// 헤더에 ?(도움말)·삭제·재배포 등 아이콘을 두고, 클릭 시 헤더↔본문 사이로 시트가 슬라이드. +// 화면당 하나만 열림 + 전환 애니메이션 + 콘텐츠 자동확장/전파 없음. API·help-sheets 의존 없음(콘텐츠는 defs 로 주입). +export type PanelSheetDef = { + key: string; + title: ReactNode; + subtitle?: ReactNode; // 제목 아래 설명(선택) + width?: number | null; + body?: ReactNode; // 설명/도움말/폼 콘텐츠 + warn?: ReactNode; // 확인 시트 경고문(confirm 과 함께) + confirm?: { onYes: () => void; yesLabel?: string; danger?: boolean; icon?: ReactNode }; + footer?: ReactNode; // 커스텀 푸터(예: 폼 제출 버튼). confirm·기본 ✕ 아이콘 대신 사용. +}; + +// 패널 본문을 감싸고, 정의된 시트들을 헤더↔본문 사이 슬라이드 시트로 렌더. +// hostClassName 으로 패널별 딤 inset 클래스(panel-sheet-host/node-sheet-host 등, 콘솔 CSS)를 덧붙일 수 있다. +export function PanelSheets({ + id, + ctl, + defs, + children, + hostClassName, + scrollContainer, +}: { + id: string; + ctl: PanelSheetsCtl; + defs: PanelSheetDef[]; + children: ReactNode; + hostClassName?: string; // 예: "panel-sheet-host" (딤 음수 inset — 콘솔 스타일) + scrollContainer?: HTMLElement | null; +}) { + return ( +
+ {children} + {defs.map((d) => ( + { + ctl.close(); + d.confirm!.onYes(); + }} + onClose={ctl.close} + /> + ) : ( + d.footer + ) + } + > + {d.confirm ?
{d.warn}
: d.body} +
+ ))} +
+ ); +} diff --git a/packages/ui/src/components.css b/packages/ui/src/components.css new file mode 100644 index 0000000..0fc4049 --- /dev/null +++ b/packages/ui/src/components.css @@ -0,0 +1,85 @@ +/* @yakcloud/ui — 베이스 컴포넌트 클래스 + yakconsole globals.css 에서 선택 추출(버튼·카드·배지·모달·탭·컨트롤·프리미티브·도움말 팝오버). + ⚠️ 레이아웃 클래스(.app/.sidebar/.detail-*/.admin-*/.main 등)는 콘솔 globals.css 에 잔류. */ + +/* ── 버튼 ── */ +.btn{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:8px;padding:9px 16px;font-size:13px;font-weight:600;cursor:pointer;background:#f0efec;color:var(--ink-1);font-family:inherit;text-decoration:none} +.btn.primary{background:var(--accent);color:#fff} +.btn.danger{background:#fbeaea;color:var(--crit)} +.btn.ghost{background:transparent;border:1px solid var(--border)} +.btn:disabled{opacity:.45;cursor:not-allowed} +.btn.sm{padding:5px 10px;font-size:12px} +/* 시트를 연 트리거 버튼 활성 표시 — 다시 누르면 토글로 닫힘. */ +.btn.active{background:var(--accent);color:#fff} +.btn.primary.active{filter:brightness(.9)} +/* 아이콘 버튼(헤더 닫기 등) */ +.btn-icon{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:var(--surface-1);color:var(--ink-2);font-size:14px;line-height:1;cursor:pointer;padding:0;flex-shrink:0} +.btn-icon:hover{background:#efeee9;color:var(--ink-1);border-color:var(--ink-3)} +/* 테두리 없는 도움말 아이콘 */ +.help-ic-plain{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border:0;border-radius:7px;background:transparent;color:var(--ink-3);cursor:pointer;padding:0;flex-shrink:0} +.help-ic-plain:hover{background:rgba(0,0,0,.06);color:var(--ink-1)} + +/* ── 카드 ── */ +.card{background:var(--surface-1);border:1px solid var(--border);border-radius:var(--radius);padding:18px} + +/* ── 배지(상태) ── */ +.badge{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:2px 9px;border-radius:99px} +.badge::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor} +.badge.active{background:#e7f5e7;color:var(--good-text)} +.badge.prov{background:#e8f0fb;color:var(--accent-dark)} +.badge.error{background:#fbeaea;color:var(--crit)} +.badge.pending{background:#fdf3dc;color:#8a6100} + +/* ── 탭 ── */ +.tabs{display:flex;justify-content:space-between;align-items:flex-end;border-bottom:1px solid var(--grid);margin:18px 0 0} +.tab{padding:9px 14px;color:var(--ink-2);cursor:pointer;border-bottom:2px solid transparent;font-size:13.5px;background:none;border-top:none;border-left:none;border-right:none;font-family:inherit} +.tab.on{color:var(--accent-dark);border-bottom-color:var(--accent);font-weight:600} + +/* ── 모달 오버레이 ── */ +.modal-overlay{position:fixed;inset:0;background:rgba(11,11,11,.42);display:flex;align-items:center;justify-content:center;padding:20px;z-index:50} +.modal{background:var(--surface-1);border-radius:var(--radius);padding:22px;max-width:480px;width:100%;box-shadow:0 12px 40px rgba(11,11,11,.22)} +.modal h2{font-size:16px;margin-bottom:4px} +.modal-head{flex-shrink:0;display:flex;justify-content:space-between;align-items:center;gap:12px;padding:18px 22px;border-bottom:1px solid var(--border);background:#f2f1ee;border-radius:var(--radius) var(--radius) 0 0} +.modal-head h2{margin:0} +.modal-body{overflow-y:auto;padding:18px 22px;display:flex;flex-direction:column;gap:14px} +.modal-scroll{flex:1;min-height:0;overflow-y:auto;padding:18px 22px;display:flex;flex-direction:column;gap:14px;background:var(--surface-1)} +.modal-foot{flex-shrink:0;display:flex;flex-direction:column;gap:10px;padding:14px 22px;border-top:1px solid var(--border);background:#f2f1ee;border-radius:0 0 var(--radius) var(--radius)} + +/* ── 컨트롤: 기간 필 · 스테퍼 ── */ +.pill-range{display:inline-flex;gap:2px;background:#f0efec;border-radius:8px;padding:2px} +.pill-range b{font-weight:600;font-size:12px;color:var(--ink-2);padding:4px 10px;border-radius:6px;cursor:pointer} +.pill-range b.on{background:var(--surface-1);color:var(--accent-dark);box-shadow:0 1px 2px rgba(0,0,0,.1)} +.stepper{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--border);border-radius:8px;padding:2px} +.stepper button{width:26px;height:26px;border:none;background:transparent;color:var(--ink-2);cursor:pointer;border-radius:6px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;padding:0} +.stepper button:hover{background:#efeee9;color:var(--ink-1)} +.stepper span{min-width:24px;text-align:center;font-weight:600;font-variant-numeric:tabular-nums} + +/* ── 프리미티브: 게이지 · 타임라인 · 코드블록 ── */ +.gauge{height:8px;background:var(--grid);border-radius:99px;overflow:hidden} +.gauge i{display:block;height:100%;border-radius:99px;background:var(--s1)} +.gauge i.hot{background:var(--serious)} +.timeline{display:flex;flex-direction:column;gap:0} +.tl{display:flex;gap:12px} +.tl .dot{width:12px;height:12px;border-radius:50%;background:var(--good);margin-top:3px;flex-shrink:0} +.tl.now .dot{background:var(--accent);box-shadow:0 0 0 4px #e8f0fb} +.tl.todo .dot{background:var(--grid)} +.tl .bar{width:2px;flex:1;background:var(--grid);margin:2px auto 2px 5px} +.tl .body{padding-bottom:16px} +.tl .t{font-weight:600;font-size:13.5px} +.tl .ts{font-size:12px;color:var(--ink-3)} +.code{background:#0b0b0b;color:#e1e0d9;border-radius:8px;padding:12px 14px;font-family:ui-monospace,Menlo,monospace;font-size:12.5px;overflow-x:auto;margin:8px 0;white-space:pre-wrap} +.code .c{color:#898781} +svg text{font-family:inherit} + +/* ── 상태 컴포넌트: 스켈레톤 · 빈/오류 상태 ── */ +.skeleton{background:linear-gradient(90deg,#eeede8 25%,#f6f5f1 37%,#eeede8 63%);background-size:400% 100%;border-radius:6px;animation:skeleton-shimmer 1.4s ease infinite} +@keyframes skeleton-shimmer{0%{background-position:100% 0}100%{background-position:-100% 0}} +.card.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px;padding:36px 22px;color:var(--ink-2)} +.card.empty .art{color:var(--ink-3)} +.card.empty .mt,.btn.mt{margin-top:10px} + +/* ── 도움말 팝오버 ── */ +.help-q{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;border:1px solid var(--border);background:var(--surface-1);color:var(--ink-2);font-size:12px;font-weight:700;line-height:1;cursor:pointer;padding:0} +.help-q:hover{background:#e8f0fb;border-color:var(--accent);color:var(--accent-dark)} +.help-pop{position:absolute;top:28px;left:0;z-index:20;width:min(440px,80vw);background:var(--surface-1);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 10px 32px rgba(11,11,11,.18);padding:14px 16px;font-weight:400;font-size:13px} +.help-pop-title{font-weight:600;font-size:14px;margin-bottom:6px} diff --git a/packages/ui/src/constants.ts b/packages/ui/src/constants.ts new file mode 100644 index 0000000..a76de65 --- /dev/null +++ b/packages/ui/src/constants.ts @@ -0,0 +1,3 @@ +// @yakcloud/ui — 순수 상수. +// CardSheet 기본 너비(px). help-sheets.ts 의 DEFAULT_SHEET_WIDTH 와 동치. +export const DEFAULT_SHEET_WIDTH = 460; diff --git a/packages/ui/src/controls.tsx b/packages/ui/src/controls.tsx new file mode 100644 index 0000000..23c27c8 --- /dev/null +++ b/packages/ui/src/controls.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { Plus } from "./icons"; + +/** 기간 선택 필 (1h/24h/7d/30d 등). .pill-range 대응. */ +export function PillRange({ + options, + defaultValue, +}: { + options: string[]; + defaultValue?: string; +}) { + const [val, setVal] = useState(defaultValue ?? options[0]); + return ( +
+ {options.map((o) => ( + setVal(o)}> + {o} + + ))} +
+ ); +} + +/** 노드 수 조정 스테퍼. .stepper 대응 (min~max 클램프). */ +export function Stepper({ + min, + max, + defaultValue, +}: { + min: number; + max: number; + defaultValue: number; +}) { + const [v, setV] = useState(defaultValue); + return ( +
+ + {v} + +
+ ); +} + +/** 모달 오버레이 — body 로 포털(슬라이드 컨테이너 안에서도 뷰포트 기준 fixed). */ +export function Modal({ + open, + onClose, + children, + style, + dismissable = true, +}: { + open: boolean; + onClose: () => void; + children: ReactNode; + style?: CSSProperties; // 넓은 모달(터미널 등)·스크롤 오버라이드용 + dismissable?: boolean; // false 면 딤(배경) 클릭으로 닫히지 않음 — 명시적 닫기만 +}) { + if (!open || typeof document === "undefined") return null; + return createPortal( +
+
e.stopPropagation()}> + {children} +
+
, + document.body, + ); +} diff --git a/packages/ui/src/decorators.ts b/packages/ui/src/decorators.ts new file mode 100644 index 0000000..f2739d7 --- /dev/null +++ b/packages/ui/src/decorators.ts @@ -0,0 +1,55 @@ +// @yakcloud/ui — 상태 데코레이터(순수 UI 분류). +// ds-sdk enum(리터럴 유니온) → {kind:BadgeKind, label} 매핑. switch-case 만, I/O·로직 없음. +import type { ClusterStatus, DeploymentStatus, ServiceStatusDTO } from "@yakcloud/ds-sdk"; + +// Primitives.Badge 의 kind 분류. +export type BadgeKind = "active" | "prov" | "error" | "pending"; + +export function statusMeta(s: ClusterStatus): { kind: BadgeKind; label: string } { + switch (s) { + case "ACTIVE": + return { kind: "active", label: "Active" }; + case "ERROR": + return { kind: "error", label: "오류" }; + case "UPDATING": + return { kind: "prov", label: "업데이트 중" }; + case "DELETING": + return { kind: "pending", label: "삭제 중" }; + case "DELETED": + return { kind: "pending", label: "삭제됨" }; + default: + return { kind: "prov", label: "Provisioning" }; + } +} + +export function deployStatusMeta(s: DeploymentStatus): { kind: BadgeKind; label: string } { + switch (s) { + case "RUNNING": + return { kind: "active", label: "Running" }; + case "FAILED": + return { kind: "error", label: "Failed" }; + case "STOPPED": + return { kind: "pending", label: "Stopped" }; + case "QUEUED": + return { kind: "prov", label: "Queued" }; + case "BUILDING": + return { kind: "prov", label: "Building" }; + default: + return { kind: "prov", label: "Deploying" }; + } +} + +export function serviceStatusMeta(s: ServiceStatusDTO): { kind: BadgeKind; label: string } { + switch (s) { + case "READY": + return { kind: "active", label: "실행 중" }; + case "ERROR": + return { kind: "error", label: "오류" }; + case "DELETING": + return { kind: "pending", label: "삭제 중" }; + case "DELETED": + return { kind: "pending", label: "삭제됨" }; + default: + return { kind: "prov", label: "준비 중" }; // REQUESTED / PROVISIONING + } +} diff --git a/packages/ui/src/icons.tsx b/packages/ui/src/icons.tsx new file mode 100644 index 0000000..ccbf134 --- /dev/null +++ b/packages/ui/src/icons.tsx @@ -0,0 +1,75 @@ +import type { SVGProps, ReactNode } from "react"; + +// 콘솔 공용 SVG 아이콘 — 이모지 대체(원칙: 모든 아이콘은 SVG). currentColor stroke, size prop. +// .btn(inline-flex gap:6) 안에서 라벨과 자동 정렬. 인라인 텍스트에는 verticalAlign 로 살짝 내림. +// 자기완결(외부 import 없음). +type IcoProps = Omit, "children"> & { size?: number }; + +function make(children: ReactNode, sw = 1.8) { + function Ico({ size = 16, style, ...p }: IcoProps) { + return ( + + {children} + + ); + } + return Ico; +} + +// ── 액션/상태 ────────────────────────────────────────────── +export const Plus = make(); +export const Close = make(, 2.1); +export const Check = make(); +export const Copy = make(<>); +export const Warn = make(<>); +export const Download = make(); +export const Retry = make(<>); +export const Hourglass = make(); +export const Timer = make(<>); +export const Trash = make(<>); + +// ── 개체/도메인 ──────────────────────────────────────────── +export const Terminal = make(); +export const Globe = make(<>); +export const Rocket = make(); +export const K8s = make(<>); +export const BarChart = make(); +export const TrendUp = make(); +export const Paperclip = make(); +export const Camera = make(<>); +export const Bell = make(); +export const Lock = make(<>); +export const Cloud = make(); +export const Mail = make(<>); +export const Flask = make(); +export const Book = make(); +export const Sparkles = make(); +export const LifeBuoy = make(<>); +export const Chat = make(); +export const Folder = make(); +export const Database = make(<>); +export const Dashboard = make(<>); +export const Gear = make(<>, 1.6); +export const Logout = make(); + +// ── 방향/펼침 ────────────────────────────────────────────── +export const ChevronDown = make(, 2); +export const ChevronRight = make(, 2); +export const ChevronLeft = make(, 2); +export const ChevronUp = make(, 2); +export const Menu = make(, 2); + +// 작은 채운 점(파드 등 상태 표시) +export const Dot = make(); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 0709987..44ac8ae 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,4 +1,35 @@ -// @yakcloud/ui -// 디자인시스템·프리미티브: 토큰·인라인SVG·CardSheet·LineChart(토큰색)·getServiceMeta(logoBase). ⛔ api-client 참조 금지. -// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조). -export const __package = "@yakcloud/ui"; +// @yakcloud/ui — 공개 배럴. +// 디자인시스템·프리미티브: 토큰·인라인 SVG·CardSheet/PanelSheets·LineChart(토큰색)·getServiceMeta(logoBase). +// ⛔ api-client 참조 금지. ds-sdk 는 import type 만(런타임 의존 아님). +// CSS 는 서브패스(./tokens.css, ./components.css, ./sheet.css)로 개별 import. + +// 상수 +export { DEFAULT_SHEET_WIDTH } from "./constants"; + +// 아이콘 (자기완결 SVG) +export * from "./icons"; + +// 프리미티브 (프레젠테이션) +export { Badge, Gauge, Timeline, CodeBlock, C, LineChart } from "./primitives"; +export type { TimelineItem, ChartSeries } from "./primitives"; + +// 컨트롤 (client) +export { PillRange, Stepper, Modal } from "./controls"; + +// 상태 컴포넌트 (client) +export { Skeleton, ErrorState, EmptyState } from "./states"; + +// 시트 (client) +export { CardSheet, SheetConfirmFooter, SheetHelpButton, lockPageScroll } from "./CardSheet"; +export { PanelSheets } from "./PanelSheets"; +export type { PanelSheetDef } from "./PanelSheets"; +export { useSheet } from "./useSheet"; +export { usePanelSheets } from "./usePanelSheets"; +export type { PanelSheetsCtl, PanelSheetCurrent } from "./usePanelSheets"; + +// 데코레이터 (ds-sdk enum → BadgeKind) +export { statusMeta, deployStatusMeta, serviceStatusMeta } from "./decorators"; +export type { BadgeKind } from "./decorators"; + +// 서비스 메타 (로고 URL·라벨) +export { getServiceMeta, getLogoFileName } from "./service-meta"; diff --git a/packages/ui/src/primitives.tsx b/packages/ui/src/primitives.tsx new file mode 100644 index 0000000..b18c400 --- /dev/null +++ b/packages/ui/src/primitives.tsx @@ -0,0 +1,123 @@ +// ───────────────────────────────────────────────────────────── +// 프리미티브 (프레젠테이션 전용, 서버 렌더 가능). CSS 클래스와 1:1 대응. +// ───────────────────────────────────────────────────────────── +import type { ReactNode } from "react"; +import type { BadgeKind } from "./decorators"; + +export function Badge({ kind, children }: { kind: BadgeKind; children: ReactNode }) { + return {children}; +} + +export function Gauge({ pct, hot }: { pct: number; hot?: boolean }) { + return ( +
+ +
+ ); +} + +export type TimelineItem = { title: string; ts: string; state?: "done" | "now" | "todo" }; + +export function Timeline({ items }: { items: TimelineItem[] }) { + return ( +
+ {items.map((it, i) => { + const last = i === items.length - 1; + const cls = it.state === "now" ? "tl now" : it.state === "todo" ? "tl todo" : "tl"; + return ( +
+
+
+ {!last &&
} +
+
+
{it.title}
+
{it.ts}
+
+
+ ); + })} +
+ ); +} + +export function CodeBlock({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +/** 코드블록 내 주석 (뮤트 색). */ +export function C({ children }: { children: ReactNode }) { + return {children}; +} + +// ── 라인 차트 (인라인 SVG) ── +// 프레젠테이션 색은 CSS 변수(style prop). SVG stroke 속성에 var() 는 브라우저 호환이 없어 style 로 주입. +// grid/baseline/label/peak/surface 색은 토큰으로 고정. 시리즈 색(s.color)만 호출자 지정. +export type ChartSeries = { + points: string; + color: string; + endLabel?: { x: number; y: number; text: string }; +}; + +export function LineChart({ + viewBox, + ariaLabel, + grid, + yTicks, + series, + peak, + xTicks, + peakColorToken = "#2a78d6", +}: { + viewBox: string; + ariaLabel: string; + grid: { x0: number; x1: number; yTop: number; yMid: number; yBot: number }; + yTicks: { y: number; label: string }[]; + series: ChartSeries[]; + peak?: { cx: number; cy: number; label: string; color?: string }; + xTicks: { x: number; y: number; label: string; anchor?: "start" | "middle" | "end" }[]; + peakColorToken?: string; // 피크 마커 기본색(토큰 override 여지) +}) { + return ( + + + + + {yTicks.map((t, i) => ( + + {t.label} + + ))} + {series.map((s, i) => ( + + ))} + {series.map((s, i) => + s.endLabel ? ( + + {s.endLabel.text} + + ) : null, + )} + {peak && ( + <> + + + {peak.label} + + + )} + {xTicks.map((t, i) => ( + + {t.label} + + ))} + + ); +} diff --git a/packages/ui/src/service-meta.ts b/packages/ui/src/service-meta.ts new file mode 100644 index 0000000..b861ecf --- /dev/null +++ b/packages/ui/src/service-meta.ts @@ -0,0 +1,41 @@ +// @yakcloud/ui — 데이터소스 타입 → 로고 URL·표시명. +// 로고는 URL 조합만(바이너리 미번들). 콘솔은 public/logos/* 를 보유, 다른 배포는 logoBase 로 CDN override. +// Oracle 만 .png, 나머지 .svg (원본 구분 보존). +import type { ServiceTypeDTO } from "@yakcloud/ds-sdk"; + +const LABELS: Record = { + MONGODB: "MongoDB", + REDIS: "Redis", + MINIO: "MinIO", + MYSQL: "MySQL", + POSTGRESQL: "PostgreSQL", + MARIADB: "MariaDB", + RABBITMQ: "RabbitMQ", + SOLR: "Solr", + ORACLE: "Oracle", +}; + +// 로고 파일명(확장자 포함). POSTGRESQL→postgres.svg, ORACLE→oracle.png. +const FILE_NAMES: Record = { + MONGODB: "mongodb.svg", + REDIS: "redis.svg", + MINIO: "minio.svg", + MYSQL: "mysql.svg", + POSTGRESQL: "postgres.svg", + MARIADB: "mariadb.svg", + RABBITMQ: "rabbitmq.svg", + SOLR: "solr.svg", + ORACLE: "oracle.png", +}; + +export function getLogoFileName(type: ServiceTypeDTO): string { + return FILE_NAMES[type]; +} + +export function getServiceMeta( + type: ServiceTypeDTO, + logoBase = "/logos", +): { logo: string; label: string } { + const base = logoBase.replace(/\/+$/, ""); + return { logo: `${base}/${FILE_NAMES[type]}`, label: LABELS[type] }; +} diff --git a/packages/ui/src/sheet.css b/packages/ui/src/sheet.css new file mode 100644 index 0000000..45ced9f --- /dev/null +++ b/packages/ui/src/sheet.css @@ -0,0 +1,42 @@ +/* @yakcloud/ui — 시트 레이어 + yakconsole globals.css:339-436 에서 선택 추출. 카드 헤더↔본문 사이로 슬라이드다운되는 재사용 시트. + 부모=position:relative 인 .sheet-host. 패널별 음수 inset(.panel-sheet-host/.tab-sheet-host 등)은 콘솔에 잔류. */ +.sheet-host{position:relative} +.sheet-dim{position:absolute;inset:0;z-index:20;background:rgba(22,24,30,.34);animation:sheet-dim-in .24s ease forwards} +.sheet-dim.closing{animation:sheet-dim-out .24s ease forwards} +@keyframes sheet-dim-in{from{opacity:0}to{opacity:1}} +@keyframes sheet-dim-out{from{opacity:1}to{opacity:0}} +/* clip 래퍼 — seam(top:0)에서 위로 밀려난 시트를 잘라 '헤더 밑에서 빠져나오는' 마스킹 효과. */ +.sheet-clip{position:absolute;top:0;left:0;right:0;z-index:21;overflow:hidden;padding-bottom:48px;pointer-events:none} +.sheet{position:relative;margin:0 auto;pointer-events:auto;overflow:hidden;width:min(460px,calc(100% - 20px));background:var(--surface-1);border:1px solid var(--border);border-top:0;border-radius:0 0 14px 14px;box-shadow:0 20px 44px rgba(0,0,0,.24);animation:sheet-slide-down .34s cubic-bezier(.2,.85,.25,1) forwards} +.sheet.closing{animation:sheet-slide-up .26s cubic-bezier(.4,0,.9,.45) forwards} +@keyframes sheet-slide-down{from{transform:translateY(-100%)}to{transform:translateY(0)}} +@keyframes sheet-slide-up{from{transform:translateY(0)}to{transform:translateY(-100%)}} +.sheet-head{padding:11px 15px;background:#f6f7f9;border-bottom:1px solid var(--border)} +.sheet-title{font-weight:700;font-size:13px;color:var(--ink-1)} +.sheet-subtitle{display:block;margin-top:2px;color:var(--ink-3);font-size:12px;font-weight:400} +/* 헤더 우측 세그먼트 탭(설정 시트 등) — 제목 좌 + 탭 우측정렬. */ +.sheet-title:has(.sheet-seg-head){display:block;width:100%} +.sheet-seg-head{display:flex;align-items:center;width:100%;gap:12px} +.sheet-seg{margin-left:auto;display:flex;gap:2px;flex-wrap:nowrap} +.sheet-seg button{font-family:inherit;font-size:12px;font-weight:500;color:var(--ink-2);background:none;border:none;padding:4px 9px;border-radius:6px;cursor:pointer;line-height:1.5;white-space:nowrap} +.sheet-seg button:hover:not(.on){color:var(--ink-1);background:rgba(0,0,0,.04)} +.sheet-seg button.on{background:#fff;color:var(--accent-dark);font-weight:700;box-shadow:0 1px 2px rgba(0,0,0,.10)} +.sheet-seg button.danger.on{color:var(--crit)} +/* 본문: 뷰포트(vh) 아닌 '고정 px' 캡 → 브라우저 높이를 줄여도 슬라이더 높이 불변. 콘솔이 --sheet-body-max-height 로 override. */ +.sheet-body{padding:14px 16px;max-height:var(--sheet-body-max-height,680px);overflow-y:auto} +.sheet-foot{display:flex;justify-content:flex-end;align-items:center;padding:7px 10px;border-top:1px solid var(--border);background:#fafbfc} +.sheet-close-ic{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:8px;background:transparent;color:var(--ink-3);cursor:pointer;padding:0} +.sheet-close-ic:hover{background:rgba(0,0,0,.06);color:var(--ink-1)} +/* 확인(삭제 등) 시트 푸터: [예] [아니오] …간격… [닫기] */ +.sheet-actions{display:flex;align-items:center;gap:8px;width:100%} +.sheet-actions-gap{flex:1} +.sheet-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 13px;border-radius:8px;border:1px solid var(--border);background:var(--surface-1);color:var(--ink-1);font-size:12.5px;font-weight:600;cursor:pointer} +.sheet-btn:hover{background:#f0f2f5} +.sheet-btn.primary{border-color:#2f6fe0;background:#2f6fe0;color:#fff} +.sheet-btn.primary:hover{background:#2560c8;border-color:#2560c8} +.sheet-btn.danger{border-color:#e6b0b0;background:#fff5f5;color:#b42318} +.sheet-btn.danger:hover{background:#ffe9e9;border-color:#dd8f8f} +.sheet-btn.ghost{border-color:transparent;background:transparent;color:var(--ink-3)} +.sheet-btn.ghost:hover{background:rgba(0,0,0,.06);color:var(--ink-1)} +.sheet-btn:disabled{opacity:.5;cursor:not-allowed} diff --git a/packages/ui/src/states.tsx b/packages/ui/src/states.tsx new file mode 100644 index 0000000..7263637 --- /dev/null +++ b/packages/ui/src/states.tsx @@ -0,0 +1,67 @@ +"use client"; + +import type { CSSProperties, ReactNode } from "react"; +import { Warn, Cloud } from "./icons"; + +// 로딩 스켈레톤 (4상태 규칙). +export function Skeleton({ + h = 16, + w = "100%", + style, +}: { + h?: number; + w?: number | string; + style?: CSSProperties; +}) { + return
; +} + +// 오류 상태 — 재시도. +export function ErrorState({ + message, + onRetry, +}: { + message?: string; + onRetry?: () => void; +}) { + return ( +
+
+

{message ?? "불러오지 못했습니다."}

+ {onRetry && ( + + )} +
+ ); +} + +// 빈 상태 — 온보딩 유도 일러스트 + CTA. +export function EmptyState({ + art, + title, + desc, + cta, +}: { + art?: ReactNode; + title: string; + desc?: ReactNode; + cta?: ReactNode; +}) { + return ( +
+
{art ?? }
+

+ {title} + {desc && ( + <> +
+ {desc} + + )} +

+ {cta &&
{cta}
} +
+ ); +} diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css new file mode 100644 index 0000000..7146d86 --- /dev/null +++ b/packages/ui/src/tokens.css @@ -0,0 +1,14 @@ +/* @yakcloud/ui — 디자인 토큰 + yakconsole globals.css:6-17 에서 추출. CVD(색각이상) 검증 팔레트 — 순서·값 임의 변경 금지(handoff §2, immutable). */ +:root{ + color-scheme: light; + --surface-1:#fcfcfb; --page:#f9f9f7; + --ink-1:#0b0b0b; --ink-2:#52514e; --ink-3:#898781; + --grid:#e1e0d9; --baseline:#c3c2b7; --border:rgba(11,11,11,0.10); + --s1:#2a78d6; --s2:#eb6834; --s3:#1baf7a; + --good:#0ca30c; --warn:#fab219; --serious:#ec835a; --crit:#d03b3b; + --good-text:#006300; + --accent:#2a78d6; --accent-dark:#1c5cab; + --radius:10px; + font-family:system-ui,-apple-system,"Segoe UI","Apple SD Gothic Neo","Noto Sans KR",sans-serif; +} diff --git a/packages/ui/src/usePanelSheets.ts b/packages/ui/src/usePanelSheets.ts new file mode 100644 index 0000000..e65d711 --- /dev/null +++ b/packages/ui/src/usePanelSheets.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +// 헤더 있는 패널용 다중 시트 오케스트레이션(순수 상태). 화면당 하나만 열림 + 전환 애니메이션. +// 같은 것 재클릭=토글 닫기. 다른 시트가 열려있으면 먼저 닫고(위로) 애니메이션 후 새로 연다(아래로). +// API 호출 없음. +export type PanelSheetCurrent = { id: string; mode: string } | null; + +export function usePanelSheets(): { + current: PanelSheetCurrent; + open: (id: string, mode: string) => void; + close: () => void; +} { + const [current, setCurrent] = useState(null); + const ref = useRef(current); + ref.current = current; + const open = useCallback((id: string, mode: string) => { + const cur = ref.current; + if (cur && cur.id === id && cur.mode === mode) { + setCurrent(null); + return; + } + if (cur) { + setCurrent(null); + window.setTimeout(() => setCurrent({ id, mode }), 280); + return; + } + setCurrent({ id, mode }); + }, []); + const close = useCallback(() => setCurrent(null), []); + return { current, open, close }; +} +export type PanelSheetsCtl = ReturnType; diff --git a/packages/ui/src/useSheet.ts b/packages/ui/src/useSheet.ts new file mode 100644 index 0000000..e74dc94 --- /dev/null +++ b/packages/ui/src/useSheet.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useState } from "react"; + +// 단일 CardSheet 상태 편의 훅. API 호출 없음(부모가 데이터를 fetch 해 children 으로 전달). +export function useSheet(): { + open: boolean; + openSheet: () => void; + closeSheet: () => void; + props: { open: boolean; onClose: () => void }; +} { + const [open, setOpen] = useState(false); + return { + open, + openSheet: () => setOpen(true), + closeSheet: () => setOpen(false), + props: { open, onClose: () => setOpen(false) }, + }; +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index f105fb2..f17e663 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "jsx": "react-jsx" }, "include": [ "src"