feat: let Studio create the topics and projects publishing requires

Publishing needs a topic and nothing could create one. The backend now owns
that surface; this is its consumer — the management contract vendored, a
gateway over its nine operations, and one Studio screen that lists, creates,
and deletes topics and projects.

The screen adds no CSS. It reuses the classes the working-copy list already
uses, so it inherits Studio's spacing, type, and colour rather than growing a
second visual vocabulary beside them. Scope stops at list/create/delete:
renaming, phase changes, and visibility are implemented in the backend and
declared in the contract, but their screens are a separate design.

Two real defects surfaced while making the public port async, and both would
have shipped:

The search page and the header search dialog shared a query key. With an empty
query, `["tech-log","search",""]` was identical for both, so react-query
handed one surface the other's cache — different shapes — and the page died
reading a field that was not there. Keys now name the surface.

The explore filter's selects are uncontrolled and read `defaultValue`, which
React applies once. Their options arrive later now, so the first render had
nothing to match and the value stayed empty: a topic in the URL no longer
showed as selected. The form key includes whether the catalog has arrived, so
it remounts with the options present. Controlled inputs would be the other
answer, but this form submits to build a URL — the URL owns the value.

The route brought its own bookkeeping: a build chunk, a manual accessibility
evidence file, and the CI artifact baseline that counts them. The gate pins a
digest of its own shape precisely so a new route cannot slip in without that
count being reviewed.

Test harnesses that render public screens now assemble the query providers and
await the settled paint, because the screens they render became async.
This commit is contained in:
DongHyeonka
2026-08-20 23:40:15 +09:00
parent 4b62bf3b1f
commit 11c2713139
52 changed files with 16509 additions and 134 deletions
@@ -6,6 +6,7 @@ import {
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
import { TECH_LOG_MANAGEMENT_CONTRIBUTION } from "./tech-log/contracts/tech-log-management-contract-contribution.ts";
import { TECH_LOG_PUBLIC_CONTRIBUTION } from "./tech-log/contracts/tech-log-public-contract-contribution.ts";
import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts";
@@ -23,8 +24,13 @@ export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContrib
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION,
TECH_LOG_STUDIO_CONTRIBUTION,
TECH_LOG_PUBLIC_CONTRIBUTION,
TECH_LOG_MANAGEMENT_CONTRIBUTION,
]
: [TECH_LOG_STUDIO_CONTRIBUTION, TECH_LOG_PUBLIC_CONTRIBUTION],
: [
TECH_LOG_STUDIO_CONTRIBUTION,
TECH_LOG_PUBLIC_CONTRIBUTION,
TECH_LOG_MANAGEMENT_CONTRIBUTION,
],
);
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
@@ -12,6 +12,7 @@ import {
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts";
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
import { createHttpManagementGateway } from "./http/http-management-gateway.ts";
import { createHttpPublicContentGateway } from "./http/http-public-content-gateway.ts";
import { publicContentQueries } from "./static/public-query.ts";
@@ -76,8 +77,12 @@ export function createTechLogFeatureInstalledInput(
? publicContentQueries
: createHttpPublicContentGateway({ operations: context.contractOperations });
const createManagementGateway = () =>
createHttpManagementGateway({ operations: context.contractOperations });
const input: TechLogFeatureInput = Object.freeze({
publicContent,
createManagementGateway,
createStudioGateway,
createStudioAssetGateway,
});
@@ -0,0 +1,64 @@
import type {
CreateDraftResponse,
ProjectEditResponse,
ProjectIndexPage,
ProjectUpdateRequest,
TopicEdit,
} from "../../contracts/management/contract.ts";
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
export type { ManagementGateway };
const ROUTE_ID = "TECH_LOG_STUDIO";
/**
* 주제·프로젝트 관리 게이트웨이.
*
* <p>Studio 게이트웨이와 같은 실패 규약을 쓴다 — 실패는 던지고, 화면은 `usePublicContent` 가 아니라
* Studio 쪽 상태 처리를 그대로 쓴다. 여기서 Result 로 감싸면 이 표면만 다른 규약이 된다.
*/
export class ManagementGatewayError extends Error {
readonly operationId: string;
readonly code: string;
constructor(operationId: string, code: string) {
super(`${operationId}: ${code}`);
this.name = "ManagementGatewayError";
this.operationId = operationId;
this.code = code;
}
}
export function createHttpManagementGateway(
deps: Readonly<{ operations: StudioOperationExecutor }>,
): ManagementGateway {
async function run<T>(operationId: string, input: unknown): Promise<T> {
const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID });
if (outcome.kind === "SUCCESS") return outcome.value as T;
if (outcome.kind === "PROBLEM") {
const problem = outcome.problem as Readonly<{ code?: string }> | null;
throw new ManagementGatewayError(operationId, problem?.code ?? "PROBLEM");
}
throw new ManagementGatewayError(operationId, outcome.kind);
}
return Object.freeze({
listTopics: () => run<TopicEdit[]>("listStudioTopics", {}),
createTopic: (input: TopicEdit) => run<TopicEdit>("createTopic", input),
updateTopic: (id: string, body: TopicEdit) => run<TopicEdit>("updateTopic", { id, body }),
deleteTopic: async (id: string, expectedVersion: number) => {
await run<void>("deleteTopic", { id, expectedVersion });
},
listProjects: (page?: number, size?: number) =>
run<ProjectIndexPage>("listStudioProjects", { ...(page !== undefined ? { page } : {}), ...(size !== undefined ? { size } : {}) }),
getProject: (id: string) => run<ProjectEditResponse>("getProjectForEdit", { id }),
createProject: (title: string) => run<CreateDraftResponse>("createProject", { title }),
updateProject: (id: string, body: ProjectUpdateRequest) =>
run<ProjectEditResponse>("updateProject", { id, body }),
deleteProject: async (id: string, expectedVersion: number) => {
await run<void>("deleteProject", { id, expectedVersion });
},
});
}
@@ -0,0 +1,26 @@
import type {
CreateDraftResponse,
ProjectEditResponse,
ProjectIndexPage,
ProjectUpdateRequest,
TopicEdit,
} from "../../contracts/management/contract.ts";
/**
* 주제·프로젝트 관리 표면.
*
* <p>Studio 게이트웨이와 같은 실패 규약이다 — 실패는 던지고, 화면이 잡는다. MOCK 대응물을 두지
* 않는 것도 의도다: 이 표면은 백엔드가 없으면 존재할 이유가 없고, 픽스처를 만들면 실제로는 만들
* 수 없는 주제를 화면이 보여주게 된다.
*/
export type ManagementGateway = Readonly<{
listTopics(): Promise<TopicEdit[]>;
createTopic(input: TopicEdit): Promise<TopicEdit>;
updateTopic(id: string, body: TopicEdit): Promise<TopicEdit>;
deleteTopic(id: string, expectedVersion: number): Promise<void>;
listProjects(page?: number, size?: number): Promise<ProjectIndexPage>;
getProject(id: string): Promise<ProjectEditResponse>;
createProject(title: string): Promise<CreateDraftResponse>;
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
deleteProject(id: string, expectedVersion: number): Promise<void>;
}>;
@@ -1,5 +1,6 @@
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
import type { StudioAssetGateway } from "./ports/studio-asset-gateway.ts";
import type { ManagementGateway } from "./ports/management-gateway.ts";
import type { StudioGateway } from "./ports/studio-gateway.ts";
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
@@ -8,6 +9,9 @@ export type TechLogFeatureInput = Readonly<{
publicContent: PublicContentQueries;
createStudioGateway(): StudioGateway;
createStudioAssetGateway(): StudioAssetGateway;
// 주제·프로젝트 관리. MOCK 대응물이 없다 — 이 표면은 백엔드가 없으면 존재할 이유가 없고,
// 픽스처를 만들면 실제로는 못 만드는 주제를 화면이 보여주게 된다.
createManagementGateway(): ManagementGateway;
}>;
declare module "../../../application/ports/in/application-api.ts" {
@@ -0,0 +1,87 @@
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f",
"sourceRevision": "6ef5c1c",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
"updateCaseDraft",
"deleteCaseDraft",
"createReferenceDraft",
"getReferenceForEdit",
"updateReferenceDraft",
"deleteReferenceDraft",
"validateCase",
"submitReviewCase",
"returnToDraftCase",
"unpublishCase",
"archiveCase",
"restoreCase",
"publishCase",
"validateReference",
"submitReviewReference",
"returnToDraftReference",
"unpublishReference",
"archiveReference",
"restoreReference",
"publishReference",
"createQuestion",
"listStudioQuestions",
"getQuestionForEdit",
"updateQuestion",
"deleteQuestion",
"addQuestionUpdate",
"updateQuestionUpdate",
"deleteQuestionUpdate",
"resolveQuestion",
"startQuestionInvestigation",
"pauseQuestion",
"resumeQuestion",
"reopenQuestion",
"archiveQuestion",
"publishQuestion",
"unpublishQuestion",
"createProject",
"listStudioProjects",
"getProjectForEdit",
"updateProject",
"deleteProject",
"changeProjectPhase",
"publishProject",
"unpublishProject",
"createProjectDecision",
"listStudioProjectDecisions",
"getProjectDecision",
"updateProjectDecision",
"acceptProjectDecision",
"rejectProjectDecision",
"supersedeProjectDecision",
"createRelease",
"listStudioReleases",
"getReleaseForEdit",
"updateRelease",
"deleteRelease",
"publishRelease",
"archiveRelease",
"listStudioTopics",
"createTopic",
"updateTopic",
"deleteTopic",
"listStudioTags",
"createTag",
"updateTag",
"deleteTag",
"getStudioSite",
"updateStudioSite",
"getStudioProfile",
"updateStudioProfile",
"publishProfile",
"unpublishProfile",
"getHomeFocus",
"updateHomeFocus",
"listStudioProjectActivities",
"createProjectActivity",
"updateProjectActivity"
]
}
@@ -0,0 +1,12 @@
import type { components } from "./generated.ts";
type Schemas = components["schemas"];
export type TopicEdit = Schemas["TopicEdit"];
export type ProjectEditResponse = Schemas["ProjectEditResponse"];
export type ProjectIndexItem = Schemas["ProjectIndexItem"];
export type ProjectIndexPage = Schemas["ProjectIndexPage"];
export type ProjectUpdateRequest = Schemas["ProjectUpdateRequest"];
export type CreateDraftRequest = Schemas["CreateDraftRequest"];
export type CreateDraftResponse = Schemas["CreateDraftResponse"];
export type ExpectedVersionRequest = Schemas["ExpectedVersionRequest"];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
"packageId": "@tech-log/public-contract",
"version": "2.0.0",
"digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e",
"sourceRevision": "b98eaf9",
"sourceRevision": "6ef5c1c",
"operationIds": [
"getPublicSite",
"getPublicHome",
@@ -2,7 +2,7 @@
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
"digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4",
"sourceRevision": "b98eaf9",
"sourceRevision": "6ef5c1c",
"operationIds": [
"getStudioSession",
"getStudioDashboard",
@@ -0,0 +1,256 @@
import type {
CommandEffectDescriptor,
InstalledContractContribution,
InstalledHttpContract,
} from "../../../contracts/external-contract-runtime.ts";
import type { ProblemDetails } from "./studio/contract.ts";
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
import canonicalSource from "./management/canonical-source.json" with { type: "json" };
import {
envelopeData,
envelopeError,
passthroughInput,
} from "./tech-log-studio-contract-contribution.ts";
import { TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID } from "../adapters/http/studio-session-credentials.ts";
type PathValues = Readonly<Record<string, string>>;
type QueryEntries = readonly (readonly [string, string])[];
const NO_PATH: PathValues = Object.freeze({});
const NO_QUERY = Object.freeze([]) as QueryEntries;
const PROBLEM = envelopeError();
/** Studio 쪽과 같은 판정이다: 4xx 도메인 거절은 적용되지 않았음이 확정, 5xx·네트워크는 불확정. */
const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> = Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
return status >= 400 && status < 500 ? "NOT_APPLIED" : "MAYBE_APPLIED";
},
});
/**
* 관리 표면은 Studio 와 같은 세션·CSRF 를 쓴다. 계약이 `sessionCookie` 보안과 `CsrfToken`
* 파라미터를 선언하고 있고, 실제로 같은 백엔드의 같은 필터 체인을 지난다 — 그래서 인증 프로필도
* 공유한다. 부트스트랩 프로필은 쓰지 않는다: CSRF 토큰을 발급하는 것은 `getStudioSession`
* 하나뿐이고, 이 표면은 그 뒤에만 호출된다.
*/
function readOperation(
operationId: string,
pathTemplate: string,
responseByteLimit: number,
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }> = () =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
): InstalledHttpContract<unknown, unknown, unknown> {
return Object.freeze({
contract: Object.freeze({
operationId,
method: "GET" as const,
pathTemplate,
inputValidator: passthroughInput(`${operationId}Input`),
outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "SAFE" as const,
requestBody: "NONE" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: null,
commandEffect: null,
projectRequest(input: never) {
return Object.freeze({ ...project(input), body: null });
},
}),
frontend: Object.freeze({
policyId: `${operationId}_V1`,
requestByteLimit: 0,
responseByteLimit,
totalDeadlineMs: 10_000,
retryBudget: 2 as const,
authProfileId: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID,
diagnosticsOperation: `techLog.management.${operationId}`,
}),
}) as InstalledHttpContract<unknown, unknown, unknown>;
}
/**
* 쓰기는 `Idempotency-Key` 를 쓰지 않는다 — 계약이 요구하지 않고, 재생 보호는 `expectedVersion`
* 이 맡는다. 그래서 `NOT_IDEMPOTENT` 가 아니라 재시도 예산 0 으로 둔다: 응답을 못 본 재시도가
* 두 번째 생성을 만들 수 있는 표면이다.
*/
function writeOperation(
operationId: string,
method: "POST" | "PUT" | "DELETE",
pathTemplate: string,
options: Readonly<{
acceptedStatuses: readonly number[];
emptyBodyStatuses?: readonly number[];
requestByteLimit: number;
responseByteLimit: number;
}>,
project: (input: never) => Readonly<{
pathValues: PathValues;
queryEntries: QueryEntries;
body: unknown;
}>,
): InstalledHttpContract<unknown, unknown, unknown> {
return Object.freeze({
contract: Object.freeze({
operationId,
method,
pathTemplate,
inputValidator: passthroughInput(`${operationId}Input`),
outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([...options.acceptedStatuses]),
emptyBodyStatuses: Object.freeze([...(options.emptyBodyStatuses ?? [])]),
// 생성은 재생 보호가 없으므로 NEVER 다. 수정·삭제는 expectedVersion 이 두 번째
// 적용을 409 로 막으므로 IDEMPOTENT 로 둘 수 있지만, 세 경우를 한 헬퍼가 만들고
// 있어 가장 보수적인 값으로 통일한다 — 재시도 예산도 0 이라 실제 차이는 없다.
retrySemantics: "NEVER" as const,
requestBody: "JSON" as const,
responseBody:
(options.emptyBodyStatuses ?? []).length > 0
? ("OPTIONAL_JSON" as const)
: ("REQUIRED_JSON" as const),
commandRecovery: null,
commandEffect: COMMAND_EFFECT,
projectRequest(input: never) {
return Object.freeze(project(input));
},
}),
frontend: Object.freeze({
policyId: `${operationId}_V1`,
requestByteLimit: options.requestByteLimit,
responseByteLimit: options.responseByteLimit,
totalDeadlineMs: 15_000,
retryBudget: 0 as const,
authProfileId: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID,
diagnosticsOperation: `techLog.management.${operationId}`,
}),
// read 쪽과 달리 여기서만 unknown 을 거친다: `emptyBodyStatuses` 유무로 responseBody 가
// 갈리는 삼항이 union 타입을 만들어, 컴파일러가 리터럴을 대상 타입과 겹친다고 보지 않는다.
}) as unknown as InstalledHttpContract<unknown, unknown, unknown>;
}
const byId = (input: never) => {
const value = input as unknown as Readonly<{ id: string }>;
return Object.freeze({ pathValues: Object.freeze({ id: value.id }), queryEntries: NO_QUERY });
};
const T = "/api/v1/studio/topics";
const P = "/api/v1/studio/projects";
const HTTP_CONTRACTS = Object.freeze([
readOperation("listStudioTopics", T, 131_072),
writeOperation(
"createTopic",
"POST",
T,
{ acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
),
writeOperation(
"updateTopic",
"PUT",
`${T}/{id}`,
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"deleteTopic",
"DELETE",
`${T}/{id}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
readOperation("listStudioProjects", P, 262_144, (input: never) => {
const value = input as unknown as Readonly<{ page?: number; size?: number }> | undefined;
const entries: (readonly [string, string])[] = [];
if (value?.page !== undefined) entries.push(["page", String(value.page)]);
if (value?.size !== undefined) entries.push(["size", String(value.size)]);
return Object.freeze({ pathValues: NO_PATH, queryEntries: Object.freeze(entries) });
}),
readOperation("getProjectForEdit", `${P}/{id}`, 262_144, byId),
writeOperation(
"createProject",
"POST",
P,
{ acceptedStatuses: [201], requestByteLimit: 4_096, responseByteLimit: 8_192 },
(input: never) =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
),
writeOperation(
"updateProject",
"PUT",
`${P}/{id}`,
{ acceptedStatuses: [200], requestByteLimit: 131_072, responseByteLimit: 262_144 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"deleteProject",
"DELETE",
`${P}/{id}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
]);
export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze(
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
);
export const TECH_LOG_MANAGEMENT_CONTRIBUTION: InstalledContractContribution = Object.freeze({
contributionId: "tech-log-management-http-v1",
featureId: TECH_LOG_FEATURE_ID,
source: Object.freeze({
kind: "EXTERNAL_PACKAGE" as const,
package: Object.freeze({
packageId: canonicalSource.packageId,
version: canonicalSource.version,
digest: canonicalSource.digest as `sha256:${string}`,
runtimeProtocolVersion: 1 as const,
sourceRevision: canonicalSource.sourceRevision,
}),
}),
http: HTTP_CONTRACTS,
events: Object.freeze([]),
});
@@ -48,6 +48,7 @@ const TECH_LOG_ROUTE_SPECS = [
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATIONS", path: "/studio/publications", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "게시 기록", navigationLabel: "게시 기록", navigationOrder: 20 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
] as const;
@@ -56,7 +56,11 @@ export function ExploreFilterForm({
item.title.toLocaleLowerCase("ko-KR") === normalizedProject,
)?.slug;
const hasActiveFilter = Boolean((showType && kind) || topic || project);
const formKey = [kind, topic, project, showType].join(":");
// 선택지는 나중에 도착하는데 아래 select 들은 `defaultValue` 를 쓰는 비제어 요소다 —
// 첫 렌더에는 맞출 option 이 없어 값이 비어버린다. 도착 여부를 키에 넣어 그때 폼을
// 다시 마운트시키면 defaultValue 가 적용된다. 제어 요소로 바꾸지 않는 이유는 이 폼이
// submit 으로 URL 을 만드는 구조라 값의 주인이 URL 이기 때문이다.
const formKey = [kind, topic, project, showType, view.ready].join(":");
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -23,7 +23,7 @@ export function SearchDialog({
// result list with a loading skeleton on every key, which is a worse dialog
// than a stale-free local filter. The predicate is the same one the catalog
// applies for a non-empty query, so the visible result set is unchanged.
const view = usePublicContent(["tech-log", "search", ""], async (queries) => ({
const view = usePublicContent(["tech-log", "search", "dialog"], async (queries) => ({
entities: await queries.searchPublicContent(""),
}));
const results = (view.data?.entities ?? []).filter((entity) =>
@@ -13,7 +13,11 @@ export function SearchPage() {
const navigate = useNavigate();
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
const query = optionalString(search.q)?.trim() ?? "";
const view = usePublicContent(["tech-log", "search", query], async (queries) => ({
// 키의 마지막 조각이 화면을 구분한다. 헤더의 검색 다이얼로그도 같은 카탈로그를 읽고
// 빈 검색어일 때 앞 세 조각이 완전히 겹치는데, 두 화면이 담아 오는 모양이 다르다
// (여기는 `results`, 다이얼로그는 `entities`). 키가 같으면 react-query 가 한쪽 캐시를
// 다른 쪽에 돌려주고, 받는 쪽은 없는 필드를 읽다 렌더에서 죽는다.
const view = usePublicContent(["tech-log", "search", "page", query], async (queries) => ({
results: await queries.searchPublicContent(query),
}));
@@ -0,0 +1,276 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
import { useStudio } from "../use-studio.ts";
/**
* 주제와 프로젝트 관리.
*
* <p>이 화면이 존재하는 이유는 문서 발행이 주제를 요구하는데 주제를 만들 곳이 없었기 때문이다.
* 그래서 범위를 목록·생성·삭제로 끊었다 — 편집(이름 변경, 단계 전환, 공개 범위)은 계약에 있고
* 백엔드도 구현돼 있으나, 그 화면은 별도 설계가 필요하다.
*
* <p>새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의
* 나머지와 같은 간격·타이포·색을 그대로 따른다.
*/
export function TaxonomyManager() {
const { managementGateway, setRequestAnnouncement } = useStudio();
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
const [projects, setProjects] = useState<ProjectIndexItem[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const [generation, setGeneration] = useState(0);
const [topicName, setTopicName] = useState("");
const [topicSlug, setTopicSlug] = useState("");
const [projectTitle, setProjectTitle] = useState("");
const reload = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError("");
void Promise.all([managementGateway.listTopics(), managementGateway.listProjects(0, 50)]).then(
([topicList, projectPage]) => {
if (cancelled) return;
setTopics(topicList);
setProjects(projectPage.items ?? []);
setLoading(false);
},
() => {
if (cancelled) return;
setError("주제와 프로젝트를 불러오지 못했습니다.");
setLoading(false);
},
);
return () => {
cancelled = true;
};
}, [managementGateway, generation]);
/**
* slug 를 비워 두면 이름에서 만든다. 한글 이름이 흔한데 slug 는 ASCII 만 받으므로, 비운 채로
* 저장하면 서버가 422 로 거절한다 — 사용자가 규칙을 몰라도 되도록 여기서 채운다.
*/
const slugify = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "");
const submitTopic = async (event: FormEvent) => {
event.preventDefault();
if (pending) return;
const name = topicName.trim();
const slug = slugify(topicSlug || topicName);
if (!name || !slug) {
setError("주제 이름과 slug 를 입력해 주세요. slug 는 영문·숫자·하이픈만 가능합니다.");
return;
}
setPending(true);
setError("");
try {
await managementGateway.createTopic({ name, slug } as TopicEdit);
setTopicName("");
setTopicSlug("");
setRequestAnnouncement(`주제 ${name} 을(를) 만들었습니다.`);
reload();
} catch {
setError("주제를 만들지 못했습니다. 같은 이름이나 slug 가 이미 있을 수 있습니다.");
} finally {
setPending(false);
}
};
const submitProject = async (event: FormEvent) => {
event.preventDefault();
if (pending) return;
const title = projectTitle.trim();
if (!title) {
setError("프로젝트 이름을 입력해 주세요.");
return;
}
setPending(true);
setError("");
try {
await managementGateway.createProject(title);
setProjectTitle("");
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
reload();
} catch {
setError("프로젝트를 만들지 못했습니다.");
} finally {
setPending(false);
}
};
const removeTopic = async (topic: TopicEdit) => {
if (pending || topic.id === undefined || topic.version === undefined) return;
setPending(true);
setError("");
try {
await managementGateway.deleteTopic(topic.id, topic.version);
setRequestAnnouncement(`주제 ${topic.name} 을(를) 삭제했습니다.`);
reload();
} catch {
setError("주제를 삭제하지 못했습니다. 이 주제를 쓰는 기록이 있을 수 있습니다.");
} finally {
setPending(false);
}
};
const removeProject = async (project: ProjectIndexItem) => {
if (pending) return;
setPending(true);
setError("");
try {
await managementGateway.deleteProject(project.id, project.version);
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 삭제했습니다.`);
reload();
} catch {
setError("프로젝트를 삭제하지 못했습니다. 연결된 기록이 있을 수 있습니다.");
} finally {
setPending(false);
}
};
return (
<div className="studio-page studio-documents-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">TAXONOMY</p>
<h1> </h1>
<p> . .</p>
</div>
</header>
<section className="studio-document-tools" aria-label="주제 만들기">
<form onSubmit={submitTopic}>
<label htmlFor="taxonomy-topic-name"> </label>
<div>
<input
id="taxonomy-topic-name"
type="text"
value={topicName}
placeholder="주제 이름"
onChange={(event) => setTopicName(event.target.value)}
/>
<input
type="text"
value={topicSlug}
placeholder="slug (비우면 이름에서 생성)"
aria-label="주제 slug"
onChange={(event) => setTopicSlug(event.target.value)}
/>
<button type="submit" disabled={pending}>
</button>
</div>
</form>
<form onSubmit={submitProject}>
<label htmlFor="taxonomy-project-title"> </label>
<div>
<input
id="taxonomy-project-title"
type="text"
value={projectTitle}
placeholder="프로젝트 이름"
onChange={(event) => setProjectTitle(event.target.value)}
/>
<button type="submit" disabled={pending}>
</button>
</div>
</form>
</section>
{loading ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{!loading && topics ? (
<>
<p className="studio-result-count">{topics.length} </p>
{topics.length ? (
<div className="studio-document-list">
{topics.map((topic) => (
<article className="studio-document-row" key={topic.id ?? topic.slug}>
<p className="studio-row-label">TOPIC</p>
<div className="studio-document-title">
<h2>{topic.name}</h2>
<p>{topic.slug}</p>
</div>
<dl>
<div>
<dt></dt>
<dd>{topic.status === "ARCHIVED" ? "보관" : "사용 중"}</dd>
</div>
</dl>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void removeTopic(topic)}
>
</button>
</article>
))}
</div>
) : (
<p className="studio-empty"> . .</p>
)}
</>
) : null}
{!loading && projects ? (
<>
<p className="studio-result-count">{projects.length} </p>
{projects.length ? (
<div className="studio-document-list">
{projects.map((project) => (
<article className="studio-document-row" key={project.id}>
<p className="studio-row-label">PROJECT</p>
<div className="studio-document-title">
<h2>{project.name}</h2>
<p>{project.currentObjective ?? "목표 미지정"}</p>
</div>
<dl>
<div>
<dt></dt>
<dd>{project.phase}</dd>
</div>
<div>
<dt></dt>
<dd>{project.targetVisibility}</dd>
</div>
</dl>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void removeProject(project)}
>
</button>
</article>
))}
</div>
) : (
<p className="studio-empty"> .</p>
)}
</>
) : null}
</div>
);
}
@@ -0,0 +1,5 @@
import { TaxonomyManager } from "../components/taxonomy-manager.tsx";
export function TaxonomyPage() {
return <TaxonomyManager />;
}
@@ -9,6 +9,7 @@ import {
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
import type {
WorkingCopy,
@@ -29,6 +30,7 @@ type StudioProviderProps = Readonly<{
// Optional so test harnesses that only exercise the document gateway keep
// working unchanged. `StudioShell` always supplies one in the running app.
createAssetGateway?: () => StudioAssetGateway;
createManagementGateway: () => ManagementGateway;
resolvePublishedLabel?: ResolvePublishedLabel;
now?: () => Date;
navigate?: (href: string) => void;
@@ -53,6 +55,7 @@ export function StudioProvider({
children,
createGateway,
createAssetGateway,
createManagementGateway,
resolvePublishedLabel = missingPublishedLabel,
now = () => new Date("2026-08-14T01:00:00.000Z"),
navigate = defaultNavigate,
@@ -64,6 +67,7 @@ export function StudioProvider({
const [assetGateway] = useState<StudioAssetGateway | null>(
() => createAssetGateway?.() ?? null,
);
const [managementGateway] = useState<ManagementGateway>(() => createManagementGateway());
const [editor, setEditor] = useState<StudioEditorState | null>(null);
const [pendingHref, setPendingHref] = useState<string | null>(null);
const [requestAnnouncement, setRequestAnnouncement] = useState("");
@@ -159,6 +163,7 @@ export function StudioProvider({
const value = useMemo<StudioContextValue>(
() => ({
gateway,
managementGateway,
assetGateway,
resolvePublishedLabel,
now,
@@ -177,6 +182,7 @@ export function StudioProvider({
clearEditor,
editor,
gateway,
managementGateway,
navigateInternal,
now,
requestAnnouncement,
@@ -58,6 +58,10 @@ export function StudioShell({ children }: StudioShellProps) {
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
[application],
);
const createManagementGateway = useCallback(
() => application.features.get(TECH_LOG_FEATURE_ID).createManagementGateway(),
[application],
);
const createAssetGateway = useCallback(
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(),
[application],
@@ -92,6 +96,7 @@ export function StudioShell({ children }: StudioShellProps) {
key={generation}
createGateway={createGateway}
createAssetGateway={createAssetGateway}
createManagementGateway={createManagementGateway}
resolvePublishedLabel={resolvePublishedLabel}
navigate={navigateInternal}
>
@@ -2,6 +2,7 @@ import { createContext, useContext, useMemo } from "react";
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
import type {
WorkingCopy,
@@ -23,6 +24,9 @@ export type StudioContextValue = Readonly<{
// `createAssetGateway` prop. `StudioShell` — the real app path — always
// supplies one, so production code sees this populated.
assetGateway: StudioAssetGateway | null;
// 주제·프로젝트 관리. `assetGateway` 와 같은 이유로 nullable 이 아니다 — 이 표면은
// MOCK 소스가 없어 항상 HTTP 이고, 없는 경우가 존재하지 않는다.
managementGateway: ManagementGateway;
resolvePublishedLabel: ResolvePublishedLabel;
now(): Date;
editor: StudioEditorState | null;
@@ -209,6 +209,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
"AssetsPage",
),
),
TECH_LOG_STUDIO_TAXONOMY: runtime(
"TECH_LOG_STUDIO_TAXONOMY",
routeModule(
() => import("./studio/pages/taxonomy-page.tsx"),
"TaxonomyPage",
),
),
TECH_LOG_STUDIO_NOT_FOUND: runtime(
"TECH_LOG_STUDIO_NOT_FOUND",
routeModule(