Files
tech-log-frontend/src/features/tech-log/presentation/studio/components/taxonomy-manager.tsx
T
DongHyeonka 5cffe30200 fix: derive a topic slug that survives a Korean name
Creating a topic failed intermittently — "a topic with that slug already
exists" — and worked when the author retried with a different name. The rule
was never intermittent, only invisible: the slug kept `[a-z0-9]` and dropped
everything else, so a Korean name contributed nothing. Whatever Latin word or
number happened to be in it became the entire slug.

Two ways that goes wrong, and the author hit both. `인증` reduced to an empty
string, which the form refused before a request was ever sent. `Redis 캐시` and
`Redis 클러스터` both reduced to `redis`, so the second one collided with the
first — a real conflict, reported honestly, about a slug the author never chose
and could not see.

Hangul is now romanized rather than discarded. Syllables decompose
arithmetically into initial, medial and final jamo, so this needs no table and
is deterministic: `백엔드 아키텍처` becomes `baekendeu-akitekcheo`. Only the
jamo mapping from Revised Romanization is applied — the sound-change rules are
deliberately left out, because a slug is read, not pronounced, and those rules
would make one name produce different slugs in different contexts.

The output keeps the shape document slugs already use
(`^[a-z0-9]+(?:-[a-z0-9]+)*$`), so the repository has one slug rule rather than
two, and the tests assert exactly that.
2026-08-21 14:56:03 +09:00

274 lines
9.5 KiB
TypeScript

import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
import { slugFromName } from "./slug-from-name.ts";
import { useStudio } from "../use-studio.ts";
/**
* 주제와 프로젝트 관리.
*
* <p>이 화면이 존재하는 이유는 문서 발행이 주제를 요구하는데 주제를 만들 곳이 없었기 때문이다.
* 그래서 범위를 목록·생성·삭제로 끊었다 — 편집(이름 변경, 단계 전환, 공개 범위)은 계약에 있고
* 백엔드도 구현돼 있으나, 그 화면은 별도 설계가 필요하다.
*
* <p>새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의
* 나머지와 같은 간격·타이포·색을 그대로 따른다.
*/
export function TaxonomyManager() {
const { managementGateway, setRequestAnnouncement } = useStudio();
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
const [projects, setProjects] = useState<ProjectIndexItem[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const [generation, setGeneration] = useState(0);
const [topicName, setTopicName] = useState("");
const [topicSlug, setTopicSlug] = useState("");
const [projectTitle, setProjectTitle] = useState("");
const reload = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError("");
void Promise.all([managementGateway.listTopics(), managementGateway.listProjects(0, 50)]).then(
([topicList, projectPage]) => {
if (cancelled) return;
setTopics(topicList);
setProjects(projectPage.items ?? []);
setLoading(false);
},
() => {
if (cancelled) return;
setError("주제와 프로젝트를 불러오지 못했습니다.");
setLoading(false);
},
);
return () => {
cancelled = true;
};
}, [managementGateway, generation]);
/**
* slug 를 비워 두면 이름에서 만든다. 한글 이름이 흔한데 이전 규칙은 한글을 전부 버려서, 이름에
* 섞인 영문·숫자만 남았다 — `인증` 은 빈 slug 가 되고 `Redis 캐시` 와 `Redis 클러스터` 는 둘 다
* `redis` 가 됐다. 지금은 로마자로 옮긴다 ({@link slugFromName}).
*/
const slugify = slugFromName;
const submitTopic = async (event: FormEvent) => {
event.preventDefault();
if (pending) return;
const name = topicName.trim();
const slug = slugify(topicSlug || topicName);
if (!name || !slug) {
setError("주제 이름과 slug 를 입력해 주세요. slug 는 영문·숫자·하이픈만 가능합니다.");
return;
}
setPending(true);
setError("");
try {
await managementGateway.createTopic({ name, slug } as TopicEdit);
setTopicName("");
setTopicSlug("");
setRequestAnnouncement(`주제 ${name} 을(를) 만들었습니다.`);
reload();
} catch {
setError("주제를 만들지 못했습니다. 같은 이름이나 slug 가 이미 있을 수 있습니다.");
} finally {
setPending(false);
}
};
const submitProject = async (event: FormEvent) => {
event.preventDefault();
if (pending) return;
const title = projectTitle.trim();
if (!title) {
setError("프로젝트 이름을 입력해 주세요.");
return;
}
setPending(true);
setError("");
try {
await managementGateway.createProject(title);
setProjectTitle("");
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
reload();
} catch {
setError("프로젝트를 만들지 못했습니다.");
} finally {
setPending(false);
}
};
const removeTopic = async (topic: TopicEdit) => {
if (pending || topic.id === undefined || topic.version === undefined) return;
setPending(true);
setError("");
try {
await managementGateway.deleteTopic(topic.id, topic.version);
setRequestAnnouncement(`주제 ${topic.name} 을(를) 삭제했습니다.`);
reload();
} catch {
setError("주제를 삭제하지 못했습니다. 이 주제를 쓰는 기록이 있을 수 있습니다.");
} finally {
setPending(false);
}
};
const removeProject = async (project: ProjectIndexItem) => {
if (pending) return;
setPending(true);
setError("");
try {
await managementGateway.deleteProject(project.id, project.version);
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 삭제했습니다.`);
reload();
} catch {
setError("프로젝트를 삭제하지 못했습니다. 연결된 기록이 있을 수 있습니다.");
} finally {
setPending(false);
}
};
return (
<div className="studio-page studio-documents-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">TAXONOMY</p>
<h1>주제와 프로젝트</h1>
<p>문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다.</p>
</div>
</header>
<section className="studio-document-tools" aria-label="주제 만들기">
<form onSubmit={submitTopic}>
<label htmlFor="taxonomy-topic-name"> 주제</label>
<div>
<input
id="taxonomy-topic-name"
type="text"
value={topicName}
placeholder="주제 이름"
onChange={(event) => setTopicName(event.target.value)}
/>
<input
type="text"
value={topicSlug}
placeholder="slug (비우면 이름에서 생성)"
aria-label="주제 slug"
onChange={(event) => setTopicSlug(event.target.value)}
/>
<button type="submit" disabled={pending}>
추가
</button>
</div>
</form>
<form onSubmit={submitProject}>
<label htmlFor="taxonomy-project-title"> 프로젝트</label>
<div>
<input
id="taxonomy-project-title"
type="text"
value={projectTitle}
placeholder="프로젝트 이름"
onChange={(event) => setProjectTitle(event.target.value)}
/>
<button type="submit" disabled={pending}>
추가
</button>
</div>
</form>
</section>
{loading ? (
<p className="studio-loading" role="status">
주제와 프로젝트를 불러오는 중입니다.
</p>
) : null}
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{!loading && topics ? (
<>
<p className="studio-result-count">{topics.length}개의 주제</p>
{topics.length ? (
<div className="studio-document-list">
{topics.map((topic) => (
<article className="studio-document-row" key={topic.id ?? topic.slug}>
<p className="studio-row-label">TOPIC</p>
<div className="studio-document-title">
<h2>{topic.name}</h2>
<p>{topic.slug}</p>
</div>
<dl>
<div>
<dt>상태</dt>
<dd>{topic.status === "ARCHIVED" ? "보관" : "사용 중"}</dd>
</div>
</dl>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void removeTopic(topic)}
>
삭제
</button>
</article>
))}
</div>
) : (
<p className="studio-empty">아직 주제가 없습니다. 위에서 하나 만들어 주세요.</p>
)}
</>
) : null}
{!loading && projects ? (
<>
<p className="studio-result-count">{projects.length}개의 프로젝트</p>
{projects.length ? (
<div className="studio-document-list">
{projects.map((project) => (
<article className="studio-document-row" key={project.id}>
<p className="studio-row-label">PROJECT</p>
<div className="studio-document-title">
<h2>{project.name}</h2>
<p>{project.currentObjective ?? "목표 미지정"}</p>
</div>
<dl>
<div>
<dt>단계</dt>
<dd>{project.phase}</dd>
</div>
<div>
<dt>공개</dt>
<dd>{project.targetVisibility}</dd>
</div>
</dl>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void removeProject(project)}
>
삭제
</button>
</article>
))}
</div>
) : (
<p className="studio-empty">아직 프로젝트가 없습니다.</p>
)}
</>
) : null}
</div>
);
}