feat: 프로젝트를 공개할 수 있게 하고, 홈이 무엇을 앞에 둘지 고를 수 있게 한다
공개 화면 다섯 곳이 조용히 비어 있었다. 원인은 하나씩 달랐지만 모두 "값을 채울 방법이 없었다"는 같은 모양이었다. 홈의 "지금 집중하는 것" — `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고, 계약에 선언된 `getHomeFocus`/`updateHomeFocus` 는 구현이 없었다. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않으므로, 운영에서는 한 번도 나타난 적이 없다. Studio 대시보드에 고르는 화면을 둔다. 홈의 "최근 기록" — 화면이 공개된 프로젝트를 하나씩 돌며 타임라인을 조립했다. 그래서 게시한 문서라도 그 프로젝트가 공개되어 있지 않으면 목록에서 통째로 빠졌고, 실제로 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을 그대로 읽는다. 프로젝트마다 요청을 보내던 N+1 도 사라진다. 프로젝트 공개 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지 못하는데, 공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈 focus)은 전부 `public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을 세우는 경로가 없었으므로 프로젝트는 영원히 비공개였다. 계약에 이미 있던 `publishProject`/`unpublishProject` 를 구현하고 주제·프로젝트 화면에 버튼을 둔다. 문서 사이 관계 연결 — `JdbcCatalogQueryAdapter` 의 RELATION/EVIDENCE 가 `List.of()` 스텁이라 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은 작성 중에 고르는 것이므로 작업본까지 포함하고, EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다. 본문 너비 — 문서 한 편이 세 폭으로 갈라져 있었다. 머리말 920px, 유형·프로젝트 줄은 shell 전체 1180px, 본문은 672px 를 가운데 정렬. 셋을 같은 폭·같은 왼쪽 끝에 세우고 읽는 단을 56rem 으로 넓힌다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
co-authored by
Claude Opus 5
parent
c03b0c77b8
commit
b3aa304975
@@ -0,0 +1,194 @@
|
||||
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 { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/**
|
||||
* 공개 홈의 "지금 집중하는 것" 을 정하는 화면.
|
||||
*
|
||||
* <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;
|
||||
void Promise.all([
|
||||
managementGateway.getHomeFocus(),
|
||||
managementGateway.listProjects(0, 50),
|
||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||
]).then(
|
||||
([current, projectPage, catalog]) => {
|
||||
if (cancelled) return;
|
||||
setFocus(current);
|
||||
setProjects(projectPage.items ?? []);
|
||||
setRelations(catalog.items ?? []);
|
||||
setProjectId(current.currentProjectId ?? "");
|
||||
setQuestionId(current.openQuestionId ?? "");
|
||||
setDecisionId(current.recentDecisionId ?? "");
|
||||
setError("");
|
||||
},
|
||||
() => {
|
||||
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-work-section" aria-labelledby="studio-home-focus-title">
|
||||
<div className="studio-section-title">
|
||||
<h2 id="studio-home-focus-title">홈에 무엇을 띄울까</h2>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{!focus && !error ? (
|
||||
<p className="studio-loading" role="status">
|
||||
홈 설정을 불러오는 중입니다.
|
||||
</p>
|
||||
) : null}
|
||||
{focus ? (
|
||||
<form className="studio-home-focus-form" onSubmit={save}>
|
||||
<p className="studio-empty-inline">
|
||||
공개 홈 맨 위 “지금 집중하는 것” 영역입니다. 셋 다 비워 두면 그 영역은
|
||||
나타나지 않습니다.
|
||||
</p>
|
||||
|
||||
<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}
|
||||
</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}>
|
||||
{pending ? "저장하는 중" : "홈 설정 저장"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -158,26 +158,28 @@ export function ReleaseManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const requestOf = (current: NonNullable<typeof 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 || 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);
|
||||
const saved = await managementGateway.updateRelease(selectedId, requestOf(draft));
|
||||
setDraft(toDraft(saved));
|
||||
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
|
||||
reload();
|
||||
@@ -193,14 +195,24 @@ export function ReleaseManager() {
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion);
|
||||
/*
|
||||
먼저 저장한다. 발행은 서버에 저장된 릴리즈를 검사하는데(`PublishReleaseUseCase`), 예전에는
|
||||
저장하지 않고 발행만 불렀다 — 화면의 칸을 다 채우고 공개를 눌러도 서버 쪽은 여전히 빈
|
||||
초안이라 "모두 채워져야 합니다" 가 떴다. 채웠는데 안 된다는 말이 나온 이유가 이것이다.
|
||||
*/
|
||||
const saved = await managementGateway.updateRelease(selectedId, requestOf(draft));
|
||||
setDraft(toDraft(saved));
|
||||
const published = await managementGateway.publishRelease(selectedId, saved.version);
|
||||
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
|
||||
reload();
|
||||
} catch {
|
||||
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로
|
||||
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다.
|
||||
} catch (error) {
|
||||
// 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
|
||||
// 그러면 이미 채운 칸까지 비었다고 말하게 된다.
|
||||
setError(
|
||||
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||
managementFailureMessage(
|
||||
error,
|
||||
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
@@ -341,6 +353,16 @@ export function ReleaseManager() {
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -159,6 +159,37 @@ export function TaxonomyManager() {
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
프로젝트 게시는 문서 게시와 별개다. 문서를 게시해도 그 문서가 속한 프로젝트는 비공개로 남고,
|
||||
공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈의 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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
@@ -279,17 +310,29 @@ export function TaxonomyManager() {
|
||||
</div>
|
||||
<div>
|
||||
<dt>공개</dt>
|
||||
<dd>{project.targetVisibility}</dd>
|
||||
<dd>
|
||||
{project.targetVisibility === "PRIVATE" ? "비공개" : "공개됨"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeProject(project)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<div className="studio-row-actions">
|
||||
<button
|
||||
className="studio-primary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void togglePublish(project)}
|
||||
>
|
||||
{project.targetVisibility === "PRIVATE" ? "게시하기" : "게시 취소"}
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeProject(project)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user