feat: 프로젝트 주제와 활동 연결을 열고, Case 목차를 되살리고, Studio 화면을 기존 디테일에 맞춘다
주요 주제 — 프로젝트 화면은 처음부터 `project.topics` 를 읽고 있었는데 공개 계약에 그 자리가 없었다. `project_topic` 은 테이블도 있고 조인할 수도 있었지만 응답에 실을 곳이 없었고, 고를 화면도 없었다. 계약에 자리를 만들고, 편집 화면에 주제 선택을 붙이고, 공개 조회가 ACTIVE 인 주제만 고른 순서대로 내보내게 한다. 활동 위치 열기 — 그 링크는 활동의 관련 기록을 따라간다. 백엔드는 처음부터 그 경로를 유도하고 있었지만, 활동을 만들 때 기록을 고를 방법이 없어 모든 줄이 관련 기록 없이 저장됐다. 활동 추가 폼에 유형·일시·제목·요약·연결할 기록·노출을 모두 둔다. Case 목차 — 오른쪽 목차가 어떤 문서에서도 나오지 않았다. Case 렌더러가 둘이었고 어느 쪽을 쓸지 `publicPath === "/cases/collection-fetch-join-pagination"` 이라는 슬러그 비교가 정했다. 설계 픽스처 문서 하나만 breadcrumb·목차가 있는 완성된 배치를 받고, 실제로 작성한 Case 는 전부 축약본으로 떨어졌다. 배치를 하나로 합치고, 목차는 본문에 제목이 있을 때만 그린다. Studio UI — 새로 만든 화면들이 기존 디테일을 따라가지 않고 있었다. `<label htmlFor>` + 별도 컨트롤 대신 편집기가 쓰는 `<label><span>` 묶음을, 임시로 만든 폼 클래스 대신 `studio-editor-section`·`studio-field-grid` 를 쓴다. 릴리즈 목록 행에는 유형 라벨이 빠져 격자 첫 열이 비어 있었고(제목이 당겨져 다른 목록과 줄이 맞지 않았다) 버튼 둘이 묶이지 않은 채였다. 프로젝트 행에는 단계·공개 범위를 한국어로 적고 "내용 편집" 을 다음 행동으로 둔다. 로그인 화면 — "인증 연동 지점을 확인하기 위한 보호 라우트" 는 이 뼈대를 만들던 사람에게 하는 말이었다. 이 화면을 보는 사람은 글을 쓰러 온 작성자다. 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
16e5b9f4ec
commit
014f21b9e1
@@ -38,28 +38,28 @@
|
|||||||
},
|
},
|
||||||
"contractSet": {
|
"contractSet": {
|
||||||
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
"setAlgorithm": "CA_CONTRACT_SET_V1",
|
||||||
"setDigest": "sha256:9e51906d7d3b8c1b6a475ea0bd2f6bb28aadba832a29e13f635b5b5acefa2d8e",
|
"setDigest": "sha256:b729150c56b64ba01d7a71a7442c14bb04d9e573b852d1d1879e748854c20174",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/management-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "436937f"
|
"sourceRevision": "06ae075"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/public-contract",
|
"packageId": "@tech-log/public-contract",
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "436937f"
|
"sourceRevision": "06ae075"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
||||||
"runtimeProtocolVersion": 1,
|
"runtimeProtocolVersion": 1,
|
||||||
"sourceRevision": "436937f"
|
"sourceRevision": "06ae075"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"packageId": "@tech-log/management-contract",
|
"packageId": "@tech-log/management-contract",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
|
||||||
"sourceRevision": "436937f",
|
"sourceRevision": "06ae075",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"createCaseDraft",
|
"createCaseDraft",
|
||||||
"getCaseForEdit",
|
"getCaseForEdit",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"packageId": "@tech-log/public-contract",
|
"packageId": "@tech-log/public-contract",
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
|
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
|
||||||
"sourceRevision": "436937f",
|
"sourceRevision": "06ae075",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getPublicSite",
|
"getPublicSite",
|
||||||
"getPublicHome",
|
"getPublicHome",
|
||||||
|
|||||||
@@ -670,6 +670,9 @@ export interface components {
|
|||||||
nextStep?: string;
|
nextStep?: string;
|
||||||
systemOverviewMarkdown?: string;
|
systemOverviewMarkdown?: string;
|
||||||
technologies?: string[];
|
technologies?: string[];
|
||||||
|
/** @description 이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||||
|
* 화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다. */
|
||||||
|
topics?: components["schemas"]["TopicSummary"][];
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1572,6 +1572,13 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
topics:
|
||||||
|
type: array
|
||||||
|
description: |-
|
||||||
|
이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
|
||||||
|
화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TopicSummary'
|
||||||
updatedAt: *id003
|
updatedAt: *id003
|
||||||
featuredDecision:
|
featuredDecision:
|
||||||
$ref: '#/components/schemas/RelatedEntry'
|
$ref: '#/components/schemas/RelatedEntry'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"packageId": "@tech-log/studio-contract",
|
"packageId": "@tech-log/studio-contract",
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
||||||
"sourceRevision": "436937f",
|
"sourceRevision": "06ae075",
|
||||||
"operationIds": [
|
"operationIds": [
|
||||||
"getStudioSession",
|
"getStudioSession",
|
||||||
"getStudioDashboard",
|
"getStudioDashboard",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type DocumentTocProps = {
|
|||||||
|
|
||||||
export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
||||||
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
|
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
|
||||||
|
const empty = headings.length === 0;
|
||||||
const detailsRef = useRef<HTMLDetailsElement>(null);
|
const detailsRef = useRef<HTMLDetailsElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,6 +42,12 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
|
|||||||
if (detailsRef.current) detailsRef.current.open = false;
|
if (detailsRef.current) detailsRef.current.open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
제목이 없는 문서에는 목차를 그리지 않는다. 예전에는 이 컴포넌트가 픽스처 문서 하나에서만
|
||||||
|
쓰여 빈 경우를 만날 일이 없었지만, 이제 모든 Case 가 지나므로 빈 레일이 남을 수 있다.
|
||||||
|
*/
|
||||||
|
if (empty) return null;
|
||||||
|
|
||||||
if (variant === "mobile") {
|
if (variant === "mobile") {
|
||||||
const current =
|
const current =
|
||||||
headings.find((heading) => heading.id === currentId) ?? headings[0];
|
headings.find((heading) => heading.id === currentId) ?? headings[0];
|
||||||
|
|||||||
+14
-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,
|
model,
|
||||||
embedded,
|
embedded,
|
||||||
resolveEvidenceAsset,
|
resolveEvidenceAsset,
|
||||||
@@ -233,7 +194,7 @@ function FetchJoinCase({
|
|||||||
<dd><PlainText text={model.environment} /></dd>
|
<dd><PlainText text={model.environment} /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>데이터셋</dt>
|
<dt>검증 데이터</dt>
|
||||||
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
|
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -519,16 +480,8 @@ export function PublicRecordRenderer({
|
|||||||
} & RenderDependencies) {
|
} & RenderDependencies) {
|
||||||
switch (model.kind) {
|
switch (model.kind) {
|
||||||
case "CASE":
|
case "CASE":
|
||||||
return model.publicPath ===
|
return (
|
||||||
"/cases/collection-fetch-join-pagination" ? (
|
<CaseDocument
|
||||||
<FetchJoinCase
|
|
||||||
model={model}
|
|
||||||
embedded={embedded}
|
|
||||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
|
||||||
resolvePublishedLabel={resolvePublishedLabel}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<GenericCase
|
|
||||||
model={model}
|
model={model}
|
||||||
embedded={embedded}
|
embedded={embedded}
|
||||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
import type { CatalogEntry } from "../../../contracts/studio/contract.ts";
|
import type { CatalogEntry } from "../../../contracts/studio/contract.ts";
|
||||||
import type { HomeFocusResponse } from "../../../contracts/management/contract.ts";
|
import type {
|
||||||
import type { ProjectIndexItem } from "../../../contracts/management/contract.ts";
|
HomeFocusResponse,
|
||||||
|
ProjectIndexItem,
|
||||||
|
} from "../../../contracts/management/contract.ts";
|
||||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||||
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 홈의 "지금 집중하는 것" 을 정하는 화면.
|
* 공개 홈의 "지금 집중하는 것" 을 정하는 화면.
|
||||||
*
|
*
|
||||||
* <p>그 영역은 세 칸(현재 작업·열린 질문·최근 결정)을 가지며, 셋이 모두 비면 홈은 영역 자체를
|
* <p>그 영역은 세 칸(현재 작업·열린 질문·최근 결정)을 가지며 셋이 모두 비면 홈은 영역 자체를 그리지
|
||||||
* 그리지 않는다. `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을 채울
|
* 않는다. `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을 채울 화면이 없었으므로,
|
||||||
* 화면이 없었으므로, 홈에서는 그 영역이 한 번도 나타난 적이 없었다.
|
* 홈에서는 그 영역이 한 번도 나타난 적이 없었다.
|
||||||
*
|
*
|
||||||
* <p>고를 수 있는 것은 실제로 존재하는 기록뿐이다. 질문과 결정은 Studio catalog 의
|
* <p>고를 수 있는 것은 실제로 존재하는 기록뿐이다. 질문과 결정은 Studio catalog 의 `RELATION` 목록에서
|
||||||
* `RELATION` 목록에서 가져온다 — 그 목록이 곧 "연결 가능한 대상" 의 정의이고, 여기서 다른
|
* 가져온다 — 그 목록이 곧 "연결 가능한 대상" 의 정의이고, 여기서 다른 기준을 쓰면 두 화면이 서로 다른
|
||||||
* 기준을 쓰면 두 화면이 서로 다른 것을 보여 준다.
|
* 것을 보여 준다.
|
||||||
*
|
*
|
||||||
* <p>비공개 프로젝트도 고를 수 있게 둔다. 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이
|
* <p>비공개 프로젝트도 고를 수 있게 둔다. 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 정상적인
|
||||||
* 정상적인 순서이기 때문이다. 다만 게시되지 않은 동안에는 홈이 그 칸을 그리지 않으므로, 목록에
|
* 순서이기 때문이다. 다만 게시되지 않은 동안에는 홈이 그 칸을 그리지 않으므로 그 사실을 적어 둔다.
|
||||||
* 그 사실을 적어 둔다.
|
|
||||||
*/
|
*/
|
||||||
export function HomeFocusEditor() {
|
export function HomeFocusEditor() {
|
||||||
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
@@ -92,10 +94,15 @@ export function HomeFocusEditor() {
|
|||||||
const nothingChosen = !projectId && !questionId && !decisionId;
|
const nothingChosen = !projectId && !questionId && !decisionId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="studio-work-section" aria-labelledby="studio-home-focus-title">
|
<section className="studio-editor-section" aria-labelledby="studio-home-focus-title">
|
||||||
<div className="studio-section-title">
|
<div className="studio-editor-section-heading">
|
||||||
<h2 id="studio-home-focus-title">홈에 무엇을 띄울까</h2>
|
<p className="studio-eyebrow">PUBLIC HOME</p>
|
||||||
|
<h2 id="studio-home-focus-title">지금 집중하는 것</h2>
|
||||||
|
<p>
|
||||||
|
공개 홈 맨 위 영역입니다. 셋 다 비워 두면 그 영역은 나타나지 않습니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="studio-screen-error" role="alert">
|
<p className="studio-screen-error" role="alert">
|
||||||
{error}
|
{error}
|
||||||
@@ -106,86 +113,77 @@ export function HomeFocusEditor() {
|
|||||||
홈 설정을 불러오는 중입니다.
|
홈 설정을 불러오는 중입니다.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{focus ? (
|
{focus ? (
|
||||||
<form className="studio-home-focus-form" onSubmit={save}>
|
<form onSubmit={save}>
|
||||||
<p className="studio-empty-inline">
|
<div className="studio-field-grid">
|
||||||
공개 홈 맨 위 “지금 집중하는 것” 영역입니다. 셋 다 비워 두면 그 영역은
|
<label className="studio-field studio-field--wide">
|
||||||
나타나지 않습니다.
|
<span>현재 작업 (프로젝트)</span>
|
||||||
</p>
|
<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 className="studio-field">
|
||||||
<label htmlFor="home-focus-project">현재 작업 (프로젝트)</label>
|
<span>열린 질문</span>
|
||||||
<select
|
<select
|
||||||
className="studio-control"
|
value={questionId}
|
||||||
id="home-focus-project"
|
onChange={(event) => setQuestionId(event.currentTarget.value)}
|
||||||
value={projectId}
|
disabled={questions.length === 0}
|
||||||
onChange={(event) => setProjectId(event.target.value)}
|
>
|
||||||
>
|
<option value="">고르지 않음</option>
|
||||||
<option value="">고르지 않음</option>
|
{questions.map((entry) => (
|
||||||
{projects.map((project) => (
|
<option key={entry.id} value={entry.id}>
|
||||||
<option key={project.id} value={project.id}>
|
{entry.label}
|
||||||
{project.name}
|
</option>
|
||||||
{project.targetVisibility === "PRIVATE" ? " (비공개)" : ""}
|
))}
|
||||||
</option>
|
</select>
|
||||||
))}
|
{questions.length === 0 ? <small>아직 Question 문서가 없습니다.</small> : null}
|
||||||
</select>
|
</label>
|
||||||
{projectHidden ? (
|
|
||||||
<p className="studio-field-note">
|
<label className="studio-field">
|
||||||
이 프로젝트는 아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 홈에 나타납니다.
|
<span>최근 결정</span>
|
||||||
</p>
|
<select
|
||||||
) : null}
|
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>
|
||||||
|
|
||||||
<div className="studio-field">
|
<div className="studio-editor-footer">
|
||||||
<label htmlFor="home-focus-question">열린 질문</label>
|
<button className="studio-secondary-button" type="submit" disabled={pending}>
|
||||||
<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 ? "저장하는 중" : "홈 설정 저장"}
|
{pending ? "저장하는 중" : "홈 설정 저장"}
|
||||||
</button>
|
</button>
|
||||||
|
<p className="studio-field-note">
|
||||||
|
{nothingChosen
|
||||||
|
? "지금은 아무것도 고르지 않아 홈에 이 영역이 나타나지 않습니다."
|
||||||
|
: "저장하면 공개 홈에 바로 반영됩니다."}
|
||||||
|
{" "}
|
||||||
|
<GuardedStudioLink href="/studio/taxonomy">주제·프로젝트</GuardedStudioLink>
|
||||||
|
에서 프로젝트를 만들고 게시할 수 있습니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import type { CatalogEntry } from "../../../contracts/studio/contract.ts";
|
||||||
import type {
|
import type {
|
||||||
ProjectActivityResponse,
|
ProjectActivityResponse,
|
||||||
ProjectEditResponse,
|
ProjectEditResponse,
|
||||||
|
TopicEdit,
|
||||||
} from "../../../contracts/management/contract.ts";
|
} from "../../../contracts/management/contract.ts";
|
||||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
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 { slugFromName } from "./slug-from-name.ts";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
/** `project_phase_check` 와 같은 목록이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */
|
/** `project_phase_check` 와 같은 집합이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */
|
||||||
const PHASES = [
|
const PHASES = [
|
||||||
["RESEARCH", "조사"],
|
{ value: "RESEARCH", label: "조사" },
|
||||||
["DESIGN", "설계"],
|
{ value: "DESIGN", label: "설계" },
|
||||||
["IMPLEMENTATION", "구현"],
|
{ value: "IMPLEMENTATION", label: "구현" },
|
||||||
["VERIFICATION", "검증"],
|
{ value: "VERIFICATION", label: "검증" },
|
||||||
["MAINTENANCE", "유지"],
|
{ value: "MAINTENANCE", label: "유지" },
|
||||||
["PAUSED", "중단"],
|
{ value: "PAUSED", label: "중단" },
|
||||||
["COMPLETED", "완료"],
|
{ value: "COMPLETED", label: "완료" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** `project_activity_activity_type_check` 와 같은 목록이다. */
|
/** `project_activity_activity_type_check` 와 같은 집합이다. */
|
||||||
const ACTIVITY_TYPES = [
|
const ACTIVITY_TYPES = [
|
||||||
["MILESTONE_REACHED", "이정표 도달"],
|
{ value: "MILESTONE_REACHED", label: "이정표 도달" },
|
||||||
["PHASE_CHANGED", "단계 전환"],
|
{ value: "PHASE_CHANGED", label: "단계 전환" },
|
||||||
["QUESTION_OPENED", "질문 열림"],
|
{ value: "QUESTION_OPENED", label: "질문 열림" },
|
||||||
["QUESTION_RESOLVED", "질문 해결"],
|
{ value: "QUESTION_RESOLVED", label: "질문 해결" },
|
||||||
["DECISION_ACCEPTED", "결정 수락"],
|
{ value: "DECISION_ACCEPTED", label: "결정 수락" },
|
||||||
["CASE_PUBLISHED", "Case 게시"],
|
{ value: "CASE_PUBLISHED", label: "Case 게시" },
|
||||||
["REFERENCE_PUBLISHED", "Reference 게시"],
|
{ value: "REFERENCE_PUBLISHED", label: "Reference 게시" },
|
||||||
["PROJECT_PAUSED", "프로젝트 중단"],
|
{ value: "PROJECT_PAUSED", label: "프로젝트 중단" },
|
||||||
["PROJECT_RESUMED", "프로젝트 재개"],
|
{ value: "PROJECT_RESUMED", label: "프로젝트 재개" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const activityTypeLabel = (value: string) =>
|
||||||
|
ACTIVITY_TYPES.find((type) => type.value === value)?.label ?? value;
|
||||||
|
|
||||||
/** `<input type="datetime-local">` 은 초와 시간대가 없는 지역 시각을 쓴다. */
|
/** `<input type="datetime-local">` 은 초와 시간대가 없는 지역 시각을 쓴다. */
|
||||||
function toLocalInput(isoTimestamp: string): string {
|
function toLocalInput(isoTimestamp: string): string {
|
||||||
const value = new Date(isoTimestamp);
|
const value = new Date(isoTimestamp);
|
||||||
@@ -49,25 +54,67 @@ function fromLocalInput(value: string): string {
|
|||||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
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 만 가질 수 있었다. 그래서 공개 화면의 "현재 목표"·"다음
|
* <p>이 화면이 없는 동안 프로젝트는 이름과 slug 만 가질 수 있었다. 그래서 공개 화면의 "현재 목표"·"다음
|
||||||
* 작업"·"목적" 이 늘 비어 있었고, 홈의 focus 카드도 제목만 남았다 — 백엔드의 `updateProject` 는 처음부터
|
* 작업"·"주요 주제" 가 늘 비어 있었고 홈의 집중 카드도 제목만 남았다 — 백엔드의 `updateProject` 는
|
||||||
* 구현돼 있었고 채울 화면만 없었다.
|
* 처음부터 구현돼 있었고 채울 화면만 없었다.
|
||||||
*
|
*
|
||||||
* <p>활동도 여기서 다룬다. 프로젝트 밖의 활동은 존재하지 않으므로 따로 둘 이유가 없고, 무엇이 공개
|
* <p>활동도 여기서 다룬다. 프로젝트 밖의 활동은 존재하지 않으므로 따로 둘 이유가 없고, 무엇이 공개
|
||||||
* 타임라인에 실리는지를 프로젝트 필드와 같은 화면에서 보는 편이 낫다.
|
* 타임라인에 실리는지를 프로젝트 필드와 같은 자리에서 보는 편이 낫다.
|
||||||
|
*
|
||||||
|
* <p>새 CSS 를 만들지 않는다 — 문서 편집기의 `studio-editor-section`·`studio-field-grid` 와 작업본
|
||||||
|
* 목록의 행 클래스를 그대로 쓰므로 Studio 의 나머지와 같은 간격·타이포·색을 따른다.
|
||||||
*/
|
*/
|
||||||
export function ProjectEditor() {
|
export function ProjectEditor() {
|
||||||
const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">();
|
const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">();
|
||||||
const projectId = params.id;
|
const projectId = params.id;
|
||||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
|
|
||||||
const [project, setProject] = useState<ProjectEditResponse | null>(null);
|
const [project, setProject] = useState<ProjectEditResponse | null>(null);
|
||||||
|
const [topics, setTopics] = useState<TopicEdit[]>([]);
|
||||||
|
const [records, setRecords] = useState<CatalogEntry[]>([]);
|
||||||
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
|
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
|
||||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
const [labels, setLabels] = useState("");
|
const [newActivity, setNewActivity] = useState({
|
||||||
|
activityType: "MILESTONE_REACHED",
|
||||||
|
title: "",
|
||||||
|
summary: "",
|
||||||
|
visibility: "PUBLIC",
|
||||||
|
relatedId: "",
|
||||||
|
occurredAt: toLocalInput(new Date().toISOString()),
|
||||||
|
});
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [generation, setGeneration] = useState(0);
|
const [generation, setGeneration] = useState(0);
|
||||||
@@ -79,23 +126,16 @@ export function ProjectEditor() {
|
|||||||
void Promise.all([
|
void Promise.all([
|
||||||
managementGateway.getProject(projectId),
|
managementGateway.getProject(projectId),
|
||||||
managementGateway.listProjectActivities(projectId),
|
managementGateway.listProjectActivities(projectId),
|
||||||
|
managementGateway.listTopics(),
|
||||||
|
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||||
]).then(
|
]).then(
|
||||||
([loaded, activityList]) => {
|
([loaded, activityList, topicList, catalog]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setProject(loaded);
|
setProject(loaded);
|
||||||
|
setDraft(toDraft(loaded));
|
||||||
setActivities(activityList);
|
setActivities(activityList);
|
||||||
setDraft({
|
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
|
||||||
name: loaded.name ?? "",
|
setRecords(catalog.items ?? []);
|
||||||
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(", "));
|
|
||||||
setError("");
|
setError("");
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
@@ -105,48 +145,55 @@ export function ProjectEditor() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [managementGateway, projectId, generation]);
|
}, [gateway, managementGateway, projectId, generation]);
|
||||||
|
|
||||||
const set = (key: string, value: string) =>
|
const update = (patch: Partial<Draft>) =>
|
||||||
setDraft((current) => ({ ...current, [key]: value }));
|
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) => {
|
const save = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (pending || !project) return;
|
if (pending || !project || !draft) return;
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
/*
|
/*
|
||||||
slug 는 공개 주소다. 비워 두면 게시할 수 없으므로 이름에서 만들어 채운다 — 예전에는 조용히
|
slug 는 공개 주소다. 비워 두면 게시할 수 없으므로 이름에서 만들어 채운다 — 예전에는 조용히
|
||||||
빈 값으로 저장되었고, 게시 화면에 가서야 "slug 가 없다" 는 말을 들었다.
|
빈 값으로 저장되었고, 게시할 때가 되어서야 "slug 가 없다" 는 말을 들었다.
|
||||||
*/
|
*/
|
||||||
const slug = draft.slug?.trim() || slugFromName(draft.name ?? "");
|
|
||||||
const saved = await managementGateway.updateProject(project.id, {
|
const saved = await managementGateway.updateProject(project.id, {
|
||||||
expectedVersion: project.version,
|
expectedVersion: project.version,
|
||||||
name: (draft.name ?? "").trim(),
|
name: draft.name.trim(),
|
||||||
slug,
|
slug: draft.slug.trim() || slugFromName(draft.name),
|
||||||
oneLinePurpose: draft.oneLinePurpose ?? "",
|
oneLinePurpose: draft.oneLinePurpose,
|
||||||
purposeMarkdown: draft.purposeMarkdown ?? "",
|
purposeMarkdown: draft.purposeMarkdown,
|
||||||
boundaryMarkdown: draft.boundaryMarkdown ?? "",
|
boundaryMarkdown: draft.boundaryMarkdown,
|
||||||
systemOverviewMarkdown: draft.systemOverviewMarkdown ?? "",
|
systemOverviewMarkdown: draft.systemOverviewMarkdown,
|
||||||
phase: draft.phase ?? "RESEARCH",
|
phase: draft.phase,
|
||||||
currentObjective: draft.currentObjective ?? "",
|
currentObjective: draft.currentObjective,
|
||||||
nextStep: draft.nextStep ?? "",
|
nextStep: draft.nextStep,
|
||||||
technologyLabels: labels
|
technologyLabels: draft.technologyLabels
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((label) => label.trim())
|
.map((label) => label.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
|
topicIds: [...draft.topicIds],
|
||||||
targetVisibility:
|
targetVisibility:
|
||||||
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
|
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
|
||||||
? project.targetVisibility
|
? project.targetVisibility
|
||||||
: "PRIVATE",
|
: "PRIVATE",
|
||||||
featuredOrder: project.featuredOrder,
|
featuredOrder: project.featuredOrder,
|
||||||
});
|
});
|
||||||
setProject(saved);
|
|
||||||
setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`);
|
|
||||||
/*
|
/*
|
||||||
이미 공개된 프로젝트라면 공개 투영도 함께 갱신해야 한다. 투영은 게시할 때 세워지므로,
|
이미 공개된 프로젝트라면 공개 투영도 함께 세워야 한다. 투영은 게시할 때 만들어지므로 저장만
|
||||||
저장만 하면 화면에는 예전 값이 남는다.
|
하면 공개 화면에는 예전 값이 남는다.
|
||||||
*/
|
*/
|
||||||
if (saved.targetVisibility !== "PRIVATE") {
|
if (saved.targetVisibility !== "PRIVATE") {
|
||||||
await managementGateway.publishProject(
|
await managementGateway.publishProject(
|
||||||
@@ -155,6 +202,7 @@ export function ProjectEditor() {
|
|||||||
saved.targetVisibility === "UNLISTED" ? "UNLISTED" : "PUBLIC",
|
saved.targetVisibility === "UNLISTED" ? "UNLISTED" : "PUBLIC",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`);
|
||||||
reload();
|
reload();
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(managementFailureMessage(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;
|
if (pending || !project) return;
|
||||||
|
const title = newActivity.title.trim();
|
||||||
|
if (!title) {
|
||||||
|
setError("활동에는 제목이 필요합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
const related = records.find((entry) => entry.id === newActivity.relatedId);
|
||||||
await managementGateway.createProjectActivity(project.id, {
|
await managementGateway.createProjectActivity(project.id, {
|
||||||
expectedProjectVersion: project.version,
|
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",
|
activityType: "MILESTONE_REACHED",
|
||||||
title: "새 활동",
|
title: "",
|
||||||
summary: "",
|
summary: "",
|
||||||
visibility: "PUBLIC",
|
visibility: "PUBLIC",
|
||||||
occurredAt: new Date().toISOString(),
|
relatedId: "",
|
||||||
|
occurredAt: toLocalInput(new Date().toISOString()),
|
||||||
});
|
});
|
||||||
setRequestAnnouncement("활동을 추가했습니다.");
|
setRequestAnnouncement(`활동 ${title} 을(를) 추가했습니다.`);
|
||||||
reload();
|
reload();
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(managementFailureMessage(failure, "활동을 추가하지 못했습니다."));
|
setError(managementFailureMessage(failure, "활동을 추가하지 못했습니다."));
|
||||||
@@ -200,9 +269,7 @@ export function ProjectEditor() {
|
|||||||
visibility: (changes.visibility ?? activity.visibility) === "PUBLIC" ? "PUBLIC" : "PRIVATE",
|
visibility: (changes.visibility ?? activity.visibility) === "PUBLIC" ? "PUBLIC" : "PRIVATE",
|
||||||
occurredAt: changes.occurredAt ?? activity.occurredAt,
|
occurredAt: changes.occurredAt ?? activity.occurredAt,
|
||||||
});
|
});
|
||||||
setActivities((current) =>
|
setActivities((current) => current.map((item) => (item.id === saved.id ? saved : item)));
|
||||||
current.map((item) => (item.id === saved.id ? saved : item)),
|
|
||||||
);
|
|
||||||
setRequestAnnouncement("활동을 저장했습니다.");
|
setRequestAnnouncement("활동을 저장했습니다.");
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(managementFailureMessage(failure, "활동을 저장하지 못했습니다."));
|
setError(managementFailureMessage(failure, "활동을 저장하지 못했습니다."));
|
||||||
@@ -218,7 +285,7 @@ export function ProjectEditor() {
|
|||||||
try {
|
try {
|
||||||
await managementGateway.deleteProjectActivity(project.id, activity.id, activity.version);
|
await managementGateway.deleteProjectActivity(project.id, activity.id, activity.version);
|
||||||
setActivities((current) => current.filter((item) => item.id !== activity.id));
|
setActivities((current) => current.filter((item) => item.id !== activity.id));
|
||||||
setRequestAnnouncement("활동을 삭제했습니다.");
|
setRequestAnnouncement(`활동 ${activity.title} 을(를) 삭제했습니다.`);
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(managementFailureMessage(failure, "활동을 삭제하지 못했습니다."));
|
setError(managementFailureMessage(failure, "활동을 삭제하지 못했습니다."));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -226,40 +293,15 @@ export function ProjectEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const field = (
|
const published = project ? project.targetVisibility !== "PRIVATE" : false;
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="studio-page studio-documents-page">
|
<div className="studio-page studio-editor-page">
|
||||||
<header className="studio-page-top">
|
<header className="studio-page-top">
|
||||||
<div className="studio-page-heading">
|
<div className="studio-page-heading">
|
||||||
<p className="studio-eyebrow">PROJECT</p>
|
<p className="studio-eyebrow">
|
||||||
|
PROJECT · VERSION {project?.version ?? "—"}
|
||||||
|
</p>
|
||||||
<h1>{project?.name ?? "프로젝트"}</h1>
|
<h1>{project?.name ?? "프로젝트"}</h1>
|
||||||
<p>
|
<p>
|
||||||
공개 프로젝트 화면과 프로필의 “현재 프로젝트”, 홈의 집중 카드가 여기 값을
|
공개 프로젝트 화면과 프로필의 “현재 프로젝트”, 홈의 집중 카드가 여기 값을
|
||||||
@@ -267,7 +309,7 @@ export function ProjectEditor() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<GuardedStudioLink className="studio-primary-action" href="/studio/taxonomy">
|
<GuardedStudioLink className="studio-primary-action" href="/studio/taxonomy">
|
||||||
목록으로
|
주제·프로젝트로
|
||||||
</GuardedStudioLink>
|
</GuardedStudioLink>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -282,120 +324,296 @@ export function ProjectEditor() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{project ? (
|
{project && draft ? (
|
||||||
<>
|
<>
|
||||||
<form className="studio-home-focus-form" onSubmit={save}>
|
<form onSubmit={save}>
|
||||||
{field("name", "이름")}
|
<section
|
||||||
{field("slug", "slug", "공개 주소가 됩니다. 비우면 이름에서 만듭니다.")}
|
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">
|
<section
|
||||||
<label htmlFor="project-phase">단계</label>
|
className="studio-editor-section"
|
||||||
<select
|
aria-labelledby="studio-project-topics-title"
|
||||||
className="studio-control"
|
>
|
||||||
id="project-phase"
|
<div className="studio-editor-section-heading">
|
||||||
value={draft.phase ?? "RESEARCH"}
|
<p className="studio-eyebrow">TOPICS</p>
|
||||||
onChange={(event) => set("phase", event.target.value)}
|
<h2 id="studio-project-topics-title">주요 주제</h2>
|
||||||
>
|
<p>프로젝트 화면 상단의 “주요 주제”가 고른 순서대로 나옵니다.</p>
|
||||||
{PHASES.map(([value, label]) => (
|
</div>
|
||||||
<option key={value} value={value}>
|
{topics.length === 0 ? (
|
||||||
{label} ({value})
|
<p className="studio-empty-inline">
|
||||||
</option>
|
아직 주제가 없습니다. 주제·프로젝트 화면에서 먼저 만듭니다.
|
||||||
))}
|
</p>
|
||||||
</select>
|
) : (
|
||||||
</div>
|
<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", "한 줄 목적", "프로젝트 목록과 프로필 카드에 나옵니다.")}
|
<section
|
||||||
{field("currentObjective", "현재 목표", "홈의 집중 카드에 나옵니다.")}
|
className="studio-editor-section"
|
||||||
{field("nextStep", "다음 작업", "홈의 집중 카드에 나옵니다.")}
|
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">
|
<div className="studio-editor-footer">
|
||||||
<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">
|
|
||||||
<button className="studio-primary-button" type="submit" disabled={pending}>
|
<button className="studio-primary-button" type="submit" disabled={pending}>
|
||||||
{pending ? "저장하는 중" : "저장"}
|
{pending ? "저장하는 중" : "저장"}
|
||||||
</button>
|
</button>
|
||||||
|
<p className="studio-field-note">
|
||||||
|
{published
|
||||||
|
? "공개된 프로젝트입니다. 저장하면 공개 화면도 함께 갱신됩니다."
|
||||||
|
: "아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 공개 화면에 나옵니다."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section className="studio-work-section" aria-labelledby="project-activity-title">
|
<section className="studio-editor-section" aria-labelledby="studio-project-activity-title">
|
||||||
<div className="studio-section-title">
|
<div className="studio-editor-section-heading">
|
||||||
<h2 id="project-activity-title">활동</h2>
|
<p className="studio-eyebrow">ACTIVITY</p>
|
||||||
<button
|
<h2 id="studio-project-activity-title">활동</h2>
|
||||||
className="studio-text-action"
|
<p>공개로 둔 줄만 프로젝트 화면의 타임라인에 나옵니다.</p>
|
||||||
type="button"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => void addActivity()}
|
|
||||||
>
|
|
||||||
활동 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="studio-field-note">
|
|
||||||
공개로 둔 줄만 프로젝트 화면의 타임라인에 나옵니다.
|
<form className="studio-field-grid" onSubmit={addActivity}>
|
||||||
</p>
|
<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>
|
||||||
|
공개 화면의 “이 활동 위치 열기”가 이 기록으로 갑니다.
|
||||||
|
</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 ? (
|
{activities.length === 0 ? (
|
||||||
<p className="studio-empty-inline">아직 활동이 없습니다.</p>
|
<p className="studio-empty-inline">아직 활동이 없습니다.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="studio-document-list">
|
<div className="studio-document-list">
|
||||||
{activities.map((activity) => (
|
{activities.map((activity) => (
|
||||||
<article className="studio-document-row" key={activity.id}>
|
<article className="studio-document-row" key={activity.id}>
|
||||||
<p className="studio-row-label">
|
<p className="studio-row-label">{activityTypeLabel(activity.activityType)}</p>
|
||||||
{ACTIVITY_TYPES.find(([value]) => value === activity.activityType)?.[1] ??
|
|
||||||
activity.activityType}
|
|
||||||
</p>
|
|
||||||
<div className="studio-document-title">
|
<div className="studio-document-title">
|
||||||
<div className="studio-field">
|
<h2>{activity.title}</h2>
|
||||||
<label htmlFor={`activity-title-${activity.id}`}>제목</label>
|
<p>
|
||||||
<input
|
{activity.summary || "요약 없음"}
|
||||||
className="studio-control"
|
{activity.relatedResourceId ? " · 기록 연결됨" : " · 연결된 기록 없음"}
|
||||||
id={`activity-title-${activity.id}`}
|
</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
<div>
|
<div>
|
||||||
<dt>일시</dt>
|
<dt>일시</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<input
|
<input
|
||||||
className="studio-control"
|
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
aria-label="활동 일시"
|
aria-label={`${activity.title} 일시`}
|
||||||
defaultValue={toLocalInput(activity.occurredAt)}
|
defaultValue={toLocalInput(activity.occurredAt)}
|
||||||
onBlur={(event) =>
|
onBlur={(event) =>
|
||||||
void saveActivity(activity, {
|
void saveActivity(activity, {
|
||||||
occurredAt: fromLocalInput(event.target.value),
|
occurredAt: fromLocalInput(event.currentTarget.value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -405,11 +623,12 @@ export function ProjectEditor() {
|
|||||||
<dt>노출</dt>
|
<dt>노출</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<select
|
<select
|
||||||
className="studio-control"
|
aria-label={`${activity.title} 노출`}
|
||||||
aria-label="활동 노출"
|
|
||||||
value={activity.visibility}
|
value={activity.visibility}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
void saveActivity(activity, { visibility: event.target.value })
|
void saveActivity(activity, {
|
||||||
|
visibility: event.currentTarget.value,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="PUBLIC">공개</option>
|
<option value="PUBLIC">공개</option>
|
||||||
@@ -417,22 +636,24 @@ export function ProjectEditor() {
|
|||||||
</select>
|
</select>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>기록</dt>
|
||||||
|
<dd>{activity.origin === "MANUAL" ? "손으로 적음" : "자동 기록"}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="studio-row-actions">
|
<button
|
||||||
<button
|
className="studio-secondary-button"
|
||||||
className="studio-secondary-button"
|
type="button"
|
||||||
type="button"
|
disabled={pending || activity.origin !== "MANUAL"}
|
||||||
disabled={pending || activity.origin !== "MANUAL"}
|
title={
|
||||||
title={
|
activity.origin === "MANUAL"
|
||||||
activity.origin === "MANUAL"
|
? undefined
|
||||||
? undefined
|
: "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다."
|
||||||
: "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다."
|
}
|
||||||
}
|
onClick={() => void removeActivity(activity)}
|
||||||
onClick={() => void removeActivity(activity)}
|
>
|
||||||
>
|
삭제
|
||||||
삭제
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -306,35 +306,47 @@ export function ReleaseManager() {
|
|||||||
className="studio-document-row"
|
className="studio-document-row"
|
||||||
aria-current={selectedId === release.id ? "true" : undefined}
|
aria-current={selectedId === release.id ? "true" : undefined}
|
||||||
>
|
>
|
||||||
|
{/*
|
||||||
|
첫 칸의 유형 라벨은 목록 격자의 첫 열이다. 빠뜨리면 제목이 그 자리로 당겨져
|
||||||
|
작업본·주제 목록과 줄이 맞지 않는다.
|
||||||
|
*/}
|
||||||
|
<p className="studio-row-label">RELEASE</p>
|
||||||
<div className="studio-document-title">
|
<div className="studio-document-title">
|
||||||
<h2>{release.title}</h2>
|
<h2>{release.title}</h2>
|
||||||
<p>
|
<p>{release.summary || "요약 없음"}</p>
|
||||||
{release.versionLabel}
|
|
||||||
{release.releasedOn ? ` · ${release.releasedOn}` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>버전</dt>
|
||||||
|
<dd>{release.versionLabel}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>상태</dt>
|
<dt>상태</dt>
|
||||||
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
|
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>공개일</dt>
|
||||||
|
<dd>{release.releasedOn || "미정"}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<button
|
<div className="studio-row-actions">
|
||||||
className="studio-secondary-button"
|
<button
|
||||||
type="button"
|
className="studio-secondary-button"
|
||||||
disabled={pending}
|
type="button"
|
||||||
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
|
disabled={pending}
|
||||||
>
|
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
|
||||||
{selectedId === release.id ? "닫기" : "편집"}
|
>
|
||||||
</button>
|
{selectedId === release.id ? "닫기" : "편집"}
|
||||||
<button
|
</button>
|
||||||
className="studio-secondary-button"
|
<button
|
||||||
type="button"
|
className="studio-secondary-button"
|
||||||
disabled={pending}
|
type="button"
|
||||||
onClick={() => void remove(release)}
|
disabled={pending}
|
||||||
>
|
onClick={() => void remove(release)}
|
||||||
삭제
|
>
|
||||||
</button>
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -342,8 +354,12 @@ export function ReleaseManager() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{draft === null ? null : (
|
{draft === null ? null : (
|
||||||
<section aria-label="릴리즈 편집">
|
<section className="studio-editor-section" aria-labelledby="studio-release-edit-title">
|
||||||
<h2 className="studio-section-title">{draft.title || "릴리즈"} 편집</h2>
|
<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">
|
<div className="studio-field-grid">
|
||||||
<label className="studio-field">
|
<label className="studio-field">
|
||||||
<span>버전</span>
|
<span>버전</span>
|
||||||
@@ -389,10 +405,8 @@ export function ReleaseManager() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset className="studio-field-grid">
|
<fieldset className="studio-checkbox-set">
|
||||||
<legend className="studio-field">
|
<legend>변경 유형</legend>
|
||||||
<span>변경 유형</span>
|
|
||||||
</legend>
|
|
||||||
{CHANGE_TYPES.map((changeType) => (
|
{CHANGE_TYPES.map((changeType) => (
|
||||||
<label key={changeType.value} className="studio-field studio-field--checkbox">
|
<label key={changeType.value} className="studio-field studio-field--checkbox">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ import { useStudio } from "../use-studio.ts";
|
|||||||
* <p>새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의
|
* <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() {
|
export function TaxonomyManager() {
|
||||||
const { managementGateway, setRequestAnnouncement } = useStudio();
|
const { managementGateway, setRequestAnnouncement } = useStudio();
|
||||||
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
|
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
|
||||||
@@ -311,23 +328,34 @@ export function TaxonomyManager() {
|
|||||||
<dl>
|
<dl>
|
||||||
<div>
|
<div>
|
||||||
<dt>단계</dt>
|
<dt>단계</dt>
|
||||||
<dd>{project.phase}</dd>
|
<dd>{phaseLabel[project.phase] ?? project.phase}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>공개</dt>
|
<dt>공개</dt>
|
||||||
|
{/*
|
||||||
|
"게시됨" 이 아니라 어디까지 보이는지를 적는다 — UNLISTED 는 주소를 아는
|
||||||
|
사람만 닿고 목록에는 없다. 둘을 같은 말로 적으면 왜 목록에 안 나오는지
|
||||||
|
설명할 방법이 없다.
|
||||||
|
*/}
|
||||||
|
<dd>{visibilityLabel[project.targetVisibility] ?? project.targetVisibility}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>다음</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{project.targetVisibility === "PRIVATE" ? "비공개" : "공개됨"}
|
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
|
||||||
|
내용 편집
|
||||||
|
</GuardedStudioLink>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="studio-row-actions">
|
<div className="studio-row-actions">
|
||||||
<button
|
<button
|
||||||
className="studio-primary-button"
|
className="studio-secondary-button"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onClick={() => void togglePublish(project)}
|
onClick={() => void togglePublish(project)}
|
||||||
>
|
>
|
||||||
{project.targetVisibility === "PRIVATE" ? "게시하기" : "게시 취소"}
|
{project.targetVisibility === "PRIVATE" ? "게시" : "게시 취소"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="studio-secondary-button"
|
className="studio-secondary-button"
|
||||||
|
|||||||
@@ -36,6 +36,31 @@
|
|||||||
.studio-app .studio-ordered-item textarea { min-height: 112px; resize: vertical; }
|
.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 .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-ordered-list,
|
||||||
.studio-app .studio-resolution-fields { min-width: 0; margin: 34px 0 0; padding: 0; border: 0; }
|
.studio-app .studio-resolution-fields { min-width: 0; margin: 34px 0 0; padding: 0; border: 0; }
|
||||||
.studio-app .studio-ordered-list legend,
|
.studio-app .studio-ordered-list legend,
|
||||||
|
|||||||
@@ -113,8 +113,6 @@
|
|||||||
*/
|
*/
|
||||||
.studio-app .studio-row-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; }
|
.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-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-primary-button,
|
||||||
.studio-app .studio-row-actions .studio-secondary-button { margin: 0; }
|
.studio-app .studio-row-actions .studio-secondary-button { margin: 0; }
|
||||||
|
|||||||
@@ -87,11 +87,14 @@ const PLATFORM_KO_MESSAGES = {
|
|||||||
"route.auth.integration.description":
|
"route.auth.integration.description":
|
||||||
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
|
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
|
||||||
"route.auth.recovering.title": "세션을 복구하고 있습니다.",
|
"route.auth.recovering.title": "세션을 복구하고 있습니다.",
|
||||||
"route.auth.required.title": "세션이 필요합니다.",
|
"route.auth.required.title": "Studio에 로그인해 주세요.",
|
||||||
"route.auth.recovering.description":
|
"route.auth.recovering.description":
|
||||||
"기존 세션 확인을 계속하려면 복구를 실행하세요.",
|
"이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.",
|
||||||
|
// 예전 문구("인증 연동 지점을 확인하기 위한 보호 라우트")는 이 뼈대를 만들던 사람에게 하는
|
||||||
|
// 말이었다. 실제로 이 화면을 보는 사람은 글을 쓰러 온 작성자이고, 알아야 할 것은 무엇을
|
||||||
|
// 누르면 되는지다.
|
||||||
"route.auth.required.description":
|
"route.auth.required.description":
|
||||||
"이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.",
|
"기록을 쓰고 게시하려면 로그인이 필요합니다. 로그인하면 방금 열려던 화면으로 돌아옵니다.",
|
||||||
"route.documentTitle": "{title} · {appName}",
|
"route.documentTitle": "{title} · {appName}",
|
||||||
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
|
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
|
||||||
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
|
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
|
||||||
@@ -271,11 +274,11 @@ const PLATFORM_EN_MESSAGES = {
|
|||||||
"route.auth.integration.description":
|
"route.auth.integration.description":
|
||||||
"This protected route is available after an external authentication owner is connected.",
|
"This protected route is available after an external authentication owner is connected.",
|
||||||
"route.auth.recovering.title": "Recovering the session.",
|
"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":
|
"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":
|
"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}",
|
"route.documentTitle": "{title} · {appName}",
|
||||||
"chunk.checking": "Checking the new release information.",
|
"chunk.checking": "Checking the new release information.",
|
||||||
"chunk.reloadOnce": "Switching to the new version once.",
|
"chunk.reloadOnce": "Switching to the new version once.",
|
||||||
|
|||||||
Reference in New Issue
Block a user