diff --git a/src/features/tech-log/presentation/studio/components/common-document-fields.tsx b/src/features/tech-log/presentation/studio/components/common-document-fields.tsx
index 1474e88..a0ff732 100644
--- a/src/features/tech-log/presentation/studio/components/common-document-fields.tsx
+++ b/src/features/tech-log/presentation/studio/components/common-document-fields.tsx
@@ -22,7 +22,7 @@ export function CommonDocumentFields({
DOCUMENT
기본 정보
-
+
diff --git a/src/features/tech-log/presentation/studio/components/document-editor-controller.ts b/src/features/tech-log/presentation/studio/components/document-editor-controller.ts
index 4e0cd53..f095478 100644
--- a/src/features/tech-log/presentation/studio/components/document-editor-controller.ts
+++ b/src/features/tech-log/presentation/studio/components/document-editor-controller.ts
@@ -8,6 +8,12 @@ export type DocumentEditorController = {
saved: WorkingCopy;
draft: WorkingCopyInput;
status: StudioEditorStatus;
+ /**
+ * 저장이 실패한 이유. 이전에는 `setRequestAnnouncement` 로만 알렸는데 그것은 aria-live 라
+ * 눈에는 아무것도 보이지 않았다 — 한글 slug 로 저장이 422 로 거절돼도 값은 화면에 그대로
+ * 남아 있어, 작성자는 저장된 줄 알고 검증에서 "slug 이 없다" 를 만났다.
+ */
+ saveError: string;
update(patch: Partial): void;
replace(draft: WorkingCopyInput): void;
save(): Promise;
diff --git a/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx b/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx
index ae46d1d..476c799 100644
--- a/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx
+++ b/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx
@@ -12,6 +12,7 @@ import { createLocalId } from "../../../domain/studio/local-id.ts";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { DocumentEditor } from "./document-editor.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
+import { slugFromName } from "./slug-from-name.ts";
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
@@ -25,6 +26,13 @@ function inputOf(document: WorkingCopy): WorkingCopyInput {
return input as WorkingCopyInput;
}
+/**
+ * 백엔드가 문서 slug 에 요구하는 모양 (`WorkingCopyInputValidator.SLUG`). 저장 전에 여기서 먼저
+ * 보는 이유는, 어긋난 값을 보내면 서버가 details 없는 422 로 거절하고 편집기는 그것을 이유 없는
+ * 실패로만 보여 주기 때문이다 — 작성자에게는 어느 칸이 문제인지 알 방법이 없었다.
+ */
+const SLUG_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
+
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
const studio = useStudio();
const session = useStudioEditorSession();
@@ -48,6 +56,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
// which can only grow the catalog -- the one legitimate reset is the effect
// below, when the screen switches to a different document.
const [assets, setAssets] = useState([]);
+ const [saveError, setSaveError] = useState("");
const observeAssets = useCallback((observed: readonly Asset[]) => {
setAssets((current) => mergeAssetCatalog(current, observed));
}, []);
@@ -97,13 +106,26 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
current.status === "SAVING" ||
current.status === "CONFLICT"
) return;
+ // 비워 두면 제목에서 만든다. 한글 제목도 로마자로 옮겨 유효한 slug 가 되므로, 작성자가
+ // slug 규칙을 몰라도 저장이 막히지 않는다.
+ const slug = current.draft.slug.trim() || slugFromName(current.draft.title);
+ if (slug && !SLUG_SHAPE.test(slug)) {
+ setSaveError(
+ "slug 은 영문 소문자·숫자·하이픈만 쓸 수 있습니다. 비워 두면 제목에서 만들어 드립니다.",
+ );
+ return;
+ }
+ const draft = slug === current.draft.slug
+ ? current.draft
+ : ({ ...current.draft, slug } as typeof current.draft);
+ setSaveError("");
setStatus("SAVING");
try {
const detail = await studio.gateway.saveDocument(
current.documentId,
{
expectedVersion: current.saved.version,
- document: current.draft,
+ document: draft,
},
{ idempotencyKey: createLocalId("studio-editor-save") },
);
@@ -115,11 +137,11 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
const conflict = isStudioGatewayError(error) &&
error.code === "VERSION_CONFLICT";
setStatus(conflict ? "CONFLICT" : "DIRTY");
- studio.setRequestAnnouncement(
- isStudioGatewayError(error)
- ? error.problem.detail
- : "저장하지 못했습니다.",
- );
+ const detail = isStudioGatewayError(error)
+ ? error.problem.detail
+ : "저장하지 못했습니다.";
+ setSaveError(detail);
+ studio.setRequestAnnouncement(detail);
}
}, [begin, editor, setStatus, studio]);
@@ -129,6 +151,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
saved: editor.saved,
draft: editor.draft,
status: editor.status,
+ saveError,
update(patch) {
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
},
@@ -137,7 +160,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
},
save,
};
- }, [documentId, editor, save, updateDraft]);
+ }, [documentId, editor, save, saveError, updateDraft]);
const currentResult = result?.key === requestKey ? result : null;
const problem = currentResult?.problem ?? null;
diff --git a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx
index daeb300..a517833 100644
--- a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx
+++ b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx
@@ -24,7 +24,18 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC
저장 버전
{controller.saved.version}
종류
{kindLabels[controller.draft.kind]}
저장본 검증
- {controller.status === "CONFLICT" ?
서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.
:
불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.
}
+ {/*
+ 경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
+ 앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
+ 두 번 말하게 된다.
+ */}
+ {controller.status === "CONFLICT" ? (
+