feat: 프로젝트 활동을 게시가 남기는 로그로 바꾼다

활동을 Studio 에서 손으로 적어야 했다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린
사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 게시가 곧 사건이므로
게시가 기록한다.

공개 화면에서는 링크를 없앤다. 각 줄에 그 기록으로 가는 링크가 있으면 같은 글에 닿는
길이 둘이 되고, 읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다.
활동은 날짜와 무엇을 올렸는지만 말하고, 글을 읽는 자리는 기록 화면 하나로 둔다.

Studio 의 활동 칸도 읽기 전용 로그가 된다 — 만들기·고치기·지우기 폼을 걷어낸다.

맥락에서 경계와 시스템 개요 칸을 뺀다. 저장은 되지만 공개 화면 어디에도 나오지 않는다
— 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 어딘가 실릴 것처럼 보이는
칸이었고 실제로는 아무도 읽지 않았다. 열의 데이터는 남겨 두고 서버 값을 그대로
왕복시킨다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
DongHyeonka
2026-08-24 15:33:43 +09:00
co-authored by Claude Opus 5
parent ab67eb912c
commit a7fe069a6d
2 changed files with 57 additions and 280 deletions
@@ -1,5 +1,3 @@
import { Link } from "react-router-dom";
import { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
@@ -21,28 +19,33 @@ export function ProjectActivityPage() {
const { project, activity } = view.data; const { project, activity } = view.data;
if (!project) return <RegisteredNotFoundRoute />; if (!project) return <RegisteredNotFoundRoute />;
/*
활동은 로그다 — 언제 무엇을 올렸는지만 적는다.
예전에는 각 줄에 그 기록으로 가는 링크가 있었다. 그러면 같은 글에 닿는 길이 둘이 되고,
읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다. 글을 읽는 자리는
기록 화면 하나로 둔다.
*/
return ( return (
<main id="main-content" className="shell project-page"> <main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 활동`} /> <ProjectPageHeader project={project} title={`${project.title} 활동`} />
<ol className="project-activity-list"> {activity.length === 0 ? (
{activity.map((item) => ( <p className="public-empty-note"> .</p>
<li key={item.id}> ) : (
<article id={item.id}> <ol className="project-activity-list">
<div> {activity.map((item) => (
<span>{item.type}</span> <li key={item.id}>
<time dateTime={item.dateTime}>{item.date}</time> <article id={item.id}>
</div> <div>
<h2>{item.title}</h2> <span>{item.type}</span>
<p>{item.summary}</p> <time dateTime={item.dateTime}>{item.date}</time>
<Link to={item.recordPath ?? item.path}> </div>
{item.recordPath <h2>{item.title}</h2>
? "연결된 공개 기록 읽기" </article>
: "이 활동 위치 열기"} </li>
</Link> ))}
</article> </ol>
</li> )}
))}
</ol>
</main> </main>
); );
} }
@@ -114,14 +114,6 @@ export function ProjectEditor() {
const [records, setRecords] = useState<CatalogEntry[]>([]); const [records, setRecords] = useState<CatalogEntry[]>([]);
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]); const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
const [draft, setDraft] = useState<Draft | null>(null); 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 [pending, setPending] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [generation, setGeneration] = useState(0); const [generation, setGeneration] = useState(0);
@@ -218,88 +210,6 @@ export function ProjectEditor() {
} }
}; };
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: "",
summary: "",
visibility: "PUBLIC",
relatedId: "",
occurredAt: toLocalInput(new Date().toISOString()),
});
setRequestAnnouncement(`활동 ${title} 을(를) 추가했습니다.`);
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "활동을 추가하지 못했습니다."));
} finally {
setPending(false);
}
};
const saveActivity = async (
activity: ProjectActivityResponse,
changes: Partial<Pick<ProjectActivityResponse, "title" | "summary" | "visibility" | "occurredAt">>,
) => {
if (pending || !project) return;
setPending(true);
setError("");
try {
const saved = await managementGateway.updateProjectActivity(project.id, activity.id, {
expectedVersion: activity.version,
title: changes.title ?? activity.title,
summary: changes.summary ?? activity.summary ?? "",
visibility: (changes.visibility ?? activity.visibility) === "PUBLIC" ? "PUBLIC" : "PRIVATE",
occurredAt: changes.occurredAt ?? activity.occurredAt,
});
setActivities((current) => current.map((item) => (item.id === saved.id ? saved : item)));
setRequestAnnouncement("활동을 저장했습니다.");
} catch (failure) {
setError(managementFailureMessage(failure, "활동을 저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const removeActivity = async (activity: ProjectActivityResponse) => {
if (pending || !project) return;
setPending(true);
setError("");
try {
await managementGateway.deleteProjectActivity(project.id, activity.id, activity.version);
setActivities((current) => current.filter((item) => item.id !== activity.id));
setRequestAnnouncement(`활동 ${activity.title} 을(를) 삭제했습니다.`);
} catch (failure) {
setError(managementFailureMessage(failure, "활동을 삭제하지 못했습니다."));
} finally {
setPending(false);
}
};
const published = project ? project.targetVisibility !== "PRIVATE" : false; const published = project ? project.targetVisibility !== "PRIVATE" : false;
return ( return (
@@ -445,9 +355,19 @@ export function ProjectEditor() {
> >
<div className="studio-editor-section-heading"> <div className="studio-editor-section-heading">
<p className="studio-eyebrow">NARRATIVE</p> <p className="studio-eyebrow">NARRATIVE</p>
<h2 id="studio-project-narrative-title"></h2> <h2 id="studio-project-narrative-title"></h2>
<p> . .</p> <p>
&ldquo; &rdquo; .
</p>
</div> </div>
{/*
경계와 시스템 개요 칸이 여기 함께 있었다. 저장은 되지만 공개 화면 어디에도
나오지 않는다 — 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 쓴 글이
어딘가 실릴 것처럼 보이는 칸이었고, 실제로는 아무도 읽지 않았다.
열의 데이터는 남겨 둔다(계약이 요구하고, 이미 적어 둔 글이 있을 수 있다). 저장할
때 서버가 가진 값을 그대로 돌려보내므로 지워지지 않는다.
*/}
<div className="studio-field-grid"> <div className="studio-field-grid">
<label className="studio-field studio-field--wide"> <label className="studio-field studio-field--wide">
<span></span> <span></span>
@@ -456,24 +376,7 @@ export function ProjectEditor() {
value={draft.purposeMarkdown} value={draft.purposeMarkdown}
onChange={(event) => update({ purposeMarkdown: event.currentTarget.value })} onChange={(event) => update({ purposeMarkdown: event.currentTarget.value })}
/> />
</label> <small> .</small>
<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> </label>
</div> </div>
</section> </section>
@@ -490,117 +393,22 @@ export function ProjectEditor() {
</div> </div>
</form> </form>
<section className="studio-editor-section" aria-labelledby="studio-project-activity-title"> <section
className="studio-editor-section"
aria-labelledby="studio-project-activity-title"
>
<div className="studio-editor-section-heading"> <div className="studio-editor-section-heading">
<p className="studio-eyebrow">ACTIVITY</p> <p className="studio-eyebrow">ACTIVITY</p>
<h2 id="studio-project-activity-title"></h2> <h2 id="studio-project-activity-title"></h2>
<p> .</p> <p>
. .
</p>
</div> </div>
<form className="studio-field-grid" onSubmit={addActivity}>
<label className="studio-field">
<span></span>
<select
value={newActivity.activityType}
onChange={(event) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, activityType: 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) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, occurredAt: value }));
}}
/>
</label>
<label className="studio-field studio-field--wide">
<span></span>
<input
value={newActivity.title}
maxLength={200}
onChange={(event) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, title: value }));
}}
/>
</label>
<label className="studio-field studio-field--wide">
<span></span>
<input
value={newActivity.summary}
maxLength={300}
onChange={(event) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, summary: value }));
}}
/>
</label>
<label className="studio-field">
<span> </span>
<select
value={newActivity.relatedId}
onChange={(event) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, relatedId: 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) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, visibility: 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) => (
@@ -609,61 +417,27 @@ export function ProjectEditor() {
<div className="studio-document-title"> <div className="studio-document-title">
<h2>{activity.title}</h2> <h2>{activity.title}</h2>
<p> <p>
{activity.summary || "요약 없음"} {activity.visibility === "PUBLIC"
{activity.relatedResourceId ? " · 기록 연결됨" : " · 연결된 기록 없음"} ? "공개 타임라인에 나옵니다"
: "비공개 — 타임라인에 나오지 않습니다"}
</p> </p>
</div> </div>
<dl> <dl>
<div> <div>
<dt></dt> <dt></dt>
<dd> <dd>
<input <time dateTime={activity.occurredAt}>
type="datetime-local" {new Date(activity.occurredAt).toLocaleDateString("ko-KR", {
aria-label={`${activity.title} 일시`} timeZone: "Asia/Seoul",
defaultValue={toLocalInput(activity.occurredAt)} })}
onBlur={(event) => </time>
void saveActivity(activity, {
occurredAt: fromLocalInput(event.currentTarget.value),
})
}
/>
</dd>
</div>
<div>
<dt></dt>
<dd>
<select
aria-label={`${activity.title} 노출`}
value={activity.visibility}
onChange={(event) =>
void saveActivity(activity, {
visibility: event.currentTarget.value,
})
}
>
<option value="PUBLIC"></option>
<option value="PRIVATE"></option>
</select>
</dd> </dd>
</div> </div>
<div> <div>
<dt></dt> <dt></dt>
<dd>{activity.origin === "MANUAL" ? "손으로 적음" : "자동 기록"}</dd> <dd>{activity.origin === "AUTO" ? "게시가 남김" : "손으로 적음"}</dd>
</div> </div>
</dl> </dl>
<button
className="studio-secondary-button"
type="button"
disabled={pending || activity.origin !== "MANUAL"}
title={
activity.origin === "MANUAL"
? undefined
: "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다."
}
onClick={() => void removeActivity(activity)}
>
</button>
</article> </article>
))} ))}
</div> </div>