feat: port TechLog Studio editors

This commit is contained in:
DongHyeonka
2026-08-15 23:41:34 +09:00
parent 887f5e6eb1
commit 5933265975
16 changed files with 879 additions and 3 deletions
@@ -0,0 +1,20 @@
import type { components } from "../../../contracts/studio/generated.ts";
type CaseInput = components["schemas"]["CaseInput"];
export function CaseFields({ draft, onChange }: { draft: CaseInput; onChange(draft: CaseInput): void }) {
const update = (patch: Partial<CaseInput>) => onChange({ ...draft, ...patch });
return (
<section className="studio-editor-section" aria-labelledby="studio-case-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">CASE</p><h2 id="studio-case-fields-title"> </h2></div>
<div className="studio-field-grid">
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.problem} onChange={(event) => update({ problem: event.currentTarget.value })} /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.conclusion} onChange={(event) => update({ conclusion: event.currentTarget.value })} /></label>
<label className="studio-field"><span> </span><textarea value={draft.environment} onChange={(event) => update({ environment: event.currentTarget.value })} /></label>
<label className="studio-field"><span> </span><textarea value={draft.reproduction} onChange={(event) => update({ reproduction: event.currentTarget.value })} /></label>
<label className="studio-field"><span> </span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /></label>
<label className="studio-field studio-field--wide"><span> Markdown</span><textarea className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /></label>
</div>
</section>
);
}
@@ -0,0 +1,33 @@
import type { components } from "../../../contracts/studio/generated.ts";
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
import { RelationEditor } from "./relation-editor.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
export function CommonDocumentFields({
draft,
topics,
projects,
relations,
onUpdate,
}: {
draft: WorkingCopyInput;
topics: CatalogEntry[];
projects: CatalogEntry[];
relations: CatalogEntry[];
onUpdate(patch: Partial<WorkingCopyInput>): void;
}) {
return (
<section className="studio-editor-section" aria-labelledby="studio-common-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">DOCUMENT</p><h2 id="studio-common-fields-title"> </h2></div>
<div className="studio-field-grid">
<label className="studio-field studio-field--wide"><span></span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /></label>
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /></label>
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value=""> </option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value=""></option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
</div>
<RelationEditor relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
</section>
);
}
@@ -0,0 +1,110 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts";
import type { components } from "../../../contracts/studio/generated.ts";
import type {
WorkingCopy,
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx";
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
const catalogTypes = ["TOPIC", "PROJECT", "RELATION", "EVIDENCE"] as const;
function inputOf(document: WorkingCopy): WorkingCopyInput {
const input: Record<string, unknown> = { ...document };
delete input.id;
delete input.version;
delete input.updatedAt;
return input as WorkingCopyInput;
}
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
const studio = useStudio();
const session = useStudioEditorSession();
const { begin, clear, editor, updateDraft } = session;
const [retryKey, setRetryKey] = useState(0);
const requestKey = `${documentId}:${retryKey}`;
const [result, setResult] = useState<{
key: string;
catalog: CatalogEntry[];
problem: Error | null;
} | null>(null);
useEffect(() => {
const request = new AbortController();
clear();
void Promise.all([
studio.gateway.getDocument(documentId, { signal: request.signal }),
...catalogTypes.map((type) => studio.gateway.getCatalog(
{ type, limit: 100 },
{ signal: request.signal },
)),
]).then(
([detail, ...catalogPages]) => {
if (request.signal.aborted) return;
setResult({
key: requestKey,
catalog: catalogPages.flatMap((page) => page.items),
problem: null,
});
begin(detail.document, inputOf(detail.document));
},
(error: unknown) => {
if (request.signal.aborted) return;
setResult({
key: requestKey,
catalog: [],
problem: error instanceof Error
? error
: new Error("Studio 문서를 불러오지 못했습니다."),
});
},
);
return () => request.abort();
}, [begin, clear, documentId, requestKey, studio.gateway]);
const controller = useMemo<DocumentEditorController | null>(() => {
if (!editor || editor.documentId !== documentId) return null;
return {
saved: editor.saved,
draft: editor.draft,
status: editor.status,
update(patch) {
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
},
replace(draft) {
if (draft.kind === editor.draft.kind) updateDraft(draft);
},
};
}, [documentId, editor, updateDraft]);
const currentResult = result?.key === requestKey ? result : null;
const problem = currentResult?.problem ?? null;
if (!currentResult || (!problem && !controller)) {
return <section className="studio-editor-loading" role="status"><p> .</p></section>;
}
if (problem && isStudioGatewayError(problem) && problem.code === "DOCUMENT_NOT_FOUND") {
return (
<section className="studio-route-state">
<p className="studio-eyebrow">404</p>
<h1>Studio </h1>
<p> Studio .</p>
<Link to="/studio/documents"> </Link>
</section>
);
}
if (problem) {
return (
<section className="studio-route-state" role="alert">
<p className="studio-eyebrow">EDITOR ERROR</p>
<h1> </h1>
<p>{isStudioGatewayError(problem) ? problem.problem.detail : problem.message}</p>
<button type="button" onClick={() => setRetryKey((value) => value + 1)}> </button>
</section>
);
}
return <DocumentEditor controller={controller!} catalog={currentResult.catalog} />;
}
@@ -0,0 +1,78 @@
import { useRef, useState, type KeyboardEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type {
WorkingCopy,
WorkingCopyInput,
} from "../../../contracts/studio/contract.ts";
import type { StudioEditorStatus } from "../use-studio.ts";
import { CaseFields } from "./case-fields.tsx";
import { CommonDocumentFields } from "./common-document-fields.tsx";
import { DocumentStatusRail } from "./document-status-rail.tsx";
import { InstantPreview } from "./instant-preview.tsx";
import { QuestionFields } from "./question-fields.tsx";
import { ReferenceFields } from "./reference-fields.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
export type DocumentEditorController = {
saved: WorkingCopy;
draft: WorkingCopyInput;
status: StudioEditorStatus;
update(patch: Partial<WorkingCopyInput>): void;
replace(draft: WorkingCopyInput): void;
save?: () => Promise<void>;
};
export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) {
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
const editTab = useRef<HTMLButtonElement>(null);
const previewTab = useRef<HTMLButtonElement>(null);
const selectTab = (next: "EDIT" | "PREVIEW") => {
setTab(next);
(next === "EDIT" ? editTab : previewTab).current?.focus();
};
const keyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
let next: "EDIT" | "PREVIEW" | null = null;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") next = tab === "EDIT" ? "PREVIEW" : "EDIT";
if (event.key === "Home") next = "EDIT";
if (event.key === "End") next = "PREVIEW";
if (!next) return;
event.preventDefault();
selectTab(next);
};
const topics = catalog.filter(({ type }) => type === "TOPIC");
const projects = catalog.filter(({ type }) => type === "PROJECT");
const relations = catalog.filter(({ type }) => type === "RELATION");
const evidence = catalog.filter(({ type }) => type === "EVIDENCE");
return (
<div className="studio-editor-page">
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
<button id="studio-edit-tab" ref={editTab} type="button" role="tab" aria-selected={tab === "EDIT"} aria-controls="studio-edit-panel" tabIndex={tab === "EDIT" ? 0 : -1} onClick={() => selectTab("EDIT")} onKeyDown={keyDown}></button>
<button id="studio-preview-tab" ref={previewTab} type="button" role="tab" aria-selected={tab === "PREVIEW"} aria-controls="studio-preview-panel" tabIndex={tab === "PREVIEW" ? 0 : -1} onClick={() => selectTab("PREVIEW")} onKeyDown={keyDown}> </button>
</div>
<div className="studio-editor-layout">
<div className="studio-editor-workspace">
<div id="studio-edit-panel" role="tabpanel" aria-labelledby="studio-edit-tab" hidden={tab !== "EDIT"}>
<header className="studio-editor-heading">
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
<h1> </h1>
<p>{controller.draft.title || "제목 없는 작업본"}</p>
</header>
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} onUpdate={controller.update} />
{controller.draft.kind === "CASE"
? <CaseFields draft={controller.draft} onChange={controller.replace} />
: controller.draft.kind === "REFERENCE"
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
: <QuestionFields draft={controller.draft} evidence={evidence} onChange={controller.replace} />}
</div>
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
<InstantPreview draft={controller.draft} catalog={catalog} />
</div>
</div>
<DocumentStatusRail controller={controller} />
</div>
</div>
);
}
@@ -0,0 +1,24 @@
import { Link } from "react-router-dom";
import type { DocumentEditorController } from "./document-editor.tsx";
const labels = {
CLEAN: "저장됨",
DIRTY: "저장되지 않음",
SAVING: "저장 중…",
CONFLICT: "저장 충돌",
} as const;
export function DocumentStatusRail({ controller }: { controller: DocumentEditorController }) {
return (
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
<p className="studio-eyebrow">WORKING COPY</p>
<h2 id="studio-document-status-title"> </h2>
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
<dl><div><dt> </dt><dd>{controller.saved.version}</dd></div><div><dt></dt><dd>{controller.draft.kind}</dd></div></dl>
<button type="button" onClick={() => { if (controller.save) void controller.save(); }} disabled={!controller.save || controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
<Link className="studio-editor-next-link" to={`/studio/documents/${controller.saved.id}/validation`}> </Link>
{controller.status === "CONFLICT" ? <p className="studio-editor-conflict" role="alert"> . .</p> : <p> . .</p>}
</aside>
);
}
@@ -0,0 +1,52 @@
import type { components } from "../../../contracts/studio/generated.ts";
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
import { projectWorkingCopy } from "../../../domain/content-format/project-public-render-model.ts";
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
function supportsPreviewEvidenceKey(key: string): boolean {
return key === "fetch-strategy-boundary";
}
function resolvePreviewEvidenceAsset(key: string) {
if (!supportsPreviewEvidenceKey(key)) {
throw new Error(`Unknown local evidence asset: ${key}`);
}
return {
src: "/media/fetch-strategy-boundary.svg",
width: 1080,
height: 420,
triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기",
dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대",
};
}
export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; catalog: CatalogEntry[] }) {
let model: ReturnType<typeof projectWorkingCopy> | null = null;
let issues: string[] | null = null;
try {
model = projectWorkingCopy(
draft,
catalog,
{ mode: "PREVIEW", publishedAt: null },
supportsPreviewEvidenceKey,
);
} catch (error) {
issues = error instanceof ContentFormatError
? error.issues.map((issue) => `${issue.line}:${issue.column} ${issue.detail}`)
: ["현재 입력으로 공개 문서를 구성할 수 없습니다."];
}
if (issues) return <section className="studio-preview-error" role="alert" aria-labelledby="studio-preview-error-title"><h2 id="studio-preview-error-title"> </h2><ul>{issues.map((issue) => <li key={issue}>{issue}</li>)}</ul></section>;
return (
<div className="studio-instant-preview">
<PublicRecordRenderer
model={model!}
embedded
resolveEvidenceAsset={resolvePreviewEvidenceAsset}
resolvePublishedLabel={() => undefined}
/>
</div>
);
}
@@ -0,0 +1,31 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
type OrderedText = components["schemas"]["OrderedText"];
function ordered(items: OrderedText[]): OrderedText[] {
return items.map((item, order) => ({ ...item, order }));
}
export function OrderedTextList({ label, fieldId, items, onChange }: { label: string; fieldId?: string; items: OrderedText[]; onChange(items: OrderedText[]): void }) {
const move = (index: number, delta: -1 | 1) => {
const target = index + delta;
if (target < 0 || target >= items.length) return;
const next = [...items];
[next[index], next[target]] = [next[target]!, next[index]!];
onChange(ordered(next));
};
return (
<fieldset id={fieldId} className="studio-ordered-list" tabIndex={fieldId ? -1 : undefined}>
<legend>{label}</legend>
{items.length === 0 ? <p> .</p> : null}
{items.map((item, index) => (
<div className="studio-ordered-item" key={item.id}>
<label><span>{label} {index + 1}</span><textarea id={fieldId ? `${fieldId}-${index}-text` : undefined} value={item.text} onChange={(event) => onChange(ordered(items.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, text: event.currentTarget.value } : candidate)))} /></label>
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => move(index, 1)} disabled={index === items.length - 1}></button><button type="button" onClick={() => onChange(ordered(items.filter((_, candidateIndex) => candidateIndex !== index)))}></button></div>
</div>
))}
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createLocalId("item"), text: "", order: items.length }])); }}>{label} </button>
</fieldset>
);
}
@@ -0,0 +1,38 @@
export const defaultPublicationFlowClasses = {
page: "studio-publication-page",
snapshotPage: "studio-snapshot-page",
heading: "studio-publication-heading",
routeState: "studio-publication-route-state",
eyebrow: "studio-publication-eyebrow",
loading: "studio-publication-loading",
gateReady: "studio-publication-gate-ready",
summary: "studio-publication-summary",
changeSummary: "studio-publication-change-summary",
publishAction: "studio-publication-action",
blocked: "studio-publication-blocked",
noWarnings: "studio-publication-no-warnings",
warningGroup: "studio-publication-warning-group",
warningList: "studio-publication-warning-list",
warningItem: "studio-publication-warning-item",
error: "studio-publication-error",
success: "studio-publication-success",
actions: "studio-publication-actions",
filters: "studio-publication-filters",
errorState: "studio-publication-error-state",
emptyState: "studio-publication-empty-state",
historyList: "studio-publication-history-list",
historyRow: "studio-publication-history-row",
eventType: "studio-publication-event-type",
eventMain: "studio-publication-event-main",
kind: "studio-publication-kind",
rowActions: "studio-publication-row-actions",
dialog: "studio-publication-dialog",
dialogBody: "studio-publication-dialog-body",
preservedNote: "studio-publication-preserved-note",
dialogActions: "studio-publication-dialog-actions",
snapshotToolbar: "studio-publication-snapshot-toolbar",
snapshotTitle: "studio-publication-snapshot-title",
snapshotDocument: "studio-publication-snapshot-document",
} as const;
export type PublicationFlowClasses = Record<keyof typeof defaultPublicationFlowClasses, string>;
@@ -0,0 +1,51 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { OrderedTextList } from "./ordered-text-list.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
type QuestionInput = components["schemas"]["QuestionInput"];
type QuestionOption = components["schemas"]["QuestionOption"];
function orderedOptions(options: QuestionOption[]): QuestionOption[] {
return options.map((option, order) => ({ ...option, order }));
}
export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionInput; evidence: CatalogEntry[]; onChange(draft: QuestionInput): void }) {
const update = (patch: Partial<QuestionInput>) => onChange({ ...draft, ...patch });
const moveOption = (index: number, delta: -1 | 1) => {
const target = index + delta;
if (target < 0 || target >= draft.options.length) return;
const next = [...draft.options];
[next[index], next[target]] = [next[target]!, next[index]!];
update({ options: orderedOptions(next) });
};
return (
<section className="studio-editor-section" aria-labelledby="studio-question-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">QUESTION</p><h2 id="studio-question-fields-title"> </h2></div>
<label className="studio-field"><span> </span><select value={draft.questionStatus ?? ""} onChange={(event) => {
const value = event.currentTarget.value;
if (value === "RESOLVED") update({ questionStatus: "RESOLVED", resolution: draft.resolution ?? { summary: "", evidenceTargetId: null, linkLabel: "" } });
else update({ questionStatus: value === "OPEN" ? "OPEN" : null, resolution: null });
}}><option value=""> </option><option value="OPEN">OPEN</option><option value="RESOLVED">RESOLVED</option></select></label>
<OrderedTextList label="사실" fieldId="studio-field-facts" items={draft.facts} onChange={(facts) => update({ facts })} />
<OrderedTextList label="가정" fieldId="studio-field-assumptions" items={draft.assumptions} onChange={(assumptions) => update({ assumptions })} />
<OrderedTextList label="미지수" fieldId="studio-field-unknowns" items={draft.unknowns} onChange={(unknowns) => update({ unknowns })} />
<OrderedTextList label="제약" fieldId="studio-field-constraints" items={draft.constraints} onChange={(constraints) => update({ constraints })} />
<fieldset id="studio-field-options" className="studio-ordered-list" tabIndex={-1}><legend></legend>
{draft.options.length === 0 ? <p> .</p> : null}
{draft.options.map((option, index) => <div className="studio-ordered-item" key={option.id}>
<label><span> {index + 1} </span><input value={option.title} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
<label><span> {index + 1} </span><textarea value={option.description} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, description: event.currentTarget.value } : candidate)) })} /></label>
<div className="studio-item-actions"><button type="button" onClick={() => moveOption(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => moveOption(index, 1)} disabled={index === draft.options.length - 1}></button><button type="button" onClick={() => update({ options: orderedOptions(draft.options.filter((_, candidateIndex) => candidateIndex !== index)) })}></button></div>
</div>)}
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createLocalId("option"), title: "", description: "", order: draft.options.length }]) }); }}> </button>
</fieldset>
<label className="studio-field studio-field--wide"><span> </span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /></label>
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend> </legend>
<label className="studio-field studio-field--wide"><span> </span><textarea value={draft.resolution.summary} onChange={(event) => update({ resolution: { ...draft.resolution!, summary: event.currentTarget.value } })} /></label>
<label className="studio-field"><span> </span><select value={draft.resolution.evidenceTargetId ?? ""} onChange={(event) => update({ resolution: { ...draft.resolution!, evidenceTargetId: event.currentTarget.value || null } })}><option value=""> </option>{evidence.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
<label className="studio-field"><span> </span><input value={draft.resolution.linkLabel} onChange={(event) => update({ resolution: { ...draft.resolution!, linkLabel: event.currentTarget.value } })} /></label>
</fieldset> : null}
</section>
);
}
@@ -0,0 +1,40 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { OrderedTextList } from "./ordered-text-list.tsx";
type ReferenceInput = components["schemas"]["ReferenceInput"];
type ReferenceRule = components["schemas"]["ReferenceRule"];
function orderedRules(rules: ReferenceRule[]): ReferenceRule[] {
return rules.map((rule, order) => ({ ...rule, order }));
}
export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; onChange(draft: ReferenceInput): void }) {
const update = (patch: Partial<ReferenceInput>) => onChange({ ...draft, ...patch });
const moveRule = (index: number, delta: -1 | 1) => {
const target = index + delta;
if (target < 0 || target >= draft.rules.length) return;
const next = [...draft.rules];
[next[index], next[target]] = [next[target]!, next[index]!];
update({ rules: orderedRules(next) });
};
return (
<section className="studio-editor-section" aria-labelledby="studio-reference-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">REFERENCE</p><h2 id="studio-reference-fields-title"> </h2></div>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.purpose} onChange={(event) => update({ purpose: event.currentTarget.value })} /></label>
<fieldset className="studio-ordered-list"><legend></legend>
{draft.rules.length === 0 ? <p> .</p> : null}
{draft.rules.map((rule, index) => <div className="studio-ordered-item" key={rule.id}>
<label><span> {index + 1} </span><input value={rule.title} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
<label><span> {index + 1} </span><textarea value={rule.body} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, body: event.currentTarget.value } : candidate)) })} /></label>
<div className="studio-item-actions"><button type="button" onClick={() => moveRule(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => moveRule(index, 1)} disabled={index === draft.rules.length - 1}></button><button type="button" onClick={() => update({ rules: orderedRules(draft.rules.filter((_, candidateIndex) => candidateIndex !== index)) })}></button></div>
</div>)}
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createLocalId("rule"), title: "", body: "", order: draft.rules.length }]) }); }}> </button>
</fieldset>
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
<OrderedTextList label="예외" items={draft.exceptions} onChange={(exceptions) => update({ exceptions })} />
<OrderedTextList label="예시" items={draft.examples} onChange={(examples) => update({ examples })} />
<label className="studio-field"><span> </span><input type="date" value={draft.verifiedOn ?? ""} onChange={(event) => update({ verifiedOn: event.currentTarget.value || null })} /></label>
</section>
);
}
@@ -0,0 +1,34 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
type RelationInput = components["schemas"]["RelationInput"];
function ordered(relations: RelationInput[]): RelationInput[] {
return relations.map((relation, order) => ({ ...relation, order }));
}
export function RelationEditor({ relations, catalog, onChange }: { relations: RelationInput[]; catalog: CatalogEntry[]; onChange(relations: RelationInput[]): void }) {
const update = (index: number, patch: Partial<RelationInput>) => onChange(ordered(relations.map((relation, candidateIndex) => candidateIndex === index ? { ...relation, ...patch } : relation)));
const move = (index: number, delta: -1 | 1) => {
const target = index + delta;
if (target < 0 || target >= relations.length) return;
const next = [...relations];
[next[index], next[target]] = [next[target]!, next[index]!];
onChange(ordered(next));
};
return (
<fieldset className="studio-ordered-list">
<legend></legend>
{relations.length === 0 ? <p> .</p> : null}
{relations.map((relation, index) => (
<div className="studio-relation-item" key={relation.id ?? `relation-${index}`}>
<label><span> {index + 1} </span><select value={relation.targetId ?? ""} onChange={(event) => update(index, { targetId: event.currentTarget.value || null })}><option value=""> </option>{catalog.map((entry) => <option key={entry.id} value={entry.id} disabled={relations.some((candidate, candidateIndex) => candidateIndex !== index && candidate.targetId === entry.id)}>{entry.label}</option>)}</select></label>
<label><span> {index + 1} </span><input value={relation.reason} onChange={(event) => update(index, { reason: event.currentTarget.value })} /></label>
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}></button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}></button></div>
</div>
))}
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}> </button>
</fieldset>
);
}
@@ -0,0 +1,7 @@
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { DocumentEditorScreen } from "../components/document-editor-screen.tsx";
export function DocumentEditPage() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_EDIT">();
return <DocumentEditorScreen documentId={String(params.id)} />;
}
@@ -6,7 +6,16 @@ import {
} from "react";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
import { StudioContext, type StudioContextValue } from "./use-studio.ts";
import type {
WorkingCopy,
WorkingCopyInput,
} from "../../contracts/studio/contract.ts";
import {
StudioContext,
type StudioContextValue,
type StudioEditorState,
type StudioEditorStatus,
} from "./use-studio.ts";
type StudioProviderProps = Readonly<{
children: ReactNode;
@@ -25,16 +34,41 @@ export function StudioProvider({
navigate = defaultNavigate,
}: StudioProviderProps) {
const [gateway] = useState<StudioGateway>(() => createGateway());
const [editor, setEditor] = useState<StudioEditorState | null>(null);
const [requestAnnouncement, setRequestAnnouncement] = useState("");
const navigateInternal = useCallback((href: string) => navigate(href), [navigate]);
const beginEditor = useCallback((saved: WorkingCopy, draft: WorkingCopyInput) => {
setEditor({ documentId: saved.id, saved, draft, status: "CLEAN" });
}, []);
const updateEditorDraft = useCallback((draft: WorkingCopyInput) => {
setEditor((current) => current ? { ...current, draft, status: "DIRTY" } : current);
}, []);
const setEditorStatus = useCallback((status: StudioEditorStatus) => {
setEditor((current) => current ? { ...current, status } : current);
}, []);
const clearEditor = useCallback(() => setEditor(null), []);
const value = useMemo<StudioContextValue>(
() => ({
gateway,
editor,
requestAnnouncement,
setRequestAnnouncement,
navigateInternal,
beginEditor,
updateEditorDraft,
setEditorStatus,
clearEditor,
}),
[gateway, navigateInternal, requestAnnouncement],
[
beginEditor,
clearEditor,
editor,
gateway,
navigateInternal,
requestAnnouncement,
setEditorStatus,
updateEditorDraft,
],
);
return (
@@ -1,12 +1,30 @@
import { createContext, useContext } from "react";
import { createContext, useContext, useMemo } from "react";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
import type {
WorkingCopy,
WorkingCopyInput,
} from "../../contracts/studio/contract.ts";
export type StudioEditorStatus = "CLEAN" | "DIRTY" | "SAVING" | "CONFLICT";
export type StudioEditorState = Readonly<{
documentId: string;
saved: WorkingCopy;
draft: WorkingCopyInput;
status: StudioEditorStatus;
}>;
export type StudioContextValue = Readonly<{
gateway: StudioGateway;
editor: StudioEditorState | null;
requestAnnouncement: string;
setRequestAnnouncement(message: string): void;
navigateInternal(href: string): void;
beginEditor(saved: WorkingCopy, draft: WorkingCopyInput): void;
updateEditorDraft(draft: WorkingCopyInput): void;
setEditorStatus(status: StudioEditorStatus): void;
clearEditor(): void;
}>;
export const StudioContext = createContext<StudioContextValue | null>(null);
@@ -16,3 +34,23 @@ export function useStudio(): StudioContextValue {
if (!context) throw new Error("useStudio must be used within StudioProvider");
return context;
}
export function useStudioEditorSession() {
const studio = useStudio();
return useMemo(
() => ({
editor: studio.editor,
begin: studio.beginEditor,
updateDraft: studio.updateEditorDraft,
setStatus: studio.setEditorStatus,
clear: studio.clearEditor,
}),
[
studio.beginEditor,
studio.clearEditor,
studio.editor,
studio.setEditorStatus,
studio.updateEditorDraft,
],
);
}