Refine program metadata layout

This commit is contained in:
2026-06-25 17:47:00 +09:00
parent 79cda65da9
commit ea3009e922

View File

@@ -10,7 +10,6 @@ import {
Layers3, Layers3,
Map, Map,
Maximize2, Maximize2,
Minimize2,
Mountain, Mountain,
Plus, Plus,
Route, Route,
@@ -222,6 +221,7 @@ const defaultContent = {
steps: cheonjiinFlow.map(({ title, feature, note }) => ({ title, feature, note })), steps: cheonjiinFlow.map(({ title, feature, note }) => ({ title, feature, note })),
deliverables: cheonjiinDeliverables, deliverables: cheonjiinDeliverables,
format: 'glb', format: 'glb',
engine: '',
programType: 'internal', programType: 'internal',
programNote: '', programNote: '',
predecessors: [], predecessors: [],
@@ -233,6 +233,7 @@ const defaultContent = {
description: '설계조건 입력부터 기본설계 성과물 생성', description: '설계조건 입력부터 기본설계 성과물 생성',
steps: wayPrimalFlow.map(({ title, feature, note }) => ({ title, feature, note })), steps: wayPrimalFlow.map(({ title, feature, note }) => ({ title, feature, note })),
format: '', format: '',
engine: '',
deliverables: ['기본설계 모델'], deliverables: ['기본설계 모델'],
programType: 'internal', programType: 'internal',
programNote: '', programNote: '',
@@ -253,6 +254,7 @@ function normalizeStoredContent(parsed) {
...(parsed.cheonjiin ?? {}), ...(parsed.cheonjiin ?? {}),
programType: getProgramType(parsed.cheonjiin?.programType ?? defaultContent.cheonjiin.programType), programType: getProgramType(parsed.cheonjiin?.programType ?? defaultContent.cheonjiin.programType),
programNote: parsed.cheonjiin?.programNote ?? '', programNote: parsed.cheonjiin?.programNote ?? '',
engine: parsed.cheonjiin?.engine ?? '',
predecessors: parsed.cheonjiin?.predecessors ?? [], predecessors: parsed.cheonjiin?.predecessors ?? [],
successors: parsed.cheonjiin?.successors ?? [], successors: parsed.cheonjiin?.successors ?? [],
mergeGroup: parsed.cheonjiin?.mergeGroup ?? '', mergeGroup: parsed.cheonjiin?.mergeGroup ?? '',
@@ -266,6 +268,7 @@ function normalizeStoredContent(parsed) {
...(parsed.wayPrimal ?? {}), ...(parsed.wayPrimal ?? {}),
programType: getProgramType(parsed.wayPrimal?.programType ?? defaultContent.wayPrimal.programType), programType: getProgramType(parsed.wayPrimal?.programType ?? defaultContent.wayPrimal.programType),
programNote: parsed.wayPrimal?.programNote ?? '', programNote: parsed.wayPrimal?.programNote ?? '',
engine: parsed.wayPrimal?.engine ?? '',
predecessors: parsed.wayPrimal?.predecessors ?? [], predecessors: parsed.wayPrimal?.predecessors ?? [],
successors: parsed.wayPrimal?.successors ?? [], successors: parsed.wayPrimal?.successors ?? [],
mergeGroup: parsed.wayPrimal?.mergeGroup ?? '', mergeGroup: parsed.wayPrimal?.mergeGroup ?? '',
@@ -283,6 +286,7 @@ function normalizeStoredContent(parsed) {
...program, ...program,
programType: getProgramType(program.programType), programType: getProgramType(program.programType),
programNote: program.programNote ?? '', programNote: program.programNote ?? '',
engine: program.engine ?? '',
predecessors: program.predecessors ?? [], predecessors: program.predecessors ?? [],
successors: program.successors ?? [], successors: program.successors ?? [],
mergeGroup: program.mergeGroup ?? '', mergeGroup: program.mergeGroup ?? '',
@@ -568,6 +572,8 @@ function FlowRow({
onStepChange, onStepChange,
format, format,
onFormatChange, onFormatChange,
engine = '',
onEngineChange,
deliverables = [], deliverables = [],
onDeliverableChange, onDeliverableChange,
onLabelChange, onLabelChange,
@@ -676,8 +682,11 @@ function FlowRow({
isRowClickable ? 'cursor-pointer transition hover:-translate-y-0.5 hover:shadow-[0_22px_60px_rgba(15,23,42,0.12)]' : '' isRowClickable ? 'cursor-pointer transition hover:-translate-y-0.5 hover:shadow-[0_22px_60px_rgba(15,23,42,0.12)]' : ''
}`} }`}
> >
<div className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between"> <div
<div> className="mb-5 flex flex-col gap-2 md:grid md:items-start md:justify-start md:gap-5"
style={{ gridTemplateColumns: '720px minmax(0, 1fr)' }}
>
<div className="max-w-none">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{isEditing && onLabelChange ? ( {isEditing && onLabelChange ? (
<input <input
@@ -740,7 +749,7 @@ function FlowRow({
value={description} value={description}
onChange={(event) => onDescriptionChange(event.target.value)} onChange={(event) => onDescriptionChange(event.target.value)}
rows={2} rows={2}
className="mt-1 w-full min-w-[360px] resize-none rounded-xl border border-slate-200 bg-white/85 px-3 py-1.5 text-sm font-bold tracking-tight text-slate-800 outline-none focus:border-teal-400" className="mt-1 w-full resize-none rounded-xl border border-slate-200 bg-white/85 px-3 py-1.5 text-sm font-bold tracking-tight text-slate-800 outline-none focus:border-teal-400"
/> />
) : ( ) : (
<h2 className="mt-1 whitespace-pre-line text-sm font-bold tracking-tight text-slate-800">{description}</h2> <h2 className="mt-1 whitespace-pre-line text-sm font-bold tracking-tight text-slate-800">{description}</h2>
@@ -752,54 +761,77 @@ function FlowRow({
onChange={(event) => onProgramNoteChange(event.target.value)} onChange={(event) => onProgramNoteChange(event.target.value)}
rows={2} rows={2}
placeholder="프로그램 노트를 입력하세요" placeholder="프로그램 노트를 입력하세요"
className="mt-2 w-full min-w-[360px] resize-none rounded-xl border border-amber-100 bg-amber-50/70 px-3 py-2 text-sm font-bold leading-5 text-slate-800 outline-none focus:border-amber-300" className="mt-2 w-full resize-none rounded-xl border border-amber-100 bg-amber-50/70 px-3 py-2 text-sm font-bold leading-5 text-slate-800 outline-none focus:border-amber-300"
/> />
) : programNote ? ( ) : programNote ? (
<div className="mt-2 max-w-3xl rounded-2xl bg-amber-50/80 px-3 py-2 text-sm font-semibold leading-5 text-amber-900 ring-1 ring-amber-100"> <div className="mt-2 rounded-2xl bg-amber-50/80 px-3 py-2 text-sm font-semibold leading-5 text-amber-900 ring-1 ring-amber-100">
<span className="mr-2 text-[11px] font-black uppercase tracking-wide text-amber-600">NOTE</span> <span className="mr-2 text-[11px] font-black uppercase tracking-wide text-amber-600">NOTE</span>
<span className="whitespace-pre-line">{programNote}</span> <span className="whitespace-pre-line">{programNote}</span>
</div> </div>
) : null} ) : null}
</div> </div>
<div className="flex flex-col items-start gap-2 md:items-end"> <div className="grid w-full gap-1.5 md:pt-1">
{onFormatChange && ( {onFormatChange && (
<div className="flex items-center gap-2"> <div className="grid w-full grid-cols-[62px_minmax(0,1fr)] items-center gap-2">
<span className="text-[11px] font-extrabold text-slate-400">포맷</span> <span className="text-left text-[11px] font-extrabold text-slate-400">포맷</span>
{isEditing ? ( <div className="flex flex-wrap justify-start gap-1.5">
<input {isEditing ? (
value={format} <input
onChange={(event) => onFormatChange(event.target.value)} value={format}
placeholder="예: glb" onChange={(event) => onFormatChange(event.target.value)}
className="h-8 w-28 rounded-full border border-slate-200 bg-white/85 px-3 text-[12px] font-extrabold text-slate-700 outline-none focus:border-teal-400" placeholder="예: glb"
/> className="h-8 w-32 rounded-full border border-slate-200 bg-white/85 px-3 text-[12px] font-extrabold text-slate-700 outline-none focus:border-teal-400"
) : ( />
<span className="min-w-16 rounded-full bg-slate-100 px-3 py-1.5 text-center text-[12px] font-extrabold text-slate-700"> ) : (
{format || '-'} <span className="min-w-16 rounded-full bg-slate-100 px-3 py-1.5 text-center text-[12px] font-extrabold text-slate-700">
</span> {format || '-'}
)} </span>
)}
</div>
</div> </div>
)} )}
{(deliverables.length > 0 || isEditing) && ( {(deliverables.length > 0 || isEditing) && (
<div className="flex max-w-[720px] flex-wrap items-center justify-start gap-1.5 md:justify-end"> <div className="grid w-full grid-cols-[62px_minmax(0,1fr)] items-start gap-2">
<span className={`mr-1 text-[11px] font-extrabold ${accent.labelText}`}>성과물</span> <span className={`pt-1 text-left text-[11px] font-extrabold ${accent.labelText}`}>성과물</span>
{(deliverables.length > 0 ? deliverables : ['']).map((item, index) => ( <div className="flex flex-wrap justify-start gap-1.5">
isEditing ? ( {(deliverables.length > 0 ? deliverables : ['']).map((item, index) => (
isEditing ? (
<input
key={index}
value={item}
onChange={(event) => onDeliverableChange?.(index, event.target.value)}
placeholder="성과물"
className="h-7 w-28 rounded-full border border-slate-200 bg-white/85 px-3 text-[11px] font-extrabold text-slate-700 outline-none focus:border-teal-400"
/>
) : splitDeliverableText(item).map((deliverable, deliverableIndex) => (
<span
key={`${deliverable}-${index}-${deliverableIndex}`}
className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-extrabold text-slate-700"
>
{deliverable}
</span>
))
))}
</div>
</div>
)}
{onEngineChange && (
<div className="grid w-full grid-cols-[62px_minmax(0,1fr)] items-center gap-2">
<span className="text-left text-[11px] font-extrabold text-slate-400">사용엔진</span>
<div className="flex flex-wrap justify-start gap-1.5">
{isEditing ? (
<input <input
key={index} value={engine}
value={item} onChange={(event) => onEngineChange(event.target.value)}
onChange={(event) => onDeliverableChange?.(index, event.target.value)} placeholder="예: hmeg, gsim"
placeholder="성과물" className="h-8 w-40 rounded-full border border-slate-200 bg-white/85 px-3 text-[12px] font-extrabold text-slate-700 outline-none focus:border-teal-400"
className="h-7 w-28 rounded-full border border-slate-200 bg-white/85 px-3 text-[11px] font-extrabold text-slate-700 outline-none focus:border-teal-400"
/> />
) : splitDeliverableText(item).map((deliverable, deliverableIndex) => ( ) : (
<span <span className="min-w-16 rounded-full bg-slate-100 px-3 py-1.5 text-center text-[12px] font-extrabold text-slate-700">
key={`${deliverable}-${index}-${deliverableIndex}`} {engine || '-'}
className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-extrabold text-slate-700" </span>
> )}
{deliverable} </div>
</span>
))
))}
</div> </div>
)} )}
{isEditing && onAddStep && ( {isEditing && onAddStep && (
@@ -1597,16 +1629,30 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
</span> </span>
</div> </div>
<p className="mt-2 text-sm font-bold leading-5 text-slate-700">{program.description}</p> <p className="mt-2 text-sm font-bold leading-5 text-slate-700">{program.description}</p>
<div className="mt-3 flex flex-wrap gap-1.5"> <div className="mt-3 grid gap-2 text-[12px] font-extrabold text-slate-500">
{(program.deliverables ?? []).map((deliverable) => ( <div className="grid grid-cols-[64px_minmax(0,1fr)] items-start gap-2">
<span key={deliverable} className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-black text-slate-600 ring-1 ring-white"> <span className="text-right text-slate-400">포맷</span>
{deliverable} <span className="text-slate-800">{program.format || '-'}</span>
</span> </div>
))} <div className="grid grid-cols-[64px_minmax(0,1fr)] items-start gap-2">
<span className="text-right text-slate-400">성과물</span>
<div className="flex flex-wrap gap-1.5">
{(program.deliverables ?? []).length > 0 ? (
(program.deliverables ?? []).map((deliverable) => (
<span key={deliverable} className="rounded-full bg-white/80 px-2.5 py-1 text-[11px] font-black text-slate-600 ring-1 ring-white">
{deliverable}
</span>
))
) : (
<span className="text-slate-800">-</span>
)}
</div>
</div>
<div className="grid grid-cols-[64px_minmax(0,1fr)] items-start gap-2">
<span className="text-right text-slate-400">사용엔진</span>
<span className="text-slate-800">{program.engine || '-'}</span>
</div>
</div> </div>
<p className="mt-3 text-[12px] font-extrabold text-slate-500">
포맷: <span className="text-slate-800">{program.format || '-'}</span>
</p>
</div> </div>
); );
}; };
@@ -2083,8 +2129,7 @@ function RelationTreePanel({
const miniRowGap = fullPage ? 96 : 72; const miniRowGap = fullPage ? 96 : 72;
const miniPadding = 34; const miniPadding = 34;
const miniSideLaneWidth = 96; const miniSideLaneWidth = 96;
const sidebarSizes = [420, 560, 720]; const resolvedSidebarWidth = sidebarWidth ?? 420;
const resolvedSidebarWidth = sidebarWidth ?? sidebarSizes[0];
const miniViewportWidth = Math.max(290, fullPage ? viewportSize.width - 64 : resolvedSidebarWidth - 24); const miniViewportWidth = Math.max(290, fullPage ? viewportSize.width - 64 : resolvedSidebarWidth - 24);
const miniMaxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0)); const miniMaxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0));
const miniGraphWidth = Math.max( const miniGraphWidth = Math.max(
@@ -2138,11 +2183,6 @@ function RelationTreePanel({
const miniScaledWidth = miniGraphWidth * miniGraphScale; const miniScaledWidth = miniGraphWidth * miniGraphScale;
const mapOffset = fullPage ? 0 : Math.max(0, Math.round((miniScaledWidth - miniViewportWidth) / 2 / miniGraphScale)); const mapOffset = fullPage ? 0 : Math.max(0, Math.round((miniScaledWidth - miniViewportWidth) / 2 / miniGraphScale));
const mapTranslateX = fullPage ? Math.max(0, Math.round((miniViewportWidth - miniScaledWidth) / 2)) : -mapOffset; const mapTranslateX = fullPage ? Math.max(0, Math.round((miniViewportWidth - miniScaledWidth) / 2)) : -mapOffset;
const canShrinkSidebar = resolvedSidebarWidth > sidebarSizes[0];
const setSidebarSize = (size) => {
onSidebarWidthChange?.(size);
};
useEffect(() => { useEffect(() => {
if (!fullPage) return undefined; if (!fullPage) return undefined;
const updateViewportSize = () => { const updateViewportSize = () => {
@@ -2332,29 +2372,19 @@ function RelationTreePanel({
)} )}
</div> </div>
{!fullPage && ( {!fullPage && (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<button <button
type="button" type="button"
disabled={!canShrinkSidebar} onClick={onOpenMapWindow}
onClick={() => setSidebarSize(sidebarSizes[0])} className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-slate-500 shadow-sm ring-1 ring-slate-200 hover:bg-slate-50"
className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-slate-500 shadow-sm ring-1 ring-slate-200 hover:bg-slate-50 disabled:opacity-25" aria-label="연결도 새창으로 보기"
aria-label="연결도 영역 최소" title="연결도 새창으로 보기"
title="연결도 영역 최소" >
> <Maximize2 className="h-3.5 w-3.5" />
<Minimize2 className="h-3.5 w-3.5" /> </button>
</button> </div>
<button
type="button"
onClick={onOpenMapWindow}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-slate-500 shadow-sm ring-1 ring-slate-200 hover:bg-slate-50 disabled:opacity-25"
aria-label="연결도 새창으로 보기"
title="연결도 새창으로 보기"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div> </div>
</div>
)} )}
</div> </div>
{relations.length > 0 ? ( {relations.length > 0 ? (
@@ -2655,6 +2685,7 @@ export default function App() {
programNote: content.cheonjiin.programNote ?? '', programNote: content.cheonjiin.programNote ?? '',
steps: editableCheonjiinFlow, steps: editableCheonjiinFlow,
format: content.cheonjiin.format, format: content.cheonjiin.format,
engine: content.cheonjiin.engine ?? '',
programType: getProgramType(content.cheonjiin.programType), programType: getProgramType(content.cheonjiin.programType),
predecessors: content.cheonjiin.predecessors ?? [], predecessors: content.cheonjiin.predecessors ?? [],
successors: content.cheonjiin.successors ?? [], successors: content.cheonjiin.successors ?? [],
@@ -2673,6 +2704,7 @@ export default function App() {
programNote: content.wayPrimal.programNote ?? '', programNote: content.wayPrimal.programNote ?? '',
steps: editableWayPrimalFlow, steps: editableWayPrimalFlow,
format: content.wayPrimal.format, format: content.wayPrimal.format,
engine: content.wayPrimal.engine ?? '',
programType: getProgramType(content.wayPrimal.programType), programType: getProgramType(content.wayPrimal.programType),
predecessors: content.wayPrimal.predecessors ?? [], predecessors: content.wayPrimal.predecessors ?? [],
successors: content.wayPrimal.successors ?? [], successors: content.wayPrimal.successors ?? [],
@@ -2691,6 +2723,7 @@ export default function App() {
programNote: program.programNote ?? '', programNote: program.programNote ?? '',
steps: normalizeProgramSteps(program), steps: normalizeProgramSteps(program),
format: program.format, format: program.format,
engine: program.engine ?? '',
programType: getProgramType(program.programType), programType: getProgramType(program.programType),
predecessors: program.predecessors ?? [], predecessors: program.predecessors ?? [],
successors: program.successors ?? [], successors: program.successors ?? [],
@@ -3455,6 +3488,7 @@ export default function App() {
} }
], ],
format: '', format: '',
engine: '',
deliverables: ['성과물'], deliverables: ['성과물'],
programType: 'internal', programType: 'internal',
programNote: '', programNote: '',
@@ -3709,7 +3743,17 @@ export default function App() {
return ( return (
<main className="min-h-screen bg-white text-slate-900"> <main className="min-h-screen bg-white text-slate-900">
<div className="mx-auto max-w-[1760px] space-y-4 px-3 py-4"> <div className="mx-auto max-w-[1760px] space-y-4 px-3 py-4">
<div className="flex justify-end gap-2"> <div className="sticky top-0 z-50 -mx-3 flex justify-end gap-2 border-b border-slate-100 bg-white/95 px-3 py-3 backdrop-blur">
{isEditing && (
<button
type="button"
onClick={addProgram}
className="flex items-center gap-2 rounded-full bg-blue-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
프로그램 추가
</button>
)}
<button <button
type="button" type="button"
onClick={toggleProgramEdit} onClick={toggleProgramEdit}
@@ -3748,26 +3792,6 @@ export default function App() {
/> />
<section className="min-w-0 space-y-5"> <section className="min-w-0 space-y-5">
{isEditing && (
<div className="flex items-center justify-between rounded-[24px] border border-white/70 bg-white/70 px-5 py-4 shadow-sm backdrop-blur">
<div>
<p className="text-[12px] font-extrabold uppercase tracking-wide text-blue-700">
프로그램 편집
</p>
<h2 className="mt-1 text-lg font-extrabold text-slate-950">
프로그램을 추가하거나 기존 내용을 수정합니다
</h2>
</div>
<button
type="button"
onClick={addProgram}
className="flex items-center gap-2 rounded-full bg-blue-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
프로그램 추가
</button>
</div>
)}
<FlowRow <FlowRow
label={content.cheonjiin.name} label={content.cheonjiin.name}
@@ -3785,6 +3809,8 @@ export default function App() {
onProgramTypeChange={(value) => updateProgramTitle('cheonjiin', 'programType', value)} onProgramTypeChange={(value) => updateProgramTitle('cheonjiin', 'programType', value)}
format={content.cheonjiin.format} format={content.cheonjiin.format}
onFormatChange={(value) => updateFormat('cheonjiin', value)} onFormatChange={(value) => updateFormat('cheonjiin', value)}
engine={content.cheonjiin.engine ?? ''}
onEngineChange={(value) => updateProgramTitle('cheonjiin', 'engine', value)}
deliverables={content.cheonjiin.deliverables} deliverables={content.cheonjiin.deliverables}
onDeliverableChange={(index, value) => updateProgramDeliverable('cheonjiin', index, value)} onDeliverableChange={(index, value) => updateProgramDeliverable('cheonjiin', index, value)}
onAddStep={() => addStep('cheonjiin')} onAddStep={() => addStep('cheonjiin')}
@@ -3819,6 +3845,8 @@ export default function App() {
onProgramTypeChange={(value) => updateProgramTitle('wayPrimal', 'programType', value)} onProgramTypeChange={(value) => updateProgramTitle('wayPrimal', 'programType', value)}
format={content.wayPrimal.format} format={content.wayPrimal.format}
onFormatChange={(value) => updateFormat('wayPrimal', value)} onFormatChange={(value) => updateFormat('wayPrimal', value)}
engine={content.wayPrimal.engine ?? ''}
onEngineChange={(value) => updateProgramTitle('wayPrimal', 'engine', value)}
deliverables={content.wayPrimal.deliverables ?? []} deliverables={content.wayPrimal.deliverables ?? []}
onDeliverableChange={(index, value) => updateProgramDeliverable('wayPrimal', index, value)} onDeliverableChange={(index, value) => updateProgramDeliverable('wayPrimal', index, value)}
onAddStep={() => addStep('wayPrimal')} onAddStep={() => addStep('wayPrimal')}
@@ -3907,6 +3935,8 @@ export default function App() {
onProgramTypeChange={(value) => updateProgram(programIndex, 'programType', value)} onProgramTypeChange={(value) => updateProgram(programIndex, 'programType', value)}
format={program.format} format={program.format}
onFormatChange={(value) => updateProgram(programIndex, 'format', value)} onFormatChange={(value) => updateProgram(programIndex, 'format', value)}
engine={program.engine ?? ''}
onEngineChange={(value) => updateProgram(programIndex, 'engine', value)}
deliverables={program.deliverables ?? []} deliverables={program.deliverables ?? []}
onDeliverableChange={(index, value) => updateProgramDeliverable(program.id, index, value)} onDeliverableChange={(index, value) => updateProgramDeliverable(program.id, index, value)}
onAddStep={() => addStep(program.id)} onAddStep={() => addStep(program.id)}