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:
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
187
packages/ui/src/CardSheet.tsx
Normal file
187
packages/ui/src/CardSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
packages/ui/src/PanelSheets.tsx
Normal file
71
packages/ui/src/PanelSheets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
packages/ui/src/components.css
Normal file
85
packages/ui/src/components.css
Normal 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}
|
||||
3
packages/ui/src/constants.ts
Normal file
3
packages/ui/src/constants.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// @yakcloud/ui — 순수 상수.
|
||||
// CardSheet 기본 너비(px). help-sheets.ts 의 DEFAULT_SHEET_WIDTH 와 동치.
|
||||
export const DEFAULT_SHEET_WIDTH = 460;
|
||||
75
packages/ui/src/controls.tsx
Normal file
75
packages/ui/src/controls.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
55
packages/ui/src/decorators.ts
Normal file
55
packages/ui/src/decorators.ts
Normal 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
75
packages/ui/src/icons.tsx
Normal 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" />);
|
||||
@ -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";
|
||||
|
||||
123
packages/ui/src/primitives.tsx
Normal file
123
packages/ui/src/primitives.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
packages/ui/src/service-meta.ts
Normal file
41
packages/ui/src/service-meta.ts
Normal 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
42
packages/ui/src/sheet.css
Normal 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}
|
||||
67
packages/ui/src/states.tsx
Normal file
67
packages/ui/src/states.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
packages/ui/src/tokens.css
Normal file
14
packages/ui/src/tokens.css
Normal 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;
|
||||
}
|
||||
34
packages/ui/src/usePanelSheets.ts
Normal file
34
packages/ui/src/usePanelSheets.ts
Normal 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>;
|
||||
19
packages/ui/src/useSheet.ts
Normal file
19
packages/ui/src/useSheet.ts
Normal 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) },
|
||||
};
|
||||
}
|
||||
@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
||||
Reference in New Issue
Block a user