The upload dialog sent `{ file, kind }` only, though `UploadAssetForm` and
`asset-upload-transport.ts` both carry `altText`/`decorative`. Every asset
uploaded through the flagship authoring flow therefore landed as
`altText: null, decorative: false`, and the directive `case-fields.tsx` and
`asset-picker.tsx` build from `asset.decorative ? "" : (asset.altText ?? "")`
could only ever be `alt=""`. The document parsed and previewed correctly and
then failed publish validation with EVIDENCE_ALT_REQUIRED, recoverable only
by hand-editing raw Markdown -- the exact thing the Picker exists to prevent.
The dialog now stages the file instead of uploading on selection, and carries
a decorative checkbox plus an alt-text field (focused as soon as a file is
chosen, submitting on Enter). A decorative asset never demands alt text; a
meaningful one is refused with a stated reason rather than a disabled button.
Insertion is unchanged: both call sites already read the Asset, so they now
insert what it actually carries.
Three new tests drive the whole loop -- upload through the real MOCK
composition, auto-insert, save, validate, preview -- and assert the result is
publishable. The pre-existing loop test no longer needs its hand-edit
workaround.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
244 lines
9.3 KiB
TypeScript
244 lines
9.3 KiB
TypeScript
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
|
|
|
|
import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
|
|
import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts";
|
|
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
|
|
import { createLocalId } from "../../../domain/studio/local-id.ts";
|
|
|
|
export type UploadState =
|
|
| { kind: "IDLE" }
|
|
| { kind: "SELECTION_FAILED"; message: string }
|
|
| { kind: "ALT_REQUIRED" }
|
|
| { kind: "UPLOADING" }
|
|
| { kind: "TRANSPORT_FAILED"; message: string }
|
|
| { kind: "TOO_LARGE" }
|
|
| { kind: "UNSUPPORTED_TYPE" }
|
|
| { kind: "READY"; asset: Asset }
|
|
| { kind: "REJECTED"; asset: Asset }
|
|
| { kind: "QUARANTINED"; asset: Asset };
|
|
|
|
/** 업로드 전송 성공과 서버 검증 성공은 다르다. 성공 응답도 상태로 나눈다. */
|
|
export function stateForUploaded(asset: Asset): UploadState {
|
|
switch (asset.managementStatus) {
|
|
case "READY":
|
|
return { kind: "READY", asset };
|
|
case "QUARANTINED":
|
|
return { kind: "QUARANTINED", asset };
|
|
case "REJECTED":
|
|
case "ARCHIVED":
|
|
return { kind: "REJECTED", asset };
|
|
}
|
|
}
|
|
|
|
export function stateForError(error: unknown): UploadState {
|
|
if (isStudioGatewayError(error)) {
|
|
if (error.code === "PAYLOAD_TOO_LARGE") return { kind: "TOO_LARGE" };
|
|
if (error.code === "UNSUPPORTED_MEDIA_TYPE") return { kind: "UNSUPPORTED_TYPE" };
|
|
return { kind: "TRANSPORT_FAILED", message: error.problem.detail };
|
|
}
|
|
return { kind: "TRANSPORT_FAILED", message: "업로드를 전송하지 못했습니다." };
|
|
}
|
|
|
|
const MESSAGES: Record<UploadState["kind"], string> = {
|
|
IDLE: "",
|
|
SELECTION_FAILED: "파일을 선택하지 못했습니다.",
|
|
ALT_REQUIRED: "대체 텍스트를 입력하거나 장식용으로 표시하세요.",
|
|
UPLOADING: "업로드 중입니다.",
|
|
TRANSPORT_FAILED: "업로드를 전송하지 못했습니다.",
|
|
TOO_LARGE: "파일 크기가 허용 범위를 넘었습니다.",
|
|
UNSUPPORTED_TYPE: "지원하지 않는 파일 형식입니다.",
|
|
READY: "업로드했습니다.",
|
|
REJECTED: "서버 검증에서 거절되어 사용할 수 없습니다.",
|
|
QUARANTINED: "보안 검사에서 격리되어 사용할 수 없습니다.",
|
|
};
|
|
|
|
export function AssetUploadDialog(props: Readonly<{
|
|
gateway: StudioAssetGateway;
|
|
kind: AssetKind;
|
|
onUploaded: (asset: Asset) => void;
|
|
onClose: () => void;
|
|
}>) {
|
|
const [state, setState] = useState<UploadState>({ kind: "IDLE" });
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [altText, setAltText] = useState("");
|
|
const [decorative, setDecorative] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const altRef = useRef<HTMLInputElement>(null);
|
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
const titleId = useId();
|
|
const descriptionId = useId();
|
|
const uploading = state.kind === "UPLOADING";
|
|
|
|
// This dialog has no `open` prop -- the caller mounts it to open it and
|
|
// unmounts it to close it (`unpublish-dialog.tsx`'s toggle-by-prop shape
|
|
// does not fit a one-shot upload flow). So the modal opens on mount and the
|
|
// unmount cleanup below closes it if the user never did.
|
|
useEffect(() => {
|
|
const dialog = dialogRef.current;
|
|
if (!dialog) return;
|
|
dialog.showModal();
|
|
inputRef.current?.focus();
|
|
return () => {
|
|
if (dialog.open && typeof dialog.close === "function") dialog.close();
|
|
};
|
|
}, []);
|
|
|
|
/**
|
|
* Final fix wave, item 2. Alt text is collected here because it is a
|
|
* property of the *Asset*, not of one directive: `case-fields.tsx` and
|
|
* `asset-picker.tsx` both build the inserted directive from
|
|
* `asset.decorative ? "" : (asset.altText ?? "")`, so an Asset that carries
|
|
* neither can only ever produce `alt=""`. Publish validation
|
|
* (`validate-working-copy.ts`'s EVIDENCE_ALT_REQUIRED) then rejects the
|
|
* document, and hand-editing raw Markdown was the only recovery — the exact
|
|
* thing the Picker exists to prevent.
|
|
*
|
|
* A decorative Asset is exempt (that is what `decorative` means in the
|
|
* canonical contract, and what the validation rule already honours), so the
|
|
* fast path for a purely ornamental image stays one tick of a checkbox.
|
|
*/
|
|
const trimmedAlt = altText.trim();
|
|
const missingAlt = !decorative && trimmedAlt.length === 0;
|
|
|
|
async function submit() {
|
|
if (!file) {
|
|
setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED });
|
|
return;
|
|
}
|
|
if (missingAlt) {
|
|
// Refused here rather than by disabling the button: a disabled control
|
|
// states no reason, and this refusal has one worth reading.
|
|
setState({ kind: "ALT_REQUIRED" });
|
|
altRef.current?.focus();
|
|
return;
|
|
}
|
|
setState({ kind: "UPLOADING" });
|
|
try {
|
|
// Fix round 1 (I1). A fresh key per call, not one generated once when
|
|
// the dialog opened: every terminal state re-enables the file input,
|
|
// so a user can retry with a *different* file after a failure, and two
|
|
// different payloads must never share one idempotency key -- the exact
|
|
// retry scenario idempotency keys exist for.
|
|
const asset = await props.gateway.uploadAsset(
|
|
{
|
|
file,
|
|
kind: props.kind,
|
|
decorative,
|
|
...(decorative ? {} : { altText: trimmedAlt }),
|
|
},
|
|
{ idempotencyKey: createLocalId("studio-asset-upload") },
|
|
);
|
|
const next = stateForUploaded(asset);
|
|
setState(next);
|
|
if (next.kind === "READY") props.onUploaded(asset);
|
|
} catch (error) {
|
|
setState(stateForError(error));
|
|
}
|
|
}
|
|
|
|
const trapFocus = (event: KeyboardEvent<HTMLDialogElement>) => {
|
|
if (event.key !== "Tab") return;
|
|
const controls = Array.from(
|
|
event.currentTarget.querySelectorAll<HTMLElement>(
|
|
"input:not([disabled]), button:not([disabled])",
|
|
),
|
|
);
|
|
if (!controls.length) return;
|
|
const first = controls[0];
|
|
const last = controls.at(-1)!;
|
|
if (event.shiftKey && document.activeElement === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && document.activeElement === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<dialog
|
|
ref={dialogRef}
|
|
className="studio-asset-upload-dialog"
|
|
aria-labelledby={titleId}
|
|
aria-describedby={descriptionId}
|
|
onCancel={(event) => {
|
|
event.preventDefault();
|
|
if (!uploading) props.onClose();
|
|
}}
|
|
onKeyDown={trapFocus}
|
|
>
|
|
<div className="studio-dialog-body">
|
|
<p className="studio-eyebrow">ASSET UPLOAD</p>
|
|
<h2 id={titleId}>Asset 업로드</h2>
|
|
<p id={descriptionId}>
|
|
업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다. 장식용이
|
|
아니면 대체 텍스트가 필요합니다.
|
|
</p>
|
|
<label className="studio-field">
|
|
<span>Asset 파일</span>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml,application/pdf"
|
|
disabled={uploading}
|
|
onChange={(event) => {
|
|
const selected = event.currentTarget.files?.[0] ?? null;
|
|
setFile(selected);
|
|
if (!selected) {
|
|
setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED });
|
|
return;
|
|
}
|
|
// A new file clears whatever the previous attempt reported.
|
|
setState({ kind: "IDLE" });
|
|
if (!decorative) altRef.current?.focus();
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="studio-field studio-field--checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={decorative}
|
|
disabled={uploading}
|
|
onChange={(event) => {
|
|
setDecorative(event.currentTarget.checked);
|
|
if (state.kind === "ALT_REQUIRED") setState({ kind: "IDLE" });
|
|
}}
|
|
/>
|
|
<span>장식용 이미지 (대체 텍스트 없음)</span>
|
|
</label>
|
|
<label className="studio-field">
|
|
<span>대체 텍스트</span>
|
|
<input
|
|
ref={altRef}
|
|
type="text"
|
|
value={altText}
|
|
disabled={uploading || decorative}
|
|
aria-invalid={state.kind === "ALT_REQUIRED"}
|
|
onChange={(event) => {
|
|
setAltText(event.currentTarget.value);
|
|
if (state.kind === "ALT_REQUIRED") setState({ kind: "IDLE" });
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Enter") return;
|
|
event.preventDefault();
|
|
void submit();
|
|
}}
|
|
/>
|
|
</label>
|
|
<p className="studio-dialog-status" role="status" aria-live="polite" aria-label="업로드 상태">{MESSAGES[state.kind]}</p>
|
|
{/* `.studio-dialog-actions button:last-child` is the primary style, so
|
|
the confirming action goes last -- same order as the other Studio
|
|
dialogs. */}
|
|
<div className="studio-dialog-actions">
|
|
<button type="button" disabled={uploading} onClick={props.onClose}>
|
|
닫기
|
|
</button>
|
|
<button type="button" disabled={uploading} onClick={() => void submit()}>
|
|
업로드
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
);
|
|
}
|