feat: insert evidence directives from the TechLog asset picker

Adds an Asset Picker and upload dialog to the CASE editor so authors can
insert `:::evidence` directives that reference backend assets, and opens
projectWorkingCopy's two evidence gates so Instant Preview accepts a key
backed by a freshly loaded READY asset instead of only the one hardcoded
legacy key. The editor screen now owns the loaded Asset list so the
Picker, the upload dialog, and Instant Preview all read the same array,
and a freshly uploaded asset appears in the preview without a refetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 04:32:56 +09:00
co-authored by Claude Opus 5
parent e2c1d076f4
commit 54d9bf9120
11 changed files with 907 additions and 12 deletions
@@ -0,0 +1,158 @@
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";
export type UploadState =
| { kind: "IDLE" }
| { kind: "SELECTION_FAILED"; message: string }
| { 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: "파일을 선택하지 못했습니다.",
UPLOADING: "업로드 중입니다.",
TRANSPORT_FAILED: "업로드를 전송하지 못했습니다.",
TOO_LARGE: "파일 크기가 허용 범위를 넘었습니다.",
UNSUPPORTED_TYPE: "지원하지 않는 파일 형식입니다.",
READY: "업로드했습니다.",
REJECTED: "서버 검증에서 거절되어 사용할 수 없습니다.",
QUARANTINED: "보안 검사에서 격리되어 사용할 수 없습니다.",
};
export function AssetUploadDialog(props: Readonly<{
gateway: StudioAssetGateway;
kind: AssetKind;
idempotencyKey: string;
onUploaded: (asset: Asset) => void;
onClose: () => void;
}>) {
const [state, setState] = useState<UploadState>({ kind: "IDLE" });
const inputRef = 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();
};
}, []);
async function submit(file: File) {
setState({ kind: "UPLOADING" });
try {
const asset = await props.gateway.uploadAsset(
{ file, kind: props.kind },
{ idempotencyKey: props.idempotencyKey },
);
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 file = event.currentTarget.files?.[0];
if (!file) {
setState({ kind: "SELECTION_FAILED", message: MESSAGES.SELECTION_FAILED });
return;
}
void submit(file);
}}
/>
</label>
<p className="studio-dialog-status" role="status" aria-live="polite">{MESSAGES[state.kind]}</p>
<div className="studio-dialog-actions">
<button type="button" disabled={uploading} onClick={props.onClose}>
</button>
</div>
</div>
</dialog>
);
}