From 54d9bf91209a2c12900927328324ed11b40469dc Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 04:32:56 +0900 Subject: [PATCH] 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) --- .../studio/components/asset-picker.tsx | 84 ++++ .../studio/components/asset-upload-dialog.tsx | 158 +++++++ .../studio/components/case-fields.tsx | 113 ++++- .../components/document-editor-screen.tsx | 18 +- .../studio/components/document-editor.tsx | 20 +- .../studio/components/instant-preview.tsx | 54 ++- .../presentation/styles/studio-editor.css | 12 + .../tech-log/presentation/styles/studio.css | 3 + tests/features/tech-log/asset-picker.test.tsx | 443 ++++++++++++++++++ .../tech-log/studio-editor-smoke.test.tsx | 7 +- .../tech-log/studio-save-navigation.test.tsx | 7 +- 11 files changed, 907 insertions(+), 12 deletions(-) create mode 100644 src/features/tech-log/presentation/studio/components/asset-picker.tsx create mode 100644 src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx create mode 100644 tests/features/tech-log/asset-picker.test.tsx diff --git a/src/features/tech-log/presentation/studio/components/asset-picker.tsx b/src/features/tech-log/presentation/studio/components/asset-picker.tsx new file mode 100644 index 0000000..a97944b --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/asset-picker.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; + +import type { Asset } from "../../../contracts/studio/contract.ts"; +import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts"; + +/** + * A directive attribute value is delimited by double quotes (see + * `parse-case-content.ts`'s `attributesOf`), so a value containing one would + * corrupt the directive -- and a raw newline would spill the directive across + * lines the block-directive parser does not expect inside an attribute. Both + * are stripped/folded rather than escaped: there is no escape syntax the + * parser accepts inside a directive attribute. + */ +function attributeValue(raw: string): string { + return raw.replaceAll('"', "").replace(/\s+/gu, " ").trim(); +} + +export function buildEvidenceDirective( + input: Readonly<{ assetKey: string; alt: string; caption: string; zoom: boolean }>, +): string { + const key = attributeValue(input.assetKey); + const alt = attributeValue(input.alt); + const caption = attributeValue(input.caption); + return `:::evidence key="${key}" alt="${alt}" caption="${caption}" zoom="${input.zoom}"\n:::`; +} + +export function AssetPicker({ + gateway, + onInsert, + onLoaded, +}: Readonly<{ + gateway: StudioAssetGateway; + onInsert: (directive: string) => void; + /** + * Optional so Task 10 Step 1's original test (which renders `AssetPicker` + * without it) keeps passing. When supplied, the editor screen uses this to + * own the same Asset list the Instant Preview resolver reads -- so a + * directive this Picker just inserted renders immediately instead of as a + * placeholder. + */ + onLoaded?: (assets: readonly Asset[]) => void; +}>) { + const [assets, setAssets] = useState([]); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + gateway + .listAssets({ managementStatus: "READY", limit: 50 }, { signal: controller.signal }) + .then((page) => { + setAssets(page.items); + onLoaded?.(page.items); + }) + .catch(() => setFailed(true)); + return () => controller.abort(); + }, [gateway, onLoaded]); + + // READY만 삽입 후보다. 서버 필터를 신뢰하되 방어적으로 한 번 더 거른다. + const selectable = assets.filter((asset) => asset.managementStatus === "READY"); + + if (failed) return

Asset 목록을 불러오지 못했습니다.

; + + if (selectable.length === 0) { + return

삽입할 수 있는 Asset이 없습니다. 먼저 업로드하세요.

; + } + + return
+
    + {selectable.map((asset) =>
  • + +
  • )} +
+
; +} diff --git a/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx b/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx new file mode 100644 index 0000000..ffca535 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx @@ -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 = { + 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({ kind: "IDLE" }); + const inputRef = 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(); + }; + }, []); + + 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) => { + 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]}

+
+ +
+
+
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/case-fields.tsx b/src/features/tech-log/presentation/studio/components/case-fields.tsx index c654e4c..a1c6d70 100644 --- a/src/features/tech-log/presentation/studio/components/case-fields.tsx +++ b/src/features/tech-log/presentation/studio/components/case-fields.tsx @@ -1,9 +1,79 @@ +import { useRef, useState } from "react"; + import type { components } from "../../../contracts/studio/generated.ts"; +import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts"; +import { createLocalId } from "../../../domain/studio/local-id.ts"; +import { useStudioAssetGateway } from "../use-studio.ts"; +import { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx"; +import { AssetUploadDialog } from "./asset-upload-dialog.tsx"; type CaseInput = components["schemas"]["CaseInput"]; -export function CaseFields({ draft, onChange }: { draft: CaseInput; onChange(draft: CaseInput): void }) { +const ASSET_KIND_OPTIONS: ReadonlyArray<{ value: AssetKind; label: string }> = [ + { value: "IMAGE", label: "이미지" }, + { value: "DIAGRAM", label: "다이어그램" }, + { value: "ATTACHMENT", label: "첨부파일" }, +]; + +/** + * `:::evidence ...` is a block directive, so the parser requires a blank line + * before and after it. Inserting straight into `selectionStart`/`selectionEnd` + * without normalizing the surrounding whitespace would produce a directive + * glued to adjacent text -- a well-formed-looking block that the parser still + * rejects. + */ +function insertAtCursor( + textarea: HTMLTextAreaElement, + directive: string, + commit: (next: string) => void, +) { + const { selectionStart, selectionEnd, value } = textarea; + const prefix = value.slice(0, selectionStart); + const suffix = value.slice(selectionEnd); + const before = prefix.length === 0 || prefix.endsWith("\n\n") ? prefix : `${prefix}\n\n`; + const after = suffix.startsWith("\n") ? suffix : `\n${suffix}`; + commit(`${before}${directive}${after}`); +} + +export function CaseFields({ + draft, + onChange, + onAssetsLoaded, + onAssetUploaded, +}: { + draft: CaseInput; + onChange(draft: CaseInput): void; + /** The editor screen owns the loaded Asset list so the Picker and Instant Preview read the same array. */ + onAssetsLoaded?: (assets: readonly Asset[]) => void; + onAssetUploaded?: (asset: Asset) => void; +}) { + // Asset UI must use the throwing accessor, not the nullable `assetGateway` + // field directly -- see `use-studio.ts`'s `useStudioAssetGateway` doc. + const assetGateway = useStudioAssetGateway(); + const bodyRef = useRef(null); + const uploadTriggerRef = useRef(null); + const [uploadKind, setUploadKind] = useState("IMAGE"); + const [uploadKey, setUploadKey] = useState(null); const update = (patch: Partial) => onChange({ ...draft, ...patch }); + + const insertDirective = (directive: string) => { + const textarea = bodyRef.current; + if (!textarea) { + update({ + bodyMarkdown: draft.bodyMarkdown + ? `${draft.bodyMarkdown}\n\n${directive}\n` + : `${directive}\n`, + }); + return; + } + insertAtCursor(textarea, directive, (next) => update({ bodyMarkdown: next })); + }; + + const closeUpload = () => { + setUploadKey(null); + queueMicrotask(() => uploadTriggerRef.current?.focus()); + }; + return (

CASE

문제와 검증

@@ -13,8 +83,47 @@ export function CaseFields({ draft, onChange }: { draft: CaseInput; onChange(dra