From c5c8b9423cfdb918fbb5f01f1bc187de256a5007 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 16 Aug 2026 00:35:11 +0900 Subject: [PATCH] feat: complete TechLog Studio publication flow --- .../task-13-report.md | 47 ++ config/contracts/registry-governance.json | 2 +- public/release-manifest.json | 37 +- scripts/test-performance.ts | 7 +- scripts/test-sample-removal.ts | 47 +- src/contracts/route-runtime-contract.ts | 43 +- src/contracts/routes.ts | 91 +--- src/features/installed-feature-contracts.ts | 14 +- src/features/installed-feature-messages.ts | 3 + src/features/installed-feature-runtimes.tsx | 10 +- .../reference-feature-runtime.tsx | 30 -- .../reference-resource-detail-page.tsx | 61 --- .../reference-resource-form-page.tsx | 158 ------ .../presentation/reference-resource-form.ts | 29 -- .../presentation/reference-resource-page.tsx | 61 --- .../reference-resource-status-page.tsx | 25 - .../use-reference-failure-action.ts | 50 -- .../presentation/use-reference-feature.ts | 138 ----- .../contracts/tech-log-route-contract.ts | 75 +++ .../components/document-editor-controller.ts | 14 + .../components/document-editor-screen.tsx | 3 +- .../studio/components/document-editor.tsx | 15 +- .../components/document-status-rail.tsx | 2 +- .../publication-event-preview-screen.tsx | 176 +++++++ .../studio/components/publication-list.tsx | 289 +++++++++++ .../studio/components/publish-screen.tsx | 403 ++++++++++++++ .../studio/components/unpublish-dialog.tsx | 112 ++++ .../studio/components/validation-report.tsx | 2 +- .../components/warning-acknowledgements.tsx | 64 +++ .../studio/pages/document-publish-page.tsx | 8 + .../studio/pages/publication-preview-page.tsx | 12 + .../studio/pages/publications-page.tsx | 5 + .../presentation/tech-log-route-runtime.tsx | 234 +++++++++ .../examples/auth-example-page.tsx | 79 --- .../examples/platform-overview-page.tsx | 484 ----------------- .../examples/state-gallery-page.tsx | 96 ---- src/presentation/examples/ui-gallery-page.tsx | 318 ------------ src/presentation/layouts/app-shell.tsx | 2 +- src/presentation/pages/home-page.tsx | 87 ---- src/presentation/pages/not-found-page.tsx | 22 - src/presentation/routes/app-router.tsx | 8 +- .../routes/platform-route-codecs.ts | 4 +- src/presentation/routes/route-runtime.tsx | 48 +- .../component/platform-overview-page.test.tsx | 164 ------ tests/component/router.test.tsx | 101 ++-- tests/component/runtime-application.test.tsx | 38 +- tests/e2e/platform-overview.spec.ts | 58 --- tests/e2e/reference-form.spec.ts | 50 -- tests/e2e/reference-route.spec.ts | 32 -- tests/e2e/tech-log-studio-workflow.spec.ts | 71 +++ tests/e2e/ui-gallery.spec.ts | 34 -- .../reference-contract.test.ts | 40 +- .../reference-feature/reference-page.test.tsx | 490 ------------------ .../reference-production-vertical.test.tsx | 121 ----- .../public-discovery-screens.test.tsx | 2 +- .../tech-log/public-document-screens.test.tsx | 2 +- .../features/tech-log/route-contract.test.ts | 10 +- .../tech-log/studio-publication-flow.test.tsx | 302 +++++++++++ tests/unit/navigation-policy.test.ts | 2 +- vite.config.ts | 44 ++ 60 files changed, 2028 insertions(+), 2948 deletions(-) create mode 100644 .superpowers/sdd/2026-08-15-techlog-ui-migration/task-13-report.md delete mode 100644 src/features/reference-feature/presentation/reference-feature-runtime.tsx delete mode 100644 src/features/reference-feature/presentation/reference-resource-detail-page.tsx delete mode 100644 src/features/reference-feature/presentation/reference-resource-form-page.tsx delete mode 100644 src/features/reference-feature/presentation/reference-resource-form.ts delete mode 100644 src/features/reference-feature/presentation/reference-resource-page.tsx delete mode 100644 src/features/reference-feature/presentation/reference-resource-status-page.tsx delete mode 100644 src/features/reference-feature/presentation/use-reference-failure-action.ts delete mode 100644 src/features/reference-feature/presentation/use-reference-feature.ts create mode 100644 src/features/tech-log/presentation/studio/components/document-editor-controller.ts create mode 100644 src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx create mode 100644 src/features/tech-log/presentation/studio/components/publication-list.tsx create mode 100644 src/features/tech-log/presentation/studio/components/publish-screen.tsx create mode 100644 src/features/tech-log/presentation/studio/components/unpublish-dialog.tsx create mode 100644 src/features/tech-log/presentation/studio/components/warning-acknowledgements.tsx create mode 100644 src/features/tech-log/presentation/studio/pages/document-publish-page.tsx create mode 100644 src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx create mode 100644 src/features/tech-log/presentation/studio/pages/publications-page.tsx create mode 100644 src/features/tech-log/presentation/tech-log-route-runtime.tsx delete mode 100644 src/presentation/examples/auth-example-page.tsx delete mode 100644 src/presentation/examples/platform-overview-page.tsx delete mode 100644 src/presentation/examples/state-gallery-page.tsx delete mode 100644 src/presentation/examples/ui-gallery-page.tsx delete mode 100644 src/presentation/pages/home-page.tsx delete mode 100644 src/presentation/pages/not-found-page.tsx delete mode 100644 tests/component/platform-overview-page.test.tsx delete mode 100644 tests/e2e/platform-overview.spec.ts delete mode 100644 tests/e2e/reference-form.spec.ts delete mode 100644 tests/e2e/reference-route.spec.ts create mode 100644 tests/e2e/tech-log-studio-workflow.spec.ts delete mode 100644 tests/e2e/ui-gallery.spec.ts delete mode 100644 tests/features/reference-feature/reference-page.test.tsx delete mode 100644 tests/features/reference-feature/reference-production-vertical.test.tsx create mode 100644 tests/features/tech-log/studio-publication-flow.test.tsx diff --git a/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-13-report.md b/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-13-report.md new file mode 100644 index 0000000..276194d --- /dev/null +++ b/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-13-report.md @@ -0,0 +1,47 @@ +# Task 13 report: publication flow and atomic install + +## Status + +`DONE_WITH_CONCERNS` + +Base SHA: `9c6906fc6f76115346360d050dd8c211fee1b5a9` + +Delivery commit: the commit containing this report, with subject `feat: complete TechLog Studio publication flow`. + +## Publication mapping + +- Added the source-faithful publish screen, warning acknowledgements, publication history/filter, unpublish dialog, immutable event snapshot preview, and their three route pages. +- Publish blocks invalid or stale validation, requires every warning acknowledgement, creates a fresh idempotency key per command/retry, preserves gateway command ordering, and exposes pending/error/retry states. +- Unpublish preserves the reason/confirmation contract. Historical preview reads the event-owned immutable snapshot and renders it through the shared `PublicRecordRenderer`; a missing/unknown event stays inside Studio. +- Added `tests/features/tech-log/studio-publication-flow.test.tsx` first. The initial red was the exact missing `publication-list.tsx` module/screen; the implemented suite is green at 7 tests. + +## Atomic install and removals + +- Installed exactly the 27 governed TechLog route definitions, codecs, runtime imports, module identities, message catalogs, schemas, and release-manifest chunk IDs. `PublicShell` and `StudioShell` are the grouped layout elements. +- Added governed Vite chunk naming for the 27 route module identities and changed the performance probe to `TECH_LOG_HOME`. +- Kept the reference contract/adapters and API/schema/invalidation/platform fixture tests, while removing its presentation runtime/pages and page-level tests. +- Removed the four sample presentation pages, starter home/not-found pages, their page-level component/E2E screens, and all four `/examples/*` E2E specs authorized by the brief. +- Added `tests/e2e/tech-log-studio-workflow.spec.ts`; it was deliberately not run and is deferred to Task 14. +- Updated the removal fixture to retain TechLog after reference removal and to exclude tests whose only contract is the removed reference runtime or the canonical (non-reduced) CI authority. +- Split `DocumentEditorController` into a type-only module to remove the editor/status-rail cycle exposed by the installed route graph. + +## Verification evidence + +- Publication + validation-preview + mock gateway: PASS, 3 files / 23 tests. +- Router + runtime application + retained reference contract: PASS, 3 files / 16 tests. +- Final route contract + navigation policy: PASS, 2 files / 10 tests. +- Registry structure: PASS, 11 registries. +- Release manifest inventory: PASS, exactly 27 derived chunk IDs. +- `git diff --check`: PASS. +- `test:sample-removal` was run once. Its isolated home smoke passed 9/9 and registry/CI reduced-contract checks passed, but its internally broad type/architecture/unit/coverage/build loop failed. Task-owned findings were fixed afterward: ES-target-incompatible `toSorted`, stale `APP_HOME`, direct adapter import, editor/status-rail cycle, reference-dependent fixture residue, canonical-CI-only tests in a reduced fixture, and missing governed build chunk names. Per fast-mode direction, that several-minute broad loop was not rerun; Task 14 must confirm the fixes through its integrated gates. + +## Files + +- Publication/UI/runtime: `src/features/tech-log/presentation/**`, including the five publication components, three pages, route runtime, and controller boundary. +- Contracts/install: `src/contracts/{routes,route-runtime-contract}.ts`, `src/features/installed-feature-*.{ts,tsx}`, TechLog route contract, platform codecs/runtime, router, layout reference, registry governance, Vite config, release manifest, performance/removal scripts. +- Tests: new publication flow and Studio workflow specs; updated route/router/runtime/reference/navigation expectations; authorized sample/reference presentation test deletions. + +## Task 14 deferred concerns + +- Run the complete integrated review/gates, including the sample-removal loop with the post-fix code, production build/manifest verification, types, lint, architecture, security, and Playwright workflow. +- Confirm the governed Vite chunk names in the generated production manifest and assess any unrelated environment/timing failures from the broad isolated fixture. diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index 9ccb27f..094fb1f 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -256,7 +256,7 @@ "consumerIdentityField": "schemaId", "consumerDirectories": [ "src/presentation/routes", - "src/features/reference-feature/presentation", + "src/features/tech-log/presentation", "src/features/reference-feature/contracts" ], "breakingFields": ["schemaId", "boundary", "runtime"] diff --git a/public/release-manifest.json b/public/release-manifest.json index bd0b8bd..dc1933e 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -8,16 +8,33 @@ "releaseId": "local-release", "builtAt": "1970-01-01T00:00:00.000Z", "routeChunks": { - "route-home": "src/presentation/pages/home-page.tsx", - "route-examples-platform": "src/presentation/examples/platform-overview-page.tsx", - "route-examples-ui": "src/presentation/examples/ui-gallery-page.tsx", - "route-examples-states": "src/presentation/examples/state-gallery-page.tsx", - "route-examples-auth": "src/presentation/examples/auth-example-page.tsx", - "route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx", - "route-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx", - "route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx", - "route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx", - "route-not-found": "src/presentation/pages/not-found-page.tsx" + "route-tech-log-home": "src/features/tech-log/presentation/public/pages/home-page.tsx", + "route-tech-log-explore": "src/features/tech-log/presentation/public/pages/explore-page.tsx", + "route-tech-log-explore-kind": "src/features/tech-log/presentation/public/pages/explore-kind-page.tsx", + "route-tech-log-case": "src/features/tech-log/presentation/public/pages/case-page.tsx", + "route-tech-log-reference": "src/features/tech-log/presentation/public/pages/reference-page.tsx", + "route-tech-log-question": "src/features/tech-log/presentation/public/pages/question-page.tsx", + "route-tech-log-topic": "src/features/tech-log/presentation/public/pages/topic-page.tsx", + "route-tech-log-projects": "src/features/tech-log/presentation/public/pages/projects-page.tsx", + "route-tech-log-project": "src/features/tech-log/presentation/public/pages/project-overview-page.tsx", + "route-tech-log-project-records": "src/features/tech-log/presentation/public/pages/project-records-page.tsx", + "route-tech-log-project-decisions": "src/features/tech-log/presentation/public/pages/project-decisions-page.tsx", + "route-tech-log-project-activity": "src/features/tech-log/presentation/public/pages/project-activity-page.tsx", + "route-tech-log-releases": "src/features/tech-log/presentation/public/pages/releases-page.tsx", + "route-tech-log-release": "src/features/tech-log/presentation/public/pages/release-page.tsx", + "route-tech-log-profile": "src/features/tech-log/presentation/public/pages/profile-page.tsx", + "route-tech-log-search": "src/features/tech-log/presentation/public/pages/search-page.tsx", + "route-tech-log-studio-home": "src/features/tech-log/presentation/studio/pages/studio-home-page.tsx", + "route-tech-log-studio-documents": "src/features/tech-log/presentation/studio/pages/documents-page.tsx", + "route-tech-log-studio-document-new": "src/features/tech-log/presentation/studio/pages/new-document-page.tsx", + "route-tech-log-studio-document-edit": "src/features/tech-log/presentation/studio/pages/document-edit-page.tsx", + "route-tech-log-studio-document-validation": "src/features/tech-log/presentation/studio/pages/document-validation-page.tsx", + "route-tech-log-studio-document-preview": "src/features/tech-log/presentation/studio/pages/document-preview-page.tsx", + "route-tech-log-studio-document-publish": "src/features/tech-log/presentation/studio/pages/document-publish-page.tsx", + "route-tech-log-studio-publications": "src/features/tech-log/presentation/studio/pages/publications-page.tsx", + "route-tech-log-studio-publication-preview": "src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx", + "route-tech-log-studio-not-found": "src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx", + "route-not-found": "src/features/tech-log/presentation/public/pages/public-not-found-page.tsx" }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", diff --git a/scripts/test-performance.ts b/scripts/test-performance.ts index eac262f..a4a8314 100644 --- a/scripts/test-performance.ts +++ b/scripts/test-performance.ts @@ -84,13 +84,8 @@ try { }).observe({ type: "layout-shift", buffered: true }); }); await page.goto(baseUrl, { waitUntil: "networkidle" }); - const target = ROUTE_REGISTRY.EXAMPLES_PLATFORM; - const targetLabel = target.navigationLabel; - if (!targetLabel) { - throw new Error("Performance route must be present in navigation."); - } + const target = ROUTE_REGISTRY.TECH_LOG_HOME; const interactionStarted = performance.now(); - await page.getByRole("link", { name: targetLabel }).click(); await page.getByRole("heading", { name: target.title }).waitFor(); const namedInteractionMs = performance.now() - interactionStarted; const paint = await page.evaluate( diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts index 682373c..bb9966b 100644 --- a/scripts/test-sample-removal.ts +++ b/scripts/test-sample-removal.ts @@ -36,6 +36,8 @@ const featureOwnedPaths = [ featureSource, featureTests, "tests/integration/http-scenario-catalog.test.ts", + "tests/integration/http-execution-v3-observability.test.ts", + "tests/features/tech-log/runtime-composition.test.ts", "tests/e2e/reference-form.spec.ts", "tests/e2e/reference-route.spec.ts", "tests/mocks", @@ -74,13 +76,17 @@ const copyTargets = [ ".nvmrc", ]; -const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts"; -import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts"; +const emptyContracts = `import type { RouteDefinition } from "../contracts/routes.ts"; import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.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: readonly unknown[] = Object.freeze([]); -export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY; -export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT; +export const ROUTE_REGISTRY = TECH_LOG_ROUTE_REGISTRY; +export const ROUTE_RUNTIME_CONTRACT = TECH_LOG_ROUTE_RUNTIME_CONTRACT; export const API_OPERATIONS = Object.freeze({}); export const INVALIDATION_REGISTRY = Object.freeze({ topics: Object.freeze([]), @@ -88,7 +94,10 @@ export const INVALIDATION_REGISTRY = Object.freeze({ edges: Object.freeze([]), }); export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]); -export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY; +export const SCHEMA_REGISTRY = Object.freeze({ + ...PLATFORM_SCHEMA_REGISTRY, + ...TECH_LOG_ROUTE_SCHEMA_REGISTRY, +}); export const NAVIGATION_ROUTES = Object.freeze( Object.values(ROUTE_REGISTRY) .filter((definition) => definition.navigationOrder !== null) @@ -109,15 +118,22 @@ export function routePath(routeId: string): string { `; const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts"; -import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-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 = PLATFORM_ROUTE_CODECS; -export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME; +export const ROUTE_CODECS = Object.freeze({ + ...PLATFORM_ROUTE_CODECS, + ...TECH_LOG_ROUTE_CODECS, +}); +export const ROUTE_RUNTIME = TECH_LOG_ROUTE_RUNTIME; `; -const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) { +const emptyAdapters = `import { createTechLogFeatureInstalledInput } from "./tech-log/adapters/create-tech-log-feature-input.ts"; + +export function createInstalledFeatureInputs(_context: unknown) { void _context; - return Object.freeze({}); + const techLog = createTechLogFeatureInstalledInput(); + return Object.freeze({ [techLog.featureId]: techLog.input }); } `; @@ -138,9 +154,11 @@ export const EXPECTED_CONTRACT_SET_PACKAGES: readonly InstalledContractPackageId COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages; `; -const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ - "ko-KR": Object.freeze({}), - "en-US": Object.freeze({}), +const emptyMessages = `import { TECH_LOG_MESSAGE_CATALOGS } from "./tech-log/contracts/tech-log-message-catalog.ts"; + +export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({ + "ko-KR": TECH_LOG_MESSAGE_CATALOGS["ko-KR"], + "en-US": TECH_LOG_MESSAGE_CATALOGS["en-US"], }); `; @@ -174,10 +192,13 @@ function runPnpm(script: string, extra: string[] = []): boolean { try { await prepareRemovalFixture(fixtureRoot, copyTargets); for (const excludedFixtureTest of [ + "tests/integration/security-followup-archive.test.ts", + "tests/unit/ci-artifact-contract.test.ts", "tests/unit/ci-workflow-generation.test.ts", "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", "tests/unit/removal-fixture.test.ts", "tests/unit/http-scenario-evidence.test.ts", + "tests/unit/task3-selective-integration.test.ts", ]) { await rm(path.join(fixtureRoot, excludedFixtureTest), { force: true }); } diff --git a/src/contracts/route-runtime-contract.ts b/src/contracts/route-runtime-contract.ts index a4ee913..31e4dc6 100644 --- a/src/contracts/route-runtime-contract.ts +++ b/src/contracts/route-runtime-contract.ts @@ -21,45 +21,4 @@ export type RouteRuntimeDefinition = Readonly<{ searchCodec: RouteCodecId; }>; -const runtime = ( - value: Definition, -): Readonly => 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({}); diff --git a/src/contracts/routes.ts b/src/contracts/routes.ts index c2b29e0..ae29122 100644 --- a/src/contracts/routes.ts +++ b/src/contracts/routes.ts @@ -15,93 +15,4 @@ export type RouteDefinition = Readonly<{ navigationOrder: number | null; }>; -const route = ( - definition: Definition, -): Readonly => 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({}); diff --git a/src/features/installed-feature-contracts.ts b/src/features/installed-feature-contracts.ts index 19a65eb..a0880e5 100644 --- a/src/features/installed-feature-contracts.ts +++ b/src/features/installed-feature-contracts.ts @@ -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>; export const ROUTE_RUNTIME_CONTRACT = Object.freeze({ - ...PLATFORM_ROUTE_RUNTIME_CONTRACT, - ...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts, + ...TECH_LOG_ROUTE_RUNTIME_CONTRACT, }) satisfies Readonly>; 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( diff --git a/src/features/installed-feature-messages.ts b/src/features/installed-feature-messages.ts index 99ddc3f..a032f32 100644 --- a/src/features/installed-feature-messages.ts +++ b/src/features/installed-feature-messages.ts @@ -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); diff --git a/src/features/installed-feature-runtimes.tsx b/src/features/installed-feature-runtimes.tsx index e706ba6..edf5062 100644 --- a/src/features/installed-feature-runtimes.tsx +++ b/src/features/installed-feature-runtimes.tsx @@ -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, }); diff --git a/src/features/reference-feature/presentation/reference-feature-runtime.tsx b/src/features/reference-feature/presentation/reference-feature-runtime.tsx deleted file mode 100644 index 5e2055c..0000000 --- a/src/features/reference-feature/presentation/reference-feature-runtime.tsx +++ /dev/null @@ -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; diff --git a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx deleted file mode 100644 index 008bd25..0000000 --- a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx +++ /dev/null @@ -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 ( - Reference resources - } - heading={{ - eyebrow: "DetailPage", - title: resource?.title ?? "Reference detail", - description: "route param과 detail query의 reset 경계를 확인합니다.", - }} - metadata={ - resource ? ( -
-
Resource ID
-
{resource.resourceId}
-
Created
-
- {resource.createdAt - ? date(new Date(resource.createdAt)) - : message("common.noDisplayValue")} -
-
- ) : ( -

요약 정보를 준비하고 있습니다.

- ) - } - feedback={ - - {resource ? ( -

이 영역에는 제품별 상세 section을 조립할 수 있습니다.

- ) : null} -
- } - aside={

상세 페이지의 관련 정보 slot입니다.

} - /> - ); -} diff --git a/src/features/reference-feature/presentation/reference-resource-form-page.tsx b/src/features/reference-feature/presentation/reference-resource-form-page.tsx deleted file mode 100644 index 599bdd6..0000000 --- a/src/features/reference-feature/presentation/reference-resource-form-page.tsx +++ /dev/null @@ -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; - -export default function ReferenceResourceFormPage() { - const navigate = useNavigate(); - const mutation = useReferenceCreate(); - const submit = useCallback( - (command: ReturnType) => - 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 ( -
{ - if (mutationBlocked) { - event.preventDefault(); - return; - } - void form.submitForm(event); - }} - > - navigate("/examples/reference-resources")} - > - 목록으로 돌아가기 - - } - heading={{ - eyebrow: "FormPage", - title: "Reference resource 만들기", - description: - "presentation schema, command mapper, 422/conflict와 dirty navigation 정책을 실행합니다.", - }} - errorSummary={ - - } - fields={ - <> - - - - } - formActions={ - - - - - - } - feedback={ - mutationEffectUnknown ? ( - { - void mutation.reconcileUnknownEffect(resolution).then(() => { - if (resolution === "APPLIED") { - form.settleApplied(); - } else { - form.settleNotApplied(); - } - }); - }} - /> - ) : form.result === "success" ? ( -

저장했습니다.

- ) : form.result === "conflict" ? ( -

충돌을 해결한 뒤 다시 제출할 수 있습니다.

- ) : null - } - aside={ -

- form value는 URL, storage, telemetry에 저장되지 않고 submit 시에만 - application command로 변환됩니다. -

- } - guard={} - /> - - ); -} diff --git a/src/features/reference-feature/presentation/reference-resource-form.ts b/src/features/reference-feature/presentation/reference-resource-form.ts deleted file mode 100644 index 059c596..0000000 --- a/src/features/reference-feature/presentation/reference-resource-form.ts +++ /dev/null @@ -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 } : {}), - }); -} diff --git a/src/features/reference-feature/presentation/reference-resource-page.tsx b/src/features/reference-feature/presentation/reference-resource-page.tsx deleted file mode 100644 index d3ac363..0000000 --- a/src/features/reference-feature/presentation/reference-resource-page.tsx +++ /dev/null @@ -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 ( - navigate("/examples/reference-resources/new"), - }, - ]} - activeFilters={ -

- limit {filters.limit} - {filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""} -

- } - toolbar={} - resultCount={ - query.data ? `총 ${query.data.length}개 항목` : "결과 확인 중" - } - > - -
    - {(query.data ?? []).map((resource) => ( -
  • - - {resource.title} - -
  • - ))} -
-
-
- ); -} diff --git a/src/features/reference-feature/presentation/reference-resource-status-page.tsx b/src/features/reference-feature/presentation/reference-resource-status-page.tsx deleted file mode 100644 index b98764a..0000000 --- a/src/features/reference-feature/presentation/reference-resource-status-page.tsx +++ /dev/null @@ -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 ( - navigate("/examples/reference-resources"), - }} - supportReference="REFERENCE-STATUS-DEMO" - /> - ); -} diff --git a/src/features/reference-feature/presentation/use-reference-failure-action.ts b/src/features/reference-feature/presentation/use-reference-failure-action.ts deleted file mode 100644 index 3ef131e..0000000 --- a/src/features/reference-feature/presentation/use-reference-failure-action.ts +++ /dev/null @@ -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; -} diff --git a/src/features/reference-feature/presentation/use-reference-feature.ts b/src/features/reference-feature/presentation/use-reference-feature.ts deleted file mode 100644 index 64ba9e3..0000000 --- a/src/features/reference-feature/presentation/use-reference-feature.ts +++ /dev/null @@ -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 }); -} diff --git a/src/features/tech-log/contracts/tech-log-route-contract.ts b/src/features/tech-log/contracts/tech-log-route-contract.ts index b8c94f5..a8020d8 100644 --- a/src/features/tech-log/contracts/tech-log-route-contract.ts +++ b/src/features/tech-log/contracts/tech-log-route-contract.ts @@ -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, }); diff --git a/src/features/tech-log/presentation/studio/components/document-editor-controller.ts b/src/features/tech-log/presentation/studio/components/document-editor-controller.ts new file mode 100644 index 0000000..4e0cd53 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/document-editor-controller.ts @@ -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): void; + replace(draft: WorkingCopyInput): void; + save(): Promise; +}; diff --git a/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx b/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx index 1279db1..d8ce73c 100644 --- a/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx +++ b/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx @@ -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"; diff --git a/src/features/tech-log/presentation/studio/components/document-editor.tsx b/src/features/tech-log/presentation/studio/components/document-editor.tsx index 6f79f41..ce21d74 100644 --- a/src/features/tech-log/presentation/studio/components/document-editor.tsx +++ b/src/features/tech-log/presentation/studio/components/document-editor.tsx @@ -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): void; - replace(draft: WorkingCopyInput): void; - save(): Promise; -}; - export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) { const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT"); const editTab = useRef(null); diff --git a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx index 299f0cd..095940f 100644 --- a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx +++ b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx @@ -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 = { diff --git a/src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx b/src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx new file mode 100644 index 0000000..aa9e801 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx @@ -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 ( +

+ 게시 Snapshot을 불러오는 중입니다. +

+ ); + } + 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 ( +
+

NOT FOUND

+

게시 기록을 찾을 수 없습니다

+

+ 이 이벤트는 현재 Studio 세션에 없거나 Snapshot을 소유하지 + 않습니다. +

+ + 게시 기록으로 돌아가기 + +
+ ); + } + return ( +
+

SNAPSHOT ERROR

+

게시 Snapshot을 불러오지 못했습니다

+

+ {isStudioGatewayError(state.problem) + ? state.problem.problem.detail + : state.problem.message} +

+ +
+ ); + } + + const { event, renderModel } = state.snapshot; + return ( +
+
+
+

IMMUTABLE SNAPSHOT

+

게시 이벤트 시점의 공개 화면

+
+
+
+
이벤트
+
{eventLabels[event.type]}
+
+
+
게시 버전
+
v{event.publishedVersion}
+
+
+
발생 시각
+
+ +
+
+
+ + 게시 기록으로 돌아가기 + +
+
+ undefined} + /> +
+
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/publication-list.tsx b/src/features/tech-log/presentation/studio/components/publication-list.tsx new file mode 100644 index 0000000..c0bf8dc --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/publication-list.tsx @@ -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({ limit: 100 }); + const [queryDraft, setQueryDraft] = useState(""); + const [typeDraft, setTypeDraft] = + useState(); + 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(null); + const [pending, setPending] = useState(false); + const [dialogError, setDialogError] = useState(""); + const [announcement, setAnnouncement] = useState(""); + const triggerRef = useRef(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 ( +
+
+

PUBLICATION EVENTS

+

게시 기록

+

+ 게시·재게시·게시 취소 이벤트와 각 시점의 Snapshot을 확인합니다. +

+
+ +
+ + + +
+ + {announcement ? ( +

+ {announcement} +

+ ) : null} + {state.status === "LOADING" ? ( +

+ 게시 기록을 불러오는 중입니다. +

+ ) : null} + {state.status === "ERROR" ? ( +
+

게시 기록을 불러오지 못했습니다

+

+ {isStudioGatewayError(state.problem) + ? state.problem.problem.detail + : state.problem.message} +

+ +
+ ) : null} + {state.status === "READY" && state.page.items.length === 0 ? ( +
+

조건에 맞는 게시 기록이 없습니다

+

검색어 또는 이벤트 필터를 바꿔 보세요.

+
+ ) : null} + {state.status === "READY" && state.page.items.length > 0 ? ( +
    + {state.page.items.map((item) => { + const href = snapshotHref(item); + const source = item.availableActions.includes( + "VIEW_SOURCE_SNAPSHOT", + ); + const canUnpublish = item.availableActions.includes("UNPUBLISH"); + return ( +
  1. +
    +

    + {eventLabels[item.event.type]} +

    +
    +

    + {kindLabels[item.document.kind]} · v + {item.event.publishedVersion} +

    +

    {item.document.title || "제목 없는 작업본"}

    +

    + 이벤트: {eventLabels[item.event.type]} · 현재 상태:{" "} + {item.publication.status === "PUBLISHED" + ? "게시 중" + : "게시 취소"} +

    +
    + +
    + {href ? ( + + {source ? "게시 취소 전 Snapshot 보기" : "Snapshot 보기"} + + ) : null} + {canUnpublish ? ( + + ) : null} +
    +
    +
  2. + ); + })} +
+ ) : null} + + { + void confirmUnpublish(); + }} + classes={styles} + /> +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/publish-screen.tsx b/src/features/tech-log/presentation/studio/components/publish-screen.tsx new file mode 100644 index 0000000..d45686d --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/publish-screen.tsx @@ -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({ status: "LOADING" }); + const [acknowledged, setAcknowledged] = useState>( + () => 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 ( +

+ 게시 준비 정보를 불러오는 중입니다. +

+ ); + } + if (state.status === "ERROR" && notFound(state.problem)) { + return ( +
+

NOT FOUND

+

작업본을 찾을 수 없습니다

+

이 작업본은 현재 Studio 세션에 없습니다.

+ + 작업본으로 돌아가기 + +
+ ); + } + if (state.status === "ERROR") { + return ( +
+

PUBLISH ERROR

+

게시 정보를 불러오지 못했습니다

+

+ {isStudioGatewayError(state.problem) + ? state.problem.problem.detail + : state.problem.message} +

+ +
+ ); + } + + 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 ( +
+
+

PUBLICATION

+

게시 준비

+

{document.title}

+
+
+

현재 버전이 이미 게시되어 있습니다

+

+ 저장 버전 v{document.version}과 게시 버전이 같습니다. 새 게시 + 이벤트를 만들지 않습니다. +

+ + 현재 게시 Snapshot 보기 + +
+
+ ); + } + + 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 ( +
+
+

PUBLICATION

+

게시 준비

+

{document.title || "제목 없는 작업본"}

+
+ +
+
+
+
저장 버전
+
v{document.version}
+
+
+
검증
+
+ {validation + ? `${validation.status} · v${validation.validatedVersion}` + : "실행 전"} +
+
+
+
Public Preview
+
+ {preview + ? `${preview.state} · v${preview.preview.previewVersion}` + : "없음"} +
+
+
+
현재 게시
+
+ {currentPublication + ? `${currentPublication.status} · v${currentPublication.publishedVersion}` + : "게시 전"} +
+
+
+
+ +
+

CHANGE SUMMARY

+

변경 요약

+

+ {currentPublication + ? `게시 버전 v${currentPublication.publishedVersion}에서 저장 버전 v${document.version}으로 반영합니다.` + : `저장 버전 v${document.version}을 처음 게시합니다.`} +

+ {preview ? ( +

+ Preview 만료:{" "} + +

+ ) : null} +
+ + {blocked ? ( +
+

{blocked.title}

+

{blocked.detail}

+ + {blocked.label} + +
+ ) : ( +
+

{actionLabel}

+ + {actionError ? ( +

+ {actionError} +

+ ) : null} + {success ? ( +

+ {success} +

+ ) : null} +
+ + Preview로 돌아가기 + + +
+
+ )} +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/unpublish-dialog.tsx b/src/features/tech-log/presentation/studio/components/unpublish-dialog.tsx new file mode 100644 index 0000000..184d601 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/unpublish-dialog.tsx @@ -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(null); + const cancelRef = useRef(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) => { + if (event.key !== "Tab") return; + const controls = Array.from( + event.currentTarget.querySelectorAll( + "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 ( + { + event.preventDefault(); + if (!pending) onDismiss(); + }} + onKeyDown={trapFocus} + > +
+

UNPUBLISH

+

게시를 취소할까요?

+

+ {item?.document.title ?? "선택한 기록"}의 Studio 게시 상태를 중단하고 + 게시 취소 이벤트를 남깁니다. +

+
+ 작업본과 이전 Snapshot은 보존됩니다. +

+ 현재 Mock에서는 기존 Public 사이트와 검색 결과를 변경하지 않습니다. +

+
+ {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/validation-report.tsx b/src/features/tech-log/presentation/studio/components/validation-report.tsx index ec92eeb..c2cbf74 100644 --- a/src/features/tech-log/presentation/studio/components/validation-report.tsx +++ b/src/features/tech-log/presentation/studio/components/validation-report.tsx @@ -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]; }); diff --git a/src/features/tech-log/presentation/studio/components/warning-acknowledgements.tsx b/src/features/tech-log/presentation/studio/components/warning-acknowledgements.tsx new file mode 100644 index 0000000..1deef9e --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/warning-acknowledgements.tsx @@ -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; + onToggle(code: string, checked: boolean): void; + classes?: PublicationFlowClasses; +}) { + const groupId = useId(); + + if (warnings.length === 0) { + return

확인할 경고가 없습니다.

; + } + + return ( +
+ 게시 전 경고 확인 +

+ 현재 검증의 경고를 모두 확인해야 게시할 수 있습니다. +

+
+ {warnings.map((warning, index) => { + const inputId = `${groupId}-warning-${index}`; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/features/tech-log/presentation/studio/pages/document-publish-page.tsx b/src/features/tech-log/presentation/studio/pages/document-publish-page.tsx new file mode 100644 index 0000000..6328095 --- /dev/null +++ b/src/features/tech-log/presentation/studio/pages/document-publish-page.tsx @@ -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 ; +} diff --git a/src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx b/src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx new file mode 100644 index 0000000..2cc887a --- /dev/null +++ b/src/features/tech-log/presentation/studio/pages/publication-preview-page.tsx @@ -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 ( + + ); +} diff --git a/src/features/tech-log/presentation/studio/pages/publications-page.tsx b/src/features/tech-log/presentation/studio/pages/publications-page.tsx new file mode 100644 index 0000000..899aec8 --- /dev/null +++ b/src/features/tech-log/presentation/studio/pages/publications-page.tsx @@ -0,0 +1,5 @@ +import { PublicationList } from "../components/publication-list.tsx"; + +export function PublicationsPage() { + return ; +} diff --git a/src/features/tech-log/presentation/tech-log-route-runtime.tsx b/src/features/tech-log/presentation/tech-log-route-runtime.tsx new file mode 100644 index 0000000..852630f --- /dev/null +++ b/src/features/tech-log/presentation/tech-log-route-runtime.tsx @@ -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; +}>; + +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( + load: () => Promise, + key: Key, +): () => Promise<{ default: Extract }> { + return async () => { + const module = await load(); + return { default: module[key] as Extract }; + }; +} + +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>; + +export const TECH_LOG_ROUTE_LAYOUTS: Readonly< + Record<"PUBLIC" | "STUDIO", ReactNode> +> = Object.freeze({ + PUBLIC: ( + + + + ), + STUDIO: ( + + + + ), +}); diff --git a/src/presentation/examples/auth-example-page.tsx b/src/presentation/examples/auth-example-page.tsx deleted file mode 100644 index aa7a1b3..0000000 --- a/src/presentation/examples/auth-example-page.tsx +++ /dev/null @@ -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): Promise { - setPending(true); - setFailed(false); - try { - await action(); - } catch { - setFailed(true); - } finally { - setPending(false); - } - } - - return ( -
- -
-
-

현재 세션 상태

- - {sessionState} - -
-
- - - -
- {sessionState === "integration-failed" ? ( -

- 외부 인증 소유자가 연결되지 않았습니다. 런타임 호스트의 인증 - 계약을 연결하세요. -

- ) : null} - {failed ?

인증 작업을 완료하지 못했습니다.

: null} -
-
- ); -} diff --git a/src/presentation/examples/platform-overview-page.tsx b/src/presentation/examples/platform-overview-page.tsx deleted file mode 100644 index ce7b1d4..0000000 --- a/src/presentation/examples/platform-overview-page.tsx +++ /dev/null @@ -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 ( -
-
{label}
-
- {value} - {hint ? {hint} : null} -
-
- ); -} - -type RouteRow = (typeof ROUTE_REGISTRY)[keyof typeof ROUTE_REGISTRY]; - -const ROUTE_COLUMNS: readonly DataTableColumn[] = Object.freeze([ - { - id: "routeId", - header: "라우트", - cell: (row) => {row.routeId}, - }, - { id: "path", header: "경로", cell: (row) => {row.path} }, - { - id: "access", - header: "접근", - cell: (row) => ( - - {row.access} - - ), - }, - { - id: "schemas", - header: "입력 스키마", - cell: (row) => - [row.paramsSchema, row.searchSchema].filter(Boolean).join(" · ") || "없음", - }, - { - id: "chunkId", - header: "청크", - cell: (row) => {row.chunkId}, - }, -]); - -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[] = - Object.freeze([ - { - id: "operationId", - header: "오퍼레이션", - cell: (row) => ( - <> - {row.operationId} - - {row.method} {row.pathTemplate} - - - ), - }, - { - id: "retry", - header: "재시도", - cell: (row) => ( - <> - - {row.retrySemantics} - - - 예산 {row.retryBudget}회 - - - ), - }, - { - id: "effect", - header: "효과 확정성", - cell: (row) => row.effect, - }, - { - id: "recovery", - header: "복구", - cell: (row) => row.recovery, - }, - { - id: "budget", - header: "예산", - cell: (row) => ( - <> - - 요청 {kilobytes(row.requestByteLimit)} · 응답{" "} - {kilobytes(row.responseByteLimit)} - - - 마감 {seconds(row.totalDeadlineMs)} - - - ), - }, - ]); - -type ProfileRow = (typeof SERVER_STATE_PROFILES)[keyof typeof SERVER_STATE_PROFILES]; - -const PROFILE_COLUMNS: readonly DataTableColumn[] = Object.freeze([ - { - id: "profileId", - header: "프로파일", - cell: (row) => {row.profileId}, - }, - { 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> -> = 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> | 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 ( -
- - -
-
-

릴리스 신원

-

- 부팅 시 경계 검사를 통과한 런타임 설정과 릴리스 매니페스트에서 옵니다. -

-
-
- {release ? ( - <> - - - - - - ) : ( - - )} -
-
- -
-
-

설치 요약

-

레지스트리 항목 수를 그대로 센 값입니다.

-
-
- - - - -
-
- -
-
-

설치된 라우트

-

- 라우트 레지스트리가 단일 진실 공급원입니다. 접근 정책, 코드 분할 청크, - 입력 스키마가 한 항목에 함께 선언됩니다. -

-
- row.routeId} - empty={ - - } - /> -
- -
-
-

계약과 HTTP 오퍼레이션

-

- 외부 계약 패키지 {EXPECTED_CONTRACT_SET_PACKAGES.length}개가 설치되어 - 있습니다. 아래 오퍼레이션은 템플릿 픽스처가 제공하며 릴리스 다이제스트에 - 포함되지 않습니다. 제품은 픽스처를 지우고 자기 패키지를 고정합니다. -

-
- row.operationId} - empty={ - - } - /> -
- -
-
-

서버 상태와 실행 상한

-

- 조회는 네 개의 고정 프로파일 중 하나를 골라야 하고, 실행 정책은 아래 - 상한을 넘을 수 없습니다. -

-
- row.profileId} - empty={} - /> -
- - - - -
-
- -
-
-

선택적 런타임 능력

-

- 정적 선택 파일이 단일 진실 공급원입니다. 런타임 설정은 이미 선택된 - 능력을 끌 수만 있고, 설정 문자열로 새 능력을 켜거나 모듈 경로를 만들지 - 못합니다. 여기 표시되는 상태는 정적 선택에 런타임 오버라이드를 적용한 - 결과이므로, 애초에 선택되지 않은 능력과 운영자가 끈 능력이 구분됩니다. -

-
-
- {capabilities.map((status) => { - const copy = CAPABILITY_COPY[status.capabilityId]; - const badge = capabilityBadge(status); - return ( - {badge.text}} - > -

{copy.description}

-
- ); - })} -
-
-
- ); -} diff --git a/src/presentation/examples/state-gallery-page.tsx b/src/presentation/examples/state-gallery-page.tsx deleted file mode 100644 index 0250bc7..0000000 --- a/src/presentation/examples/state-gallery-page.tsx +++ /dev/null @@ -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 ( -
- - -
-
-

비동기 데이터 상태

-

초기 로딩과 백그라운드 갱신을 구분해 기존 콘텐츠를 보존합니다.

-
-
- - - - - -
기존 콘텐츠는 계속 표시됩니다.
-
-
- - setLastAction("빈 화면 작업을 실행했습니다.")}> - 첫 작업 시작 - - } - /> - - - setLastAction("오류 요청을 다시 시도했습니다.")} - /> - -
-
- -
-
-

접근과 탐색 상태

-

인증 여부와 서버 권한 결과를 서로 다른 상태로 전달합니다.

-
-
- setLastAction("로그인 연동 작업을 시작했습니다.")} - /> - setLastAction("접근 가능한 화면으로 이동합니다.")} - /> - setLastAction("시작 화면으로 이동합니다.")} - /> -
-
- - - {lastAction} - -
- ); -} diff --git a/src/presentation/examples/ui-gallery-page.tsx b/src/presentation/examples/ui-gallery-page.tsx deleted file mode 100644 index 5f2935f..0000000 --- a/src/presentation/examples/ui-gallery-page.tsx +++ /dev/null @@ -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 ( - - - - ); -} - -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): void { - event.preventDefault(); - setFieldTouched(true); - if (projectName.trim().length === 0) { - setNotice("입력값을 확인해 주세요."); - return; - } - setNotice(`“${projectName.trim()}” 입력을 확인했습니다.`); - } - - return ( -
- - -
-
-

버튼과 입력

-

키보드, 비활성 상태, 오류 설명을 포함한 기본 상호작용입니다.

-
-
- -
- - - - -
-
- -
- setProjectName(event.currentTarget.value)} - /> - - -
-
-
- -
-
-

폼과 선택 컨트롤

-

native semantics, 설명·오류 연결과 controlled 상태를 제공합니다.

-
-
- -
-