feat: complete TechLog Studio publication flow

This commit is contained in:
DongHyeonka
2026-08-16 00:35:11 +09:00
parent 9c6906fc6f
commit c5c8b9423c
60 changed files with 2028 additions and 2948 deletions
+1 -42
View File
@@ -21,45 +21,4 @@ export type RouteRuntimeDefinition = Readonly<{
searchCodec: RouteCodecId;
}>;
const runtime = <Definition extends RouteRuntimeDefinition>(
value: Definition,
): Readonly<Definition> => Object.freeze(value);
export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({
APP_HOME: runtime({
routeId: "APP_HOME",
moduleId: "home-page",
paramsCodec: "none",
searchCodec: "none",
}),
EXAMPLES_PLATFORM: runtime({
routeId: "EXAMPLES_PLATFORM",
moduleId: "platform-overview-page",
paramsCodec: "none",
searchCodec: "none",
}),
EXAMPLES_UI: runtime({
routeId: "EXAMPLES_UI",
moduleId: "ui-gallery-page",
paramsCodec: "none",
searchCodec: "none",
}),
EXAMPLES_STATES: runtime({
routeId: "EXAMPLES_STATES",
moduleId: "state-gallery-page",
paramsCodec: "none",
searchCodec: "none",
}),
EXAMPLES_AUTH: runtime({
routeId: "EXAMPLES_AUTH",
moduleId: "auth-example-page",
paramsCodec: "none",
searchCodec: "none",
}),
NOT_FOUND: runtime({
routeId: "NOT_FOUND",
moduleId: "not-found-page",
paramsCodec: "NotFoundSplat",
searchCodec: "none",
}),
});
export const PLATFORM_ROUTE_RUNTIME_CONTRACT = Object.freeze({});
+1 -90
View File
@@ -15,93 +15,4 @@ export type RouteDefinition = Readonly<{
navigationOrder: number | null;
}>;
const route = <Definition extends RouteDefinition>(
definition: Definition,
): Readonly<Definition> => Object.freeze(definition);
export const PLATFORM_ROUTE_REGISTRY = Object.freeze({
APP_HOME: route({
routeId: "APP_HOME",
path: "/",
layoutGroup: "PUBLIC",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "app-shell",
errorSurface: "route-boundary",
chunkId: "route-home",
title: "시작",
navigationLabel: "시작",
navigationOrder: 10,
}),
EXAMPLES_PLATFORM: route({
routeId: "EXAMPLES_PLATFORM",
path: "/examples/platform",
layoutGroup: "PUBLIC",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "example-page",
errorSurface: "route-boundary",
chunkId: "route-examples-platform",
title: "플랫폼 구성",
navigationLabel: "플랫폼 구성",
navigationOrder: 15,
}),
EXAMPLES_UI: route({
routeId: "EXAMPLES_UI",
path: "/examples/ui",
layoutGroup: "PUBLIC",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "example-page",
errorSurface: "route-boundary",
chunkId: "route-examples-ui",
title: "UI 구성요소",
navigationLabel: "UI 구성요소",
navigationOrder: 20,
}),
EXAMPLES_STATES: route({
routeId: "EXAMPLES_STATES",
path: "/examples/states",
layoutGroup: "PUBLIC",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "example-page",
errorSurface: "route-boundary",
chunkId: "route-examples-states",
title: "화면 상태",
navigationLabel: "화면 상태",
navigationOrder: 30,
}),
EXAMPLES_AUTH: route({
routeId: "EXAMPLES_AUTH",
path: "/examples/auth",
layoutGroup: "PUBLIC",
paramsSchema: null,
searchSchema: null,
access: "public",
loadingSurface: "example-page",
errorSurface: "route-boundary",
chunkId: "route-examples-auth",
title: "인증 연동",
navigationLabel: "인증 연동",
navigationOrder: 40,
}),
NOT_FOUND: route({
routeId: "NOT_FOUND",
path: "*",
layoutGroup: "PUBLIC",
paramsSchema: "NotFoundSplat",
searchSchema: null,
access: "public",
loadingSurface: "none",
errorSurface: "not-found",
chunkId: "route-not-found",
title: "페이지를 찾을 수 없음",
navigationLabel: null,
navigationOrder: null,
}),
});
export const PLATFORM_ROUTE_REGISTRY = Object.freeze({});
+8 -6
View File
@@ -1,6 +1,4 @@
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
import type { RouteRuntimeDefinition } from "../contracts/route-runtime-contract.ts";
import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.ts";
import type { RouteDefinition } from "../contracts/routes.ts";
import {
composeSchemaRegistry,
@@ -14,18 +12,21 @@ import {
import { validateRestProfileBindings } from "../contracts/rest-profiles.ts";
import { composeRuntimeSchemaCodecs } from "../contracts/schema-registry.ts";
import { composeBoundaryMapperRegistry } from "../contracts/boundary-mapper.ts";
import {
TECH_LOG_ROUTE_REGISTRY,
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
TECH_LOG_ROUTE_SCHEMA_REGISTRY,
} from "./tech-log/contracts/tech-log-route-contract.ts";
export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([
REFERENCE_FEATURE_CONTRACT,
]);
export const ROUTE_REGISTRY = Object.freeze({
...PLATFORM_ROUTE_REGISTRY,
...REFERENCE_FEATURE_CONTRACT.routes,
...TECH_LOG_ROUTE_REGISTRY,
}) satisfies Readonly<Record<string, RouteDefinition>>;
export const ROUTE_RUNTIME_CONTRACT = Object.freeze({
...PLATFORM_ROUTE_RUNTIME_CONTRACT,
...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts,
...TECH_LOG_ROUTE_RUNTIME_CONTRACT,
}) satisfies Readonly<Record<string, RouteRuntimeDefinition>>;
export const API_OPERATIONS = composeApiOperations(
INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.apiOperations),
@@ -56,6 +57,7 @@ export const INVALIDATION_TOPIC_VERSIONS = Object.freeze(
);
export const SCHEMA_REGISTRY = composeSchemaRegistry([
PLATFORM_SCHEMA_REGISTRY,
TECH_LOG_ROUTE_SCHEMA_REGISTRY,
...INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.schemas),
]);
export const RUNTIME_SCHEMA_CODECS = composeRuntimeSchemaCodecs(
@@ -1,10 +1,13 @@
import { REFERENCE_MESSAGE_CATALOGS } from "./reference-feature/contracts/reference-message-catalog.ts";
import { TECH_LOG_MESSAGE_CATALOGS } from "./tech-log/contracts/tech-log-message-catalog.ts";
export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({
...REFERENCE_MESSAGE_CATALOGS["ko-KR"],
...TECH_LOG_MESSAGE_CATALOGS["ko-KR"],
}),
"en-US": Object.freeze({
...REFERENCE_MESSAGE_CATALOGS["en-US"],
...TECH_LOG_MESSAGE_CATALOGS["en-US"],
}),
} as const);
+2 -8
View File
@@ -1,18 +1,12 @@
import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
import {
REFERENCE_FEATURE_ROUTE_CODECS,
REFERENCE_FEATURE_ROUTE_RUNTIME,
} from "./reference-feature/presentation/reference-feature-runtime.tsx";
import { TECH_LOG_ROUTE_CODECS } from "./tech-log/presentation/tech-log-route-codecs.ts";
import { TECH_LOG_ROUTE_RUNTIME } from "./tech-log/presentation/tech-log-route-runtime.tsx";
export const ROUTE_CODECS = Object.freeze({
...PLATFORM_ROUTE_CODECS,
...REFERENCE_FEATURE_ROUTE_CODECS,
...TECH_LOG_ROUTE_CODECS,
});
export const ROUTE_RUNTIME = Object.freeze({
...PLATFORM_ROUTE_RUNTIME,
...REFERENCE_FEATURE_ROUTE_RUNTIME,
...TECH_LOG_ROUTE_RUNTIME,
});
@@ -1,30 +0,0 @@
import { lazy } from "react";
import {
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
} from "../contracts/reference-schemas.ts";
export const REFERENCE_FEATURE_ROUTE_CODECS = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
ReferenceResourceParams: referenceResourceParamsSchema,
} as const;
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
REFERENCE_RESOURCE_LIST: Object.freeze({
moduleId: "reference-resource-page",
Component: lazy(() => import("./reference-resource-page.tsx")),
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
moduleId: "reference-resource-detail-page",
Component: lazy(() => import("./reference-resource-detail-page.tsx")),
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
moduleId: "reference-resource-form-page",
Component: lazy(() => import("./reference-resource-form-page.tsx")),
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
moduleId: "reference-resource-status-page",
Component: lazy(() => import("./reference-resource-status-page.tsx")),
}),
} as const;
@@ -1,61 +0,0 @@
import { Link } from "react-router-dom";
import {
AsyncSurface,
DetailPage,
} from "../../../presentation/design-system/index.ts";
import { useRouteInput } from "../../../presentation/routes/route-input.tsx";
import { useLocale } from "../../../presentation/i18n/index.ts";
import { useReferenceFailureAction } from "./use-reference-failure-action.ts";
import { useReferenceDetail } from "./use-reference-feature.ts";
export default function ReferenceResourceDetailPage() {
const { date, message } = useLocale();
const route = useRouteInput();
const resourceId = String(route.params.resourceId);
const { query } = useReferenceDetail(resourceId);
const resource = query.data;
const failureAction = useReferenceFailureAction(query.state.failure);
return (
<DetailPage
key={resourceId}
breadcrumb={
<Link to="/examples/reference-resources">Reference resources</Link>
}
heading={{
eyebrow: "DetailPage",
title: resource?.title ?? "Reference detail",
description: "route param과 detail query의 reset 경계를 확인합니다.",
}}
metadata={
resource ? (
<dl>
<dt>Resource ID</dt>
<dd>{resource.resourceId}</dd>
<dt>Created</dt>
<dd>
{resource.createdAt
? date(new Date(resource.createdAt))
: message("common.noDisplayValue")}
</dd>
</dl>
) : (
<p> .</p>
)
}
feedback={
<AsyncSurface
state={query.state}
onAction={failureAction}
onRetry={query.retry}
>
{resource ? (
<p> section을 .</p>
) : null}
</AsyncSurface>
}
aside={<p> slot입니다.</p>}
/>
);
}
@@ -1,158 +0,0 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
Button,
AsyncSurface,
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
FormPage,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../../presentation/design-system/index.ts";
import {
REFERENCE_FORM_DEFAULTS,
referenceResourceFormSchema,
toCreateReferenceCommand,
type ReferenceResourceFormValues,
} from "./reference-resource-form.ts";
import { useReferenceCreate } from "./use-reference-feature.ts";
const FIELD_LABELS = Object.freeze({
name: "새 항목 이름",
note: "설명",
}) satisfies Record<keyof ReferenceResourceFormValues, string>;
export default function ReferenceResourceFormPage() {
const navigate = useNavigate();
const mutation = useReferenceCreate();
const submit = useCallback(
(command: ReturnType<typeof toCreateReferenceCommand>) =>
mutation.submit(command),
[mutation],
);
const form = useAppForm({
schema: referenceResourceFormSchema,
defaultValues: REFERENCE_FORM_DEFAULTS,
allowedServerFields: ["name", "note"],
mapToCommand: toCreateReferenceCommand,
submit,
});
const mutationEffectUnknown =
mutation.state.indicator === "mutation-effect-unknown";
const mutationBlocked =
mutationEffectUnknown || mutation.state.indicator === "mutation-pending";
const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
return (
<Form
id={form.formId}
pending={form.pending}
onSubmit={(event) => {
if (mutationBlocked) {
event.preventDefault();
return;
}
void form.submitForm(event);
}}
>
<FormPage
breadcrumb={
<button
className="ui-button ui-button--ghost"
type="button"
onClick={() => navigate("/examples/reference-resources")}
>
</button>
}
heading={{
eyebrow: "FormPage",
title: "Reference resource 만들기",
description:
"presentation schema, command mapper, 422/conflict와 dirty navigation 정책을 실행합니다.",
}}
errorSummary={
<ErrorSummary
fieldErrors={form.fieldErrors}
formErrors={form.formErrors}
fieldLabels={FIELD_LABELS}
fieldId={form.fieldId}
onFocusField={form.focusField}
/>
}
fields={
<>
<FormField
{...form.field("name")}
label={FIELD_LABELS.name}
description="앞뒤 공백은 command mapper 전에 제거됩니다."
autoComplete="off"
required
/>
<FormField
{...form.field("note")}
label={FIELD_LABELS.note}
description="선택 입력이며 비어 있으면 command에 포함되지 않습니다."
autoComplete="off"
/>
</>
}
formActions={
<FormActions sticky>
<Button
variant="secondary"
onClick={() => navigate("/examples/reference-resources")}
disabled={form.pending || mutationBlocked}
>
</Button>
<Button
type="submit"
disabled={form.pending || mutationBlocked}
>
{form.pending ? "저장 중…" : "저장"}
</Button>
<Button
variant="ghost"
onClick={() => form.reset()}
disabled={!form.dirty || form.pending || mutationBlocked}
>
</Button>
</FormActions>
}
feedback={
mutationEffectUnknown ? (
<AsyncSurface
state={mutation.state}
onReconcileUnknownEffect={(resolution) => {
void mutation.reconcileUnknownEffect(resolution).then(() => {
if (resolution === "APPLIED") {
form.settleApplied();
} else {
form.settleNotApplied();
}
});
}}
/>
) : form.result === "success" ? (
<p role="status">.</p>
) : form.result === "conflict" ? (
<p role="status"> .</p>
) : null
}
aside={
<p>
form value는 URL, storage, telemetry에 submit
application command로 .
</p>
}
guard={<DirtyNavigationDialog guard={guard} />}
/>
</Form>
);
}
@@ -1,29 +0,0 @@
import { z } from "zod";
export const referenceResourceFormSchema = z
.object({
name: z
.string()
.trim()
.min(2, "이름은 두 글자 이상이어야 합니다.")
.max(120),
note: z.string().trim().max(500).default(""),
})
.strict();
export type ReferenceResourceFormValues = z.infer<
typeof referenceResourceFormSchema
>;
export const REFERENCE_FORM_DEFAULTS: ReferenceResourceFormValues =
Object.freeze({
name: "",
note: "",
});
export function toCreateReferenceCommand(values: ReferenceResourceFormValues) {
return Object.freeze({
name: values.name,
...(values.note ? { note: values.note } : {}),
});
}
@@ -1,61 +0,0 @@
import { Link, useNavigate } from "react-router-dom";
import {
AsyncSurface,
Button,
CollectionPage,
} from "../../../presentation/design-system/index.ts";
import { useReferenceFailureAction } from "./use-reference-failure-action.ts";
import { useReferenceFeature } from "./use-reference-feature.ts";
export default function ReferenceResourcePage() {
const navigate = useNavigate();
const { filters, query } = useReferenceFeature();
const failureAction = useReferenceFailureAction(query.state.failure);
return (
<CollectionPage
heading={{
eyebrow: "제거 가능한 수직 슬라이스",
title: "Reference feature",
description:
"URL codec, application input, HTTP/schema/mapper와 query 상태를 한 경로로 검증합니다.",
}}
actions={[
{
kind: "button",
label: "새 항목 만들기",
onAction: () => navigate("/examples/reference-resources/new"),
},
]}
activeFilters={
<p data-testid="reference-filter">
limit {filters.limit}
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
</p>
}
toolbar={<Button onClick={() => void query.retry()}></Button>}
resultCount={
query.data ? `${query.data.length}개 항목` : "결과 확인 중"
}
>
<AsyncSurface
state={query.state}
onAction={failureAction}
onRetry={query.retry}
>
<ul aria-label="Reference resources">
{(query.data ?? []).map((resource) => (
<li key={resource.resourceId}>
<Link
to={`/examples/reference-resources/${encodeURIComponent(resource.resourceId)}`}
>
{resource.title}
</Link>
</li>
))}
</ul>
</AsyncSurface>
</CollectionPage>
);
}
@@ -1,25 +0,0 @@
import { useNavigate } from "react-router-dom";
import { StatusPage } from "../../../presentation/design-system/index.ts";
export default function ReferenceResourceStatusPage() {
const navigate = useNavigate();
return (
<StatusPage
variant="maintenance"
heading={{
eyebrow: "StatusPage · maintenance",
title: "잠시 사용할 수 없습니다.",
description:
"도메인 데이터나 raw 오류를 노출하지 않는 중립적인 상태 페이지 예시입니다.",
}}
primaryAction={{
kind: "button",
label: "목록으로 이동",
onAction: () => navigate("/examples/reference-resources"),
}}
supportReference="REFERENCE-STATUS-DEMO"
/>
);
}
@@ -1,50 +0,0 @@
import { useCallback } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import type { AppFailure } from "../../../contracts/errors.ts";
import { useSession } from "../../../presentation/providers/session-provider.tsx";
const REFERENCE_SUPPORT_ROUTE = "/examples/reference-resources/status";
/**
* Reference queries own concrete destinations for generic application failure
* actions. Retry remains query-owned; guarded release reloads remain in the
* chunk recovery boundary.
*/
export function useReferenceFailureAction(
failure: AppFailure | undefined,
): (() => void) | undefined {
const location = useLocation();
const navigate = useNavigate();
const { beginSignIn } = useSession();
const action = failure?.action;
const handleAction = useCallback(() => {
if (action === "reauth") {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
void beginSignIn(returnTo).catch(() => {
void navigate("/");
});
return;
}
if (action === "navigate") {
void navigate("/");
return;
}
if (action === "contact-support") {
void navigate(REFERENCE_SUPPORT_ROUTE);
}
}, [
action,
beginSignIn,
location.hash,
location.pathname,
location.search,
navigate,
]);
return action === "reauth" ||
action === "navigate" ||
action === "contact-support"
? handleAction
: undefined;
}
@@ -1,138 +0,0 @@
import { useApplication } from "../../../presentation/providers/application-provider.tsx";
import {
useApplicationMutation,
useApplicationQuery,
} from "../../../presentation/adapters/query/application-query.ts";
import { useRouteInput } from "../../../presentation/routes/route-input.tsx";
import {
REFERENCE_FEATURE_ID,
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
REFERENCE_RESOURCE_QUERY_NAMESPACE,
} from "../contracts/reference-feature-contract.ts";
import type {
ReferenceCreateCommand,
ReferenceFeatureInput,
ReferenceListFilters,
} from "../application/reference-feature-api.ts";
import type { ReferenceResourceView } from "../contracts/reference-mapper.ts";
import {
bindQuery,
type BoundMutation,
type QueryResultMeasure,
} from "../../../contracts/server-state.ts";
import { useServerStateScope } from "../../../presentation/adapters/query/server-state-scope-provider.tsx";
const UTF8 = new TextEncoder();
/**
* §10.4. Feature-owned measurement over the mapped application value. There is
* no generic fallback: bounded string bytes plus fixed primitive width plus a
* small per-item overhead, never `JSON.stringify` or a recursive walker.
*/
function measureResourceView(view: ReferenceResourceView): QueryResultMeasure {
return {
itemCount: 1,
estimatedBytes:
UTF8.encode(view.resourceId).byteLength +
UTF8.encode(view.title).byteLength +
UTF8.encode(view.createdAt ?? "").byteLength +
32,
};
}
function measureResourceList(
views: readonly ReferenceResourceView[],
): QueryResultMeasure {
let estimatedBytes = 16;
for (const view of views) {
estimatedBytes += measureResourceView(view).estimatedBytes;
}
return { itemCount: views.length, estimatedBytes };
}
export function useReferenceFeatureInput(): ReferenceFeatureInput {
return useApplication().features.get(REFERENCE_FEATURE_ID);
}
export function useReferenceDetail(resourceId: string) {
const input = useReferenceFeatureInput();
const scope = useServerStateScope();
const query = useApplicationQuery(
bindQuery(
{
definitionId: "reference-resource-detail-v1",
definitionVersion: 1,
owner: REFERENCE_FEATURE_ID,
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
namespaceVersion:
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
operationId: "GET_REFERENCE_RESOURCE",
profileId: "DETAIL_STANDARD",
measureResult: measureResourceView,
execute: (selectedResourceId: string, { signal }) =>
input.getResource(selectedResourceId, { signal }),
},
resourceId,
scope,
),
);
return Object.freeze({ query });
}
export function useReferenceCreate() {
const input = useReferenceFeatureInput();
const scope = useServerStateScope();
const mutation: BoundMutation<
ReferenceCreateCommand,
ReferenceResourceView
> = {
definitionId: "reference-resource-create-v1",
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
requiresIdempotencyKey: true,
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope,
execute: input.createResource,
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
};
return useApplicationMutation(mutation);
}
export function useReferenceFeature() {
const input = useReferenceFeatureInput();
const scope = useServerStateScope();
const routeInput = useRouteInput();
const filters = routeInput.search as ReferenceListFilters;
const query = useApplicationQuery(
bindQuery(
{
definitionId: "reference-resource-list-v1",
definitionVersion: 1,
owner: REFERENCE_FEATURE_ID,
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
namespaceVersion:
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
operationId: "LIST_REFERENCE_RESOURCES",
profileId: "LIST_STANDARD",
measureResult: measureResourceList,
execute: (selectedFilters: ReferenceListFilters, { signal }) =>
input.listResources(selectedFilters, { signal }),
},
filters,
scope,
),
);
const mutation = useApplicationMutation({
definitionId: "reference-resource-create-v1",
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
requiresIdempotencyKey: true,
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope,
execute: input.createResource,
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
});
return Object.freeze({ filters, query, mutation });
}
@@ -6,6 +6,7 @@ import type {
RouteDefinition,
RouteLayoutGroup,
} from "../../../contracts/routes.ts";
import type { SchemaDefinition } from "../../../contracts/schema-registry.ts";
type RouteSpec = Readonly<{
routeId: string;
@@ -52,6 +53,79 @@ const TECH_LOG_ROUTE_SPECS = [
export type TechLogRouteId = (typeof TECH_LOG_ROUTE_SPECS)[number]["routeId"];
const routeSchema = (
schemaId: string,
boundary: "route-params" | "route-search",
unknownFieldPolicy: "REJECT_UNKNOWN" | "STRIP_UNKNOWN",
): SchemaDefinition =>
Object.freeze({
schemaId,
boundary,
owner: "feature-tech-log",
runtime: "zod",
schemaVersion: 1,
direction: "REQUEST",
unknownFieldPolicy,
});
export const TECH_LOG_ROUTE_SCHEMA_REGISTRY = Object.freeze({
TechLogExploreKindParams: routeSchema(
"TechLogExploreKindParams",
"route-params",
"REJECT_UNKNOWN",
),
TechLogSlugParams: routeSchema(
"TechLogSlugParams",
"route-params",
"REJECT_UNKNOWN",
),
TechLogVersionParams: routeSchema(
"TechLogVersionParams",
"route-params",
"REJECT_UNKNOWN",
),
TechLogDocumentIdParams: routeSchema(
"TechLogDocumentIdParams",
"route-params",
"REJECT_UNKNOWN",
),
TechLogPublicationEventIdParams: routeSchema(
"TechLogPublicationEventIdParams",
"route-params",
"REJECT_UNKNOWN",
),
TechLogStudioSplat: routeSchema(
"TechLogStudioSplat",
"route-params",
"REJECT_UNKNOWN",
),
TechLogHomeSearch: routeSchema(
"TechLogHomeSearch",
"route-search",
"STRIP_UNKNOWN",
),
TechLogExploreSearch: routeSchema(
"TechLogExploreSearch",
"route-search",
"STRIP_UNKNOWN",
),
TechLogExploreKindSearch: routeSchema(
"TechLogExploreKindSearch",
"route-search",
"STRIP_UNKNOWN",
),
TechLogSearchQuery: routeSchema(
"TechLogSearchQuery",
"route-search",
"STRIP_UNKNOWN",
),
TechLogCaseStateSearch: routeSchema(
"TechLogCaseStateSearch",
"route-search",
"STRIP_UNKNOWN",
),
});
function chunkId(routeId: TechLogRouteId): string {
return routeId === "NOT_FOUND"
? "route-not-found"
@@ -92,4 +166,5 @@ export const TECH_LOG_ROUTE_RUNTIME_CONTRACT = Object.freeze(
export const TECH_LOG_ROUTE_CONTRACT = Object.freeze({
routes: TECH_LOG_ROUTE_REGISTRY,
routeRuntimeContracts: TECH_LOG_ROUTE_RUNTIME_CONTRACT,
schemas: TECH_LOG_ROUTE_SCHEMA_REGISTRY,
});
@@ -0,0 +1,14 @@
import type {
WorkingCopy,
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import type { StudioEditorStatus } from "../use-studio.ts";
export type DocumentEditorController = {
saved: WorkingCopy;
draft: WorkingCopyInput;
status: StudioEditorStatus;
update(patch: Partial<WorkingCopyInput>): void;
replace(draft: WorkingCopyInput): void;
save(): Promise<void>;
};
@@ -7,7 +7,8 @@ import type {
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { DocumentEditor } from "./document-editor.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
@@ -1,11 +1,7 @@
import { useRef, useState, type KeyboardEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type {
WorkingCopy,
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import type { StudioEditorStatus } from "../use-studio.ts";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { CaseFields } from "./case-fields.tsx";
import { CommonDocumentFields } from "./common-document-fields.tsx";
import { DocumentStatusRail } from "./document-status-rail.tsx";
@@ -15,15 +11,6 @@ import { ReferenceFields } from "./reference-fields.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
export type DocumentEditorController = {
saved: WorkingCopy;
draft: WorkingCopyInput;
status: StudioEditorStatus;
update(patch: Partial<WorkingCopyInput>): void;
replace(draft: WorkingCopyInput): void;
save(): Promise<void>;
};
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
const editTab = useRef<HTMLButtonElement>(null);
@@ -1,4 +1,4 @@
import type { DocumentEditorController } from "./document-editor.tsx";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
const labels = {
@@ -0,0 +1,176 @@
/* eslint-disable react-hooks/set-state-in-effect -- a changed event or retry intentionally enters a fresh loading state. */
import { useEffect, useState } from "react";
import {
isStudioGatewayError,
type StudioGatewayError,
} from "../../../application/ports/studio-gateway-error.ts";
import type { PublicationSnapshot } from "../../../contracts/studio/contract.ts";
import type { EvidenceAsset } from "../../../domain/public-render-content.ts";
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import {
defaultPublicationFlowClasses,
type PublicationFlowClasses,
} from "./publication-flow-classes.ts";
import { useStudio } from "../use-studio.ts";
const eventLabels = {
PUBLISHED: "게시",
REPUBLISHED: "재게시",
UNPUBLISHED: "게시 취소",
} as const;
function resolveEvidenceAsset(key: string): EvidenceAsset {
if (key !== "fetch-strategy-boundary") {
throw new Error(`Unknown local evidence asset: ${key}`);
}
return {
src: "/media/fetch-strategy-boundary.svg",
width: 1080,
height: 420,
triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대",
};
}
function dateTime(value: string) {
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "long",
timeStyle: "short",
timeZone: "Asia/Seoul",
}).format(new Date(value));
}
export function PublicationEventPreviewScreen({
publicationEventId,
classes: styles = defaultPublicationFlowClasses,
}: {
publicationEventId: string;
classes?: PublicationFlowClasses;
}) {
const { gateway } = useStudio();
const [retry, setRetry] = useState(0);
const [state, setState] = useState<
| { status: "LOADING" }
| { status: "ERROR"; problem: StudioGatewayError | Error }
| { status: "READY"; snapshot: PublicationSnapshot }
>({ status: "LOADING" });
useEffect(() => {
const controller = new AbortController();
let current = true;
setState({ status: "LOADING" });
void gateway
.getPublicationSnapshot(publicationEventId, {
signal: controller.signal,
})
.then(
(snapshot) => {
if (current) setState({ status: "READY", snapshot });
},
(error: unknown) => {
if (!current || controller.signal.aborted) return;
setState({
status: "ERROR",
problem:
error instanceof Error
? error
: new Error("Snapshot을 불러오지 못했습니다."),
});
},
);
return () => {
current = false;
controller.abort();
};
}, [gateway, publicationEventId, retry]);
if (state.status === "LOADING") {
return (
<p className={styles.loading} role="status">
Snapshot을 .
</p>
);
}
if (state.status === "ERROR") {
const missing =
isStudioGatewayError(state.problem) &&
(state.problem.code === "PUBLICATION_EVENT_NOT_FOUND" ||
state.problem.code === "PUBLICATION_SNAPSHOT_NOT_FOUND");
if (missing) {
return (
<section className={styles.routeState}>
<p className={styles.eyebrow}>NOT FOUND</p>
<h1> </h1>
<p>
Studio Snapshot을
.
</p>
<GuardedStudioLink href="/studio/publications">
</GuardedStudioLink>
</section>
);
}
return (
<section className={styles.routeState} role="alert">
<p className={styles.eyebrow}>SNAPSHOT ERROR</p>
<h1> Snapshot을 </h1>
<p>
{isStudioGatewayError(state.problem)
? state.problem.problem.detail
: state.problem.message}
</p>
<button type="button" onClick={() => setRetry((value) => value + 1)}>
</button>
</section>
);
}
const { event, renderModel } = state.snapshot;
return (
<div className={styles.snapshotPage}>
<section
className={styles.snapshotToolbar}
aria-label="게시 Snapshot 정보"
>
<div>
<p className={styles.eyebrow}>IMMUTABLE SNAPSHOT</p>
<p className={styles.snapshotTitle}> </p>
</div>
<dl>
<div>
<dt></dt>
<dd>{eventLabels[event.type]}</dd>
</div>
<div>
<dt> </dt>
<dd>v{event.publishedVersion}</dd>
</div>
<div>
<dt> </dt>
<dd>
<time dateTime={event.occurredAt}>
{dateTime(event.occurredAt)}
</time>
</dd>
</div>
</dl>
<GuardedStudioLink href="/studio/publications">
</GuardedStudioLink>
</section>
<div className={styles.snapshotDocument}>
<PublicRecordRenderer
model={renderModel}
embedded
resolveEvidenceAsset={resolveEvidenceAsset}
resolvePublishedLabel={() => undefined}
/>
</div>
</div>
);
}
@@ -0,0 +1,289 @@
/* eslint-disable react-hooks/set-state-in-effect -- a changed history query intentionally enters a fresh loading state. */
import { useEffect, useRef, useState, type FormEvent } from "react";
import {
isStudioGatewayError,
type StudioGatewayError,
} from "../../../application/ports/studio-gateway-error.ts";
import type { ListPublicationsQuery } from "../../../application/ports/studio-gateway.ts";
import type {
PublicationListItem,
PublicationPage,
} from "../../../contracts/studio/contract.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import {
defaultPublicationFlowClasses,
type PublicationFlowClasses,
} from "./publication-flow-classes.ts";
import { UnpublishDialog } from "./unpublish-dialog.tsx";
import { useStudio } from "../use-studio.ts";
const eventLabels = {
PUBLISHED: "게시",
REPUBLISHED: "재게시",
UNPUBLISHED: "게시 취소",
} as const;
const kindLabels = {
CASE: "Case",
REFERENCE: "Reference",
QUESTION: "Question",
} as const;
function dateTime(value: string) {
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Seoul",
}).format(new Date(value));
}
function snapshotHref(item: PublicationListItem) {
if (
item.availableActions.includes("VIEW_SOURCE_SNAPSHOT") &&
item.event.sourcePublishedEventId
) {
return `/studio/publications/${item.event.sourcePublishedEventId}/preview`;
}
if (item.availableActions.includes("VIEW_SNAPSHOT")) {
return `/studio/publications/${item.event.publicationEventId}/preview`;
}
return null;
}
export function PublicationList({
classes: styles = defaultPublicationFlowClasses,
}: { classes?: PublicationFlowClasses } = {}) {
const studio = useStudio();
const [query, setQuery] = useState<ListPublicationsQuery>({ limit: 100 });
const [queryDraft, setQueryDraft] = useState("");
const [typeDraft, setTypeDraft] =
useState<ListPublicationsQuery["type"]>();
const [reload, setReload] = useState(0);
const [state, setState] = useState<
| { status: "LOADING" }
| { status: "ERROR"; problem: StudioGatewayError | Error }
| { status: "READY"; page: PublicationPage }
>({ status: "LOADING" });
const [selected, setSelected] = useState<PublicationListItem | null>(null);
const [pending, setPending] = useState(false);
const [dialogError, setDialogError] = useState("");
const [announcement, setAnnouncement] = useState("");
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const controller = new AbortController();
let current = true;
setState({ status: "LOADING" });
void studio.gateway.listPublications(query, {
signal: controller.signal,
}).then(
(page) => {
if (current) setState({ status: "READY", page });
},
(error: unknown) => {
if (!current || controller.signal.aborted) return;
setState({
status: "ERROR",
problem:
error instanceof Error
? error
: new Error("게시 기록을 불러오지 못했습니다."),
});
},
);
return () => {
current = false;
controller.abort();
};
}, [query, reload, studio.gateway]);
const applyFilter = (event: FormEvent) => {
event.preventDefault();
setQuery({
...(queryDraft.trim() ? { q: queryDraft.trim().slice(0, 100) } : {}),
...(typeDraft ? { type: typeDraft } : {}),
limit: 100,
});
};
const dismiss = () => {
setSelected(null);
setDialogError("");
queueMicrotask(() => triggerRef.current?.focus());
};
const confirmUnpublish = async () => {
if (
!selected ||
pending ||
!selected.availableActions.includes("UNPUBLISH")
) {
return;
}
setPending(true);
setDialogError("");
try {
const result = await studio.gateway.unpublishPublication(
selected.publication.publicationId,
{
expectedPublicationRevision:
selected.publication.publicationRevision,
},
{ idempotencyKey: createLocalId("studio-unpublish") },
);
setSelected(null);
setAnnouncement("게시를 취소했습니다.");
studio.setRequestAnnouncement("게시를 취소했습니다.");
setReload((value) => value + 1);
} catch (error) {
setDialogError(
isStudioGatewayError(error)
? error.problem.detail
: "게시를 취소하지 못했습니다.",
);
} finally {
setPending(false);
}
};
return (
<div className={styles.page}>
<header className={styles.heading}>
<p className={styles.eyebrow}>PUBLICATION EVENTS</p>
<h1> </h1>
<p>
·· Snapshot을 .
</p>
</header>
<form className={styles.filters} onSubmit={applyFilter}>
<label>
<span></span>
<input
value={queryDraft}
maxLength={100}
onChange={(event) => setQueryDraft(event.currentTarget.value)}
placeholder="제목 또는 요약"
/>
</label>
<label>
<span></span>
<select
value={typeDraft ?? ""}
onChange={(event) =>
setTypeDraft(
(event.currentTarget.value || undefined) as ListPublicationsQuery["type"],
)
}
>
<option value=""></option>
<option value="PUBLISHED"></option>
<option value="REPUBLISHED"></option>
<option value="UNPUBLISHED"> </option>
</select>
</label>
<button type="submit"></button>
</form>
{announcement ? (
<p className={styles.success} role="status">
{announcement}
</p>
) : null}
{state.status === "LOADING" ? (
<p className={styles.loading} role="status">
.
</p>
) : null}
{state.status === "ERROR" ? (
<section className={styles.errorState} role="alert">
<h2> </h2>
<p>
{isStudioGatewayError(state.problem)
? state.problem.problem.detail
: state.problem.message}
</p>
<button type="button" onClick={() => setReload((value) => value + 1)}>
</button>
</section>
) : null}
{state.status === "READY" && state.page.items.length === 0 ? (
<section className={styles.emptyState}>
<h2> </h2>
<p> .</p>
</section>
) : null}
{state.status === "READY" && state.page.items.length > 0 ? (
<ol className={styles.historyList}>
{state.page.items.map((item) => {
const href = snapshotHref(item);
const source = item.availableActions.includes(
"VIEW_SOURCE_SNAPSHOT",
);
const canUnpublish = item.availableActions.includes("UNPUBLISH");
return (
<li key={item.event.publicationEventId}>
<article className={styles.historyRow}>
<p className={styles.eventType}>
{eventLabels[item.event.type]}
</p>
<div className={styles.eventMain}>
<p className={styles.kind}>
{kindLabels[item.document.kind]} · v
{item.event.publishedVersion}
</p>
<h2>{item.document.title || "제목 없는 작업본"}</h2>
<p>
: {eventLabels[item.event.type]} · :{" "}
{item.publication.status === "PUBLISHED"
? "게시 중"
: "게시 취소"}
</p>
</div>
<time dateTime={item.event.occurredAt}>
{dateTime(item.event.occurredAt)}
</time>
<div className={styles.rowActions}>
{href ? (
<GuardedStudioLink href={href}>
{source ? "게시 취소 전 Snapshot 보기" : "Snapshot 보기"}
</GuardedStudioLink>
) : null}
{canUnpublish ? (
<button
type="button"
aria-label={`${item.document.title || "제목 없는 작업본"} 게시 취소`}
onClick={(event) => {
triggerRef.current = event.currentTarget;
setDialogError("");
setSelected(item);
}}
>
</button>
) : null}
</div>
</article>
</li>
);
})}
</ol>
) : null}
<UnpublishDialog
item={selected}
pending={pending}
error={dialogError}
onDismiss={dismiss}
onConfirm={() => {
void confirmUnpublish();
}}
classes={styles}
/>
</div>
);
}
@@ -0,0 +1,403 @@
/* eslint-disable react-hooks/set-state-in-effect -- a changed document or retry intentionally enters a fresh loading state. */
import { useEffect, useMemo, useState } from "react";
import {
isStudioGatewayError,
type StudioGatewayError,
} from "../../../application/ports/studio-gateway-error.ts";
import type {
PreviewDetail,
WorkingCopyDetail,
} from "../../../contracts/studio/contract.ts";
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import {
defaultPublicationFlowClasses,
type PublicationFlowClasses,
} from "./publication-flow-classes.ts";
import { useStudio } from "../use-studio.ts";
import { WarningAcknowledgements } from "./warning-acknowledgements.tsx";
type LoadState =
| { status: "LOADING" }
| { status: "ERROR"; problem: StudioGatewayError | Error }
| { status: "READY"; detail: WorkingCopyDetail; preview: PreviewDetail | null };
function dateTime(value: string) {
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Seoul",
}).format(new Date(value));
}
function notFound(problem: StudioGatewayError | Error) {
return isStudioGatewayError(problem) && problem.code === "DOCUMENT_NOT_FOUND";
}
export function PublishScreen({
documentId,
classes: styles = defaultPublicationFlowClasses,
}: {
documentId: string;
classes?: PublicationFlowClasses;
}) {
const studio = useStudio();
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<LoadState>({ status: "LOADING" });
const [acknowledged, setAcknowledged] = useState<Set<string>>(
() => new Set(),
);
const [pending, setPending] = useState(false);
const [actionError, setActionError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
const controller = new AbortController();
let current = true;
setState({ status: "LOADING" });
setAcknowledged(new Set());
setActionError("");
setSuccess("");
void (async () => {
const detail = await studio.gateway.getDocument(documentId, {
signal: controller.signal,
});
const sameVersion =
detail.currentPublication?.status === "PUBLISHED" &&
detail.currentPublication.publishedVersion === detail.document.version;
let preview: PreviewDetail | null = null;
if (!sameVersion && detail.latestPreview) {
try {
preview = await studio.gateway.getCurrentPreview(documentId, {
signal: controller.signal,
});
} catch (error) {
if (
!(
isStudioGatewayError(error) &&
error.code === "PREVIEW_NOT_FOUND"
)
) {
throw error;
}
}
}
if (current) setState({ status: "READY", detail, preview });
})().catch((error: unknown) => {
if (!current || controller.signal.aborted) return;
setState({
status: "ERROR",
problem:
error instanceof Error
? error
: new Error("게시 정보를 불러오지 못했습니다."),
});
});
return () => {
current = false;
controller.abort();
};
}, [attempt, documentId, studio.gateway]);
const ready = state.status === "READY" ? state : null;
const validation = ready?.detail.currentValidation ?? null;
const warnings = useMemo(
() =>
validation?.issues.filter((issue) => issue.severity === "WARNING") ?? [],
[validation],
);
if (state.status === "LOADING") {
return (
<p className={styles.loading} role="status">
.
</p>
);
}
if (state.status === "ERROR" && notFound(state.problem)) {
return (
<section className={styles.routeState}>
<p className={styles.eyebrow}>NOT FOUND</p>
<h1> </h1>
<p> Studio .</p>
<GuardedStudioLink href="/studio/documents">
</GuardedStudioLink>
</section>
);
}
if (state.status === "ERROR") {
return (
<section className={styles.routeState} role="alert">
<p className={styles.eyebrow}>PUBLISH ERROR</p>
<h1> </h1>
<p>
{isStudioGatewayError(state.problem)
? state.problem.problem.detail
: state.problem.message}
</p>
<button type="button" onClick={() => setAttempt((value) => value + 1)}>
</button>
</section>
);
}
const { detail, preview } = state;
const { document, currentPublication } = detail;
const sameVersion =
currentPublication?.status === "PUBLISHED" &&
currentPublication.publishedVersion === document.version;
const validationMatches =
validation !== null &&
deriveValidationState({ ...detail, now: studio.now() }).freshness ===
"CURRENT";
const validationAllowsPreview =
validationMatches && validation?.status !== "INVALID";
const previewIsCurrent =
preview?.state === "CURRENT" &&
preview.preview.previewVersion === document.version &&
preview.currentValidationId === validation?.validationId;
const warningsDone = warnings.every((warning) =>
acknowledged.has(warning.code),
);
const canPublish =
validationAllowsPreview && previewIsCurrent && warningsDone && !pending;
const actionLabel = currentPublication ? "다시 게시" : "게시";
const toggleWarning = (code: string, checked: boolean) => {
setAcknowledged((current) => {
const next = new Set(current);
if (checked) next.add(code);
else next.delete(code);
return next;
});
};
const publish = async () => {
if (!canPublish || !validation || !preview) return;
setPending(true);
setActionError("");
setSuccess("");
try {
const result = await studio.gateway.publishDocument(
document.id,
{
expectedVersion: document.version,
validationId: validation.validationId,
previewId: preview.preview.previewId,
acknowledgedWarningCodes: [...acknowledged].sort(),
},
{ idempotencyKey: createLocalId("studio-publish") },
);
setSuccess("게시했습니다.");
studio.setRequestAnnouncement("게시했습니다.");
studio.clearEditor();
studio.navigateInternal(
`/studio/publications/${result.event.publicationEventId}/preview`,
);
} catch (error) {
setActionError(
isStudioGatewayError(error)
? error.problem.detail
: "게시하지 못했습니다. 다시 시도해 주세요.",
);
} finally {
setPending(false);
}
};
if (sameVersion && currentPublication) {
return (
<div className={styles.page}>
<header className={styles.heading}>
<p className={styles.eyebrow}>PUBLICATION</p>
<h1> </h1>
<p>{document.title}</p>
</header>
<section
className={styles.gateReady}
aria-labelledby="same-version-title"
>
<h2 id="same-version-title"> </h2>
<p>
v{document.version} .
.
</p>
<GuardedStudioLink
href={`/studio/publications/${currentPublication.latestEventId}/preview`}
>
Snapshot
</GuardedStudioLink>
</section>
</div>
);
}
let blocked: {
title: string;
detail: string;
href: string;
label: string;
} | null = null;
if (!validation) {
blocked = {
title: "저장 버전 검증이 필요합니다",
detail: "게시 전에 현재 저장 버전을 검증해 주세요.",
href: `/studio/documents/${document.id}/validation`,
label: "검증하기",
};
} else if (!validationMatches) {
blocked = {
title: "검증 결과가 현재 버전과 다릅니다",
detail: "현재 저장 버전으로 다시 검증해야 합니다.",
href: `/studio/documents/${document.id}/validation`,
label: "다시 검증",
};
} else if (validation.status === "INVALID") {
blocked = {
title: "검증 오류를 먼저 수정해야 합니다",
detail: `${validation.issues.filter((issue) => issue.severity === "ERROR").length}개의 오류가 게시를 막고 있습니다.`,
href: `/studio/documents/${document.id}/validation`,
label: "검증 오류 보기",
};
} else if (!preview) {
blocked = {
title: "Public Preview가 필요합니다",
detail: "검증된 저장 버전의 공개 레이아웃을 먼저 확인해 주세요.",
href: `/studio/documents/${document.id}/preview`,
label: "Public Preview 만들기",
};
} else if (!previewIsCurrent) {
blocked = {
title:
preview.state === "EXPIRED"
? "Public Preview가 만료되었습니다"
: "Public Preview가 오래되었습니다",
detail: "현재 검증과 저장 버전으로 Preview를 다시 만들어야 합니다.",
href: `/studio/documents/${document.id}/preview`,
label: "Public Preview 다시 만들기",
};
}
return (
<div className={styles.page}>
<header className={styles.heading}>
<p className={styles.eyebrow}>PUBLICATION</p>
<h1> </h1>
<p>{document.title || "제목 없는 작업본"}</p>
</header>
<section className={styles.summary} aria-label="게시 준비 요약">
<dl>
<div>
<dt> </dt>
<dd>v{document.version}</dd>
</div>
<div>
<dt></dt>
<dd>
{validation
? `${validation.status} · v${validation.validatedVersion}`
: "실행 전"}
</dd>
</div>
<div>
<dt>Public Preview</dt>
<dd>
{preview
? `${preview.state} · v${preview.preview.previewVersion}`
: "없음"}
</dd>
</div>
<div>
<dt> </dt>
<dd>
{currentPublication
? `${currentPublication.status} · v${currentPublication.publishedVersion}`
: "게시 전"}
</dd>
</div>
</dl>
</section>
<section
className={styles.changeSummary}
aria-labelledby="change-summary-title"
>
<p className={styles.eyebrow}>CHANGE SUMMARY</p>
<h2 id="change-summary-title"> </h2>
<p>
{currentPublication
? `게시 버전 v${currentPublication.publishedVersion}에서 저장 버전 v${document.version}으로 반영합니다.`
: `저장 버전 v${document.version}을 처음 게시합니다.`}
</p>
{preview ? (
<p>
Preview :{" "}
<time dateTime={preview.preview.expiresAt}>
{dateTime(preview.preview.expiresAt)}
</time>
</p>
) : null}
</section>
{blocked ? (
<section
className={styles.blocked}
aria-labelledby="publish-blocked-title"
>
<h2 id="publish-blocked-title">{blocked.title}</h2>
<p>{blocked.detail}</p>
<GuardedStudioLink href={blocked.href}>
{blocked.label}
</GuardedStudioLink>
</section>
) : (
<section
className={styles.publishAction}
aria-labelledby="publish-action-title"
>
<h2 id="publish-action-title">{actionLabel}</h2>
<WarningAcknowledgements
warnings={warnings}
acknowledged={acknowledged}
onToggle={toggleWarning}
classes={styles}
/>
{actionError ? (
<p className={styles.error} role="alert">
{actionError}
</p>
) : null}
{success ? (
<p className={styles.success} role="status">
{success}
</p>
) : null}
<div className={styles.actions}>
<GuardedStudioLink
href={`/studio/documents/${document.id}/preview`}
>
Preview로
</GuardedStudioLink>
<button
type="button"
disabled={!canPublish}
onClick={() => {
void publish();
}}
>
{pending ? "게시 중…" : actionLabel}
</button>
</div>
</section>
)}
</div>
);
}
@@ -0,0 +1,112 @@
import { useEffect, useId, useRef, type KeyboardEvent } from "react";
import type { PublicationListItem } from "../../../contracts/studio/contract.ts";
import {
defaultPublicationFlowClasses,
type PublicationFlowClasses,
} from "./publication-flow-classes.ts";
export function UnpublishDialog({
item,
pending,
error,
onDismiss,
onConfirm,
classes: styles = defaultPublicationFlowClasses,
}: {
item: PublicationListItem | null;
pending: boolean;
error: string;
onDismiss(): void;
onConfirm(): void;
classes?: PublicationFlowClasses;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
const descriptionId = useId();
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (item && !dialog.open) {
dialog.showModal();
cancelRef.current?.focus();
} else if (!item && dialog.open) {
dialog.close();
}
}, [item]);
useEffect(
() => () => {
if (dialogRef.current?.open) dialogRef.current.close();
},
[],
);
const trapFocus = (event: KeyboardEvent<HTMLDialogElement>) => {
if (event.key !== "Tab") return;
const controls = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(
"button:not([disabled])",
),
);
if (!controls.length) return;
const first = controls[0];
const last = controls.at(-1)!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
return (
<dialog
ref={dialogRef}
className={styles.dialog}
aria-labelledby={titleId}
aria-describedby={descriptionId}
onCancel={(event) => {
event.preventDefault();
if (!pending) onDismiss();
}}
onKeyDown={trapFocus}
>
<div className={styles.dialogBody}>
<p className={styles.eyebrow}>UNPUBLISH</p>
<h2 id={titleId}> ?</h2>
<p id={descriptionId}>
{item?.document.title ?? "선택한 기록"} Studio
.
</p>
<div className={styles.preservedNote}>
<strong> Snapshot은 .</strong>
<p>
Mock에서는 Public .
</p>
</div>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
<div className={styles.dialogActions}>
<button
ref={cancelRef}
type="button"
disabled={pending}
onClick={onDismiss}
>
</button>
<button type="button" disabled={pending} onClick={onConfirm}>
{pending ? "취소 처리 중…" : "게시 취소 확인"}
</button>
</div>
</div>
</dialog>
);
}
@@ -39,7 +39,7 @@ export function ValidationReport({
report: ValidationReportModel;
current: boolean;
}) {
const issues = report.issues.toSorted((left, right) => {
const issues = [...report.issues].sort((left, right) => {
const rank = { ERROR: 0, WARNING: 1 } as const;
return rank[left.severity] - rank[right.severity];
});
@@ -0,0 +1,64 @@
import { useId } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import {
defaultPublicationFlowClasses,
type PublicationFlowClasses,
} from "./publication-flow-classes.ts";
type ValidationIssue = components["schemas"]["ValidationIssue"];
export function WarningAcknowledgements({
warnings,
acknowledged,
onToggle,
classes: styles = defaultPublicationFlowClasses,
}: {
warnings: ValidationIssue[];
acknowledged: ReadonlySet<string>;
onToggle(code: string, checked: boolean): void;
classes?: PublicationFlowClasses;
}) {
const groupId = useId();
if (warnings.length === 0) {
return <p className={styles.noWarnings}> .</p>;
}
return (
<fieldset
className={styles.warningGroup}
aria-describedby={`${groupId}-description`}
>
<legend> </legend>
<p id={`${groupId}-description`}>
.
</p>
<div className={styles.warningList}>
{warnings.map((warning, index) => {
const inputId = `${groupId}-warning-${index}`;
return (
<label
className={styles.warningItem}
htmlFor={inputId}
key={`${warning.code}:${warning.path}`}
>
<input
id={inputId}
type="checkbox"
checked={acknowledged.has(warning.code)}
onChange={(event) =>
onToggle(warning.code, event.currentTarget.checked)
}
/>
<span>
<strong>{warning.code}</strong>
<small>{warning.message}</small>
</span>
</label>
);
})}
</div>
</fieldset>
);
}
@@ -0,0 +1,8 @@
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { PublishScreen } from "../components/publish-screen.tsx";
export function DocumentPublishPage() {
const { params } =
useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_PUBLISH">();
return <PublishScreen documentId={String(params.id)} />;
}
@@ -0,0 +1,12 @@
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { PublicationEventPreviewScreen } from "../components/publication-event-preview-screen.tsx";
export function PublicationPreviewPage() {
const { params } =
useRouteInput<"TECH_LOG_STUDIO_PUBLICATION_PREVIEW">();
return (
<PublicationEventPreviewScreen
publicationEventId={String(params.publicationEventId)}
/>
);
}
@@ -0,0 +1,5 @@
import { PublicationList } from "../components/publication-list.tsx";
export function PublicationsPage() {
return <PublicationList />;
}
@@ -0,0 +1,234 @@
import {
lazy,
type ComponentType,
type LazyExoticComponent,
type ReactNode,
} from "react";
import { Outlet } from "react-router-dom";
import {
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
type TechLogRouteId,
} from "../contracts/tech-log-route-contract.ts";
import { PublicShell } from "./public/public-shell.tsx";
import { StudioShell } from "./studio/studio-shell.tsx";
type RouteRuntime = Readonly<{
moduleId: string;
Component: LazyExoticComponent<ComponentType>;
}>;
function runtime(
routeId: TechLogRouteId,
load: () => Promise<{ default: ComponentType }>,
): RouteRuntime {
return Object.freeze({
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
Component: lazy(load),
});
}
function routeModule<Module, Key extends keyof Module>(
load: () => Promise<Module>,
key: Key,
): () => Promise<{ default: Extract<Module[Key], ComponentType> }> {
return async () => {
const module = await load();
return { default: module[key] as Extract<Module[Key], ComponentType> };
};
}
export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
TECH_LOG_HOME: runtime(
"TECH_LOG_HOME",
routeModule(() => import("./public/pages/home-page.tsx"), "HomePage"),
),
TECH_LOG_EXPLORE: runtime(
"TECH_LOG_EXPLORE",
routeModule(
() => import("./public/pages/explore-page.tsx"),
"ExplorePage",
),
),
TECH_LOG_EXPLORE_KIND: runtime(
"TECH_LOG_EXPLORE_KIND",
routeModule(
() => import("./public/pages/explore-kind-page.tsx"),
"ExploreKindPage",
),
),
TECH_LOG_CASE: runtime(
"TECH_LOG_CASE",
routeModule(() => import("./public/pages/case-page.tsx"), "CasePage"),
),
TECH_LOG_REFERENCE: runtime(
"TECH_LOG_REFERENCE",
routeModule(
() => import("./public/pages/reference-page.tsx"),
"ReferencePage",
),
),
TECH_LOG_QUESTION: runtime(
"TECH_LOG_QUESTION",
routeModule(
() => import("./public/pages/question-page.tsx"),
"QuestionPage",
),
),
TECH_LOG_TOPIC: runtime(
"TECH_LOG_TOPIC",
routeModule(() => import("./public/pages/topic-page.tsx"), "TopicPage"),
),
TECH_LOG_PROJECTS: runtime(
"TECH_LOG_PROJECTS",
routeModule(
() => import("./public/pages/projects-page.tsx"),
"ProjectsPage",
),
),
TECH_LOG_PROJECT: runtime(
"TECH_LOG_PROJECT",
routeModule(
() => import("./public/pages/project-overview-page.tsx"),
"ProjectOverviewPage",
),
),
TECH_LOG_PROJECT_RECORDS: runtime(
"TECH_LOG_PROJECT_RECORDS",
routeModule(
() => import("./public/pages/project-records-page.tsx"),
"ProjectRecordsPage",
),
),
TECH_LOG_PROJECT_DECISIONS: runtime(
"TECH_LOG_PROJECT_DECISIONS",
routeModule(
() => import("./public/pages/project-decisions-page.tsx"),
"ProjectDecisionsPage",
),
),
TECH_LOG_PROJECT_ACTIVITY: runtime(
"TECH_LOG_PROJECT_ACTIVITY",
routeModule(
() => import("./public/pages/project-activity-page.tsx"),
"ProjectActivityPage",
),
),
TECH_LOG_RELEASES: runtime(
"TECH_LOG_RELEASES",
routeModule(
() => import("./public/pages/releases-page.tsx"),
"ReleasesPage",
),
),
TECH_LOG_RELEASE: runtime(
"TECH_LOG_RELEASE",
routeModule(
() => import("./public/pages/release-page.tsx"),
"ReleasePage",
),
),
TECH_LOG_PROFILE: runtime(
"TECH_LOG_PROFILE",
routeModule(
() => import("./public/pages/profile-page.tsx"),
"ProfilePage",
),
),
TECH_LOG_SEARCH: runtime(
"TECH_LOG_SEARCH",
routeModule(() => import("./public/pages/search-page.tsx"), "SearchPage"),
),
TECH_LOG_STUDIO_HOME: runtime(
"TECH_LOG_STUDIO_HOME",
routeModule(
() => import("./studio/pages/studio-home-page.tsx"),
"StudioHomePage",
),
),
TECH_LOG_STUDIO_DOCUMENTS: runtime(
"TECH_LOG_STUDIO_DOCUMENTS",
routeModule(
() => import("./studio/pages/documents-page.tsx"),
"DocumentsPage",
),
),
TECH_LOG_STUDIO_DOCUMENT_NEW: runtime(
"TECH_LOG_STUDIO_DOCUMENT_NEW",
routeModule(
() => import("./studio/pages/new-document-page.tsx"),
"NewDocumentPage",
),
),
TECH_LOG_STUDIO_DOCUMENT_EDIT: runtime(
"TECH_LOG_STUDIO_DOCUMENT_EDIT",
routeModule(
() => import("./studio/pages/document-edit-page.tsx"),
"DocumentEditPage",
),
),
TECH_LOG_STUDIO_DOCUMENT_VALIDATION: runtime(
"TECH_LOG_STUDIO_DOCUMENT_VALIDATION",
routeModule(
() => import("./studio/pages/document-validation-page.tsx"),
"DocumentValidationPage",
),
),
TECH_LOG_STUDIO_DOCUMENT_PREVIEW: runtime(
"TECH_LOG_STUDIO_DOCUMENT_PREVIEW",
routeModule(
() => import("./studio/pages/document-preview-page.tsx"),
"DocumentPreviewPage",
),
),
TECH_LOG_STUDIO_DOCUMENT_PUBLISH: runtime(
"TECH_LOG_STUDIO_DOCUMENT_PUBLISH",
routeModule(
() => import("./studio/pages/document-publish-page.tsx"),
"DocumentPublishPage",
),
),
TECH_LOG_STUDIO_PUBLICATIONS: runtime(
"TECH_LOG_STUDIO_PUBLICATIONS",
routeModule(
() => import("./studio/pages/publications-page.tsx"),
"PublicationsPage",
),
),
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: runtime(
"TECH_LOG_STUDIO_PUBLICATION_PREVIEW",
routeModule(
() => import("./studio/pages/publication-preview-page.tsx"),
"PublicationPreviewPage",
),
),
TECH_LOG_STUDIO_NOT_FOUND: runtime(
"TECH_LOG_STUDIO_NOT_FOUND",
routeModule(
() => import("./studio/pages/studio-not-found-page.tsx"),
"StudioNotFoundPage",
),
),
NOT_FOUND: runtime(
"NOT_FOUND",
routeModule(
() => import("./public/pages/public-not-found-page.tsx"),
"PublicNotFoundPage",
),
),
}) satisfies Readonly<Record<TechLogRouteId, RouteRuntime>>;
export const TECH_LOG_ROUTE_LAYOUTS: Readonly<
Record<"PUBLIC" | "STUDIO", ReactNode>
> = Object.freeze({
PUBLIC: (
<PublicShell>
<Outlet />
</PublicShell>
),
STUDIO: (
<StudioShell>
<Outlet />
</StudioShell>
),
});
@@ -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>
);
}
+1 -1
View File
@@ -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">
-87
View File
@@ -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>
);
}
-22
View File
@@ -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>
);
}
+2 -6
View File
@@ -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
View File
@@ -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({});