101f595bac
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
666 lines
23 KiB
TypeScript
666 lines
23 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { createPortal, flushSync } from 'react-dom';
|
||
|
||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||
import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerTeamId } from '../../shared/types';
|
||
import {
|
||
DEFAULT_PLAYER_IMAGE_OFFSET,
|
||
DEFAULT_PLAYER_IMAGE_SCALE,
|
||
DEFAULT_PLAYER_RING_COLOR,
|
||
} from '../../shared/types/appPlayers';
|
||
import { getDndApi } from '../shared/dndApi';
|
||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||
import { useAppPlayers } from '../shared/playerToken/useAppPlayers';
|
||
import { usePlayerImageUrl } from '../shared/playerToken/usePlayerImageUrl';
|
||
import { Button, Input } from '../shared/ui/controls';
|
||
|
||
import styles from './EditorApp.module.css';
|
||
import {
|
||
filterMaterialImagePaths,
|
||
getDroppedFileEntries,
|
||
pickFirstMaterialImagePath,
|
||
useFileDropZone,
|
||
} from './fileDrop';
|
||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||
import playerStyles from './PlayersModals.module.css';
|
||
|
||
const PLAYER_DND_MIME = 'application/x-dnd-player-id';
|
||
|
||
function PlayerRow({
|
||
player,
|
||
selected,
|
||
onSelect,
|
||
}: {
|
||
player: AppPlayer;
|
||
selected: boolean;
|
||
onSelect: () => void;
|
||
}) {
|
||
const url = usePlayerImageUrl(player.id);
|
||
return (
|
||
<button
|
||
type="button"
|
||
data-testid={`player-row-${player.id}`}
|
||
className={[playerStyles.playerRow, selected ? playerStyles.playerRowSelected : '']
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
draggable
|
||
onDragStart={(e) => {
|
||
e.dataTransfer.setData(PLAYER_DND_MIME, player.id);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
}}
|
||
onClick={onSelect}
|
||
>
|
||
<PlayerTokenView
|
||
name={player.name}
|
||
imageUrl={url}
|
||
ringColor={player.ringColor}
|
||
imageOffset={player.imageOffset}
|
||
imageScale={player.imageScale}
|
||
sizePx={48}
|
||
hideName
|
||
/>
|
||
<span>{player.name}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function TeamSection({
|
||
team,
|
||
players,
|
||
selectedId,
|
||
onSelect,
|
||
onAssign,
|
||
onEdit,
|
||
onDelete,
|
||
}: {
|
||
team: AppPlayerTeam | null;
|
||
players: AppPlayer[];
|
||
selectedId: PlayerId | null;
|
||
onSelect: (id: PlayerId) => void;
|
||
onAssign: (id: PlayerId, teamId: PlayerTeamId | null) => void;
|
||
onEdit?: () => void;
|
||
onDelete?: () => void;
|
||
}) {
|
||
const { t } = useEditorI18n();
|
||
return (
|
||
<section
|
||
className={playerStyles.team}
|
||
onDragOver={(e) => {
|
||
if (e.dataTransfer.types.includes(PLAYER_DND_MIME)) e.preventDefault();
|
||
}}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
const id = e.dataTransfer.getData(PLAYER_DND_MIME);
|
||
if (id) onAssign(id as PlayerId, team?.id ?? null);
|
||
}}
|
||
>
|
||
<div className={playerStyles.teamHeader}>
|
||
{team ? <span className={playerStyles.teamDot} style={{ background: team.color }} /> : null}
|
||
<span className={playerStyles.teamName}>{team?.name ?? t('players.ungrouped')}</span>
|
||
{team ? (
|
||
<details className={playerStyles.teamMenu}>
|
||
<summary aria-label={t('players.teamMenu')}>⋮</summary>
|
||
<div className={playerStyles.teamMenuPopup}>
|
||
<button type="button" onClick={onEdit}>
|
||
{t('common.edit')}
|
||
</button>
|
||
<button type="button" onClick={onDelete}>
|
||
{t('common.delete')}
|
||
</button>
|
||
</div>
|
||
</details>
|
||
) : null}
|
||
</div>
|
||
<div className={playerStyles.teamBody}>
|
||
{players.map((player) => (
|
||
<PlayerRow
|
||
key={player.id}
|
||
player={player}
|
||
selected={player.id === selectedId}
|
||
onSelect={() => onSelect(player.id)}
|
||
/>
|
||
))}
|
||
{players.length === 0 ? <div className={playerStyles.dropTarget}>{t('players.dropHere')}</div> : null}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||
const { t } = useEditorI18n();
|
||
const api = getDndApi();
|
||
const { players, teams } = useAppPlayers();
|
||
const [query, setQuery] = useState('');
|
||
const [selectedId, setSelectedId] = useState<PlayerId | null>(null);
|
||
const [name, setName] = useState('');
|
||
const [ringColor, setRingColor] = useState(DEFAULT_PLAYER_RING_COLOR);
|
||
const [imageOffset, setImageOffset] = useState(DEFAULT_PLAYER_IMAGE_OFFSET);
|
||
const [imageScale, setImageScale] = useState(DEFAULT_PLAYER_IMAGE_SCALE);
|
||
const [filePath, setFilePath] = useState<string | null>(null);
|
||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||
const [adding, setAdding] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [progress, setProgress] = useState({ percent: 0, detail: '' });
|
||
/** Electron не поддерживает window.prompt — форма команды в сайдбаре. */
|
||
const [teamForm, setTeamForm] = useState<{
|
||
id: PlayerTeamId | null;
|
||
name: string;
|
||
color: string;
|
||
} | null>(null);
|
||
const [pendingDeletePlayer, setPendingDeletePlayer] = useState<AppPlayer | null>(null);
|
||
const [pendingDeleteTeam, setPendingDeleteTeam] = useState<AppPlayerTeam | null>(null);
|
||
const appearanceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const selected = players.find((p) => p.id === selectedId) ?? null;
|
||
const selectedUrl = usePlayerImageUrl(selected?.id);
|
||
|
||
const resetModalState = useCallback(() => {
|
||
if (appearanceTimerRef.current) {
|
||
clearTimeout(appearanceTimerRef.current);
|
||
appearanceTimerRef.current = null;
|
||
}
|
||
setQuery('');
|
||
setSelectedId(null);
|
||
setName('');
|
||
setRingColor(DEFAULT_PLAYER_RING_COLOR);
|
||
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
|
||
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
|
||
setFilePath(null);
|
||
setPreviewUrl((prev) => {
|
||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||
return null;
|
||
});
|
||
setAdding(false);
|
||
setSaving(false);
|
||
setProgress({ percent: 0, detail: '' });
|
||
setTeamForm(null);
|
||
setPendingDeletePlayer(null);
|
||
setPendingDeleteTeam(null);
|
||
}, []);
|
||
|
||
const handleClose = useCallback(() => {
|
||
if (saving) return;
|
||
resetModalState();
|
||
onClose();
|
||
}, [onClose, resetModalState, saving]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setSelectedId((current) =>
|
||
current && players.some((p) => p.id === current) ? current : (players[0]?.id ?? null),
|
||
);
|
||
}, [open, players]);
|
||
|
||
// Синхронизировать форму только при смене выбранного игрока — не при каждом
|
||
// players.stateChanged (иначе сбрасывается несохранённый zoom/pan).
|
||
useEffect(() => {
|
||
if (adding) return;
|
||
if (!selectedId) return;
|
||
const p = players.find((item) => item.id === selectedId);
|
||
if (!p) return;
|
||
setName(p.name);
|
||
setRingColor(p.ringColor);
|
||
setImageOffset(p.imageOffset);
|
||
setImageScale(p.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
|
||
setFilePath(null);
|
||
setPreviewUrl(null);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- только selectedId / adding
|
||
}, [adding, selectedId]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
return api.on(ipcChannels.players.upsertProgress, (event) => {
|
||
setProgress({
|
||
percent: Math.max(0, Math.min(100, Math.round(event.percent))),
|
||
detail: event.detail?.trim() ?? t('players.savingWait'),
|
||
});
|
||
});
|
||
}, [api, open, t]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key !== 'Escape' || saving) return;
|
||
if (pendingDeletePlayer) {
|
||
setPendingDeletePlayer(null);
|
||
return;
|
||
}
|
||
if (pendingDeleteTeam) {
|
||
setPendingDeleteTeam(null);
|
||
return;
|
||
}
|
||
if (teamForm) {
|
||
setTeamForm(null);
|
||
return;
|
||
}
|
||
handleClose();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [handleClose, open, pendingDeletePlayer, pendingDeleteTeam, saving, teamForm]);
|
||
|
||
const filtered = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
return q ? players.filter((p) => p.name.toLowerCase().includes(q)) : players;
|
||
}, [players, query]);
|
||
|
||
const beginAdd = () => {
|
||
setAdding(true);
|
||
setSelectedId(null);
|
||
setName('');
|
||
setRingColor(DEFAULT_PLAYER_RING_COLOR);
|
||
setImageOffset({ ...DEFAULT_PLAYER_IMAGE_OFFSET });
|
||
setImageScale(DEFAULT_PLAYER_IMAGE_SCALE);
|
||
setFilePath(null);
|
||
setPreviewUrl(null);
|
||
};
|
||
|
||
const setPreviewFromPathAndUrl = (path: string, url: string) => {
|
||
setFilePath(path);
|
||
setPreviewUrl((prev) => {
|
||
if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||
return url || null;
|
||
});
|
||
};
|
||
|
||
const pickImage = async () => {
|
||
const result = await api.invoke(ipcChannels.players.pickImage, {});
|
||
if (result.canceled) return;
|
||
setPreviewFromPathAndUrl(result.filePath, result.previewDataUrl);
|
||
};
|
||
|
||
const applyDroppedImage = (path: string, previewFromFile: string) => {
|
||
if (!adding && !selected) beginAdd();
|
||
setPreviewFromPathAndUrl(path, previewFromFile);
|
||
};
|
||
|
||
const drop = useFileDropZone({
|
||
disabled: saving,
|
||
filterPaths: filterMaterialImagePaths,
|
||
onDropPaths: (paths) => {
|
||
const picked = pickFirstMaterialImagePath(paths);
|
||
if (!picked) return;
|
||
applyDroppedImage(picked, '');
|
||
},
|
||
});
|
||
|
||
const appearanceLatestRef = useRef({
|
||
selected,
|
||
adding,
|
||
saving,
|
||
ringColor,
|
||
imageOffset,
|
||
imageScale,
|
||
});
|
||
appearanceLatestRef.current = { selected, adding, saving, ringColor, imageOffset, imageScale };
|
||
|
||
const persistAppearance = (patch: {
|
||
ringColor?: string;
|
||
imageOffset?: typeof imageOffset;
|
||
imageScale?: number;
|
||
}) => {
|
||
const snap = appearanceLatestRef.current;
|
||
if (!snap.selected || snap.adding || snap.saving) return;
|
||
const payload = {
|
||
id: snap.selected.id,
|
||
teamId: snap.selected.teamId,
|
||
name: snap.selected.name,
|
||
ringColor: patch.ringColor ?? snap.ringColor,
|
||
imageOffset: patch.imageOffset ?? snap.imageOffset,
|
||
imageScale: patch.imageScale ?? snap.imageScale,
|
||
};
|
||
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
|
||
appearanceTimerRef.current = setTimeout(() => {
|
||
appearanceTimerRef.current = null;
|
||
void api.invoke(ipcChannels.players.upsert, payload);
|
||
}, 180);
|
||
};
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (appearanceTimerRef.current) clearTimeout(appearanceTimerRef.current);
|
||
};
|
||
}, []);
|
||
|
||
const save = async () => {
|
||
const trimmed = name.trim();
|
||
if (!trimmed || (selected === null && filePath === null)) return;
|
||
if (appearanceTimerRef.current) {
|
||
clearTimeout(appearanceTimerRef.current);
|
||
appearanceTimerRef.current = null;
|
||
}
|
||
flushSync(() => {
|
||
setSaving(true);
|
||
setProgress({ percent: 0, detail: t('players.savingWait') });
|
||
});
|
||
try {
|
||
const result = await api.invoke(ipcChannels.players.upsert, {
|
||
...(selected ? { id: selected.id, teamId: selected.teamId } : {}),
|
||
name: trimmed,
|
||
...(filePath ? { filePath } : {}),
|
||
ringColor,
|
||
imageOffset,
|
||
imageScale,
|
||
});
|
||
setAdding(false);
|
||
setSelectedId(result.player.id);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
if (!open) return null;
|
||
const activeUrl = previewUrl ?? selectedUrl;
|
||
|
||
return createPortal(
|
||
<>
|
||
<div className={styles.modalBackdrop} aria-hidden />
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
data-testid="players-modal"
|
||
className={[styles.modalDialog, playerStyles.dialog].join(' ')}
|
||
>
|
||
<div className={styles.modalHeader}>
|
||
<div className={styles.modalTitle}>{t('players.managerTitle')}</div>
|
||
<button
|
||
type="button"
|
||
aria-label={t('common.close')}
|
||
onClick={handleClose}
|
||
className={styles.modalClose}
|
||
disabled={saving}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className={playerStyles.body}>
|
||
<aside className={playerStyles.sidebar}>
|
||
<Input value={query} onChange={setQuery} placeholder={t('players.search')} />
|
||
<div className={playerStyles.sidebarActions}>
|
||
<Button variant="primary" data-testid="players-add" onClick={beginAdd}>
|
||
{t('players.add')}
|
||
</Button>
|
||
<Button
|
||
data-testid="players-add-team"
|
||
onClick={() =>
|
||
setTeamForm({
|
||
id: null,
|
||
name: '',
|
||
color: DEFAULT_PLAYER_RING_COLOR,
|
||
})
|
||
}
|
||
>
|
||
{t('players.addTeam')}
|
||
</Button>
|
||
</div>
|
||
{teamForm ? (
|
||
<div className={playerStyles.teamForm} data-testid="players-team-form">
|
||
<label className={playerStyles.teamFormField}>
|
||
<span>{t('players.teamName')}</span>
|
||
<Input
|
||
value={teamForm.name}
|
||
onChange={(v) => setTeamForm((prev) => (prev ? { ...prev, name: v } : prev))}
|
||
placeholder={t('players.teamName')}
|
||
autoFocus
|
||
/>
|
||
</label>
|
||
<label className={playerStyles.teamFormField}>
|
||
<span>{t('players.teamColor')}</span>
|
||
<input
|
||
type="color"
|
||
value={teamForm.color}
|
||
onChange={(e) =>
|
||
setTeamForm((prev) =>
|
||
prev ? { ...prev, color: e.currentTarget.value } : prev,
|
||
)
|
||
}
|
||
/>
|
||
</label>
|
||
<div className={playerStyles.teamFormActions}>
|
||
<Button
|
||
variant="primary"
|
||
disabled={!teamForm.name.trim()}
|
||
onClick={() => {
|
||
const trimmed = teamForm.name.trim();
|
||
if (!trimmed) return;
|
||
void api
|
||
.invoke(ipcChannels.players.upsertTeam, {
|
||
...(teamForm.id ? { id: teamForm.id } : {}),
|
||
name: trimmed,
|
||
color: teamForm.color,
|
||
})
|
||
.then(() => setTeamForm(null));
|
||
}}
|
||
>
|
||
{t('common.save')}
|
||
</Button>
|
||
<Button onClick={() => setTeamForm(null)}>{t('common.cancel')}</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<div className={playerStyles.list}>
|
||
{teams.map((team) => (
|
||
<TeamSection
|
||
key={team.id}
|
||
team={team}
|
||
players={filtered.filter((p) => p.teamId === team.id)}
|
||
selectedId={selectedId}
|
||
onSelect={(id) => {
|
||
setAdding(false);
|
||
setSelectedId(id);
|
||
}}
|
||
onAssign={(playerId, teamId) =>
|
||
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
|
||
}
|
||
onEdit={() =>
|
||
setTeamForm({
|
||
id: team.id,
|
||
name: team.name,
|
||
color: team.color,
|
||
})
|
||
}
|
||
onDelete={() => setPendingDeleteTeam(team)}
|
||
/>
|
||
))}
|
||
<TeamSection
|
||
team={null}
|
||
players={filtered.filter((p) => p.teamId === null)}
|
||
selectedId={selectedId}
|
||
onSelect={(id) => {
|
||
setAdding(false);
|
||
setSelectedId(id);
|
||
}}
|
||
onAssign={(playerId, teamId) =>
|
||
void api.invoke(ipcChannels.players.assignTeam, { playerId, teamId })
|
||
}
|
||
/>
|
||
</div>
|
||
</aside>
|
||
<main
|
||
className={[playerStyles.editor, drop.dragOver ? playerStyles.editorDropOver : '']
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
onDragEnter={drop.onDragEnter}
|
||
onDragLeave={drop.onDragLeave}
|
||
onDragOver={drop.onDragOver}
|
||
onDrop={(e) => {
|
||
drop.onDrop(e);
|
||
const entries = getDroppedFileEntries(e);
|
||
const files = e.dataTransfer?.files;
|
||
for (let i = 0; i < entries.length; i += 1) {
|
||
const entry = entries[i]!;
|
||
if (!pickFirstMaterialImagePath([entry.path])) continue;
|
||
const file = files?.[i];
|
||
applyDroppedImage(entry.path, file ? URL.createObjectURL(file) : '');
|
||
return;
|
||
}
|
||
}}
|
||
>
|
||
{drop.dragOver ? <div className={styles.dropHintOverlay}>{t('players.dropHint')}</div> : null}
|
||
{adding || selected ? (
|
||
<>
|
||
<PlayerTokenView
|
||
data-testid="player-token-preview"
|
||
name={name}
|
||
imageUrl={activeUrl}
|
||
ringColor={ringColor}
|
||
imageOffset={imageOffset}
|
||
imageScale={imageScale}
|
||
panEnabled
|
||
onImageOffsetChange={(next) => {
|
||
setImageOffset(next);
|
||
persistAppearance({ imageOffset: next });
|
||
}}
|
||
onImageScaleChange={(next) => {
|
||
setImageScale(next);
|
||
persistAppearance({ imageScale: next });
|
||
}}
|
||
sizePx={220}
|
||
/>
|
||
<label className={playerStyles.field}>
|
||
<span>{t('players.name')}</span>
|
||
<Input value={name} onChange={setName} placeholder={t('players.namePlaceholder')} />
|
||
</label>
|
||
<label className={playerStyles.field}>
|
||
<span>{t('players.ringColor')}</span>
|
||
<input
|
||
type="color"
|
||
value={ringColor}
|
||
onChange={(e) => {
|
||
const next = e.currentTarget.value;
|
||
setRingColor(next);
|
||
persistAppearance({ ringColor: next });
|
||
}}
|
||
/>
|
||
</label>
|
||
<div className={playerStyles.actions}>
|
||
<Button data-testid="players-choose-image" onClick={() => void pickImage()}>
|
||
{t('players.chooseImage')}
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
data-testid="players-save"
|
||
disabled={!name.trim() || (!selected && !filePath) || saving}
|
||
onClick={() => void save()}
|
||
>
|
||
{saving ? t('common.saving') : t('common.save')}
|
||
</Button>
|
||
{selected ? (
|
||
<Button onClick={() => setPendingDeletePlayer(selected)}>{t('common.delete')}</Button>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className={styles.muted}>{t('players.selectPrompt')}</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
</div>
|
||
{pendingDeletePlayer
|
||
? createPortal(
|
||
<>
|
||
<button
|
||
type="button"
|
||
aria-label={t('common.close')}
|
||
className={styles.modalBackdrop}
|
||
onClick={() => setPendingDeletePlayer(null)}
|
||
/>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
data-testid="players-delete-confirm"
|
||
className={styles.modalDialog}
|
||
>
|
||
<div className={styles.modalHeader}>
|
||
<div className={styles.modalTitle}>{t('players.deleteTitle')}</div>
|
||
<button
|
||
type="button"
|
||
aria-label={t('common.close')}
|
||
className={styles.modalClose}
|
||
onClick={() => setPendingDeletePlayer(null)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className={styles.muted}>
|
||
{t('players.deleteConfirm', { name: pendingDeletePlayer.name })}
|
||
</div>
|
||
<div className={styles.modalFooter}>
|
||
<Button onClick={() => setPendingDeletePlayer(null)}>{t('common.cancel')}</Button>
|
||
<Button
|
||
variant="primary"
|
||
onClick={() => {
|
||
const id = pendingDeletePlayer.id;
|
||
setPendingDeletePlayer(null);
|
||
void api.invoke(ipcChannels.players.delete, { id });
|
||
}}
|
||
>
|
||
{t('common.delete')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
)
|
||
: null}
|
||
{pendingDeleteTeam
|
||
? createPortal(
|
||
<>
|
||
<button
|
||
type="button"
|
||
aria-label={t('common.close')}
|
||
className={styles.modalBackdrop}
|
||
onClick={() => setPendingDeleteTeam(null)}
|
||
/>
|
||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||
<div className={styles.modalHeader}>
|
||
<div className={styles.modalTitle}>{t('players.deleteTeamTitle')}</div>
|
||
<button
|
||
type="button"
|
||
aria-label={t('common.close')}
|
||
className={styles.modalClose}
|
||
onClick={() => setPendingDeleteTeam(null)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className={styles.muted}>
|
||
{t('players.deleteTeamConfirm', { name: pendingDeleteTeam.name })}
|
||
</div>
|
||
<div className={styles.modalFooter}>
|
||
<Button onClick={() => setPendingDeleteTeam(null)}>{t('common.cancel')}</Button>
|
||
<Button
|
||
variant="primary"
|
||
onClick={() => {
|
||
const id = pendingDeleteTeam.id;
|
||
setPendingDeleteTeam(null);
|
||
void api.invoke(ipcChannels.players.deleteTeam, { id });
|
||
}}
|
||
>
|
||
{t('common.delete')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
)
|
||
: null}
|
||
{saving ? (
|
||
<div className={styles.progressOverlay} role="dialog" aria-busy data-testid="players-save-progress">
|
||
<div className={styles.progressModal}>
|
||
<div className={styles.progressTitle}>{t('players.savingTitle')}</div>
|
||
<div className={styles.previewSpinner} aria-hidden />
|
||
<div className={styles.progressBar}>
|
||
<div className={styles.progressFill} style={{ width: `${String(progress.percent)}%` }} />
|
||
</div>
|
||
<div className={styles.progressMeta}>
|
||
<div>{progress.detail}</div>
|
||
<div>{progress.percent}%</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|