Compare commits
28
Commits
89a73c13c6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2420fcee9f | ||
|
|
2632850322 | ||
|
|
ad8f322b43 | ||
|
|
ab4d822956 | ||
|
|
344a163d84 | ||
|
|
60c8c82097 | ||
|
|
b3119952d5 | ||
|
|
7211dd1a92 | ||
|
|
3036b8d788 | ||
|
|
fd73bc88a1 | ||
|
|
e5770dfbc8 | ||
|
|
a7fe069a6d | ||
|
|
ab67eb912c | ||
|
|
e9b866184a | ||
|
|
84d72c4f60 | ||
|
|
6b9dc3f0a2 | ||
|
|
f9d20e8946 | ||
|
|
014f21b9e1 | ||
|
|
16e5b9f4ec | ||
|
|
0eb3c86839 | ||
|
|
c87e0a2338 | ||
|
|
b3aa304975 | ||
|
|
c03b0c77b8 | ||
|
|
1801414592 | ||
|
|
7345500ef3 | ||
|
|
fb478f951b | ||
|
|
7289ce97bb | ||
|
|
348420618d |
@@ -29,3 +29,4 @@ artifacts/tests/visual/
|
||||
# Git worktrees created inside the repository. A worktree is a checkout, not
|
||||
# source: committing one would nest a second working copy inside this one.
|
||||
.worktrees/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# TECH_LOG_STUDIO_PROJECT_EDIT accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: TECH_LOG_STUDIO_PROJECT_EDIT
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: pending
|
||||
M5 Error association: pending
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
|
||||
@@ -0,0 +1,18 @@
|
||||
# TECH_LOG_STUDIO_RELEASE_EDIT accessibility review
|
||||
|
||||
Status: pending-manual-review
|
||||
Route ID: TECH_LOG_STUDIO_RELEASE_EDIT
|
||||
Release ID:
|
||||
Reviewer:
|
||||
Reviewed at:
|
||||
Signature:
|
||||
Attestation: pending
|
||||
M1 Keyboard: pending
|
||||
M2 Visible focus: pending
|
||||
M3 Route focus: pending
|
||||
M4 Modal focus: pending
|
||||
M5 Error association: pending
|
||||
M6 Color signal: pending
|
||||
M7 Reduced motion: pending
|
||||
Screen reader: pending
|
||||
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -1202,12 +1202,24 @@
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md",
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md",
|
||||
"schemaId": "markdown",
|
||||
"production": "source-controlled"
|
||||
},
|
||||
{
|
||||
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
|
||||
@@ -1962,7 +1974,9 @@
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
|
||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
|
||||
"artifact-artifacts-tests-a11y-manual-report-json"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"registrationAllowed": false,
|
||||
"loginTheme": "keycloak",
|
||||
"accessTokenLifespan": 300,
|
||||
"ssoSessionIdleTimeout": 1800,
|
||||
"ssoSessionMaxLifespan": 36000,
|
||||
"ssoSessionIdleTimeout": 28800,
|
||||
"ssoSessionMaxLifespan": 86400,
|
||||
"roles": {
|
||||
"realm": [
|
||||
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
|
||||
|
||||
@@ -594,6 +594,20 @@ const commonSecurityRules = {
|
||||
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
|
||||
message: "Runtime script construction is prohibited by FE-OC-019.",
|
||||
},
|
||||
{
|
||||
/*
|
||||
setState 업데이터는 핸들러가 끝난 뒤, 다음 렌더에 실행된다. 그때 React 는 이미
|
||||
`event.currentTarget` 을 null 로 되돌려 놓았으므로 업데이터 안에서 그것을 읽으면
|
||||
"Cannot read properties of null" 로 화면이 통째로 죽는다.
|
||||
|
||||
타입 검사도 lint 도 잡지 못했고, 첫 입력에서야 드러났다 — 값은 핸들러가 도는 동안
|
||||
지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.
|
||||
*/
|
||||
selector:
|
||||
"CallExpression[callee.name=/^set[A-Z]/] > ArrowFunctionExpression MemberExpression[property.name='currentTarget']",
|
||||
message:
|
||||
"Read event.currentTarget before the setState updater runs — it is null by the time the updater is called.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -38,28 +38,28 @@
|
||||
},
|
||||
"contractSet": {
|
||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||
"setDigest": "sha256:38329cfe645e1d6cbfc7a9bb3e20e23b579a286ad0d1254982c70de5c6055ae8",
|
||||
"setDigest": "sha256:cdcfb628a502d71596f1162726eb395aad0f5f92cf05fd77d304f8e51c81b2fc",
|
||||
"packages": [
|
||||
{
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "65a04fc"
|
||||
"sourceRevision": "ef49d3a"
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.0.0",
|
||||
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
|
||||
"version": "2.1.0",
|
||||
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "65a04fc"
|
||||
"sourceRevision": "ef49d3a"
|
||||
},
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.0.0",
|
||||
"digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4",
|
||||
"version": "3.1.0",
|
||||
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
|
||||
"runtimeProtocolVersion": 1,
|
||||
"sourceRevision": "65a04fc"
|
||||
"sourceRevision": "ef49d3a"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -468,7 +468,12 @@ const CANONICAL_GATE_SHAPE_SHA256 =
|
||||
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
|
||||
// was first reproduced from the previous gates.json, so the computation that
|
||||
// produced this value is known to be the one the constant was pinned under.
|
||||
"187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865";
|
||||
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
|
||||
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
|
||||
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
|
||||
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
|
||||
// 재현했다.
|
||||
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
|
||||
|
||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||
const normalized = gates.map(
|
||||
@@ -523,8 +528,13 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
||||
// Alignment follow-up, item 2 added the TechLog junit report.
|
||||
// The taxonomy route added its own manual a11y evidence file — every installed
|
||||
// route carries one, and the gate checks that the two sets match exactly.
|
||||
if (contract.artifacts.length !== 132) {
|
||||
failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`);
|
||||
// The project edit route did the same: it is what finally lets a project carry
|
||||
// a purpose, a current objective, and a next step, so the public screens that
|
||||
// read those fields stop rendering blanks.
|
||||
// The release edit route followed: the editor used to open below the release
|
||||
// list, so editing meant scrolling past every release to reach it.
|
||||
if (contract.artifacts.length !== 134) {
|
||||
failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`);
|
||||
}
|
||||
if (contract.stages.length !== 5) {
|
||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||
|
||||
@@ -34,9 +34,13 @@ async function visit(
|
||||
request(): { method(): string };
|
||||
}) => {
|
||||
const u = new URL(r.url()).pathname;
|
||||
if (r.status() >= 400 && u.startsWith("/api") && !u.includes("/studio/session")) {
|
||||
if (r.status() < 400 || !u.startsWith("/api")) return;
|
||||
// 두 가지는 화면이 다루는 정상 상태다: 로그인 전 세션 탐침의 401, 그리고 아직 미리보기를
|
||||
// 만들지 않은 문서의 404. 이것들을 실패로 세면 매번 같은 줄이 뜨고, 진짜 실패가 그 사이에
|
||||
// 묻힌다 — 늑대가 왔다고 매번 외치는 점검은 아무도 읽지 않는다.
|
||||
if (u.includes("/studio/session")) return;
|
||||
if (r.status() === 404 && r.request().method() === "GET" && u.endsWith("/preview")) return;
|
||||
problems.push(`${r.status()} ${r.request().method()} ${u}`);
|
||||
}
|
||||
};
|
||||
page.on("console", onConsole);
|
||||
page.on("response", onResponse);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type {
|
||||
CreateDraftResponse,
|
||||
HomeFocusRequest,
|
||||
HomeFocusResponse,
|
||||
ProjectActivityRequest,
|
||||
ProjectActivityResponse,
|
||||
UpdateProjectActivityRequest,
|
||||
ProjectEditResponse,
|
||||
ProjectIndexPage,
|
||||
ProjectUpdateRequest,
|
||||
@@ -10,6 +15,7 @@ import type {
|
||||
TopicEdit,
|
||||
} from "../../contracts/management/contract.ts";
|
||||
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
|
||||
import { ManagementGatewayError } from "../../application/ports/management-gateway-error.ts";
|
||||
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
||||
|
||||
export type { ManagementGateway };
|
||||
@@ -23,17 +29,10 @@ const ROUTE_ID = "TECH_LOG_STUDIO";
|
||||
* 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 {
|
||||
ManagementGatewayError,
|
||||
managementFailureMessage,
|
||||
} from "../../application/ports/management-gateway-error.ts";
|
||||
|
||||
export function createHttpManagementGateway(
|
||||
deps: Readonly<{ operations: StudioOperationExecutor }>,
|
||||
@@ -46,7 +45,11 @@ export function createHttpManagementGateway(
|
||||
// the code under `error` — reading `problem.code` found nothing and every
|
||||
// failure surfaced as the literal "PROBLEM", matching no i18n key.
|
||||
const body = outcome.problem as
|
||||
| Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>
|
||||
| Readonly<{
|
||||
code?: unknown;
|
||||
detail?: unknown;
|
||||
error?: Readonly<{ code?: unknown; message?: unknown }>;
|
||||
}>
|
||||
| null;
|
||||
const code =
|
||||
typeof body?.code === "string"
|
||||
@@ -54,9 +57,15 @@ export function createHttpManagementGateway(
|
||||
: typeof body?.error?.code === "string"
|
||||
? body.error.code
|
||||
: "PROBLEM";
|
||||
throw new ManagementGatewayError(operationId, code);
|
||||
const detail =
|
||||
typeof body?.error?.message === "string"
|
||||
? body.error.message
|
||||
: typeof body?.detail === "string"
|
||||
? body.detail
|
||||
: "";
|
||||
throw new ManagementGatewayError(operationId, code, detail);
|
||||
}
|
||||
throw new ManagementGatewayError(operationId, outcome.kind);
|
||||
throw new ManagementGatewayError(operationId, outcome.kind, "");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
@@ -81,6 +90,28 @@ export function createHttpManagementGateway(
|
||||
deleteRelease: async (id: string, expectedVersion: number) => {
|
||||
await run<void>("deleteRelease", { id, expectedVersion });
|
||||
},
|
||||
listProjectActivities: (id: string) =>
|
||||
run<ProjectActivityResponse[]>("listStudioProjectActivities", { id }),
|
||||
createProjectActivity: (id: string, body: ProjectActivityRequest) =>
|
||||
run<ProjectActivityResponse>("createProjectActivity", { id, body }),
|
||||
updateProjectActivity: (
|
||||
id: string,
|
||||
activityId: string,
|
||||
body: UpdateProjectActivityRequest,
|
||||
) => run<ProjectActivityResponse>("updateProjectActivity", { id, activityId, body }),
|
||||
deleteProjectActivity: async (id: string, activityId: string, expectedVersion: number) => {
|
||||
await run<void>("deleteProjectActivity", { id, activityId, expectedVersion });
|
||||
},
|
||||
publishProject: (
|
||||
id: string,
|
||||
expectedVersion: number,
|
||||
visibility: "PUBLIC" | "UNLISTED" = "PUBLIC",
|
||||
) => run<PublishResponse>("publishProject", { id, expectedVersion, visibility }),
|
||||
unpublishProject: (id: string, expectedVersion: number) =>
|
||||
run<ProjectEditResponse>("unpublishProject", { id, expectedVersion }),
|
||||
getHomeFocus: () => run<HomeFocusResponse>("getHomeFocus", {}),
|
||||
updateHomeFocus: (body: HomeFocusRequest) =>
|
||||
run<HomeFocusResponse>("updateHomeFocus", body),
|
||||
publishRelease: (id: string, expectedVersion: number) =>
|
||||
run<PublishResponse>("publishRelease", { id, expectedVersion }),
|
||||
archiveRelease: (id: string, expectedVersion: number) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
HomeFocusItem,
|
||||
LatestRecordEntry,
|
||||
ProjectActivity,
|
||||
ProjectDecision,
|
||||
Project,
|
||||
@@ -19,12 +20,12 @@ import {
|
||||
decisionItemToDecision,
|
||||
flattenRelations,
|
||||
knowledgeListItemToRecord,
|
||||
markdownLines,
|
||||
markdownSections,
|
||||
questionListItemToRecord,
|
||||
releaseDetailToRelease,
|
||||
searchItemToEntity,
|
||||
} from "./public-content-mapping.ts";
|
||||
import type { components } from "../../contracts/public/generated.ts";
|
||||
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
|
||||
|
||||
const ROUTE_ID = "TECH_LOG_PUBLIC";
|
||||
@@ -141,13 +142,19 @@ export function createHttpPublicContentGateway(
|
||||
|
||||
const canonicalPath = String(detail.canonicalPath ?? "");
|
||||
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
|
||||
const projectOf = (entry: Readonly<{ title?: string; path?: string }> | undefined) =>
|
||||
entry?.path
|
||||
? { name: entry.title ?? "", slug: entry.path.split("/").filter(Boolean).pop() ?? "", path: entry.path }
|
||||
: undefined;
|
||||
|
||||
if (kind === "CASE") {
|
||||
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
|
||||
return Object.freeze({
|
||||
...baseOf("CASE", slug, {
|
||||
title: body.title as string,
|
||||
summary: body.problemSummary as string,
|
||||
// 제목 바로 아래에 오는 것은 문서의 요약이다. 유형별 요약(문제/범위)을 쓰면 바로 아래
|
||||
// 블록과 같은 글을 두 번 말한다.
|
||||
summary: (body.summary as string) ?? "",
|
||||
path: canonicalPath,
|
||||
primaryTopic: body.primaryTopic as never,
|
||||
primaryProject: body.primaryProject as never,
|
||||
@@ -162,12 +169,27 @@ export function createHttpPublicContentGateway(
|
||||
kind: "CASE",
|
||||
problem: (body.problemSummary as string) ?? "",
|
||||
conclusion: (body.conclusionSummary as string) ?? "",
|
||||
environment: ((body.environmentSummary as readonly string[]) ?? []).join(", "),
|
||||
// The Case document renders a verification line. The contract has no
|
||||
// field for it — verification lives in the body — so it stays empty
|
||||
// rather than being guessed from a heading.
|
||||
verification: "",
|
||||
// `environmentSummary` 는 검증 환경과 재현 조건을 그 순서로 담는다 — 서버가 비어 있지
|
||||
// 않은 것만 순서대로 넣는다. 예전에는 둘을 쉼표로 이어 붙여 한 칸에 넣고 재현 조건 칸은
|
||||
// "계약에 없다"며 비워 두었는데, 계약에는 있었고 채우는 쪽이 없었을 뿐이다.
|
||||
environment: ((body.environmentSummary as readonly string[]) ?? [])[0] ?? "",
|
||||
verification: ((body.environmentSummary as readonly string[]) ?? [])[1] ?? "",
|
||||
lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string),
|
||||
content: (body.content as string) ?? "",
|
||||
bodyAssets: Object.freeze(
|
||||
((body.bodyAssets as readonly Readonly<Record<string, unknown>>[]) ?? []).map((asset) =>
|
||||
Object.freeze({
|
||||
assetKey: asset.assetKey as string,
|
||||
assetId: asset.assetId as string,
|
||||
url: asset.url as string,
|
||||
contentType: asset.contentType as string,
|
||||
altText: (asset.altText as string) ?? "",
|
||||
width: (asset.width as number) ?? null,
|
||||
height: (asset.height as number) ?? null,
|
||||
decorative: Boolean(asset.decorative),
|
||||
}),
|
||||
),
|
||||
),
|
||||
sections: markdownSections(body.content as string),
|
||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||
}
|
||||
@@ -177,7 +199,7 @@ export function createHttpPublicContentGateway(
|
||||
return Object.freeze({
|
||||
...baseOf("REFERENCE", slug, {
|
||||
title: body.title as string,
|
||||
summary: body.purposeSummary as string,
|
||||
summary: (body.summary as string) ?? "",
|
||||
path: canonicalPath,
|
||||
primaryTopic: body.primaryTopic as never,
|
||||
primaryProject: body.primaryProject as never,
|
||||
@@ -189,48 +211,90 @@ export function createHttpPublicContentGateway(
|
||||
}),
|
||||
}),
|
||||
kind: "REFERENCE",
|
||||
purpose: (body.purposeSummary as string) ?? "",
|
||||
/*
|
||||
여기서 읽는 이름은 계약이 실제로 주는 이름이어야 한다. 한때 `purposeSummary`,
|
||||
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown` 을 읽었는데 계약에는 그런
|
||||
칸이 없다 — 전부 undefined 로 떨어져 공개 Reference 화면이 통째로 비었다. Studio 에서는
|
||||
같은 글이 다 보이므로 "공개 쪽만 안 나온다" 로 드러났다.
|
||||
|
||||
규칙과 예시는 `content` 마크다운을 잘라 만드는 것이 아니라 계약이 구조로 준다. Studio 의
|
||||
편집기가 제목과 본문을 따로 받기 때문이다.
|
||||
*/
|
||||
purpose: (body.scopeSummary as string) ?? "",
|
||||
rules: Object.freeze(
|
||||
markdownSections(body.content as string).map((section) => ({
|
||||
title: section.title,
|
||||
body: section.paragraphs.join("\n"),
|
||||
})),
|
||||
((body.rules as readonly Readonly<Record<string, unknown>>[] | undefined) ?? []).map(
|
||||
(rule) => ({
|
||||
title: String(rule.title ?? ""),
|
||||
body: String(rule.body ?? ""),
|
||||
}),
|
||||
),
|
||||
applyWhen: Object.freeze(markdownLines(body.applyWhenMarkdown as string)),
|
||||
exceptions: Object.freeze(markdownLines(body.exceptionsMarkdown as string)),
|
||||
examples: Object.freeze(markdownLines(body.examplesMarkdown as string)),
|
||||
),
|
||||
applyWhen: Object.freeze(((body.appliesTo as readonly string[] | undefined) ?? []).map(String)),
|
||||
exceptions: Object.freeze(
|
||||
((body.excludedScope as readonly string[] | undefined) ?? []).map(String),
|
||||
),
|
||||
examples: Object.freeze(((body.examples as readonly string[] | undefined) ?? []).map(String)),
|
||||
verifiedAt: dateLabel(body.lastVerifiedAt as string),
|
||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||
}
|
||||
|
||||
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
|
||||
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | undefined) ?? [];
|
||||
const pointsOf = (group: string) =>
|
||||
Object.freeze(
|
||||
points
|
||||
.filter((point) => point.group === group)
|
||||
.flatMap((point) => (point.items as readonly string[] | undefined) ?? []),
|
||||
);
|
||||
/*
|
||||
`points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는
|
||||
`{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세
|
||||
화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기
|
||||
때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다.
|
||||
|
||||
`as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. 계약의 타입을 그대로 쓰면 다음에 모양이
|
||||
바뀔 때 컴파일이 먼저 막는다.
|
||||
*/
|
||||
type QuestionPoints = components["schemas"]["QuestionPointGroup"];
|
||||
const points = body.points as QuestionPoints | undefined;
|
||||
const pointsOf = (group: keyof QuestionPoints) =>
|
||||
Object.freeze([...(points?.[group] ?? [])].map(String));
|
||||
return Object.freeze({
|
||||
...baseOf("QUESTION", slug, {
|
||||
title: body.question as string,
|
||||
summary: body.summary as string,
|
||||
path: canonicalPath,
|
||||
primaryTopic: body.primaryTopic as never,
|
||||
primaryProject: body.primaryProject as never,
|
||||
/*
|
||||
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
|
||||
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트
|
||||
칸이 늘 비어 있었다.
|
||||
|
||||
그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
|
||||
`name`/`slug` 이므로 여기서 옮겨 준다 — slug 는 경로의 마지막 마디다.
|
||||
*/
|
||||
primaryProject: projectOf(groups.primaryProject) as never,
|
||||
publishedAt: body.updatedAt as string,
|
||||
relations: flattenRelations(groups, {
|
||||
derivedCases: "이 질문에서 나온 기록",
|
||||
projectDecisions: "이 질문이 이끈 결정",
|
||||
relatedQuestions: "관련 질문",
|
||||
}),
|
||||
/*
|
||||
계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다.
|
||||
여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고,
|
||||
하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다.
|
||||
|
||||
`primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미
|
||||
보여 주므로 관계 목록에 넣지 않는다.
|
||||
*/
|
||||
relations: flattenRelations(
|
||||
{
|
||||
resultCase: groups.resultCase,
|
||||
producedDecision: groups.producedDecision,
|
||||
derivedReferences: groups.derivedReferences,
|
||||
},
|
||||
{
|
||||
resultCase: "이 질문에서 나온 기록",
|
||||
producedDecision: "이 질문이 이끈 결정",
|
||||
derivedReferences: "이 질문에서 정리된 기준",
|
||||
},
|
||||
),
|
||||
}),
|
||||
kind: "QUESTION",
|
||||
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
|
||||
facts: pointsOf("KNOWN_FACT"),
|
||||
assumptions: pointsOf("ASSUMPTION"),
|
||||
unknowns: pointsOf("UNRESOLVED"),
|
||||
constraints: pointsOf("CONSTRAINT"),
|
||||
facts: pointsOf("facts"),
|
||||
assumptions: pointsOf("assumptions"),
|
||||
unknowns: pointsOf("unknowns"),
|
||||
constraints: pointsOf("constraints"),
|
||||
options: Object.freeze([]),
|
||||
nextValidation: (body.nextVerification as string) ?? "",
|
||||
}) as unknown as Extract<PublicRecord, { kind: K }>;
|
||||
@@ -305,6 +369,33 @@ export function createHttpPublicContentGateway(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 투영이 고른 최근 기록. `entryType` 은 계약이 네 값만 허용하므로 그대로 믿고 쓴다 —
|
||||
* 서버가 이미 걸러 보낸다.
|
||||
*/
|
||||
async function getLatestEntries(): Promise<LatestRecordEntry[]> {
|
||||
const home = await read<Readonly<{ latestEntries?: readonly Readonly<Record<string, unknown>>[] }>>(
|
||||
"getPublicHome",
|
||||
{},
|
||||
);
|
||||
if (home === NOT_FOUND) return [];
|
||||
return (home.latestEntries ?? []).map((entry) => {
|
||||
const topic = entry.primaryTopic as Readonly<Record<string, unknown>> | null | undefined;
|
||||
const project = entry.primaryProject as Readonly<Record<string, unknown>> | null | undefined;
|
||||
const path = String(entry.path ?? "");
|
||||
return Object.freeze({
|
||||
id: `${String(entry.entryType ?? "")}:${path}`,
|
||||
entryType: String(entry.entryType ?? "CASE") as LatestRecordEntry["entryType"],
|
||||
title: String(entry.title ?? ""),
|
||||
summary: String(entry.summary ?? ""),
|
||||
path,
|
||||
publishedAt: String(entry.publishedAt ?? ""),
|
||||
topic: topic ? String(topic.name ?? "") : "",
|
||||
project: project ? String(project.name ?? "") : "",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
|
||||
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
|
||||
"getPublicHome",
|
||||
@@ -448,6 +539,7 @@ export function createHttpPublicContentGateway(
|
||||
getProjectDecisions,
|
||||
getProjectActivity,
|
||||
getHomeFocusItems,
|
||||
getLatestEntries,
|
||||
searchPublicContent,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,6 +194,9 @@ export function knowledgeListItemToRecord(item: Readonly<Record<string, unknown>
|
||||
environment: "",
|
||||
verification: "",
|
||||
lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string),
|
||||
// 목록 항목은 본문을 담지 않는다 — 본문은 상세 조회에서만 온다.
|
||||
content: "",
|
||||
bodyAssets: Object.freeze([]),
|
||||
sections: Object.freeze([]),
|
||||
}) as CaseRecord;
|
||||
}
|
||||
|
||||
@@ -170,16 +170,16 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
||||
const has = (id: string | null, type: CatalogEntry["type"]) => Boolean(id && dependencies.catalog.some((entry) => entry.id === id && entry.type === type));
|
||||
if (blank(document.title)) error("TITLE_REQUIRED", "/title", "제목을 입력하세요.");
|
||||
if (blank(document.slug)) error("SLUG_REQUIRED", "/slug", "slug를 입력하세요."); else if (dependencies.documents.some((item) => item.id !== document.id && item.slug === document.slug)) error("SLUG_DUPLICATE", "/slug", "중복 slug입니다.");
|
||||
if (blank(document.summary)) error("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
|
||||
if (!has(document.topicId, "TOPIC")) error("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
|
||||
if (blank(document.summary)) warning("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
|
||||
if (!has(document.topicId, "TOPIC")) warning("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
|
||||
if (!document.projectId) {
|
||||
if (document.kind === "PROJECT_DECISION") error("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
|
||||
if (document.kind === "PROJECT_DECISION") warning("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
|
||||
else warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다.");
|
||||
} else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다.");
|
||||
document.relations.forEach((relation, index) => { if (!has(relation.targetId, "RELATION")) error("RELATION_TARGET_NOT_FOUND", `/relations/${index}/targetId`, "관계 대상을 찾을 수 없습니다."); });
|
||||
if (document.kind === "CASE") {
|
||||
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
||||
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
||||
if (blank(document.problem)) warning("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) warning("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
||||
if (blank(document.bodyMarkdown)) warning("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
||||
else try {
|
||||
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
|
||||
// The key gate is the preview projection's gate, verbatim: a resolvable
|
||||
@@ -202,20 +202,20 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
|
||||
}
|
||||
}
|
||||
} catch { error("CONTENT_FORMAT_INVALID", "/bodyMarkdown", "지원하는 문법을 사용하세요."); }
|
||||
if (!document.lastVerifiedOn) error("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
|
||||
if (!document.lastVerifiedOn) warning("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
|
||||
} else if (document.kind === "REFERENCE") {
|
||||
if (blank(document.purpose)) error("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) error("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) error("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) error("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
|
||||
if (blank(document.purpose)) warning("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) warning("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) warning("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) warning("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
|
||||
} else if (document.kind === "QUESTION") {
|
||||
if (!document.questionStatus) error("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) error("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) error("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
|
||||
if (!document.questionStatus) warning("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) warning("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) warning("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
|
||||
if (document.questionStatus === "OPEN") { if (!document.unknowns.length) error("QUESTION_UNKNOWN_REQUIRED", "/unknowns", "미확인 사항이 필요합니다."); if (document.resolution) error("OPEN_QUESTION_RESOLUTION_FORBIDDEN", "/resolution", "열린 질문에는 결론을 둘 수 없습니다."); }
|
||||
if (document.questionStatus === "RESOLVED") { if (!document.resolution) error("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) error("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) error("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
|
||||
if (document.questionStatus === "RESOLVED") { if (!document.resolution) warning("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) warning("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) warning("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
|
||||
if (document.options.length < 2) warning("QUESTION_OPTIONS_FEWER_THAN_TWO", "/options", "선택지 두 개를 권장합니다.");
|
||||
} else {
|
||||
if (!document.decisionStatus) error("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
|
||||
if (!document.decidedOn) error("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
|
||||
if (blank(document.statement)) error("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
|
||||
if (blank(document.rationale)) error("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
|
||||
if (!document.consequences.length) error("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
|
||||
if (!document.decisionStatus) warning("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
|
||||
if (!document.decidedOn) warning("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
|
||||
if (blank(document.statement)) warning("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
|
||||
if (blank(document.rationale)) warning("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
|
||||
if (!document.consequences.length) warning("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
|
||||
if (!document.relations.length) error("DECISION_EVIDENCE_REQUIRED", "/relations", "근거 기록이 하나 이상 필요합니다.");
|
||||
}
|
||||
const validatedAt = dependencies.now.toISOString();
|
||||
|
||||
@@ -35,6 +35,18 @@ type PublicRecordBase = {
|
||||
relations: ReadonlyArray<PublicRelation>;
|
||||
};
|
||||
|
||||
/** 본문이 `:::evidence key="..."` 로 가리키는 Asset. 계약의 `BodyAsset` 과 같은 모양이다. */
|
||||
export type PublicBodyAsset = {
|
||||
assetKey: string;
|
||||
assetId: string;
|
||||
url: string;
|
||||
contentType: string;
|
||||
altText: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
decorative: boolean;
|
||||
};
|
||||
|
||||
export type CaseRecord = PublicRecordBase & {
|
||||
kind: "CASE";
|
||||
problem: string;
|
||||
@@ -42,6 +54,9 @@ export type CaseRecord = PublicRecordBase & {
|
||||
environment: string;
|
||||
verification: string;
|
||||
lastVerifiedLabel: string;
|
||||
/** 본문 Markdown 원문. 정적 기록은 문서 화면이 자체 본문을 쓰므로 비어 있다. */
|
||||
content: string;
|
||||
bodyAssets: ReadonlyArray<PublicBodyAsset>;
|
||||
sections: ReadonlyArray<RecordSection>;
|
||||
};
|
||||
|
||||
@@ -173,6 +188,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
|
||||
environment: "PostgreSQL 16 · Hibernate 6 · Spring Data JPA",
|
||||
verification: "FeedItem 100개, Zipf 편중 Highlight/Mention",
|
||||
lastVerifiedLabel: "2026.08.11",
|
||||
content: "",
|
||||
bodyAssets: [],
|
||||
sections: [
|
||||
{
|
||||
id: "fix-the-problem",
|
||||
@@ -256,6 +273,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
|
||||
environment: "Spring Boot · Redis · Testcontainers",
|
||||
verification: "동일한 Port 계약으로 In-memory와 Redis Adapter 계약 테스트 실행",
|
||||
lastVerifiedLabel: "2026.08.07",
|
||||
content: "",
|
||||
bodyAssets: [],
|
||||
sections: [
|
||||
{
|
||||
id: "ownership",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type HomeFocusItem,
|
||||
} from "./public-content.ts";
|
||||
import type {
|
||||
LatestRecordEntry,
|
||||
PublicContentQueries,
|
||||
PublicTopic,
|
||||
} from "../../application/ports/public-content-queries.ts";
|
||||
@@ -53,6 +54,7 @@ export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
|
||||
.filter(
|
||||
(record) =>
|
||||
!hasTopicFilter ||
|
||||
record.topicSlug.toLocaleLowerCase("ko-KR") === requestedTopic ||
|
||||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
|
||||
)
|
||||
.filter(
|
||||
@@ -119,6 +121,38 @@ export function listTopics(): PublicTopic[] {
|
||||
.map((entry) => Object.freeze(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 —
|
||||
* 프로젝트 활동과 릴리스가 원천이다.
|
||||
*/
|
||||
export function getLatestEntries(): LatestRecordEntry[] {
|
||||
const recordByPath = new Map(publicRecords.map((record) => [record.path, record]));
|
||||
const activities = projects.flatMap((project) =>
|
||||
project.activity.map((activity) => {
|
||||
const record = recordByPath.get(activity.recordPath ?? activity.path);
|
||||
const published = activity.type === "PUBLICATION" && record;
|
||||
return {
|
||||
id: activity.id,
|
||||
entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"],
|
||||
title: published ? record.title : activity.title,
|
||||
summary: activity.summary,
|
||||
path: activity.path,
|
||||
publishedAt: activity.dateTime,
|
||||
topic: record?.topic ?? project.topics[0] ?? "",
|
||||
project: project.title,
|
||||
};
|
||||
}),
|
||||
);
|
||||
/*
|
||||
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
|
||||
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
|
||||
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
|
||||
*/
|
||||
return [...activities].sort((left, right) =>
|
||||
right.publishedAt.localeCompare(left.publishedAt),
|
||||
);
|
||||
}
|
||||
|
||||
export function getHomeFocusItems(): HomeFocusItem[] {
|
||||
const project = getProject("backend-skeleton");
|
||||
const question = getRecord("QUESTION", "validate-edge-token-again");
|
||||
@@ -264,6 +298,9 @@ export const publicContentQueries = Object.freeze({
|
||||
async listTopics() {
|
||||
return listTopics();
|
||||
},
|
||||
async getLatestEntries() {
|
||||
return getLatestEntries();
|
||||
},
|
||||
async getHomeFocusItems() {
|
||||
return getHomeFocusItems();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 관리 표면의 실패.
|
||||
*
|
||||
* <p>어댑터가 아니라 포트 계층에 두는 이유는 화면이 이것을 읽어야 하기 때문이다 — `presentation`
|
||||
* 은 `adapters` 를 보지 않는다 (`feature-presentation-does-not-know-outbound-adapters`).
|
||||
* Studio 쪽 `studio-gateway-error.ts` 가 같은 이유로 같은 자리에 있다.
|
||||
*/
|
||||
export class ManagementGatewayError extends Error {
|
||||
readonly operationId: string;
|
||||
readonly code: string;
|
||||
/**
|
||||
* 서버가 준, 사람이 읽을 수 있는 이유.
|
||||
*
|
||||
* <p>이것이 없어서 화면은 실패할 때마다 자기가 지어낸 문구를 보여 줬다 — "게시 중이거나,
|
||||
* 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다" 같은 추측 셋. 서버는 정확히
|
||||
* 무엇인지 알고 그것을 보내 주는데도 그랬고, 그래서 버전 충돌도 "사용 중" 으로 읽혔다.
|
||||
*/
|
||||
readonly detail: string;
|
||||
|
||||
constructor(operationId: string, code: string, detail: string) {
|
||||
super(`${operationId}: ${code}`);
|
||||
this.name = "ManagementGatewayError";
|
||||
this.operationId = operationId;
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
/** 실패에서 서버가 준 문구를 꺼낸다. 없으면 부른 쪽이 준 기본값을 쓴다. */
|
||||
export function managementFailureMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof ManagementGatewayError && error.detail ? error.detail : fallback;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import type {
|
||||
CreateDraftResponse,
|
||||
HomeFocusRequest,
|
||||
HomeFocusResponse,
|
||||
ProjectActivityRequest,
|
||||
ProjectActivityResponse,
|
||||
UpdateProjectActivityRequest,
|
||||
ProjectEditResponse,
|
||||
ProjectIndexPage,
|
||||
ProjectUpdateRequest,
|
||||
@@ -27,6 +32,33 @@ export type ManagementGateway = Readonly<{
|
||||
createProject(title: string): Promise<CreateDraftResponse>;
|
||||
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
|
||||
deleteProject(id: string, expectedVersion: number): Promise<void>;
|
||||
/**
|
||||
* 프로젝트 게시. 프로젝트는 Studio 문서가 아니라 게시 파이프라인 밖에 있고, 그래서 문서를
|
||||
* 게시해도 그 문서가 속한 프로젝트는 비공개로 남는다 — 공개 화면(프로젝트 목록·프로필의
|
||||
* "현재 프로젝트"·홈의 focus)은 모두 게시된 프로젝트만 읽으므로, 이 호출 없이는 어디에도
|
||||
* 나타나지 않는다.
|
||||
*/
|
||||
publishProject(
|
||||
id: string,
|
||||
expectedVersion: number,
|
||||
visibility?: "PUBLIC" | "UNLISTED",
|
||||
): Promise<PublishResponse>;
|
||||
unpublishProject(id: string, expectedVersion: number): Promise<ProjectEditResponse>;
|
||||
/**
|
||||
* 프로젝트 활동. 공개 프로젝트 화면의 "활동" 은 이 목록을 투영 없이 직접 읽으므로, 여기서
|
||||
* 만든 줄이 곧 그 화면이다.
|
||||
*/
|
||||
listProjectActivities(id: string): Promise<ProjectActivityResponse[]>;
|
||||
createProjectActivity(id: string, body: ProjectActivityRequest): Promise<ProjectActivityResponse>;
|
||||
updateProjectActivity(
|
||||
id: string,
|
||||
activityId: string,
|
||||
body: UpdateProjectActivityRequest,
|
||||
): Promise<ProjectActivityResponse>;
|
||||
deleteProjectActivity(id: string, activityId: string, expectedVersion: number): Promise<void>;
|
||||
/** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */
|
||||
getHomeFocus(): Promise<HomeFocusResponse>;
|
||||
updateHomeFocus(body: HomeFocusRequest): Promise<HomeFocusResponse>;
|
||||
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
|
||||
getRelease(id: string): Promise<ReleaseEditResponse>;
|
||||
createRelease(title: string): Promise<CreateDraftResponse>;
|
||||
|
||||
@@ -36,6 +36,23 @@ type PublicRecordBase = {
|
||||
relations: ReadonlyArray<PublicRelation>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 본문이 `:::evidence key="..."` 로 가리키는 Asset.
|
||||
*
|
||||
* <p>본문에는 key 만 있고 `/media/{assetId}` 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이
|
||||
* 의도된 성질이다 — 공개 화면이 key 를 주소로 바꾸려면 이 대응이 함께 와야 한다.
|
||||
*/
|
||||
export type PublicBodyAsset = {
|
||||
assetKey: string;
|
||||
assetId: string;
|
||||
url: string;
|
||||
contentType: string;
|
||||
altText: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
decorative: boolean;
|
||||
};
|
||||
|
||||
export type CaseRecord = PublicRecordBase & {
|
||||
kind: "CASE";
|
||||
problem: string;
|
||||
@@ -43,6 +60,14 @@ export type CaseRecord = PublicRecordBase & {
|
||||
environment: string;
|
||||
verification: string;
|
||||
lastVerifiedLabel: string;
|
||||
/**
|
||||
* 본문 Markdown 원문.
|
||||
*
|
||||
* <p>`sections` 는 이것을 제목·문단·불릿으로만 줄인 것이라 표·코드·callout·evidence 가 사라진다.
|
||||
* 문서 화면은 원문을 직접 파싱한다.
|
||||
*/
|
||||
content: string;
|
||||
bodyAssets: ReadonlyArray<PublicBodyAsset>;
|
||||
sections: ReadonlyArray<RecordSection>;
|
||||
};
|
||||
|
||||
@@ -139,6 +164,24 @@ export type PublicTopic = {
|
||||
recordCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다.
|
||||
*
|
||||
* 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가
|
||||
* 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만
|
||||
* 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다.
|
||||
*/
|
||||
export type LatestRecordEntry = {
|
||||
id: string;
|
||||
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
|
||||
title: string;
|
||||
summary: string;
|
||||
path: string;
|
||||
publishedAt: string;
|
||||
topic: string;
|
||||
project: string;
|
||||
};
|
||||
|
||||
export type FocusKey = "current" | "question" | "decision";
|
||||
|
||||
export type HomeFocusItem = {
|
||||
@@ -202,6 +245,7 @@ export type PublicContentQueries = Readonly<{
|
||||
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
|
||||
*/
|
||||
listTopics(): Promise<PublicTopic[]>;
|
||||
getLatestEntries(): Promise<LatestRecordEntry[]>;
|
||||
getHomeFocusItems(): Promise<HomeFocusItem[]>;
|
||||
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
|
||||
}>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
||||
"sourceRevision": "65a04fc",
|
||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||
"sourceRevision": "ef49d3a",
|
||||
"operationIds": [
|
||||
"createCaseDraft",
|
||||
"getCaseForEdit",
|
||||
@@ -83,6 +83,7 @@
|
||||
"updateHomeFocus",
|
||||
"listStudioProjectActivities",
|
||||
"createProjectActivity",
|
||||
"updateProjectActivity"
|
||||
"updateProjectActivity",
|
||||
"deleteProjectActivity"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,3 +15,8 @@ export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
|
||||
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
|
||||
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
|
||||
export type PublishResponse = Schemas["PublishResponse"];
|
||||
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
|
||||
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
|
||||
export type ProjectActivityRequest = Schemas["ProjectActivityRequest"];
|
||||
export type UpdateProjectActivityRequest = Schemas["UpdateProjectActivityRequest"];
|
||||
export type ProjectActivityResponse = Schemas["ProjectActivityResponse"];
|
||||
|
||||
@@ -841,7 +841,7 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/studio/home-focus": {
|
||||
"/api/v1/studio/home/focus": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@@ -883,7 +883,12 @@ export interface paths {
|
||||
get?: never;
|
||||
put: operations["updateProjectActivity"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
/** @description 프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
|
||||
* 적은 줄이 공개 타임라인에 영구히 남았다.
|
||||
*
|
||||
* `AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
|
||||
* 있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다. */
|
||||
delete: operations["deleteProjectActivity"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@@ -963,6 +968,24 @@ export interface components {
|
||||
data: components["schemas"]["ReleaseEditResponse"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
HomeFocusResponseEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["HomeFocusResponse"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
ProjectActivityResponseEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["ProjectActivityResponse"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
ProjectActivityListEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["ProjectActivityResponse"][];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
PublishResponseEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
@@ -5519,7 +5542,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublishResponse"];
|
||||
"application/json": components["schemas"]["PublishResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -5528,7 +5551,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -5537,7 +5560,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -5546,7 +5569,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -5555,7 +5578,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
@@ -5564,7 +5587,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
@@ -5573,7 +5596,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -5582,7 +5605,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -5610,7 +5633,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectEditResponse"];
|
||||
"application/json": components["schemas"]["ProjectEditResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -5619,7 +5642,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -5628,7 +5651,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -5637,7 +5660,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -5646,7 +5669,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
@@ -5655,7 +5678,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
@@ -5664,7 +5687,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -5673,7 +5696,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8117,7 +8140,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HomeFocusResponse"];
|
||||
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -8126,7 +8149,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -8135,7 +8158,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -8144,7 +8167,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -8153,7 +8176,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -8162,7 +8185,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8188,7 +8211,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HomeFocusResponse"];
|
||||
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -8197,7 +8220,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -8206,7 +8229,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -8215,7 +8238,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -8224,7 +8247,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
@@ -8233,7 +8256,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
@@ -8242,7 +8265,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -8251,7 +8274,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8273,7 +8296,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectActivityResponse"][];
|
||||
"application/json": components["schemas"]["ProjectActivityListEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 401 */
|
||||
@@ -8282,7 +8305,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 403 */
|
||||
@@ -8291,7 +8314,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 404 */
|
||||
@@ -8300,7 +8323,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 500 */
|
||||
@@ -8309,7 +8332,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8337,7 +8360,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectActivityResponse"];
|
||||
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 400 */
|
||||
@@ -8346,7 +8369,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 401 */
|
||||
@@ -8355,7 +8378,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 403 */
|
||||
@@ -8364,7 +8387,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 404 */
|
||||
@@ -8373,7 +8396,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 409 */
|
||||
@@ -8382,7 +8405,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 422 */
|
||||
@@ -8391,7 +8414,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 500 */
|
||||
@@ -8400,7 +8423,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8429,7 +8452,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectActivityResponse"];
|
||||
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 400 */
|
||||
@@ -8438,7 +8461,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 401 */
|
||||
@@ -8447,7 +8470,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 403 */
|
||||
@@ -8456,7 +8479,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 404 */
|
||||
@@ -8465,7 +8488,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 409 */
|
||||
@@ -8474,7 +8497,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 422 */
|
||||
@@ -8483,7 +8506,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 500 */
|
||||
@@ -8492,7 +8515,97 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteProjectActivity: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
"X-CSRF-TOKEN": components["parameters"]["CsrfToken"];
|
||||
};
|
||||
path: {
|
||||
id: string;
|
||||
activityId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExpectedVersionRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
500: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3015,49 +3015,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PublishResponse'
|
||||
$ref: '#/components/schemas/PublishResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -3085,49 +3085,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProjectEditResponse'
|
||||
$ref: '#/components/schemas/ProjectEditResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -4973,7 +4973,7 @@ paths:
|
||||
$ref: '#/components/schemas/ExpectedVersionRequest'
|
||||
security:
|
||||
- sessionCookie: []
|
||||
/api/v1/studio/home-focus:
|
||||
/api/v1/studio/home/focus:
|
||||
get:
|
||||
operationId: getHomeFocus
|
||||
tags:
|
||||
@@ -4985,37 +4985,37 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HomeFocusResponse'
|
||||
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
security:
|
||||
- sessionCookie: []
|
||||
put:
|
||||
@@ -5030,49 +5030,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HomeFocusResponse'
|
||||
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -5101,33 +5101,31 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
$ref: '#/components/schemas/ProjectActivityListEnvelope'
|
||||
'401':
|
||||
description: '401'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: '403'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: '404'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: '500'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
post:
|
||||
operationId: createProjectActivity
|
||||
tags:
|
||||
@@ -5154,49 +5152,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
|
||||
'400':
|
||||
description: '400'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: '401'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: '403'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: '404'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: '409'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: '422'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: '500'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
/api/v1/studio/projects/{id}/activities/{activityId}:
|
||||
put:
|
||||
operationId: updateProjectActivity
|
||||
@@ -5230,49 +5228,126 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
|
||||
'400':
|
||||
description: '400'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: '401'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: '403'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: '404'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: '409'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: '422'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: '500'
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
delete:
|
||||
operationId: deleteProjectActivity
|
||||
tags:
|
||||
- Projects
|
||||
description: |-
|
||||
프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
|
||||
적은 줄이 공개 타임라인에 영구히 남았다.
|
||||
|
||||
`AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
|
||||
있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: activityId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- $ref: '#/components/parameters/CsrfToken'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExpectedVersionRequest'
|
||||
responses:
|
||||
'204':
|
||||
description: No Content
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
security:
|
||||
- sessionCookie: []
|
||||
components:
|
||||
securitySchemes:
|
||||
sessionCookie:
|
||||
@@ -5510,6 +5585,53 @@ components:
|
||||
$ref: '#/components/schemas/ReleaseEditResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
HomeFocusResponseEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- meta
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
const: true
|
||||
data:
|
||||
$ref: '#/components/schemas/HomeFocusResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
ProjectActivityResponseEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- meta
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
const: true
|
||||
data:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
ProjectActivityListEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- meta
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
const: true
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProjectActivityResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
PublishResponseEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"packageId": "@tech-log/public-contract",
|
||||
"version": "2.0.0",
|
||||
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
|
||||
"sourceRevision": "65a04fc",
|
||||
"version": "2.1.0",
|
||||
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
|
||||
"sourceRevision": "ef49d3a",
|
||||
"operationIds": [
|
||||
"getPublicSite",
|
||||
"getPublicHome",
|
||||
|
||||
@@ -373,6 +373,30 @@ export interface components {
|
||||
height?: number;
|
||||
contentType?: string;
|
||||
};
|
||||
/** @description 본문이 `:::evidence key="..."` 로 가리키는 Asset 이다.
|
||||
*
|
||||
* 본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데, `/media/{assetId}` 는 UUID
|
||||
* 로만 서빙한다 — 주소가 추측 불가능한 것이 의도된 성질이므로 key 에서 주소를 만들 수
|
||||
* 없다. 그래서 공개 화면이 key 를 해석할 수 있도록, 게시된 기록이 실제로 참조하는 Asset 을
|
||||
* 함께 준다.
|
||||
*
|
||||
* 목록은 게시 시점에 고정된 `PUBLISHED` scope 의 참조에서 온다. 게시 이후 작업본이 Asset
|
||||
* 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않는다.
|
||||
* */
|
||||
BodyAsset: {
|
||||
assetKey: string;
|
||||
/** Format: uuid */
|
||||
assetId: string;
|
||||
url: string;
|
||||
contentType: string;
|
||||
altText?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** @description 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개
|
||||
* 화면도 같은 값을 보고 `alt` 를 정해야 판정과 표시가 어긋나지 않는다.
|
||||
* */
|
||||
decorative: boolean;
|
||||
};
|
||||
RelatedEntry: {
|
||||
/** @enum {string} */
|
||||
type: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT" | "PROJECT_DECISION" | "RELEASE";
|
||||
@@ -430,7 +454,7 @@ export interface components {
|
||||
};
|
||||
LatestEntry: {
|
||||
/** @enum {string} */
|
||||
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
|
||||
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
|
||||
title: string;
|
||||
summary: string;
|
||||
path: string;
|
||||
@@ -512,6 +536,7 @@ export interface components {
|
||||
indexable: boolean;
|
||||
case: {
|
||||
title: string;
|
||||
summary?: string;
|
||||
problemSummary: string;
|
||||
conclusionSummary: string;
|
||||
environmentSummary?: string[];
|
||||
@@ -523,6 +548,10 @@ export interface components {
|
||||
tags: components["schemas"]["TagSummary"][];
|
||||
primaryProject?: components["schemas"]["ProjectSummary"];
|
||||
coverAsset?: components["schemas"]["AssetReference"];
|
||||
/** @description 본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한
|
||||
* Asset 이 더 이상 서빙되지 않는 경우다.
|
||||
* */
|
||||
bodyAssets?: components["schemas"]["BodyAsset"][];
|
||||
/** Format: date-time */
|
||||
publishedAt: string;
|
||||
/** Format: date-time */
|
||||
@@ -543,9 +572,15 @@ export interface components {
|
||||
indexable: boolean;
|
||||
reference: {
|
||||
title: string;
|
||||
summary?: string;
|
||||
scopeSummary: string;
|
||||
appliesTo: string[];
|
||||
excludedScope: string[];
|
||||
rules?: {
|
||||
title: string;
|
||||
body: string;
|
||||
}[];
|
||||
examples?: string[];
|
||||
/** @enum {string} */
|
||||
freshnessStatus: "CURRENT" | "REVIEW_DUE" | "HISTORICAL";
|
||||
content: string;
|
||||
@@ -642,6 +677,9 @@ export interface components {
|
||||
nextStep?: string;
|
||||
systemOverviewMarkdown?: string;
|
||||
technologies?: string[];
|
||||
/** @description 이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||
* 화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다. */
|
||||
topics?: components["schemas"]["TopicSummary"][];
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tech Log Public API
|
||||
version: 2.0.0
|
||||
version: 2.1.0
|
||||
description: |
|
||||
Tech Log 공개 조회 계약이다. 인증이 필요하지 않다.
|
||||
|
||||
@@ -815,6 +815,47 @@ components:
|
||||
type: integer
|
||||
contentType:
|
||||
type: string
|
||||
BodyAsset:
|
||||
type: object
|
||||
description: |
|
||||
본문이 `:::evidence key="..."` 로 가리키는 Asset 이다.
|
||||
|
||||
본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데, `/media/{assetId}` 는 UUID
|
||||
로만 서빙한다 — 주소가 추측 불가능한 것이 의도된 성질이므로 key 에서 주소를 만들 수
|
||||
없다. 그래서 공개 화면이 key 를 해석할 수 있도록, 게시된 기록이 실제로 참조하는 Asset 을
|
||||
함께 준다.
|
||||
|
||||
목록은 게시 시점에 고정된 `PUBLISHED` scope 의 참조에서 온다. 게시 이후 작업본이 Asset
|
||||
을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않는다.
|
||||
required:
|
||||
- assetKey
|
||||
- assetId
|
||||
- url
|
||||
- contentType
|
||||
- decorative
|
||||
properties:
|
||||
assetKey:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
assetId:
|
||||
type: string
|
||||
format: uuid
|
||||
url:
|
||||
type: string
|
||||
contentType:
|
||||
type: string
|
||||
altText:
|
||||
type: string
|
||||
width:
|
||||
type: integer
|
||||
height:
|
||||
type: integer
|
||||
decorative:
|
||||
type: boolean
|
||||
description: |
|
||||
장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개
|
||||
화면도 같은 값을 보고 `alt` 를 정해야 판정과 표시가 어긋나지 않는다.
|
||||
RelatedEntry:
|
||||
type: object
|
||||
required:
|
||||
@@ -981,6 +1022,7 @@ components:
|
||||
enum:
|
||||
- CASE
|
||||
- REFERENCE
|
||||
- QUESTION
|
||||
- PROJECT_ACTIVITY
|
||||
- RELEASE
|
||||
title:
|
||||
@@ -1198,6 +1240,12 @@ components:
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
|
||||
#
|
||||
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
|
||||
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
|
||||
summary:
|
||||
type: string
|
||||
problemSummary:
|
||||
type: string
|
||||
conclusionSummary:
|
||||
@@ -1224,6 +1272,13 @@ components:
|
||||
$ref: '#/components/schemas/ProjectSummary'
|
||||
coverAsset:
|
||||
$ref: '#/components/schemas/AssetReference'
|
||||
bodyAssets:
|
||||
type: array
|
||||
description: |
|
||||
본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한
|
||||
Asset 이 더 이상 서빙되지 않는 경우다.
|
||||
items:
|
||||
$ref: '#/components/schemas/BodyAsset'
|
||||
publishedAt: *id003
|
||||
updatedAt: *id003
|
||||
lastVerifiedAt: *id003
|
||||
@@ -1277,6 +1332,12 @@ components:
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
|
||||
#
|
||||
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
|
||||
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
|
||||
summary:
|
||||
type: string
|
||||
scopeSummary:
|
||||
type: string
|
||||
appliesTo:
|
||||
@@ -1287,6 +1348,23 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
# Reference 의 본문은 `content` 마크다운이 아니라 이 두 칸에 있다. Studio 의 Reference
|
||||
# 편집기는 규칙(제목+본문)과 예시를 따로 받고 body_markdown 은 비워 두므로, 이것을
|
||||
# 내보내지 않으면 공개 화면에 판단 기준과 예시가 통째로 빠진다.
|
||||
rules:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [title, body]
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
examples:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
freshnessStatus:
|
||||
type: string
|
||||
enum:
|
||||
@@ -1524,6 +1602,13 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
topics:
|
||||
type: array
|
||||
description: |-
|
||||
이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||
화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다.
|
||||
items:
|
||||
$ref: '#/components/schemas/TopicSummary'
|
||||
updatedAt: *id003
|
||||
featuredDecision:
|
||||
$ref: '#/components/schemas/RelatedEntry'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.0.0",
|
||||
"digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4",
|
||||
"sourceRevision": "65a04fc",
|
||||
"version": "3.1.0",
|
||||
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
|
||||
"sourceRevision": "ef49d3a",
|
||||
"operationIds": [
|
||||
"getStudioSession",
|
||||
"getStudioDashboard",
|
||||
|
||||
@@ -1074,7 +1074,32 @@ export interface components {
|
||||
height: number | null;
|
||||
decorative: boolean;
|
||||
};
|
||||
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"];
|
||||
/** @description `---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
|
||||
* */
|
||||
ThematicBreakBlock: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "THEMATIC_BREAK";
|
||||
};
|
||||
/** @description `` 로 쓴 그림이다.
|
||||
*
|
||||
* `EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
|
||||
* 시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
|
||||
* 링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
|
||||
* */
|
||||
ImageBlock: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "IMAGE";
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string | null;
|
||||
};
|
||||
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"] | components["schemas"]["ThematicBreakBlock"] | components["schemas"]["ImageBlock"];
|
||||
CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
||||
/** @enum {string} */
|
||||
kind: "CASE";
|
||||
@@ -1142,7 +1167,7 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
status: "PROPOSED" | "ADOPTED";
|
||||
/** Format: date */
|
||||
decidedOn: string;
|
||||
decidedOn: string | null;
|
||||
statement: string;
|
||||
rationale: string;
|
||||
consequences: components["schemas"]["OrderedText"][];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tech Log Studio API
|
||||
version: 3.0.0
|
||||
version: 3.1.0
|
||||
description: |
|
||||
Tech Log Studio orchestration 계약이다.
|
||||
|
||||
@@ -871,7 +871,7 @@ components:
|
||||
required: [id, text, order]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
text: { type: string, minLength: 1, maxLength: 100000 }
|
||||
text: { type: string, maxLength: 100000 }
|
||||
order: { type: integer, minimum: 0 }
|
||||
ReferenceRule:
|
||||
type: object
|
||||
@@ -1242,7 +1242,7 @@ components:
|
||||
kind: { $ref: "#/components/schemas/RecordKind" }
|
||||
slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
|
||||
title: { type: string, minLength: 1, maxLength: 120 }
|
||||
summary: { type: string, minLength: 1, maxLength: 300 }
|
||||
summary: { type: string, maxLength: 300 }
|
||||
publicPath: { type: string, minLength: 1, maxLength: 500 }
|
||||
topic: { $ref: "#/components/schemas/DisplayTarget" }
|
||||
project:
|
||||
@@ -1257,7 +1257,7 @@ components:
|
||||
required: [type, text]
|
||||
properties:
|
||||
type: { type: string, enum: [TEXT] }
|
||||
text: { type: string, minLength: 1, maxLength: 100000 }
|
||||
text: { type: string, maxLength: 100000 }
|
||||
InlineContainer:
|
||||
type: object
|
||||
required: [type, children]
|
||||
@@ -1321,7 +1321,10 @@ components:
|
||||
properties:
|
||||
type: { type: string, enum: [HEADING] }
|
||||
id: { type: string, minLength: 1, maxLength: 200 }
|
||||
level: { type: integer, minimum: 2, maximum: 4 }
|
||||
# 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
|
||||
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
|
||||
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
|
||||
level: { type: integer, minimum: 1, maximum: 6 }
|
||||
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
||||
ParagraphBlock:
|
||||
type: object
|
||||
@@ -1452,6 +1455,29 @@ components:
|
||||
width: { type: [integer, "null"], minimum: 1 }
|
||||
height: { type: [integer, "null"], minimum: 1 }
|
||||
decorative: { type: boolean }
|
||||
ThematicBreakBlock:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: |
|
||||
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
|
||||
required: [type]
|
||||
properties:
|
||||
type: { type: string, enum: [THEMATIC_BREAK] }
|
||||
ImageBlock:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: |
|
||||
`` 로 쓴 그림이다.
|
||||
|
||||
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
|
||||
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
|
||||
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
|
||||
required: [type, src, alt, title]
|
||||
properties:
|
||||
type: { type: string, enum: [IMAGE] }
|
||||
src: { type: string, minLength: 1, maxLength: 500 }
|
||||
alt: { type: string, maxLength: 300 }
|
||||
title: { type: [string, "null"], maxLength: 300 }
|
||||
CaseRenderBlock:
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/HeadingBlock" }
|
||||
@@ -1463,6 +1489,8 @@ components:
|
||||
- { $ref: "#/components/schemas/DataTableBlock" }
|
||||
- { $ref: "#/components/schemas/CalloutBlock" }
|
||||
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
|
||||
- { $ref: "#/components/schemas/ThematicBreakBlock" }
|
||||
- { $ref: "#/components/schemas/ImageBlock" }
|
||||
discriminator:
|
||||
propertyName: type
|
||||
mapping:
|
||||
@@ -1475,6 +1503,8 @@ components:
|
||||
DATA_TABLE: "#/components/schemas/DataTableBlock"
|
||||
CALLOUT: "#/components/schemas/CalloutBlock"
|
||||
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
|
||||
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
|
||||
IMAGE: "#/components/schemas/ImageBlock"
|
||||
CasePublicRenderModel:
|
||||
unevaluatedProperties: false
|
||||
allOf:
|
||||
@@ -1483,8 +1513,8 @@ components:
|
||||
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks]
|
||||
properties:
|
||||
kind: { type: string, enum: [CASE] }
|
||||
problem: { type: string, minLength: 1, maxLength: 100000 }
|
||||
conclusion: { type: string, minLength: 1, maxLength: 100000 }
|
||||
problem: { type: string, maxLength: 100000 }
|
||||
conclusion: { type: string, maxLength: 100000 }
|
||||
environment: { type: string, maxLength: 100000 }
|
||||
reproduction: { type: string, maxLength: 100000 }
|
||||
lastVerifiedOn: { type: string, format: date }
|
||||
@@ -1497,7 +1527,7 @@ components:
|
||||
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
|
||||
properties:
|
||||
kind: { type: string, enum: [REFERENCE] }
|
||||
purpose: { type: string, minLength: 1, maxLength: 100000 }
|
||||
purpose: { type: string, maxLength: 100000 }
|
||||
rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
|
||||
applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||
@@ -1508,7 +1538,7 @@ components:
|
||||
additionalProperties: false
|
||||
required: [summary, evidenceTarget, linkLabel]
|
||||
properties:
|
||||
summary: { type: string, minLength: 1, maxLength: 100000 }
|
||||
summary: { type: string, maxLength: 100000 }
|
||||
evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" }
|
||||
linkLabel: { type: string, minLength: 1, maxLength: 120 }
|
||||
QuestionPublicRenderModel:
|
||||
@@ -1528,7 +1558,7 @@ components:
|
||||
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
|
||||
nextValidation: { type: string, minLength: 1, maxLength: 100000 }
|
||||
nextValidation: { type: string, maxLength: 100000 }
|
||||
resolution:
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/ResolvedQuestionResolution" }
|
||||
@@ -1542,9 +1572,12 @@ components:
|
||||
properties:
|
||||
kind: { type: string, enum: [PROJECT_DECISION] }
|
||||
status: { type: string, enum: [PROPOSED, ADOPTED] }
|
||||
decidedOn: { type: string, format: date }
|
||||
statement: { type: string, minLength: 1, maxLength: 100000 }
|
||||
rationale: { type: string, minLength: 1, maxLength: 100000 }
|
||||
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
|
||||
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
|
||||
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
|
||||
decidedOn: { type: [string, "null"], format: date }
|
||||
statement: { type: string, maxLength: 100000 }
|
||||
rationale: { type: string, maxLength: 100000 }
|
||||
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
|
||||
PublicRenderModel:
|
||||
oneOf:
|
||||
|
||||
@@ -310,6 +310,103 @@ const HTTP_CONTRACTS = Object.freeze([
|
||||
});
|
||||
},
|
||||
),
|
||||
readOperation("listStudioProjectActivities", `${P}/{id}/activities`, 262_144, byId),
|
||||
writeOperation(
|
||||
"createProjectActivity",
|
||||
"POST",
|
||||
`${P}/{id}/activities`,
|
||||
{ acceptedStatuses: [201], 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(
|
||||
"updateProjectActivity",
|
||||
"PUT",
|
||||
`${P}/{id}/activities/{activityId}`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{
|
||||
id: string;
|
||||
activityId: string;
|
||||
body: unknown;
|
||||
}>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: value.body,
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"deleteProjectActivity",
|
||||
"DELETE",
|
||||
`${P}/{id}/activities/{activityId}`,
|
||||
{
|
||||
acceptedStatuses: [204],
|
||||
emptyBodyStatuses: [204],
|
||||
requestByteLimit: 1_024,
|
||||
responseByteLimit: 1_024,
|
||||
},
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{
|
||||
id: string;
|
||||
activityId: string;
|
||||
expectedVersion: number;
|
||||
}>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"publishProject",
|
||||
"POST",
|
||||
`${P}/{id}/publish`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{
|
||||
id: string;
|
||||
expectedVersion: number;
|
||||
visibility: "PUBLIC" | "UNLISTED";
|
||||
}>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion, visibility: value.visibility },
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"unpublishProject",
|
||||
"POST",
|
||||
`${P}/{id}/unpublish`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 },
|
||||
(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("getHomeFocus", `${D}/home/focus`, 8_192),
|
||||
writeOperation(
|
||||
"updateHomeFocus",
|
||||
"PUT",
|
||||
`${D}/home/focus`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 4_096, responseByteLimit: 8_192 },
|
||||
(input: never) =>
|
||||
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
|
||||
),
|
||||
writeOperation(
|
||||
"publishRelease",
|
||||
"POST",
|
||||
|
||||
@@ -49,7 +49,9 @@ const TECH_LOG_ROUTE_SPECS = [
|
||||
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_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
|
||||
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASE_EDIT", path: "/studio/releases/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "릴리즈 편집", navigationLabel: null, navigationOrder: null }),
|
||||
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;
|
||||
|
||||
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
|
||||
return line;
|
||||
}
|
||||
|
||||
/*
|
||||
`:::name key="value"` 를 remark-directive 가 읽는 `:::name{key="value"}` 로 바꾼다.
|
||||
|
||||
이름과 나머지 사이의 경계를 `\s` 로 못 박는 것이 중요하다. 예전에는 이름을
|
||||
`[a-z0-9-]*` 로 두고 나머지가 `{` 로 시작하지 않기만 요구했는데, 정규식이 되돌아가며
|
||||
이름의 마지막 글자를 나머지 쪽으로 넘겨 그 조건을 피해 갔다 — `:::note` 는 이름 `not`
|
||||
에 본문 `e` 가 되어 `:::not{e}` 로, 이미 중괄호를 쓴 `:::table{id="t"}` 는
|
||||
`:::tabl{e{id="t"}}` 로 망가졌다. 그래서 속성 없는 디렉티브는 이름이 통째로 바뀌고,
|
||||
중괄호 형태는 아예 해석되지 않았다.
|
||||
*/
|
||||
return line.replace(
|
||||
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i,
|
||||
/^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
|
||||
(
|
||||
_match,
|
||||
marker: string,
|
||||
attributes: string,
|
||||
newline: string,
|
||||
) => `${marker}{${attributes.trim()}}${newline}`,
|
||||
) => {
|
||||
const trimmed = attributes.trim();
|
||||
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
|
||||
},
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
|
||||
|
||||
const trustedRelativeLinkOrigin = "https://techlog.invalid";
|
||||
|
||||
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
|
||||
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
|
||||
note: "참고",
|
||||
tip: "도움말",
|
||||
warning: "주의",
|
||||
danger: "위험",
|
||||
};
|
||||
|
||||
function hasAsciiControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
@@ -326,8 +347,8 @@ function headingBlock(
|
||||
node: Heading,
|
||||
usedIds: Set<string>,
|
||||
): components["schemas"]["HeadingBlock"] {
|
||||
if (node.depth < 2 || node.depth > 4) {
|
||||
invalid(node, "only heading levels 2 through 4 are supported");
|
||||
if (node.depth < 1 || node.depth > 6) {
|
||||
invalid(node, "only heading levels 1 through 6 are supported");
|
||||
}
|
||||
|
||||
const children = [...node.children];
|
||||
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
|
||||
return inlineFromNodes(cell.children);
|
||||
}
|
||||
|
||||
/**
|
||||
* `:::table` 없이 쓴 GFM 표에 붙일 값.
|
||||
*
|
||||
* <p>서버 렌더러는 파이프 표를 그대로 읽는다 — `:::table` 이라는 directive 자체를 모른다.
|
||||
* 여기서만 감싸기를 요구하면 같은 본문이 Studio 와 공개 화면에서 다르게 읽히므로, 감싸지 않은
|
||||
* 표도 받는다. `id` 는 자리 순서로 만들고 `caption` 은 비운다. 설명이 필요하면 `:::table` 로
|
||||
* 감싸 `caption` 을 주면 된다.
|
||||
*/
|
||||
function bareTableAttributes(index: number): Record<string, string> {
|
||||
return { id: `table-${index}`, caption: "", rowHeaderColumn: "none" };
|
||||
}
|
||||
|
||||
function tableBlock(
|
||||
node: ContainerDirective,
|
||||
node: ContainerDirective | Table,
|
||||
usedIds: Set<string>,
|
||||
bareIndex?: number,
|
||||
): components["schemas"]["DataTableBlock"] {
|
||||
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]);
|
||||
if (node.children.length !== 1 || node.children[0].type !== "table") {
|
||||
const bare = node.type === "table";
|
||||
const attributes = bare
|
||||
? bareTableAttributes(bareIndex!)
|
||||
: attributesOf(node as ContainerDirective, ["id", "caption", "rowHeaderColumn"]);
|
||||
if (!bare) {
|
||||
const container = node as ContainerDirective;
|
||||
if (container.children.length !== 1 || container.children[0].type !== "table") {
|
||||
invalid(node, "table directive must contain exactly one GFM table");
|
||||
}
|
||||
const table = node.children[0] as Table;
|
||||
}
|
||||
const table = (bare ? node : (node as ContainerDirective).children[0]) as Table;
|
||||
if (table.children.length === 0) invalid(table, "table header is required");
|
||||
|
||||
const id = attributes.id;
|
||||
@@ -506,6 +546,25 @@ function directiveBlock(
|
||||
content: inlineFromNodes((node.children[0] as Paragraph).children),
|
||||
};
|
||||
}
|
||||
/*
|
||||
서버 렌더러가 아는 callout 이름이다(`note`/`tip` 은 정보, `warning`/`danger` 는 경고).
|
||||
`:::callout tone="..."` 만 받으면 서버가 정상으로 읽는 본문을 여기서 거절하게 되므로 둘 다
|
||||
받는다. 이름이 곧 tone 이라 속성이 없고, 라벨은 이름에서 만든다.
|
||||
*/
|
||||
case "note":
|
||||
case "tip":
|
||||
case "warning":
|
||||
case "danger": {
|
||||
if (node.children.length !== 1 || node.children[0].type !== "paragraph") {
|
||||
invalid(node, `${node.name} directive must contain exactly one paragraph`);
|
||||
}
|
||||
return {
|
||||
type: "CALLOUT",
|
||||
tone: node.name === "note" || node.name === "tip" ? "info" : "warning",
|
||||
label: CALLOUT_LABELS[node.name],
|
||||
content: inlineFromNodes((node.children[0] as Paragraph).children),
|
||||
};
|
||||
}
|
||||
case "evidence": {
|
||||
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
|
||||
if (node.children.length !== 0) {
|
||||
@@ -533,10 +592,34 @@ function directiveBlock(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
|
||||
*
|
||||
* <p>Markdown 에서 `` 는 문단 안의 inline 이다. 인라인 유니온에는 그림이 없고
|
||||
* 앞으로도 둘 이유가 없다 — 글 가운데 끼워 넣는 그림은 이 문서 형식이 다루는 대상이 아니다.
|
||||
* 그래서 "문단이 그림 하나로만 이루어진 경우"만 블록으로 올린다.
|
||||
*/
|
||||
function imageBlockOf(node: Paragraph): components["schemas"]["ImageBlock"] | null {
|
||||
const visible = node.children.filter(
|
||||
(child) => !(child.type === "text" && child.value.trim() === ""),
|
||||
);
|
||||
if (visible.length !== 1) return null;
|
||||
const only = visible[0]!;
|
||||
if (only.type !== "image") return null;
|
||||
const image = only as unknown as { url: string; alt?: string | null; title?: string | null };
|
||||
if (!isSafeLink(image.url)) invalid(node, `unsafe image URL: ${image.url}`);
|
||||
return {
|
||||
type: "IMAGE",
|
||||
src: image.url,
|
||||
alt: image.alt ?? "",
|
||||
title: image.title ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function paragraphBlock(
|
||||
node: Paragraph,
|
||||
): components["schemas"]["ParagraphBlock"] {
|
||||
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
||||
): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
|
||||
return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
||||
}
|
||||
|
||||
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||
listItemCount += 1;
|
||||
return `list-item-${listItemCount}`;
|
||||
};
|
||||
let bareTableCount = 0;
|
||||
|
||||
return tree.children.map((node: Content): CaseAuthoringBlock => {
|
||||
switch (node.type) {
|
||||
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||
return codeBlock(node);
|
||||
case "containerDirective":
|
||||
return directiveBlock(node, usedIds);
|
||||
case "html":
|
||||
case "table": {
|
||||
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
|
||||
// 공개 화면에서 다르게 읽힌다.
|
||||
bareTableCount += 1;
|
||||
return tableBlock(node, usedIds, bareTableCount);
|
||||
}
|
||||
case "thematicBreak":
|
||||
return { type: "THEMATIC_BREAK" };
|
||||
case "html":
|
||||
case "definition":
|
||||
case "yaml":
|
||||
case "table":
|
||||
case "footnoteDefinition":
|
||||
case "leafDirective":
|
||||
return invalid(node, `unsupported block syntax: ${node.type}`);
|
||||
|
||||
@@ -131,7 +131,18 @@ function renderContext(
|
||||
|
||||
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
|
||||
if (input.kind === "PROJECT_DECISION") {
|
||||
if (!project?.publicPath) fail("PROJECT public path is required");
|
||||
/*
|
||||
Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 프로젝트가
|
||||
공개되어 있지 않으면 이 문서에는 아직 주소가 없다.
|
||||
|
||||
예전 문구는 "PROJECT public path is required" 였다. 사실이지만 작성자가 할 일을 말해 주지
|
||||
않는다 — 무엇을 어디서 눌러야 하는지 적는다.
|
||||
*/
|
||||
if (!project?.publicPath) {
|
||||
fail(
|
||||
"이 Decision 의 공개 주소는 프로젝트 주소 아래에 있습니다. 주제·프로젝트 화면에서 이 프로젝트를 먼저 게시해 주세요.",
|
||||
);
|
||||
}
|
||||
return `${project.publicPath}/decisions#${input.slug}`;
|
||||
}
|
||||
const prefix =
|
||||
@@ -186,7 +197,7 @@ export function projectWorkingCopy(
|
||||
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
|
||||
if (!topic) fail("TOPIC catalog entry is required");
|
||||
if (input.kind === "PROJECT_DECISION" && !project) {
|
||||
fail("PROJECT catalog entry is required");
|
||||
fail("Decision 은 프로젝트에 속합니다. 기본 정보에서 프로젝트를 골라 주세요.");
|
||||
}
|
||||
|
||||
const base = {
|
||||
@@ -273,12 +284,16 @@ export function projectWorkingCopy(
|
||||
|
||||
case "PROJECT_DECISION":
|
||||
if (!input.decisionStatus) fail("Decision status is required");
|
||||
if (!input.decidedOn) fail("Decision date is required");
|
||||
/*
|
||||
결정일은 비어 있어도 모델을 만든다. 검증이 그것을 경고로만 다루므로 날짜 없이 게시할 수
|
||||
있는데, 여기서 막으면 그 문서는 미리보기조차 열리지 않는다 — 작성자는 "경고라면서 왜
|
||||
안 되냐"를 만난다. 화면이 "미정"이라고 말하면 된다.
|
||||
*/
|
||||
return {
|
||||
...base,
|
||||
kind: "PROJECT_DECISION",
|
||||
status: input.decisionStatus,
|
||||
decidedOn: input.decidedOn,
|
||||
decidedOn: input.decidedOn ?? null,
|
||||
statement: input.statement,
|
||||
rationale: input.rationale,
|
||||
consequences: ordered(input.consequences),
|
||||
|
||||
@@ -188,6 +188,14 @@ function serializeBlock(block: CaseRenderBlock): string {
|
||||
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
|
||||
":::",
|
||||
].join("\n");
|
||||
case "THEMATIC_BREAK":
|
||||
return "---";
|
||||
case "IMAGE":
|
||||
// 제목은 Markdown 이 따옴표로 감싼다. 없으면 붙이지 않아야 다시 읽었을 때 빈 제목이 되지
|
||||
// 않는다.
|
||||
return block.title === null
|
||||
? ``
|
||||
: `}")`;
|
||||
default:
|
||||
return assertNever(block);
|
||||
}
|
||||
|
||||
@@ -11,3 +11,28 @@ export function createLocalId(
|
||||
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
|
||||
return `${prefix}-${now()}-${entropy}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집기가 새로 만든 목록 항목(관계·근거·규칙·선택지·순서 있는 문장)의 id.
|
||||
*
|
||||
* `createLocalId` 와 나눠 두는 이유는 이 값이 화면 밖으로 나가기 때문이다. 그쪽은 접두사를 붙여
|
||||
* `relation-<uuid>` 같은 문자열을 만들고, 그건 React key 나 Idempotency-Key 로는 좋지만 계약에는
|
||||
* 넣을 수 없다 — 계약이 요구하는 형식은 uuid 이고, 접두사가 붙은 값은 서버가 파싱조차 하지 못해
|
||||
* 400 (`InvalidFormatException`) 이 된다. 저장 버튼이 "계약을 어겼다"는 말만 남기고 아무것도 저장하지
|
||||
* 않던 이유가 이것이었다.
|
||||
*
|
||||
* 서버는 자기가 소유하지 않은 id 를 신뢰하지 않고 새로 부여한다({@code StudioRelationStore.replace}).
|
||||
* 그래서 여기서 만드는 값은 "이 줄은 새것"이라는 표시일 뿐, 저장 뒤의 진짜 id 는 서버가 정한다.
|
||||
*/
|
||||
export function createNewItemId(
|
||||
source: RandomUuidSource | null | undefined = globalThis.crypto,
|
||||
random: () => number = Math.random,
|
||||
): string {
|
||||
const uuid = source?.randomUUID?.();
|
||||
if (uuid) return uuid;
|
||||
// randomUUID 가 없는 환경(비보안 컨텍스트)을 위한 대비. 형식만 uuid v4 를 지키면 된다 — 값 자체는
|
||||
// 서버가 어차피 새로 부여한다.
|
||||
const hex = (length: number) =>
|
||||
Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join("");
|
||||
return `${hex(8)}-${hex(4)}-4${hex(3)}-${"89ab"[Math.floor(random() * 4)]}${hex(3)}-${hex(12)}`;
|
||||
}
|
||||
|
||||
@@ -180,7 +180,42 @@ function resolvePublicEvidenceAssetDescriptor(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시된 본문을 블록으로 바꾼다.
|
||||
*
|
||||
* <p>예전에는 하드코딩된 슬러그 하나만 진짜 파서를 탔고 나머지는 모두 {@link genericCaseBlocks}
|
||||
* 를 거쳤다 — 정규식이 `##` 제목과 `-` 불릿만 알아보므로 표·코드·callout 은 물론 evidence
|
||||
* directive 까지 글자 그대로 문단이 되어 공개 화면에 그대로 보였다.
|
||||
*
|
||||
* <p>본문이 지원하지 않는 문법을 담고 있으면 화면 전체를 잃는 대신 예전 방식으로 돌아간다.
|
||||
* 읽는 사람에게는 덜 정확한 화면이 빈 화면보다 낫다.
|
||||
*/
|
||||
function caseBodyBlocks(record: CaseRecord): CaseAuthoringBlock[] {
|
||||
if (record.slug === "collection-fetch-join-pagination") {
|
||||
return parseCaseContent(fetchJoinBody);
|
||||
}
|
||||
if (!record.content.trim()) return genericCaseBlocks(record);
|
||||
try {
|
||||
return parseCaseContent(record.content);
|
||||
} catch {
|
||||
return genericCaseBlocks(record);
|
||||
}
|
||||
}
|
||||
|
||||
export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
||||
/*
|
||||
본문은 evidence 를 key 로만 가리키고 `/media/{assetId}` 는 UUID 로만 서빙하므로, 계약이 함께
|
||||
준 `bodyAssets` 로 key 를 주소로 바꾼다. 대응이 없는 key 는 그 블록을 지운다 — 해석기가
|
||||
던지면 문서 전체가 사라지고, 남겨 두면 주소 없는 그림 자리가 남는다.
|
||||
*/
|
||||
const assetsByKey = new Map(record.bodyAssets.map((asset) => [asset.assetKey, asset]));
|
||||
const blocks = caseBodyBlocks(record).filter(
|
||||
(block) =>
|
||||
block.type !== "EVIDENCE_FIGURE" ||
|
||||
record.slug === "collection-fetch-join-pagination" ||
|
||||
assetsByKey.has(block.key),
|
||||
);
|
||||
|
||||
const model = resolveCaseEvidenceAssets(
|
||||
{
|
||||
...publicRenderModelBase(record),
|
||||
@@ -190,18 +225,37 @@ export function CaseDocumentPage({ record }: { record: CaseRecord }) {
|
||||
environment: record.environment,
|
||||
reproduction: record.verification,
|
||||
lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"),
|
||||
bodyBlocks:
|
||||
record.slug === "collection-fetch-join-pagination"
|
||||
? parseCaseContent(fetchJoinBody)
|
||||
: genericCaseBlocks(record),
|
||||
bodyBlocks: blocks,
|
||||
},
|
||||
(key) => {
|
||||
const asset = assetsByKey.get(key);
|
||||
if (!asset) return resolvePublicEvidenceAssetDescriptor(key);
|
||||
return {
|
||||
assetId: asset.assetId,
|
||||
assetKey: asset.assetKey,
|
||||
mediaType: asset.contentType,
|
||||
publicPath: asset.url,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
decorative: asset.decorative,
|
||||
};
|
||||
},
|
||||
resolvePublicEvidenceAssetDescriptor,
|
||||
);
|
||||
|
||||
return (
|
||||
<PublicRecordRenderer
|
||||
model={model}
|
||||
resolveEvidenceAsset={resolvePublicEvidenceAsset}
|
||||
resolveEvidenceAsset={(key) => {
|
||||
const asset = assetsByKey.get(key);
|
||||
if (!asset) return resolvePublicEvidenceAsset(key);
|
||||
return {
|
||||
src: asset.url,
|
||||
width: asset.width ?? 0,
|
||||
height: asset.height ?? 0,
|
||||
triggerLabel: `${asset.altText || asset.assetKey} 크게 보기`,
|
||||
dialogLabel: asset.altText || asset.assetKey,
|
||||
};
|
||||
}}
|
||||
resolvePublishedLabel={(path) =>
|
||||
path === record.path ? record.publishedLabel : undefined
|
||||
}
|
||||
|
||||
@@ -37,7 +37,18 @@ export function ExploreFilterForm({
|
||||
);
|
||||
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
|
||||
return {
|
||||
topics: [...new Set(records.map((record) => record.topic))].sort(),
|
||||
// 주제는 이름이 아니라 slug 로 거른다 — 프로젝트와 같다. 이름을 실었을 때는
|
||||
// `topic=OAuth/OIDC 인증 경계` 가 나갔고, slug 로 거르는 API 는 0건을 돌려줬다.
|
||||
// 화면에 보일 이름과 보낼 slug 가 다르므로 짝으로 들고 있어야 한다.
|
||||
topics: [
|
||||
...new Map(
|
||||
records
|
||||
.filter((record) => record.topicSlug)
|
||||
.map((record) => [record.topicSlug, record.topic] as const),
|
||||
),
|
||||
]
|
||||
.map(([slug, name]) => ({ slug, name }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, "ko-KR")),
|
||||
projects: resolved
|
||||
.filter((item) => item !== undefined)
|
||||
.map((item) => ({ slug: item.slug, title: item.title })),
|
||||
@@ -47,8 +58,10 @@ export function ExploreFilterForm({
|
||||
const projects = view.data?.projects ?? [];
|
||||
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
||||
const selectedTopic = topics.find(
|
||||
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
||||
);
|
||||
(item) =>
|
||||
item.slug.toLocaleLowerCase("ko-KR") === normalizedTopic ||
|
||||
item.name.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
||||
)?.slug;
|
||||
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
|
||||
const selectedProject = projects.find(
|
||||
(item) =>
|
||||
@@ -67,7 +80,7 @@ export function ExploreFilterForm({
|
||||
const data = new FormData(event.currentTarget);
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of data) {
|
||||
if (typeof value === "string") search.append(key, value);
|
||||
if (typeof value === "string" && value !== "") search.append(key, value);
|
||||
}
|
||||
void navigate(`${action}?${search.toString()}`);
|
||||
}
|
||||
@@ -83,7 +96,7 @@ export function ExploreFilterForm({
|
||||
{showType ? (
|
||||
<label><span>유형</span><select name="type" defaultValue={kind ?? ""}><option value="">전체</option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
|
||||
) : null}
|
||||
<label><span>주제</span><select name="topic" defaultValue={selectedTopic ?? ""}><option value="">전체</option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label>
|
||||
<label><span>주제</span><select name="topic" defaultValue={selectedTopic ?? ""}><option value="">전체</option>{topics.map((item) => <option value={item.slug} key={item.slug}>{item.name}</option>)}</select></label>
|
||||
<label><span>프로젝트</span><select name="project" defaultValue={selectedProject ?? ""}><option value="">전체</option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
|
||||
<button type="submit">적용</button>
|
||||
{hasActiveFilter ? <Link to={action}>필터 초기화</Link> : null}
|
||||
|
||||
@@ -19,7 +19,13 @@ export function PublicDocumentHeader({ record }: { record: PublicRecord }) {
|
||||
{kindLabels[record.kind]}
|
||||
</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link to={`/topics/${record.topicSlug}`}>{record.topic}</Link>
|
||||
{/*
|
||||
주제 페이지가 아니라 그 주제로 거른 탐색으로 보낸다. `/topics/:slug` 는 세 개를
|
||||
하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 되고, 설명·범위·대표 기록이 전부
|
||||
비어 있어 지금 채울 내용도 없다. 독자가 여기서 기대하는 것 — 같은 주제의 기록 목록 —
|
||||
은 탐색 필터가 그대로 준다.
|
||||
*/}
|
||||
<Link to={`/explore?topic=${encodeURIComponent(record.topicSlug)}`}>{record.topic}</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link to={`/projects/${record.projectSlug}`}>
|
||||
{record.projectTitle}
|
||||
@@ -57,7 +63,9 @@ export function publicRenderModelBase(
|
||||
topic: {
|
||||
id: `topic-${record.topicSlug}`,
|
||||
label: record.topic,
|
||||
publicPath: `/topics/${record.topicSlug}`,
|
||||
// 공개 문서의 머리말이 실제로 그리는 주제 링크는 이 값이다 — 위의 breadcrumb 과 같은
|
||||
// 이유로 탐색 필터를 가리킨다. 둘 중 하나만 고치면 화면에서는 그대로 404 로 간다.
|
||||
publicPath: `/explore?topic=${encodeURIComponent(record.topicSlug)}`,
|
||||
},
|
||||
project: {
|
||||
id: `project-${record.projectSlug}`,
|
||||
|
||||
@@ -39,56 +39,43 @@ function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 고른 최근 기록에 릴리스를 얹는다.
|
||||
*
|
||||
* 예전에는 이 함수가 공개된 프로젝트를 하나씩 돌며 활동을 모아 타임라인을 만들었다. 그래서 게시된
|
||||
* 문서라도 그 문서가 매달린 프로젝트가 공개되어 있지 않으면 홈에서 통째로 사라졌다 — 실제로 Case 를
|
||||
* 게시했는데 홈에는 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을
|
||||
* 그대로 읽는다. 프로젝트마다 요청을 하나씩 보내던 N+1 도 같이 사라진다.
|
||||
*
|
||||
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
|
||||
* 시간순으로 합친다.
|
||||
*/
|
||||
const latestTypeLabels: Readonly<Record<string, string>> = {
|
||||
PROJECT_ACTIVITY: "PROJECT ACTIVITY",
|
||||
QUESTION: "OPEN QUESTION",
|
||||
};
|
||||
|
||||
async function getLatestEntries(
|
||||
publicContent: PublicContentQueries,
|
||||
): Promise<LatestEntry[]> {
|
||||
const publicRecords = await publicContent.listRecords();
|
||||
const publicRecordByPath = new Map(
|
||||
publicRecords.map((record) => [record.path, record]),
|
||||
);
|
||||
const searchableEntities = await publicContent.searchPublicContent("");
|
||||
const projectPrefix = "/projects/";
|
||||
const projectSlugs = searchableEntities
|
||||
.filter((entity) => entity.contentType === "PROJECT")
|
||||
.flatMap((entity) =>
|
||||
entity.path.startsWith(projectPrefix)
|
||||
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
||||
: [],
|
||||
);
|
||||
// One project at a time would serialise a request per project; issuing them
|
||||
// together keeps the timeline's cost at its slowest project rather than their
|
||||
// sum. The flatten below restores the original single-list shape.
|
||||
const projectTimeline = (
|
||||
await Promise.all(
|
||||
projectSlugs.map(async (projectSlug) => {
|
||||
const project = await publicContent.getProject(projectSlug);
|
||||
if (!project) return [];
|
||||
const activities = await publicContent.getProjectActivity(projectSlug);
|
||||
return activities.map((activity) => {
|
||||
const record = publicRecordByPath.get(
|
||||
activity.recordPath ?? activity.path,
|
||||
);
|
||||
return {
|
||||
id: activity.id,
|
||||
typeLabel:
|
||||
activity.type === "PUBLICATION" && record
|
||||
? record.kind
|
||||
: "PROJECT ACTIVITY",
|
||||
title:
|
||||
activity.type === "PUBLICATION" && record
|
||||
? record.title
|
||||
: activity.title,
|
||||
summary: activity.summary,
|
||||
date: activity.date,
|
||||
dateTime: activity.dateTime,
|
||||
topic: record?.topic ?? project.topics[0] ?? "",
|
||||
project: project.title,
|
||||
path: activity.path,
|
||||
};
|
||||
});
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
const [records, searchableEntities] = await Promise.all([
|
||||
publicContent.getLatestEntries(),
|
||||
publicContent.searchPublicContent(""),
|
||||
]);
|
||||
|
||||
const recordTimeline: LatestEntry[] = records.map((entry) => ({
|
||||
id: entry.id,
|
||||
// 목록의 다른 이름들과 같은 자리에 놓이므로 표기도 같은 규칙을 쓴다 — 대문자에 공백.
|
||||
typeLabel: latestTypeLabels[entry.entryType] ?? entry.entryType,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
date: dateLabel(entry.publishedAt),
|
||||
dateTime: entry.publishedAt,
|
||||
topic: entry.topic,
|
||||
project: entry.project,
|
||||
path: entry.path,
|
||||
}));
|
||||
|
||||
const releaseTimeline = (
|
||||
await Promise.all(
|
||||
searchableEntities
|
||||
@@ -117,11 +104,20 @@ async function getLatestEntries(
|
||||
)
|
||||
).flat();
|
||||
|
||||
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
|
||||
return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
|
||||
right.dateTime.localeCompare(left.dateTime),
|
||||
);
|
||||
}
|
||||
|
||||
/** 목록의 날짜 칸은 공개 화면 어디서나 같은 형식이다. */
|
||||
function dateLabel(isoTimestamp: string): string {
|
||||
const parsed = new Date(isoTimestamp);
|
||||
if (Number.isNaN(parsed.getTime())) return "";
|
||||
return `${parsed.getFullYear()}.${String(parsed.getMonth() + 1).padStart(2, "0")}.${String(
|
||||
parsed.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
||||
const requestedKey = optionalString(search.focus);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
@@ -21,9 +19,19 @@ export function ProjectActivityPage() {
|
||||
const { project, activity } = view.data;
|
||||
if (!project) return <RegisteredNotFoundRoute />;
|
||||
|
||||
/*
|
||||
활동은 로그다 — 언제 무엇을 올렸는지만 적는다.
|
||||
|
||||
예전에는 각 줄에 그 기록으로 가는 링크가 있었다. 그러면 같은 글에 닿는 길이 둘이 되고,
|
||||
읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다. 글을 읽는 자리는
|
||||
기록 화면 하나로 둔다.
|
||||
*/
|
||||
return (
|
||||
<main id="main-content" className="shell project-page">
|
||||
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
|
||||
{activity.length === 0 ? (
|
||||
<p className="public-empty-note">아직 이 프로젝트에서 공개한 기록이 없습니다.</p>
|
||||
) : (
|
||||
<ol className="project-activity-list">
|
||||
{activity.map((item) => (
|
||||
<li key={item.id}>
|
||||
@@ -33,16 +41,11 @@ export function ProjectActivityPage() {
|
||||
<time dateTime={item.dateTime}>{item.date}</time>
|
||||
</div>
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.summary}</p>
|
||||
<Link to={item.recordPath ?? item.path}>
|
||||
{item.recordPath
|
||||
? "연결된 공개 기록 읽기"
|
||||
: "이 활동 위치 열기"}
|
||||
</Link>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,10 +38,11 @@ export function TopicPage() {
|
||||
const topic = topicConfig(params.slug);
|
||||
// Hooks run unconditionally, so the unknown-topic case is handled by the
|
||||
// loader and the not-found route is chosen after it.
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const view = usePublicContent(
|
||||
["tech-log", "topic", topic?.title],
|
||||
["tech-log", "topic", slug],
|
||||
async (queries) =>
|
||||
topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] },
|
||||
topic ? { records: await queries.listRecords({ topic: slug }) } : { records: [] },
|
||||
);
|
||||
if (!topic) return <RegisteredNotFoundRoute />;
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
@@ -53,8 +53,12 @@ export type ResolvedAssetLike = Readonly<{
|
||||
function fromDescriptor(descriptor: ResolvedAssetLike): EvidenceAsset {
|
||||
return Object.freeze({
|
||||
src: descriptor.publicPath,
|
||||
width: descriptor.width ?? 1,
|
||||
height: descriptor.height ?? 1,
|
||||
// 치수를 모르면 0 으로 둔다 — 1 이 아니라. 1×1 은 "아주 작은 그림" 이라는 거짓말이고,
|
||||
// `loading="lazy"` 와 만나면 브라우저는 화면에 걸리지 않는 1×1 상자를 영영 가져오지 않는다.
|
||||
// 실제로 업로드가 치수를 기록하지 않아 모든 그림이 그렇게 사라졌다. 0 은 figure 가
|
||||
// 자기 값을 모른다는 뜻이고, 렌더러가 그때 속성을 빼고 즉시 로드로 바꾼다.
|
||||
width: descriptor.width ?? 0,
|
||||
height: descriptor.height ?? 0,
|
||||
triggerLabel: `${descriptor.assetKey} 이미지 크게 보기`,
|
||||
dialogLabel: `${descriptor.assetKey} 확대`,
|
||||
});
|
||||
|
||||
@@ -99,6 +99,19 @@ function renderBlock(
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
/>
|
||||
);
|
||||
case "THEMATIC_BREAK":
|
||||
return <hr key={key} className="document-rule" />;
|
||||
case "IMAGE":
|
||||
/*
|
||||
작성자가 적은 경로를 그대로 쓴다. 경로 검증은 파싱할 때 끝났다(`isSafeLink`).
|
||||
`title` 이 있으면 그림 설명으로 보여 준다 — Markdown 이 제목을 그런 뜻으로 쓴다.
|
||||
*/
|
||||
return (
|
||||
<figure key={key} className="document-image">
|
||||
<img src={block.src} alt={block.alt} loading="lazy" />
|
||||
{block.title ? <figcaption>{block.title}</figcaption> : null}
|
||||
</figure>
|
||||
);
|
||||
default:
|
||||
return assertNever(block);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,20 @@ type DocumentTocProps = {
|
||||
|
||||
export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
||||
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
|
||||
const empty = headings.length === 0;
|
||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
`IntersectionObserver` 가 없는 환경이 있다 — jsdom 이 그렇고, 오래된 브라우저도 그렇다.
|
||||
없으면 "지금 읽는 절" 표시만 못 할 뿐 목차 자체는 쓸 수 있으므로, 없다고 문서를 통째로
|
||||
못 그리게 두지 않는다.
|
||||
|
||||
한동안 이 컴포넌트는 픽스처 Case 하나에서만 쓰여 그 환경을 만난 적이 없었다. 모든 Case 가
|
||||
목차를 받게 되면서 드러났다.
|
||||
*/
|
||||
if (typeof IntersectionObserver === "undefined") return undefined;
|
||||
|
||||
const elements = headings
|
||||
.map((heading) => document.getElementById(heading.id))
|
||||
.filter((element): element is HTMLElement => Boolean(element));
|
||||
@@ -41,6 +52,12 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
||||
if (detailsRef.current) detailsRef.current.open = false;
|
||||
}
|
||||
|
||||
/*
|
||||
제목이 없는 문서에는 목차를 그리지 않는다. 예전에는 이 컴포넌트가 픽스처 문서 하나에서만
|
||||
쓰여 빈 경우를 만날 일이 없었지만, 이제 모든 Case 가 지나므로 빈 레일이 남을 수 있다.
|
||||
*/
|
||||
if (empty) return null;
|
||||
|
||||
if (variant === "mobile") {
|
||||
const current =
|
||||
headings.find((heading) => heading.id === currentId) ?? headings[0];
|
||||
|
||||
@@ -30,16 +30,20 @@ export function PublicEvidenceFigure({
|
||||
dialogRef.current?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 치수를 아는 그림만 자리를 미리 잡는다. 모르는데 숫자를 적으면 그 상자가 진짜 크기가 되고,
|
||||
* `loading="lazy"` 는 화면에 걸리지 않는 상자를 끝내 가져오지 않는다 — 그림이 통째로 사라진다.
|
||||
* 모를 때는 속성을 빼고 즉시 로드해, 브라우저가 원래 크기로 그리게 둔다.
|
||||
*/
|
||||
const known = asset.width > 0 && asset.height > 0;
|
||||
const sizing = known
|
||||
? ({ width: asset.width, height: asset.height, loading: "lazy" } as const)
|
||||
: ({ loading: "eager" } as const);
|
||||
|
||||
if (!zoom) {
|
||||
return (
|
||||
<figure className="evidence-figure">
|
||||
<img
|
||||
src={asset.src}
|
||||
width={asset.width}
|
||||
height={asset.height}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
<img src={asset.src} alt={alt} {...sizing} />
|
||||
<figcaption>{caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
@@ -56,13 +60,7 @@ export function PublicEvidenceFigure({
|
||||
aria-describedby={descriptionId}
|
||||
onClick={open}
|
||||
>
|
||||
<img
|
||||
src={asset.src}
|
||||
width={asset.width}
|
||||
height={asset.height}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
<img src={asset.src} alt={alt} {...sizing} />
|
||||
<span>크게 보기</span>
|
||||
<span className="visually-hidden" id={descriptionId}>
|
||||
{alt}
|
||||
@@ -84,13 +82,7 @@ export function PublicEvidenceFigure({
|
||||
<button type="button" className="dialog-close" onClick={close}>
|
||||
닫기
|
||||
</button>
|
||||
<img
|
||||
src={asset.src}
|
||||
width={asset.width}
|
||||
height={asset.height}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
<img src={asset.src} alt={alt} {...sizing} />
|
||||
<p>{caption}</p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
+18
-61
@@ -128,57 +128,18 @@ function publicRelations(
|
||||
}));
|
||||
}
|
||||
|
||||
function GenericCase({
|
||||
model,
|
||||
embedded,
|
||||
resolveEvidenceAsset,
|
||||
resolvePublishedLabel,
|
||||
}: {
|
||||
model: CasePublicRenderModel;
|
||||
embedded: boolean;
|
||||
} & RenderDependencies) {
|
||||
const Root = embedded ? "div" : "main";
|
||||
return (
|
||||
<Root
|
||||
id={embedded ? undefined : "main-content"}
|
||||
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
|
||||
>
|
||||
<ModelDocumentHeader
|
||||
model={model}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
<section className="document-snapshot" aria-label="문제와 결론">
|
||||
<div>
|
||||
<p className="snapshot-label">문제</p>
|
||||
<p><PlainText text={model.problem} /></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="snapshot-label snapshot-label--answer">결론</p>
|
||||
<p><PlainText text={model.conclusion} /></p>
|
||||
</div>
|
||||
</section>
|
||||
<dl className="document-facts">
|
||||
<div>
|
||||
<dt>검증 환경</dt>
|
||||
<dd><PlainText text={model.environment} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>검증 데이터</dt>
|
||||
<dd><PlainText text={model.reproduction} /></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<article className="public-document-body">
|
||||
<CaseBodyRenderer
|
||||
blocks={model.bodyBlocks}
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
/>
|
||||
</article>
|
||||
<PublicDocumentRelations relations={publicRelations(model.relations)} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
function FetchJoinCase({
|
||||
/**
|
||||
* Case 한 편.
|
||||
*
|
||||
* 한동안 Case 렌더러가 둘이었다. 이 완성된 배치와, 목차도 breadcrumb 도 없는 축약본. 어느 쪽을
|
||||
* 쓸지는 `publicPath === "/cases/collection-fetch-join-pagination"` 라는 슬러그 비교가 정했다 —
|
||||
* 설계 픽스처로 만든 문서 하나만 제대로 된 화면을 받고, 실제로 작성한 Case 는 전부 축약본으로
|
||||
* 떨어졌다. 그래서 오른쪽 목차가 어떤 문서에서도 나타나지 않았다.
|
||||
*
|
||||
* 배치는 하나다. 목차는 본문에 제목이 있을 때 나온다.
|
||||
*/
|
||||
function CaseDocument({
|
||||
model,
|
||||
embedded,
|
||||
resolveEvidenceAsset,
|
||||
@@ -233,7 +194,7 @@ function FetchJoinCase({
|
||||
<dd><PlainText text={model.environment} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>데이터셋</dt>
|
||||
<dt>검증 데이터</dt>
|
||||
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -474,7 +435,11 @@ function ProjectDecisionDocument({
|
||||
<header>
|
||||
<div>
|
||||
<span>{model.status}</span>
|
||||
{model.decidedOn ? (
|
||||
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
|
||||
) : (
|
||||
<span>결정일 미정</span>
|
||||
)}
|
||||
</div>
|
||||
<h2>{model.title}</h2>
|
||||
<p><PlainText text={model.statement} /></p>
|
||||
@@ -519,16 +484,8 @@ export function PublicRecordRenderer({
|
||||
} & RenderDependencies) {
|
||||
switch (model.kind) {
|
||||
case "CASE":
|
||||
return model.publicPath ===
|
||||
"/cases/collection-fetch-join-pagination" ? (
|
||||
<FetchJoinCase
|
||||
model={model}
|
||||
embedded={embedded}
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
resolvePublishedLabel={resolvePublishedLabel}
|
||||
/>
|
||||
) : (
|
||||
<GenericCase
|
||||
return (
|
||||
<CaseDocument
|
||||
model={model}
|
||||
embedded={embedded}
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
|
||||
@@ -5,9 +5,20 @@ import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
|
||||
import { useStudioAssetGateway } from "../use-studio.ts";
|
||||
import { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx";
|
||||
import { AssetUploadDialog } from "./asset-upload-dialog.tsx";
|
||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||
|
||||
type CaseInput = components["schemas"]["CaseInput"];
|
||||
|
||||
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
|
||||
export const CASE_FIELD_PATHS = [
|
||||
"/problem",
|
||||
"/conclusion",
|
||||
"/environment",
|
||||
"/reproduction",
|
||||
"/lastVerifiedOn",
|
||||
"/bodyMarkdown",
|
||||
] as const;
|
||||
|
||||
const ASSET_KIND_OPTIONS: ReadonlyArray<{ value: AssetKind; label: string }> = [
|
||||
{ value: "IMAGE", label: "이미지" },
|
||||
{ value: "DIAGRAM", label: "다이어그램" },
|
||||
@@ -36,11 +47,14 @@ function insertAtCursor(
|
||||
|
||||
export function CaseFields({
|
||||
draft,
|
||||
issues,
|
||||
onChange,
|
||||
onAssetsObserved,
|
||||
onAssetUploaded,
|
||||
}: {
|
||||
draft: CaseInput;
|
||||
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
|
||||
issues: readonly FieldIssue[];
|
||||
onChange(draft: CaseInput): void;
|
||||
/**
|
||||
* The editor screen owns the resolution catalog Instant Preview reads, and
|
||||
@@ -81,14 +95,14 @@ export function CaseFields({
|
||||
<section className="studio-editor-section" aria-labelledby="studio-case-fields-title">
|
||||
<div className="studio-editor-section-heading"><p className="studio-eyebrow">CASE</p><h2 id="studio-case-fields-title">문제와 검증</h2></div>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field studio-field--wide"><span>문제</span><textarea value={draft.problem} onChange={(event) => update({ problem: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field studio-field--wide"><span>결론</span><textarea value={draft.conclusion} onChange={(event) => update({ conclusion: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field"><span>검증 환경</span><textarea value={draft.environment} onChange={(event) => update({ environment: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field"><span>재현 조건</span><textarea value={draft.reproduction} onChange={(event) => update({ reproduction: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /></label>
|
||||
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field studio-field--wide"><span>문제</span><textarea value={draft.problem} onChange={(event) => update({ problem: event.currentTarget.value })} /><FieldNotice issues={issues} path="/problem" /></label>
|
||||
<label className="studio-field studio-field--wide"><span>결론</span><textarea value={draft.conclusion} onChange={(event) => update({ conclusion: event.currentTarget.value })} /><FieldNotice issues={issues} path="/conclusion" /></label>
|
||||
<label className="studio-field"><span>검증 환경</span><textarea value={draft.environment} onChange={(event) => update({ environment: event.currentTarget.value })} /><FieldNotice issues={issues} path="/environment" /></label>
|
||||
<label className="studio-field"><span>재현 조건</span><textarea value={draft.reproduction} onChange={(event) => update({ reproduction: event.currentTarget.value })} /><FieldNotice issues={issues} path="/reproduction" /></label>
|
||||
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
|
||||
<label className="studio-field studio-field--wide"><span>본문 Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
|
||||
</div>
|
||||
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
|
||||
<div className="studio-asset-panel" role="group" aria-labelledby="studio-asset-panel-title">
|
||||
<p className="studio-eyebrow">EVIDENCE</p>
|
||||
<h3 id="studio-asset-panel-title">본문에 Asset 삽입</h3>
|
||||
<p>목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.</p>
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
|
||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||
import { RelationEditor } from "./relation-editor.tsx";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
|
||||
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. 나머지는 게시 버튼 옆에 남는다. */
|
||||
export const COMMON_FIELD_PATHS = [
|
||||
"/title",
|
||||
"/slug",
|
||||
"/summary",
|
||||
"/topicId",
|
||||
"/projectId",
|
||||
"/relations",
|
||||
] as const;
|
||||
|
||||
export function CommonDocumentFields({
|
||||
draft,
|
||||
topics,
|
||||
projects,
|
||||
relations,
|
||||
issues,
|
||||
onUpdate,
|
||||
}: {
|
||||
draft: WorkingCopyInput;
|
||||
topics: CatalogEntry[];
|
||||
projects: CatalogEntry[];
|
||||
relations: CatalogEntry[];
|
||||
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
|
||||
issues: readonly FieldIssue[];
|
||||
onUpdate(patch: Partial<WorkingCopyInput>): void;
|
||||
}) {
|
||||
return (
|
||||
<section className="studio-editor-section" aria-labelledby="studio-common-fields-title">
|
||||
<div className="studio-editor-section-heading"><p className="studio-eyebrow">DOCUMENT</p><h2 id="studio-common-fields-title">기본 정보</h2></div>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field studio-field--wide"><span>제목</span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
|
||||
<label className="studio-field studio-field--wide"><span>요약</span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value="">선택하지 않음</option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value="">미지정</option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
<label className="studio-field studio-field--wide"><span>제목</span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /><FieldNotice issues={issues} path="/title" /></label>
|
||||
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /><FieldNotice issues={issues} path="/slug" /></label>
|
||||
<label className="studio-field studio-field--wide"><span>요약</span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /><FieldNotice issues={issues} path="/summary" /></label>
|
||||
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value="">선택하지 않음</option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select><FieldNotice issues={issues} path="/topicId" /></label>
|
||||
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value="">미지정</option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select><FieldNotice issues={issues} path="/projectId" /></label>
|
||||
</div>
|
||||
<RelationEditor evidence={draft.kind === "PROJECT_DECISION"} relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
|
||||
<FieldNotice issues={issues} path="/relations" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,35 @@ export type DocumentEditorController = {
|
||||
* 남아 있어, 작성자는 저장된 줄 알고 검증에서 "slug 이 없다" 를 만났다.
|
||||
*/
|
||||
saveError: string;
|
||||
/**
|
||||
* 마지막 검증이 남긴 항목. 게시를 막는 것이 무엇인지 고치는 자리에서 보여 주기 위한 것이며,
|
||||
* `current` 가 거짓이면 지금 저장된 버전을 검사한 결과가 아니다.
|
||||
*/
|
||||
validation: Readonly<{
|
||||
current: boolean;
|
||||
version: number;
|
||||
issues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
|
||||
}> | null;
|
||||
/**
|
||||
* 게시가 진행 중인가. 한 번의 클릭이 저장·검증·미리보기·게시 네 번의 왕복을 만들므로, 그
|
||||
* 사이에 다시 누르면 같은 문서를 두 번 게시하려 든다.
|
||||
*/
|
||||
publishing: boolean;
|
||||
/** 게시가 실패한 이유. 서버가 준 문구를 그대로 쓴다. */
|
||||
publishError: string;
|
||||
/**
|
||||
* 게시를 막은 검증 항목. 예전에는 이것을 보려면 검증 화면으로 나가야 했다 — 고칠 칸은 편집
|
||||
* 화면에 있는데 무엇이 모자란지는 다른 화면에 있었다. 지금은 막힌 자리에서 바로 보여 준다.
|
||||
*/
|
||||
publishIssues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
|
||||
update(patch: Partial<WorkingCopyInput>): void;
|
||||
replace(draft: WorkingCopyInput): void;
|
||||
save(): Promise<void>;
|
||||
/**
|
||||
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다. 백엔드는 게시 요청에 신선한 검증 id 와
|
||||
* 미리보기 id, 그리고 현재 경고를 모두 확인했다는 목록을 요구한다 —
|
||||
* `PublishStudioDocumentUseCase` 가 셋을 모두 검사한다. 그 셋은 작성자에게 물어볼 것이 없으므로
|
||||
* 여기서 채운다. 작성자가 밟는 단계는 저장과 게시 둘뿐이다.
|
||||
*/
|
||||
publish(): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -6,13 +6,16 @@ import type {
|
||||
Asset,
|
||||
WorkingCopy,
|
||||
WorkingCopyInput,
|
||||
WorkingCopyDetail,
|
||||
} from "../../../contracts/studio/contract.ts";
|
||||
import { mergeAssetCatalog } from "../../../domain/content-format/asset-evidence-catalog.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||
import { DocumentEditor } from "./document-editor.tsx";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
||||
import { slugFromName } from "./slug-from-name.ts";
|
||||
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
@@ -31,6 +34,22 @@ function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||
* 보는 이유는, 어긋난 값을 보내면 서버가 details 없는 422 로 거절하고 편집기는 그것을 이유 없는
|
||||
* 실패로만 보여 주기 때문이다 — 작성자에게는 어느 칸이 문제인지 알 방법이 없었다.
|
||||
*/
|
||||
type ValidationIssue = components["schemas"]["ValidationIssue"];
|
||||
|
||||
/**
|
||||
* 마지막 검증을 편집기가 쓸 모양으로 줄인다. `current` 는 "지금 저장된 버전을 검사한 결과인가"
|
||||
* 다 — 아니면 목록은 참고일 뿐이고, 방금 고친 것이 아직 모자라다고 말할 수 있다.
|
||||
*/
|
||||
function validationOf(detail: WorkingCopyDetail, now: Date) {
|
||||
const report = detail.currentValidation;
|
||||
if (!report) return null;
|
||||
return Object.freeze({
|
||||
current: deriveValidationState({ ...detail, currentValidation: report, now }).freshness === "CURRENT",
|
||||
version: report.validatedVersion,
|
||||
issues: report.issues,
|
||||
});
|
||||
}
|
||||
|
||||
const SLUG_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
||||
|
||||
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
@@ -57,6 +76,15 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
// below, when the screen switches to a different document.
|
||||
const [assets, setAssets] = useState<readonly Asset[]>([]);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
/**
|
||||
* 마지막 검증 결과. 편집기가 이것을 들고 있는 이유는, 무엇이 모자라 게시가 막히는지 작성자가
|
||||
* 고치는 자리에서 보여야 하기 때문이다 — 예전에는 별도 화면으로 나가야만 알 수 있었고,
|
||||
* 돌아와서 어느 칸이었는지 기억해야 했다.
|
||||
*/
|
||||
const [validationSource, setValidationSource] = useState<WorkingCopyDetail | null>(null);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [publishError, setPublishError] = useState("");
|
||||
const [publishIssues, setPublishIssues] = useState<readonly ValidationIssue[]>([]);
|
||||
const observeAssets = useCallback((observed: readonly Asset[]) => {
|
||||
setAssets((current) => mergeAssetCatalog(current, observed));
|
||||
}, []);
|
||||
@@ -83,6 +111,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
problem: null,
|
||||
});
|
||||
begin(detail.document, inputOf(detail.document));
|
||||
setValidationSource(detail);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (request.signal.aborted) return;
|
||||
@@ -130,6 +159,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
{ idempotencyKey: createLocalId("studio-editor-save") },
|
||||
);
|
||||
begin(detail.document, inputOf(detail.document));
|
||||
// 저장하면 직전 검증은 그 버전의 것이 아니게 된다. 그 사실을 바로 반영해야 낡은 목록이
|
||||
// 방금 고친 항목을 아직 모자라다고 말하지 않는다.
|
||||
setValidationSource(detail);
|
||||
studio.setRequestAnnouncement(
|
||||
`버전 ${detail.document.version}으로 저장했습니다.`,
|
||||
);
|
||||
@@ -145,6 +177,125 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
}
|
||||
}, [begin, editor, setStatus, studio]);
|
||||
|
||||
/*
|
||||
`save` 가 스스로 CLEAN·SAVING·CONFLICT 를 걸러 내므로 단축키는 늘 열어 둔다 — 저장할 것이
|
||||
없을 때 눌러도 아무 일도 일어나지 않는다.
|
||||
*/
|
||||
useSaveShortcut(save);
|
||||
|
||||
/**
|
||||
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다.
|
||||
*
|
||||
* <p>백엔드는 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두 확인했다는
|
||||
* 목록을 요구한다(`PublishStudioDocumentUseCase`). 예전에는 그 셋을 작성자가 세 화면을 차례로
|
||||
* 밟아 만들었다 — 검증 화면에서 버튼을 누르고, 미리보기 화면에서 또 누르고, 게시 화면에서
|
||||
* 경고를 하나씩 체크했다.
|
||||
*
|
||||
* <p>그 셋은 작성자에게 물어볼 것이 없다. 검증 결과는 서버가 판정하고, 미리보기는 그 판정에서
|
||||
* 만들어지며, 경고는 게시를 막지 않는다. 그래서 여기서 잇달아 부른다. 서버가 지키던 불변식은
|
||||
* 그대로다 — 사라진 것은 작성자가 밟던 화면뿐이다.
|
||||
*
|
||||
* <p>막는 것은 `ERROR` 뿐이다. 그때는 게시를 멈추고 지적을 그 칸 아래에 보여 준다.
|
||||
*/
|
||||
const publish = useCallback(async () => {
|
||||
const current = editor;
|
||||
if (!current || current.status === "SAVING" || current.status === "CONFLICT") return;
|
||||
setPublishError("");
|
||||
setPublishIssues([]);
|
||||
setPublishing(true);
|
||||
const id = current.documentId;
|
||||
try {
|
||||
// 게시는 저장된 버전을 대상으로 한다. 편집 중인 값이 있으면 먼저 맞춘다 — 그러지 않으면
|
||||
// 방금 고친 칸이 반영되지 않은 채 검증받는다.
|
||||
let version = current.saved.version;
|
||||
if (current.status === "DIRTY") {
|
||||
const slug = current.draft.slug.trim() || slugFromName(current.draft.title);
|
||||
if (slug && !SLUG_SHAPE.test(slug)) {
|
||||
setPublishError("slug 은 영문 소문자·숫자·하이픈만 쓸 수 있습니다. 비워 두면 제목에서 만들어 드립니다.");
|
||||
return;
|
||||
}
|
||||
const draft = slug === current.draft.slug
|
||||
? current.draft
|
||||
: ({ ...current.draft, slug } as typeof current.draft);
|
||||
setStatus("SAVING");
|
||||
const saved = await studio.gateway.saveDocument(
|
||||
id,
|
||||
{ expectedVersion: version, document: draft },
|
||||
{ idempotencyKey: createLocalId("studio-publish-save") },
|
||||
);
|
||||
begin(saved.document, inputOf(saved.document));
|
||||
setValidationSource(saved);
|
||||
version = saved.document.version;
|
||||
}
|
||||
|
||||
const report = await studio.gateway.validateDocument(
|
||||
id,
|
||||
{ expectedVersion: version },
|
||||
{ idempotencyKey: createLocalId("studio-publish-validate") },
|
||||
);
|
||||
if (report.status === "INVALID") {
|
||||
setPublishIssues(report.issues);
|
||||
setPublishError("게시할 수 없습니다. 표시한 칸을 채워 주세요.");
|
||||
studio.setRequestAnnouncement("게시할 수 없습니다. 표시한 칸을 채워 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = await studio.gateway.createPreview(
|
||||
id,
|
||||
{ expectedVersion: version, validationId: report.validationId },
|
||||
{ idempotencyKey: createLocalId("studio-publish-preview") },
|
||||
);
|
||||
|
||||
// 경고는 게시를 막지 않는다. 서버는 "현재 경고를 모두 확인했다"는 목록을 요구할 뿐이므로
|
||||
// 방금 받은 경고를 그대로 넘긴다.
|
||||
const acknowledged = [
|
||||
...new Set(
|
||||
report.issues
|
||||
.filter((issue) => issue.severity === "WARNING")
|
||||
.map((issue) => issue.code),
|
||||
),
|
||||
].sort();
|
||||
const result = await studio.gateway.publishDocument(
|
||||
id,
|
||||
{
|
||||
expectedVersion: version,
|
||||
validationId: report.validationId,
|
||||
previewId: preview.previewId,
|
||||
acknowledgedWarningCodes: acknowledged,
|
||||
},
|
||||
{ idempotencyKey: createLocalId("studio-publish") },
|
||||
);
|
||||
// 경고를 남긴 채 게시했다면 그 사실은 남겨 둔다 — 게시가 되었다고 해서 지적이 사라진 것은
|
||||
// 아니다.
|
||||
setPublishIssues(report.issues.filter((issue) => issue.severity === "WARNING"));
|
||||
studio.setRequestAnnouncement("게시했습니다.");
|
||||
studio.clearEditor();
|
||||
studio.navigateInternal(
|
||||
`/studio/publications/${result.event.publicationEventId}/preview`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isStudioGatewayError(error) && error.code === "VERSION_CONFLICT") {
|
||||
setStatus("CONFLICT");
|
||||
} else if (current.status === "DIRTY") {
|
||||
setStatus("DIRTY");
|
||||
}
|
||||
const detail = isStudioGatewayError(error)
|
||||
? error.problem.detail
|
||||
: "게시하지 못했습니다. 다시 시도해 주세요.";
|
||||
setPublishError(detail);
|
||||
studio.setRequestAnnouncement(detail);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}, [begin, editor, setStatus, studio]);
|
||||
|
||||
/**
|
||||
* 검증의 신선도는 시각에 달렸는데, 시계는 렌더마다 새 함수다. 그것을 효과 의존성에 넣었더니
|
||||
* 문서를 끝없이 다시 불러왔다 — 화면이 정착하지 못해 탭 전환조차 먹히지 않았다. 시각은
|
||||
* 저장할 값이 아니라 지금 묻는 값이므로 렌더에서 읽는다.
|
||||
*/
|
||||
const validation = validationSource ? validationOf(validationSource, studio.now()) : null;
|
||||
|
||||
const controller = useMemo<DocumentEditorController | null>(() => {
|
||||
if (!editor || editor.documentId !== documentId) return null;
|
||||
return {
|
||||
@@ -152,15 +303,20 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
||||
draft: editor.draft,
|
||||
status: editor.status,
|
||||
saveError,
|
||||
validation,
|
||||
publishing,
|
||||
publishError,
|
||||
publishIssues,
|
||||
update(patch) {
|
||||
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
|
||||
},
|
||||
replace(draft) {
|
||||
if (draft.kind === editor.draft.kind) updateDraft(draft);
|
||||
},
|
||||
publish,
|
||||
save,
|
||||
};
|
||||
}, [documentId, editor, save, saveError, updateDraft]);
|
||||
}, [documentId, editor, publish, publishError, publishIssues, publishing, save, saveError, updateDraft, validation]);
|
||||
|
||||
const currentResult = result?.key === requestKey ? result : null;
|
||||
const problem = currentResult?.problem ?? null;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useRef, useState, type KeyboardEvent } from "react";
|
||||
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import type { Asset } from "../../../contracts/studio/contract.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";
|
||||
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
|
||||
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
|
||||
import { DocumentStatusBar } from "./document-status-bar.tsx";
|
||||
import { InstantPreview } from "./instant-preview.tsx";
|
||||
import { ProjectDecisionFields } from "./project-decision-fields.tsx";
|
||||
import { QuestionFields } from "./question-fields.tsx";
|
||||
import { ReferenceFields } from "./reference-fields.tsx";
|
||||
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
|
||||
import { issuesOutside } from "./field-issues.tsx";
|
||||
import { QUESTION_FIELD_PATHS, QuestionFields } from "./question-fields.tsx";
|
||||
import { REFERENCE_FIELD_PATHS, ReferenceFields } from "./reference-fields.tsx";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
|
||||
@@ -27,56 +26,69 @@ export function DocumentEditor({
|
||||
onAssetsObserved: (assets: readonly Asset[]) => void;
|
||||
onAssetUploaded: (asset: Asset) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
|
||||
const editTab = useRef<HTMLButtonElement>(null);
|
||||
const previewTab = useRef<HTMLButtonElement>(null);
|
||||
const selectTab = (next: "EDIT" | "PREVIEW") => {
|
||||
setTab(next);
|
||||
(next === "EDIT" ? editTab : previewTab).current?.focus();
|
||||
};
|
||||
const keyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
let next: "EDIT" | "PREVIEW" | null = null;
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") next = tab === "EDIT" ? "PREVIEW" : "EDIT";
|
||||
if (event.key === "Home") next = "EDIT";
|
||||
if (event.key === "End") next = "PREVIEW";
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
selectTab(next);
|
||||
};
|
||||
const topics = catalog.filter(({ type }) => type === "TOPIC");
|
||||
const projects = catalog.filter(({ type }) => type === "PROJECT");
|
||||
const relations = catalog.filter(({ type }) => type === "RELATION");
|
||||
const evidence = catalog.filter(({ type }) => type === "EVIDENCE");
|
||||
|
||||
/*
|
||||
게시를 막는 것이 무엇인지 그 칸 아래에 적는다. 예전에는 이 목록이 화면 맨 위에 한 덩어리로
|
||||
있었고, 작성자는 `/topicId` 같은 경로를 읽고 어느 칸인지 스스로 찾아야 했다.
|
||||
|
||||
어느 칸에도 붙지 못한 것은 게시 버튼 옆에 남긴다 — 사라지면 이유를 말해 주지 않는 실패만
|
||||
남는다.
|
||||
*/
|
||||
const issues = controller.publishIssues;
|
||||
const kindPaths = controller.draft.kind === "CASE"
|
||||
? CASE_FIELD_PATHS
|
||||
: controller.draft.kind === "REFERENCE"
|
||||
? REFERENCE_FIELD_PATHS
|
||||
: controller.draft.kind === "QUESTION"
|
||||
? QUESTION_FIELD_PATHS
|
||||
: DECISION_FIELD_PATHS;
|
||||
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
|
||||
|
||||
/*
|
||||
편집과 미리보기를 한 화면에 나란히 둔다. 예전에는 탭이었고, 고친 것이 어떻게 보이는지
|
||||
확인하려면 탭을 옮겨야 했다 — 옮기는 동안 편집 중이던 칸은 화면에서 사라졌고, 돌아오면
|
||||
스크롤 위치도 잃었다. 두 패널을 동시에 두면 그 왕복이 통째로 없어진다.
|
||||
|
||||
탭을 없앴으므로 각 패널은 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을 가리켜
|
||||
landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를 대신한다.
|
||||
*/
|
||||
return (
|
||||
<div className="studio-editor-page">
|
||||
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
|
||||
<button id="studio-edit-tab" ref={editTab} type="button" role="tab" aria-selected={tab === "EDIT"} aria-controls="studio-edit-panel" tabIndex={tab === "EDIT" ? 0 : -1} onClick={() => selectTab("EDIT")} onKeyDown={keyDown}>편집</button>
|
||||
<button id="studio-preview-tab" ref={previewTab} type="button" role="tab" aria-selected={tab === "PREVIEW"} aria-controls="studio-preview-panel" tabIndex={tab === "PREVIEW" ? 0 : -1} onClick={() => selectTab("PREVIEW")} onKeyDown={keyDown}>즉시 미리보기</button>
|
||||
</div>
|
||||
<div className="studio-editor-layout">
|
||||
<div className="studio-editor-workspace">
|
||||
<div id="studio-edit-panel" role="tabpanel" aria-labelledby="studio-edit-tab" hidden={tab !== "EDIT"}>
|
||||
<div className="studio-editor-split">
|
||||
<section className="studio-editor-workspace" aria-labelledby="studio-edit-title">
|
||||
<header className="studio-editor-heading">
|
||||
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
|
||||
<h1>문서 편집</h1>
|
||||
<h1 id="studio-edit-title">문서 편집</h1>
|
||||
<p>{controller.draft.title || "제목 없는 작업본"}</p>
|
||||
</header>
|
||||
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} onUpdate={controller.update} />
|
||||
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
|
||||
{controller.draft.kind === "CASE"
|
||||
? <CaseFields draft={controller.draft} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
|
||||
? <CaseFields draft={controller.draft} issues={issues} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
|
||||
: controller.draft.kind === "REFERENCE"
|
||||
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
|
||||
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
|
||||
: controller.draft.kind === "QUESTION"
|
||||
? <QuestionFields draft={controller.draft} evidence={evidence} onChange={controller.replace} />
|
||||
: <ProjectDecisionFields draft={controller.draft} onChange={controller.replace} />}
|
||||
</div>
|
||||
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
|
||||
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
|
||||
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
|
||||
</section>
|
||||
{/*
|
||||
미리보기는 편집 칸보다 훨씬 짧을 수도, 길 수도 있다. 그대로 두면 본문을 스크롤하는
|
||||
동안 화면 밖으로 나가 버리므로 붙잡아 두고 자기 높이 안에서 따로 스크롤한다.
|
||||
*/}
|
||||
<section className="studio-editor-preview" aria-labelledby="studio-preview-title">
|
||||
<header className="studio-editor-preview__heading">
|
||||
<p className="studio-eyebrow">LIVE</p>
|
||||
<h2 id="studio-preview-title">즉시 미리보기</h2>
|
||||
</header>
|
||||
<div className="studio-editor-preview__body">
|
||||
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<DocumentStatusRail controller={controller} />
|
||||
</div>
|
||||
<DocumentStatusBar controller={controller} unplacedIssues={unplaced} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type { ListDocumentsQuery } from "../../../application/ports/studio-gateway.ts";
|
||||
import type { DocumentPage } from "../../../contracts/studio/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
|
||||
@@ -25,6 +26,21 @@ const nextLabel = {
|
||||
NONE: "완료",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 다음 단계는 이름만 있고 갈 곳이 없었다. 작성자는 편집 → 검증 → 미리보기 → 게시를 순서대로
|
||||
* 밟아야만 게시 화면에 닿을 수 있었고, 그 경로 어디에도 "게시" 라는 말이 먼저 보이지 않아
|
||||
* 게시하는 방법을 알기 어려웠다. 서버가 이미 다음 할 일을 알려 주므로, 그 말을 그대로 링크로
|
||||
* 만든다.
|
||||
*/
|
||||
const nextHref = {
|
||||
CONTINUE_EDITING: "edit",
|
||||
VALIDATE: "validation",
|
||||
FIX_VALIDATION: "edit",
|
||||
CREATE_PREVIEW: "preview",
|
||||
PUBLISH: "publish",
|
||||
NONE: "",
|
||||
} as const;
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
@@ -83,10 +99,10 @@ export function DocumentList() {
|
||||
? { ...current, items: current.items.filter((row) => row.id !== item.id) }
|
||||
: current,
|
||||
);
|
||||
} catch {
|
||||
setDeleteError(
|
||||
"삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.",
|
||||
);
|
||||
} catch (error) {
|
||||
// 서버는 무엇이 막았는지 정확히 알고 그것을 보내 준다. 예전에는 그 문구를 버리고 추측 셋을
|
||||
// 늘어놓아, 버전 충돌도 "사용 중" 으로 읽혔다.
|
||||
setDeleteError(managementFailureMessage(error, "삭제하지 못했습니다."));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
@@ -251,7 +267,17 @@ export function DocumentList() {
|
||||
</div>
|
||||
<div>
|
||||
<dt>다음</dt>
|
||||
<dd>{nextLabel[item.nextAction]}</dd>
|
||||
<dd>
|
||||
{nextHref[item.nextAction] ? (
|
||||
<GuardedStudioLink
|
||||
href={`/studio/documents/${item.id}/${nextHref[item.nextAction]}`}
|
||||
>
|
||||
{nextLabel[item.nextAction]}
|
||||
</GuardedStudioLink>
|
||||
) : (
|
||||
nextLabel[item.nextAction]
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>수정</dt>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||
import type { FieldIssue } from "./field-issues.tsx";
|
||||
|
||||
const labels = {
|
||||
CLEAN: "저장됨",
|
||||
DIRTY: "저장되지 않음",
|
||||
SAVING: "저장 중…",
|
||||
CONFLICT: "저장 충돌",
|
||||
} as const;
|
||||
|
||||
const kindLabels = {
|
||||
CASE: "CASE",
|
||||
REFERENCE: "REFERENCE",
|
||||
QUESTION: "QUESTION",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 작업 상태와 저장·게시를 화면 아래에 고정한다.
|
||||
*
|
||||
* 예전에는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께 쓸 가로 폭을 가져갔고,
|
||||
* 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다. 아래에 고정하면 폭을 돌려주면서
|
||||
* 스크롤 위치와 무관하게 저장·게시에 손이 닿는다 — 릴리즈 편집에서 버튼이 아래로 밀려나던
|
||||
* 것과 같은 문제를 여기서는 만들지 않는다.
|
||||
*
|
||||
* `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이 묶음이 무엇인지가
|
||||
* 바뀐 것은 아니고, 보조 기술이 이 영역을 부르던 이름도 그대로여야 한다.
|
||||
*/
|
||||
export function DocumentStatusBar({
|
||||
controller,
|
||||
unplacedIssues,
|
||||
}: {
|
||||
controller: DocumentEditorController;
|
||||
/**
|
||||
* 어느 칸에도 붙지 못한 지적. 화면에 없는 칸을 가리키는 것들이며, 여기 남기지 않으면 이유를
|
||||
* 말해 주지 않는 실패만 남는다.
|
||||
*/
|
||||
unplacedIssues: readonly FieldIssue[];
|
||||
}) {
|
||||
const busy = controller.status === "SAVING" || controller.publishing;
|
||||
return (
|
||||
<aside className="studio-document-status-bar" aria-labelledby="studio-document-status-title">
|
||||
<h2 id="studio-document-status-title" className="studio-visually-hidden">작업 상태</h2>
|
||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||
{/*
|
||||
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||
두 번 말하게 된다.
|
||||
*/}
|
||||
{controller.status === "CONFLICT" ? (
|
||||
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
||||
) : controller.saveError ? (
|
||||
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
||||
) : controller.publishError ? (
|
||||
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
||||
) : (
|
||||
<p className="studio-document-status-bar__note">불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.</p>
|
||||
)}
|
||||
<div className="studio-document-status-bar__actions">
|
||||
<button type="button" title="Ctrl+S" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||
{/*
|
||||
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
|
||||
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
|
||||
확인했다는 목록을 요구하기 때문이다(`PublishStudioDocumentUseCase`).
|
||||
|
||||
그 셋은 작성자에게 물어볼 것이 없다. 검증은 서버가 판정하고, 미리보기는 그 판정으로부터
|
||||
만들어지며, 경고는 게시를 막지 않는다. 그래서 세 요청을 이 버튼 뒤로 옮겼다. 계약도
|
||||
백엔드도 그대로다 — 사라진 것은 작성자가 밟던 화면이지 서버가 지키던 불변식이 아니다.
|
||||
*/}
|
||||
<button
|
||||
className="studio-primary-button"
|
||||
type="button"
|
||||
onClick={() => { void controller.publish(); }}
|
||||
disabled={busy || controller.status === "CONFLICT"}
|
||||
>
|
||||
{controller.publishing ? "게시 중…" : "게시"}
|
||||
</button>
|
||||
</div>
|
||||
{unplacedIssues.length ? (
|
||||
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
|
||||
{unplacedIssues.map((issue) => (
|
||||
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
|
||||
{issue.message} <code>{issue.path}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { DocumentEditorController } from "./document-editor-controller.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
|
||||
const labels = {
|
||||
CLEAN: "저장됨",
|
||||
DIRTY: "저장되지 않음",
|
||||
SAVING: "저장 중…",
|
||||
CONFLICT: "저장 충돌",
|
||||
} as const;
|
||||
|
||||
const kindLabels = {
|
||||
CASE: "CASE",
|
||||
REFERENCE: "REFERENCE",
|
||||
QUESTION: "QUESTION",
|
||||
PROJECT_DECISION: "Decision",
|
||||
} as const;
|
||||
|
||||
export function DocumentStatusRail({ controller }: { controller: DocumentEditorController }) {
|
||||
return (
|
||||
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
|
||||
<p className="studio-eyebrow">WORKING COPY</p>
|
||||
<h2 id="studio-document-status-title">작업 상태</h2>
|
||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||
<GuardedStudioLink className="studio-editor-next-link" href={`/studio/documents/${controller.saved.id}/validation`}>저장본 검증</GuardedStudioLink>
|
||||
{/*
|
||||
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
|
||||
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
|
||||
두 번 말하게 된다.
|
||||
*/}
|
||||
{controller.status === "CONFLICT" ? (
|
||||
<p className="studio-editor-conflict" role="alert">서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.</p>
|
||||
) : controller.saveError ? (
|
||||
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
|
||||
) : (
|
||||
<p>불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.</p>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
|
||||
export type FieldIssue = components["schemas"]["ValidationIssue"];
|
||||
|
||||
/**
|
||||
* 검증 항목을 그 항목이 가리키는 칸 옆으로 나눠 주기 위한 것들.
|
||||
*
|
||||
* <p>예전에는 게시하기 전에 검증 화면으로 나가서 버튼을 누르고, 지적을 읽고, 편집 화면으로
|
||||
* 돌아와 어느 칸이었는지 기억해서 고쳐야 했다. 검증 결과는 이미 `path` 로 어느 칸인지 말하고
|
||||
* 있었으므로 — `/title`, `/topicId`, `/problem` — 그 자리에 그대로 붙이면 화면을 오갈 이유가
|
||||
* 없어진다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `path` 가 가리키는 칸의 지적을 고른다.
|
||||
*
|
||||
* <p>정확히 같은 경로뿐 아니라 그 아래 경로도 함께 고른다. 배열 칸은 서버가
|
||||
* `/relations/0/targetId` 처럼 항목을 짚어 주는데, 화면에는 `relations` 라는 칸 하나만 있기
|
||||
* 때문이다. 이렇게 하지 않으면 그런 지적은 어느 칸에도 붙지 못하고 사라진다.
|
||||
*/
|
||||
export function issuesFor(
|
||||
issues: readonly FieldIssue[],
|
||||
path: string,
|
||||
): readonly FieldIssue[] {
|
||||
return issues.filter(
|
||||
(issue) => issue.path === path || issue.path.startsWith(`${path}/`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `paths` 중 어느 것에도 붙지 않는 지적. 게시 버튼 옆에 남겨 두기 위한 것이다 — 화면에 없는
|
||||
* 칸을 가리키는 지적이 조용히 사라지면, 작성자는 이유를 말해 주지 않는 실패만 보게 된다.
|
||||
*/
|
||||
export function issuesOutside(
|
||||
issues: readonly FieldIssue[],
|
||||
paths: readonly string[],
|
||||
): readonly FieldIssue[] {
|
||||
return issues.filter(
|
||||
(issue) =>
|
||||
!paths.some(
|
||||
(path) => issue.path === path || issue.path.startsWith(`${path}/`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 칸에 붙는 지적을 그 칸 아래에 적는다. `ERROR` 는 게시를 막고 `WARNING` 은 막지 않으므로
|
||||
* 둘을 다른 색으로 구분하되, 둘 다 읽히도록 `role` 을 준다.
|
||||
*/
|
||||
export function FieldNotice({
|
||||
issues,
|
||||
path,
|
||||
}: {
|
||||
issues: readonly FieldIssue[];
|
||||
path: string;
|
||||
}) {
|
||||
const matched = issuesFor(issues, path);
|
||||
if (matched.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{matched.map((issue) => (
|
||||
<span
|
||||
key={`${issue.code}:${issue.path}`}
|
||||
className={`studio-field-notice studio-field-notice--${issue.severity.toLowerCase()}`}
|
||||
data-severity={issue.severity}
|
||||
role={issue.severity === "ERROR" ? "alert" : "status"}
|
||||
>
|
||||
{issue.message}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import type {
|
||||
HomeFocusResponse,
|
||||
ProjectIndexItem,
|
||||
} from "../../../contracts/management/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
|
||||
/**
|
||||
* 공개 홈의 "지금 집중하는 것" 을 정하는 화면.
|
||||
*
|
||||
* <p>그 영역은 세 칸(현재 작업·열린 질문·최근 결정)을 가지며 셋이 모두 비면 홈은 영역 자체를 그리지
|
||||
* 않는다. `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을 채울 화면이 없었으므로,
|
||||
* 홈에서는 그 영역이 한 번도 나타난 적이 없었다.
|
||||
*
|
||||
* <p>고를 수 있는 것은 실제로 존재하는 기록뿐이다. 질문과 결정은 Studio catalog 의 `RELATION` 목록에서
|
||||
* 가져온다 — 그 목록이 곧 "연결 가능한 대상" 의 정의이고, 여기서 다른 기준을 쓰면 두 화면이 서로 다른
|
||||
* 것을 보여 준다.
|
||||
*
|
||||
* <p>비공개 프로젝트도 고를 수 있게 둔다. 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 정상적인
|
||||
* 순서이기 때문이다. 다만 게시되지 않은 동안에는 홈이 그 칸을 그리지 않으므로 그 사실을 적어 둔다.
|
||||
*/
|
||||
export function HomeFocusEditor() {
|
||||
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||
const [focus, setFocus] = useState<HomeFocusResponse | null>(null);
|
||||
const [projects, setProjects] = useState<ProjectIndexItem[]>([]);
|
||||
const [relations, setRelations] = useState<CatalogEntry[]>([]);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [questionId, setQuestionId] = useState("");
|
||||
const [decisionId, setDecisionId] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
/*
|
||||
try/catch 로 감싼다. `Promise.all([gateway.foo()])` 은 `foo` 가 거절하는 것만 잡는다 —
|
||||
호출 자체가 동기적으로 던지면(예: 그 표면을 갖추지 않은 게이트웨이) 배열을 만드는 중에
|
||||
터지므로 rejection handler 를 지나지 못한다. 그러면 이 섹션이 아니라 대시보드 전체가
|
||||
빈 화면이 된다. 한 칸의 실패가 화면을 통째로 날리지 않게 한다.
|
||||
*/
|
||||
void (async () => {
|
||||
try {
|
||||
const [current, projectPage, catalog] = await Promise.all([
|
||||
managementGateway.getHomeFocus(),
|
||||
managementGateway.listProjects(0, 50),
|
||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setFocus(current);
|
||||
setProjects(projectPage.items ?? []);
|
||||
setRelations(catalog.items ?? []);
|
||||
setProjectId(current.currentProjectId ?? "");
|
||||
setQuestionId(current.openQuestionId ?? "");
|
||||
setDecisionId(current.recentDecisionId ?? "");
|
||||
setError("");
|
||||
} catch {
|
||||
if (!cancelled) setError("홈 설정을 불러오지 못했습니다.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gateway, managementGateway, generation]);
|
||||
|
||||
const questions = relations.filter((entry) => entry.kind === "QUESTION");
|
||||
const decisions = relations.filter((entry) => entry.kind === "PROJECT_DECISION");
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending || !focus) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await managementGateway.updateHomeFocus({
|
||||
expectedVersion: focus.version,
|
||||
// 빈 문자열은 "고르지 않음" 이다. 계약은 uuid 만 받으므로 보내지 않는다.
|
||||
currentProjectId: projectId || undefined,
|
||||
openQuestionId: questionId || undefined,
|
||||
recentDecisionId: decisionId || undefined,
|
||||
});
|
||||
setFocus(saved);
|
||||
setRequestAnnouncement("홈에 표시할 항목을 저장했습니다.");
|
||||
reload();
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "홈 설정을 저장하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chosenProject = projects.find((project) => project.id === projectId);
|
||||
const projectHidden = chosenProject && chosenProject.targetVisibility === "PRIVATE";
|
||||
const nothingChosen = !projectId && !questionId && !decisionId;
|
||||
|
||||
return (
|
||||
<section className="studio-editor-section" aria-labelledby="studio-home-focus-title">
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">PUBLIC HOME</p>
|
||||
<h2 id="studio-home-focus-title">지금 집중하는 것</h2>
|
||||
<p>
|
||||
공개 홈 맨 위 영역입니다. 셋 다 비워 두면 그 영역은 나타나지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{!focus && !error ? (
|
||||
<p className="studio-loading" role="status">
|
||||
홈 설정을 불러오는 중입니다.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{focus ? (
|
||||
<form onSubmit={save}>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>현재 작업 (프로젝트)</span>
|
||||
<select value={projectId} onChange={(event) => setProjectId(event.currentTarget.value)}>
|
||||
<option value="">고르지 않음</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
{project.targetVisibility === "PRIVATE" ? " (비공개)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{projectHidden ? (
|
||||
<small>
|
||||
이 프로젝트는 아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 홈에 나타납니다.
|
||||
</small>
|
||||
) : (
|
||||
<small>단계·현재 목표·다음 작업이 카드에 함께 나옵니다.</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="studio-field">
|
||||
<span>열린 질문</span>
|
||||
<select
|
||||
value={questionId}
|
||||
onChange={(event) => setQuestionId(event.currentTarget.value)}
|
||||
disabled={questions.length === 0}
|
||||
>
|
||||
<option value="">고르지 않음</option>
|
||||
{questions.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{questions.length === 0 ? <small>아직 Question 문서가 없습니다.</small> : null}
|
||||
</label>
|
||||
|
||||
<label className="studio-field">
|
||||
<span>최근 결정</span>
|
||||
<select
|
||||
value={decisionId}
|
||||
onChange={(event) => setDecisionId(event.currentTarget.value)}
|
||||
disabled={decisions.length === 0}
|
||||
>
|
||||
<option value="">고르지 않음</option>
|
||||
{decisions.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{decisions.length === 0 ? <small>아직 Decision 문서가 없습니다.</small> : null}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="studio-editor-footer">
|
||||
<button className="studio-secondary-button" type="submit" disabled={pending}>
|
||||
{pending ? "저장하는 중" : "홈 설정 저장"}
|
||||
</button>
|
||||
<p className="studio-field-note">
|
||||
{nothingChosen
|
||||
? "지금은 아무것도 고르지 않아 홈에 이 영역이 나타나지 않습니다."
|
||||
: "저장하면 공개 홈에 바로 반영됩니다."}
|
||||
{" "}
|
||||
<GuardedStudioLink href="/studio/taxonomy">주제·프로젝트</GuardedStudioLink>
|
||||
에서 프로젝트를 만들고 게시할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||
|
||||
type OrderedText = components["schemas"]["OrderedText"];
|
||||
|
||||
@@ -25,7 +25,7 @@ export function OrderedTextList({ label, fieldId, items, onChange }: { label: st
|
||||
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === items.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(items.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
||||
</div>
|
||||
))}
|
||||
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createLocalId("item"), text: "", order: items.length }])); }}>{label} 추가</button>
|
||||
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createNewItemId(), text: "", order: items.length }])); }}>{label} 추가</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||
|
||||
type ProjectDecisionInput = components["schemas"]["ProjectDecisionInput"];
|
||||
|
||||
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
|
||||
export const DECISION_FIELD_PATHS = [
|
||||
"/decisionStatus",
|
||||
"/decidedOn",
|
||||
"/statement",
|
||||
"/rationale",
|
||||
"/consequences",
|
||||
] as const;
|
||||
|
||||
export function ProjectDecisionFields({
|
||||
draft,
|
||||
issues,
|
||||
onChange,
|
||||
}: {
|
||||
draft: ProjectDecisionInput;
|
||||
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
|
||||
issues: readonly FieldIssue[];
|
||||
onChange(draft: ProjectDecisionInput): void;
|
||||
}) {
|
||||
const update = (patch: Partial<ProjectDecisionInput>) => {
|
||||
@@ -41,6 +54,7 @@ export function ProjectDecisionFields({
|
||||
<option value="PROPOSED">PROPOSED</option>
|
||||
<option value="ADOPTED">ADOPTED</option>
|
||||
</select>
|
||||
<FieldNotice issues={issues} path="/decisionStatus" />
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>결정일</span>
|
||||
@@ -51,6 +65,7 @@ export function ProjectDecisionFields({
|
||||
update({ decidedOn: event.currentTarget.value || null });
|
||||
}}
|
||||
/>
|
||||
<FieldNotice issues={issues} path="/decidedOn" />
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>결정문</span>
|
||||
@@ -58,6 +73,7 @@ export function ProjectDecisionFields({
|
||||
value={draft.statement}
|
||||
onChange={(event) => update({ statement: event.currentTarget.value })}
|
||||
/>
|
||||
<FieldNotice issues={issues} path="/statement" />
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>판단 이유</span>
|
||||
@@ -65,6 +81,7 @@ export function ProjectDecisionFields({
|
||||
value={draft.rationale}
|
||||
onChange={(event) => update({ rationale: event.currentTarget.value })}
|
||||
/>
|
||||
<FieldNotice issues={issues} path="/rationale" />
|
||||
</label>
|
||||
</div>
|
||||
<OrderedTextList
|
||||
@@ -73,6 +90,7 @@ export function ProjectDecisionFields({
|
||||
items={draft.consequences}
|
||||
onChange={(consequences) => update({ consequences })}
|
||||
/>
|
||||
<FieldNotice issues={issues} path="/consequences" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import type {
|
||||
ProjectActivityResponse,
|
||||
ProjectEditResponse,
|
||||
TopicEdit,
|
||||
} from "../../../contracts/management/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { slugFromName } from "./slug-from-name.ts";
|
||||
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
|
||||
/** 경로 파라미터는 `unknown` 으로 들어온다 — 라우터가 코덱을 통과시킨 값이라도 타입은 좁혀 써야 한다. */
|
||||
function routeId(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** `project_phase_check` 와 같은 집합이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */
|
||||
const PHASES = [
|
||||
{ value: "RESEARCH", label: "조사" },
|
||||
{ value: "DESIGN", label: "설계" },
|
||||
{ value: "IMPLEMENTATION", label: "구현" },
|
||||
{ value: "VERIFICATION", label: "검증" },
|
||||
{ value: "MAINTENANCE", label: "유지" },
|
||||
{ value: "PAUSED", label: "중단" },
|
||||
{ value: "COMPLETED", label: "완료" },
|
||||
] as const;
|
||||
|
||||
/** `project_activity_activity_type_check` 와 같은 집합이다. */
|
||||
const ACTIVITY_TYPES = [
|
||||
{ value: "MILESTONE_REACHED", label: "이정표 도달" },
|
||||
{ value: "PHASE_CHANGED", label: "단계 전환" },
|
||||
{ value: "QUESTION_OPENED", label: "질문 열림" },
|
||||
{ value: "QUESTION_RESOLVED", label: "질문 해결" },
|
||||
{ value: "DECISION_ACCEPTED", label: "결정 수락" },
|
||||
{ value: "CASE_PUBLISHED", label: "Case 게시" },
|
||||
{ value: "REFERENCE_PUBLISHED", label: "Reference 게시" },
|
||||
{ value: "PROJECT_PAUSED", label: "프로젝트 중단" },
|
||||
{ value: "PROJECT_RESUMED", label: "프로젝트 재개" },
|
||||
] as const;
|
||||
|
||||
const activityTypeLabel = (value: string) =>
|
||||
ACTIVITY_TYPES.find((type) => type.value === value)?.label ?? value;
|
||||
|
||||
/** `<input type="datetime-local">` 은 초와 시간대가 없는 지역 시각을 쓴다. */
|
||||
function toLocalInput(isoTimestamp: string): string {
|
||||
const value = new Date(isoTimestamp);
|
||||
if (Number.isNaN(value.getTime())) return "";
|
||||
const pad = (part: number) => String(part).padStart(2, "0");
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(
|
||||
value.getHours(),
|
||||
)}:${pad(value.getMinutes())}`;
|
||||
}
|
||||
|
||||
function fromLocalInput(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
name: string;
|
||||
slug: string;
|
||||
oneLinePurpose: string;
|
||||
purposeMarkdown: string;
|
||||
boundaryMarkdown: string;
|
||||
systemOverviewMarkdown: string;
|
||||
phase: string;
|
||||
currentObjective: string;
|
||||
nextStep: string;
|
||||
technologyLabels: string;
|
||||
topicIds: readonly string[];
|
||||
};
|
||||
|
||||
function toDraft(project: ProjectEditResponse): Draft {
|
||||
return {
|
||||
name: project.name,
|
||||
slug: project.slug ?? "",
|
||||
oneLinePurpose: project.oneLinePurpose ?? "",
|
||||
purposeMarkdown: project.purposeMarkdown ?? "",
|
||||
boundaryMarkdown: project.boundaryMarkdown ?? "",
|
||||
systemOverviewMarkdown: project.systemOverviewMarkdown ?? "",
|
||||
phase: project.phase,
|
||||
currentObjective: project.currentObjective ?? "",
|
||||
nextStep: project.nextStep ?? "",
|
||||
technologyLabels: (project.technologyLabels ?? []).join(", "),
|
||||
topicIds: project.topicIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 한 편의 편집 화면.
|
||||
*
|
||||
* <p>이 화면이 없는 동안 프로젝트는 이름과 slug 만 가질 수 있었다. 그래서 공개 화면의 "현재 목표"·"다음
|
||||
* 작업"·"주요 주제" 가 늘 비어 있었고 홈의 집중 카드도 제목만 남았다 — 백엔드의 `updateProject` 는
|
||||
* 처음부터 구현돼 있었고 채울 화면만 없었다.
|
||||
*
|
||||
* <p>활동도 여기서 다룬다. 프로젝트 밖의 활동은 존재하지 않으므로 따로 둘 이유가 없고, 무엇이 공개
|
||||
* 타임라인에 실리는지를 프로젝트 필드와 같은 자리에서 보는 편이 낫다.
|
||||
*
|
||||
* <p>새 CSS 를 만들지 않는다 — 문서 편집기의 `studio-editor-section`·`studio-field-grid` 와 작업본
|
||||
* 목록의 행 클래스를 그대로 쓰므로 Studio 의 나머지와 같은 간격·타이포·색을 따른다.
|
||||
*/
|
||||
export function ProjectEditor() {
|
||||
const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">();
|
||||
const projectId = routeId(params.id);
|
||||
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||
|
||||
const [project, setProject] = useState<ProjectEditResponse | null>(null);
|
||||
const [topics, setTopics] = useState<TopicEdit[]>([]);
|
||||
const [records, setRecords] = useState<CatalogEntry[]>([]);
|
||||
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// 동기적으로 던지는 호출도 잡는다 — `home-focus-editor` 와 같은 이유다.
|
||||
void (async () => {
|
||||
try {
|
||||
const [loaded, activityList, topicList, catalog] = await Promise.all([
|
||||
managementGateway.getProject(projectId),
|
||||
managementGateway.listProjectActivities(projectId),
|
||||
managementGateway.listTopics(),
|
||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setProject(loaded);
|
||||
setDraft(toDraft(loaded));
|
||||
setActivities(activityList);
|
||||
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
|
||||
setRecords(catalog.items ?? []);
|
||||
setError("");
|
||||
} catch {
|
||||
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gateway, managementGateway, projectId, generation]);
|
||||
|
||||
const update = (patch: Partial<Draft>) =>
|
||||
setDraft((current) => (current ? { ...current, ...patch } : current));
|
||||
|
||||
const toggleTopic = (topicId: string) =>
|
||||
setDraft((current) => {
|
||||
if (!current) return current;
|
||||
const chosen = current.topicIds.includes(topicId)
|
||||
? current.topicIds.filter((id) => id !== topicId)
|
||||
: [...current.topicIds, topicId];
|
||||
return { ...current, topicIds: chosen };
|
||||
});
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending || !project || !draft) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
/*
|
||||
slug 는 공개 주소다. 비워 두면 게시할 수 없으므로 이름에서 만들어 채운다 — 예전에는 조용히
|
||||
빈 값으로 저장되었고, 게시할 때가 되어서야 "slug 가 없다" 는 말을 들었다.
|
||||
*/
|
||||
const saved = await managementGateway.updateProject(project.id, {
|
||||
expectedVersion: project.version,
|
||||
name: draft.name.trim(),
|
||||
slug: draft.slug.trim() || slugFromName(draft.name),
|
||||
oneLinePurpose: draft.oneLinePurpose,
|
||||
purposeMarkdown: draft.purposeMarkdown,
|
||||
boundaryMarkdown: draft.boundaryMarkdown,
|
||||
systemOverviewMarkdown: draft.systemOverviewMarkdown,
|
||||
phase: draft.phase,
|
||||
currentObjective: draft.currentObjective,
|
||||
nextStep: draft.nextStep,
|
||||
technologyLabels: draft.technologyLabels
|
||||
.split(",")
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean),
|
||||
topicIds: [...draft.topicIds],
|
||||
targetVisibility:
|
||||
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
|
||||
? project.targetVisibility
|
||||
: "PRIVATE",
|
||||
featuredOrder: project.featuredOrder,
|
||||
});
|
||||
/*
|
||||
이미 공개된 프로젝트라면 공개 투영도 함께 세워야 한다. 투영은 게시할 때 만들어지므로 저장만
|
||||
하면 공개 화면에는 예전 값이 남는다.
|
||||
*/
|
||||
if (saved.targetVisibility !== "PRIVATE") {
|
||||
await managementGateway.publishProject(
|
||||
saved.id,
|
||||
saved.version,
|
||||
saved.targetVisibility === "UNLISTED" ? "UNLISTED" : "PUBLIC",
|
||||
);
|
||||
}
|
||||
setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`);
|
||||
reload();
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "프로젝트를 저장하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 제출과 같은 일을 하므로 submit 이벤트를 만들어 보낸다 — 검증·기본값 처리가 한 곳에 남는다.
|
||||
useSaveShortcut(
|
||||
() => void save({ preventDefault: () => {} } as FormEvent),
|
||||
Boolean(project && draft) && !pending,
|
||||
);
|
||||
|
||||
const published = project ? project.targetVisibility !== "PRIVATE" : false;
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-editor-page">
|
||||
<header className="studio-page-top">
|
||||
<div className="studio-page-heading">
|
||||
<p className="studio-eyebrow">
|
||||
PROJECT · VERSION {project?.version ?? "—"}
|
||||
</p>
|
||||
<h1>{project?.name ?? "프로젝트"}</h1>
|
||||
<p>
|
||||
공개 프로젝트 화면과 프로필의 “현재 프로젝트”, 홈의 집중 카드가 여기 값을
|
||||
그대로 읽습니다.
|
||||
</p>
|
||||
</div>
|
||||
<GuardedStudioLink className="studio-primary-action" href="/studio/taxonomy">
|
||||
주제·프로젝트로
|
||||
</GuardedStudioLink>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{!project && !error ? (
|
||||
<p className="studio-loading" role="status">
|
||||
프로젝트를 불러오는 중입니다.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{project && draft ? (
|
||||
<>
|
||||
<form onSubmit={save}>
|
||||
<section
|
||||
className="studio-editor-section"
|
||||
aria-labelledby="studio-project-basics-title"
|
||||
>
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">PROJECT</p>
|
||||
<h2 id="studio-project-basics-title">기본 정보</h2>
|
||||
<p>목록과 카드에 그대로 나가는 값입니다.</p>
|
||||
</div>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>이름</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
maxLength={120}
|
||||
onChange={(event) => update({ name: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>slug</span>
|
||||
<input
|
||||
value={draft.slug}
|
||||
maxLength={100}
|
||||
placeholder="비우면 이름에서 만듭니다"
|
||||
onChange={(event) => update({ slug: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>단계</span>
|
||||
<select
|
||||
value={draft.phase}
|
||||
onChange={(event) => update({ phase: event.currentTarget.value })}
|
||||
>
|
||||
{PHASES.map((phase) => (
|
||||
<option key={phase.value} value={phase.value}>
|
||||
{phase.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>한 줄 목적</span>
|
||||
<input
|
||||
value={draft.oneLinePurpose}
|
||||
maxLength={200}
|
||||
onChange={(event) => update({ oneLinePurpose: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>현재 목표</span>
|
||||
<input
|
||||
value={draft.currentObjective}
|
||||
onChange={(event) => update({ currentObjective: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>다음 작업</span>
|
||||
<input
|
||||
value={draft.nextStep}
|
||||
onChange={(event) => update({ nextStep: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>기술 라벨</span>
|
||||
<input
|
||||
value={draft.technologyLabels}
|
||||
placeholder="쉼표로 구분합니다"
|
||||
onChange={(event) => update({ technologyLabels: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="studio-editor-section"
|
||||
aria-labelledby="studio-project-topics-title"
|
||||
>
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">TOPICS</p>
|
||||
<h2 id="studio-project-topics-title">주요 주제</h2>
|
||||
<p>프로젝트 화면 상단의 “주요 주제”가 고른 순서대로 나옵니다.</p>
|
||||
</div>
|
||||
{topics.length === 0 ? (
|
||||
<p className="studio-empty-inline">
|
||||
아직 주제가 없습니다. 주제·프로젝트 화면에서 먼저 만듭니다.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="studio-choice-list">
|
||||
{topics.map((topic) => (
|
||||
<li key={topic.id}>
|
||||
<label className="studio-choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.topicIds.includes(topic.id ?? "")}
|
||||
onChange={() => toggleTopic(topic.id ?? "")}
|
||||
/>
|
||||
<span>{topic.name}</span>
|
||||
<small>{topic.slug}</small>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="studio-editor-section"
|
||||
aria-labelledby="studio-project-narrative-title"
|
||||
>
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">NARRATIVE</p>
|
||||
<h2 id="studio-project-narrative-title">목적</h2>
|
||||
<p>
|
||||
프로젝트 화면의 “프로젝트가 지키는 기준”이 이 글을 그립니다.
|
||||
</p>
|
||||
</div>
|
||||
{/*
|
||||
경계와 시스템 개요 칸이 여기 함께 있었다. 저장은 되지만 공개 화면 어디에도
|
||||
나오지 않는다 — 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 쓴 글이
|
||||
어딘가 실릴 것처럼 보이는 칸이었고, 실제로는 아무도 읽지 않았다.
|
||||
|
||||
열의 데이터는 남겨 둔다(계약이 요구하고, 이미 적어 둔 글이 있을 수 있다). 저장할
|
||||
때 서버가 가진 값을 그대로 돌려보내므로 지워지지 않는다.
|
||||
*/}
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>목적</span>
|
||||
<textarea
|
||||
className="studio-markdown"
|
||||
value={draft.purposeMarkdown}
|
||||
onChange={(event) => update({ purposeMarkdown: event.currentTarget.value })}
|
||||
/>
|
||||
<small>마크다운을 씁니다.</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="studio-editor-footer">
|
||||
<button className="studio-primary-button" type="submit" title="Ctrl+S" disabled={pending}>
|
||||
{pending ? "저장하는 중" : "저장"}
|
||||
</button>
|
||||
<p className="studio-field-note">
|
||||
{published
|
||||
? "공개된 프로젝트입니다. 저장하면 공개 화면도 함께 갱신됩니다."
|
||||
: "아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 공개 화면에 나옵니다."}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section
|
||||
className="studio-editor-section"
|
||||
aria-labelledby="studio-project-activity-title"
|
||||
>
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">ACTIVITY</p>
|
||||
<h2 id="studio-project-activity-title">활동</h2>
|
||||
<p>
|
||||
기록을 게시하면 이 목록에 한 줄이 자동으로 남습니다. 손으로 적는 자리가 아닙니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<p className="studio-empty-inline">
|
||||
아직 이 프로젝트에서 게시한 기록이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="studio-document-list">
|
||||
{activities.map((activity) => (
|
||||
<article className="studio-document-row" key={activity.id}>
|
||||
<p className="studio-row-label">{activityTypeLabel(activity.activityType)}</p>
|
||||
<div className="studio-document-title">
|
||||
<h2>{activity.title}</h2>
|
||||
<p>
|
||||
{activity.visibility === "PUBLIC"
|
||||
? "공개 타임라인에 나옵니다"
|
||||
: "비공개 — 타임라인에 나오지 않습니다"}
|
||||
</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>게시</dt>
|
||||
<dd>
|
||||
<time dateTime={activity.occurredAt}>
|
||||
{new Date(activity.occurredAt).toLocaleDateString("ko-KR", {
|
||||
timeZone: "Asia/Seoul",
|
||||
})}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기록</dt>
|
||||
<dd>{activity.origin === "AUTO" ? "게시가 남김" : "손으로 적음"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||
|
||||
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
|
||||
export const QUESTION_FIELD_PATHS = [
|
||||
"/questionStatus",
|
||||
"/facts",
|
||||
"/assumptions",
|
||||
"/unknowns",
|
||||
"/constraints",
|
||||
"/options",
|
||||
"/nextValidation",
|
||||
"/resolution",
|
||||
] as const;
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
type QuestionInput = components["schemas"]["QuestionInput"];
|
||||
type QuestionOption = components["schemas"]["QuestionOption"];
|
||||
@@ -10,7 +23,7 @@ function orderedOptions(options: QuestionOption[]): QuestionOption[] {
|
||||
return options.map((option, order) => ({ ...option, order }));
|
||||
}
|
||||
|
||||
export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionInput; evidence: CatalogEntry[]; onChange(draft: QuestionInput): void }) {
|
||||
export function QuestionFields({ draft, evidence, issues, onChange }: { draft: QuestionInput; evidence: CatalogEntry[]; issues: readonly FieldIssue[]; onChange(draft: QuestionInput): void }) {
|
||||
const update = (patch: Partial<QuestionInput>) => onChange({ ...draft, ...patch });
|
||||
const moveOption = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
@@ -26,22 +39,28 @@ export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionI
|
||||
const value = event.currentTarget.value;
|
||||
if (value === "RESOLVED") update({ questionStatus: "RESOLVED", resolution: draft.resolution ?? { summary: "", evidenceTargetId: null, linkLabel: "" } });
|
||||
else update({ questionStatus: value === "OPEN" ? "OPEN" : null, resolution: null });
|
||||
}}><option value="">아직 정하지 않음</option><option value="OPEN">OPEN</option><option value="RESOLVED">RESOLVED</option></select></label>
|
||||
}}><option value="">아직 정하지 않음</option><option value="OPEN">OPEN</option><option value="RESOLVED">RESOLVED</option></select><FieldNotice issues={issues} path="/questionStatus" /></label>
|
||||
<OrderedTextList label="사실" fieldId="studio-field-facts" items={draft.facts} onChange={(facts) => update({ facts })} />
|
||||
<FieldNotice issues={issues} path="/facts" />
|
||||
<OrderedTextList label="가정" fieldId="studio-field-assumptions" items={draft.assumptions} onChange={(assumptions) => update({ assumptions })} />
|
||||
<FieldNotice issues={issues} path="/assumptions" />
|
||||
<OrderedTextList label="미지수" fieldId="studio-field-unknowns" items={draft.unknowns} onChange={(unknowns) => update({ unknowns })} />
|
||||
<FieldNotice issues={issues} path="/unknowns" />
|
||||
<OrderedTextList label="제약" fieldId="studio-field-constraints" items={draft.constraints} onChange={(constraints) => update({ constraints })} />
|
||||
<FieldNotice issues={issues} path="/constraints" />
|
||||
<fieldset id="studio-field-options" className="studio-ordered-list" tabIndex={-1}><legend>선택지</legend>
|
||||
<FieldNotice issues={issues} path="/options" />
|
||||
{draft.options.length === 0 ? <p>아직 입력한 선택지가 없습니다.</p> : null}
|
||||
{draft.options.map((option, index) => <div className="studio-ordered-item" key={option.id}>
|
||||
<label><span>선택지 {index + 1} 제목</span><input value={option.title} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
|
||||
<label><span>선택지 {index + 1} 설명</span><textarea value={option.description} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, description: event.currentTarget.value } : candidate)) })} /></label>
|
||||
<div className="studio-item-actions"><button type="button" onClick={() => moveOption(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveOption(index, 1)} disabled={index === draft.options.length - 1}>아래로</button><button type="button" onClick={() => update({ options: orderedOptions(draft.options.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
||||
</div>)}
|
||||
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createLocalId("option"), title: "", description: "", order: draft.options.length }]) }); }}>선택지 추가</button>
|
||||
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createNewItemId(), title: "", description: "", order: draft.options.length }]) }); }}>선택지 추가</button>
|
||||
</fieldset>
|
||||
<label className="studio-field studio-field--wide"><span>다음 검증</span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field studio-field--wide"><span>다음 검증</span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /><FieldNotice issues={issues} path="/nextValidation" /></label>
|
||||
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend>해결 내용</legend>
|
||||
<FieldNotice issues={issues} path="/resolution" />
|
||||
<label className="studio-field studio-field--wide"><span>해결 요약</span><textarea value={draft.resolution.summary} onChange={(event) => update({ resolution: { ...draft.resolution!, summary: event.currentTarget.value } })} /></label>
|
||||
<label className="studio-field"><span>해결 근거</span><select value={draft.resolution.evidenceTargetId ?? ""} onChange={(event) => update({ resolution: { ...draft.resolution!, evidenceTargetId: event.currentTarget.value || null } })}><option value="">근거 선택</option>{evidence.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
|
||||
<label className="studio-field"><span>근거 링크 문구</span><input value={draft.resolution.linkLabel} onChange={(event) => update({ resolution: { ...draft.resolution!, linkLabel: event.currentTarget.value } })} /></label>
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
|
||||
import { OrderedTextList } from "./ordered-text-list.tsx";
|
||||
|
||||
type ReferenceInput = components["schemas"]["ReferenceInput"];
|
||||
type ReferenceRule = components["schemas"]["ReferenceRule"];
|
||||
|
||||
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
|
||||
export const REFERENCE_FIELD_PATHS = [
|
||||
"/purpose",
|
||||
"/rules",
|
||||
"/applyWhen",
|
||||
"/exceptions",
|
||||
"/examples",
|
||||
"/verifiedOn",
|
||||
] as const;
|
||||
|
||||
function orderedRules(rules: ReferenceRule[]): ReferenceRule[] {
|
||||
return rules.map((rule, order) => ({ ...rule, order }));
|
||||
}
|
||||
|
||||
export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; onChange(draft: ReferenceInput): void }) {
|
||||
export function ReferenceFields({ draft, issues, onChange }: { draft: ReferenceInput; issues: readonly FieldIssue[]; onChange(draft: ReferenceInput): void }) {
|
||||
const update = (patch: Partial<ReferenceInput>) => onChange({ ...draft, ...patch });
|
||||
const moveRule = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
@@ -21,20 +32,24 @@ export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; on
|
||||
return (
|
||||
<section className="studio-editor-section" aria-labelledby="studio-reference-fields-title">
|
||||
<div className="studio-editor-section-heading"><p className="studio-eyebrow">REFERENCE</p><h2 id="studio-reference-fields-title">재사용할 기준</h2></div>
|
||||
<label className="studio-field studio-field--wide"><span>목적</span><textarea value={draft.purpose} onChange={(event) => update({ purpose: event.currentTarget.value })} /></label>
|
||||
<label className="studio-field studio-field--wide"><span>목적</span><textarea value={draft.purpose} onChange={(event) => update({ purpose: event.currentTarget.value })} /><FieldNotice issues={issues} path="/purpose" /></label>
|
||||
<fieldset className="studio-ordered-list"><legend>규칙</legend>
|
||||
<FieldNotice issues={issues} path="/rules" />
|
||||
{draft.rules.length === 0 ? <p>아직 입력한 규칙이 없습니다.</p> : null}
|
||||
{draft.rules.map((rule, index) => <div className="studio-ordered-item" key={rule.id}>
|
||||
<label><span>규칙 {index + 1} 제목</span><input value={rule.title} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
|
||||
<label><span>규칙 {index + 1} 본문</span><textarea value={rule.body} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, body: event.currentTarget.value } : candidate)) })} /></label>
|
||||
<div className="studio-item-actions"><button type="button" onClick={() => moveRule(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => moveRule(index, 1)} disabled={index === draft.rules.length - 1}>아래로</button><button type="button" onClick={() => update({ rules: orderedRules(draft.rules.filter((_, candidateIndex) => candidateIndex !== index)) })}>삭제</button></div>
|
||||
</div>)}
|
||||
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createLocalId("rule"), title: "", body: "", order: draft.rules.length }]) }); }}>규칙 추가</button>
|
||||
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createNewItemId(), title: "", body: "", order: draft.rules.length }]) }); }}>규칙 추가</button>
|
||||
</fieldset>
|
||||
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
|
||||
<FieldNotice issues={issues} path="/applyWhen" />
|
||||
<OrderedTextList label="예외" items={draft.exceptions} onChange={(exceptions) => update({ exceptions })} />
|
||||
<FieldNotice issues={issues} path="/exceptions" />
|
||||
<OrderedTextList label="예시" items={draft.examples} onChange={(examples) => update({ examples })} />
|
||||
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.verifiedOn ?? ""} onChange={(event) => update({ verifiedOn: event.currentTarget.value || null })} /></label>
|
||||
<FieldNotice issues={issues} path="/examples" />
|
||||
<label className="studio-field"><span>마지막 검증일</span><input type="date" value={draft.verifiedOn ?? ""} onChange={(event) => update({ verifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/verifiedOn" /></label>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
||||
import { createNewItemId } from "../../../domain/studio/local-id.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
type RelationInput = components["schemas"]["RelationInput"];
|
||||
@@ -29,7 +29,7 @@ export function RelationEditor({ relations, catalog, evidence = false, onChange
|
||||
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}>위로</button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}>아래로</button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}>삭제</button></div>
|
||||
</div>
|
||||
))}
|
||||
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}>{noun} 추가</button>
|
||||
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createNewItemId(), targetId: null, reason: "", order: relations.length }])); }}>{noun} 추가</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
ReleaseEditResponse,
|
||||
ReleaseUpdateRequest,
|
||||
} from "../../../contracts/management/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/**
|
||||
* 릴리즈 한 편의 편집 화면.
|
||||
*
|
||||
* <p>예전에는 이 폼이 릴리즈 목록 <em>아래</em>에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고
|
||||
* 목록 전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 문서·프로젝트가 각자 편집
|
||||
* 주소를 갖는 것과 같은 이유로 릴리즈도 자기 주소를 갖는다.
|
||||
*
|
||||
* <p>본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리 자유
|
||||
* 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다.
|
||||
*/
|
||||
|
||||
/** 백엔드 {@code UpdateReleaseUseCase.CHANGE_TYPES} 와 같은 집합이다. */
|
||||
const CHANGE_TYPES = [
|
||||
{ value: "FEATURE", label: "기능" },
|
||||
{ value: "FIX", label: "수정" },
|
||||
{ value: "REFACTOR", label: "구조" },
|
||||
{ value: "DOCS", label: "문서" },
|
||||
{ value: "INFRA", label: "인프라" },
|
||||
{ value: "BREAKING", label: "호환 깨짐" },
|
||||
] as const;
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: "reasonMarkdown", label: "왜 바꿨나" },
|
||||
{ key: "changesMarkdown", label: "무엇을 바꿨나" },
|
||||
{ key: "userImpactMarkdown", label: "사용자에게 달라지는 것" },
|
||||
{ key: "implementationImpactMarkdown", label: "구현에 남는 것" },
|
||||
{ key: "verificationMarkdown", label: "어떻게 검증했나" },
|
||||
{ key: "knownLimitationsMarkdown", label: "아직 못 한 것" },
|
||||
] as const;
|
||||
|
||||
type Draft = Readonly<{
|
||||
expectedVersion: number;
|
||||
versionLabel: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
releasedOn: string;
|
||||
changeTypes: readonly string[];
|
||||
reasonMarkdown: string;
|
||||
changesMarkdown: string;
|
||||
userImpactMarkdown: string;
|
||||
implementationImpactMarkdown: string;
|
||||
verificationMarkdown: string;
|
||||
knownLimitationsMarkdown: string;
|
||||
}>;
|
||||
|
||||
function toDraft(release: ReleaseEditResponse): Draft {
|
||||
return {
|
||||
expectedVersion: release.version,
|
||||
versionLabel: release.versionLabel,
|
||||
title: release.title,
|
||||
summary: release.summary ?? "",
|
||||
releasedOn: release.releasedOn ?? "",
|
||||
changeTypes: release.changeTypes ?? [],
|
||||
reasonMarkdown: release.reasonMarkdown ?? "",
|
||||
changesMarkdown: release.changesMarkdown ?? "",
|
||||
userImpactMarkdown: release.userImpactMarkdown ?? "",
|
||||
implementationImpactMarkdown: release.implementationImpactMarkdown ?? "",
|
||||
verificationMarkdown: release.verificationMarkdown ?? "",
|
||||
knownLimitationsMarkdown: release.knownLimitationsMarkdown ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
DRAFT: "작성 중",
|
||||
PUBLISHED: "공개",
|
||||
ARCHIVED: "보관",
|
||||
};
|
||||
|
||||
/** 경로 파라미터는 `unknown` 으로 들어온다 — 라우터가 코덱을 통과시킨 값이라도 타입은 좁혀 써야 한다. */
|
||||
function routeId(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function ReleaseEditor() {
|
||||
const { params } = useRouteInput<"TECH_LOG_STUDIO_RELEASE_EDIT">();
|
||||
const releaseId = routeId(params.id);
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void managementGateway.getRelease(releaseId).then(
|
||||
(release) => {
|
||||
if (cancelled) return;
|
||||
setDraft(toDraft(release));
|
||||
setStatus(release.workflowStatus);
|
||||
setError("");
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [managementGateway, releaseId, generation]);
|
||||
|
||||
const update = (patch: Partial<Draft>) =>
|
||||
setDraft((current) => (current === null ? current : { ...current, ...patch }));
|
||||
|
||||
const requestOf = (current: Draft): ReleaseUpdateRequest =>
|
||||
({
|
||||
expectedVersion: current.expectedVersion,
|
||||
versionLabel: current.versionLabel.trim(),
|
||||
title: current.title.trim(),
|
||||
summary: current.summary,
|
||||
changeTypes: [...current.changeTypes],
|
||||
changesMarkdown: current.changesMarkdown,
|
||||
verificationMarkdown: current.verificationMarkdown,
|
||||
...(current.releasedOn ? { releasedOn: current.releasedOn } : {}),
|
||||
reasonMarkdown: current.reasonMarkdown,
|
||||
userImpactMarkdown: current.userImpactMarkdown,
|
||||
implementationImpactMarkdown: current.implementationImpactMarkdown,
|
||||
knownLimitationsMarkdown: current.knownLimitationsMarkdown,
|
||||
}) as ReleaseUpdateRequest;
|
||||
|
||||
const save = async () => {
|
||||
if (pending || draft === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
|
||||
setDraft(toDraft(saved));
|
||||
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
|
||||
reload();
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "저장하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
if (pending || draft === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
/*
|
||||
먼저 저장한다. 발행은 서버에 저장된 릴리즈를 검사하는데(`PublishReleaseUseCase`), 예전에는
|
||||
저장하지 않고 발행만 불렀다 — 화면의 칸을 다 채우고 공개를 눌러도 서버 쪽은 여전히 빈
|
||||
초안이라 "모두 채워져야 합니다" 가 떴다. 채웠는데 안 된다는 말이 나온 이유가 이것이다.
|
||||
*/
|
||||
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
|
||||
setDraft(toDraft(saved));
|
||||
const published = await managementGateway.publishRelease(releaseId, saved.version);
|
||||
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
|
||||
reload();
|
||||
} catch (failure) {
|
||||
// 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
|
||||
// 그러면 이미 채운 칸까지 비었다고 말하게 된다.
|
||||
setError(
|
||||
managementFailureMessage(
|
||||
failure,
|
||||
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const archive = async () => {
|
||||
if (pending || draft === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.archiveRelease(releaseId, draft.expectedVersion);
|
||||
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
|
||||
reload();
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "공개에서 내리지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
useSaveShortcut(save, draft !== null && !pending);
|
||||
|
||||
const toggleChangeType = (value: string) => {
|
||||
if (draft === null) return;
|
||||
update({
|
||||
changeTypes: draft.changeTypes.includes(value)
|
||||
? draft.changeTypes.filter((entry) => entry !== value)
|
||||
: [...draft.changeTypes, value],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-editor-page">
|
||||
<header className="studio-page-top">
|
||||
<div className="studio-page-heading">
|
||||
<p className="studio-eyebrow">
|
||||
RELEASE · {STATUS_LABELS[status] ?? status}
|
||||
</p>
|
||||
<h1>{draft?.title || "릴리즈"}</h1>
|
||||
<p>공개 변경 기록 화면과 푸터 링크가 이 값을 그대로 읽습니다.</p>
|
||||
</div>
|
||||
<GuardedStudioLink className="studio-primary-action" href="/studio/releases">
|
||||
릴리즈 목록으로
|
||||
</GuardedStudioLink>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{draft === null && !error ? (
|
||||
<p className="studio-loading" role="status">
|
||||
릴리즈를 불러오는 중입니다.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{draft === null ? null : (
|
||||
<section className="studio-editor-section" aria-labelledby="studio-release-edit-title">
|
||||
<div className="studio-editor-section-heading">
|
||||
<p className="studio-eyebrow">RELEASE · {draft.versionLabel}</p>
|
||||
<h2 id="studio-release-edit-title">{draft.title || "릴리즈"}</h2>
|
||||
<p>변경 기록 화면과 푸터 링크가 이 값을 그대로 읽습니다.</p>
|
||||
</div>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field">
|
||||
<span>버전</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.versionLabel}
|
||||
placeholder="0.1.0"
|
||||
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
|
||||
/>
|
||||
{/*
|
||||
새 릴리즈는 `draft-…` 라는 자리표시자 버전으로 만들어지고, 서버는 그것이 남아
|
||||
있으면 공개를 거절한다(`ReleaseDrafts.isPlaceholder`). 화면에는 값이 채워져
|
||||
보이므로 왜 거절당하는지 알 길이 없었다.
|
||||
*/}
|
||||
{draft.versionLabel.startsWith("draft-") ? (
|
||||
<span className="studio-field-notice studio-field-notice--error" role="alert">
|
||||
자리표시자 버전입니다. 공개하려면 실제 버전으로 바꿔 주세요 (예: 0.2.0).
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>제목</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(event) => update({ title: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>공개일</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draft.releasedOn}
|
||||
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>한 줄 요약</span>
|
||||
<textarea
|
||||
value={draft.summary}
|
||||
onChange={(event) => update({ summary: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="studio-checkbox-set">
|
||||
<legend>변경 유형</legend>
|
||||
{CHANGE_TYPES.map((changeType) => (
|
||||
<label key={changeType.value} className="studio-field studio-field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.changeTypes.includes(changeType.value)}
|
||||
onChange={() => toggleChangeType(changeType.value)}
|
||||
/>
|
||||
<span>{changeType.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<div className="studio-field-grid">
|
||||
{SECTIONS.map((section) => (
|
||||
<label key={section.key} className="studio-field studio-field--wide">
|
||||
<span>{section.label}</span>
|
||||
<textarea
|
||||
value={draft[section.key]}
|
||||
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="studio-create-footer">
|
||||
<button
|
||||
className="studio-primary-button"
|
||||
type="button"
|
||||
title="Ctrl+S"
|
||||
disabled={pending}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
공개
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void archive()}
|
||||
>
|
||||
공개에서 내리기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
ReleaseIndexItem,
|
||||
ReleaseUpdateRequest,
|
||||
} from "../../../contracts/management/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/**
|
||||
@@ -80,13 +82,13 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
|
||||
export function ReleaseManager() {
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
const [releases, setReleases] = useState<ReleaseIndexItem[] | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
/* 방금 만든 릴리즈를 목록에서 짚어 준다 — 목록이 길면 어느 것이 새것인지 알기 어렵다. */
|
||||
const [createdId, setCreatedId] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
@@ -111,29 +113,6 @@ export function ReleaseManager() {
|
||||
};
|
||||
}, [managementGateway, generation]);
|
||||
|
||||
// 선택된 릴리즈의 본문은 목록에 없다 — 목록 행은 마크다운을 싣지 않으므로 따로 읽는다.
|
||||
useEffect(() => {
|
||||
if (selectedId === null) {
|
||||
setDraft(null);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
void managementGateway.getRelease(selectedId).then(
|
||||
(release) => {
|
||||
if (!cancelled) setDraft(toDraft(release));
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [managementGateway, selectedId, generation]);
|
||||
|
||||
const update = (patch: Partial<Draft>) =>
|
||||
setDraft((current) => (current === null ? current : { ...current, ...patch }));
|
||||
|
||||
const submitNew = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending) return;
|
||||
@@ -147,75 +126,13 @@ export function ReleaseManager() {
|
||||
try {
|
||||
const created = await managementGateway.createRelease(title);
|
||||
setNewTitle("");
|
||||
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
|
||||
setSelectedId(created.id);
|
||||
reload();
|
||||
} catch {
|
||||
setError("릴리즈를 만들지 못했습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (pending || draft === null || selectedId === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const body: ReleaseUpdateRequest = {
|
||||
expectedVersion: draft.expectedVersion,
|
||||
versionLabel: draft.versionLabel.trim(),
|
||||
title: draft.title.trim(),
|
||||
summary: draft.summary,
|
||||
changeTypes: [...draft.changeTypes],
|
||||
changesMarkdown: draft.changesMarkdown,
|
||||
verificationMarkdown: draft.verificationMarkdown,
|
||||
...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}),
|
||||
reasonMarkdown: draft.reasonMarkdown,
|
||||
userImpactMarkdown: draft.userImpactMarkdown,
|
||||
implementationImpactMarkdown: draft.implementationImpactMarkdown,
|
||||
knownLimitationsMarkdown: draft.knownLimitationsMarkdown,
|
||||
} as ReleaseUpdateRequest;
|
||||
const saved = await managementGateway.updateRelease(selectedId, body);
|
||||
setDraft(toDraft(saved));
|
||||
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("저장하지 못했습니다. 같은 버전이 이미 있거나 다른 곳에서 먼저 수정되었을 수 있습니다.");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
if (pending || draft === null || selectedId === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion);
|
||||
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
|
||||
reload();
|
||||
} catch {
|
||||
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로
|
||||
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다.
|
||||
setError(
|
||||
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||
setRequestAnnouncement(
|
||||
`릴리즈 ${title} 초안을 만들었습니다. 목록에서 편집을 눌러 내용을 채웁니다.`,
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const archive = async () => {
|
||||
if (pending || draft === null || selectedId === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.archiveRelease(selectedId, draft.expectedVersion);
|
||||
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
|
||||
setCreatedId(created.id);
|
||||
reload();
|
||||
} catch {
|
||||
setError("공개에서 내리지 못했습니다.");
|
||||
} catch (error) {
|
||||
setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
@@ -227,24 +144,15 @@ export function ReleaseManager() {
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.deleteRelease(release.id, release.version);
|
||||
if (selectedId === release.id) setSelectedId(null);
|
||||
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("삭제하지 못했습니다. 공개된 릴리즈는 삭제 대신 공개에서 내려야 합니다.");
|
||||
} catch (error) {
|
||||
setError(managementFailureMessage(error, "삭제하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleChangeType = (value: string) => {
|
||||
if (draft === null) return;
|
||||
const next = draft.changeTypes.includes(value)
|
||||
? draft.changeTypes.filter((entry) => entry !== value)
|
||||
: [...draft.changeTypes, value];
|
||||
update({ changeTypes: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
@@ -291,29 +199,38 @@ export function ReleaseManager() {
|
||||
<article
|
||||
key={release.id}
|
||||
className="studio-document-row"
|
||||
aria-current={selectedId === release.id ? "true" : undefined}
|
||||
aria-current={createdId === release.id ? "true" : undefined}
|
||||
>
|
||||
{/*
|
||||
첫 칸의 유형 라벨은 목록 격자의 첫 열이다. 빠뜨리면 제목이 그 자리로 당겨져
|
||||
작업본·주제 목록과 줄이 맞지 않는다.
|
||||
*/}
|
||||
<p className="studio-row-label">RELEASE</p>
|
||||
<div className="studio-document-title">
|
||||
<h2>{release.title}</h2>
|
||||
<p>
|
||||
{release.versionLabel}
|
||||
{release.releasedOn ? ` · ${release.releasedOn}` : ""}
|
||||
</p>
|
||||
<p>{release.publication.canonicalPath ?? "아직 공개하지 않음"}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>버전</dt>
|
||||
<dd>{release.versionLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>상태</dt>
|
||||
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>공개일</dt>
|
||||
<dd>{release.releasedOn || "미정"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
<div className="studio-row-actions">
|
||||
<GuardedStudioLink
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
|
||||
href={`/studio/releases/${release.id}`}
|
||||
>
|
||||
{selectedId === release.id ? "닫기" : "편집"}
|
||||
</button>
|
||||
편집
|
||||
</GuardedStudioLink>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
@@ -322,106 +239,13 @@ export function ReleaseManager() {
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{draft === null ? null : (
|
||||
<section aria-label="릴리즈 편집">
|
||||
<h2 className="studio-section-title">{draft.title || "릴리즈"} 편집</h2>
|
||||
<div className="studio-field-grid">
|
||||
<label className="studio-field">
|
||||
<span>버전</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.versionLabel}
|
||||
placeholder="0.1.0"
|
||||
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>제목</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(event) => update({ title: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>공개일</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draft.releasedOn}
|
||||
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="studio-field studio-field--wide">
|
||||
<span>한 줄 요약</span>
|
||||
<textarea
|
||||
value={draft.summary}
|
||||
onChange={(event) => update({ summary: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="studio-field-grid">
|
||||
<legend className="studio-field">
|
||||
<span>변경 유형</span>
|
||||
</legend>
|
||||
{CHANGE_TYPES.map((changeType) => (
|
||||
<label key={changeType.value} className="studio-field studio-field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.changeTypes.includes(changeType.value)}
|
||||
onChange={() => toggleChangeType(changeType.value)}
|
||||
/>
|
||||
<span>{changeType.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<div className="studio-field-grid">
|
||||
{SECTIONS.map((section) => (
|
||||
<label key={section.key} className="studio-field studio-field--wide">
|
||||
<span>{section.label}</span>
|
||||
<textarea
|
||||
value={draft[section.key]}
|
||||
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="studio-create-footer">
|
||||
<button
|
||||
className="studio-primary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
공개
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void archive()}
|
||||
>
|
||||
공개에서 내리기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { StudioDashboard as StudioDashboardData } from "../../../contracts/
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { HomeFocusEditor } from "./home-focus-editor.tsx";
|
||||
|
||||
type DocumentSummary = components["schemas"]["DocumentSummary"];
|
||||
|
||||
@@ -158,6 +159,7 @@ export function StudioDashboard() {
|
||||
href="/studio/documents"
|
||||
empty="게시 준비가 끝난 문서가 없습니다."
|
||||
/>
|
||||
<HomeFocusEditor />
|
||||
<section className="studio-work-section">
|
||||
<div className="studio-section-title">
|
||||
<h2>최근 게시</h2>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { slugFromName } from "./slug-from-name.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
@@ -14,6 +16,23 @@ import { useStudio } from "../use-studio.ts";
|
||||
* <p>새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의
|
||||
* 나머지와 같은 간격·타이포·색을 그대로 따른다.
|
||||
*/
|
||||
/** 공개 프로젝트 화면과 같은 말을 쓴다 — 두 화면이 다른 낱말을 쓰면 같은 것인지 알기 어렵다. */
|
||||
const phaseLabel: Record<string, string> = {
|
||||
RESEARCH: "조사",
|
||||
DESIGN: "설계",
|
||||
IMPLEMENTATION: "구현",
|
||||
VERIFICATION: "검증",
|
||||
MAINTENANCE: "유지",
|
||||
PAUSED: "중단",
|
||||
COMPLETED: "완료",
|
||||
};
|
||||
|
||||
const visibilityLabel: Record<string, string> = {
|
||||
PRIVATE: "비공개",
|
||||
UNLISTED: "주소로만",
|
||||
PUBLIC: "공개",
|
||||
};
|
||||
|
||||
export function TaxonomyManager() {
|
||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
|
||||
@@ -75,8 +94,8 @@ export function TaxonomyManager() {
|
||||
setTopicSlug("");
|
||||
setRequestAnnouncement(`주제 ${name} 을(를) 만들었습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("주제를 만들지 못했습니다. 같은 이름이나 slug 가 이미 있을 수 있습니다.");
|
||||
} catch (error) {
|
||||
setError(managementFailureMessage(error, "주제를 만들지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
@@ -93,12 +112,36 @@ export function TaxonomyManager() {
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await managementGateway.createProject(title);
|
||||
const created = await managementGateway.createProject(title);
|
||||
/*
|
||||
생성 계약은 이름만 받는데(`CreateDraftRequest`), slug 가 없는 프로젝트는 공개 화면에
|
||||
나타날 수 없다 — 공개 계약의 `ProjectSummary` 는 `slug` 와 `path` 를 요구하므로 서버는
|
||||
slug 가 빈 프로젝트를 통째로 생략한다. 기록에 프로젝트를 붙여 게시해도 공개 문서의
|
||||
프로젝트 칸이 비어 있던 이유가 이것이다.
|
||||
|
||||
그래서 만든 직후에 이름에서 만든 slug 를 채운다. 주제가 `{name, slug}` 를 함께 보내는
|
||||
것과 같은 규칙이고(`slugFromName`), 한글 이름도 로마자로 옮겨 유효한 slug 가 된다.
|
||||
*/
|
||||
const project = await managementGateway.getProject(created.id);
|
||||
await managementGateway.updateProject(created.id, {
|
||||
expectedVersion: project.version,
|
||||
name: project.name,
|
||||
slug: slugFromName(project.name),
|
||||
oneLinePurpose: project.oneLinePurpose ?? "",
|
||||
purposeMarkdown: project.purposeMarkdown ?? "",
|
||||
boundaryMarkdown: project.boundaryMarkdown ?? "",
|
||||
phase: project.phase,
|
||||
technologyLabels: project.technologyLabels ?? [],
|
||||
targetVisibility:
|
||||
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
|
||||
? project.targetVisibility
|
||||
: "PRIVATE",
|
||||
});
|
||||
setProjectTitle("");
|
||||
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("프로젝트를 만들지 못했습니다.");
|
||||
} catch (error) {
|
||||
setError(managementFailureMessage(error, "프로젝트를 만들지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
@@ -112,8 +155,8 @@ export function TaxonomyManager() {
|
||||
await managementGateway.deleteTopic(topic.id, topic.version);
|
||||
setRequestAnnouncement(`주제 ${topic.name} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("주제를 삭제하지 못했습니다. 이 주제를 쓰는 기록이 있을 수 있습니다.");
|
||||
} catch (error) {
|
||||
setError(managementFailureMessage(error, "주제를 삭제하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
@@ -127,8 +170,39 @@ export function TaxonomyManager() {
|
||||
await managementGateway.deleteProject(project.id, project.version);
|
||||
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 삭제했습니다.`);
|
||||
reload();
|
||||
} catch {
|
||||
setError("프로젝트를 삭제하지 못했습니다. 연결된 기록이 있을 수 있습니다.");
|
||||
} catch (error) {
|
||||
setError(managementFailureMessage(error, "프로젝트를 삭제하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
프로젝트 게시는 문서 게시와 별개다. 문서를 게시해도 그 문서가 속한 프로젝트는 비공개로 남고,
|
||||
공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈의 focus)은 전부 게시된 프로젝트만 읽는다.
|
||||
그래서 그 화면들이 조용히 비어 있었다 — 게시할 방법 자체가 없었기 때문이다.
|
||||
*/
|
||||
const togglePublish = async (project: ProjectIndexItem) => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
const published = project.targetVisibility !== "PRIVATE";
|
||||
try {
|
||||
if (published) {
|
||||
await managementGateway.unpublishProject(project.id, project.version);
|
||||
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 비공개로 되돌렸습니다.`);
|
||||
} else {
|
||||
await managementGateway.publishProject(project.id, project.version, "PUBLIC");
|
||||
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 공개했습니다.`);
|
||||
}
|
||||
reload();
|
||||
} catch (error) {
|
||||
setError(
|
||||
managementFailureMessage(
|
||||
error,
|
||||
published ? "프로젝트를 비공개로 되돌리지 못했습니다." : "프로젝트를 게시하지 못했습니다.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
@@ -210,7 +284,12 @@ export function TaxonomyManager() {
|
||||
<dl>
|
||||
<div>
|
||||
<dt>상태</dt>
|
||||
<dd>{topic.status === "ARCHIVED" ? "보관" : "사용 중"}</dd>
|
||||
{/*
|
||||
`ACTIVE` 를 "사용 중" 이라 적었더니, 방금 만들어 아무도 쓰지 않는 주제까지
|
||||
"사용 중" 으로 보였다. 그 상태에서 삭제가 거절되면 작성자는 둘을 같은
|
||||
말로 읽는다 — 실제로는 서로 다른 이야기다.
|
||||
*/}
|
||||
<dd>{topic.status === "ARCHIVED" ? "보관" : "활성"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
@@ -239,19 +318,45 @@ export function TaxonomyManager() {
|
||||
<article className="studio-document-row" key={project.id}>
|
||||
<p className="studio-row-label">PROJECT</p>
|
||||
<div className="studio-document-title">
|
||||
<h2>{project.name}</h2>
|
||||
<h2>
|
||||
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
|
||||
{project.name}
|
||||
</GuardedStudioLink>
|
||||
</h2>
|
||||
<p>{project.currentObjective ?? "목표 미지정"}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>단계</dt>
|
||||
<dd>{project.phase}</dd>
|
||||
<dd>{phaseLabel[project.phase] ?? project.phase}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>공개</dt>
|
||||
<dd>{project.targetVisibility}</dd>
|
||||
{/*
|
||||
"게시됨" 이 아니라 어디까지 보이는지를 적는다 — UNLISTED 는 주소를 아는
|
||||
사람만 닿고 목록에는 없다. 둘을 같은 말로 적으면 왜 목록에 안 나오는지
|
||||
설명할 방법이 없다.
|
||||
*/}
|
||||
<dd>{visibilityLabel[project.targetVisibility] ?? project.targetVisibility}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>다음</dt>
|
||||
<dd>
|
||||
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
|
||||
내용 편집
|
||||
</GuardedStudioLink>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="studio-row-actions">
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void togglePublish(project)}
|
||||
>
|
||||
{project.targetVisibility === "PRIVATE" ? "게시" : "게시 취소"}
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
@@ -260,6 +365,7 @@ export function TaxonomyManager() {
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Ctrl+S(맥에서는 Cmd+S)로 저장한다.
|
||||
*
|
||||
* <p>브라우저의 기본 동작은 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장 대화상자가
|
||||
* 뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다. 그래서 기본 동작을 막고 이
|
||||
* 화면의 저장으로 돌린다.
|
||||
*
|
||||
* <p>`enabled` 가 거짓이면 아무것도 하지 않는다. 저장할 것이 없거나 이미 저장 중일 때 단축키가 요청을
|
||||
* 겹쳐 보내지 않게 하는 것은 호출자의 몫이다 — 화면마다 "지금 저장할 수 있는가"의 뜻이 다르다.
|
||||
*/
|
||||
export function useSaveShortcut(
|
||||
save: () => void | Promise<void>,
|
||||
enabled = true,
|
||||
) {
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== "s" && event.key !== "S") return;
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
// Ctrl+Shift+S 는 다른 뜻으로 쓰는 곳이 있어 가로채지 않는다.
|
||||
if (event.shiftKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
if (enabled) void save();
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [enabled, save]);
|
||||
}
|
||||
@@ -55,11 +55,23 @@ export function ValidationReport({
|
||||
<h2 id="studio-validation-report-title">검증 결과</h2>
|
||||
</div>
|
||||
<span
|
||||
className={`studio-workflow-status studio-workflow-status--${report.status.toLowerCase()}`}
|
||||
className={`studio-workflow-status studio-workflow-status--${current ? report.status.toLowerCase() : "stale"}`}
|
||||
>
|
||||
{report.status}
|
||||
{current ? report.status : "지난 결과"}
|
||||
</span>
|
||||
</div>
|
||||
{/*
|
||||
낡은 판정을 현재 것과 같은 모양으로 보여주면 읽는 사람은 지금 상태로 읽는다. 실제로
|
||||
버전 2 때의 오류 목록을 보고 버전 5 가 그렇다고 이해한 일이 있었다 — "이전 결과" 라는
|
||||
작은 글씨 하나로는 그 오해를 막지 못한다. 무엇에 대한 판정인지 먼저 말한다.
|
||||
*/}
|
||||
{current ? null : (
|
||||
<p className="studio-workflow-stale-notice" role="status">
|
||||
아래는 <strong>버전 {report.validatedVersion}</strong> 을 검사한 지난 결과입니다.
|
||||
그 뒤 문서가 바뀌었으니 지금 상태와 다를 수 있습니다 — 위의 <strong>다시 검증</strong>을
|
||||
눌러 현재 버전을 확인해 주세요.
|
||||
</p>
|
||||
)}
|
||||
<dl className="studio-workflow-metadata">
|
||||
<div><dt>대상 버전</dt><dd>{report.validatedVersion}</dd></div>
|
||||
<div><dt>상태</dt><dd>{current ? "현재" : "이전 결과"}</dd></div>
|
||||
@@ -67,7 +79,10 @@ export function ValidationReport({
|
||||
<div><dt>유효 시각</dt><dd>{formatDateTime(report.validUntil)}</dd></div>
|
||||
</dl>
|
||||
{issues.length ? (
|
||||
<ol className="studio-workflow-issue-list" aria-label="검증 항목">
|
||||
<ol
|
||||
className={`studio-workflow-issue-list${current ? "" : " studio-workflow-issue-list--stale"}`}
|
||||
aria-label={current ? "검증 항목" : "지난 검증 항목"}
|
||||
>
|
||||
{issues.map((issue, index) => (
|
||||
<li key={`${issue.severity}-${issue.code}-${issue.path}-${index}`}>
|
||||
<GuardedStudioLink
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProjectEditor } from "../components/project-editor.tsx";
|
||||
|
||||
export function ProjectEditPage() {
|
||||
return <ProjectEditor />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReleaseEditor } from "../components/release-editor.tsx";
|
||||
|
||||
export function ReleaseEditPage() {
|
||||
return <ReleaseEditor />;
|
||||
}
|
||||
@@ -17,7 +17,12 @@
|
||||
--warning-soft: #fff7ed;
|
||||
--code-canvas: #15181d;
|
||||
--shell: 1180px;
|
||||
--body-copy: 42rem;
|
||||
/*
|
||||
읽는 단의 폭. 본문·코드블록·표·콜아웃·그림이 모두 이 값을 쓰므로 한 곳만 바꾸면 단 전체가 함께
|
||||
움직인다. 42rem(672px)은 영문 기준 측정값이었고, 한글 본문에서는 좁아 보이는 데다 바로 위의
|
||||
유형/프로젝트/게시 줄이 shell 전체(1180px)를 쓰고 있어 대비가 더 심했다.
|
||||
*/
|
||||
--body-copy: 56rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -95,6 +100,102 @@ a {
|
||||
same reserved height: with only `main` covered the footer still sat at the
|
||||
viewport bottom during loading and then dropped out of view when the content
|
||||
arrived, which is the whole 0.192 the page scored. */
|
||||
/*
|
||||
로그인 관문.
|
||||
|
||||
이 화면은 공개 셸과 Studio 셸 양쪽에서 나온다 — 보호된 주소를 열면 그 주소가 속한 레이아웃 안에
|
||||
이 자리가 대신 그려진다. 그래서 두 곳 모두에 있는 토큰만 쓴다.
|
||||
|
||||
카드로 세우는 이유는 이것이 "화면을 못 그렸다"가 아니라 "여기서 한 걸음 더 필요하다"이기
|
||||
때문이다. 오류 화면과 같은 모양이면 작성자는 무언가 고장 난 줄로 읽는다.
|
||||
*/
|
||||
.auth-gate {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding-block: clamp(64px, 12vh, 140px);
|
||||
}
|
||||
|
||||
.auth-gate .auth-gate__card {
|
||||
width: min(100%, 34rem);
|
||||
padding: clamp(32px, 5vw, 52px);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.auth-gate .page-header {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-gate .page-header__eyebrow {
|
||||
margin: 0 0 14px;
|
||||
color: var(--signal);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.auth-gate .page-header h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(27px, 3.2vw, 33px);
|
||||
font-weight: 660;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.auth-gate .page-header__description {
|
||||
margin: 16px 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 16px;
|
||||
line-height: 1.72;
|
||||
}
|
||||
|
||||
.auth-gate .auth-gate__actions {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 32px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* 이 버튼은 화면에서 유일한 행동이다. 폭을 채워 어디를 눌러야 하는지 묻지 않게 한다. */
|
||||
.auth-gate .auth-gate__actions button {
|
||||
display: inline-flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding-inline: 20px;
|
||||
border: 1px solid var(--signal);
|
||||
border-radius: 6px;
|
||||
background: var(--signal);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-gate .auth-gate__actions button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.auth-gate .auth-gate__return {
|
||||
margin: 0;
|
||||
color: var(--faint);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.auth-gate .ui-terminal-error {
|
||||
margin: 20px 0 0;
|
||||
color: var(--warning);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.site-frame > main,
|
||||
.site-frame > .ui-page {
|
||||
/* Takes the slack so the footer stays put whether the route rendered a long
|
||||
@@ -1329,11 +1430,18 @@ dialog::backdrop {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 모달 dialog 를 화면 가운데에 둔다. UA 기본값(margin:auto)에 맡기면 이 빌드에서는 좌상단에
|
||||
붙는다 — .search-dialog 와 Studio 의 .studio-unsaved-dialog 가 같은 이유로 position/inset/
|
||||
margin 을 명시한다. 여기만 빠져 있어 "크게 보기" 가 왼쪽 위에 열렸다. */
|
||||
.figure-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: min(1180px, calc(100% - 48px));
|
||||
max-width: none;
|
||||
max-height: calc(100dvh - 48px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 9px;
|
||||
background: var(--paper);
|
||||
@@ -2174,6 +2282,20 @@ dialog::backdrop {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
/*
|
||||
문서 한 편은 하나의 단으로 읽힌다.
|
||||
|
||||
예전에는 세 조각이 저마다 다른 폭이었다 — 머리말 920px, 사실 줄은 shell 전체 1180px, 본문은
|
||||
672px 를 가운데 정렬. 그래서 눈이 왼쪽 끝을 세 번 다시 찾아야 했고, 가장 좁은 본문이 가장 넓은
|
||||
사실 줄 안에 갇힌 것처럼 보였다. 셋을 같은 폭·같은 왼쪽 끝에 세운다.
|
||||
*/
|
||||
.public-document-header,
|
||||
.document-snapshot,
|
||||
.document-facts {
|
||||
width: min(100%, var(--body-copy));
|
||||
max-width: var(--body-copy);
|
||||
}
|
||||
|
||||
.public-page-header h1,
|
||||
.public-document-header h1 {
|
||||
margin: 15px 0 18px;
|
||||
@@ -2297,7 +2419,7 @@ dialog::backdrop {
|
||||
.reference-purpose,
|
||||
.document-relations {
|
||||
width: min(100%, var(--body-copy));
|
||||
margin: 82px auto 0;
|
||||
margin: 82px 0 0;
|
||||
}
|
||||
.public-document-body > section + section { margin-top: 76px; padding-top: 70px; border-top: 1px solid var(--line); }
|
||||
.public-document-body h2,
|
||||
@@ -2613,3 +2735,29 @@ textarea.studio-control { min-height: 118px; resize: vertical; line-height: 1.65
|
||||
.studio-diff-grid section, .studio-diff-grid section + section { min-height: 0; padding: 23px 0; border-left: 0; }
|
||||
.studio-diff-grid section + section { border-top: 1px solid var(--line); }
|
||||
}
|
||||
|
||||
/* 본문 구분선과 그림. 문서 본문의 다른 블록과 같은 세로 리듬을 따른다. */
|
||||
.document-body > section > .document-rule {
|
||||
margin: 34px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.document-body > section > .document-image {
|
||||
margin: 26px 0;
|
||||
}
|
||||
|
||||
.document-body > section > .document-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.document-body > section > .document-image figcaption {
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
.studio-app .studio-editor-page,
|
||||
.studio-app .studio-editor-layout,
|
||||
.studio-app .studio-editor-split,
|
||||
.studio-app .studio-editor-workspace,
|
||||
.studio-app .studio-editor-workspace > [role="tabpanel"] { min-width: 0; }
|
||||
.studio-app .studio-editor-preview { min-width: 0; }
|
||||
|
||||
.studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); }
|
||||
.studio-app .studio-editor-tabs { display: flex; gap: 28px; border-bottom: 1px solid var(--line-strong); }
|
||||
.studio-app .studio-editor-tabs button { position: relative; min-width: 112px; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 650; }
|
||||
.studio-app .studio-editor-tabs button[aria-selected="true"] { color: var(--ink); }
|
||||
.studio-app .studio-editor-tabs button[aria-selected="true"]::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--signal); content: ""; }
|
||||
/*
|
||||
편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
|
||||
보려면 편집하던 자리를 화면에서 치워야 했다.
|
||||
*/
|
||||
/*
|
||||
이 화면만 공용 컨테이너보다 넓다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께
|
||||
쓰는데, 여기서는 그 폭에 본문 두 벌이 들어가야 해서 한 칸이 566px 로 좁아졌다. 헤더와
|
||||
다른 화면은 그대로 두고 편집기가 놓인 경우에만 넓힌다.
|
||||
*/
|
||||
.studio-app:has(.studio-editor-split) .studio-header-inner,
|
||||
.studio-app:has(.studio-editor-split) .studio-main { width: min(1600px, calc(100% - 80px)); }
|
||||
|
||||
.studio-app .studio-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 280px); gap: 64px; align-items: start; padding-top: 40px; }
|
||||
/*
|
||||
편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
|
||||
보려면 편집하던 자리를 화면에서 치워야 했다.
|
||||
|
||||
두 칸 모두 페이지 스크롤을 그대로 탄다. 미리보기를 붙박이로 두고 자체 스크롤을 주었더니
|
||||
편집기를 내려도 미리보기는 제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가
|
||||
둘을 같이 보는 것인데 움직임이 갈라지면 그 이점이 없다.
|
||||
*/
|
||||
.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; padding-top: 40px; }
|
||||
/* 반쪽 폭에는 62px 짜리 제목이 앉을 자리가 없다. */
|
||||
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(28px, 3vw, 40px); }
|
||||
|
||||
.studio-app .studio-editor-preview { display: flex; flex-direction: column; padding-left: 40px; border-left: 1px solid var(--line); }
|
||||
.studio-app .studio-editor-preview__heading { padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
||||
.studio-app .studio-editor-preview__heading .studio-eyebrow { margin-bottom: 8px; }
|
||||
.studio-app .studio-editor-preview__body { padding-top: 20px; }
|
||||
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
|
||||
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
|
||||
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||
@@ -17,7 +39,7 @@
|
||||
.studio-app .studio-editor-section-heading { margin-bottom: 24px; }
|
||||
.studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; }
|
||||
.studio-app .studio-editor-section-heading h2,
|
||||
.studio-app .studio-document-status-rail h2,
|
||||
.studio-app .studio-editor-preview__heading h2,
|
||||
.studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; }
|
||||
|
||||
.studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
||||
@@ -36,6 +58,31 @@
|
||||
.studio-app .studio-ordered-item textarea { min-height: 112px; resize: vertical; }
|
||||
.studio-app .studio-field .studio-markdown-field { min-height: 420px; font-family: "IBM Plex Mono", monospace; font-size: 13px; }
|
||||
|
||||
/* 칸 아래 한 줄 설명. 라벨과 같은 격자 안에 들어가므로 별도 여백을 두지 않는다. */
|
||||
.studio-app .studio-field > small { color: var(--faint); font-size: 12px; font-weight: 450; line-height: 1.6; }
|
||||
|
||||
/* 여러 개를 고르는 목록(주제 등). 작업본 목록의 유형 선택과 같은 모양을 쓴다. */
|
||||
.studio-app .studio-choice-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 24px; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--line); }
|
||||
.studio-app .studio-choice { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px 12px; align-items: center; min-height: 60px; padding: 12px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||
.studio-app .studio-choice input { width: 20px; min-width: 20px; min-height: 20px; padding: 0; }
|
||||
.studio-app .studio-choice span { font-size: 15px; font-weight: 550; }
|
||||
.studio-app .studio-choice small { grid-column: 2; color: var(--faint); font-family: "IBM Plex Mono", monospace; font-size: 11px; }
|
||||
|
||||
/* 편집 화면 하단의 저장 줄. 버튼과 그 옆의 상태 한 줄. */
|
||||
.studio-app .studio-editor-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 32px 0 0; }
|
||||
.studio-app .studio-editor-footer .studio-primary-button,
|
||||
.studio-app .studio-editor-footer .studio-secondary-button { margin: 0; }
|
||||
.studio-app .studio-editor-footer .studio-field-note { flex: 1 1 260px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
|
||||
/* 체크박스 묶음(변경 유형 등). `.studio-field-grid` 를 쓰면 legend 가 한 칸을 차지해 줄이 어긋난다. */
|
||||
.studio-app .studio-checkbox-set { margin: 34px 0 0; padding: 0; border: 0; }
|
||||
.studio-app .studio-checkbox-set legend { padding: 0 0 14px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||
.studio-app .studio-checkbox-set { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0 24px; }
|
||||
.studio-app .studio-checkbox-set legend { grid-column: 1 / -1; }
|
||||
.studio-app .studio-checkbox-set .studio-field--checkbox { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; align-items: center; min-height: 52px; padding: 8px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||
.studio-app .studio-checkbox-set .studio-field--checkbox input { width: 20px; min-width: 20px; min-height: 20px; padding: 0; }
|
||||
.studio-app .studio-checkbox-set .studio-field--checkbox span { font-size: 15px; font-weight: 500; }
|
||||
|
||||
.studio-app .studio-ordered-list,
|
||||
.studio-app .studio-resolution-fields { min-width: 0; margin: 34px 0 0; padding: 0; border: 0; }
|
||||
.studio-app .studio-ordered-list legend,
|
||||
@@ -46,27 +93,38 @@
|
||||
.studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.studio-app .studio-item-actions button,
|
||||
.studio-app .studio-add-item,
|
||||
.studio-app .studio-document-status-rail button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
||||
.studio-app .studio-document-status-bar button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
|
||||
.studio-app .studio-item-actions button:disabled,
|
||||
.studio-app .studio-add-item:disabled { opacity: 0.45; }
|
||||
.studio-app .studio-add-item { margin-top: 12px; }
|
||||
.studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
||||
.studio-app .studio-resolution-fields legend { grid-column: 1 / -1; }
|
||||
|
||||
.studio-app .studio-document-status-rail { position: sticky; top: 28px; min-width: 0; padding: 24px 0; border-top: 1px solid var(--line-strong); border-bottom: 1px solid var(--line-strong); }
|
||||
.studio-app .studio-document-status-rail .studio-eyebrow { margin-bottom: 8px; }
|
||||
.studio-app .studio-editor-status { margin: 20px 0; font-weight: 700; }
|
||||
/*
|
||||
작업 상태와 저장·게시를 화면 아래에 붙여 둔다. 문서가 길어져도 스크롤 위치와 무관하게 손이
|
||||
닿는다. 배경을 불투명하게 두는 것은 장식이 아니라 필요다 — 밑으로 지나가는 본문이 비치면
|
||||
상태 글자를 읽을 수 없다.
|
||||
*/
|
||||
.studio-app .studio-document-status-bar { position: sticky; bottom: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 28px; min-width: 0; margin-top: 48px; padding: 16px 0; border-top: 1px solid var(--line-strong); background: var(--paper); }
|
||||
.studio-app .studio-editor-status { margin: 0; font-weight: 700; }
|
||||
.studio-app .studio-editor-status--dirty,
|
||||
.studio-app .studio-editor-status--conflict,
|
||||
.studio-app .studio-editor-conflict { color: #8f2f27; }
|
||||
.studio-app .studio-editor-status--clean { color: #166748; }
|
||||
.studio-app .studio-document-status-rail dl { display: grid; gap: 12px; margin: 0 0 20px; }
|
||||
.studio-app .studio-document-status-rail dl div { display: flex; justify-content: space-between; gap: 16px; }
|
||||
.studio-app .studio-document-status-rail dt { color: var(--muted); font-size: 12px; }
|
||||
.studio-app .studio-document-status-rail dd { margin: 0; font-size: 13px; }
|
||||
.studio-app .studio-document-status-rail button { width: 100%; border-color: var(--signal); background: var(--signal); color: #fff; }
|
||||
.studio-app .studio-document-status-rail button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
|
||||
.studio-app .studio-document-status-rail > p:last-child { margin: 16px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
||||
.studio-app .studio-document-status-bar dl { display: flex; gap: 20px; margin: 0; }
|
||||
.studio-app .studio-document-status-bar dl div { display: flex; align-items: baseline; gap: 8px; }
|
||||
.studio-app .studio-document-status-bar dt { color: var(--muted); font-size: 12px; }
|
||||
.studio-app .studio-document-status-bar dd { margin: 0; font-size: 13px; }
|
||||
.studio-app .studio-document-status-bar__note { flex: 1 1 240px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
/* 색은 위 묶음이 정한다 — 여기서 다시 칠하면 실패가 안내문처럼 보인다. */
|
||||
.studio-app .studio-document-status-bar .studio-editor-conflict { flex: 1 1 240px; margin: 0; font-size: 12px; line-height: 1.6; }
|
||||
.studio-app .studio-document-status-bar__actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||
/* `.studio-primary-button` 은 세로로 쌓이는 자리에 맞춘 바깥 여백을 갖고 있다. 한 줄에
|
||||
나란히 두면 그 여백이 줄 높이를 늘리고, 옆 버튼만 늘어나 짝이 어긋난다. */
|
||||
.studio-app .studio-document-status-bar__actions .studio-primary-button { margin: 0; }
|
||||
.studio-app .studio-document-status-bar button { min-width: 104px; border-color: var(--signal); background: var(--signal); color: #fff; }
|
||||
.studio-app .studio-document-status-bar button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
|
||||
.studio-app .studio-document-status-bar .studio-editor-unplaced-issues { flex: 1 1 100%; }
|
||||
|
||||
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
|
||||
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
|
||||
@@ -82,7 +140,7 @@
|
||||
matches this form's submit button, and both selectors carry the same
|
||||
specificity, so only source order tells them apart. */
|
||||
/* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the
|
||||
reason `.studio-editor-layout` already needs the same pair: a grid track and
|
||||
reason `.studio-editor-split` already needs the same pair: a grid track and
|
||||
a flex container both default to an automatic minimum of their min-content
|
||||
size, so at 360px the search row sized itself to 366px inside a 328px column
|
||||
and pushed the submit button off-screen (document scrollWidth 382). */
|
||||
@@ -103,15 +161,19 @@
|
||||
.studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.studio-app .studio-editor-layout { grid-template-columns: minmax(0, 1fr); gap: 40px; }
|
||||
.studio-app .studio-document-status-rail { position: static; }
|
||||
.studio-app .studio-editor-split { grid-template-columns: minmax(0, 1fr); gap: 40px; }
|
||||
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(38px, 5vw, 62px); }
|
||||
.studio-app .studio-editor-preview { padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.studio-app .studio-editor-heading { padding-bottom: 28px; }
|
||||
.studio-app .studio-editor-tabs { gap: 18px; }
|
||||
.studio-app .studio-editor-tabs button { flex: 1; min-width: 0; }
|
||||
.studio-app .studio-editor-layout { padding-top: 28px; }
|
||||
.studio-app .studio-editor-split { padding-top: 28px; }
|
||||
/* 안내문은 세 줄로 접혀 뷰포트의 20%를 고정으로 가져간다. 실패 문구는 남기고 안내만 접는다 —
|
||||
Ctrl+S 는 여기서 누를 수 있는 것도 아니다. */
|
||||
.studio-app .studio-document-status-bar__note { display: none; }
|
||||
.studio-app .studio-document-status-bar__actions { flex: 1 1 100%; margin-left: 0; }
|
||||
.studio-app .studio-document-status-bar__actions button { flex: 1; min-width: 0; }
|
||||
.studio-app .studio-field-grid,
|
||||
.studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.studio-app .studio-field--wide,
|
||||
@@ -119,3 +181,43 @@
|
||||
.studio-app .studio-item-actions { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.studio-app .studio-instant-preview .public-record-embedded { padding: 22px 16px; }
|
||||
}
|
||||
|
||||
/* 게시까지의 단계. 순서가 있는 길이라 번호를 붙인다 — 장식이 아니라 밟아야 하는 차례다. */
|
||||
.studio-app .studio-editor-flow ol { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; }
|
||||
.studio-app .studio-editor-flow li { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.studio-app .studio-editor-flow li + li::before { content: "→"; margin-right: 4px; color: var(--faint); }
|
||||
.studio-app .studio-editor-flow span { display: inline-flex; min-width: 18px; height: 18px; align-items: center; justify-content: center; border-radius: 999px; background: var(--line); color: var(--muted); font-size: 11px; font-weight: 650; }
|
||||
|
||||
/* 게시를 막는 항목. 고치는 자리에서 보이지 않으면 작성자는 별도 화면과 편집기를 오가며
|
||||
어느 칸이었는지 기억해야 한다. */
|
||||
.studio-app .studio-editor-validation { margin: 0 0 20px; padding: 14px 16px; border: 1px solid var(--danger, #b4232a); border-radius: 6px; background: var(--paper); }
|
||||
.studio-app .studio-editor-validation--stale { border-color: var(--line-strong); opacity: 0.72; }
|
||||
.studio-app .studio-editor-validation-title { margin: 0 0 10px; color: var(--danger, #b4232a); font-size: 13px; font-weight: 700; }
|
||||
.studio-app .studio-editor-validation--stale .studio-editor-validation-title { color: var(--muted); }
|
||||
.studio-app .studio-editor-validation ul { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
|
||||
.studio-app .studio-editor-validation li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-size: 13px; color: var(--ink); }
|
||||
.studio-app .studio-editor-validation li span { min-width: 30px; color: var(--danger, #b4232a); font-size: 11px; font-weight: 700; }
|
||||
.studio-app .studio-editor-validation li[data-severity="WARNING"] span { color: var(--muted); }
|
||||
.studio-app .studio-editor-validation code { color: var(--faint); font-size: 11px; }
|
||||
|
||||
/*
|
||||
칸 아래에 붙는 지적. 검증 결과를 화면 맨 위 한 덩어리로 모아 두는 대신 그 칸 옆에 두면,
|
||||
작성자는 `/topicId` 같은 경로를 읽고 어느 칸인지 스스로 찾을 필요가 없다.
|
||||
|
||||
`.studio-field` 는 세로 흐름이므로 별도의 배치가 필요 없다 — 입력 바로 다음 줄에 놓인다.
|
||||
*/
|
||||
.studio-app .studio-field-notice { display: block; margin-top: 6px; font-size: 12px; line-height: 1.5; }
|
||||
.studio-app .studio-field-notice--error { color: var(--danger, #b4232a); }
|
||||
.studio-app .studio-field-notice--warning { color: var(--muted); }
|
||||
.studio-app .studio-editor-unplaced-issues { display: grid; gap: 6px; margin: 10px 0 0; padding: 0; list-style: none; }
|
||||
.studio-app .studio-editor-unplaced-issues li { font-size: 12px; color: var(--danger, #b4232a); }
|
||||
.studio-app .studio-editor-unplaced-issues li[data-severity="WARNING"] { color: var(--muted); }
|
||||
.studio-app .studio-editor-unplaced-issues code { color: var(--faint); font-size: 11px; }
|
||||
|
||||
/*
|
||||
저장과 게시는 되돌릴 수 있는 정도가 다르다 — 하나는 초안을 남기고, 하나는 공개한다. 둘이
|
||||
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
|
||||
*/
|
||||
.studio-app .studio-document-status-bar__actions { gap: 12px; }
|
||||
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
|
||||
.studio-app .studio-document-status-bar button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
.studio-app .studio-document-row dt { color: var(--muted); font-size: 11px; }
|
||||
.studio-app .studio-document-row dd { margin: 5px 0 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.studio-app .studio-secondary-button { margin-top: 24px; }
|
||||
/*
|
||||
한 행에 버튼이 둘 이상일 때. `.studio-secondary-button` 의 위쪽 여백은 버튼이 하나뿐이던 때의
|
||||
값이라, 묶음 안에서는 묶음이 여백을 갖고 버튼은 나란히 선다.
|
||||
*/
|
||||
.studio-app .studio-row-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; }
|
||||
|
||||
.studio-app .studio-field-note { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.studio-app .studio-row-actions .studio-primary-button,
|
||||
.studio-app .studio-row-actions .studio-secondary-button { margin: 0; }
|
||||
.studio-app .studio-empty-state { padding-block: 56px; border-bottom: 1px solid var(--line); }
|
||||
.studio-app .studio-empty-state h2 { margin: 0; font-size: 25px; }
|
||||
.studio-app .studio-empty-state p { margin: 12px 0 22px; color: var(--muted); }
|
||||
@@ -118,8 +127,14 @@
|
||||
.studio-app .studio-type-list strong { font-size: 18px; }
|
||||
.studio-app .studio-type-list span,
|
||||
.studio-app .studio-type-list small { color: var(--muted); line-height: 1.6; }
|
||||
.studio-app .studio-create-footer { display: flex; align-items: center; gap: 20px; margin-top: 28px; }
|
||||
.studio-app .studio-create-footer .studio-primary-button { margin: 0; }
|
||||
.studio-app .studio-create-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-top: 28px; }
|
||||
/*
|
||||
`.studio-secondary-button` 는 목록 행에서 쓰려고 전역으로 `margin-top: 24px` 를 갖는다. 여기서
|
||||
풀어 주지 않으면 primary(저장)만 위로 붙고 secondary(공개·공개에서 내리기)는 24px 아래로
|
||||
내려앉아, 같은 줄의 버튼들이 서로 다른 높이에 선다.
|
||||
*/
|
||||
.studio-app .studio-create-footer .studio-primary-button,
|
||||
.studio-app .studio-create-footer .studio-secondary-button { margin: 0; }
|
||||
.studio-app .studio-create-footer p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.studio-app .studio-primary-button:disabled { opacity: 0.55; cursor: wait; }
|
||||
|
||||
|
||||
@@ -86,3 +86,8 @@
|
||||
:global(.studio-workflow-issue-list) a > span:last-child { display: none; }
|
||||
:global(.studio-preview-public-frame .public-record-embedded) { padding: 22px 16px; }
|
||||
}
|
||||
|
||||
/* 지난 판정은 현재 것과 같은 무게로 보이면 안 된다 — 읽는 사람이 지금 상태로 읽는다. */
|
||||
:global(.studio-workflow-status--stale) { border-color: var(--line); background: var(--paper); color: var(--muted); }
|
||||
:global(.studio-workflow-stale-notice) { margin: 0 0 18px; padding: 12px 14px; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--paper); color: var(--muted); font-size: 13px; line-height: 1.6; }
|
||||
:global(.studio-workflow-issue-list--stale) { opacity: 0.62; }
|
||||
|
||||
@@ -216,6 +216,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
||||
"TaxonomyPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_PROJECT_EDIT: runtime(
|
||||
"TECH_LOG_STUDIO_PROJECT_EDIT",
|
||||
routeModule(
|
||||
() => import("./studio/pages/project-edit-page.tsx"),
|
||||
"ProjectEditPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_RELEASES: runtime(
|
||||
"TECH_LOG_STUDIO_RELEASES",
|
||||
routeModule(
|
||||
@@ -223,6 +230,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
||||
"StudioReleasesPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_RELEASE_EDIT: runtime(
|
||||
"TECH_LOG_STUDIO_RELEASE_EDIT",
|
||||
routeModule(
|
||||
() => import("./studio/pages/release-edit-page.tsx"),
|
||||
"ReleaseEditPage",
|
||||
),
|
||||
),
|
||||
TECH_LOG_STUDIO_NOT_FOUND: runtime(
|
||||
"TECH_LOG_STUDIO_NOT_FOUND",
|
||||
routeModule(
|
||||
|
||||
@@ -87,11 +87,16 @@ const PLATFORM_KO_MESSAGES = {
|
||||
"route.auth.integration.description":
|
||||
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
|
||||
"route.auth.recovering.title": "세션을 복구하고 있습니다.",
|
||||
"route.auth.required.title": "세션이 필요합니다.",
|
||||
"route.auth.eyebrow": "SIGN IN",
|
||||
"route.auth.returnTo": "로그인하면 {path} 로 돌아옵니다.",
|
||||
"route.auth.required.title": "Studio에 로그인해 주세요.",
|
||||
"route.auth.recovering.description":
|
||||
"기존 세션 확인을 계속하려면 복구를 실행하세요.",
|
||||
"이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.",
|
||||
// 예전 문구("인증 연동 지점을 확인하기 위한 보호 라우트")는 이 뼈대를 만들던 사람에게 하는
|
||||
// 말이었다. 실제로 이 화면을 보는 사람은 글을 쓰러 온 작성자이고, 알아야 할 것은 무엇을
|
||||
// 누르면 되는지다.
|
||||
"route.auth.required.description":
|
||||
"이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.",
|
||||
"기록을 쓰고 게시하려면 로그인이 필요합니다. 로그인하면 방금 열려던 화면으로 돌아옵니다.",
|
||||
"route.documentTitle": "{title} · {appName}",
|
||||
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
|
||||
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
|
||||
@@ -271,11 +276,13 @@ const PLATFORM_EN_MESSAGES = {
|
||||
"route.auth.integration.description":
|
||||
"This protected route is available after an external authentication owner is connected.",
|
||||
"route.auth.recovering.title": "Recovering the session.",
|
||||
"route.auth.required.title": "A session is required.",
|
||||
"route.auth.eyebrow": "SIGN IN",
|
||||
"route.auth.returnTo": "You return to {path} after signing in.",
|
||||
"route.auth.required.title": "Sign in to Studio.",
|
||||
"route.auth.recovering.description":
|
||||
"Run recovery to continue checking the existing session.",
|
||||
"Checking whether the previous sign-in is still valid. Try again if this screen stays.",
|
||||
"route.auth.required.description":
|
||||
"This protected route demonstrates the authentication integration seam.",
|
||||
"Writing and publishing records needs a signed-in session. You return to this screen afterwards.",
|
||||
"route.documentTitle": "{title} · {appName}",
|
||||
"chunk.checking": "Checking the new release information.",
|
||||
"chunk.reloadOnce": "Switching to the new version once.",
|
||||
|
||||
@@ -19,6 +19,7 @@ export type MessageParameters = Readonly<{
|
||||
"action.alertCloseNamed": { title: string };
|
||||
"route.loadingNamed": { title: string };
|
||||
"route.documentTitle": { title: string; appName: string };
|
||||
"route.auth.returnTo": { path: string };
|
||||
"template.supportReference": { reference: string };
|
||||
"form.remaining": { count: number };
|
||||
"boot.supportReference": { reference: string };
|
||||
|
||||
@@ -241,9 +241,17 @@ function ProtectedRoute({
|
||||
);
|
||||
}
|
||||
const recovering = decision.action === "wait-for-session";
|
||||
/*
|
||||
Signing in is a screen, not a bare gate. This used to be a title and a button
|
||||
dropped on `ui-page`, so opening a protected address showed no page at all —
|
||||
just two stray elements. It is one card now, and it names the address the
|
||||
visitor came for so they know where signing in returns them.
|
||||
*/
|
||||
return (
|
||||
<section className="ui-page" data-surface="authentication-required">
|
||||
<section className="ui-page auth-gate" data-surface="authentication-required">
|
||||
<div className="auth-gate__card">
|
||||
<PageHeader
|
||||
eyebrow={message("route.auth.eyebrow")}
|
||||
title={
|
||||
recovering
|
||||
? message("route.auth.recovering.title")
|
||||
@@ -255,21 +263,26 @@ function ProtectedRoute({
|
||||
: message("route.auth.required.description")
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() => void continueSession()}
|
||||
>
|
||||
<div className="auth-gate__actions">
|
||||
<Button disabled={pending} onClick={() => void continueSession()}>
|
||||
{pending
|
||||
? message("common.processing")
|
||||
: recovering
|
||||
? message("action.recoverSession")
|
||||
: message("action.signIn")}
|
||||
</Button>
|
||||
<p className="auth-gate__return">
|
||||
{message("route.auth.returnTo", {
|
||||
path: `${location.pathname}${location.search}`,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{failed ? (
|
||||
<p className="ui-terminal-error" role="alert">
|
||||
{message("shell.session.actionFailed")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -461,8 +461,7 @@ test("inserts the directive at the saved cursor position and the live preview re
|
||||
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
||||
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
||||
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||
|
||||
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
|
||||
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
|
||||
@@ -1819,12 +1818,12 @@ test("a Picker search never drops an already-inserted asset out of Instant Previ
|
||||
// must narrow; the preview must not.
|
||||
await user.type(screen.getByLabelText("Asset 검색"), "other");
|
||||
await user.click(screen.getByRole("button", { name: "검색" }));
|
||||
const picker = screen.getByRole("group", { name: "본문에 Asset 삽입" });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
|
||||
expect(within(picker).queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
||||
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
||||
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||
|
||||
assert.equal(
|
||||
within(panel).queryByRole("alert")?.textContent ?? null,
|
||||
|
||||
@@ -60,6 +60,36 @@ describe("Content Format v1", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts the plain Markdown the server renderer already accepted", () => {
|
||||
/*
|
||||
이 문법들은 서버 렌더러가 원래 읽던 것인데 이 파서만 거절하고 있었다 — 같은 본문이 Studio
|
||||
와 공개 화면에서 다르게 읽혔다. 표는 `:::table` 로 감싸지 않아도 되고, callout 이름은 서버가
|
||||
아는 note/tip/warning/danger 를 쓴다.
|
||||
*/
|
||||
expect(parseCaseContent("| A | B |\n|---|---|\n| 1 | 2 |").map((block) => block.type)).toEqual([
|
||||
"DATA_TABLE",
|
||||
]);
|
||||
expect(parseCaseContent(":::note\n\n참고할 것.\n\n:::").map((block) => block.type)).toEqual([
|
||||
"CALLOUT",
|
||||
]);
|
||||
expect(parseCaseContent(":::danger\n\n위험.\n\n:::").map((block) => block.type)).toEqual([
|
||||
"CALLOUT",
|
||||
]);
|
||||
expect(parseCaseContent("# 제목1\n\n###### 제목6").map((block) => block.type)).toEqual([
|
||||
"HEADING",
|
||||
"HEADING",
|
||||
]);
|
||||
expect(parseCaseContent("---").map((block) => block.type)).toEqual(["THEMATIC_BREAK"]);
|
||||
expect(parseCaseContent("").map((block) => block.type)).toEqual([
|
||||
"IMAGE",
|
||||
]);
|
||||
// 속성이 없는 directive 도 이름이 온전해야 한다. 정규화 정규식이 되돌아가며 마지막 글자를
|
||||
// 속성 쪽으로 넘기는 바람에 `:::note` 가 `:::not{e}` 로 바뀌던 적이 있다.
|
||||
expect(() => parseCaseContent(":::unknown\n\ntext\n\n:::")).toThrow(
|
||||
/unknown block directive: unknown/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe, raw HTML, and unsupported input with source positions", () => {
|
||||
const rejected = [
|
||||
"<script>alert(1)</script>",
|
||||
@@ -74,11 +104,12 @@ describe("Content Format v1", () => {
|
||||
"[x](</safe\u001fpath>)",
|
||||
"[x](</safe\u007fpath>)",
|
||||
"- outer\n - nested",
|
||||
"# level one",
|
||||
"- [ ] task",
|
||||
"> > nested",
|
||||
':::unknown key="value"\ntext\n:::',
|
||||
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::',
|
||||
")",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const source of rejected) {
|
||||
|
||||
@@ -18,7 +18,7 @@ test("vendored contract matches the recorded canonical digest", () => {
|
||||
|
||||
test("canonical source records the pinned revision and version", () => {
|
||||
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
|
||||
assert.equal(canonicalSource.version, "3.0.0");
|
||||
assert.equal(canonicalSource.version, "3.1.0");
|
||||
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
|
||||
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
|
||||
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
|
||||
|
||||
@@ -397,7 +397,10 @@ test("validation issue order, evidence, preview freshness and warning-set public
|
||||
{ expectedVersion: 2 },
|
||||
{ idempotencyKey: "invalid" },
|
||||
);
|
||||
assert.equal(invalid.status, "INVALID");
|
||||
// 비어 있음은 더 이상 게시를 막지 않는다 — 무엇을 얼마나 쓸지는 작성자가 정한다. 그래서 이
|
||||
// 문서는 INVALID 가 아니라 WARNINGS 다. 항목이 사라지는 것이 아니라 심각도만 내려간다는 것이
|
||||
// 여기서 확인해야 할 점이고, 그래서 코드 목록은 그대로 둔다.
|
||||
assert.equal(invalid.status, "WARNINGS");
|
||||
assert.deepEqual(
|
||||
invalid.issues.map(({ code }) => code),
|
||||
["QUESTION_FACT_REQUIRED", "QUESTION_OPTIONS_FEWER_THAN_TWO"],
|
||||
|
||||
@@ -296,7 +296,7 @@ describe("TechLog explore discovery", () => {
|
||||
const user = userEvent.setup();
|
||||
const { router } = await renderDiscoveryRoute(
|
||||
"TECH_LOG_EXPLORE",
|
||||
"/explore?type=CASE&topic=JPA&project=backend-skeleton",
|
||||
"/explore?type=CASE&topic=jpa&project=backend-skeleton",
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
|
||||
@@ -306,22 +306,28 @@ describe("TechLog explore discovery", () => {
|
||||
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
|
||||
expect(screen.getByLabelText("주제")).toHaveValue("JPA");
|
||||
expect(screen.getByLabelText("주제")).toHaveValue("jpa");
|
||||
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
|
||||
});
|
||||
// 주제 선택지는 보이는 이름과 보내는 값이 다르다. 값이 이름이면 slug 로 거르는 API 가
|
||||
// 0건을 돌려주고, 화면은 「조건에 맞는 공개 기록이 없습니다」만 남는다 — 운영에서 실제로
|
||||
// 그랬다. 목록에서 유도한 값이라 이 단언이 없으면 조용히 되돌아간다.
|
||||
expect(
|
||||
within(screen.getByLabelText("주제")).getByRole("option", { name: "Authentication" }),
|
||||
).toHaveValue("authentication");
|
||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
|
||||
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
|
||||
await user.selectOptions(screen.getByLabelText("주제"), "Authentication");
|
||||
await user.selectOptions(screen.getByLabelText("주제"), "authentication");
|
||||
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
|
||||
await user.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.search).toBe(
|
||||
"?project=auth-lab&topic=Authentication&type=QUESTION",
|
||||
"?project=auth-lab&topic=authentication&type=QUESTION",
|
||||
);
|
||||
});
|
||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||
|
||||
@@ -42,6 +42,13 @@ const routeComponents = {
|
||||
|
||||
type DocumentRouteId = keyof typeof routeComponents;
|
||||
|
||||
/** 머리말이 거는 주제 링크를 확인하기 위한 픽스처의 이름 → slug 대응. */
|
||||
const topicSlugs: Readonly<Record<string, string>> = {
|
||||
JPA: "jpa",
|
||||
Authentication: "authentication",
|
||||
Redis: "redis",
|
||||
};
|
||||
|
||||
class NoopIntersectionObserver implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "0px";
|
||||
@@ -205,13 +212,30 @@ describe("TechLog canonical Public documents", () => {
|
||||
const main = screen.getByRole("main");
|
||||
|
||||
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
|
||||
expect(within(main).getByText(evidence, { exact: false })).toBeVisible();
|
||||
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
|
||||
`${kind}/${topic}/${project}`,
|
||||
/*
|
||||
목차가 본문의 절 제목을 그대로 다시 적으므로 같은 글이 두 자리에 나온다. 여기서 확인하려는
|
||||
것은 "그 글이 문서에 있는가" 이므로 첫 자리로 충분하다.
|
||||
*/
|
||||
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
|
||||
expect(firstEvidence).toBeVisible();
|
||||
const breadcrumb = within(main).getByRole("navigation", { name: "문서 경로" });
|
||||
expect(breadcrumb).toHaveTextContent(`${kind}/${topic}/${project}`);
|
||||
/*
|
||||
글자만 보고 있었더니 주제 링크가 어디로 가는지는 아무도 묻지 않았다. 그 링크는
|
||||
`/topics/:slug` 를 가리켰고, 그 화면은 세 개의 주제를 하드코딩해 두고 있어 실제 주제는
|
||||
무엇이든 404 였다 — 게시한 모든 문서가 죽은 링크를 하나씩 달고 있었다.
|
||||
*/
|
||||
expect(within(breadcrumb).getByRole("link", { name: topic })).toHaveAttribute(
|
||||
"href",
|
||||
`/explore?topic=${topicSlugs[topic]}`,
|
||||
);
|
||||
|
||||
if (path === "/cases/collection-fetch-join-pagination") {
|
||||
expect(within(main).getByText(`게시 ${published} · 마지막 검증 2026.08.11`)).toBeVisible();
|
||||
/*
|
||||
Case 는 한 배치를 쓴다. 예전에는 픽스처 문서 하나만 `.case-meta` 를 받고 나머지 Case 는
|
||||
`.public-document-header dl` 로 떨어졌으므로 여기도 경로로 갈랐다. 이제 유형으로 가른다.
|
||||
*/
|
||||
if (kind === "Case") {
|
||||
expect(container.querySelector(".case-meta")).toHaveTextContent(`게시 ${published}`);
|
||||
} else {
|
||||
const metadata = container.querySelector(".public-document-header dl");
|
||||
expect(metadata).toHaveTextContent(`유형${kind}`);
|
||||
@@ -258,12 +282,12 @@ describe("TechLog canonical Public documents", () => {
|
||||
expect(image).toHaveAttribute("loading", "lazy");
|
||||
});
|
||||
|
||||
it("uses the generic Case markup and omits source relations for the canonical empty state", async () => {
|
||||
it("uses the one Case layout and omits source relations for the canonical empty state", async () => {
|
||||
const generic = await renderDocumentRoute(
|
||||
"TECH_LOG_CASE",
|
||||
"/cases/redis-adapter-ttl-boundary",
|
||||
);
|
||||
expect(screen.getByRole("main")).toHaveClass("shell", "public-document-page");
|
||||
expect(screen.getByRole("main")).toHaveClass("case-page");
|
||||
expect(generic.container.querySelector("#ownership")).toHaveTextContent(
|
||||
"정책과 저장 명령의 주인을 구분하기",
|
||||
);
|
||||
|
||||
@@ -252,35 +252,26 @@ describe("TechLog project screens", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps project activity ordered and preserves self-fragment and record links", async () => {
|
||||
/*
|
||||
활동은 로그다 — 언제 무엇을 올렸는지만 적는다. 각 줄에 그 기록으로 가는 링크가 있으면 같은
|
||||
글에 닿는 길이 둘이 되고, 읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다.
|
||||
글을 읽는 자리는 기록 화면 하나로 둔다.
|
||||
*/
|
||||
it("keeps project activity ordered and leaves reading to the records screen", async () => {
|
||||
const { container } = await renderPublicRoute(
|
||||
"TECH_LOG_PROJECT_ACTIVITY",
|
||||
"/projects/backend-skeleton/activity",
|
||||
);
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({
|
||||
id: item.id,
|
||||
href: item.querySelector("a")?.getAttribute("href"),
|
||||
label: item.querySelector("a")?.textContent,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
id: "fetch-join-case-published",
|
||||
href: "/cases/collection-fetch-join-pagination",
|
||||
label: "연결된 공개 기록 읽기",
|
||||
},
|
||||
{
|
||||
id: "storage-contract",
|
||||
href: "/projects/backend-skeleton/activity#storage-contract",
|
||||
label: "이 활동 위치 열기",
|
||||
},
|
||||
{
|
||||
id: "redis-case-published",
|
||||
href: "/cases/redis-adapter-ttl-boundary",
|
||||
label: "연결된 공개 기록 읽기",
|
||||
},
|
||||
const items = Array.from(
|
||||
container.querySelectorAll(".project-activity-list > li > article"),
|
||||
);
|
||||
expect(items.map((item) => item.id)).toEqual([
|
||||
"fetch-join-case-published",
|
||||
"storage-contract",
|
||||
"redis-case-published",
|
||||
]);
|
||||
expect(items.flatMap((item) => Array.from(item.querySelectorAll("a")))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
|
||||
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
|
||||
import { createHttpPublicContentGateway } from "../../../src/features/tech-log/adapters/http/http-public-content-gateway.ts";
|
||||
|
||||
type QuestionDetail = components["schemas"]["QuestionDetailResponse"];
|
||||
|
||||
/*
|
||||
Open Question 을 게시했는데 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다.
|
||||
|
||||
게이트웨이가 `points` 를 `{group, items}` 배열로 읽고 `.filter` 를 불렀는데, 계약의
|
||||
`QuestionPointGroup` 은 그룹 이름을 키로 갖는 **객체**다. 객체에는 `.filter` 가 없으므로
|
||||
매핑이 통째로 터졌다. `as` 캐스트가 그 어긋남을 타입 검사에서 가렸다.
|
||||
|
||||
탐색 목록은 이 칸들을 빈 배열로 두고 만들기 때문에 목록에서는 멀쩡히 보였다 — 그래서
|
||||
"게시했는데 public 에 안 뜬다" 로만 드러났고, 어느 층이 깨졌는지는 보이지 않았다.
|
||||
|
||||
그래서 여기서는 계약 모양 그대로의 응답을 진짜 게이트웨이에 통과시키고, 화면이 읽는 네 칸이
|
||||
실제로 채워져 나오는지 묻는다. 모양만 검사하면 이 사고는 다시 지나간다.
|
||||
*/
|
||||
function gatewayReturning(value: unknown) {
|
||||
return createHttpPublicContentGateway({
|
||||
operations: {
|
||||
execute: () =>
|
||||
Promise.resolve({
|
||||
kind: "SUCCESS" as const,
|
||||
value,
|
||||
metadata: { status: 200 },
|
||||
effect: "NOT_APPLICABLE" as const,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const DETAIL = {
|
||||
canonicalPath: "/questions/refresh-rotation-replica-contention",
|
||||
indexable: true,
|
||||
question: {
|
||||
question: "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
|
||||
summary: "두 replica가 같은 refresh token으로 동시에 갱신할 수 있다.",
|
||||
context: "",
|
||||
importance: "",
|
||||
status: "OPEN",
|
||||
nextVerification: "저장소를 공유한 뒤에 재현한다.",
|
||||
points: {
|
||||
facts: ["realm은 refresh token rotation과 재사용 허용 0회를 쓴다."],
|
||||
assumptions: ["운영에서는 replica가 둘 이상이고 저장소를 공유한다."],
|
||||
unknowns: ["같은 refresh token으로 동시에 갱신하면 어떻게 되는지."],
|
||||
constraints: ["이미 발급된 access token은 만료 전까지 계속 통한다."],
|
||||
},
|
||||
updates: [],
|
||||
openedAt: "2026-08-24T00:00:00.000Z",
|
||||
updatedAt: "2026-08-26T13:41:53.974512Z",
|
||||
},
|
||||
relations: {
|
||||
primaryProject: {
|
||||
type: "PROJECT",
|
||||
title: "KeyCloak Patterns",
|
||||
path: "/projects/keycloak-patterns",
|
||||
summary: "Keycloak을 쓰면서 실제로 부딪힌 인증 경계를 기록합니다",
|
||||
},
|
||||
resultCase: {
|
||||
type: "CASE",
|
||||
title: "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
|
||||
path: "/cases/split-custody-access-token",
|
||||
summary: "토큰 교환과 토큰 관리의 책임이 서버로 이전했다.",
|
||||
},
|
||||
derivedReferences: [],
|
||||
},
|
||||
} satisfies QuestionDetail;
|
||||
|
||||
test("a published Open Question maps its four point groups into the screen's fields", async () => {
|
||||
const gateway = gatewayReturning(DETAIL);
|
||||
|
||||
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
|
||||
|
||||
assert.ok(record, "게시된 질문은 상세로 돌아와야 한다");
|
||||
assert.equal(record.kind, "QUESTION");
|
||||
assert.equal(record.title, DETAIL.question.question);
|
||||
assert.equal(record.questionStatus, "OPEN");
|
||||
assert.deepEqual([...record.facts], DETAIL.question.points.facts);
|
||||
assert.deepEqual([...record.assumptions], DETAIL.question.points.assumptions);
|
||||
assert.deepEqual([...record.unknowns], DETAIL.question.points.unknowns);
|
||||
assert.deepEqual([...record.constraints], DETAIL.question.points.constraints);
|
||||
assert.equal(record.nextValidation, DETAIL.question.nextVerification);
|
||||
// 질문 상세는 프로젝트를 `relations` 에 담는다 — `question` 에서 찾으면 머리말의 프로젝트
|
||||
// 칸이 늘 비어 있다.
|
||||
assert.equal(record.projectTitle, "KeyCloak Patterns");
|
||||
assert.equal(record.projectSlug, "keycloak-patterns");
|
||||
});
|
||||
|
||||
/*
|
||||
관계의 이유 자리에 영문 키가 그대로 나오고 있었다 — 매퍼가 찾던 그룹 이름이 계약에 없는
|
||||
것들이었기 때문이다. 그리고 `primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다.
|
||||
*/
|
||||
test("a question's relations read their reason from the contract's own group names", async () => {
|
||||
const gateway = gatewayReturning(DETAIL);
|
||||
|
||||
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
|
||||
|
||||
assert.deepEqual(
|
||||
[...record!.relations].map((relation) => ({ reason: relation.reason, path: relation.path })),
|
||||
[
|
||||
{
|
||||
reason: "이 질문에서 나온 기록",
|
||||
path: "/cases/split-custody-access-token",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
네 그룹은 계약이 필수로 두고 있다. 이름이 하나라도 바뀌면 위 `satisfies` 가 먼저 깨지고,
|
||||
화면이 읽는 이름과의 대응도 여기서 끊긴다.
|
||||
*/
|
||||
test("the contract names the four point groups the screen renders", () => {
|
||||
const points: components["schemas"]["QuestionPointGroup"] = {
|
||||
facts: [],
|
||||
assumptions: [],
|
||||
unknowns: [],
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
assert.deepEqual(Object.keys(points).sort(), [
|
||||
"assumptions",
|
||||
"constraints",
|
||||
"facts",
|
||||
"unknowns",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
|
||||
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
|
||||
|
||||
type ReferenceBody =
|
||||
components["schemas"]["ReferenceDetailResponse"]["reference"];
|
||||
|
||||
/*
|
||||
이 파일은 사고 하나에서 나왔다.
|
||||
|
||||
공개 Reference 화면이 통째로 비어 있었다. Studio 에서는 같은 글이 다 보였으므로 "공개 쪽만 안
|
||||
나온다" 로 드러났다. 원인은 게이트웨이가 읽던 이름이 계약에 없는 것들이었다는 것이다 —
|
||||
`purposeSummary`, `applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. 전부 undefined 로
|
||||
떨어졌고 타입 검사는 `as string` 단언 때문에 아무 말도 하지 않았다.
|
||||
|
||||
그래서 여기서 확인하는 것은 값이 아니라 **이름**이다. 계약이 그 칸을 갖고 있는가, 그리고 화면이
|
||||
기대하는 모양인가.
|
||||
*/
|
||||
test("the public Reference contract keeps the fields the screen reads", () => {
|
||||
const reference = {
|
||||
title: "",
|
||||
// 제목 바로 아래에 오는 것은 문서의 요약이다. scopeSummary 는 그 아래 "이 기준을 쓰는 이유"다.
|
||||
summary: "",
|
||||
scopeSummary: "",
|
||||
appliesTo: [],
|
||||
excludedScope: [],
|
||||
rules: [{ title: "", body: "" }],
|
||||
examples: [],
|
||||
freshnessStatus: "CURRENT",
|
||||
content: "",
|
||||
contentFormat: "MARKDOWN",
|
||||
contentFormatVersion: 1,
|
||||
tags: [],
|
||||
publishedAt: "",
|
||||
updatedAt: "",
|
||||
} satisfies ReferenceBody;
|
||||
|
||||
// 화면이 읽는 이름 그대로. 하나라도 계약에서 사라지면 위의 `satisfies` 가 먼저 깨진다.
|
||||
assert.deepEqual(Object.keys(reference).sort(), [
|
||||
"appliesTo",
|
||||
"content",
|
||||
"contentFormat",
|
||||
"contentFormatVersion",
|
||||
"examples",
|
||||
"excludedScope",
|
||||
"freshnessStatus",
|
||||
"publishedAt",
|
||||
"rules",
|
||||
"scopeSummary",
|
||||
"summary",
|
||||
"tags",
|
||||
"title",
|
||||
"updatedAt",
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
Reference 의 본문은 `content` 마크다운이 아니라 규칙과 예시에 있다. Studio 의 편집기가 제목과
|
||||
본문을 따로 받고 `body_markdown` 은 비워 두기 때문이다 — 그래서 `content` 를 잘라 규칙을 만들려
|
||||
하면 언제나 빈 목록이 된다.
|
||||
*/
|
||||
test("a rule carries its own title and body, not a slice of markdown", () => {
|
||||
const rule: NonNullable<ReferenceBody["rules"]>[number] = {
|
||||
title: "Authorization Endpoint에는 client_secret을 보내지 않는다",
|
||||
body: "이 요청은 브라우저의 full-page navigation으로 나간다.",
|
||||
};
|
||||
|
||||
assert.equal(typeof rule.title, "string");
|
||||
assert.equal(typeof rule.body, "string");
|
||||
});
|
||||
@@ -386,7 +386,12 @@ describe("shared Public record renderer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses generic Case markup and does not present generatedAt as publication", () => {
|
||||
/*
|
||||
Case 렌더러가 둘이었을 때 이 테스트는 "축약본" 쪽을 지켰다. 어느 쪽을 쓸지는 슬러그 비교가
|
||||
정했고, 설계 픽스처 문서 하나만 breadcrumb 과 목차가 있는 배치를 받았다 — 실제로 작성한 Case 는
|
||||
전부 축약본이었고 오른쪽 목차가 어디에도 나오지 않았다. 배치는 이제 하나다.
|
||||
*/
|
||||
it("uses the one Case layout and does not present generatedAt as publication", () => {
|
||||
renderInRouter(
|
||||
<PublicRecordRenderer
|
||||
{...renderDependencies}
|
||||
@@ -402,14 +407,17 @@ describe("shared Public record renderer", () => {
|
||||
);
|
||||
|
||||
const main = screen.getByRole("main");
|
||||
expect(main).toHaveClass("shell", "public-document-page");
|
||||
expect(main).toHaveClass("case-page");
|
||||
expect(main).toHaveAttribute("id", "main-content");
|
||||
expect(screen.getByText("게시 전")).toBeVisible();
|
||||
// 완성된 배치에서는 "게시 전" 이 기록 줄의 한 문장 안에 들어간다 — 홀로 선 노드가 아니다.
|
||||
expect(main).toHaveTextContent("게시 전");
|
||||
expect(main).not.toHaveTextContent("2035.05.06");
|
||||
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
|
||||
"문제문제결론결론",
|
||||
);
|
||||
expect(screen.getByText("일반 본문")).toBeVisible();
|
||||
// 제목이 없는 본문에는 목차를 그리지 않는다 — 빈 레일만 남는다.
|
||||
expect(screen.queryByLabelText("문서 목차")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders supplied Reference and Question preview fields and empty copy", () => {
|
||||
|
||||
@@ -39,7 +39,9 @@ const expectedRoutes = [
|
||||
["TECH_LOG_STUDIO_PUBLICATION_PREVIEW", "/studio/publications/:publicationEventId/preview", "STUDIO", "TechLogPublicationEventIdParams", null],
|
||||
["TECH_LOG_STUDIO_ASSETS", "/studio/assets", "STUDIO", null, null],
|
||||
["TECH_LOG_STUDIO_TAXONOMY", "/studio/taxonomy", "STUDIO", null, null],
|
||||
["TECH_LOG_STUDIO_PROJECT_EDIT", "/studio/projects/:id", "STUDIO", "TechLogDocumentIdParams", null],
|
||||
["TECH_LOG_STUDIO_RELEASES", "/studio/releases", "STUDIO", null, null],
|
||||
["TECH_LOG_STUDIO_RELEASE_EDIT", "/studio/releases/:id", "STUDIO", "TechLogDocumentIdParams", null],
|
||||
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
|
||||
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
|
||||
] as const;
|
||||
@@ -72,13 +74,15 @@ const expectedTitles = {
|
||||
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
|
||||
TECH_LOG_STUDIO_ASSETS: "Asset",
|
||||
TECH_LOG_STUDIO_TAXONOMY: "주제와 프로젝트",
|
||||
TECH_LOG_STUDIO_PROJECT_EDIT: "프로젝트 편집",
|
||||
TECH_LOG_STUDIO_RELEASES: "릴리즈",
|
||||
TECH_LOG_STUDIO_RELEASE_EDIT: "릴리즈 편집",
|
||||
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
|
||||
NOT_FOUND: "페이지를 찾을 수 없습니다.",
|
||||
} as const;
|
||||
|
||||
describe("TechLog route boundary contract", () => {
|
||||
it("freezes the standalone 29-route inventory before runtime installation", () => {
|
||||
it("freezes the standalone 31-route inventory before runtime installation", () => {
|
||||
expect(
|
||||
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
|
||||
definition.routeId,
|
||||
@@ -153,7 +157,7 @@ describe("TechLog route boundary contract", () => {
|
||||
for (const locale of ["ko-KR", "en-US"] as const) {
|
||||
const catalog: Readonly<Record<string, string>> =
|
||||
TECH_LOG_MESSAGE_CATALOGS[locale];
|
||||
expect(Object.keys(catalog)).toHaveLength(60);
|
||||
expect(Object.keys(catalog)).toHaveLength(64);
|
||||
for (const [routeId, title] of Object.entries(expectedTitles)) {
|
||||
expect(catalog[`route.${routeId}.title`]).toBe(title);
|
||||
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { afterEach, test } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
import { useSaveShortcut } from "../../../src/features/tech-log/presentation/studio/components/use-save-shortcut.ts";
|
||||
|
||||
function pressSave(overrides: Partial<KeyboardEventInit> = {}) {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "s",
|
||||
ctrlKey: true,
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
...overrides,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
/*
|
||||
브라우저의 Ctrl+S 는 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장 대화상자가
|
||||
뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다. 그래서 기본 동작을 막는
|
||||
것까지가 이 훅의 일이다.
|
||||
*/
|
||||
test("Ctrl+S saves and keeps the browser from offering the page as a file", () => {
|
||||
let saved = 0;
|
||||
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||
|
||||
const event = pressSave();
|
||||
|
||||
assert.equal(saved, 1);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test("Cmd+S does the same, because the habit is the same on a Mac", () => {
|
||||
let saved = 0;
|
||||
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||
|
||||
pressSave({ ctrlKey: false, metaKey: true });
|
||||
|
||||
assert.equal(saved, 1);
|
||||
unmount();
|
||||
});
|
||||
|
||||
/*
|
||||
저장할 것이 없을 때도 기본 동작은 막는다. 막지 않으면 "저장할 것이 없다"는 화면 상태와
|
||||
브라우저의 저장 대화상자가 동시에 나와, 무엇이 일어났는지 알 수 없다.
|
||||
*/
|
||||
test("a disabled shortcut still swallows the browser default", () => {
|
||||
let saved = 0;
|
||||
const { unmount } = renderHook(() =>
|
||||
useSaveShortcut(() => { saved += 1; }, false),
|
||||
);
|
||||
|
||||
const event = pressSave();
|
||||
|
||||
assert.equal(saved, 0);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test("modified chords are left to whoever else wants them", () => {
|
||||
let saved = 0;
|
||||
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||
|
||||
const shift = pressSave({ shiftKey: true });
|
||||
const alt = pressSave({ altKey: true });
|
||||
const plain = pressSave({ ctrlKey: false });
|
||||
|
||||
assert.equal(saved, 0);
|
||||
assert.equal(shift.defaultPrevented, false);
|
||||
assert.equal(alt.defaultPrevented, false);
|
||||
assert.equal(plain.defaultPrevented, false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test("the listener goes away with the screen", () => {
|
||||
let saved = 0;
|
||||
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||
unmount();
|
||||
|
||||
pressSave();
|
||||
|
||||
assert.equal(saved, 0);
|
||||
});
|
||||
@@ -166,8 +166,7 @@ describe("TechLog Studio project decision authoring", () => {
|
||||
expect(screen.getByRole("complementary", { name: "작업 상태" }))
|
||||
.toHaveTextContent("종류Decision");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
|
||||
const preview = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
|
||||
const preview = screen.getByRole("region", { name: "즉시 미리보기" });
|
||||
expect(
|
||||
within(preview).getByRole("heading", {
|
||||
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
derivePreviewState,
|
||||
deriveValidationState,
|
||||
} from "../../../src/features/tech-log/domain/studio/document-state.ts";
|
||||
import { createLocalId } from "../../../src/features/tech-log/domain/studio/local-id.ts";
|
||||
import {
|
||||
createLocalId,
|
||||
createNewItemId,
|
||||
} from "../../../src/features/tech-log/domain/studio/local-id.ts";
|
||||
|
||||
const now = "2026-08-14T12:00:00.000Z";
|
||||
const validUntil = "2026-08-14T12:30:00.000Z";
|
||||
@@ -317,3 +320,32 @@ test("local Studio IDs are deterministic without Web Crypto and prefer UUIDs whe
|
||||
"save-fixture-uuid",
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
이 두 함수가 나뉜 이유는 하나는 화면 안에만 머물고 다른 하나는 계약을 건너가기 때문이다.
|
||||
|
||||
한때 새 관계·규칙·선택지의 id 를 `createLocalId` 로 만들었고, 그래서 `relation-<uuid>` 같은
|
||||
값이 요청 본문에 실렸다. 계약은 그 자리에 uuid 를 요구하므로 서버는 파싱조차 못 하고 400 을
|
||||
돌려주었다 — 편집기에는 "saveStudioDocument broke its contract." 한 줄만 남고 저장은 통째로
|
||||
실패했다. 목록에 고를 대상이 하나도 없던 시절에는 아무도 이 경로를 지나지 않아 드러나지 않았다.
|
||||
*/
|
||||
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
test("new list item IDs are bare UUIDs, because they cross the contract", () => {
|
||||
assert.equal(
|
||||
createNewItemId({ randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" }),
|
||||
"3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607",
|
||||
);
|
||||
// Web Crypto 가 없는 환경에서도 형식은 uuid 여야 한다 — 값은 서버가 새로 부여하지만, 형식이
|
||||
// 어긋나면 요청 자체가 거절된다.
|
||||
assert.match(createNewItemId(null, () => 0.5), UUID_SHAPE);
|
||||
assert.match(createNewItemId(null, () => 0), UUID_SHAPE);
|
||||
assert.match(createNewItemId(null, () => 0.999), UUID_SHAPE);
|
||||
});
|
||||
|
||||
test("prefixed local IDs never satisfy the contract's UUID shape", () => {
|
||||
assert.equal(
|
||||
UUID_SHAPE.test(createLocalId("relation", { randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -104,25 +104,27 @@ describe("TechLog Studio document editor", () => {
|
||||
expect(screen.getByRole("complementary", { name: "작업 상태" })).toHaveTextContent(
|
||||
"저장 버전4종류CASE",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "저장본 검증" })).toHaveAttribute(
|
||||
"href",
|
||||
`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/validation`,
|
||||
);
|
||||
// 작성자가 밟는 단계는 저장과 게시 둘뿐이다. 예전에는 검증 → 미리보기 → 게시 세 화면을
|
||||
// 차례로 거쳐야 했는데, 그 셋은 백엔드가 게시 요청에 요구하는 값을 만들기 위한 것이지
|
||||
// 작성자에게 물어볼 것이 아니었다 — 지금은 게시 버튼 뒤에서 잇달아 부른다.
|
||||
const rail = screen.getByRole("complementary", { name: "작업 상태" });
|
||||
expect(within(rail).getAllByRole("button").map((button) => button.textContent)).toEqual([
|
||||
"저장",
|
||||
"게시",
|
||||
]);
|
||||
expect(within(rail).queryAllByRole("link")).toHaveLength(0);
|
||||
|
||||
const editTab = screen.getByRole("tab", { name: "편집" });
|
||||
const previewTab = screen.getByRole("tab", { name: "즉시 미리보기" });
|
||||
editTab.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(previewTab).toHaveFocus();
|
||||
expect(previewTab).toHaveAttribute("aria-selected", "true");
|
||||
// 편집과 미리보기는 한 화면에 함께 있다. 탭이었을 때는 고친 결과를 보려면 편집하던 자리를
|
||||
// 화면에서 치워야 했고, 돌아오면 스크롤 위치도 잃었다 — 그 왕복이 없어야 한다는 것이
|
||||
// 이 화면의 요구다. 아무것도 누르지 않은 채로 둘 다 보이는지 묻는다.
|
||||
expect(screen.getByRole("region", { name: "문서 편집" })).toBeVisible();
|
||||
expect(
|
||||
within(screen.getByRole("tabpanel", { name: "즉시 미리보기" })).getByRole(
|
||||
within(screen.getByRole("region", { name: "즉시 미리보기" })).getByRole(
|
||||
"heading",
|
||||
{ level: 1, name: "편집한 Redis 경계" },
|
||||
),
|
||||
).toBeVisible();
|
||||
await user.keyboard("{Home}");
|
||||
expect(editTab).toHaveFocus();
|
||||
expect(screen.queryByRole("tab", { name: "즉시 미리보기" })).toBeNull();
|
||||
expect(screen.getByLabelText("제목")).toHaveValue("편집한 Redis 경계");
|
||||
expect(calls).toEqual({ save: 0, validate: 0, preview: 0, publish: 0 });
|
||||
});
|
||||
|
||||
@@ -79,7 +79,9 @@ describe("TechLog consumer-visible style contract", () => {
|
||||
expect(rootStyle.getPropertyValue("--ink")).toBe("#17181b");
|
||||
expect(rootStyle.getPropertyValue("--signal")).toBe("#3e5cc7");
|
||||
expect(rootStyle.getPropertyValue("--shell")).toBe("1180px");
|
||||
expect(rootStyle.getPropertyValue("--body-copy")).toBe("42rem");
|
||||
// 42rem 은 영문 기준 측정값이었다. 한글 본문에서 좁았고, 바로 위의 유형/프로젝트 줄이
|
||||
// shell 전체를 쓰고 있어 대비가 더 심했다. 읽는 단 전체가 이 값 하나를 따른다.
|
||||
expect(rootStyle.getPropertyValue("--body-copy")).toBe("56rem");
|
||||
expect(rootStyle.color).toBe("var(--ink)");
|
||||
expect(rootStyle.background).toBe("var(--canvas)");
|
||||
expect(bodyStyle.color).toBe("var(--ink)");
|
||||
|
||||
@@ -105,7 +105,8 @@ async function interact(page, action) {
|
||||
} else if (action === "studio-menu") {
|
||||
await page.getByRole("button", { name: "Studio 메뉴 열기" }).click();
|
||||
} else if (action === "immediate-preview") {
|
||||
await page.getByRole("tab", { name: "즉시 미리보기" }).click();
|
||||
// 미리보기는 이제 편집 옆에 늘 떠 있다 — 열 탭이 없으므로 자리 잡기를 기다리기만 한다.
|
||||
await page.getByRole("region", { name: "즉시 미리보기" }).waitFor();
|
||||
} else if (action === "dirty-dialog") {
|
||||
await page.getByLabel("요약").fill("저장하지 않은 시각 검증 변경");
|
||||
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
|
||||
|
||||
@@ -802,8 +802,10 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
// shorter than one `systemctl show`, so the tree has to be sampled, not
|
||||
// sampled once at whatever moment the assertions happen to arrive.
|
||||
const tree = recordProviderProcessTree(execution.child.pid!);
|
||||
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
||||
const unit = await waitForProviderUnit("vulnerability", execution.child.pid, tree.sample);
|
||||
tree.sample();
|
||||
const properties = showProviderUnit(unit);
|
||||
tree.sample();
|
||||
expect(properties).toMatchObject({
|
||||
ActiveState: "active",
|
||||
CPUQuotaPerSecUSec: "1s",
|
||||
@@ -820,9 +822,29 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
await expect(readFile(path.join(cgroupRoot, "pids.max"), "utf8")).resolves.toBe("64\n");
|
||||
await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
|
||||
expect(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||
expect(showProcessArguments(execution.child.pid)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||
// Read once, while the supervisor is still alive, and reuse below. `argv`
|
||||
// does not change over a process's life, so the recorded value says the
|
||||
// same thing a second lookup would -- except that by the time the run has
|
||||
// completed there is no process left to look up, and `ps` failed.
|
||||
const supervisorArguments = showProcessArguments(execution.child.pid);
|
||||
expect(supervisorArguments).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||
const reportIdentity = await lstat(fixture.reportPath);
|
||||
const result = await execution.completion;
|
||||
/*
|
||||
기다리는 동안 직접 표본을 남긴다. 5ms 타이머에만 맡기면 다른 테스트 파일이 함께 도는 동안
|
||||
이벤트 루프가 밀려 표본이 통째로 비는 일이 있었다 — 관측 대상이 1.5초를 사는데도
|
||||
`directChildArguments` 가 빈 배열로 나왔다. 기다림 자체를 표본 채취의 박자로 쓰면 부하와
|
||||
무관해진다.
|
||||
*/
|
||||
const result = await (async () => {
|
||||
let settled: Awaited<typeof execution.completion> | undefined;
|
||||
const completion = execution.completion.then((value) => (settled = value));
|
||||
while (settled === undefined) {
|
||||
tree.sample();
|
||||
await Promise.race([completion, delay(5)]);
|
||||
}
|
||||
tree.sample();
|
||||
return settled;
|
||||
})();
|
||||
tree.stop();
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).not.toContain(credential);
|
||||
@@ -837,14 +859,23 @@ describe("candidate archive and provider upload boundaries", () => {
|
||||
const directChildPids = tree.directChildPids();
|
||||
const directChildArguments = tree.directChildArguments();
|
||||
const observedArguments = [
|
||||
showProcessArguments(execution.child.pid),
|
||||
supervisorArguments,
|
||||
...directChildArguments,
|
||||
...processArguments,
|
||||
];
|
||||
expect(observedArguments.join("\n")).not.toContain(credential);
|
||||
expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1);
|
||||
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1);
|
||||
expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
|
||||
/*
|
||||
scope 안의 프로세스는 "몇 개인가" 로 고정할 수 없다 — 같은 argv 를 가진 pid 가 fork 로 둘이
|
||||
될 수 있고, 바로 아래 bwrap/prlimit 단언이 같은 이유로 존재만 본다. 실행 구조가 하나임은
|
||||
직전 두 줄이 직속 자식으로 이미 고정한다(systemd-run 1개, raw-guardian 1개) — 그쪽이
|
||||
중복 실행을 잡는 자리다. 여기서는 wrapper 가 scope 안에 있었는지만 본다.
|
||||
*/
|
||||
expect(
|
||||
processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts")).length,
|
||||
`scope wrapper never observed in the scope; recorded: ${processArguments.join(" | ")}`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length,
|
||||
`sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`,
|
||||
@@ -1717,11 +1748,15 @@ function providerSupervisorEnvironment(
|
||||
async function waitForProviderUnit(
|
||||
kind: "vulnerability" | "provenance",
|
||||
supervisorPid: number | undefined,
|
||||
/** 블로킹 조회 앞뒤로 부른다 — 그 사이에만 살아 있는 프로세스를 놓치지 않기 위한 것이다. */
|
||||
onPoll?: () => void,
|
||||
): Promise<string> {
|
||||
if (!supervisorPid) throw new Error("provider supervisor did not expose its PID");
|
||||
const pattern = `ca-provider-${kind}-${supervisorPid}-*.scope`;
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
onPoll?.();
|
||||
const units = listProviderUnits(pattern);
|
||||
onPoll?.();
|
||||
if (units.length === 1) return units[0]!;
|
||||
if (units.length > 1) throw new Error(`provider unit identity is ambiguous: ${units.join(", ")}`);
|
||||
await delay(25);
|
||||
@@ -2020,6 +2055,13 @@ function recordProviderProcessTree(supervisorPid: number) {
|
||||
timer.unref();
|
||||
sample();
|
||||
return Object.freeze({
|
||||
/*
|
||||
타이머만으로는 부족하다. 이 테스트는 `systemctl` 을 `spawnSync` 로 부르며 기다리는데, 그
|
||||
동안 이벤트 루프가 멈춰 5ms 간격이 굶는다 — bubblewrap 은 그 한 틈보다 짧게 살 수 있고,
|
||||
그러면 관측하려던 바로 그 프로세스가 기록에서 빠진다. 그래서 블로킹 호출을 하는 쪽이
|
||||
직접 이 함수를 불러 같은 박자로 표본을 남긴다.
|
||||
*/
|
||||
sample,
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
|
||||
@@ -200,8 +200,8 @@ describe("CI gate contract", () => {
|
||||
// than taking either side's number.
|
||||
// Task 11 added TECH_LOG_STUDIO_ASSETS's manual accessibility evidence.
|
||||
// Alignment follow-up item 2 added the TechLog junit report.
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(111);
|
||||
expect(contract.artifacts).toHaveLength(132);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(113);
|
||||
expect(contract.artifacts).toHaveLength(134);
|
||||
expect(contract.stages).toHaveLength(5);
|
||||
expect(contract.retention.classes).toHaveLength(5);
|
||||
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
||||
|
||||
@@ -192,7 +192,7 @@ describe("selective Task 3 contract closure", () => {
|
||||
expect(canonical.gates).toHaveLength(27);
|
||||
expect(canonical.commands).toHaveLength(85);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(97);
|
||||
expect(canonical.artifacts).toHaveLength(132);
|
||||
expect(canonical.artifacts).toHaveLength(134);
|
||||
expect(canonical.stages).toHaveLength(5);
|
||||
expect(canonical.retention.classes).toHaveLength(5);
|
||||
|
||||
|
||||
@@ -48,9 +48,11 @@ describe("TechLog production serving contract", () => {
|
||||
"^/studio/documents/[^/]+/publish$",
|
||||
"^/studio/documents/[^/]+/validation$",
|
||||
"^/studio/documents/new$",
|
||||
"^/studio/projects/[^/]+$",
|
||||
"^/studio/publications$",
|
||||
"^/studio/publications/[^/]+/preview$",
|
||||
"^/studio/releases$",
|
||||
"^/studio/releases/[^/]+$",
|
||||
"^/studio/taxonomy$",
|
||||
]);
|
||||
expect(contract.notFound).toEqual({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user