feat(ui,api-client): B4/B5 추출 — @yakcloud/ui + @yakcloud/api-client

@yakcloud/ui (15 모듈, react peer, ds-sdk type-only):
- globals.css 3분할(tokens/components/sheet.css; 680px→--sheet-body-max-height)
- icons·primitives·controls·states, LineChart 6색 CSS 토큰화(+peakColorToken)
- CardSheet/PanelSheets: apiGet 디커플(콘텐츠 prop 주입)·lockPageScroll(container?) 폴백
- decorators(statusMeta 등, ds-sdk enum 소비), getServiceMeta(type, logoBase) 로고 URL-only
- 수정: tsconfig jsx:react-jsx, @types/react(-dom), CSS exports 서브패스

@yakcloud/api-client (3 모듈, react peer, ds-sdk type-only):
- apiGet/apiSend/apiDownload + ApiClientError, usePoll(SWR·LRU·gen guard·401/403)
- /api/v1 고정 훅 제외, 서버코드 유입 0(ApiCode만 ds-sdk에서)

검증: DAG PASS(ui↛api-client, api-client↛서버), 디커플 PASS, tsc --noEmit 통과(양 패키지).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 11:41:00 +09:00
parent 7d1c92a406
commit e34a325b52
21 changed files with 1144 additions and 9 deletions

View File

@ -29,5 +29,8 @@
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18" "react": ">=18"
},
"devDependencies": {
"@types/react": "^19.0.0"
} }
} }

View File

@ -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<T>(res: Response): Promise<T> {
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<T>(path: string): Promise<T> {
return fetch(path, { headers: { accept: "application/json" }, cache: "no-store" }).then((r) =>
unwrap<T>(r),
);
}
export function apiSend<T>(
path: string,
method: "POST" | "PUT" | "PATCH" | "DELETE",
body?: unknown,
): Promise<T> {
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<T>(r));
}
// kubeconfig 등 파일 다운로드 (봉투 아님) — Blob 을 받아 브라우저 저장.
export async function apiDownload(path: string, fallbackName: string, body?: unknown): Promise<void> {
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);
}

View File

@ -0,0 +1,113 @@
"use client";
// 폴링 + SWR 캐시 (클라 전용) — usePoll<T>(path, intervalMs).
// path-고정 편의 훅(useMe/useClusters/…)은 콘솔앱 전용이라 패키지에서 제외; 소비자가
// usePoll<DTO>(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<T> {
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/<id>/usage) 이므로 클러스터/리소스별로 분리 보관되어 서로 섞이지 않는다.
// 클라 전용: SSR 중엔 effect 가 실행되지 않아 set 되지 않으므로 요청 간 오염이 없다.
const POLL_CACHE_MAX = 200; // 장기 세션에서 무한 증가 방지(간이 LRU).
const pollCache = new Map<string, unknown>();
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<T>(path: string | null, intervalMs = 0): PollState<T> {
const [data, setData] = useState<T | undefined>(
() => (path ? (pollCache.get(path) as T | undefined) : undefined),
);
const [error, setError] = useState<ApiClientError | null>(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<T>(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<T>(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 };
}

View File

@ -1,4 +1,13 @@
// @yakcloud/api-client // @yakcloud/api-client
// HTTP·폴링(클라 전용): apiGet/apiSend/apiDownload·usePoll. ⛔ 서버코드(respond) 유입 금지. // HTTP·폴링(클라 전용): apiGet/apiSend/apiDownload·usePoll. ⛔ 서버코드(respond) 유입 금지.
// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조). // [DAG] ⛔ @yakcloud/ui 참조 금지. ds-sdk 는 type-only. ⛔ prisma/next/next-auth 금지.
export const __package = "@yakcloud/api-client";
// ── 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";

View File

@ -10,10 +10,14 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} },
"./tokens.css": "./src/tokens.css",
"./components.css": "./src/components.css",
"./sheet.css": "./src/sheet.css"
}, },
"files": [ "files": [
"dist" "dist",
"src/*.css"
], ],
"publishConfig": { "publishConfig": {
"registry": "https://gitea.yakenator.io/api/packages/yakcloud/npm/" "registry": "https://gitea.yakenator.io/api/packages/yakcloud/npm/"
@ -30,5 +34,9 @@
"peerDependencies": { "peerDependencies": {
"react": ">=18", "react": ">=18",
"react-dom": ">=18" "react-dom": ">=18"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
} }
} }

View File

@ -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 (
<>
<div className={`sheet-dim${cls}`} onClick={onClose} aria-hidden />
{/* clip 래퍼: 상단(seam) 위로 밀려난 부분을 잘라 '헤더 밑에서 미끄러져 나오는' 마스킹 효과 */}
<div className="sheet-clip">
<div
className={`sheet${cls}`}
role="dialog"
aria-modal
style={{ width: `min(${width ?? DEFAULT_SHEET_WIDTH}px, calc(100% - 20px))` }}
>
<div className="sheet-head">
<span className="sheet-title">{title}</span>
{subtitle ? <span className="sheet-subtitle">{subtitle}</span> : null}
</div>
<div className="sheet-body">{children}</div>
<div className="sheet-foot">
{footer ?? (
<button className="sheet-close-ic" onClick={onClose} aria-label="닫기" title="닫기">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</div>
</div>
</>
);
}
// 확인(삭제·재배포 등) 시트용 푸터 — [아이콘 예] [아니오] …간격… [닫기].
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 (
<div className="sheet-actions">
<button type="button" className={`sheet-btn${primary ? " primary" : danger ? " danger" : ""}`} onClick={onYes}>
{icon ?? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6M14 11v6" />
</svg>
)}
{yesLabel}
</button>
<button type="button" className="sheet-btn" onClick={onClose}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<span className="sheet-actions-gap" />
<button type="button" className="sheet-close-ic" onClick={onClose} aria-label="닫기" title="닫기">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
);
}
// 패널 헤더에 두는 도움말(?) 트리거 — SVG 아이콘.
export function SheetHelpButton({
onOpen,
label = "도움말",
plain = false,
}: {
onOpen: () => void;
label?: string;
plain?: boolean;
}) {
return (
<button
type="button"
className={plain ? "help-ic-plain" : "btn-icon"}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
aria-label={label}
title={label}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</button>
);
}

View File

@ -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 (
<div className={`sheet-host${hostClassName ? " " + hostClassName : ""}`}>
{children}
{defs.map((d) => (
<CardSheet
key={d.key}
open={ctl.current?.id === id && ctl.current.mode === d.key}
title={d.title}
subtitle={d.subtitle}
width={d.width}
onClose={ctl.close}
scrollContainer={scrollContainer}
footer={
d.confirm ? (
<SheetConfirmFooter
yesLabel={d.confirm.yesLabel}
danger={d.confirm.danger}
icon={d.confirm.icon}
onYes={() => {
ctl.close();
d.confirm!.onYes();
}}
onClose={ctl.close}
/>
) : (
d.footer
)
}
>
{d.confirm ? <div className="scaledown-help">{d.warn}</div> : d.body}
</CardSheet>
))}
</div>
);
}

View File

@ -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}

View File

@ -0,0 +1,3 @@
// @yakcloud/ui — 순수 상수.
// CardSheet 기본 너비(px). help-sheets.ts 의 DEFAULT_SHEET_WIDTH 와 동치.
export const DEFAULT_SHEET_WIDTH = 460;

View File

@ -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<string | undefined>(defaultValue ?? options[0]);
return (
<div className="pill-range">
{options.map((o) => (
<b key={o} className={o === val ? "on" : undefined} onClick={() => setVal(o)}>
{o}
</b>
))}
</div>
);
}
/** 노드 수 조정 스테퍼. .stepper 대응 (min~max 클램프). */
export function Stepper({
min,
max,
defaultValue,
}: {
min: number;
max: number;
defaultValue: number;
}) {
const [v, setV] = useState(defaultValue);
return (
<div className="stepper">
<button aria-label="감소" onClick={() => setV((x) => Math.max(min, x - 1))}>
</button>
<span>{v}</span>
<button aria-label="증가" onClick={() => setV((x) => Math.min(max, x + 1))}>
<Plus size={14} />
</button>
</div>
);
}
/** 모달 오버레이 — 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(
<div className="modal-overlay" onClick={dismissable ? onClose : undefined} role="dialog" aria-modal>
<div className="modal" style={style} onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body,
);
}

View File

@ -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
}
}

75
packages/ui/src/icons.tsx Normal file
View File

@ -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<SVGProps<SVGSVGElement>, "children"> & { size?: number };
function make(children: ReactNode, sw = 1.8) {
function Ico({ size = 16, style, ...p }: IcoProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={sw}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
style={{ flex: "0 0 auto", verticalAlign: "-0.15em", ...style }}
{...p}
>
{children}
</svg>
);
}
return Ico;
}
// ── 액션/상태 ──────────────────────────────────────────────
export const Plus = make(<path d="M12 5v14M5 12h14" />);
export const Close = make(<path d="M18 6 6 18M6 6l12 12" />, 2.1);
export const Check = make(<path d="M20 6 9 17l-5-5" />);
export const Copy = make(<><rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>);
export const Warn = make(<><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><path d="M12 9v4M12 17h.01" /></>);
export const Download = make(<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />);
export const Retry = make(<><path d="M23 4v6h-6" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" /></>);
export const Hourglass = make(<path d="M5 22h14M5 2h14M17 22v-4.17a2 2 0 0 0-.59-1.42L12 12l-4.41 4.41A2 2 0 0 0 7 17.83V22M7 2v4.17a2 2 0 0 0 .59 1.42L12 12l4.41-4.41A2 2 0 0 0 17 6.17V2" />);
export const Timer = make(<><circle cx="12" cy="13" r="8" /><path d="M12 13V9M9 1h6M5 4 3 6" /></>);
export const Trash = make(<><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></>);
// ── 개체/도메인 ────────────────────────────────────────────
export const Terminal = make(<path d="m4 17 6-6-6-6M12 19h8" />);
export const Globe = make(<><circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></>);
export const Rocket = make(<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09zM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2zM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />);
export const K8s = make(<><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="2.5" /><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8" /></>);
export const BarChart = make(<path d="M3 3v18h18M7 16v-4M12 16V8M17 16v-7" />);
export const TrendUp = make(<path d="M22 7 13.5 15.5l-5-5L2 17M16 7h6v6" />);
export const Paperclip = make(<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />);
export const Camera = make(<><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" /><circle cx="12" cy="13" r="3" /></>);
export const Bell = make(<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0" />);
export const Lock = make(<><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></>);
export const Cloud = make(<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" />);
export const Mail = make(<><rect x="2" y="4" width="20" height="16" rx="2" /><path d="m22 7-10 5L2 7" /></>);
export const Flask = make(<path d="M9 3h6M10 3v6l-5.6 9.6A1 1 0 0 0 5.3 20h13.4a1 1 0 0 0 .87-1.4L14 9V3M7.5 14h9" />);
export const Book = make(<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />);
export const Sparkles = make(<path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3z" />);
export const LifeBuoy = make(<><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="4" /><path d="m4.93 4.93 4.24 4.24M14.83 14.83l4.24 4.24M14.83 9.17l4.24-4.24M9.17 14.83l-4.24 4.24" /></>);
export const Chat = make(<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />);
export const Folder = make(<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />);
export const Database = make(<><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14a9 3 0 0 0 18 0V5M3 12a9 3 0 0 0 18 0" /></>);
export const Dashboard = make(<><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /></>);
export const Gear = make(<><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></>, 1.6);
export const Logout = make(<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />);
// ── 방향/펼침 ──────────────────────────────────────────────
export const ChevronDown = make(<path d="m6 9 6 6 6-6" />, 2);
export const ChevronRight = make(<path d="m9 18 6-6-6-6" />, 2);
export const ChevronLeft = make(<path d="m15 18-6-6 6-6" />, 2);
export const ChevronUp = make(<path d="m18 15-6-6-6 6" />, 2);
export const Menu = make(<path d="M4 6h16M4 12h16M4 18h16" />, 2);
// 작은 채운 점(파드 등 상태 표시)
export const Dot = make(<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none" />);

View File

@ -1,4 +1,35 @@
// @yakcloud/ui // @yakcloud/ui — 공개 배럴.
// 디자인시스템·프리미티브: 토큰·인라인SVG·CardSheet·LineChart(토큰색)·getServiceMeta(logoBase). ⛔ api-client 참조 금지. // 디자인시스템·프리미티브: 토큰·인라인 SVG·CardSheet/PanelSheets·LineChart(토큰색)·getServiceMeta(logoBase).
// B1 스켈레톤 — 실제 추출은 후속(scaffold-input.md 판정표 참조). // ⛔ api-client 참조 금지. ds-sdk 는 import type 만(런타임 의존 아님).
export const __package = "@yakcloud/ui"; // 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";

View File

@ -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 <span className={`badge ${kind}`}>{children}</span>;
}
export function Gauge({ pct, hot }: { pct: number; hot?: boolean }) {
return (
<div className="gauge">
<i className={hot ? "hot" : undefined} style={{ width: `${pct}%` }} />
</div>
);
}
export type TimelineItem = { title: string; ts: string; state?: "done" | "now" | "todo" };
export function Timeline({ items }: { items: TimelineItem[] }) {
return (
<div className="timeline">
{items.map((it, i) => {
const last = i === items.length - 1;
const cls = it.state === "now" ? "tl now" : it.state === "todo" ? "tl todo" : "tl";
return (
<div className={cls} key={i}>
<div>
<div className="dot" />
{!last && <div className="bar" />}
</div>
<div className="body">
<div className="t">{it.title}</div>
<div className="ts">{it.ts}</div>
</div>
</div>
);
})}
</div>
);
}
export function CodeBlock({ children, className }: { children: ReactNode; className?: string }) {
return <div className={`code${className ? " " + className : ""}`}>{children}</div>;
}
/** 코드블록 내 주석 (뮤트 색). */
export function C({ children }: { children: ReactNode }) {
return <span className="c">{children}</span>;
}
// ── 라인 차트 (인라인 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 (
<svg viewBox={viewBox} width="100%" role="img" aria-label={ariaLabel}>
<line x1={grid.x0} y1={grid.yTop} x2={grid.x1} y2={grid.yTop} style={{ stroke: "var(--grid)" }} />
<line x1={grid.x0} y1={grid.yMid} x2={grid.x1} y2={grid.yMid} style={{ stroke: "var(--grid)" }} />
<line x1={grid.x0} y1={grid.yBot} x2={grid.x1} y2={grid.yBot} style={{ stroke: "var(--baseline)" }} />
{yTicks.map((t, i) => (
<text key={i} x={grid.x0 - 6} y={t.y} fontSize="10" style={{ fill: "var(--ink-3)" }} textAnchor="end">
{t.label}
</text>
))}
{series.map((s, i) => (
<polyline
key={i}
fill="none"
stroke={s.color}
strokeWidth="2"
strokeLinejoin="round"
points={s.points}
/>
))}
{series.map((s, i) =>
s.endLabel ? (
<text key={`e${i}`} x={s.endLabel.x} y={s.endLabel.y} fontSize="11" style={{ fill: "var(--ink-2)" }}>
{s.endLabel.text}
</text>
) : null,
)}
{peak && (
<>
<circle cx={peak.cx} cy={peak.cy} r="4" fill={peak.color ?? peakColorToken} style={{ stroke: "var(--surface-1)" }} strokeWidth="2" />
<text x={peak.cx} y={peak.cy - 10} fontSize="10" style={{ fill: "var(--ink-2)" }} textAnchor="middle">
{peak.label}
</text>
</>
)}
{xTicks.map((t, i) => (
<text key={`x${i}`} x={t.x} y={t.y} fontSize="10" style={{ fill: "var(--ink-3)" }} textAnchor={t.anchor ?? "start"}>
{t.label}
</text>
))}
</svg>
);
}

View File

@ -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<ServiceTypeDTO, string> = {
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<ServiceTypeDTO, string> = {
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] };
}

42
packages/ui/src/sheet.css Normal file
View File

@ -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}

View File

@ -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 <div className="skeleton" style={{ height: h, width: w, ...style }} />;
}
// 오류 상태 — 재시도.
export function ErrorState({
message,
onRetry,
}: {
message?: string;
onRetry?: () => void;
}) {
return (
<div className="card empty">
<div className="art"><Warn size={40} /></div>
<p>{message ?? "불러오지 못했습니다."}</p>
{onRetry && (
<button className="btn mt" onClick={onRetry}>
</button>
)}
</div>
);
}
// 빈 상태 — 온보딩 유도 일러스트 + CTA.
export function EmptyState({
art,
title,
desc,
cta,
}: {
art?: ReactNode;
title: string;
desc?: ReactNode;
cta?: ReactNode;
}) {
return (
<div className="card empty">
<div className="art">{art ?? <Cloud size={40} />}</div>
<p>
<b>{title}</b>
{desc && (
<>
<br />
{desc}
</>
)}
</p>
{cta && <div className="mt">{cta}</div>}
</div>
);
}

View File

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

View File

@ -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<PanelSheetCurrent>(null);
const ref = useRef<PanelSheetCurrent>(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<typeof usePanelSheets>;

View File

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

View File

@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src",
"jsx": "react-jsx"
}, },
"include": [ "include": [
"src" "src"