diff --git a/public/release-manifest.json b/public/release-manifest.json index fa37ac8..9798c8c 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,28 +38,28 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:9e51906d7d3b8c1b6a475ea0bd2f6bb28aadba832a29e13f635b5b5acefa2d8e", + "setDigest": "sha256:b729150c56b64ba01d7a71a7442c14bb04d9e573b852d1d1879e748854c20174", "packages": [ { "packageId": "@tech-log/management-contract", "version": "1.0.0", "digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55", "runtimeProtocolVersion": 1, - "sourceRevision": "436937f" + "sourceRevision": "06ae075" }, { "packageId": "@tech-log/public-contract", "version": "2.1.0", - "digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b", + "digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c", "runtimeProtocolVersion": 1, - "sourceRevision": "436937f" + "sourceRevision": "06ae075" }, { "packageId": "@tech-log/studio-contract", "version": "3.1.0", "digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f", "runtimeProtocolVersion": 1, - "sourceRevision": "436937f" + "sourceRevision": "06ae075" } ] } diff --git a/src/features/tech-log/contracts/management/canonical-source.json b/src/features/tech-log/contracts/management/canonical-source.json index d108862..82ac283 100644 --- a/src/features/tech-log/contracts/management/canonical-source.json +++ b/src/features/tech-log/contracts/management/canonical-source.json @@ -2,7 +2,7 @@ "packageId": "@tech-log/management-contract", "version": "1.0.0", "digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55", - "sourceRevision": "436937f", + "sourceRevision": "06ae075", "operationIds": [ "createCaseDraft", "getCaseForEdit", diff --git a/src/features/tech-log/contracts/public/canonical-source.json b/src/features/tech-log/contracts/public/canonical-source.json index 6e4293d..ecf0137 100644 --- a/src/features/tech-log/contracts/public/canonical-source.json +++ b/src/features/tech-log/contracts/public/canonical-source.json @@ -1,8 +1,8 @@ { "packageId": "@tech-log/public-contract", "version": "2.1.0", - "digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b", - "sourceRevision": "436937f", + "digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c", + "sourceRevision": "06ae075", "operationIds": [ "getPublicSite", "getPublicHome", diff --git a/src/features/tech-log/contracts/public/generated.ts b/src/features/tech-log/contracts/public/generated.ts index 0fd42cc..7dd55ad 100644 --- a/src/features/tech-log/contracts/public/generated.ts +++ b/src/features/tech-log/contracts/public/generated.ts @@ -670,6 +670,9 @@ export interface components { nextStep?: string; systemOverviewMarkdown?: string; technologies?: string[]; + /** @description 이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다. + * 화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다. */ + topics?: components["schemas"]["TopicSummary"][]; /** Format: date-time */ updatedAt: string; }; diff --git a/src/features/tech-log/contracts/public/public-api.openapi.yaml b/src/features/tech-log/contracts/public/public-api.openapi.yaml index 1c7cf2e..f40b592 100644 --- a/src/features/tech-log/contracts/public/public-api.openapi.yaml +++ b/src/features/tech-log/contracts/public/public-api.openapi.yaml @@ -1572,6 +1572,13 @@ components: type: array items: type: string + topics: + type: array + description: |- + 이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다. + 화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다. + items: + $ref: '#/components/schemas/TopicSummary' updatedAt: *id003 featuredDecision: $ref: '#/components/schemas/RelatedEntry' diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index 72ddc51..22250c8 100644 --- a/src/features/tech-log/contracts/studio/canonical-source.json +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -2,7 +2,7 @@ "packageId": "@tech-log/studio-contract", "version": "3.1.0", "digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f", - "sourceRevision": "436937f", + "sourceRevision": "06ae075", "operationIds": [ "getStudioSession", "getStudioDashboard", diff --git a/src/features/tech-log/presentation/shared/public-render/document-toc.tsx b/src/features/tech-log/presentation/shared/public-render/document-toc.tsx index a8752de..b915a0b 100644 --- a/src/features/tech-log/presentation/shared/public-render/document-toc.tsx +++ b/src/features/tech-log/presentation/shared/public-render/document-toc.tsx @@ -12,6 +12,7 @@ type DocumentTocProps = { export function DocumentToc({ headings, variant }: DocumentTocProps) { const [currentId, setCurrentId] = useState(headings[0]?.id ?? ""); + const empty = headings.length === 0; const detailsRef = useRef(null); useEffect(() => { @@ -41,6 +42,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]; diff --git a/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx b/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx index ab68645..e715672 100644 --- a/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx +++ b/src/features/tech-log/presentation/shared/public-render/public-record-renderer.tsx @@ -128,57 +128,18 @@ function publicRelations( })); } -function GenericCase({ - model, - embedded, - resolveEvidenceAsset, - resolvePublishedLabel, -}: { - model: CasePublicRenderModel; - embedded: boolean; -} & RenderDependencies) { - const Root = embedded ? "div" : "main"; - return ( - - -
-
-

문제

-

</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> @@ -519,16 +480,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} diff --git a/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx b/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx index 89ad5f8..1a0f293 100644 --- a/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx +++ b/src/features/tech-log/presentation/studio/components/home-focus-editor.tsx @@ -1,25 +1,27 @@ import { useCallback, useEffect, useState, type FormEvent } from "react"; import type { CatalogEntry } from "../../../contracts/studio/contract.ts"; -import type { HomeFocusResponse } from "../../../contracts/management/contract.ts"; -import type { ProjectIndexItem } from "../../../contracts/management/contract.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"; /** * 공개 홈의 "지금 집중하는 것" 을 정하는 화면. * - * <p>그 영역은 세 칸(현재 작업·열린 질문·최근 결정)을 가지며, 셋이 모두 비면 홈은 영역 자체를 - * 그리지 않는다. `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을 채울 - * 화면이 없었으므로, 홈에서는 그 영역이 한 번도 나타난 적이 없었다. + * <p>그 영역은 세 칸(현재 작업·열린 질문·최근 결정)을 가지며 셋이 모두 비면 홈은 영역 자체를 그리지 + * 않는다. `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을 채울 화면이 없었으므로, + * 홈에서는 그 영역이 한 번도 나타난 적이 없었다. * - * <p>고를 수 있는 것은 실제로 존재하는 기록뿐이다. 질문과 결정은 Studio catalog 의 - * `RELATION` 목록에서 가져온다 — 그 목록이 곧 "연결 가능한 대상" 의 정의이고, 여기서 다른 - * 기준을 쓰면 두 화면이 서로 다른 것을 보여 준다. + * <p>고를 수 있는 것은 실제로 존재하는 기록뿐이다. 질문과 결정은 Studio catalog 의 `RELATION` 목록에서 + * 가져온다 — 그 목록이 곧 "연결 가능한 대상" 의 정의이고, 여기서 다른 기준을 쓰면 두 화면이 서로 다른 + * 것을 보여 준다. * - * <p>비공개 프로젝트도 고를 수 있게 둔다. 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 - * 정상적인 순서이기 때문이다. 다만 게시되지 않은 동안에는 홈이 그 칸을 그리지 않으므로, 목록에 - * 그 사실을 적어 둔다. + * <p>비공개 프로젝트도 고를 수 있게 둔다. 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 정상적인 + * 순서이기 때문이다. 다만 게시되지 않은 동안에는 홈이 그 칸을 그리지 않으므로 그 사실을 적어 둔다. */ export function HomeFocusEditor() { const { gateway, managementGateway, setRequestAnnouncement } = useStudio(); @@ -92,10 +94,15 @@ export function HomeFocusEditor() { const nothingChosen = !projectId && !questionId && !decisionId; return ( - <section className="studio-work-section" aria-labelledby="studio-home-focus-title"> - <div className="studio-section-title"> - <h2 id="studio-home-focus-title">홈에 무엇을 띄울까</h2> + <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} @@ -106,86 +113,77 @@ export function HomeFocusEditor() { 홈 설정을 불러오는 중입니다. </p> ) : null} + {focus ? ( - <form className="studio-home-focus-form" onSubmit={save}> - <p className="studio-empty-inline"> - 공개 홈 맨 위 &ldquo;지금 집중하는 것&rdquo; 영역입니다. 셋 다 비워 두면 그 영역은 - 나타나지 않습니다. - </p> + <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> - <div className="studio-field"> - <label htmlFor="home-focus-project">현재 작업 (프로젝트)</label> - <select - className="studio-control" - id="home-focus-project" - value={projectId} - onChange={(event) => setProjectId(event.target.value)} - > - <option value="">고르지 않음</option> - {projects.map((project) => ( - <option key={project.id} value={project.id}> - {project.name} - {project.targetVisibility === "PRIVATE" ? " (비공개)" : ""} - </option> - ))} - </select> - {projectHidden ? ( - <p className="studio-field-note"> - 이 프로젝트는 아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 홈에 나타납니다. - </p> - ) : null} + <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-field"> - <label htmlFor="home-focus-question">열린 질문</label> - <select - className="studio-control" - id="home-focus-question" - value={questionId} - onChange={(event) => setQuestionId(event.target.value)} - > - <option value="">고르지 않음</option> - {questions.map((entry) => ( - <option key={entry.id} value={entry.id}> - {entry.label} - </option> - ))} - </select> - {questions.length === 0 ? ( - <p className="studio-field-note">아직 Question 문서가 없습니다.</p> - ) : null} - </div> - - <div className="studio-field"> - <label htmlFor="home-focus-decision">최근 결정</label> - <select - className="studio-control" - id="home-focus-decision" - value={decisionId} - onChange={(event) => setDecisionId(event.target.value)} - > - <option value="">고르지 않음</option> - {decisions.map((entry) => ( - <option key={entry.id} value={entry.id}> - {entry.label} - </option> - ))} - </select> - {decisions.length === 0 ? ( - <p className="studio-field-note">아직 Decision 문서가 없습니다.</p> - ) : null} - </div> - - {nothingChosen ? ( - <p className="studio-field-note"> - 지금은 아무것도 고르지 않아 홈에 이 영역이 나타나지 않습니다. - </p> - ) : null} - - <div className="studio-row-actions"> - <button className="studio-primary-button" type="submit" disabled={pending}> + <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} diff --git a/src/features/tech-log/presentation/studio/components/project-editor.tsx b/src/features/tech-log/presentation/studio/components/project-editor.tsx index f3d0f23..4678754 100644 --- a/src/features/tech-log/presentation/studio/components/project-editor.tsx +++ b/src/features/tech-log/presentation/studio/components/project-editor.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useState, type FormEvent } from "react"; +import type { CatalogEntry } from "../../../contracts/studio/contract.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"; @@ -10,30 +12,33 @@ import { GuardedStudioLink } from "./guarded-studio-link.tsx"; import { slugFromName } from "./slug-from-name.ts"; import { useStudio } from "../use-studio.ts"; -/** `project_phase_check` 와 같은 목록이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */ +/** `project_phase_check` 와 같은 집합이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */ const PHASES = [ - ["RESEARCH", "조사"], - ["DESIGN", "설계"], - ["IMPLEMENTATION", "구현"], - ["VERIFICATION", "검증"], - ["MAINTENANCE", "유지"], - ["PAUSED", "중단"], - ["COMPLETED", "완료"], + { 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` 와 같은 목록이다. */ +/** `project_activity_activity_type_check` 와 같은 집합이다. */ const ACTIVITY_TYPES = [ - ["MILESTONE_REACHED", "이정표 도달"], - ["PHASE_CHANGED", "단계 전환"], - ["QUESTION_OPENED", "질문 열림"], - ["QUESTION_RESOLVED", "질문 해결"], - ["DECISION_ACCEPTED", "결정 수락"], - ["CASE_PUBLISHED", "Case 게시"], - ["REFERENCE_PUBLISHED", "Reference 게시"], - ["PROJECT_PAUSED", "프로젝트 중단"], - ["PROJECT_RESUMED", "프로젝트 재개"], + { 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); @@ -49,25 +54,67 @@ function fromLocalInput(value: string): string { 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 만 가질 수 있었다. 그래서 공개 화면의 "현재 목표"·"다음 - * 작업"·"목적" 이 늘 비어 있었고, 홈의 focus 카드도 제목만 남았다 — 백엔드의 `updateProject` 는 처음부터 - * 구현돼 있었고 채울 화면만 없었다. + * 작업"·"주요 주제" 가 늘 비어 있었고 홈의 집중 카드도 제목만 남았다 — 백엔드의 `updateProject` 는 + * 처음부터 구현돼 있었고 채울 화면만 없었다. * * <p>활동도 여기서 다룬다. 프로젝트 밖의 활동은 존재하지 않으므로 따로 둘 이유가 없고, 무엇이 공개 - * 타임라인에 실리는지를 프로젝트 필드와 같은 화면에서 보는 편이 낫다. + * 타임라인에 실리는지를 프로젝트 필드와 같은 자리에서 보는 편이 낫다. + * + * <p>새 CSS 를 만들지 않는다 — 문서 편집기의 `studio-editor-section`·`studio-field-grid` 와 작업본 + * 목록의 행 클래스를 그대로 쓰므로 Studio 의 나머지와 같은 간격·타이포·색을 따른다. */ export function ProjectEditor() { const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">(); const projectId = params.id; - const { managementGateway, setRequestAnnouncement } = useStudio(); + 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<Record<string, string>>({}); - const [labels, setLabels] = useState(""); + const [draft, setDraft] = useState<Draft | null>(null); + const [newActivity, setNewActivity] = useState({ + activityType: "MILESTONE_REACHED", + title: "", + summary: "", + visibility: "PUBLIC", + relatedId: "", + occurredAt: toLocalInput(new Date().toISOString()), + }); const [pending, setPending] = useState(false); const [error, setError] = useState(""); const [generation, setGeneration] = useState(0); @@ -79,23 +126,16 @@ export function ProjectEditor() { void Promise.all([ managementGateway.getProject(projectId), managementGateway.listProjectActivities(projectId), + managementGateway.listTopics(), + gateway.getCatalog({ type: "RELATION", limit: 100 }), ]).then( - ([loaded, activityList]) => { + ([loaded, activityList, topicList, catalog]) => { if (cancelled) return; setProject(loaded); + setDraft(toDraft(loaded)); setActivities(activityList); - setDraft({ - name: loaded.name ?? "", - slug: loaded.slug ?? "", - oneLinePurpose: loaded.oneLinePurpose ?? "", - purposeMarkdown: loaded.purposeMarkdown ?? "", - boundaryMarkdown: loaded.boundaryMarkdown ?? "", - systemOverviewMarkdown: loaded.systemOverviewMarkdown ?? "", - phase: loaded.phase, - currentObjective: loaded.currentObjective ?? "", - nextStep: loaded.nextStep ?? "", - }); - setLabels((loaded.technologyLabels ?? []).join(", ")); + setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED")); + setRecords(catalog.items ?? []); setError(""); }, () => { @@ -105,48 +145,55 @@ export function ProjectEditor() { return () => { cancelled = true; }; - }, [managementGateway, projectId, generation]); + }, [gateway, managementGateway, projectId, generation]); - const set = (key: string, value: string) => - setDraft((current) => ({ ...current, [key]: value })); + 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) return; + if (pending || !project || !draft) return; setPending(true); setError(""); try { /* slug 는 공개 주소다. 비워 두면 게시할 수 없으므로 이름에서 만들어 채운다 — 예전에는 조용히 - 빈 값으로 저장되었고, 게시 화면에 가서야 "slug 가 없다" 는 말을 들었다. + 빈 값으로 저장되었고, 게시할 때가 되어서야 "slug 가 없다" 는 말을 들었다. */ - const slug = draft.slug?.trim() || slugFromName(draft.name ?? ""); const saved = await managementGateway.updateProject(project.id, { expectedVersion: project.version, - name: (draft.name ?? "").trim(), - slug, - oneLinePurpose: draft.oneLinePurpose ?? "", - purposeMarkdown: draft.purposeMarkdown ?? "", - boundaryMarkdown: draft.boundaryMarkdown ?? "", - systemOverviewMarkdown: draft.systemOverviewMarkdown ?? "", - phase: draft.phase ?? "RESEARCH", - currentObjective: draft.currentObjective ?? "", - nextStep: draft.nextStep ?? "", - technologyLabels: labels + 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, }); - setProject(saved); - setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`); /* - 이미 공개된 프로젝트라면 공개 투영도 함께 갱신해야 한다. 투영은 게시할 때 세워지므로, - 저장만 하면 화면에는 예전 값이 남는다. + 이미 공개된 프로젝트라면 공개 투영도 함께 세워야 한다. 투영은 게시할 때 만들어지므로 저장만 + 하면 공개 화면에는 예전 값이 남는다. */ if (saved.targetVisibility !== "PRIVATE") { await managementGateway.publishProject( @@ -155,6 +202,7 @@ export function ProjectEditor() { saved.targetVisibility === "UNLISTED" ? "UNLISTED" : "PUBLIC", ); } + setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`); reload(); } catch (failure) { setError(managementFailureMessage(failure, "프로젝트를 저장하지 못했습니다.")); @@ -163,20 +211,41 @@ export function ProjectEditor() { } }; - const addActivity = async () => { + const addActivity = async (event: FormEvent) => { + event.preventDefault(); if (pending || !project) return; + const title = newActivity.title.trim(); + if (!title) { + setError("활동에는 제목이 필요합니다."); + return; + } setPending(true); setError(""); try { + const related = records.find((entry) => entry.id === newActivity.relatedId); await managementGateway.createProjectActivity(project.id, { expectedProjectVersion: project.version, + activityType: newActivity.activityType, + title, + summary: newActivity.summary, + visibility: newActivity.visibility === "PUBLIC" ? "PUBLIC" : "PRIVATE", + /* + 공개 화면의 "이 활동 위치 열기" 는 이 자리를 따라간다. 비워 두면 그 링크는 갈 곳이 없다 — + 예전에는 고를 방법 자체가 없어 모든 활동의 그 버튼이 눌리지 않았다. + */ + relatedResourceType: related?.kind ?? undefined, + relatedResourceId: related?.id ?? undefined, + occurredAt: fromLocalInput(newActivity.occurredAt), + }); + setNewActivity({ activityType: "MILESTONE_REACHED", - title: "새 활동", + title: "", summary: "", visibility: "PUBLIC", - occurredAt: new Date().toISOString(), + relatedId: "", + occurredAt: toLocalInput(new Date().toISOString()), }); - setRequestAnnouncement("활동을 추가했습니다."); + setRequestAnnouncement(`활동 ${title} 을(를) 추가했습니다.`); reload(); } catch (failure) { setError(managementFailureMessage(failure, "활동을 추가하지 못했습니다.")); @@ -200,9 +269,7 @@ export function ProjectEditor() { visibility: (changes.visibility ?? activity.visibility) === "PUBLIC" ? "PUBLIC" : "PRIVATE", occurredAt: changes.occurredAt ?? activity.occurredAt, }); - setActivities((current) => - current.map((item) => (item.id === saved.id ? saved : item)), - ); + setActivities((current) => current.map((item) => (item.id === saved.id ? saved : item))); setRequestAnnouncement("활동을 저장했습니다."); } catch (failure) { setError(managementFailureMessage(failure, "활동을 저장하지 못했습니다.")); @@ -218,7 +285,7 @@ export function ProjectEditor() { try { await managementGateway.deleteProjectActivity(project.id, activity.id, activity.version); setActivities((current) => current.filter((item) => item.id !== activity.id)); - setRequestAnnouncement("활동을 삭제했습니다."); + setRequestAnnouncement(`활동 ${activity.title} 을(를) 삭제했습니다.`); } catch (failure) { setError(managementFailureMessage(failure, "활동을 삭제하지 못했습니다.")); } finally { @@ -226,40 +293,15 @@ export function ProjectEditor() { } }; - const field = ( - key: string, - label: string, - hint?: string, - multiline = false, - ) => ( - <div className="studio-field" key={key}> - <label htmlFor={`project-${key}`}>{label}</label> - {multiline ? ( - <textarea - className="studio-control studio-markdown" - id={`project-${key}`} - rows={5} - value={draft[key] ?? ""} - onChange={(event) => set(key, event.target.value)} - /> - ) : ( - <input - className="studio-control" - id={`project-${key}`} - type="text" - value={draft[key] ?? ""} - onChange={(event) => set(key, event.target.value)} - /> - )} - {hint ? <p className="studio-field-note">{hint}</p> : null} - </div> - ); + const published = project ? project.targetVisibility !== "PRIVATE" : false; return ( - <div className="studio-page studio-documents-page"> + <div className="studio-page studio-editor-page"> <header className="studio-page-top"> <div className="studio-page-heading"> - <p className="studio-eyebrow">PROJECT</p> + <p className="studio-eyebrow"> + PROJECT · VERSION {project?.version ?? "—"} + </p> <h1>{project?.name ?? "프로젝트"}</h1> <p> 공개 프로젝트 화면과 프로필의 &ldquo;현재 프로젝트&rdquo;, 홈의 집중 카드가 여기 값을 @@ -267,7 +309,7 @@ export function ProjectEditor() { </p> </div> <GuardedStudioLink className="studio-primary-action" href="/studio/taxonomy"> - 목록으로 + 주제·프로젝트로 </GuardedStudioLink> </header> @@ -282,120 +324,296 @@ export function ProjectEditor() { </p> ) : null} - {project ? ( + {project && draft ? ( <> - <form className="studio-home-focus-form" onSubmit={save}> - {field("name", "이름")} - {field("slug", "slug", "공개 주소가 됩니다. 비우면 이름에서 만듭니다.")} + <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> - <div className="studio-field"> - <label htmlFor="project-phase">단계</label> - <select - className="studio-control" - id="project-phase" - value={draft.phase ?? "RESEARCH"} - onChange={(event) => set("phase", event.target.value)} - > - {PHASES.map(([value, label]) => ( - <option key={value} value={value}> - {label} ({value}) - </option> - ))} - </select> - </div> + <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>프로젝트 화면 상단의 &ldquo;주요 주제&rdquo;가 고른 순서대로 나옵니다.</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> - {field("oneLinePurpose", "한 줄 목적", "프로젝트 목록과 프로필 카드에 나옵니다.")} - {field("currentObjective", "현재 목표", "홈의 집중 카드에 나옵니다.")} - {field("nextStep", "다음 작업", "홈의 집중 카드에 나옵니다.")} + <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 })} + /> + </label> + <label className="studio-field studio-field--wide"> + <span>경계</span> + <textarea + className="studio-markdown" + value={draft.boundaryMarkdown} + onChange={(event) => update({ boundaryMarkdown: event.currentTarget.value })} + /> + </label> + <label className="studio-field studio-field--wide"> + <span>시스템 개요</span> + <textarea + className="studio-markdown" + value={draft.systemOverviewMarkdown} + onChange={(event) => + update({ systemOverviewMarkdown: event.currentTarget.value }) + } + /> + </label> + </div> + </section> - <div className="studio-field"> - <label htmlFor="project-labels">기술 라벨</label> - <input - className="studio-control" - id="project-labels" - type="text" - value={labels} - onChange={(event) => setLabels(event.target.value)} - /> - <p className="studio-field-note">쉼표로 구분합니다.</p> - </div> - - {field("purposeMarkdown", "목적", undefined, true)} - {field("boundaryMarkdown", "경계", undefined, true)} - {field("systemOverviewMarkdown", "시스템 개요", undefined, true)} - - <div className="studio-row-actions"> + <div className="studio-editor-footer"> <button className="studio-primary-button" type="submit" disabled={pending}> {pending ? "저장하는 중" : "저장"} </button> + <p className="studio-field-note"> + {published + ? "공개된 프로젝트입니다. 저장하면 공개 화면도 함께 갱신됩니다." + : "아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 공개 화면에 나옵니다."} + </p> </div> </form> - <section className="studio-work-section" aria-labelledby="project-activity-title"> - <div className="studio-section-title"> - <h2 id="project-activity-title">활동</h2> - <button - className="studio-text-action" - type="button" - disabled={pending} - onClick={() => void addActivity()} - > - 활동 추가 - </button> + <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> - <p className="studio-field-note"> - 공개로 둔 줄만 프로젝트 화면의 타임라인에 나옵니다. - </p> + + <form className="studio-field-grid" onSubmit={addActivity}> + <label className="studio-field"> + <span>유형</span> + <select + value={newActivity.activityType} + onChange={(event) => + setNewActivity((current) => ({ + ...current, + activityType: event.currentTarget.value, + })) + } + > + {ACTIVITY_TYPES.map((type) => ( + <option key={type.value} value={type.value}> + {type.label} + </option> + ))} + </select> + </label> + <label className="studio-field"> + <span>일시</span> + <input + type="datetime-local" + value={newActivity.occurredAt} + onChange={(event) => + setNewActivity((current) => ({ + ...current, + occurredAt: event.currentTarget.value, + })) + } + /> + </label> + <label className="studio-field studio-field--wide"> + <span>제목</span> + <input + value={newActivity.title} + maxLength={200} + onChange={(event) => + setNewActivity((current) => ({ ...current, title: event.currentTarget.value })) + } + /> + </label> + <label className="studio-field studio-field--wide"> + <span>요약</span> + <input + value={newActivity.summary} + maxLength={300} + onChange={(event) => + setNewActivity((current) => ({ + ...current, + summary: event.currentTarget.value, + })) + } + /> + </label> + <label className="studio-field"> + <span>연결할 기록</span> + <select + value={newActivity.relatedId} + onChange={(event) => + setNewActivity((current) => ({ + ...current, + relatedId: event.currentTarget.value, + })) + } + > + <option value="">연결하지 않음</option> + {records.map((entry) => ( + <option key={entry.id} value={entry.id}> + {entry.label} + </option> + ))} + </select> + <small> + 공개 화면의 &ldquo;이 활동 위치 열기&rdquo;가 이 기록으로 갑니다. + </small> + </label> + <label className="studio-field"> + <span>노출</span> + <select + value={newActivity.visibility} + onChange={(event) => + setNewActivity((current) => ({ + ...current, + visibility: event.currentTarget.value, + })) + } + > + <option value="PUBLIC">공개</option> + <option value="PRIVATE">비공개</option> + </select> + </label> + <div className="studio-field studio-field--wide"> + <button className="studio-secondary-button" type="submit" disabled={pending}> + 활동 추가 + </button> + </div> + </form> + {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"> - {ACTIVITY_TYPES.find(([value]) => value === activity.activityType)?.[1] ?? - activity.activityType} - </p> + <p className="studio-row-label">{activityTypeLabel(activity.activityType)}</p> <div className="studio-document-title"> - <div className="studio-field"> - <label htmlFor={`activity-title-${activity.id}`}>제목</label> - <input - className="studio-control" - id={`activity-title-${activity.id}`} - type="text" - defaultValue={activity.title} - onBlur={(event) => - event.target.value !== activity.title && - void saveActivity(activity, { title: event.target.value }) - } - /> - </div> - <div className="studio-field"> - <label htmlFor={`activity-summary-${activity.id}`}>요약</label> - <input - className="studio-control" - id={`activity-summary-${activity.id}`} - type="text" - defaultValue={activity.summary ?? ""} - onBlur={(event) => - event.target.value !== (activity.summary ?? "") && - void saveActivity(activity, { summary: event.target.value }) - } - /> - </div> + <h2>{activity.title}</h2> + <p> + {activity.summary || "요약 없음"} + {activity.relatedResourceId ? " · 기록 연결됨" : " · 연결된 기록 없음"} + </p> </div> <dl> <div> <dt>일시</dt> <dd> <input - className="studio-control" type="datetime-local" - aria-label="활동 일시" + aria-label={`${activity.title} 일시`} defaultValue={toLocalInput(activity.occurredAt)} onBlur={(event) => void saveActivity(activity, { - occurredAt: fromLocalInput(event.target.value), + occurredAt: fromLocalInput(event.currentTarget.value), }) } /> @@ -405,11 +623,12 @@ export function ProjectEditor() { <dt>노출</dt> <dd> <select - className="studio-control" - aria-label="활동 노출" + aria-label={`${activity.title} 노출`} value={activity.visibility} onChange={(event) => - void saveActivity(activity, { visibility: event.target.value }) + void saveActivity(activity, { + visibility: event.currentTarget.value, + }) } > <option value="PUBLIC">공개</option> @@ -417,22 +636,24 @@ export function ProjectEditor() { </select> </dd> </div> + <div> + <dt>기록</dt> + <dd>{activity.origin === "MANUAL" ? "손으로 적음" : "자동 기록"}</dd> + </div> </dl> - <div className="studio-row-actions"> - <button - className="studio-secondary-button" - type="button" - disabled={pending || activity.origin !== "MANUAL"} - title={ - activity.origin === "MANUAL" - ? undefined - : "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다." - } - onClick={() => void removeActivity(activity)} - > - 삭제 - </button> - </div> + <button + className="studio-secondary-button" + type="button" + disabled={pending || activity.origin !== "MANUAL"} + title={ + activity.origin === "MANUAL" + ? undefined + : "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다." + } + onClick={() => void removeActivity(activity)} + > + 삭제 + </button> </article> ))} </div> diff --git a/src/features/tech-log/presentation/studio/components/release-manager.tsx b/src/features/tech-log/presentation/studio/components/release-manager.tsx index b2bf278..44cd50c 100644 --- a/src/features/tech-log/presentation/studio/components/release-manager.tsx +++ b/src/features/tech-log/presentation/studio/components/release-manager.tsx @@ -306,35 +306,47 @@ export function ReleaseManager() { className="studio-document-row" aria-current={selectedId === 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.summary || "요약 없음"}</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 - className="studio-secondary-button" - type="button" - disabled={pending} - onClick={() => setSelectedId(selectedId === release.id ? null : release.id)} - > - {selectedId === release.id ? "닫기" : "편집"} - </button> - <button - className="studio-secondary-button" - type="button" - disabled={pending} - onClick={() => void remove(release)} - > - 삭제 - </button> + <div className="studio-row-actions"> + <button + className="studio-secondary-button" + type="button" + disabled={pending} + onClick={() => setSelectedId(selectedId === release.id ? null : release.id)} + > + {selectedId === release.id ? "닫기" : "편집"} + </button> + <button + className="studio-secondary-button" + type="button" + disabled={pending} + onClick={() => void remove(release)} + > + 삭제 + </button> + </div> </article> ))} </div> @@ -342,8 +354,12 @@ export function ReleaseManager() { )} {draft === null ? null : ( - <section aria-label="릴리즈 편집"> - <h2 className="studio-section-title">{draft.title || "릴리즈"} 편집</h2> + <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> @@ -389,10 +405,8 @@ export function ReleaseManager() { </label> </div> - <fieldset className="studio-field-grid"> - <legend className="studio-field"> - <span>변경 유형</span> - </legend> + <fieldset className="studio-checkbox-set"> + <legend>변경 유형</legend> {CHANGE_TYPES.map((changeType) => ( <label key={changeType.value} className="studio-field studio-field--checkbox"> <input diff --git a/src/features/tech-log/presentation/studio/components/taxonomy-manager.tsx b/src/features/tech-log/presentation/studio/components/taxonomy-manager.tsx index a7f8759..56b9413 100644 --- a/src/features/tech-log/presentation/studio/components/taxonomy-manager.tsx +++ b/src/features/tech-log/presentation/studio/components/taxonomy-manager.tsx @@ -16,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); @@ -311,23 +328,34 @@ export function TaxonomyManager() { <dl> <div> <dt>단계</dt> - <dd>{project.phase}</dd> + <dd>{phaseLabel[project.phase] ?? project.phase}</dd> </div> <div> <dt>공개</dt> + {/* + "게시됨" 이 아니라 어디까지 보이는지를 적는다 — UNLISTED 는 주소를 아는 + 사람만 닿고 목록에는 없다. 둘을 같은 말로 적으면 왜 목록에 안 나오는지 + 설명할 방법이 없다. + */} + <dd>{visibilityLabel[project.targetVisibility] ?? project.targetVisibility}</dd> + </div> + <div> + <dt>다음</dt> <dd> - {project.targetVisibility === "PRIVATE" ? "비공개" : "공개됨"} + <GuardedStudioLink href={`/studio/projects/${project.id}`}> + 내용 편집 + </GuardedStudioLink> </dd> </div> </dl> <div className="studio-row-actions"> <button - className="studio-primary-button" + className="studio-secondary-button" type="button" disabled={pending} onClick={() => void togglePublish(project)} > - {project.targetVisibility === "PRIVATE" ? "게시하기" : "게시 취소"} + {project.targetVisibility === "PRIVATE" ? "게시" : "게시 취소"} </button> <button className="studio-secondary-button" diff --git a/src/features/tech-log/presentation/styles/studio-editor.css b/src/features/tech-log/presentation/styles/studio-editor.css index 5311274..95f85e8 100644 --- a/src/features/tech-log/presentation/styles/studio-editor.css +++ b/src/features/tech-log/presentation/styles/studio-editor.css @@ -36,6 +36,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, diff --git a/src/features/tech-log/presentation/styles/studio.css b/src/features/tech-log/presentation/styles/studio.css index 40822e3..1c9a240 100644 --- a/src/features/tech-log/presentation/styles/studio.css +++ b/src/features/tech-log/presentation/styles/studio.css @@ -113,8 +113,6 @@ */ .studio-app .studio-row-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; } -/* 홈 focus 설정. 편집기의 `.studio-field` 를 그대로 쓰되 한 단으로 세운다. */ -.studio-app .studio-home-focus-form { display: grid; gap: 4px; max-width: 560px; margin-top: 8px; } .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; } diff --git a/src/presentation/i18n/catalog.ts b/src/presentation/i18n/catalog.ts index f1c043f..695d534 100644 --- a/src/presentation/i18n/catalog.ts +++ b/src/presentation/i18n/catalog.ts @@ -87,11 +87,14 @@ const PLATFORM_KO_MESSAGES = { "route.auth.integration.description": "외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.", "route.auth.recovering.title": "세션을 복구하고 있습니다.", - "route.auth.required.title": "세션이 필요합니다.", + "route.auth.required.title": "Studio에 로그인해 주세요.", "route.auth.recovering.description": - "기존 세션 확인을 계속하려면 복구를 실행하세요.", + "이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.", + // 예전 문구("인증 연동 지점을 확인하기 위한 보호 라우트")는 이 뼈대를 만들던 사람에게 하는 + // 말이었다. 실제로 이 화면을 보는 사람은 글을 쓰러 온 작성자이고, 알아야 할 것은 무엇을 + // 누르면 되는지다. "route.auth.required.description": - "이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.", + "기록을 쓰고 게시하려면 로그인이 필요합니다. 로그인하면 방금 열려던 화면으로 돌아옵니다.", "route.documentTitle": "{title} · {appName}", "chunk.checking": "새 릴리스 정보를 확인하고 있습니다.", "chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.", @@ -271,11 +274,11 @@ 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.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.",