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 = { 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({ kind: "IDLE" }); const [file, setFile] = useState(null); const [altText, setAltText] = useState(""); const [decorative, setDecorative] = useState(false); const inputRef = useRef(null); const altRef = useRef(null); const dialogRef = useRef(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) => { if (event.key !== "Tab") return; const controls = Array.from( event.currentTarget.querySelectorAll( "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 ( { event.preventDefault(); if (!uploading) props.onClose(); }} onKeyDown={trapFocus} >

ASSET UPLOAD

Asset 업로드

업로드한 파일은 서버 검증을 거친 뒤에만 본문에 삽입할 수 있습니다. 장식용이 아니면 대체 텍스트가 필요합니다.

{MESSAGES[state.kind]}

{/* `.studio-dialog-actions button:last-child` is the primary style, so the confirming action goes last -- same order as the other Studio dialogs. */}
); }