fix(ui): polish editor layouts and confirm scene delete

Stretch key action buttons, center empty audio/image pickers, and require confirmation before deleting a scene.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-29 15:35:07 +08:00
parent a2b418b78a
commit c7bf7cf449
8 changed files with 355 additions and 154 deletions
+71 -5
View File
@@ -62,8 +62,9 @@
.editorSidebar { .editorSidebar {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
display: grid; display: flex;
grid-template-rows: auto 1fr; flex-direction: column;
gap: 0;
padding: 12px; padding: 12px;
border-right: 1px solid var(--stroke); border-right: 1px solid var(--stroke);
background: var(--editor-column-bg); background: var(--editor-column-bg);
@@ -105,10 +106,25 @@
.gridTools { .gridTools {
display: grid; display: grid;
gap: 10px; gap: 10px;
flex: 0 0 auto;
}
.gridTools > * {
width: 100%;
max-width: 100%;
display: flex;
box-sizing: border-box;
}
.gridTools button {
width: 100%;
box-sizing: border-box;
flex: 1;
} }
.spacer14 { .spacer14 {
height: 14px; height: 14px;
flex: 0 0 auto;
} }
.spacer18 { .spacer18 {
@@ -124,17 +140,28 @@
.gamePropsButtons > * { .gamePropsButtons > * {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
max-width: 100%;
display: flex;
box-sizing: border-box;
}
.gamePropsButtons button {
width: 100%;
box-sizing: border-box;
flex: 1;
} }
.sidebarScroll { .sidebarScroll {
overflow: auto; overflow: auto;
padding-right: 2px; padding-right: 2px;
min-height: 0; min-height: 0;
flex: 1 1 auto;
align-self: stretch; align-self: stretch;
} }
.sceneListGrid { .sceneListGrid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px; gap: 12px;
align-content: start; align-content: start;
justify-items: stretch; justify-items: stretch;
@@ -192,15 +219,19 @@
.progressModal { .progressModal {
width: min(520px, calc(100vw - 32px)); width: min(520px, calc(100vw - 32px));
border-radius: 12px; border-radius: 12px;
padding: 14px; padding: 16px 16px 20px;
background: rgba(25, 28, 38, 0.92); background: rgba(25, 28, 38, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
display: grid;
gap: 12px;
justify-items: center;
} }
.progressTitle { .progressTitle {
font-weight: 700; font-weight: 700;
margin-bottom: 10px; width: 100%;
text-align: center;
} }
.progressBar { .progressBar {
@@ -209,6 +240,7 @@
overflow: hidden; overflow: hidden;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
} }
.progressFill { .progressFill {
@@ -218,11 +250,13 @@
} }
.progressMeta { .progressMeta {
margin-top: 10px; margin-top: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 12px;
opacity: 0.9; opacity: 0.9;
font-size: 12px; font-size: 12px;
width: 100%;
} }
.inspectorTitle { .inspectorTitle {
@@ -610,6 +644,23 @@
gap: 10px; gap: 10px;
} }
.projectPickerForm > * {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.projectPickerForm > *:has(button),
.projectPickerForm > span {
display: flex;
}
.projectPickerForm button {
width: 100%;
box-sizing: border-box;
flex: 1;
}
.spacer6 { .spacer6 {
height: 6px; height: 6px;
} }
@@ -918,6 +969,21 @@
background 0.15s ease; background 0.15s ease;
} }
.audioDropEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 96px;
text-align: center;
padding: 8px 4px;
}
.audioDropEmpty > * {
max-width: 100%;
}
.audioDropDragOver { .audioDropDragOver {
border-color: var(--accent); border-color: var(--accent);
background: var(--accent-fill-soft); background: var(--accent-fill-soft);
+175 -121
View File
@@ -2380,68 +2380,73 @@ function CampaignInspector({
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null} ) : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? ( {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div> <div className={styles.audioDropEmpty}>
) : ( <div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
<div className={styles.audioList}> <Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('campaign.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div> </div>
) : (
<>
<div className={styles.audioList}>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('campaign.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div>
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
</>
)} )}
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
</div> </div>
</div> </div>
); );
@@ -2653,68 +2658,73 @@ function SceneInspector({
<div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div> <div className={styles.dropHintOverlay}>{t('drop.hintAudio')}</div>
) : null} ) : null}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? ( {mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div> <div className={styles.audioDropEmpty}>
) : ( <div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
<div className={styles.audioList}> <Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('scene.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div> </div>
) : (
<>
<div className={styles.audioList}>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('scene.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div>
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
</>
)} )}
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
</div> </div>
<div className={styles.spacer6} /> <div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.branching')}</div> <div className={styles.labelSm}>{t('scene.branching')}</div>
@@ -2754,6 +2764,7 @@ function SceneListCard({
const thumbUrl = useAssetUrl(scene.previewThumbAssetId); const thumbUrl = useAssetUrl(scene.previewThumbAssetId);
const previewUrl = useAssetUrl(scene.previewAssetId); const previewUrl = useAssetUrl(scene.previewAssetId);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [pendingDelete, setPendingDelete] = useState(false);
useEffect(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
@@ -2909,8 +2920,8 @@ function SceneListCard({
role="menuitem" role="menuitem"
className={styles.sceneCtxDanger} className={styles.sceneCtxDanger}
onClick={() => { onClick={() => {
onDeleteScene(scene.id);
setMenu(null); setMenu(null);
setPendingDelete(true);
}} }}
> >
{t('common.delete')} {t('common.delete')}
@@ -2920,6 +2931,49 @@ function SceneListCard({
document.body, document.body,
) )
: null} : null}
{pendingDelete
? createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalBackdrop}
onClick={() => setPendingDelete(false)}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('confirmDeleteScene.title')}</div>
<button
type="button"
aria-label={t('common.close')}
className={styles.modalClose}
onClick={() => setPendingDelete(false)}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>
{t('confirmDeleteScene.body', { name: scene.title })}
</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={() => setPendingDelete(false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
onClick={() => {
setPendingDelete(false);
onDeleteScene(scene.id);
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
)
: null}
</div> </div>
); );
} }
+5 -3
View File
@@ -114,9 +114,11 @@ export function MaterialsBrowser({
<div className={matStyles.side}> <div className={matStyles.side}>
<Input value={query} onChange={setQuery} placeholder={t('materials.search')} /> <Input value={query} onChange={setQuery} placeholder={t('materials.search')} />
{mode === 'editor' && onAdd ? ( {mode === 'editor' && onAdd ? (
<Button variant="primary" onClick={onAdd}> <div className={matStyles.sideAddBtn}>
{t('materials.add')} <Button variant="primary" onClick={onAdd}>
</Button> {t('materials.add')}
</Button>
</div>
) : null} ) : null}
<div className={matStyles.list}> <div className={matStyles.list}>
{filtered.map((m) => ( {filtered.map((m) => (
@@ -74,6 +74,24 @@
overflow: hidden; overflow: hidden;
} }
.sideAddBtn {
width: 100%;
flex: 0 0 auto;
}
.sideAddBtn > * {
width: 100%;
max-width: 100%;
display: flex;
box-sizing: border-box;
}
.sideAddBtn button {
width: 100%;
box-sizing: border-box;
flex: 1;
}
.list { .list {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -245,6 +263,21 @@
position: relative; position: relative;
} }
.imageDropEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 112px;
text-align: center;
padding: 8px 4px;
}
.imageDropEmpty > * {
max-width: 100%;
}
.imageDropOver { .imageDropOver {
border-color: var(--color-accent, #a78bfa); border-color: var(--color-accent, #a78bfa);
} }
+29 -13
View File
@@ -180,20 +180,36 @@ export function MaterialEditModal({
{previewSrc ? ( {previewSrc ? (
<img className={matStyles.previewThumb} src={previewSrc} alt="" /> <img className={matStyles.previewThumb} src={previewSrc} alt="" />
) : ( ) : (
<div className={styles.muted}>{t('materials.imageEmpty')}</div> <div className={matStyles.imageDropEmpty}>
<div className={styles.muted}>{t('materials.imageEmpty')}</div>
<Button
disabled={saving}
onClick={() => {
void (async () => {
const picked = await onPickImage();
if (!picked) return;
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
})();
}}
>
{t('materials.chooseImage')}
</Button>
</div>
)} )}
<Button {previewSrc ? (
disabled={saving} <Button
onClick={() => { disabled={saving}
void (async () => { onClick={() => {
const picked = await onPickImage(); void (async () => {
if (!picked) return; const picked = await onPickImage();
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl); if (!picked) return;
})(); setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
}} })();
> }}
{t('materials.chooseImage')} >
</Button> {t('materials.chooseImage')}
</Button>
) : null}
</div> </div>
{!hasImage ? <div className={styles.fieldError}>{t('materials.imageRequired')}</div> : null} {!hasImage ? <div className={styles.fieldError}>{t('materials.imageRequired')}</div> : null}
</div> </div>
@@ -349,6 +349,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.', 'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
'confirmDelete.failedTitle': 'Не удалось удалить', 'confirmDelete.failedTitle': 'Не удалось удалить',
'confirmDeleteScene.title': 'Удаление сцены',
'confirmDeleteScene.body': 'Удалить сцену «{name}»? Её нельзя будет восстановить.',
'picker.title': 'Проекты', 'picker.title': 'Проекты',
'picker.newPlaceholder': 'Название нового проекта…', 'picker.newPlaceholder': 'Название нового проекта…',
'picker.create': 'Создать проект', 'picker.create': 'Создать проект',
@@ -911,6 +914,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'Permanently delete project “{name}”? The file and cache will be removed from disk.', 'Permanently delete project “{name}”? The file and cache will be removed from disk.',
'confirmDelete.failedTitle': 'Could not delete', 'confirmDelete.failedTitle': 'Could not delete',
'confirmDeleteScene.title': 'Delete scene',
'confirmDeleteScene.body': 'Delete scene “{name}”? This cannot be undone.',
'picker.title': 'Projects', 'picker.title': 'Projects',
'picker.newPlaceholder': 'New project name…', 'picker.newPlaceholder': 'New project name…',
'picker.create': 'Create project', 'picker.create': 'Create project',
+27 -12
View File
@@ -205,19 +205,34 @@ export function NpcEditModal({
{previewSrc ? ( {previewSrc ? (
<img className={matStyles.previewThumb} src={previewSrc} alt="" /> <img className={matStyles.previewThumb} src={previewSrc} alt="" />
) : ( ) : (
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div> <div className={matStyles.imageDropEmpty}>
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div>
<Button
onClick={() => {
void (async () => {
const picked = await onPickImage();
if (!picked) return;
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
})();
}}
>
{t('npcs.chooseAvatar')}
</Button>
</div>
)} )}
<Button {previewSrc ? (
onClick={() => { <Button
void (async () => { onClick={() => {
const picked = await onPickImage(); void (async () => {
if (!picked) return; const picked = await onPickImage();
setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl); if (!picked) return;
})(); setPreviewFromPathAndUrl(picked.filePath, picked.previewDataUrl);
}} })();
> }}
{t('npcs.chooseAvatar')} >
</Button> {t('npcs.chooseAvatar')}
</Button>
) : null}
</div> </div>
{!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null} {!hasImage ? <div className={editorStyles.fieldError}>{t('npcs.avatarRequired')}</div> : null}
</div> </div>
@@ -366,11 +366,20 @@
.clearSceneBtn { .clearSceneBtn {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
flex: 0 0 auto;
}
.clearSceneBtn > * {
width: 100%;
max-width: 100%;
display: flex;
box-sizing: border-box;
} }
.clearSceneBtn button { .clearSceneBtn button {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
flex: 1;
} }
.sceneToken { .sceneToken {