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
+ +
; +} 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