feat: complete TechLog Studio publication flow
This commit is contained in:
@@ -1,79 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import { PageHeader } from "../design-system/index.ts";
|
||||
import { useSession } from "../providers/session-provider.tsx";
|
||||
|
||||
export default function AuthExamplePage() {
|
||||
const location = useLocation();
|
||||
const { sessionState, beginSignIn, signOut, recover } = useSession();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
async function execute(action: () => Promise<unknown>): Promise<void> {
|
||||
setPending(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await action();
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="ui-page">
|
||||
<PageHeader
|
||||
eyebrow="예제"
|
||||
title="인증 연동"
|
||||
description="스켈레톤은 자격 증명을 소유하지 않고 외부 인증 구현이 연결될 포트와 화면 상태만 제공합니다."
|
||||
/>
|
||||
<section className="ui-panel auth-example" aria-labelledby="auth-state-title">
|
||||
<div>
|
||||
<h2 id="auth-state-title">현재 세션 상태</h2>
|
||||
<output className="session-status" data-state={sessionState}>
|
||||
{sessionState}
|
||||
</output>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="ui-button"
|
||||
type="button"
|
||||
disabled={pending || sessionState === "integration-failed"}
|
||||
onClick={() =>
|
||||
void execute(() =>
|
||||
beginSignIn(`${location.pathname}${location.search}`),
|
||||
)
|
||||
}
|
||||
>
|
||||
로그인 시작
|
||||
</button>
|
||||
<button
|
||||
className="ui-button ui-button--secondary"
|
||||
type="button"
|
||||
disabled={pending || sessionState !== "authenticated"}
|
||||
onClick={() => void execute(signOut)}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
<button
|
||||
className="ui-button ui-button--secondary"
|
||||
type="button"
|
||||
disabled={pending || sessionState !== "recovery-pending"}
|
||||
onClick={() => void execute(recover)}
|
||||
>
|
||||
세션 복구
|
||||
</button>
|
||||
</div>
|
||||
{sessionState === "integration-failed" ? (
|
||||
<p role="status">
|
||||
외부 인증 소유자가 연결되지 않았습니다. 런타임 호스트의 인증
|
||||
계약을 연결하세요.
|
||||
</p>
|
||||
) : null}
|
||||
{failed ? <p role="alert">인증 작업을 완료하지 못했습니다.</p> : null}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { HTTP_EXECUTION_CEILINGS } from "../../contracts/external-contract-runtime.ts";
|
||||
import { SERVER_STATE_PROFILES } from "../../contracts/server-state.ts";
|
||||
import {
|
||||
COMPOSED_CONTRACT_CONTRIBUTIONS,
|
||||
EXPECTED_CONTRACT_SET_PACKAGES,
|
||||
} from "../../features/installed-contract-contributions.ts";
|
||||
import { ROUTE_REGISTRY } from "../../features/installed-feature-contracts.ts";
|
||||
import type {
|
||||
RuntimeCapabilityId,
|
||||
RuntimeCapabilityStatus,
|
||||
} from "../../contracts/runtime-capabilities.ts";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
DataTable,
|
||||
EmptySurface,
|
||||
PageHeader,
|
||||
type DataTableColumn,
|
||||
} from "../design-system/index.ts";
|
||||
import { useApplication } from "../providers/application-provider.tsx";
|
||||
|
||||
/**
|
||||
* Every number and row on this page is read from an installed registry at
|
||||
* render time. Nothing is transcribed by hand, so deleting a feature removes
|
||||
* its rows and the page keeps describing what the repository actually is.
|
||||
*/
|
||||
|
||||
function kilobytes(bytes: number): string {
|
||||
if (bytes === 0) return "없음";
|
||||
if (bytes >= 1_048_576) return `${bytes / 1_048_576} MiB`;
|
||||
return `${bytes / 1024} KiB`;
|
||||
}
|
||||
|
||||
function seconds(milliseconds: number): string {
|
||||
return milliseconds < 1000
|
||||
? `${milliseconds}ms`
|
||||
: `${milliseconds / 1000}초`;
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: Readonly<{ label: string; value: string; hint?: string }>) {
|
||||
return (
|
||||
<div className="platform-metric">
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
<span className="platform-metric__value">{value}</span>
|
||||
{hint ? <span className="platform-metric__hint">{hint}</span> : null}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RouteRow = (typeof ROUTE_REGISTRY)[keyof typeof ROUTE_REGISTRY];
|
||||
|
||||
const ROUTE_COLUMNS: readonly DataTableColumn<RouteRow>[] = Object.freeze([
|
||||
{
|
||||
id: "routeId",
|
||||
header: "라우트",
|
||||
cell: (row) => <code>{row.routeId}</code>,
|
||||
},
|
||||
{ id: "path", header: "경로", cell: (row) => <code>{row.path}</code> },
|
||||
{
|
||||
id: "access",
|
||||
header: "접근",
|
||||
cell: (row) => (
|
||||
<Badge variant={row.access === "public" ? "success" : "warning"}>
|
||||
{row.access}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "schemas",
|
||||
header: "입력 스키마",
|
||||
cell: (row) =>
|
||||
[row.paramsSchema, row.searchSchema].filter(Boolean).join(" · ") || "없음",
|
||||
},
|
||||
{
|
||||
id: "chunkId",
|
||||
header: "청크",
|
||||
cell: (row) => <code>{row.chunkId}</code>,
|
||||
},
|
||||
]);
|
||||
|
||||
type OperationRow = Readonly<{
|
||||
operationId: string;
|
||||
method: string;
|
||||
pathTemplate: string;
|
||||
retrySemantics: string;
|
||||
retryBudget: number;
|
||||
totalDeadlineMs: number;
|
||||
requestByteLimit: number;
|
||||
responseByteLimit: number;
|
||||
effect: string;
|
||||
recovery: string;
|
||||
}>;
|
||||
|
||||
const OPERATION_COLUMNS: readonly DataTableColumn<OperationRow>[] =
|
||||
Object.freeze([
|
||||
{
|
||||
id: "operationId",
|
||||
header: "오퍼레이션",
|
||||
cell: (row) => (
|
||||
<>
|
||||
<code>{row.operationId}</code>
|
||||
<span className="platform-operation__path">
|
||||
{row.method} {row.pathTemplate}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "retry",
|
||||
header: "재시도",
|
||||
cell: (row) => (
|
||||
<>
|
||||
<Badge variant={row.retrySemantics === "SAFE" ? "success" : "warning"}>
|
||||
{row.retrySemantics}
|
||||
</Badge>
|
||||
<span className="platform-operation__path">
|
||||
예산 {row.retryBudget}회
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "effect",
|
||||
header: "효과 확정성",
|
||||
cell: (row) => row.effect,
|
||||
},
|
||||
{
|
||||
id: "recovery",
|
||||
header: "복구",
|
||||
cell: (row) => row.recovery,
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
header: "예산",
|
||||
cell: (row) => (
|
||||
<>
|
||||
<span className="platform-operation__path">
|
||||
요청 {kilobytes(row.requestByteLimit)} · 응답{" "}
|
||||
{kilobytes(row.responseByteLimit)}
|
||||
</span>
|
||||
<span className="platform-operation__path">
|
||||
마감 {seconds(row.totalDeadlineMs)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
type ProfileRow = (typeof SERVER_STATE_PROFILES)[keyof typeof SERVER_STATE_PROFILES];
|
||||
|
||||
const PROFILE_COLUMNS: readonly DataTableColumn<ProfileRow>[] = Object.freeze([
|
||||
{
|
||||
id: "profileId",
|
||||
header: "프로파일",
|
||||
cell: (row) => <code>{row.profileId}</code>,
|
||||
},
|
||||
{ id: "stale", header: "stale", cell: (row) => seconds(row.staleTimeMs) },
|
||||
{ id: "gc", header: "gc", cell: (row) => seconds(row.gcTimeMs) },
|
||||
{
|
||||
id: "refetch",
|
||||
header: "재조회",
|
||||
cell: (row) =>
|
||||
[
|
||||
row.refetchOnMount === "always"
|
||||
? "mount(always)"
|
||||
: row.refetchOnMount && "mount",
|
||||
row.refetchOnFocus && "focus",
|
||||
row.refetchOnReconnect && "reconnect",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
header: "결과 예산",
|
||||
cell: (row) =>
|
||||
`${row.maxResultItems}건 · ${kilobytes(row.maxEstimatedResultBytes)}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const CAPABILITY_COPY: Readonly<
|
||||
Record<RuntimeCapabilityId, Readonly<{ label: string; description: string }>>
|
||||
> = Object.freeze({
|
||||
REALTIME: Object.freeze({
|
||||
label: "실시간 수신",
|
||||
description:
|
||||
"WebSocket, SSE, 경계 폴링 런타임은 구현되어 있습니다. 제품 기여물이 엔드포인트와 이벤트 서술자를 제공해야 설치됩니다.",
|
||||
}),
|
||||
WEB_WORKER: Object.freeze({
|
||||
label: "웹 워커",
|
||||
description:
|
||||
"워커 실행 계약과 전용 타입 프로젝트가 준비되어 있습니다. 프로파일링으로 확인된 CPU 작업이 있어야 설치됩니다.",
|
||||
}),
|
||||
SERVICE_WORKER: Object.freeze({
|
||||
label: "서비스 워커",
|
||||
description:
|
||||
"참조 런타임과 두 단계 빌드가 준비되어 있습니다. 설치하면 검증된 정적 자산 캐시와 등록 해제 경로가 함께 켜집니다.",
|
||||
}),
|
||||
OFFLINE_COMMANDS: Object.freeze({
|
||||
label: "오프라인 명령",
|
||||
description:
|
||||
"명령 큐 상태 기계가 준비되어 있습니다. 복구 서술자를 가진 KEYED 오퍼레이션이 있어야 설치됩니다.",
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* A capability that was never selected and one an operator switched off look
|
||||
* identical if both are reported as "off". The snapshot separates them, and so
|
||||
* does this badge.
|
||||
*/
|
||||
function capabilityBadge(
|
||||
status: RuntimeCapabilityStatus,
|
||||
): Readonly<{ text: string; variant: "success" | "warning" | "neutral" }> {
|
||||
if (status.selected === 0) return { text: "미선택", variant: "neutral" };
|
||||
if (status.active === 0) {
|
||||
return { text: "운영자가 비활성화함", variant: "warning" };
|
||||
}
|
||||
return { text: `활성 (${status.active})`, variant: "success" };
|
||||
}
|
||||
|
||||
function buildOperationRows(): readonly OperationRow[] {
|
||||
return Object.freeze(
|
||||
[...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.values()].map(
|
||||
(installed) => {
|
||||
const { contract, frontend } = installed;
|
||||
return Object.freeze({
|
||||
operationId: contract.operationId,
|
||||
method: contract.method,
|
||||
pathTemplate: contract.pathTemplate,
|
||||
retrySemantics: contract.retrySemantics,
|
||||
retryBudget: frontend.retryBudget,
|
||||
totalDeadlineMs: frontend.totalDeadlineMs,
|
||||
requestByteLimit: frontend.requestByteLimit,
|
||||
responseByteLimit: frontend.responseByteLimit,
|
||||
effect:
|
||||
contract.commandEffect === null
|
||||
? "해당 없음"
|
||||
: contract.commandEffect.successEffect,
|
||||
recovery:
|
||||
contract.commandRecovery === null
|
||||
? "해당 없음"
|
||||
: contract.commandRecovery.mode,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlatformOverviewPage() {
|
||||
const { runtime } = useApplication();
|
||||
const [release, setRelease] = useState<
|
||||
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void runtime.getReleaseSummary().then((summary) => {
|
||||
if (active) setRelease(summary);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [runtime]);
|
||||
|
||||
const routes = Object.values(ROUTE_REGISTRY);
|
||||
const operations = buildOperationRows();
|
||||
const capabilities = runtime.getCapabilitySnapshot();
|
||||
const activeCapabilityCount = capabilities.filter(
|
||||
(status) => status.active > 0,
|
||||
).length;
|
||||
const fixtureContributions =
|
||||
COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
|
||||
(contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
|
||||
).length;
|
||||
|
||||
return (
|
||||
<section className="ui-page">
|
||||
<PageHeader
|
||||
eyebrow="예제"
|
||||
title="플랫폼 구성"
|
||||
description="이 화면의 모든 값은 설치된 레지스트리에서 렌더 시점에 읽습니다. 손으로 옮겨 적은 숫자가 없으므로 코드가 바뀌면 이 화면도 함께 바뀝니다."
|
||||
/>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-release-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-release-title">릴리스 신원</h2>
|
||||
<p>
|
||||
부팅 시 경계 검사를 통과한 런타임 설정과 릴리스 매니페스트에서 옵니다.
|
||||
</p>
|
||||
</header>
|
||||
<dl className="platform-metric-grid" aria-live="polite">
|
||||
{release ? (
|
||||
<>
|
||||
<Metric label="빌드" value={release.buildId} />
|
||||
<Metric label="릴리스" value={release.releaseId} />
|
||||
<Metric
|
||||
label="설정 스키마"
|
||||
value={release.configSchemaVersion}
|
||||
hint={
|
||||
release.apiContractVersion
|
||||
? `레거시 계약 ${release.apiContractVersion}`
|
||||
: "계약 집합 사용"
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label="계약 집합 다이제스트"
|
||||
value={
|
||||
release.contractSetDigest
|
||||
? `${release.contractSetDigest.slice(0, 20)}…`
|
||||
: "없음"
|
||||
}
|
||||
hint={
|
||||
release.contractSetDigest
|
||||
? "릴리스 매니페스트 V2"
|
||||
: "릴리스 매니페스트 V1"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Metric label="상태" value="런타임 정보를 확인하고 있습니다." />
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-summary-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-summary-title">설치 요약</h2>
|
||||
<p>레지스트리 항목 수를 그대로 센 값입니다.</p>
|
||||
</header>
|
||||
<dl className="platform-metric-grid">
|
||||
<Metric label="라우트" value={`${routes.length}개`} />
|
||||
<Metric label="HTTP 오퍼레이션" value={`${operations.length}개`} />
|
||||
<Metric
|
||||
label="외부 계약 패키지"
|
||||
value={`${EXPECTED_CONTRACT_SET_PACKAGES.length}개`}
|
||||
hint={`템플릿 픽스처 ${fixtureContributions}개`}
|
||||
/>
|
||||
<Metric
|
||||
label="선택적 런타임 능력"
|
||||
value={`${activeCapabilityCount} / ${capabilities.length}`}
|
||||
hint="런타임 오버라이드 반영"
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-routes-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-routes-title">설치된 라우트</h2>
|
||||
<p>
|
||||
라우트 레지스트리가 단일 진실 공급원입니다. 접근 정책, 코드 분할 청크,
|
||||
입력 스키마가 한 항목에 함께 선언됩니다.
|
||||
</p>
|
||||
</header>
|
||||
<DataTable
|
||||
caption="설치된 라우트 목록"
|
||||
columns={ROUTE_COLUMNS}
|
||||
rows={routes}
|
||||
rowKey={(row) => row.routeId}
|
||||
empty={
|
||||
<EmptySurface
|
||||
title="설치된 라우트가 없습니다."
|
||||
description="라우트 레지스트리가 비어 있습니다."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-contracts-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-contracts-title">계약과 HTTP 오퍼레이션</h2>
|
||||
<p>
|
||||
외부 계약 패키지 {EXPECTED_CONTRACT_SET_PACKAGES.length}개가 설치되어
|
||||
있습니다. 아래 오퍼레이션은 템플릿 픽스처가 제공하며 릴리스 다이제스트에
|
||||
포함되지 않습니다. 제품은 픽스처를 지우고 자기 패키지를 고정합니다.
|
||||
</p>
|
||||
</header>
|
||||
<DataTable
|
||||
caption="설치된 HTTP 오퍼레이션"
|
||||
columns={OPERATION_COLUMNS}
|
||||
rows={operations}
|
||||
rowKey={(row) => row.operationId}
|
||||
empty={
|
||||
<EmptySurface
|
||||
title="설치된 HTTP 오퍼레이션이 없습니다."
|
||||
description="계약 기여물을 추가하면 이 표에 나타납니다."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-server-state-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-server-state-title">서버 상태와 실행 상한</h2>
|
||||
<p>
|
||||
조회는 네 개의 고정 프로파일 중 하나를 골라야 하고, 실행 정책은 아래
|
||||
상한을 넘을 수 없습니다.
|
||||
</p>
|
||||
</header>
|
||||
<DataTable
|
||||
caption="서버 상태 프로파일"
|
||||
columns={PROFILE_COLUMNS}
|
||||
rows={Object.values(SERVER_STATE_PROFILES)}
|
||||
rowKey={(row) => row.profileId}
|
||||
empty={<EmptySurface title="프로파일이 없습니다." />}
|
||||
/>
|
||||
<dl className="platform-metric-grid">
|
||||
<Metric
|
||||
label="응답 상한"
|
||||
value={kilobytes(HTTP_EXECUTION_CEILINGS.hardResponseBytes)}
|
||||
hint={`기본 ${kilobytes(HTTP_EXECUTION_CEILINGS.defaultResponseBytes)}`}
|
||||
/>
|
||||
<Metric
|
||||
label="요청 상한"
|
||||
value={kilobytes(HTTP_EXECUTION_CEILINGS.hardRequestBytes)}
|
||||
hint={`기본 ${kilobytes(HTTP_EXECUTION_CEILINGS.defaultRequestBytes)}`}
|
||||
/>
|
||||
<Metric
|
||||
label="총 마감 상한"
|
||||
value={seconds(HTTP_EXECUTION_CEILINGS.hardTotalDeadlineMs)}
|
||||
hint={`기본 ${seconds(HTTP_EXECUTION_CEILINGS.defaultTotalDeadlineMs)}`}
|
||||
/>
|
||||
<Metric
|
||||
label="재시도 상한"
|
||||
value={`${HTTP_EXECUTION_CEILINGS.hardRetryCount}회`}
|
||||
hint="전송 실패에만 적용"
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-capabilities-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-capabilities-title">선택적 런타임 능력</h2>
|
||||
<p>
|
||||
정적 선택 파일이 단일 진실 공급원입니다. 런타임 설정은 이미 선택된
|
||||
능력을 끌 수만 있고, 설정 문자열로 새 능력을 켜거나 모듈 경로를 만들지
|
||||
못합니다. 여기 표시되는 상태는 정적 선택에 런타임 오버라이드를 적용한
|
||||
결과이므로, 애초에 선택되지 않은 능력과 운영자가 끈 능력이 구분됩니다.
|
||||
</p>
|
||||
</header>
|
||||
<div className="component-grid component-grid--two">
|
||||
{capabilities.map((status) => {
|
||||
const copy = CAPABILITY_COPY[status.capabilityId];
|
||||
const badge = capabilityBadge(status);
|
||||
return (
|
||||
<Card
|
||||
key={status.capabilityId}
|
||||
title={copy.label}
|
||||
footer={<Badge variant={badge.variant}>{badge.text}</Badge>}
|
||||
>
|
||||
<p>{copy.description}</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { deriveAsyncState } from "../../application/view-models/async-state.ts";
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
import {
|
||||
AsyncSurface,
|
||||
EmptySurface,
|
||||
LoadingSurface,
|
||||
TerminalErrorSurface,
|
||||
AuthRequiredSurface,
|
||||
ForbiddenSurface,
|
||||
NotFoundSurface,
|
||||
Button,
|
||||
Card,
|
||||
PageHeader,
|
||||
} from "../design-system/index.ts";
|
||||
|
||||
export default function StateGalleryPage() {
|
||||
const [lastAction, setLastAction] = useState(
|
||||
"상태 화면의 작업을 선택하면 결과가 여기에 표시됩니다.",
|
||||
);
|
||||
const refreshingState = deriveAsyncState({
|
||||
data: ["기존 데이터"],
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="ui-page">
|
||||
<PageHeader
|
||||
eyebrow="예제"
|
||||
title="화면 상태"
|
||||
description="로딩, 빈 화면, 오류, 인증 필요와 권한 없음 상태가 다음 행동까지 일관되게 안내합니다."
|
||||
/>
|
||||
|
||||
<section className="gallery-section" aria-labelledby="async-states-title">
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="async-states-title">비동기 데이터 상태</h2>
|
||||
<p>초기 로딩과 백그라운드 갱신을 구분해 기존 콘텐츠를 보존합니다.</p>
|
||||
</header>
|
||||
<div className="component-grid component-grid--two">
|
||||
<Card title="초기 로딩">
|
||||
<LoadingSurface label="예제 데이터를 불러오는 중" />
|
||||
</Card>
|
||||
<Card title="백그라운드 갱신">
|
||||
<AsyncSurface state={refreshingState}>
|
||||
<div className="state-preview-content">기존 콘텐츠는 계속 표시됩니다.</div>
|
||||
</AsyncSurface>
|
||||
</Card>
|
||||
<Card title="빈 화면">
|
||||
<EmptySurface
|
||||
title="아직 표시할 항목이 없습니다."
|
||||
description="첫 항목을 추가하거나 필터를 초기화할 수 있습니다."
|
||||
action={
|
||||
<Button onClick={() => setLastAction("빈 화면 작업을 실행했습니다.")}>
|
||||
첫 작업 시작
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
<Card title="복구 가능한 오류">
|
||||
<TerminalErrorSurface
|
||||
userMessageKey={
|
||||
createFailure("NETWORK_UNREACHABLE", "EXAMPLE", 0)
|
||||
.userMessageKey
|
||||
}
|
||||
action="retry"
|
||||
onAction={() => setLastAction("오류 요청을 다시 시도했습니다.")}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="gallery-section" aria-labelledby="access-states-title">
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="access-states-title">접근과 탐색 상태</h2>
|
||||
<p>인증 여부와 서버 권한 결과를 서로 다른 상태로 전달합니다.</p>
|
||||
</header>
|
||||
<div className="component-grid component-grid--three">
|
||||
<AuthRequiredSurface
|
||||
onSignIn={() => setLastAction("로그인 연동 작업을 시작했습니다.")}
|
||||
/>
|
||||
<ForbiddenSurface
|
||||
onNavigate={() => setLastAction("접근 가능한 화면으로 이동합니다.")}
|
||||
/>
|
||||
<NotFoundSurface
|
||||
onNavigate={() => setLastAction("시작 화면으로 이동합니다.")}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<output className="gallery-notice" aria-live="polite">
|
||||
{lastAction}
|
||||
</output>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Menu,
|
||||
PageHeader,
|
||||
ProgressBar,
|
||||
RadioGroup,
|
||||
Select,
|
||||
Switch,
|
||||
Tabs,
|
||||
TextArea,
|
||||
TextField,
|
||||
ToastProvider,
|
||||
Tooltip,
|
||||
useToast,
|
||||
} from "../design-system/index.ts";
|
||||
|
||||
const COLOR_TOKENS = Object.freeze([
|
||||
["Surface", "--color-surface"],
|
||||
["Muted surface", "--color-surface-muted"],
|
||||
["Content", "--color-content"],
|
||||
["Muted content", "--color-content-muted"],
|
||||
["Action", "--color-action"],
|
||||
["Danger", "--color-danger"],
|
||||
["Focus", "--color-focus"],
|
||||
]);
|
||||
|
||||
export default function UiGalleryPage() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<UiGalleryContent />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function UiGalleryContent() {
|
||||
const toast = useToast();
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [template, setTemplate] = useState("application");
|
||||
const [reviewed, setReviewed] = useState(false);
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [density, setDensity] = useState("comfortable");
|
||||
const [fieldTouched, setFieldTouched] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [notice, setNotice] = useState(
|
||||
"구성요소를 조작하면 결과가 여기에 표시됩니다.",
|
||||
);
|
||||
const [alertVisible, setAlertVisible] = useState(true);
|
||||
const fieldError =
|
||||
fieldTouched && projectName.trim().length === 0
|
||||
? "프로젝트 이름을 입력해 주세요."
|
||||
: undefined;
|
||||
|
||||
function submitExample(event: FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault();
|
||||
setFieldTouched(true);
|
||||
if (projectName.trim().length === 0) {
|
||||
setNotice("입력값을 확인해 주세요.");
|
||||
return;
|
||||
}
|
||||
setNotice(`“${projectName.trim()}” 입력을 확인했습니다.`);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="ui-page">
|
||||
<PageHeader
|
||||
eyebrow="예제"
|
||||
title="UI 구성요소"
|
||||
description="제품 도메인과 독립적인 공통 컨트롤, 피드백, 표면과 디자인 토큰을 직접 조작할 수 있습니다."
|
||||
/>
|
||||
|
||||
<section className="gallery-section" aria-labelledby="controls-title">
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="controls-title">버튼과 입력</h2>
|
||||
<p>키보드, 비활성 상태, 오류 설명을 포함한 기본 상호작용입니다.</p>
|
||||
</header>
|
||||
<div className="component-grid component-grid--two">
|
||||
<Card title="버튼" description="의미와 위험도에 따라 변형을 선택합니다.">
|
||||
<div className="button-row">
|
||||
<Button onClick={() => setNotice("기본 작업을 실행했습니다.")}>
|
||||
기본 작업
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setNotice("보조 작업을 실행했습니다.")}
|
||||
>
|
||||
보조 작업
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setNotice("위험 작업 예제를 선택했습니다.")}
|
||||
>
|
||||
위험 작업
|
||||
</Button>
|
||||
<Button disabled>사용 불가</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="입력창" description="레이블과 도움말, 오류가 입력에 연결됩니다.">
|
||||
<form className="example-form" noValidate onSubmit={submitExample}>
|
||||
<TextField
|
||||
label="프로젝트 이름"
|
||||
description="새 도메인을 연결할 때 사용할 중립적인 예제입니다."
|
||||
error={fieldError}
|
||||
value={projectName}
|
||||
required
|
||||
onChange={(event) => setProjectName(event.currentTarget.value)}
|
||||
/>
|
||||
<Button type="submit">입력 확인</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="gallery-section" aria-labelledby="form-controls-title">
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="form-controls-title">폼과 선택 컨트롤</h2>
|
||||
<p>native semantics, 설명·오류 연결과 controlled 상태를 제공합니다.</p>
|
||||
</header>
|
||||
<div className="component-grid component-grid--two">
|
||||
<Card title="긴 입력과 선택">
|
||||
<div className="component-stack">
|
||||
<TextArea
|
||||
label="설명"
|
||||
maxLength={120}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="시작 템플릿"
|
||||
options={[
|
||||
{ value: "application", label: "Application" },
|
||||
{ value: "library", label: "Library" },
|
||||
]}
|
||||
value={template}
|
||||
onChange={(event) => setTemplate(event.currentTarget.value)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={reviewed}
|
||||
label="접근성 계약을 확인했습니다."
|
||||
onChange={(event) => setReviewed(event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="단일 선택과 설정">
|
||||
<div className="component-stack">
|
||||
<RadioGroup
|
||||
label="화면 밀도"
|
||||
name="density"
|
||||
onChange={setDensity}
|
||||
options={[
|
||||
{ value: "comfortable", label: "여유롭게" },
|
||||
{ value: "compact", label: "조밀하게" },
|
||||
]}
|
||||
value={density}
|
||||
/>
|
||||
<Switch
|
||||
checked={notifications}
|
||||
description="boolean 설정에만 switch를 사용합니다."
|
||||
label="알림 사용"
|
||||
onChange={setNotifications}
|
||||
/>
|
||||
<ProgressBar label="설정 준비도" max={4} value={3} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="gallery-section" aria-labelledby="feedback-title">
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="feedback-title">피드백과 모달</h2>
|
||||
<p>상태 전달은 색에만 의존하지 않으며, 모든 제어에는 이름이 있습니다.</p>
|
||||
</header>
|
||||
<div className="component-grid component-grid--two">
|
||||
<Card title="알림과 배지" description="짧은 상태와 문맥형 피드백입니다.">
|
||||
<div className="component-stack">
|
||||
{alertVisible ? (
|
||||
<Alert
|
||||
title="설정이 저장되었습니다."
|
||||
variant="success"
|
||||
onDismiss={() => setAlertVisible(false)}
|
||||
>
|
||||
<p>운영 환경에는 실제 저장 포트를 연결하세요.</p>
|
||||
</Alert>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setAlertVisible(true)}
|
||||
>
|
||||
알림 다시 표시
|
||||
</Button>
|
||||
)}
|
||||
<div className="badge-row" aria-label="배지 변형">
|
||||
<Badge>중립</Badge>
|
||||
<Badge variant="info">정보</Badge>
|
||||
<Badge variant="success">준비됨</Badge>
|
||||
<Badge variant="warning">확인 필요</Badge>
|
||||
<Badge variant="danger">실패</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="모달" description="배경과 키보드 Esc로 닫고 포커스를 복원합니다.">
|
||||
<div className="button-row">
|
||||
<Button onClick={() => setDialogOpen(true)}>모달 열기</Button>
|
||||
<Menu
|
||||
items={[
|
||||
{
|
||||
id: "inspect",
|
||||
label: "상태 확인",
|
||||
onSelect: () => setNotice("메뉴 작업을 실행했습니다."),
|
||||
},
|
||||
{
|
||||
id: "notify",
|
||||
label: "Toast 표시",
|
||||
onSelect: () =>
|
||||
toast.push({
|
||||
id: "gallery-saved",
|
||||
title: "예제가 저장되었습니다.",
|
||||
tone: "success",
|
||||
}),
|
||||
},
|
||||
]}
|
||||
triggerLabel="작업 메뉴"
|
||||
/>
|
||||
<Tooltip content="이 설명은 필수 정보가 아닙니다.">
|
||||
<Button variant="ghost">도움말</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
title="연동 확인"
|
||||
description="도메인 작업을 실행하기 전 확인 화면의 기본 구조입니다."
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNotice("모달의 확인 작업을 실행했습니다.");
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>민감한 값이나 구현 세부정보는 확인 문구에 포함하지 않습니다.</p>
|
||||
</Dialog>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="gallery-section" aria-labelledby="navigation-title">
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="navigation-title">탐색 패턴</h2>
|
||||
<p>화살표 키와 명시적인 활성화 정책을 갖는 탭 예제입니다.</p>
|
||||
</header>
|
||||
<Tabs
|
||||
activation="manual"
|
||||
label="디자인 시스템 계층"
|
||||
tabs={[
|
||||
{
|
||||
id: "tokens",
|
||||
label: "토큰",
|
||||
panel: <p>원시 값에서 의미와 컴포넌트 토큰을 파생합니다.</p>,
|
||||
},
|
||||
{
|
||||
id: "primitives",
|
||||
label: "프리미티브",
|
||||
panel: <p>native semantics와 interaction을 닫습니다.</p>,
|
||||
},
|
||||
{
|
||||
id: "patterns",
|
||||
label: "패턴",
|
||||
panel: <p>반복되는 사용자 문제를 조합으로 해결합니다.</p>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="gallery-section" aria-labelledby="tokens-title">
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="tokens-title">디자인 토큰</h2>
|
||||
<p>구성요소가 사용하는 의미 기반 색상과 형태 토큰입니다.</p>
|
||||
</header>
|
||||
<div className="token-grid">
|
||||
{COLOR_TOKENS.map(([label, token]) => (
|
||||
<article className="token-swatch" key={token}>
|
||||
<span
|
||||
className="token-swatch__color"
|
||||
style={{ backgroundColor: `var(${token})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<strong>{label}</strong>
|
||||
<code>{token}</code>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<output className="gallery-notice" aria-live="polite">
|
||||
{notice}
|
||||
</output>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function AppShell() {
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<NavLink className="app-shell__brand" to={routePath("APP_HOME")}>
|
||||
<NavLink className="app-shell__brand" to={routePath("TECH_LOG_HOME")}>
|
||||
{message("common.appName")}
|
||||
</NavLink>
|
||||
<div className="app-shell__session">
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { routePath } from "../../features/installed-feature-contracts.ts";
|
||||
import { PageHeader } from "../design-system/index.ts";
|
||||
import { useApplication } from "../providers/application-provider.tsx";
|
||||
|
||||
const READINESS_ITEMS = Object.freeze([
|
||||
{
|
||||
title: "실행 계약",
|
||||
description: "런타임 설정, 릴리스 정합성, 오류 경계가 마운트 전에 검증됩니다.",
|
||||
},
|
||||
{
|
||||
title: "교체 가능한 연동",
|
||||
description: "인증, HTTP, 캐시, 저장소, 텔레메트리가 포트 뒤에 분리되어 있습니다.",
|
||||
},
|
||||
{
|
||||
title: "접근 가능한 화면",
|
||||
description: "키보드 탐색, 포커스 이동, 반응형 앱 셸의 기본 동작이 준비되어 있습니다.",
|
||||
},
|
||||
]);
|
||||
|
||||
export default function HomePage() {
|
||||
const { runtime } = useApplication();
|
||||
const [release, setRelease] = useState<
|
||||
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void runtime.getReleaseSummary().then((summary) => {
|
||||
if (active) setRelease(summary);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [runtime]);
|
||||
|
||||
return (
|
||||
<section className="ui-page">
|
||||
<PageHeader
|
||||
eyebrow="프로젝트 시작점"
|
||||
title="Tech Log"
|
||||
description="도메인을 추가하기 전에 실행 구조와 범용 사용자 경험을 확인할 수 있는 중립적인 스켈레톤입니다."
|
||||
/>
|
||||
<div className="readiness-grid" aria-label="구현 준비 상태">
|
||||
{READINESS_ITEMS.map((item) => (
|
||||
<article className="ui-panel" key={item.title}>
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<p className="ui-runtime-summary" aria-live="polite">
|
||||
{release
|
||||
? `빌드 ${release.buildId} · 릴리스 ${release.releaseId}`
|
||||
: "검증된 런타임 정보를 확인하고 있습니다."}
|
||||
</p>
|
||||
<section className="ui-panel starter-actions" aria-labelledby="starter-title">
|
||||
<div>
|
||||
<h2 id="starter-title">준비된 화면 살펴보기</h2>
|
||||
<p>
|
||||
설치된 라우트와 계약, 런타임 능력은 플랫폼 구성 화면에서, 공통
|
||||
구성요소와 비동기 화면 상태는 예제 라우트에서 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<Link className="ui-button" to={routePath("EXAMPLES_PLATFORM")}>
|
||||
플랫폼 구성 보기
|
||||
</Link>
|
||||
<Link
|
||||
className="ui-button ui-button--secondary"
|
||||
to={routePath("EXAMPLES_UI")}
|
||||
>
|
||||
UI 구성요소 보기
|
||||
</Link>
|
||||
<Link
|
||||
className="ui-button ui-button--secondary"
|
||||
to={routePath("EXAMPLES_STATES")}
|
||||
>
|
||||
화면 상태 보기
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { routePath } from "../../features/installed-feature-contracts.ts";
|
||||
import { PageHeader } from "../design-system/index.ts";
|
||||
import { useLocale } from "../i18n/index.ts";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const { message } = useLocale();
|
||||
return (
|
||||
<section className="ui-page">
|
||||
<PageHeader
|
||||
title={message("page.notFound.title")}
|
||||
description={message("page.notFound.description")}
|
||||
/>
|
||||
<div>
|
||||
<Link className="ui-button" to={routePath("APP_HOME")}>
|
||||
{message("action.goHome")}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
createBrowserRouter,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
type RouteObject,
|
||||
useLocation,
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.tsx";
|
||||
import { Button, PageHeader } from "../design-system/index.ts";
|
||||
import { LocaleProvider, useLocale } from "../i18n/index.ts";
|
||||
import { AppShell } from "../layouts/app-shell.tsx";
|
||||
import { TECH_LOG_ROUTE_LAYOUTS } from "../../features/tech-log/presentation/tech-log-route-runtime.tsx";
|
||||
import { useApplication } from "../providers/application-provider.tsx";
|
||||
import { SessionProvider, useSession } from "../providers/session-provider.tsx";
|
||||
import { ThemeProvider } from "../providers/theme-provider.tsx";
|
||||
@@ -459,10 +458,7 @@ export function AppRouter({
|
||||
createGroupedRouteObjects(
|
||||
ROUTE_REGISTRY,
|
||||
ROUTE_RUNTIME,
|
||||
{
|
||||
PUBLIC: <AppShell />,
|
||||
STUDIO: <Outlet />,
|
||||
},
|
||||
TECH_LOG_ROUTE_LAYOUTS,
|
||||
buildId,
|
||||
ROUTE_CODECS,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const PLATFORM_ROUTE_CODECS = {
|
||||
export const PLATFORM_ROUTE_CODECS = Object.freeze({
|
||||
none: z.object({}).strict(),
|
||||
NotFoundSplat: z.object({ "*": z.string().min(1).optional() }).strict(),
|
||||
} as const;
|
||||
});
|
||||
|
||||
@@ -1,47 +1 @@
|
||||
import {
|
||||
lazy,
|
||||
type ComponentType,
|
||||
type LazyExoticComponent,
|
||||
} from "react";
|
||||
|
||||
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.ts";
|
||||
|
||||
type RouteModule = Readonly<{ default: ComponentType }>;
|
||||
type RouteRuntime = Readonly<{
|
||||
moduleId: string;
|
||||
Component: LazyExoticComponent<ComponentType>;
|
||||
}>;
|
||||
|
||||
function runtime(
|
||||
routeId: keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT,
|
||||
load: () => Promise<RouteModule>,
|
||||
): RouteRuntime {
|
||||
return Object.freeze({
|
||||
moduleId: PLATFORM_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
|
||||
Component: lazy(load),
|
||||
});
|
||||
}
|
||||
|
||||
export const PLATFORM_ROUTE_RUNTIME = {
|
||||
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.tsx")),
|
||||
EXAMPLES_PLATFORM: runtime(
|
||||
"EXAMPLES_PLATFORM",
|
||||
() => import("../examples/platform-overview-page.tsx"),
|
||||
),
|
||||
EXAMPLES_UI: runtime(
|
||||
"EXAMPLES_UI",
|
||||
() => import("../examples/ui-gallery-page.tsx"),
|
||||
),
|
||||
EXAMPLES_STATES: runtime(
|
||||
"EXAMPLES_STATES",
|
||||
() => import("../examples/state-gallery-page.tsx"),
|
||||
),
|
||||
EXAMPLES_AUTH: runtime(
|
||||
"EXAMPLES_AUTH",
|
||||
() => import("../examples/auth-example-page.tsx"),
|
||||
),
|
||||
NOT_FOUND: runtime(
|
||||
"NOT_FOUND",
|
||||
() => import("../pages/not-found-page.tsx"),
|
||||
),
|
||||
} satisfies Record<keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT, RouteRuntime>;
|
||||
export const PLATFORM_ROUTE_RUNTIME = Object.freeze({});
|
||||
|
||||
Reference in New Issue
Block a user