refactor(admin): scene 업로드 화면을 단순화

This commit is contained in:
2026-03-10 14:30:25 +09:00
parent 1717f335f0
commit c47f60163d
2 changed files with 113 additions and 65 deletions

View File

@@ -82,6 +82,7 @@ const formatResultSummary = (uploadResult: UploadResult | null) => {
export default function AdminPage() { export default function AdminPage() {
const [session, setSession] = useState<AuthResponse | null>(null); const [session, setSession] = useState<AuthResponse | null>(null);
const [activeView, setActiveView] = useState<AdminView>('scene'); const [activeView, setActiveView] = useState<AdminView>('scene');
const [isDurationOverrideEnabled, setIsDurationOverrideEnabled] = useState(false);
const [loginId, setLoginId] = useState<string>(copy.admin.defaultLoginId); const [loginId, setLoginId] = useState<string>(copy.admin.defaultLoginId);
const [password, setPassword] = useState<string>(copy.admin.defaultPassword); const [password, setPassword] = useState<string>(copy.admin.defaultPassword);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
@@ -123,6 +124,7 @@ export default function AdminPage() {
const handleLogout = () => { const handleLogout = () => {
setSession(null); setSession(null);
setActiveView('scene'); setActiveView('scene');
setIsDurationOverrideEnabled(false);
setSceneMessage(null); setSceneMessage(null);
setSoundMessage(null); setSoundMessage(null);
setUploadResult(null); setUploadResult(null);
@@ -149,18 +151,15 @@ export default function AdminPage() {
} }
const formData = new FormData(); const formData = new FormData();
for (const fieldName of ['cardFile', 'stageFile', 'mobileStageFile', 'hdStageFile']) { const sourceImageFile = source.get('sourceImageFile');
const file = source.get(fieldName);
if (file instanceof File && file.size > 0) { if (sourceImageFile instanceof File && sourceImageFile.size > 0) {
formData.append(fieldName, file); formData.append('sourceImageFile', sourceImageFile);
}
} }
for (const fieldName of ['placeholderGradient', 'blurDataUrl']) { const blurDataUrl = String(source.get('blurDataUrl') ?? '').trim();
const value = String(source.get(fieldName) ?? '').trim(); if (blurDataUrl) {
if (value) { formData.append('blurDataUrl', blurDataUrl);
formData.append(fieldName, value);
}
} }
const response = await adminApi.uploadScene(sceneId, formData, session.accessToken); const response = await adminApi.uploadScene(sceneId, formData, session.accessToken);
@@ -201,10 +200,15 @@ export default function AdminPage() {
} }
} }
for (const fieldName of ['durationSec', 'defaultVolume']) { const defaultVolume = String(source.get('defaultVolume') ?? '').trim();
const value = String(source.get(fieldName) ?? '').trim(); if (defaultVolume) {
if (value) { formData.append('defaultVolume', defaultVolume);
formData.append(fieldName, value); }
if (isDurationOverrideEnabled) {
const durationSec = String(source.get('durationSec') ?? '').trim();
if (durationSec) {
formData.append('durationSec', durationSec);
} }
} }
@@ -212,6 +216,7 @@ export default function AdminPage() {
setUploadResult({ type: 'sound', payload: response }); setUploadResult({ type: 'sound', payload: response });
setSoundMessage(copy.admin.messages.soundUploadDone(presetId)); setSoundMessage(copy.admin.messages.soundUploadDone(presetId));
form.reset(); form.reset();
setIsDurationOverrideEnabled(false);
} catch (error) { } catch (error) {
setSoundMessage(error instanceof Error ? error.message : copy.admin.messages.soundUploadFailed); setSoundMessage(error instanceof Error ? error.message : copy.admin.messages.soundUploadFailed);
} finally { } finally {
@@ -221,6 +226,8 @@ export default function AdminPage() {
const activeMeta = getViewMeta(activeView); const activeMeta = getViewMeta(activeView);
const currentMessage = activeView === 'scene' ? sceneMessage : soundMessage; const currentMessage = activeView === 'scene' ? sceneMessage : soundMessage;
const lastExtractedDurationSec =
uploadResult?.type === 'sound' ? uploadResult.payload.durationSec ?? null : null;
if (!session) { if (!session) {
return ( return (
@@ -488,48 +495,33 @@ export default function AdminPage() {
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]"> <div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="px-6 py-6"> <div className="px-6 py-6">
<div className="grid gap-5 md:grid-cols-2"> <div className="max-w-xl">
<div> <div>
<label htmlFor="scene-id" className="mb-2 block text-sm font-medium text-slate-700"> <label htmlFor="scene-id" className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.scene.sceneIdLabel} {copy.admin.views.scene.sceneIdLabel}
</label> </label>
<input id="scene-id" name="sceneId" className={fieldClassName} placeholder={copy.admin.views.scene.sceneIdPlaceholder} /> <input id="scene-id" name="sceneId" className={fieldClassName} placeholder={copy.admin.views.scene.sceneIdPlaceholder} />
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.sceneIdHint}</p>
</div> </div>
<div> </div>
<div className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-5">
<div className="max-w-2xl">
<label className="mb-2 block text-sm font-medium text-slate-700"> <label className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.scene.placeholderGradientLabel} {copy.admin.views.scene.sourceImageLabel}
</label> </label>
<input <input
name="placeholderGradient" name="sourceImageFile"
className={fieldClassName} type="file"
placeholder={copy.admin.views.scene.placeholderGradientPlaceholder} accept="image/jpeg,image/png"
className={fileClassName}
/> />
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.sourceImageHint}</p>
<p className="mt-1 text-xs text-slate-400">{copy.admin.views.scene.sourceImageDerivedHint}</p>
</div> </div>
</div> </div>
<div className="mt-5 grid gap-5 md:grid-cols-2"> <div className="mt-6 max-w-2xl">
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.cardImageLabel}</label>
<input name="cardFile" type="file" accept="image/*" className={fileClassName} />
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.stageImageLabel}</label>
<input name="stageFile" type="file" accept="image/*" className={fileClassName} />
</div>
</div>
<div className="mt-5 grid gap-5 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.mobileStageLabel}</label>
<input name="mobileStageFile" type="file" accept="image/*" className={fileClassName} />
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.hdStageLabel}</label>
<input name="hdStageFile" type="file" accept="image/*" className={fileClassName} />
</div>
</div>
<div className="mt-5">
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.blurDataUrlLabel}</label> <label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.blurDataUrlLabel}</label>
<textarea <textarea
name="blurDataUrl" name="blurDataUrl"
@@ -537,6 +529,7 @@ export default function AdminPage() {
className={textareaClassName} className={textareaClassName}
placeholder={copy.admin.views.scene.blurDataUrlPlaceholder} placeholder={copy.admin.views.scene.blurDataUrlPlaceholder}
/> />
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.blurDataUrlHint}</p>
</div> </div>
</div> </div>
@@ -576,16 +569,12 @@ export default function AdminPage() {
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]"> <div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="px-6 py-6"> <div className="px-6 py-6">
<div className="grid gap-5 md:grid-cols-3"> <div className="grid gap-5 md:grid-cols-3">
<div className="md:col-span-1"> <div className="md:col-span-2">
<label htmlFor="preset-id" className="mb-2 block text-sm font-medium text-slate-700"> <label htmlFor="preset-id" className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.sound.presetIdLabel} {copy.admin.views.sound.presetIdLabel}
</label> </label>
<input id="preset-id" name="presetId" className={fieldClassName} placeholder={copy.admin.views.sound.presetIdPlaceholder} /> <input id="preset-id" name="presetId" className={fieldClassName} placeholder={copy.admin.views.sound.presetIdPlaceholder} />
</div> </div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.sound.durationLabel}</label>
<input name="durationSec" type="number" min={0} className={fieldClassName} placeholder={copy.admin.views.sound.durationPlaceholder} />
</div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.sound.defaultVolumeLabel}</label> <label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.sound.defaultVolumeLabel}</label>
<input name="defaultVolume" type="number" min={0} max={100} className={fieldClassName} placeholder={copy.admin.views.sound.defaultVolumePlaceholder} /> <input name="defaultVolume" type="number" min={0} max={100} className={fieldClassName} placeholder={copy.admin.views.sound.defaultVolumePlaceholder} />
@@ -607,10 +596,62 @@ export default function AdminPage() {
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.sound.fallbackLoopFileLabel}</label> <label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.sound.fallbackLoopFileLabel}</label>
<input name="fallbackLoopFile" type="file" accept="audio/*" className={fileClassName} /> <input name="fallbackLoopFile" type="file" accept="audio/*" className={fileClassName} />
</div> </div>
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-slate-900">
{copy.admin.views.sound.durationOverrideToggle}
</p>
<p className="mt-1 text-xs leading-5 text-slate-500">
{copy.admin.views.sound.durationOverrideHint}
</p>
</div>
<button
type="button"
onClick={() => setIsDurationOverrideEnabled((current) => !current)}
className={`inline-flex h-7 w-12 items-center rounded-full px-1 transition ${
isDurationOverrideEnabled ? 'bg-slate-900' : 'bg-slate-300'
}`}
aria-pressed={isDurationOverrideEnabled}
>
<span
className={`h-5 w-5 rounded-full bg-white transition ${
isDurationOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{isDurationOverrideEnabled ? (
<div className="mt-4">
<label className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.sound.durationLabel}
</label>
<input
name="durationSec"
type="number"
min={0}
className={fieldClassName}
placeholder={copy.admin.views.sound.durationPlaceholder}
/>
</div>
) : null}
</div>
</div> </div>
<div className="border-t border-slate-200 bg-slate-50 px-6 py-6 xl:border-l xl:border-t-0"> <div className="border-t border-slate-200 bg-slate-50 px-6 py-6 xl:border-l xl:border-t-0">
<p className="text-sm font-semibold text-slate-900">{copy.admin.views.sound.notesTitle}</p> <p className="text-sm font-semibold text-slate-900">{copy.admin.views.sound.notesTitle}</p>
<div className="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
{copy.admin.views.sound.extractedDurationLabel}
</p>
<p className="mt-2 text-sm font-medium text-slate-900">
{lastExtractedDurationSec == null
? copy.admin.views.sound.extractedDurationEmpty
: copy.admin.views.sound.extractedDurationValue(lastExtractedDurationSec)}
</p>
</div>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500"> <ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
{copy.admin.views.sound.notes.map((note) => ( {copy.admin.views.sound.notes.map((note) => (
<li key={note}>{note}</li> <li key={note}>{note}</li>

View File

@@ -182,26 +182,27 @@ export const ko = {
eyebrow: 'Image Registration', eyebrow: 'Image Registration',
title: 'Scene 이미지 등록', title: 'Scene 이미지 등록',
description: description:
'Space에 들어온 사용자가 선택하는 배경 이미지 세트를 등록합니다. cardFile과 stageFile은 최초 생성 시 필수입니다.', 'Space에 들어온 사용자가 선택하는 배경 이미지 세트를 등록합니다. 원본 1장을 올리면 서버가 card, stage, mobileStage, hdStage를 자동 생성합니다.',
statTitle: 'Image Pipeline', statTitle: 'Image Pipeline',
statValue: 'Scene Assets', statValue: 'Scene Assets',
statHint: 'R2 / Manifest Sync', statHint: 'R2 / Manifest Sync',
workspaceTitle: '이미지 등록 워크스페이스', workspaceTitle: '이미지 등록 워크스페이스',
workspaceDescription: '왼쪽 메뉴는 유지되고, 이 중앙 영역만 `이미지 등록` 작업으로 전환됩니다.', workspaceDescription: '왼쪽 메뉴는 유지되고, 이 중앙 영역에서 원본 이미지 1장으로 전체 scene 파생본을 생성합니다.',
sceneIdLabel: 'Scene ID', sceneIdLabel: 'Scene ID',
sceneIdPlaceholder: 'rain-window', sceneIdPlaceholder: 'rain-window',
placeholderGradientLabel: 'Placeholder Gradient', sceneIdHint: 'scene catalog에 등록된 id와 동일한 값을 사용하세요.',
placeholderGradientPlaceholder: 'linear-gradient(160deg, #1e293b 0%, #0f172a 100%)', sourceImageLabel: 'Source Image',
cardImageLabel: 'Card Image', sourceImageHint: '최소 3200x1800 · 권장 3840x2160',
stageImageLabel: 'Stage Image', sourceImageDerivedHint: '업로드하면 card, stage, mobileStage, hdStage 파생본이 자동 생성됩니다.',
mobileStageLabel: 'Mobile Stage', blurDataUrlLabel: 'Blur Placeholder Data URL',
hdStageLabel: 'HD Stage', blurDataUrlPlaceholder: 'data:image/jpeg;base64,... 저해상도 프리뷰 문자열',
blurDataUrlLabel: 'Blur Data URL', blurDataUrlHint: '선택 입력입니다. 이미지가 완전히 로드되기 전 잠깐 보여줄 저해상도 프리뷰가 필요할 때만 넣으세요.',
blurDataUrlPlaceholder: 'data:image/jpeg;base64,...',
notesTitle: '업로드 메모', notesTitle: '업로드 메모',
notes: [ notes: [
'최초 생성 시 `cardFile`, `stageFile`은 필수입니다.', '최초 생성 시 `sourceImageFile`은 필수입니다.',
'모바일과 HD 파일은 선택값이며 빠지면 기존 값을 유지합니다.', '원본 1장을 업로드하면 서버가 4개 파생본(card, stage, mobileStage, hdStage)을 자동 생성합니다.',
'가로형 기준 최소 3200x1800, 권장 3840x2160 원본을 사용하세요.',
'`blurDataUrl`은 이미지가 완전히 로드되기 전 잠깐 보여줄 저해상도 base64 프리뷰입니다.',
'업로드 즉시 R2 경로와 manifest 응답 값이 갱신됩니다.', '업로드 즉시 R2 경로와 manifest 응답 값이 갱신됩니다.',
], ],
submit: '이미지 등록', submit: '이미지 등록',
@@ -211,16 +212,21 @@ export const ko = {
eyebrow: 'Audio Registration', eyebrow: 'Audio Registration',
title: 'Sound 오디오 등록', title: 'Sound 오디오 등록',
description: description:
'Loop, preview, fallback 오디오를 등록합니다. loopFile은 최초 생성 시 필수이며 기본 볼륨과 duration 메타데이터를 함께 저장합니다.', 'Loop, preview, fallback 오디오를 등록합니다. loopFile은 최초 생성 시 필수이며 duration은 기본적으로 loopFile에서 자동 추출됩니다.',
statTitle: 'Audio Pipeline', statTitle: 'Audio Pipeline',
statValue: 'Sound Assets', statValue: 'Sound Assets',
statHint: 'Loop / Preview / Fallback', statHint: 'Loop / Preview / Fallback',
workspaceTitle: '오디오 등록 워크스페이스', workspaceTitle: '오디오 등록 워크스페이스',
workspaceDescription: '좌측 네비게이션은 고정되고, 이 중앙 영역만 `오디오 등록` 작업으로 바뀝니다.', workspaceDescription: '좌측 네비게이션은 고정되고, 이 중앙 영역에서 loop 메타데이터와 파생 오디오를 함께 관리합니다.',
presetIdLabel: 'Preset ID', presetIdLabel: 'Preset ID',
presetIdPlaceholder: 'rain-focus', presetIdPlaceholder: 'rain-focus',
durationLabel: 'Duration Sec', durationLabel: 'Duration Override Sec',
durationPlaceholder: '1800', durationPlaceholder: '비워두면 loopFile에서 자동 추출',
durationOverrideToggle: '수동 duration 보정',
durationOverrideHint: '기본값은 loopFile에서 자동 추출됩니다. 값을 넣으면 자동 추출 대신 이 값으로 저장합니다.',
extractedDurationLabel: '최근 추출 길이',
extractedDurationEmpty: '아직 자동 추출 결과가 없습니다.',
extractedDurationValue: (seconds: number) => `${seconds} sec`,
defaultVolumeLabel: 'Default Volume', defaultVolumeLabel: 'Default Volume',
defaultVolumePlaceholder: '60', defaultVolumePlaceholder: '60',
loopFileLabel: 'Loop File', loopFileLabel: 'Loop File',
@@ -230,7 +236,8 @@ export const ko = {
notes: [ notes: [
'최초 생성 시 `loopFile`은 필수입니다.', '최초 생성 시 `loopFile`은 필수입니다.',
'`preview`, `fallback`은 선택값이며 기존 메타데이터를 유지할 수 있습니다.', '`preview`, `fallback`은 선택값이며 기존 메타데이터를 유지할 수 있습니다.',
'`defaultVolume`, `durationSec`는 manifest 응답에 함께 반영됩니다.', '`durationSec`은 기본적으로 자동 추출되고, 필요할 때만 수동 override를 켤 수 있습니다.',
'`defaultVolume`와 최종 `durationSec`는 manifest 응답에 함께 반영됩니다.',
], ],
submit: '오디오 등록', submit: '오디오 등록',
pending: '오디오 업로드 중...', pending: '오디오 업로드 중...',