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.
274 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|