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() {
const [session, setSession] = useState<AuthResponse | null>(null);
const [activeView, setActiveView] = useState<AdminView>('scene');
const [isDurationOverrideEnabled, setIsDurationOverrideEnabled] = useState(false);
const [loginId, setLoginId] = useState<string>(copy.admin.defaultLoginId);
const [password, setPassword] = useState<string>(copy.admin.defaultPassword);
const [loginError, setLoginError] = useState<string | null>(null);
@@ -123,6 +124,7 @@ export default function AdminPage() {
const handleLogout = () => {
setSession(null);
setActiveView('scene');
setIsDurationOverrideEnabled(false);
setSceneMessage(null);
setSoundMessage(null);
setUploadResult(null);
@@ -149,18 +151,15 @@ export default function AdminPage() {
}
const formData = new FormData();
for (const fieldName of ['cardFile', 'stageFile', 'mobileStageFile', 'hdStageFile']) {
const file = source.get(fieldName);
if (file instanceof File && file.size > 0) {
formData.append(fieldName, file);
}
const sourceImageFile = source.get('sourceImageFile');
if (sourceImageFile instanceof File && sourceImageFile.size > 0) {
formData.append('sourceImageFile', sourceImageFile);
}
for (const fieldName of ['placeholderGradient', 'blurDataUrl']) {
const value = String(source.get(fieldName) ?? '').trim();
if (value) {
formData.append(fieldName, value);
}
const blurDataUrl = String(source.get('blurDataUrl') ?? '').trim();
if (blurDataUrl) {
formData.append('blurDataUrl', blurDataUrl);
}
const response = await adminApi.uploadScene(sceneId, formData, session.accessToken);
@@ -201,10 +200,15 @@ export default function AdminPage() {
}
}
for (const fieldName of ['durationSec', 'defaultVolume']) {
const value = String(source.get(fieldName) ?? '').trim();
if (value) {
formData.append(fieldName, value);
const defaultVolume = String(source.get('defaultVolume') ?? '').trim();
if (defaultVolume) {
formData.append('defaultVolume', defaultVolume);
}
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 });
setSoundMessage(copy.admin.messages.soundUploadDone(presetId));
form.reset();
setIsDurationOverrideEnabled(false);
} catch (error) {
setSoundMessage(error instanceof Error ? error.message : copy.admin.messages.soundUploadFailed);
} finally {
@@ -221,6 +226,8 @@ export default function AdminPage() {
const activeMeta = getViewMeta(activeView);
const currentMessage = activeView === 'scene' ? sceneMessage : soundMessage;
const lastExtractedDurationSec =
uploadResult?.type === 'sound' ? uploadResult.payload.durationSec ?? null : null;
if (!session) {
return (
@@ -488,48 +495,33 @@ export default function AdminPage() {
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="px-6 py-6">
<div className="grid gap-5 md:grid-cols-2">
<div className="max-w-xl">
<div>
<label htmlFor="scene-id" className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.scene.sceneIdLabel}
</label>
<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 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">
{copy.admin.views.scene.placeholderGradientLabel}
{copy.admin.views.scene.sourceImageLabel}
</label>
<input
name="placeholderGradient"
className={fieldClassName}
placeholder={copy.admin.views.scene.placeholderGradientPlaceholder}
name="sourceImageFile"
type="file"
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 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.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">
<div className="mt-6 max-w-2xl">
<label className="mb-2 block text-sm font-medium text-slate-700">{copy.admin.views.scene.blurDataUrlLabel}</label>
<textarea
name="blurDataUrl"
@@ -537,6 +529,7 @@ export default function AdminPage() {
className={textareaClassName}
placeholder={copy.admin.views.scene.blurDataUrlPlaceholder}
/>
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.blurDataUrlHint}</p>
</div>
</div>
@@ -576,16 +569,12 @@ export default function AdminPage() {
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="px-6 py-6">
<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">
{copy.admin.views.sound.presetIdLabel}
</label>
<input id="preset-id" name="presetId" className={fieldClassName} placeholder={copy.admin.views.sound.presetIdPlaceholder} />
</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>
<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} />
@@ -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>
<input name="fallbackLoopFile" type="file" accept="audio/*" className={fileClassName} />
</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 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>
<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">
{copy.admin.views.sound.notes.map((note) => (
<li key={note}>{note}</li>

View File

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