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"; /** * 주제와 프로젝트 관리. * *

이 화면이 존재하는 이유는 문서 발행이 주제를 요구하는데 주제를 만들 곳이 없었기 때문이다. * 그래서 범위를 목록·생성·삭제로 끊었다 — 편집(이름 변경, 단계 전환, 공개 범위)은 계약에 있고 * 백엔드도 구현돼 있으나, 그 화면은 별도 설계가 필요하다. * *

새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의 * 나머지와 같은 간격·타이포·색을 그대로 따른다. */ export function TaxonomyManager() { const { managementGateway, setRequestAnnouncement } = useStudio(); const [topics, setTopics] = useState(null); const [projects, setProjects] = useState(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 (

TAXONOMY

주제와 프로젝트

문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다.

setTopicName(event.target.value)} /> setTopicSlug(event.target.value)} />
setProjectTitle(event.target.value)} />
{loading ? (

주제와 프로젝트를 불러오는 중입니다.

) : null} {error ? (

{error}

) : null} {!loading && topics ? ( <>

{topics.length}개의 주제

{topics.length ? (
{topics.map((topic) => (

TOPIC

{topic.name}

{topic.slug}

상태
{topic.status === "ARCHIVED" ? "보관" : "사용 중"}
))}
) : (

아직 주제가 없습니다. 위에서 하나 만들어 주세요.

)} ) : null} {!loading && projects ? ( <>

{projects.length}개의 프로젝트

{projects.length ? (
{projects.map((project) => (

PROJECT

{project.name}

{project.currentObjective ?? "목표 미지정"}

단계
{project.phase}
공개
{project.targetVisibility}
))}
) : (

아직 프로젝트가 없습니다.

)} ) : null}
); }