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:
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user